oxedyne/daimond/dev/verify_handle.mjs
29.0 KiB, 1 run
created by r2519314175:467, 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_handle.mjs — the account has a public name, every device of it agrees, |
| 2 | // and the name never restamps itself on the way through. |
| 3 | // |
| 4 | // A handle is what a shared Diamond will be shared WITH and what a rating will |
| 5 | // be attributed TO. Four things have to be true before any of that can be built |
| 6 | // on it, and each one has failed somewhere in this app already: |
| 7 | // |
| 8 | // 1. IT IS THE ACCOUNT'S NAME, NOT THE DEVICE'S. `identity.js` already has a |
| 9 | // `displayName()`, which labels this device's keypair, lives only in this |
| 10 | // browser and is seen by nobody. Reusing it is the obvious move and the |
| 11 | // wrong one, so the check is that the two are SEPARATE: adopting a handle |
| 12 | // must leave the device's own label exactly where it was. |
| 13 | // |
| 14 | // 2. IT RIDES THE PARCEL, AND IT IS A FIXED POINT. `push()` skips the wire |
| 15 | // only while two collects give the same bytes. A field restamped on the way |
| 16 | // in makes every parcel differ from the last one sent, and two devices then |
| 17 | // push at each other for ever -- which has happened here, over a pairing |
| 18 | // name, on a phone freshly paired by QR. So the merge must write what |
| 19 | // arrived VERBATIM, stamp included, and must move nothing when the record |
| 20 | // it is handed is one this device already holds. |
| 21 | // |
| 22 | // 3. THE MERGE IS THE SAME ON BOTH DEVICES. Later stamp wins; on a tie the |
| 23 | // lexicographically smaller name wins, because a tie broken by "keep mine" |
| 24 | // is two devices that never converge. |
| 25 | // |
| 26 | // 4. A REFUSAL SAYS WHICH REFUSAL IT IS. A name somebody else holds, a name |
| 27 | // that is not a name, and a name the operator keeps are three different |
| 28 | // sentences. One sentence for all three is a user staring at a field that |
| 29 | // will not take what they typed. |
| 30 | // |
| 31 | // And the half that makes it public at all: a handle another account holds must |
| 32 | // be resolvable, or nothing can ever be attributed to it. |
| 33 | // |
| 34 | // THE GATEWAY IS STUBBED HERE, and it is stubbed as a real namespace owner -- |
| 35 | // one name to one account, a 409 for a name that is held. What the REAL gateway |
| 36 | // does with the namespace is proved in Rust, against a real store, in |
| 37 | // `gateway/src/schema.rs` and `gateway/src/handlers/account.rs`. This file is |
| 38 | // about the browser: what it asks for, what it stores, what it sends on. |
| 39 | // |
| 40 | // EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a |
| 41 | // damaged copy of a real source file to the real page, and the run is expected |
| 42 | // to FAIL. A break whose anchor does not appear exactly once aborts, because a |
| 43 | // check proved against code that was never broken is not proved at all. |
| 44 | // |
| 45 | // node dev/verify_handle.mjs --break restamp # 2 fails: the stamp is rewritten |
| 46 | // node dev/verify_handle.mjs --break applystamps # 2 fails: applying its OWN parcel moves it |
| 47 | // node dev/verify_handle.mjs --break nocarry # 2 fails: the parcel drops it |
| 48 | // node dev/verify_handle.mjs --break alwaysadopt # 3 fails: an older record wins |
| 49 | // node dev/verify_handle.mjs --break handledeaf # 8b fails: adopted, and not drawn |
| 50 | // node dev/verify_handle.mjs --break onemessage # 4 fails: one sentence for three noes |
| 51 | // node dev/verify_handle.mjs --break devicename # 1 fails: the device label is overwritten |
| 52 | // node dev/verify_handle.mjs --break nolookup # the public half fails |
| 53 | // node dev/verify_handle.mjs # and then, clean |
| 54 | // |
| 55 | // eval "$(bash dev/world.sh 3 --env)" |
| 56 | // node dev/verify_handle.mjs |
| 57 | // |
| 58 | // Needs dev/serve.mjs only. No gateway on :9002 and no mock LLM: nothing here |
| 59 | // runs a turn. |
| 60 | import fs from 'node:fs'; |
| 61 | import path from 'node:path'; |
| 62 | import { fileURLToPath } from 'node:url'; |
| 63 | import { open, scratch, errors, PASS } from './harness.mjs'; |
| 64 | |
| 65 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 66 | const WWW = path.join(HERE, '..', 'www'); |
| 67 | |
| 68 | const BREAK = (() => { |
| 69 | const i = process.argv.indexOf('--break'); |
| 70 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 71 | })(); |
| 72 | |
| 73 | const ok = [], bad = []; |
| 74 | const check = (name, pass, detail) => { |
| 75 | (pass ? ok : bad).push(name); |
| 76 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 77 | }; |
| 78 | |
| 79 | // ── The breaks ─────────────────────────────────────────────────────── |
| 80 | const BREAKS = { |
| 81 | // The merge stamps what it adopts with this device's clock. This is the |
| 82 | // pairing-name defect exactly: the record is right, the stamp is fresh, and |
| 83 | // the parcel differs from the last one sent every single time. |
| 84 | // |
| 85 | // BOTH writes, because there are two -- the merge and the gateway's own |
| 86 | // answer -- and a break that damaged one would leave the other doing the job |
| 87 | // on the path the check happens to use. The anchors carry the line above |
| 88 | // them, which is what tells the two functions apart. |
| 89 | restamp: [{ |
| 90 | file: 'js/identity.js', |
| 91 | find: '\t\tif (!handleBeats(incoming, mine)) return false;\n' |
| 92 | + '\t\ttry { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: incoming.t })); }', |
| 93 | with: '\t\tif (!handleBeats(incoming, mine)) return false;\n' |
| 94 | + '\t\ttry { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: Date.now() })); }', |
| 95 | }, { |
| 96 | file: 'js/identity.js', |
| 97 | find: '\t\tif (mine && mine.h === incoming.h && mine.t === incoming.t) return false;\n' |
| 98 | + '\t\ttry { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: incoming.t })); }', |
| 99 | with: '\t\tif (mine && mine.h === incoming.h && mine.t === incoming.t) return false;\n' |
| 100 | + '\t\ttry { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: Date.now() })); }', |
| 101 | }], |
| 102 | // The section restamps itself ON APPLY: it takes every record it is handed, |
| 103 | // including one it already holds, and writes the clock over the stamp. This |
| 104 | // is the `touchSelfDevice` defect precisely -- a parcel that differs from |
| 105 | // the last one sent every time it is packed, on a device that has just been |
| 106 | // paired, and two devices pushing at each other about nothing. |
| 107 | applystamps: [{ |
| 108 | file: 'js/identity.js', |
| 109 | find: '\t\tif (!handleBeats(incoming, mine)) return false;\n' |
| 110 | + '\t\ttry { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: incoming.t })); }', |
| 111 | with: '\t\tif (!incoming) return false;\n' |
| 112 | + '\t\ttry { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: Date.now() })); }', |
| 113 | }], |
| 114 | // The parcel does not carry the handle at all, so a second device of the |
| 115 | // same account never hears the name. |
| 116 | nocarry: [{ |
| 117 | file: 'js/sync.js', |
| 118 | find: "\t\ttry { if (window.DaimondIdentity) state.handle = DaimondIdentity.handleSnapshot(); }", |
| 119 | with: "\t\ttry { if (false) state.handle = DaimondIdentity.handleSnapshot(); }", |
| 120 | }], |
| 121 | // Whatever arrives is taken, stamps ignored. Two devices then hand the same |
| 122 | // two names back and forth, and an older record undoes a rename. |
| 123 | // The drawer is deaf to the event. Everything the STORE does stays exactly |
| 124 | // right -- the record is adopted, the parcel carries it, the ties settle -- |
| 125 | // and only 8b reddens, which is what makes 8b a check about the screen |
| 126 | // rather than a second reading of the store. |
| 127 | handledeaf: [{ |
| 128 | file: 'js/daimond.js', |
| 129 | find: "\t\t\twindow.addEventListener('daimond:handle', function () {", |
| 130 | with: "\t\t\twindow.addEventListener('daimond:handle-nobody-sends-this', function () {", |
| 131 | }], |
| 132 | alwaysadopt: [{ |
| 133 | file: 'js/identity.js', |
| 134 | find: '\t\tif (!handleBeats(incoming, mine)) return false;', |
| 135 | with: '\t\tif (!incoming) return false;', |
| 136 | }], |
| 137 | // One sentence for every refusal. |
| 138 | onemessage: [{ |
| 139 | file: 'js/sync.js', |
| 140 | find: "\t\tif (reason === 'taken') return t('handle.taken');", |
| 141 | with: "\t\tif (reason === 'taken') return t('handle.failed');", |
| 142 | }], |
| 143 | // The account's public name is written over the device's own label -- the |
| 144 | // exact confusion this feature is shaped around avoiding. |
| 145 | devicename: [{ |
| 146 | file: 'js/identity.js', |
| 147 | find: '\t\tif (!handleBeats(incoming, mine)) return false;\n' |
| 148 | + '\t\ttry { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: incoming.t })); }', |
| 149 | with: '\t\tif (!handleBeats(incoming, mine)) return false;\n' |
| 150 | + '\t\tlocalStorage.setItem(K_NAME, incoming.h);\n' |
| 151 | + '\t\ttry { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: incoming.t })); }', |
| 152 | }, { |
| 153 | file: 'js/identity.js', |
| 154 | find: '\t\tif (mine && mine.h === incoming.h && mine.t === incoming.t) return false;\n' |
| 155 | + '\t\ttry { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: incoming.t })); }', |
| 156 | with: '\t\tif (mine && mine.h === incoming.h && mine.t === incoming.t) return false;\n' |
| 157 | + '\t\tlocalStorage.setItem(K_NAME, incoming.h);\n' |
| 158 | + '\t\ttry { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: incoming.t })); }', |
| 159 | }], |
| 160 | // Nobody else's name can be resolved, so nothing can be attributed to one. |
| 161 | nolookup: [{ |
| 162 | file: 'js/sync.js', |
| 163 | find: '\t\tif (r.status !== 200 || !j.ok || !j.found) return { found: false };', |
| 164 | with: '\t\tif (true) return { found: false };', |
| 165 | }], |
| 166 | }; |
| 167 | |
| 168 | if (BREAK && !BREAKS[BREAK]) { |
| 169 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 170 | process.exit(2); |
| 171 | } |
| 172 | |
| 173 | /// The damaged source of ONE file, with every edit for it applied, or a hard |
| 174 | /// stop. |
| 175 | /// |
| 176 | /// All of a file's edits go into one body on purpose. Registering two routes for |
| 177 | /// the same URL serves only the last one, so a break with two edits in one file |
| 178 | /// would silently deliver half of itself -- and the half it dropped is the half |
| 179 | /// the check was written for. That happened here: the merge was left intact, the |
| 180 | /// run went green, and the break looked like a proof that the code was right. |
| 181 | function damaged(file, specs) { |
| 182 | let src = fs.readFileSync(path.join(WWW, file), 'utf8'); |
| 183 | for (const spec of specs) { |
| 184 | const n = src.split(spec.find).length - 1; |
| 185 | if (n !== 1) { |
| 186 | console.error(`break '${BREAK}': an anchor appears ${n} times in ${file}, ` |
| 187 | + 'so it was not applied and the run below would prove nothing.'); |
| 188 | process.exit(2); |
| 189 | } |
| 190 | src = src.replace(spec.find, spec.with); |
| 191 | } |
| 192 | return src; |
| 193 | } |
| 194 | |
| 195 | // ── The English the user is owed ───────────────────────────────────── |
| 196 | // Read from the CATALOGUE FILE, not from the page: a check that asks the page |
| 197 | // what it thinks the sentence is, and then asserts the page said it, is a check |
| 198 | // that would pass with every sentence replaced by the same one. |
| 199 | const CATALOGUE = (() => { |
| 200 | const src = fs.readFileSync(path.join(WWW, 'i18n/en.js'), 'utf8'); |
| 201 | const out = {}; |
| 202 | for (const key of ['handle.taken', 'handle.invalid', 'handle.reserved', 'handle.failed']) { |
| 203 | const m = src.match(new RegExp(`'${key.replace('.', '\\.')}':\\s*'((?:[^'\\\\]|\\\\.)*)'`)); |
| 204 | if (!m) { |
| 205 | console.error(`i18n/en.js carries no '${key}' -- the check below would compare nothing.`); |
| 206 | process.exit(2); |
| 207 | } |
| 208 | out[key] = m[1].replace(/\\'/g, "'").replace(/\\\\/g, '\\'); |
| 209 | } |
| 210 | return out; |
| 211 | })(); |
| 212 | |
| 213 | // ── The stubbed gateway ────────────────────────────────────────────── |
| 214 | // A real namespace owner, in miniature: one name to one account, and a 409 for a |
| 215 | // name somebody else holds. |
| 216 | |
| 217 | const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' }; |
| 218 | const json = (body, status = 200) => ({ |
| 219 | status, contentType: 'application/json', headers: CORS, body: JSON.stringify(body), |
| 220 | }); |
| 221 | |
| 222 | const ME = 'acct-under-test'; |
| 223 | const SOMEONE = 'acct-somebody-else'; |
| 224 | const THEIRS = 'quiet-heron-22aa'; |
| 225 | const THEIR_FP = 'aaaa bbbb cccc dddd'; |
| 226 | const MINTED = 'amber-otter-7f3q'; |
| 227 | |
| 228 | /// handle → account id. The reservation, and the only place a name is held. |
| 229 | const NS = new Map([[THEIRS, SOMEONE]]); |
| 230 | let clock = 1_700_000_000; // the gateway's clock, in seconds |
| 231 | let mine = ''; // what the account under test is called |
| 232 | let mineTs = 0; |
| 233 | let asked = 0; // GETs of this account's own record |
| 234 | |
| 235 | /// The gateway's own rule, in miniature. |
| 236 | function normalise(raw) { |
| 237 | const h = String(raw || '').trim().toLowerCase(); |
| 238 | if (h.length < 3 || h.length > 24) return null; |
| 239 | if (!/^[a-z0-9-]+$/.test(h)) return null; |
| 240 | if (h.startsWith('-') || h.endsWith('-') || h.includes('--')) return null; |
| 241 | return h; |
| 242 | } |
| 243 | const RESERVED = new Set(['admin', 'daimond', 'support', 'system', 'root', 'operator']); |
| 244 | |
| 245 | function mint() { |
| 246 | if (mine) return; |
| 247 | mine = MINTED; |
| 248 | mineTs = ++clock; |
| 249 | NS.set(mine, ME); |
| 250 | } |
| 251 | |
| 252 | /// Serve one `/api/account` request. `refuseGet` is device B, whose gateway is |
| 253 | /// deliberately mute so that only the parcel can tell it the account's name. |
| 254 | function accountRoute(refuseGet) { |
| 255 | return (r) => { |
| 256 | const req = r.request(); |
| 257 | const url = new URL(req.url()); |
| 258 | const method = req.method(); |
| 259 | |
| 260 | if (method === 'POST' && url.searchParams.get('op') === 'handle') { |
| 261 | let body = {}; |
| 262 | try { body = JSON.parse(req.postData() || '{}'); } catch (e) { body = {}; } |
| 263 | const want = normalise(body.handle); |
| 264 | if (!want) return r.fulfill(json({ ok: false, reason: 'invalid' }, 400)); |
| 265 | if (RESERVED.has(want)) return r.fulfill(json({ ok: false, reason: 'reserved' }, 400)); |
| 266 | const holder = NS.get(want); |
| 267 | if (holder && holder !== ME) return r.fulfill(json({ ok: false, reason: 'taken' }, 409)); |
| 268 | if (want === mine) { |
| 269 | return r.fulfill(json({ ok: true, reason: 'unchanged', handle: mine, handle_ts: mineTs })); |
| 270 | } |
| 271 | NS.delete(mine); |
| 272 | mine = want; |
| 273 | mineTs = ++clock; |
| 274 | NS.set(mine, ME); |
| 275 | return r.fulfill(json({ ok: true, reason: 'claimed', handle: mine, handle_ts: mineTs })); |
| 276 | } |
| 277 | |
| 278 | if (method === 'GET') { |
| 279 | if (refuseGet) return r.fulfill(json({ ok: false, error: 'no' }, 500)); |
| 280 | const wanted = url.searchParams.get('handle'); |
| 281 | if (wanted !== null) { |
| 282 | const holder = NS.get(normalise(wanted) || ''); |
| 283 | if (!holder) return r.fulfill(json({ ok: true, found: false })); |
| 284 | return r.fulfill(json({ |
| 285 | ok: true, found: true, |
| 286 | handle: normalise(wanted), |
| 287 | fingerprint: holder === SOMEONE ? THEIR_FP : 'ffff ffff ffff ffff', |
| 288 | })); |
| 289 | } |
| 290 | asked++; |
| 291 | return r.fulfill(json({ ok: true, account_id: ME, handle: mine, handle_ts: mineTs })); |
| 292 | } |
| 293 | |
| 294 | // Registration. The gateway mints the handle: the client never proposes |
| 295 | // one, so there is no collision to explain during sign-in. |
| 296 | mint(); |
| 297 | return r.fulfill(json({ |
| 298 | ok: true, account_id: ME, created: true, handle: mine, handle_ts: mineTs, |
| 299 | })); |
| 300 | }; |
| 301 | } |
| 302 | |
| 303 | /// Everything a page needs to boot signed in, plus the account endpoint. |
| 304 | async function stub(page, { refuseGet = false, broken = true } = {}) { |
| 305 | if (BREAK && broken) { |
| 306 | const byFile = new Map(); |
| 307 | for (const spec of BREAKS[BREAK]) { |
| 308 | if (!byFile.has(spec.file)) byFile.set(spec.file, []); |
| 309 | byFile.get(spec.file).push(spec); |
| 310 | } |
| 311 | for (const [file, specs] of byFile) { |
| 312 | const body = damaged(file, specs); |
| 313 | await page.route('**/' + file, r => r.fulfill({ |
| 314 | status: 200, contentType: 'application/javascript', body, |
| 315 | })); |
| 316 | } |
| 317 | } |
| 318 | await page.route(/\/api\/account(\?|$)/, accountRoute(refuseGet)); |
| 319 | await page.route('**/api/auth/challenge', r => r.fulfill(json({ ok: true, challenge: 'chal-h', challenge_id: 'cid-h' }))); |
| 320 | await page.route('**/api/auth/verify', r => r.fulfill(json({ ok: true }))); |
| 321 | await page.route('**/api/balance', r => r.fulfill(json({ ok: true, credits_minor: 5000, currency: 'usd', entries: [] }))); |
| 322 | await page.route('**/api/licence', r => r.fulfill(json({ ok: true, licence: true, held: true, currency: 'usd' }))); |
| 323 | // The sync mailbox is not what this is about, and a device pushing into a |
| 324 | // 404 retries. Answer it, emptily. |
| 325 | await page.route('**/api/sync**', r => r.fulfill(json({ ok: true, version: 0, blob: null }))); |
| 326 | } |
| 327 | |
| 328 | const PROFILE_A = scratch('pw', 'handle-a' + (BREAK ? '-' + BREAK : '')); |
| 329 | const PROFILE_B = scratch('pw', 'handle-b' + (BREAK ? '-' + BREAK : '')); |
| 330 | for (const p of [PROFILE_A, PROFILE_B]) fs.rmSync(p, { recursive: true, force: true }); |
| 331 | |
| 332 | const a = await open({ |
| 333 | name: 'handlea', profile: PROFILE_A, connect: false, |
| 334 | route: (page) => stub(page), |
| 335 | }); |
| 336 | const page = a.page; |
| 337 | let b = null; |
| 338 | |
| 339 | /// The handle this device is showing. |
| 340 | const handleOf = (p) => p.evaluate(() => window.DaimondSync.handle()); |
| 341 | /// The handle section of the parcel this device would send. |
| 342 | const sectionOf = (p) => p.evaluate(async () => { |
| 343 | const parcel = await window.DaimondSync.parcel(); |
| 344 | return JSON.stringify(parcel.handle === undefined ? null : parcel.handle); |
| 345 | }); |
| 346 | |
| 347 | try { |
| 348 | await page.waitForFunction(() => !!(window.DaimondSync && window.DaimondSync.handle), |
| 349 | null, { timeout: 15000 }); |
| 350 | |
| 351 | // ── 1. The device learns the account's name ────────────────────── |
| 352 | await page.waitForFunction(() => !!window.DaimondSync.handle(), null, { timeout: 15000 }) |
| 353 | .catch(() => { /* asserted below, with the value in the message */ }); |
| 354 | const learned = await handleOf(page); |
| 355 | check('the device learns the handle the gateway minted', learned === MINTED, |
| 356 | `showing '${learned}', the gateway minted '${MINTED}'`); |
| 357 | check('and it had to ask for it', asked > 0, `${asked} request(s)`); |
| 358 | |
| 359 | // ── 2. It is the ACCOUNT's name, not the device's ──────────────── |
| 360 | // The trap this whole feature is shaped around: `displayName()` labels this |
| 361 | // device's keypair and must be untouched by any of it. |
| 362 | const label = await page.evaluate(() => ({ |
| 363 | device: window.DaimondIdentity.displayName(), |
| 364 | raw: localStorage.getItem('daimond-id-name'), |
| 365 | })); |
| 366 | check('the device\'s own label is left alone', label.device === 'handlea' && label.raw !== learned, |
| 367 | `device label '${label.device}', handle '${learned}'`); |
| 368 | |
| 369 | // ── 3. It rides the parcel ─────────────────────────────────────── |
| 370 | const carried = JSON.parse(await sectionOf(page) || 'null'); |
| 371 | check('the parcel carries the handle', !!carried && carried.h === learned, |
| 372 | `parcel carries ${JSON.stringify(carried)}`); |
| 373 | check('and carries the gateway\'s stamp, not a local clock', |
| 374 | !!carried && carried.t === mineTs, `parcel stamp ${carried && carried.t}, gateway ${mineTs}`); |
| 375 | |
| 376 | // ── 4. The fixed point ─────────────────────────────────────────── |
| 377 | // Applying this device's OWN parcel must leave the next one identical. |
| 378 | // Compared as BYTES, because that is what the push-skip compares. |
| 379 | const before = await sectionOf(page); |
| 380 | await page.evaluate(async () => { |
| 381 | const p = await window.DaimondSync.parcel(); |
| 382 | await window.DaimondSync.apply(p); |
| 383 | }); |
| 384 | await page.waitForTimeout(1200); |
| 385 | const after = await sectionOf(page); |
| 386 | check('applying its own parcel leaves the handle section byte-identical', |
| 387 | before === after, `${before} → ${after}`); |
| 388 | |
| 389 | // ── 5. A newer record is adopted VERBATIM ──────────────────────── |
| 390 | const NEWER = { v: 1, h: 'silver-brolga-4k2m', t: mineTs + 500 }; |
| 391 | await page.evaluate(async (rec) => { |
| 392 | await window.DaimondSync.apply({ handle: rec }); |
| 393 | }, NEWER); |
| 394 | await page.waitForTimeout(600); |
| 395 | const adopted = JSON.parse(await sectionOf(page) || 'null'); |
| 396 | check('a later record is adopted', adopted && adopted.h === NEWER.h, |
| 397 | `showing '${adopted && adopted.h}'`); |
| 398 | check('and its stamp is copied, not taken from this device\'s clock', |
| 399 | adopted && adopted.t === NEWER.t, `stamp ${adopted && adopted.t}, sent ${NEWER.t}`); |
| 400 | // The stamp check is only worth anything if a local clock would LOOK |
| 401 | // different, so say what one would have been. |
| 402 | const nowish = await page.evaluate(() => Date.now()); |
| 403 | check('and a local clock would have been visibly different', |
| 404 | Math.abs(nowish - NEWER.t) > 60_000, `local ${nowish} vs adopted ${NEWER.t}`); |
| 405 | // And the device's own label survived a handle arriving by parcel, which is |
| 406 | // the OTHER of the two paths that write the record. Asked again here rather |
| 407 | // than only after sign-in, because the first check went through the |
| 408 | // gateway's answer and this one goes through the merge. |
| 409 | const labelAfter = await page.evaluate(() => window.DaimondIdentity.displayName()); |
| 410 | check('a handle arriving by parcel still leaves the device label alone', |
| 411 | labelAfter === 'handlea' && labelAfter !== NEWER.h, `device label '${labelAfter}'`); |
| 412 | |
| 413 | // ── 6. An older record is refused ──────────────────────────────── |
| 414 | await page.evaluate(async (t) => { |
| 415 | await window.DaimondSync.apply({ handle: { v: 1, h: 'olive-quoll-1a1a', t: t } }); |
| 416 | }, NEWER.t - 100); |
| 417 | await page.waitForTimeout(600); |
| 418 | const held = await handleOf(page); |
| 419 | check('an older record does not undo a rename', held === NEWER.h, `showing '${held}'`); |
| 420 | |
| 421 | // ── 7. A tie is broken the same way on both devices ────────────── |
| 422 | // Same stamp, two names: the smaller name wins, whichever order they arrive |
| 423 | // in. A tie broken by "keep mine" is two devices that never converge. |
| 424 | const TIE = NEWER.t; |
| 425 | const settle = async (first, second) => { |
| 426 | await page.evaluate(async ([f, s, t]) => { |
| 427 | await window.DaimondSync.apply({ handle: { v: 1, h: f, t: t } }); |
| 428 | await window.DaimondSync.apply({ handle: { v: 1, h: s, t: t } }); |
| 429 | }, [first, second, TIE]); |
| 430 | await page.waitForTimeout(400); |
| 431 | return handleOf(page); |
| 432 | }; |
| 433 | const one = await settle('mellow-teal-3x3x', 'amber-wren-9q9q'); |
| 434 | const two = await settle('amber-wren-9q9q', 'mellow-teal-3x3x'); |
| 435 | check('a tie settles the same way whichever parcel arrives first', |
| 436 | one === two && !!one, `${one} vs ${two}`); |
| 437 | |
| 438 | // ── 8. A second device of the same account shows the same name ─── |
| 439 | // Its gateway is mute (every GET is a 500), so the ONLY thing that can tell |
| 440 | // it the account's name is the parcel. |
| 441 | const bundle = await page.evaluate(() => window.DaimondIdentity.exportBundle()); |
| 442 | check('the pairing bundle carries the handle', !!(bundle && bundle.hdl && bundle.hdl.h), |
| 443 | JSON.stringify(bundle && bundle.hdl)); |
| 444 | |
| 445 | b = await open({ |
| 446 | name: 'handleb', profile: PROFILE_B, connect: false, |
| 447 | route: (p) => stub(p, { refuseGet: true }), |
| 448 | }); |
| 449 | await b.page.waitForFunction(() => !!(window.DaimondSync && window.DaimondSync.parcel), |
| 450 | null, { timeout: 15000 }); |
| 451 | // B becomes the SAME account: it adopts A's identity bundle and unlocks it |
| 452 | // with the same passphrase, exactly as a paired device does. The handle is |
| 453 | // then wiped from B, so what it shows afterwards can only have come from the |
| 454 | // parcel it is about to be handed. |
| 455 | const became = await b.page.evaluate(async ([bun, pass]) => { |
| 456 | const took = window.DaimondIdentity.importBundle(bun); |
| 457 | const un = await window.DaimondIdentity.unlock(pass); |
| 458 | localStorage.removeItem('daimond-id-handle'); |
| 459 | return { took: took, unlocked: !!(un && un.ok), showing: window.DaimondSync.handle() }; |
| 460 | }, [bundle, PASS]); |
| 461 | check('the second device adopted the identity and holds no name yet', |
| 462 | became.took && became.unlocked && !became.showing, JSON.stringify(became)); |
| 463 | |
| 464 | // ── 8b. AND THE SCREEN SAYS SO, WITHOUT ANOTHER CLICK ──────────── |
| 465 | // |
| 466 | // The store is not the screen. B is holding the account view OPEN before the |
| 467 | // parcel arrives and nothing is touched afterwards, so the row that names the |
| 468 | // account either redraws itself or it does not. It did not: `identity.js` |
| 469 | // dispatched `daimond:handle` on both write paths and nothing anywhere |
| 470 | // listened, so the local claim redrew the drawer by calling it directly and |
| 471 | // the adopted one -- this path -- left B showing the name it had, or, as |
| 472 | // here, no name at all. |
| 473 | await b.page.evaluate(() => { window.DaimondAdmin.home(); }); |
| 474 | await b.page.waitForTimeout(400); |
| 475 | /// What the account row on that screen is showing, or '' when it is not drawn. |
| 476 | const shownOn = (p) => p.evaluate(() => { |
| 477 | const r = document.getElementById('account-handle'); |
| 478 | const v = r && r.querySelector('.account-fp-val'); |
| 479 | return v ? (v.textContent || '').trim() : ''; |
| 480 | }); |
| 481 | const drawnBefore = await shownOn(b.page); |
| 482 | check('the second device has the account view open and no name drawn on it', |
| 483 | drawnBefore === '', `showing '${drawnBefore}'`); |
| 484 | |
| 485 | // APPLIED AND READ IN ONE EVALUATE, with no timeout between them. This world |
| 486 | // has no gateway, so the home view's console section fails to load and retries |
| 487 | // itself on a timer -- `renderHome` again at 1.2s, 2.4s, 3.6s -- which redraws |
| 488 | // the handle row for a reason that has nothing to do with the handle. A check |
| 489 | // that waited would pass with the listener deleted, which is what it did when |
| 490 | // it was written that way. A `setTimeout` callback cannot run between an |
| 491 | // `await` resuming and the statements after it, so this reads the DOM at the |
| 492 | // only moment nothing else can have touched it. |
| 493 | const parcelA = await page.evaluate(() => window.DaimondSync.parcel()); |
| 494 | const adopted2 = await b.page.evaluate(async (p) => { |
| 495 | await window.DaimondSync.apply(p); |
| 496 | const r = document.getElementById('account-handle'); |
| 497 | const v = r && r.querySelector('.account-fp-val'); |
| 498 | return { drawn: v ? (v.textContent || '').trim() : '', held: window.DaimondSync.handle() }; |
| 499 | }, parcelA); |
| 500 | await b.page.waitForTimeout(800); |
| 501 | const onB = await handleOf(b.page); |
| 502 | const onA = await handleOf(page); |
| 503 | check('and the parcel alone gives it the account\'s name', onB === onA && !!onB, |
| 504 | `A shows '${onA}', B shows '${onB}'`); |
| 505 | |
| 506 | check('and the open drawer draws it, with nothing clicked and no timer to help', |
| 507 | !!adopted2.held && adopted2.drawn === adopted2.held, |
| 508 | `drawn '${adopted2.drawn}', held '${adopted2.held}'`); |
| 509 | |
| 510 | // ── 9. A refusal says WHICH refusal ────────────────────────────── |
| 511 | // Back to a name the stub's namespace will accept, so the refusals below are |
| 512 | // about the names asked for and not about where this device had got to. |
| 513 | await page.evaluate(async () => { await window.DaimondSync.claimHandle('bright-finch-5m5m'); }); |
| 514 | await page.waitForTimeout(400); |
| 515 | |
| 516 | const refusals = await page.evaluate(async ([taken, reserved]) => { |
| 517 | const out = {}; |
| 518 | out.taken = await window.DaimondSync.claimHandle(taken); |
| 519 | out.invalid = await window.DaimondSync.claimHandle('no'); |
| 520 | out.reserved = await window.DaimondSync.claimHandle(reserved); |
| 521 | out.after = window.DaimondSync.handle(); |
| 522 | return out; |
| 523 | }, [THEIRS, 'admin']); |
| 524 | |
| 525 | check('a name somebody else holds is refused as taken', |
| 526 | refusals.taken.ok === false && refusals.taken.reason === 'taken', |
| 527 | JSON.stringify(refusals.taken)); |
| 528 | check('and each refusal carries its own sentence from the catalogue', |
| 529 | refusals.taken.message === CATALOGUE['handle.taken'] |
| 530 | && refusals.invalid.message === CATALOGUE['handle.invalid'] |
| 531 | && refusals.reserved.message === CATALOGUE['handle.reserved'], |
| 532 | [refusals.taken.message, refusals.invalid.message, refusals.reserved.message].join(' | ')); |
| 533 | check('which are three different sentences, not one repeated', |
| 534 | new Set([refusals.taken.message, refusals.invalid.message, refusals.reserved.message]).size === 3); |
| 535 | check('and a refused rename leaves the name where it was', |
| 536 | refusals.after === 'bright-finch-5m5m', `showing '${refusals.after}'`); |
| 537 | |
| 538 | // ── 10. A rename that takes ────────────────────────────────────── |
| 539 | const renamed = await page.evaluate(async () => { |
| 540 | const r = await window.DaimondSync.claimHandle('Copper-Marten-8P8P'); |
| 541 | return { r: r, showing: window.DaimondSync.handle() }; |
| 542 | }); |
| 543 | check('a rename takes, folded to what the namespace stores', |
| 544 | renamed.r.ok === true && renamed.showing === 'copper-marten-8p8p', JSON.stringify(renamed)); |
| 545 | check('and the namespace agrees the account holds it', |
| 546 | NS.get('copper-marten-8p8p') === ME, `held by ${NS.get('copper-marten-8p8p')}`); |
| 547 | const afterRename = JSON.parse(await sectionOf(page) || 'null'); |
| 548 | check('and the parcel carries the new name with the gateway\'s stamp', |
| 549 | afterRename && afterRename.h === 'copper-marten-8p8p' && afterRename.t === mineTs, |
| 550 | JSON.stringify(afterRename)); |
| 551 | // The third and last place the record is written, and the device's own label |
| 552 | // has to survive that one too. |
| 553 | const labelEnd = await page.evaluate(() => window.DaimondIdentity.displayName()); |
| 554 | check('and a rename still leaves the device label alone', |
| 555 | labelEnd === 'handlea', `device label '${labelEnd}'`); |
| 556 | |
| 557 | // ── 11. Somebody else's name resolves ──────────────────────────── |
| 558 | const found = await page.evaluate(async (h) => window.DaimondSync.lookupHandle(h), THEIRS); |
| 559 | check('another account\'s handle is readable', found.found === true && found.handle === THEIRS, |
| 560 | JSON.stringify(found)); |
| 561 | check('and comes with the key fingerprint a share would be checked against', |
| 562 | found.fingerprint === THEIR_FP, JSON.stringify(found)); |
| 563 | const missing = await page.evaluate(async () => window.DaimondSync.lookupHandle('nobody-here-7z')); |
| 564 | check('a name nobody holds is a miss, not a crash', missing.found === false, |
| 565 | JSON.stringify(missing)); |
| 566 | |
| 567 | // And nothing THREW on the way past. Failed loads are excluded on purpose: |
| 568 | // this world has no gateway on :9002, so the endpoints that are not stubbed |
| 569 | // here -- the wake channel among them -- answer 502, which says nothing about |
| 570 | // the handle. An exception does. |
| 571 | const threw = errors(a).concat(b ? errors(b) : []) |
| 572 | .filter(e => !/Failed to load resource|502|WebSocket/i.test(e)); |
| 573 | check('nothing threw in either page', threw.length === 0, threw.slice(0, 3).join(' | ')); |
| 574 | |
| 575 | } finally { |
| 576 | await a.close(); |
| 577 | if (b) await b.close(); |
| 578 | } |
| 579 | |
| 580 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 581 | if (bad.length) console.log('failed: ' + bad.join(', ')); |
| 582 | process.exit(bad.length ? 1 : 0); |