Oregami
Repositories/oxedyne/daimond

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.
12import { open, signInAs } from './harness.mjs';
13
14const ok = [], bad = [];
15const check = (name, pass, detail) => {
16 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
17 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
18};
19
20const s = await open({ name: 'pair', signIn: true, connect: false });
21const { page } = s;
22let child = null; // the real second device, opened below on its own profile
23
24await page.waitForFunction(
25 () => !!window.DaimondPairing && !!window.DaimondGateway && DaimondGateway.state().authed,
26 null, { timeout: 12000 },
27).catch(() => {});
28
29try {
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
247console.log('\n' + (bad.length ? `FAIL: ${bad.length} failed, ${ok.length} passed` : `ok: all ${ok.length} passed`));
248process.exit(bad.length ? 1 : 0);