oxedyne/daimond/dev/verify_pairing.mjs
11.8 KiB, 1 run
created by r2519314175:561, 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_pairing.mjs — an identity travels to a second device through a one-time |
| 2 | // gateway code, so the new device becomes the SAME account. |
| 3 | // |
| 4 | // Needs the dev stack up: the app (DAIMOND_PORT, default 8777) and the gateway on |
| 5 | // :9002. Device B is simulated by wiping the local identity and redeeming the code in |
| 6 | // the same page. |
| 7 | // |
| 8 | // The second half runs a REAL second device: its own browser profile, its own |
| 9 | // localStorage, no identity of its own. That is the only way to prove the |
| 10 | // presentation snapshot, since a snapshot applied in the parent's own page would |
| 11 | // be indistinguishable from the parent's own settings. |
| 12 | import { open, signInAs } from './harness.mjs'; |
| 13 | |
| 14 | const ok = [], bad = []; |
| 15 | const check = (name, pass, detail) => { |
| 16 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 17 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 18 | }; |
| 19 | |
| 20 | const s = await open({ name: 'pair', signIn: true, connect: false }); |
| 21 | const { page } = s; |
| 22 | let child = null; // the real second device, opened below on its own profile |
| 23 | |
| 24 | await page.waitForFunction( |
| 25 | () => !!window.DaimondPairing && !!window.DaimondGateway && DaimondGateway.state().authed, |
| 26 | null, { timeout: 12000 }, |
| 27 | ).catch(() => {}); |
| 28 | |
| 29 | try { |
| 30 | const p1 = await page.evaluate(() => DaimondIdentity.publicKeyB64url()); |
| 31 | const authed = await page.evaluate(() => DaimondGateway.state().authed); |
| 32 | check('device A has an identity and an authed session', !!p1 && authed); |
| 33 | |
| 34 | // Device A creates a pairing code. |
| 35 | const created = await page.evaluate(async () => { |
| 36 | try { return await DaimondPairing.create(); } catch (e) { return { error: e.message }; } |
| 37 | }); |
| 38 | check('create() returns a pairing code', !!(created && created.code && created.code.length >= 8), |
| 39 | created && (created.code || created.error)); |
| 40 | |
| 41 | // Device B: wipe the identity, then redeem the code to get it back. |
| 42 | const redeemed = await page.evaluate(async (code) => { |
| 43 | DaimondIdentity.reset(); |
| 44 | const goneBefore = DaimondIdentity.exists(); |
| 45 | let ok = false, err = null; |
| 46 | try { ok = await DaimondPairing.redeem(code); } catch (e) { err = e.message; } |
| 47 | return { goneBefore, ok, err, existsAfter: DaimondIdentity.exists(), pub: DaimondIdentity.publicKeyB64url() }; |
| 48 | }, created.code); |
| 49 | check('resetting cleared the local identity (a fresh device)', redeemed.goneBefore === false); |
| 50 | check('redeem imports the identity — same account on device B', |
| 51 | redeemed.ok === true && redeemed.existsAfter === true && redeemed.pub === p1, |
| 52 | redeemed.err || ('pub-match=' + (redeemed.pub === p1))); |
| 53 | |
| 54 | // The code is single-use. |
| 55 | const second = await page.evaluate(async (code) => { |
| 56 | try { await DaimondPairing.redeem(code); return 'redeemed-again'; } catch (e) { return e.message; } |
| 57 | }, created.code); |
| 58 | check('a pairing code cannot be redeemed twice', /invalid|expired/i.test(second), second); |
| 59 | |
| 60 | // A bad code fails cleanly. |
| 61 | const bogus = await page.evaluate(async () => { |
| 62 | try { await DaimondPairing.redeem('not-a-real-code'); return 'redeemed'; } catch (e) { return e.message; } |
| 63 | }); |
| 64 | check('an unknown code is rejected', /invalid|expired/i.test(bogus), bogus); |
| 65 | |
| 66 | // The UI entry points were injected without any bespoke markup. |
| 67 | const ui = await page.evaluate(() => ({ |
| 68 | redeem: !!document.getElementById('pair-redeem-entry'), |
| 69 | link: !!document.getElementById('pair-link-btn'), |
| 70 | })); |
| 71 | check('redeem entry button is present on the identity screen', ui.redeem); |
| 72 | check('link-a-device button is present in the top actions', ui.link); |
| 73 | |
| 74 | // ── Linking carries how the app LOOKS, once ──────────────────────── |
| 75 | // Two devices that hold one account should not need setting up twice, so the |
| 76 | // link carries the parent's presentation across: theme, skin, language, |
| 77 | // display currency, reading size and the panel layout. It is a ONE-TIME |
| 78 | // handover, not a synced setting — from then on each device is its own. |
| 79 | const LOOK = { |
| 80 | 'daimond-theme': 'light', |
| 81 | 'daimond-skin': 'warm', |
| 82 | 'daimond-locale': 'de', |
| 83 | 'daimond-currency': 'eur', |
| 84 | 'daimond-fs-scale': '1.15', |
| 85 | 'daimond-layout': JSON.stringify({ open: { work: true }, widths: { rail: 399, dock: 300 }, grid: '2x2' }), |
| 86 | }; |
| 87 | await page.evaluate((look) => { |
| 88 | Object.keys(look).forEach((k) => localStorage.setItem(k, look[k])); |
| 89 | }, LOOK); |
| 90 | const handover = await page.evaluate(async () => { |
| 91 | try { return await DaimondPairing.create(); } catch (e) { return { error: e.message }; } |
| 92 | }); |
| 93 | check('the parent creates a second code with its presentation attached', |
| 94 | !!(handover && handover.code), handover && (handover.code || handover.error)); |
| 95 | |
| 96 | if (handover && handover.code) { |
| 97 | child = await open({ name: 'pairchild', signIn: false, connect: false }); |
| 98 | await child.page.waitForFunction(() => !!window.DaimondPairing, null, { timeout: 12000 }) |
| 99 | .catch(() => {}); |
| 100 | // The child starts with its OWN look, deliberately different in every |
| 101 | // field the snapshot carries, so nothing below can pass by coincidence. |
| 102 | await child.page.evaluate(() => { |
| 103 | localStorage.setItem('daimond-theme', 'dark'); |
| 104 | localStorage.setItem('daimond-skin', 'sharp'); |
| 105 | localStorage.setItem('daimond-locale', 'en'); |
| 106 | localStorage.setItem('daimond-currency', 'usd'); |
| 107 | localStorage.removeItem('daimond-fs-scale'); |
| 108 | localStorage.removeItem('daimond-layout'); |
| 109 | }); |
| 110 | const fresh = await child.page.evaluate(() => ({ |
| 111 | identity: window.DaimondIdentity.exists(), |
| 112 | theme: localStorage.getItem('daimond-theme'), |
| 113 | })); |
| 114 | check('the child is a real second device: its own profile, no identity', |
| 115 | fresh.identity === false && fresh.theme === 'dark', JSON.stringify(fresh)); |
| 116 | |
| 117 | const took = await child.page.evaluate(async (code) => { |
| 118 | try { return { ok: await DaimondPairing.redeem(code) }; } |
| 119 | catch (e) { return { err: e.message }; } |
| 120 | }, handover.code); |
| 121 | check('the child redeems the code', took.ok === true, took.err || ''); |
| 122 | |
| 123 | const stored = await child.page.evaluate(() => ({ |
| 124 | theme: localStorage.getItem('daimond-theme'), |
| 125 | skin: localStorage.getItem('daimond-skin'), |
| 126 | locale: localStorage.getItem('daimond-locale'), |
| 127 | currency: localStorage.getItem('daimond-currency'), |
| 128 | scale: localStorage.getItem('daimond-fs-scale'), |
| 129 | layout: localStorage.getItem('daimond-layout'), |
| 130 | })); |
| 131 | check('the redeem wrote the parent’s theme, skin, language and currency', |
| 132 | stored.theme === 'light' && stored.skin === 'warm' |
| 133 | && stored.locale === 'de' && stored.currency === 'eur', |
| 134 | JSON.stringify(stored).slice(0, 160)); |
| 135 | check('and the reading size and the whole panel layout', |
| 136 | stored.scale === '1.15' |
| 137 | && !!stored.layout && JSON.parse(stored.layout).widths.rail === 399, |
| 138 | 'scale=' + stored.scale + ' layout=' + String(stored.layout).slice(0, 60)); |
| 139 | |
| 140 | // The keys are read before first paint, so the reload the redeem dialog |
| 141 | // already does IS the apply. Prove the paint, not just the storage. |
| 142 | await child.page.reload({ waitUntil: 'domcontentloaded' }); |
| 143 | await child.page.waitForTimeout(600); |
| 144 | const painted = await child.page.evaluate(() => ({ |
| 145 | theme: document.documentElement.getAttribute('data-theme'), |
| 146 | skin: document.documentElement.getAttribute('data-skin'), |
| 147 | lang: document.documentElement.lang, |
| 148 | scale: getComputedStyle(document.documentElement).getPropertyValue('--fs-scale').trim(), |
| 149 | })); |
| 150 | check('the child paints as the parent did: theme, skin, language, reading size', |
| 151 | painted.theme === 'light' && painted.skin === 'warm' |
| 152 | && painted.lang === 'de' && painted.scale === '1.15', |
| 153 | JSON.stringify(painted)); |
| 154 | |
| 155 | // …and then it is the child's own. A later sync must not re-impose the |
| 156 | // parent's look: screen configuration is per-device from here on. |
| 157 | await signInAs(child, 'pair').catch(() => {}); |
| 158 | const diverged = await child.page.evaluate(async () => { |
| 159 | localStorage.setItem('daimond-theme', 'dark'); |
| 160 | if (window.DaimondTheme && DaimondTheme.set) DaimondTheme.set('dark'); |
| 161 | const ready = !!(window.DaimondSync && window.DaimondIdentity.isUnlocked()); |
| 162 | try { await window.DaimondSync.pull(); } catch (e) {} |
| 163 | try { await window.DaimondSync.push(); } catch (e) {} |
| 164 | await new Promise(r => setTimeout(r, 400)); |
| 165 | return { |
| 166 | ready: ready, |
| 167 | theme: localStorage.getItem('daimond-theme'), |
| 168 | painted: document.documentElement.getAttribute('data-theme'), |
| 169 | }; |
| 170 | }); |
| 171 | check('a later sync does not re-impose the parent’s look (the child diverges)', |
| 172 | diverged.theme === 'dark' && diverged.painted !== 'light', |
| 173 | JSON.stringify(diverged)); |
| 174 | |
| 175 | // The gateway refuses a parked bundle over 8 KiB, so the snapshot has two |
| 176 | // guards: a per-value cap, and a budget on the whole. Neither may ever cost |
| 177 | // the LINK — the identity is the thing being carried. |
| 178 | const huge = JSON.stringify({ open: { work: true }, pad: 'x'.repeat(9000) }); |
| 179 | await page.evaluate((big) => { |
| 180 | localStorage.setItem('daimond-layout', big); |
| 181 | localStorage.setItem('daimond-theme', 'lollypop'); |
| 182 | }, huge); |
| 183 | const third = await page.evaluate(async () => { |
| 184 | try { return await DaimondPairing.create(); } catch (e) { return { error: e.message }; } |
| 185 | }); |
| 186 | check('a layout too large to be a preference still parks a bundle', |
| 187 | !!(third && third.code), third && (third.code || third.error)); |
| 188 | if (third && third.code) { |
| 189 | const shed = await child.page.evaluate(async (arg) => { |
| 190 | try { await DaimondPairing.redeem(arg.code); } catch (e) { return { err: e.message }; } |
| 191 | return { |
| 192 | theme: localStorage.getItem('daimond-theme'), |
| 193 | layout: localStorage.getItem('daimond-layout'), |
| 194 | }; |
| 195 | }, { code: third.code }); |
| 196 | check('the small keys still travel when the layout is dropped', |
| 197 | shed.theme === 'lollypop', shed.err || ('theme=' + shed.theme)); |
| 198 | check('and the oversize layout does not', shed.layout !== huge, |
| 199 | 'layout=' + String(shed.layout).slice(0, 40)); |
| 200 | } |
| 201 | |
| 202 | // Six values each under the per-value cap can still add up past what the |
| 203 | // gateway will park. The budget drops the snapshot whole rather than let the |
| 204 | // link fail — the child then simply keeps its own look, which is the right |
| 205 | // way to lose this feature. |
| 206 | const before = await page.evaluate(() => { |
| 207 | const keep = {}; |
| 208 | ['daimond-theme', 'daimond-skin', 'daimond-locale', 'daimond-currency', |
| 209 | 'daimond-fs-scale', 'daimond-layout'].forEach((k) => { keep[k] = localStorage.getItem(k); }); |
| 210 | const pad = 'y'.repeat(4000); |
| 211 | Object.keys(keep).forEach((k) => localStorage.setItem(k, pad)); |
| 212 | return keep; |
| 213 | }); |
| 214 | const fourth = await page.evaluate(async () => { |
| 215 | try { return await DaimondPairing.create(); } catch (e) { return { error: e.message }; } |
| 216 | }); |
| 217 | await page.evaluate((keep) => { |
| 218 | Object.keys(keep).forEach((k) => { |
| 219 | if (keep[k] === null) localStorage.removeItem(k); else localStorage.setItem(k, keep[k]); |
| 220 | }); |
| 221 | }, before); |
| 222 | check('a snapshot over the whole budget still parks a bundle', |
| 223 | !!(fourth && fourth.code), fourth && (fourth.code || fourth.error)); |
| 224 | if (fourth && fourth.code) { |
| 225 | const dropped = await child.page.evaluate(async (code) => { |
| 226 | try { await DaimondPairing.redeem(code); } catch (e) { return { err: e.message }; } |
| 227 | return { theme: localStorage.getItem('daimond-theme') }; |
| 228 | }, fourth.code); |
| 229 | check('and none of it travels — the child keeps its own look', |
| 230 | dropped.theme === 'lollypop', dropped.err || ('theme=' + String(dropped.theme).slice(0, 20))); |
| 231 | } |
| 232 | |
| 233 | const cerrs = child.errs.filter(e => !/favicon|ERR_|Failed to load resource|401|402|404|409|426|502|Unauthorized/.test(e)); |
| 234 | check('no unexpected console errors on the child device', cerrs.length === 0, |
| 235 | cerrs.slice(0, 3).join(' | ')); |
| 236 | } |
| 237 | |
| 238 | const errs = s.errs.filter(e => !/favicon|ERR_|Failed to load resource|401|404|426|502|Unauthorized/.test(e)); |
| 239 | check('no unexpected console errors', errs.length === 0, errs.slice(0, 3).join(' | ')); |
| 240 | } catch (e) { |
| 241 | check('verify_pairing ran without throwing', false, String(e && e.message || e)); |
| 242 | } finally { |
| 243 | await child?.close?.().catch?.(() => {}); |
| 244 | await s.close?.().catch?.(() => {}); |
| 245 | } |
| 246 | |
| 247 | console.log('\n' + (bad.length ? `FAIL: ${bad.length} failed, ${ok.length} passed` : `ok: all ${ok.length} passed`)); |
| 248 | process.exit(bad.length ? 1 : 0); |