oxedyne/daimond/dev/verify_devices.mjs
29.2 KiB, 1 run
created by r2519314175:357, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | // verify_devices.mjs — "is my account on more than one device?", answered. |
| 2 | // |
| 3 | // Pairing moves the identity WHOLE, so both devices then hold one keypair and |
| 4 | // the gateway cannot tell them apart; it keeps no record of a pairing either. |
| 5 | // What can still be shown is a ROSTER: each device writes its own line, the |
| 6 | // sync parcel carries the lines, and the merge unions them. This drives the |
| 7 | // real page and checks the four things that make that roster trustworthy: |
| 8 | // |
| 9 | // 1. The id is minted, not measured. Two fresh installs of the same browser |
| 10 | // on the same machine get DIFFERENT ids, and the id is only ever a random |
| 11 | // number in this account's own storage. |
| 12 | // 2. The parcel is byte-stable between real changes. This device's own stamp |
| 13 | // does not move on every collect -- sync skips a push whose parcel |
| 14 | // stringifies to what it last sent, and a stamp that always moved would |
| 15 | // put the whole parcel back on the wire for nothing, for ever. |
| 16 | // 3. The merge is freshest-wins, STRICTLY, per entry, with unknown entries |
| 17 | // unioned in -- and a parcel with no roster at all is a no-op both ways. |
| 18 | // 3b. The name a USER gives a device merges on a stamp of its own. A device |
| 19 | // refreshes only its own `seen`, so a name typed on device A for device B |
| 20 | // would never travel if it rode on `seen` -- B's next refresh would win the |
| 21 | // whole line back and take the name off it. |
| 22 | // 4. The surface tells the truth: it lists devices that SYNC this account, it |
| 23 | // marks this one, it lets any line be named, and it offers no control that |
| 24 | // pretends a device can be signed out (nothing could enforce that under one |
| 25 | // shared keypair). |
| 26 | // 5. A name typed at pairing time survives the reload that redeeming does, and |
| 27 | // the new device's line takes it up the moment that line is minted. |
| 28 | // |
| 29 | // node dev/verify_devices.mjs |
| 30 | // |
| 31 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and dev/mockllm.mjs |
| 32 | // (DAIMOND_MOCK_PORT, default 9099). No gateway. |
| 33 | import fs from 'node:fs'; |
| 34 | import { open, shot, scratch } from './harness.mjs'; |
| 35 | |
| 36 | // Two FIXED profiles, wiped before use rather than minted per run. Every check |
| 37 | // below is about a FRESH install -- one that has never seen a roster -- so the |
| 38 | // profile has to be new anyway, and a fixed pair leaves two directories behind |
| 39 | // instead of two more on every run. |
| 40 | const PROFILES = ['devices-one', 'devices-two'].map(n => scratch('pw', n)); |
| 41 | for (const p of PROFILES) fs.rmSync(p, { recursive: true, force: true }); |
| 42 | |
| 43 | const ok = [], bad = []; |
| 44 | const check = (name, pass, detail) => { |
| 45 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 46 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 47 | }; |
| 48 | |
| 49 | const A = 'aaaa1111bbbb2222'; // a fabricated second device |
| 50 | const B = 'cccc3333dddd4444'; // and a third |
| 51 | |
| 52 | const s = await open({ name: 'devices', profile: PROFILES[0] }); |
| 53 | const { page } = s; |
| 54 | |
| 55 | try { |
| 56 | await page.waitForFunction(() => !!(window.DaimondCore && DaimondCore.collectSync), null, |
| 57 | { timeout: 12000 }).catch(() => {}); |
| 58 | |
| 59 | // ── (1) Minted, not measured ──────────────────────────────────── |
| 60 | const mine = await page.evaluate(async () => { |
| 61 | const p = await DaimondCore.collectSync(); |
| 62 | return { |
| 63 | devices: p.devices, |
| 64 | id: localStorage.getItem('daimond-device-id'), |
| 65 | stored: localStorage.getItem('daimond-devices'), |
| 66 | }; |
| 67 | }); |
| 68 | const ids = Object.keys(mine.devices || {}); |
| 69 | check('the parcel carries a devices roster', !!mine.devices && typeof mine.devices === 'object', |
| 70 | JSON.stringify(mine.devices || null).slice(0, 120)); |
| 71 | check('a fresh install knows exactly one device — itself', ids.length === 1, ids.join(',')); |
| 72 | check('this device\'s id is 16 hex characters, kept in localStorage', |
| 73 | /^[0-9a-f]{16}$/.test(mine.id || ''), String(mine.id)); |
| 74 | check('the roster is keyed by that id', ids[0] === mine.id, ids[0] + ' vs ' + mine.id); |
| 75 | const me = (mine.devices || {})[ids[0]] || {}; |
| 76 | check('its line names the environment, in words', typeof me.name === 'string' && me.name.length > 2, |
| 77 | JSON.stringify(me.name)); |
| 78 | check('with created and seen as real ms stamps (not 32-bit truncated)', |
| 79 | me.created > 1.7e12 && me.seen > 1.7e12, me.created + ' / ' + me.seen); |
| 80 | check('the merged roster is persisted', !!mine.stored && !!JSON.parse(mine.stored)[mine.id]); |
| 81 | |
| 82 | // A second install of the SAME browser on the SAME machine. If the id were |
| 83 | // derived from the environment the two would collide; a random number cannot. |
| 84 | const s2 = await open({ name: 'devices2', profile: PROFILES[1] }); |
| 85 | const other = await s2.page.evaluate(async () => { |
| 86 | const p = await DaimondCore.collectSync(); |
| 87 | return { id: localStorage.getItem('daimond-device-id'), devices: p.devices }; |
| 88 | }).catch(() => null); |
| 89 | await s2.close(); |
| 90 | check('a second install of the same browser mints a DIFFERENT id — nothing is derived from the machine', |
| 91 | !!other && /^[0-9a-f]{16}$/.test(other.id) && other.id !== mine.id, |
| 92 | (other && other.id) + ' vs ' + mine.id); |
| 93 | const otherName = other && ((other.devices || {})[other.id] || {}).name; |
| 94 | check('but describes itself the same way, because the description IS the environment', |
| 95 | typeof otherName === 'string' && otherName.length > 2 && otherName === me.name, |
| 96 | otherName + ' vs ' + me.name); |
| 97 | |
| 98 | // ── (2) Byte-stable between real changes ──────────────────────── |
| 99 | const stable = await page.evaluate(async () => { |
| 100 | const a = JSON.stringify((await DaimondCore.collectSync()).devices); |
| 101 | await new Promise(r => setTimeout(r, 400)); |
| 102 | const b = JSON.stringify((await DaimondCore.collectSync()).devices); |
| 103 | return { a, b }; |
| 104 | }); |
| 105 | check('two collects in a row stringify identically — the push skip survives', |
| 106 | typeof stable.a === 'string' && stable.a !== '{}' && stable.a === stable.b, |
| 107 | String(stable.a).slice(0, 80) + ' | ' + String(stable.b).slice(0, 80)); |
| 108 | |
| 109 | // ── (3) The merge ─────────────────────────────────────────────── |
| 110 | const merged = await page.evaluate(async ([A, B]) => { |
| 111 | const now = Date.now(); |
| 112 | const roster = () => JSON.parse(localStorage.getItem('daimond-devices') || '{}'); |
| 113 | const out = {}; |
| 114 | // An unknown device unions in. |
| 115 | await DaimondCore.applySync({ v: 2, devices: { |
| 116 | [A]: { name: 'Firefox on Windows', created: now - 9e6, seen: now - 3600e3 }, |
| 117 | } }); |
| 118 | out.unioned = roster(); |
| 119 | // A STALE copy of a device already known must not roll it back. |
| 120 | await DaimondCore.applySync({ v: 2, devices: { |
| 121 | [A]: { name: 'Stale name', created: now - 9e6, seen: now - 7200e3 }, |
| 122 | } }); |
| 123 | out.afterStale = roster(); |
| 124 | // An EQUAL stamp keeps what is here (strictly newer, or nothing happens). |
| 125 | await DaimondCore.applySync({ v: 2, devices: { |
| 126 | [A]: { name: 'Equal name', created: now - 9e6, seen: now - 3600e3 }, |
| 127 | } }); |
| 128 | out.afterEqual = roster(); |
| 129 | // A fresher one wins. |
| 130 | await DaimondCore.applySync({ v: 2, devices: { |
| 131 | [A]: { name: 'Firefox on Windows', created: now - 9e6, seen: now - 60e3 }, |
| 132 | } }); |
| 133 | out.afterFresh = roster(); |
| 134 | // This device's own line is not rolled back by a stale remote copy of it. |
| 135 | const self = localStorage.getItem('daimond-device-id'); |
| 136 | const before = roster()[self]; |
| 137 | await DaimondCore.applySync({ v: 2, devices: { |
| 138 | [self]: { name: 'Somebody else\'s idea', created: 1, seen: 1 }, |
| 139 | } }); |
| 140 | out.selfBefore = before; out.selfAfter = roster()[self]; |
| 141 | // A malformed id is refused, so the roster stays a roster. |
| 142 | await DaimondCore.applySync({ v: 2, devices: { |
| 143 | '../../etc': { name: 'nope', created: now, seen: now }, |
| 144 | '2': { name: 'nope', created: now, seen: now }, |
| 145 | } }); |
| 146 | out.afterJunk = roster(); |
| 147 | // A third device, so the ordering below has something to order. |
| 148 | await DaimondCore.applySync({ v: 2, devices: { |
| 149 | [B]: { name: 'Safari on iOS', created: now - 5e6, seen: now - 86400e3 }, |
| 150 | } }); |
| 151 | out.three = roster(); |
| 152 | out.parcel = (await DaimondCore.collectSync()).devices || {}; |
| 153 | out.parcelKeys = Object.keys(out.parcel); |
| 154 | // And a parcel from a device that predates all of this changes nothing. |
| 155 | const snap = JSON.stringify(roster()); |
| 156 | await DaimondCore.applySync({ v: 1 }); |
| 157 | out.afterV1 = JSON.stringify(roster()); |
| 158 | out.snap = snap; |
| 159 | out.self = self; |
| 160 | return out; |
| 161 | }, [A, B]); |
| 162 | |
| 163 | check('an unknown device unions in', !!merged.unioned[A], Object.keys(merged.unioned).join(',')); |
| 164 | check('a STALE copy of a known device does not roll it back', |
| 165 | merged.afterStale[A] && merged.afterStale[A].name === 'Firefox on Windows', |
| 166 | JSON.stringify(merged.afterStale[A])); |
| 167 | check('an EQUAL stamp keeps what is here — strictly newer, or nothing', |
| 168 | merged.afterEqual[A] && merged.afterEqual[A].name === 'Firefox on Windows', |
| 169 | JSON.stringify(merged.afterEqual[A])); |
| 170 | check('a FRESHER copy wins', merged.afterFresh[A] && merged.afterFresh[A].seen > merged.afterStale[A].seen, |
| 171 | JSON.stringify(merged.afterFresh[A])); |
| 172 | check('this device\'s own line is not rolled back by a stale remote copy of it', |
| 173 | merged.selfAfter && merged.selfAfter.seen === merged.selfBefore.seen, |
| 174 | JSON.stringify(merged.selfAfter)); |
| 175 | check('a malformed device id is refused', !merged.afterJunk['../../etc'] && !merged.afterJunk['2'], |
| 176 | Object.keys(merged.afterJunk).join(',')); |
| 177 | check('the parcel lists the ids SORTED — enumeration order would push for ever', |
| 178 | JSON.stringify(merged.parcelKeys) === JSON.stringify(merged.parcelKeys.slice().sort()), |
| 179 | merged.parcelKeys.join(',')); |
| 180 | check('a parcel with no roster at all is a no-op', merged.afterV1 === merged.snap); |
| 181 | |
| 182 | // ── (3b) The name the user gives a device ─────────────────────── |
| 183 | // "Chromium on Linux" is what a device can say about itself; it is not what |
| 184 | // its owner calls it. So a line carries a LABEL as well, with a stamp of its |
| 185 | // own, and the rename is done through the real drawer control rather than by |
| 186 | // writing storage -- the point of the feature is that a user can do it. |
| 187 | // A roster written by a version that had no names at all, read back. It has |
| 188 | // to decode rather than be dropped, and it must not invent a name for a |
| 189 | // device nobody has named. |
| 190 | const legacy = await page.evaluate(async () => { |
| 191 | const keep = localStorage.getItem('daimond-devices') || '{}'; |
| 192 | const OLD = 'eeee5555ffff6666'; |
| 193 | localStorage.setItem('daimond-devices', JSON.stringify({ |
| 194 | [OLD]: { name: 'Edge on Windows', created: 1.75e12, seen: 1.75e12 }, |
| 195 | })); |
| 196 | const line = ((await DaimondCore.collectSync()).devices || {})[OLD]; |
| 197 | localStorage.setItem('daimond-devices', keep); |
| 198 | return line; |
| 199 | }); |
| 200 | check('a roster stored before names existed still decodes, with no name and no stamp', |
| 201 | !!legacy && legacy.name === 'Edge on Windows' && legacy.label === '' && legacy.namedAt === 0, |
| 202 | JSON.stringify(legacy)); |
| 203 | |
| 204 | const named = await page.evaluate(async ([A, B]) => { |
| 205 | const now = Date.now(); |
| 206 | const roster = () => JSON.parse(localStorage.getItem('daimond-devices') || '{}'); |
| 207 | const wait = (n) => new Promise(r => setTimeout(r, n)); |
| 208 | // Rename a device the way a user does: open the drawer, press the control |
| 209 | // on that device's row, type, save. |
| 210 | const renameVia = async (id, text) => { |
| 211 | DaimondAdmin.home(); |
| 212 | const row = [...document.querySelectorAll('#admin-home .device-row')] |
| 213 | .find(r => ((r.querySelector('.device-id') || {}).textContent || '') === id.slice(-4)); |
| 214 | const btn = row && row.querySelector('.device-rename'); |
| 215 | if (!btn) return false; |
| 216 | btn.click(); |
| 217 | await wait(80); |
| 218 | const input = document.querySelector('.dlg .dlg-input'); |
| 219 | const ok = document.querySelector('.dlg .dlg-ok'); |
| 220 | if (!input || !ok) return false; |
| 221 | input.value = text; |
| 222 | ok.click(); |
| 223 | await wait(200); |
| 224 | return !document.querySelector('.dlg'); |
| 225 | }; |
| 226 | const out = {}; |
| 227 | // This device names ANOTHER device's line. |
| 228 | out.renamed = await renameVia(A, ' Kitchen laptop '); |
| 229 | out.local = roster()[A]; |
| 230 | out.parcel = ((await DaimondCore.collectSync()).devices || {})[A]; |
| 231 | // The named device refreshes its own `seen` and knows nothing of the name. |
| 232 | // Its line is fresher, so it wins the line -- and must not take the name. |
| 233 | await DaimondCore.applySync({ v: 2, devices: { |
| 234 | [A]: { name: 'Firefox on Windows', created: now - 9e6, seen: now - 1e3 }, |
| 235 | } }); |
| 236 | out.afterSelfRefresh = roster()[A]; |
| 237 | // A rename made on the OTHER device arrives even though its `seen` is |
| 238 | // older than what is here: the label merges on its own stamp alone. |
| 239 | await DaimondCore.applySync({ v: 2, devices: { |
| 240 | [A]: { name: 'Firefox on Windows', created: now - 9e6, seen: now - 9e5, |
| 241 | label: 'Work desktop', namedAt: now + 5e3 }, |
| 242 | } }); |
| 243 | out.afterRemoteRename = roster()[A]; |
| 244 | // An EQUAL namedAt keeps what is here, exactly as an equal `seen` does. |
| 245 | await DaimondCore.applySync({ v: 2, devices: { |
| 246 | [A]: { name: 'Firefox on Windows', created: now - 9e6, seen: now - 9e5, |
| 247 | label: 'Somebody else\'s idea', namedAt: now + 5e3 }, |
| 248 | } }); |
| 249 | out.afterEqualNamedAt = roster()[A]; |
| 250 | // And an OLDER rename loses. |
| 251 | await DaimondCore.applySync({ v: 2, devices: { |
| 252 | [A]: { name: 'Firefox on Windows', created: now - 9e6, seen: now - 9e5, |
| 253 | label: 'Older idea', namedAt: now - 1e6 }, |
| 254 | } }); |
| 255 | out.afterOlderRename = roster()[A]; |
| 256 | // A device that predates names carries neither field, and must not take |
| 257 | // the name off a line that has one -- even when its `seen` wins the line. |
| 258 | await DaimondCore.applySync({ v: 2, devices: { |
| 259 | [A]: { name: 'Firefox on Windows', created: now - 9e6, seen: now - 500 }, |
| 260 | } }); |
| 261 | out.afterOldFormat = roster()[A]; |
| 262 | // Clearing a name is itself a rename: an empty label with a fresher stamp, |
| 263 | // arriving on a line whose `seen` did not move at all. |
| 264 | // Its `seen` is EXACTLY what is already stored, so nothing but the naming |
| 265 | // can move: a label that travelled with the entry would not arrive at all. |
| 266 | const bSeen = (roster()[B] || {}).seen; |
| 267 | await DaimondCore.applySync({ v: 2, devices: { |
| 268 | [B]: { name: 'Safari on iOS', created: now - 5e6, seen: bSeen, |
| 269 | label: 'Phone', namedAt: now }, |
| 270 | } }); |
| 271 | out.bNamed = roster()[B]; |
| 272 | await DaimondCore.applySync({ v: 2, devices: { |
| 273 | [B]: { name: 'Safari on iOS', created: now - 5e6, seen: bSeen, |
| 274 | label: '', namedAt: now + 2e3 }, |
| 275 | } }); |
| 276 | out.bCleared = roster()[B]; |
| 277 | // A name is a name, not a paragraph. |
| 278 | await renameVia(B, 'x'.repeat(400)); |
| 279 | out.capped = ((roster()[B] || {}).label || '').length; |
| 280 | await renameVia(B, ''); |
| 281 | out.bBlank = roster()[B]; |
| 282 | // The push skip survives the two new fields. |
| 283 | const s1 = JSON.stringify((await DaimondCore.collectSync()).devices); |
| 284 | await wait(300); |
| 285 | const s2 = JSON.stringify((await DaimondCore.collectSync()).devices); |
| 286 | out.stableA = s1; out.stableB = s2; |
| 287 | return out; |
| 288 | }, [A, B]); |
| 289 | |
| 290 | check('a device row can be renamed from the drawer', named.renamed === true, String(named.renamed)); |
| 291 | check('the name is stored trimmed, with a stamp of its own', |
| 292 | !!named.local && named.local.label === 'Kitchen laptop' && named.local.namedAt > 1.7e12, |
| 293 | JSON.stringify(named.local)); |
| 294 | check('the derived description is kept underneath it, as the fallback', |
| 295 | !!named.local && named.local.name === 'Firefox on Windows', JSON.stringify(named.local)); |
| 296 | check('and the name rides in the parcel, so it can reach the other devices', |
| 297 | !!named.parcel && named.parcel.label === 'Kitchen laptop' && named.parcel.namedAt === named.local.namedAt, |
| 298 | JSON.stringify(named.parcel)); |
| 299 | check('the named device refreshing its own seen does NOT take the name off', |
| 300 | !!named.afterSelfRefresh && named.afterSelfRefresh.label === 'Kitchen laptop' |
| 301 | && named.afterSelfRefresh.seen > named.local.seen, |
| 302 | JSON.stringify(named.afterSelfRefresh)); |
| 303 | check('a rename from another device arrives even though its seen is older — the label merges on namedAt', |
| 304 | !!named.afterRemoteRename && named.afterRemoteRename.label === 'Work desktop', |
| 305 | JSON.stringify(named.afterRemoteRename)); |
| 306 | check('an EQUAL namedAt keeps the name that is here — strictly newer, or nothing', |
| 307 | !!named.afterEqualNamedAt && named.afterEqualNamedAt.label === 'Work desktop', |
| 308 | JSON.stringify(named.afterEqualNamedAt)); |
| 309 | check('an OLDER rename loses', !!named.afterOlderRename && named.afterOlderRename.label === 'Work desktop', |
| 310 | JSON.stringify(named.afterOlderRename)); |
| 311 | check('an entry from before names existed carries none, and takes none away', |
| 312 | !!named.afterOldFormat && named.afterOldFormat.label === 'Work desktop' |
| 313 | && named.afterOldFormat.seen > named.afterRemoteRename.seen, |
| 314 | JSON.stringify(named.afterOldFormat)); |
| 315 | check('a name reaches a line whose seen did not move at all', |
| 316 | !!named.bNamed && named.bNamed.label === 'Phone', JSON.stringify(named.bNamed)); |
| 317 | check('and clearing it is itself a rename, so the clearing travels too', |
| 318 | !!named.bCleared && named.bCleared.label === '' && named.bCleared.namedAt > named.bNamed.namedAt, |
| 319 | JSON.stringify(named.bCleared)); |
| 320 | check('a name is capped, so one line cannot become a paragraph', named.capped === 64, |
| 321 | String(named.capped)); |
| 322 | check('an empty box clears the name, back to what the device says about itself', |
| 323 | !!named.bBlank && named.bBlank.label === '' && named.bBlank.name === 'Safari on iOS', |
| 324 | JSON.stringify(named.bBlank)); |
| 325 | check('two collects in a row still stringify identically with names in play — the push skip survives', |
| 326 | typeof named.stableA === 'string' && named.stableA === named.stableB, |
| 327 | String(named.stableA).slice(0, 100)); |
| 328 | |
| 329 | // ── (4) The surface ───────────────────────────────────────────── |
| 330 | const view = await page.evaluate(() => { |
| 331 | DaimondAdmin.home(); |
| 332 | const secs = [...document.querySelectorAll('#admin-home .admin-sec')].map(e => e.textContent); |
| 333 | const rows = [...document.querySelectorAll('#admin-home .device-row')].map(r => ({ |
| 334 | text: r.innerText.replace(/\s+/g, ' ').trim(), |
| 335 | name: (r.querySelector('.device-name') || {}).textContent || '', |
| 336 | nameTitle: (r.querySelector('.device-name') || {}).title, |
| 337 | nameLabel: (r.querySelector('.device-name') || {}).getAttribute |
| 338 | ? r.querySelector('.device-name').getAttribute('aria-label') : null, |
| 339 | suffix: (r.querySelector('.device-id') || {}).textContent || '', |
| 340 | buttons: r.querySelectorAll('button').length, |
| 341 | // What each control CLAIMS to do, which is the thing worth asserting on. |
| 342 | // A count cannot tell a rename from a revoke, and counting is what made |
| 343 | // this file go stale twice as controls were added beside it. |
| 344 | acts: [...r.querySelectorAll('button')].map(b => |
| 345 | ((b.getAttribute('aria-label') || b.title || b.textContent || '').trim())), |
| 346 | rename: r.querySelectorAll('button.device-rename').length, |
| 347 | })); |
| 348 | const notes = [...document.querySelectorAll('#admin-home .admin-note')].map(e => e.textContent); |
| 349 | return { secs, rows, notes, self: localStorage.getItem('daimond-device-id') }; |
| 350 | }); |
| 351 | check('the Admin drawer has a Devices section', view.secs.some(x => /device/i.test(x)), |
| 352 | view.secs.join(' | ')); |
| 353 | check('with one line per device', view.rows.length === 3, view.rows.length + ' rows'); |
| 354 | check('exactly one of them is marked as this device', |
| 355 | view.rows.filter(r => /this device/i.test(r.text)).length === 1, |
| 356 | view.rows.map(r => r.text).join(' | ')); |
| 357 | check('the others carry a relative last-seen, not a raw stamp', |
| 358 | view.rows.filter(r => /(just now|\d+[mhd] ago)/.test(r.text)).length === 2, |
| 359 | view.rows.map(r => r.text).join(' | ')); |
| 360 | check('every row offers a rename — a device is named where it is listed', |
| 361 | view.rows.every(r => r.rename === 1), view.rows.map(r => r.rename).join(',')); |
| 362 | // The property, stated as a property. Removing a line from this list is not a |
| 363 | // revocation and must never read as one: every paired device holds the SAME |
| 364 | // keypair (`identity.js` exportBundle hands over the wrapped private key), so |
| 365 | // no control here could revoke one even if it said it did. |
| 366 | // |
| 367 | // This was `buttons === 1` and went stale the moment a second control landed |
| 368 | // beside the rename, which is the ninth time a literal count has broken a |
| 369 | // check in this codebase. A count cannot tell a rename from a revoke; the |
| 370 | // words on the controls can. |
| 371 | const REVOKES = /revoke|sign ?out|log ?out|disconnect|unpair|deauthor/i; |
| 372 | const claims = view.rows.flatMap(r => r.acts).filter(a => REVOKES.test(a)); |
| 373 | check('nothing here pretends a device can be revoked', |
| 374 | claims.length === 0, claims.join(' | ') || view.rows.flatMap(r => r.acts).join(' | ')); |
| 375 | check('a named device shows the user\'s name in place of the derived description', |
| 376 | view.rows.some(r => r.name === 'Work desktop') && !view.rows.some(r => r.name === 'Firefox on Windows'), |
| 377 | view.rows.map(r => r.name).join(' | ')); |
| 378 | check('a device with no name of its own still shows what it says about itself', |
| 379 | view.rows.some(r => r.name === 'Safari on iOS'), view.rows.map(r => r.name).join(' | ')); |
| 380 | // THE PROPERTY. This list is what has SYNCED, and the note has to say so in |
| 381 | // three parts, because every row above it carries a ✕: syncing is what puts |
| 382 | // a device here, a device that holds the account without syncing is |
| 383 | // therefore MISSING from it (so absence is not proof of no access), and |
| 384 | // taking a line off the list does not sign that device out. |
| 385 | // |
| 386 | // This was `/sync/ && /appears/` and went red when the copy said "is not |
| 387 | // listed" instead of "never appears here" -- the same claim in other words. |
| 388 | // `/sync/` alone would pass for a note that called these paired devices and |
| 389 | // left a user believing the ✕ revoked one. |
| 390 | const noteIsHonest = (n) => { |
| 391 | const bits = n.split(/(?<=[.!?:;])\s+/); |
| 392 | const neg = /\b(not|no|never|nothing|none|cannot)\b|n[’']t\b/i; |
| 393 | const list = /\b(list|listed|listing|appears?|shown?|show|here|below|missing)\b/i; |
| 394 | return { |
| 395 | // Syncing is what puts a device on this list. |
| 396 | scope: /sync/i.test(n), |
| 397 | // One that holds the account and has not synced is absent from it. |
| 398 | absent: bits.some(b => /sync/i.test(b) && neg.test(b) && list.test(b)), |
| 399 | // And nothing on this list ends a device's access. |
| 400 | signout: bits.some(b => neg.test(b) |
| 401 | && /\bsigns?[- ]?(a |the )?(device |it )?out\b|\brevokes?\b|\bcuts? off\b|\bdeauthor/i.test(b)), |
| 402 | }; |
| 403 | }; |
| 404 | const honest = view.notes.map(noteIsHonest) |
| 405 | .find(h => h.scope && h.absent && h.signout); |
| 406 | check('and the copy says these are devices that SYNC, that one which has not is missing, and that nothing here signs a device out', |
| 407 | !!honest, |
| 408 | view.notes.filter(n => /sync|device/i.test(n)).join(' | ').slice(0, 200)); |
| 409 | // Two of a user's devices can easily describe themselves identically |
| 410 | // ("Chrome on macOS" twice), so each line carries the tail of its own id. |
| 411 | const wantSuffix = [view.self, A, B].map(x => x.slice(-4)).sort(); |
| 412 | check('each line carries the tail of its OWN id, so two alike devices are still two', |
| 413 | JSON.stringify(view.rows.map(r => r.suffix).sort()) === JSON.stringify(wantSuffix), |
| 414 | view.rows.map(r => r.suffix).join(',') + ' vs ' + wantSuffix.join(',')); |
| 415 | |
| 416 | // notes4.txt, Admin panel: "The Device names are shortened with '...' which |
| 417 | // is fine but they should show hover text with the full name." CSS does the |
| 418 | // shortening (`.device-name{text-overflow:ellipsis}`); what is asked here is |
| 419 | // that the FULL name still reaches a mouse (`title`) and a keyboard or screen |
| 420 | // reader user, for whom a `title` is invisible (`aria-label`). |
| 421 | check('every device name carries the FULL name in a title, for a mouse to hover', |
| 422 | view.rows.every(r => r.nameTitle === r.name && r.name.length > 0), |
| 423 | JSON.stringify(view.rows.map(r => ({ name: r.name, title: r.nameTitle })))); |
| 424 | check('and in an aria-label, since a title alone says nothing to a keyboard or screen-reader user', |
| 425 | view.rows.every(r => r.nameLabel === r.name), |
| 426 | JSON.stringify(view.rows.map(r => ({ name: r.name, label: r.nameLabel })))); |
| 427 | |
| 428 | // This device can be named too, and naming it must not cost it the one mark |
| 429 | // that says which line the user is standing on. |
| 430 | const selfNamed = await page.evaluate(async () => { |
| 431 | const self = localStorage.getItem('daimond-device-id'); |
| 432 | DaimondAdmin.home(); |
| 433 | const row = [...document.querySelectorAll('#admin-home .device-row')] |
| 434 | .find(r => ((r.querySelector('.device-id') || {}).textContent || '') === self.slice(-4)); |
| 435 | const btn = row && row.querySelector('.device-rename'); |
| 436 | if (!btn) return null; |
| 437 | btn.click(); |
| 438 | await new Promise(r => setTimeout(r, 80)); |
| 439 | const input = document.querySelector('.dlg .dlg-input'); |
| 440 | const ok = document.querySelector('.dlg .dlg-ok'); |
| 441 | if (!input || !ok) return null; |
| 442 | // The box opens on the name that is there now, and says what it falls back |
| 443 | // to when emptied. |
| 444 | const opened = { value: input.value, placeholder: input.placeholder || '' }; |
| 445 | input.value = 'Studio Mac'; |
| 446 | ok.click(); |
| 447 | await new Promise(r => setTimeout(r, 200)); |
| 448 | const mine = [...document.querySelectorAll('#admin-home .device-row')] |
| 449 | .find(r => ((r.querySelector('.device-id') || {}).textContent || '') === self.slice(-4)); |
| 450 | return { |
| 451 | opened: opened, |
| 452 | name: (mine.querySelector('.device-name') || {}).textContent || '', |
| 453 | text: mine.innerText.replace(/\s+/g, ' ').trim(), |
| 454 | suffix: (mine.querySelector('.device-id') || {}).textContent || '', |
| 455 | }; |
| 456 | }); |
| 457 | check('this device can be named as well', !!selfNamed && selfNamed.name === 'Studio Mac', |
| 458 | JSON.stringify(selfNamed)); |
| 459 | check('and stays marked as this device, with its id tail, once named', |
| 460 | !!selfNamed && /this device/i.test(selfNamed.text) && selfNamed.suffix.length === 4, |
| 461 | selfNamed && selfNamed.text); |
| 462 | check('the rename box offers what the device calls itself as the placeholder', |
| 463 | !!selfNamed && /\w/.test(selfNamed.opened.placeholder) && selfNamed.opened.value === '', |
| 464 | JSON.stringify(selfNamed && selfNamed.opened)); |
| 465 | |
| 466 | await shot(s, 'devices-roster'); |
| 467 | |
| 468 | // Back to one device: the quiet line has to answer the question in the other |
| 469 | // direction too, or a user with one device learns nothing at all. |
| 470 | const alone = await page.evaluate(() => { |
| 471 | const self = localStorage.getItem('daimond-device-id'); |
| 472 | const keep = JSON.parse(localStorage.getItem('daimond-devices') || '{}')[self]; |
| 473 | localStorage.setItem('daimond-devices', JSON.stringify({ [self]: keep })); |
| 474 | DaimondAdmin.home(); |
| 475 | return { |
| 476 | rows: [...document.querySelectorAll('#admin-home .device-row')].length, |
| 477 | notes: [...document.querySelectorAll('#admin-home .admin-note')].map(e => e.textContent), |
| 478 | }; |
| 479 | }); |
| 480 | check('with one device there is still a line for it', alone.rows === 1, String(alone.rows)); |
| 481 | check('and a sentence saying so, so the question is answered either way', |
| 482 | alone.notes.some(n => /only this device/i.test(n)), |
| 483 | alone.notes.join(' | ').slice(0, 120)); |
| 484 | |
| 485 | // ── (5) A name chosen while pairing ───────────────────────────── |
| 486 | // The name is typed on the NEW device, during redeem — before that device has |
| 487 | // a line to put it on. Its line is minted on the first collect, which happens |
| 488 | // after the reload the redeem dialog performs, so the name is stashed and the |
| 489 | // mint consumes it. Redeeming needs a gateway; the field and the stash do not. |
| 490 | const paired = await page.evaluate(async () => { |
| 491 | const out = {}; |
| 492 | DaimondPairing.showRedeem('ABCD1234'); |
| 493 | await new Promise(r => setTimeout(r, 80)); |
| 494 | const box = document.querySelector('.pair-scrim .pair-box'); |
| 495 | const field = box && box.querySelector('.pair-name'); |
| 496 | out.hasField = !!field; |
| 497 | out.ph = field ? (field.getAttribute('placeholder') || '') : ''; |
| 498 | out.maxlen = field ? (field.getAttribute('maxlength') || '') : ''; |
| 499 | document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); |
| 500 | await new Promise(r => setTimeout(r, 60)); |
| 501 | out.closed = !document.querySelector('.pair-scrim'); |
| 502 | // What a successful redeem does with what was typed. |
| 503 | DaimondPairing.stashName(' Kitchen laptop '); |
| 504 | out.stash = localStorage.getItem('daimond-pair-label'); |
| 505 | // The reload boundary: a device that has just redeemed has no line at all. |
| 506 | localStorage.removeItem('daimond-device-id'); |
| 507 | localStorage.setItem('daimond-devices', '{}'); |
| 508 | const reg = (await DaimondCore.collectSync()).devices || {}; |
| 509 | out.self = reg[localStorage.getItem('daimond-device-id')] || null; |
| 510 | out.stashAfter = localStorage.getItem('daimond-pair-label'); |
| 511 | // And the next device to mint a line does not inherit it. |
| 512 | localStorage.removeItem('daimond-device-id'); |
| 513 | localStorage.setItem('daimond-devices', '{}'); |
| 514 | const reg2 = (await DaimondCore.collectSync()).devices || {}; |
| 515 | out.second = reg2[localStorage.getItem('daimond-device-id')] || null; |
| 516 | return out; |
| 517 | }); |
| 518 | check('the redeem dialog offers a name for the device being linked', |
| 519 | paired.hasField === true && /\w/.test(paired.ph), JSON.stringify(paired.ph)); |
| 520 | check('the field is capped there too', String(paired.maxlen) === '64', String(paired.maxlen)); |
| 521 | check('the chosen name is stashed, trimmed, across the reload redeeming does', |
| 522 | paired.stash === 'Kitchen laptop', JSON.stringify(paired.stash)); |
| 523 | check('and the roster takes it up the moment this device first mints its line', |
| 524 | !!paired.self && paired.self.label === 'Kitchen laptop' && paired.self.namedAt > 1.7e12, |
| 525 | JSON.stringify(paired.self)); |
| 526 | check('the stash is consumed, so the next line minted is not named for it', |
| 527 | !paired.stashAfter && !!paired.second && paired.second.label === '', |
| 528 | JSON.stringify(paired.stashAfter) + ' / ' + JSON.stringify(paired.second)); |
| 529 | |
| 530 | const errs = s.errs.filter(e => !/favicon|ERR_|Failed to load resource|401|402|409|426|502/.test(e)); |
| 531 | check('no unexpected console errors', errs.length === 0, errs.slice(0, 3).join(' | ')); |
| 532 | } catch (e) { |
| 533 | check('verify_devices ran without throwing', false, String(e && e.message || e)); |
| 534 | } finally { |
| 535 | await s.close?.().catch?.(() => {}); |
| 536 | } |
| 537 | |
| 538 | console.log('\n' + (bad.length ? `FAIL: ${bad.length} failed, ${ok.length} passed` : `ok: all ${ok.length} passed`)); |
| 539 | process.exit(bad.length ? 1 : 0); |