Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_safemode.mjs

12.5 KiB, 1 run

created by r2519314175:655, 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_safemode.mjs — the app can be asked to start without the sync engine,
2// and it never does so quietly.
3//
4// THE BUG THIS IS FOR. An iPhone loops: unlock with a passkey, the app appears
5// for about a second, the lock screen is back, repeat. Six diagnoses have been
6// made across four sessions and ALL SIX WERE WRONG, every one of them reasoned
7// from source rather than from evidence, because a phone has no console.
8//
9// A safe start is not a fix. It is an EXPERIMENT the user can run, and a phone
10// they can use while it runs. If the app stays up with sync skipped, the cause
11// is inside what was skipped; if it loops anyway, sync is exonerated. Nothing so
12// far has been able to say either.
13//
14// WHAT IS LOCKED DOWN.
15//
16// A. `DaimondSafe.on()` gates the sync engine at `ready()`, which is the single
17// place every entry point in sync.js already consults — so a pull, a push, a
18// nudge and the wake channel are all refused by one rule rather than five.
19// B. It arms ITSELF at three boots in ninety seconds. A user whose app will not
20// stay open long enough to be used cannot be asked to find a button.
21// C. It is never silent: the top bar carries a chip saying sync is off, and the
22// chip is what turns it back on. A device that quietly stopped saving to the
23// account would be a worse bug than the one this is armed against.
24// D. The lock screen — the only screen a looping device stays on long enough to
25// read — offers the control too, and says which way it will go.
26// E. It survives the reload it exists because of: localStorage, not session.
27// F. A safe start writes a line in the durable trail, on EVERY boot and not
28// only the one that armed it, or a later cycle reads as an ordinary boot
29// that happened not to sync.
30//
31// PROVED RED with `--break gate`, which removes the check from `ready()` — the
32// one line that makes any of it do anything.
33//
34// node dev/verify_safemode.mjs
35// node dev/verify_safemode.mjs --break gate # must fail, loudly
36//
37// Needs dev/serve.mjs. No gateway: what is under test is whether the CLIENT
38// starts its engine at all.
39
40import { open, signInAs, scratch } from './harness.mjs';
41import fs from 'node:fs';
42
43const BREAK = process.argv.includes('--break');
44
45const out = [];
46let bad = 0;
47const check = (ok, what, detail) => {
48 out.push(`${ok ? 'PASS' : 'FAIL'} ${what}${detail != null ? ' — ' + detail : ''}`);
49 if (!ok) bad++;
50 return ok;
51};
52
53const PROFILE = scratch('pw', 'safemode-' + process.pid);
54fs.rmSync(PROFILE, { recursive: true, force: true });
55
56const s = await open({ name: 'safemode', connect: false, profile: PROFILE });
57const p = s.page;
58
59if (BREAK) {
60 // The gate removed from `ready()`. Everything else stays: the flag is still
61 // written, the chip is still asked for, the trail is still marked. Only the
62 // one line that makes the engine obey it is gone.
63 await p.route('**/js/sync.js', async (route) => {
64 const res = await route.fetch();
65 let body = await res.text();
66 body = body.replace('if (window.DaimondSafe && DaimondSafe.on()) return false;', '');
67 await route.fulfill({ response: res, body,
68 headers: { ...res.headers(), 'content-type': 'text/javascript; charset=utf-8' } });
69 });
70 await p.reload({ waitUntil: 'domcontentloaded' });
71 await signInAs(s, 'safemode');
72 await p.waitForTimeout(800);
73}
74
75// ── The module exists at all ────────────────────────────────────────
76check(await p.evaluate(() => !!(window.DaimondSafe && DaimondSafe.on && DaimondSafe.set)),
77 'the app has a safe start to offer');
78
79// ── A. Off by default, and the engine runs ──────────────────────────
80{
81 const st = await p.evaluate(() => ({
82 safe: window.DaimondSafe.on(),
83 ready: !!(window.DaimondSync && DaimondSync.status && DaimondSync.status()),
84 }));
85 check(st.safe === false, 'an ordinary start is not a safe one', JSON.stringify(st));
86}
87
88// ── E. The flag is where a reload cannot clear it ───────────────────
89{
90 await p.evaluate(() => DaimondSafe.set(true, 'user'));
91 const where = await p.evaluate(() => ({
92 local: Object.keys(localStorage).filter((k) => /safe-mode/.test(k)),
93 session: Object.keys(sessionStorage).filter((k) => /safe-mode/.test(k)),
94 }));
95 check(where.local.length > 0 && where.session.length === 0,
96 'a safe start is remembered where the reload it guards against cannot clear it',
97 JSON.stringify(where));
98}
99
100// ── A. With it on, the engine refuses to run ────────────────────────
101//
102// Measured at the engine and not at the flag: `ready()` is private, so what is
103// asked is whether a PULL reaches the mailbox at all.
104//
105// THE SESSION HAS TO BE FAKED, and this is the whole difficulty. There is no
106// gateway in this world, so `ready()` is already false on the OTHER three
107// grounds it tests — and a check written without noticing that passes whether
108// the safe gate is there or not. It did: the first `--break gate` run went 13/13
109// green, which is a check that cannot fail and therefore is not evidence.
110//
111// So `DaimondGateway.state()` is made to answer "authed", leaving the safe gate
112// as the ONLY thing standing between `pull()` and a request. Stubbed in both
113// runs, so what the two are compared on is one line of the app.
114{
115 await p.reload({ waitUntil: 'domcontentloaded' });
116 await signInAs(s, 'safemode');
117 await p.waitForTimeout(600);
118
119 const posed = await p.evaluate(() => {
120 try {
121 var real = DaimondGateway.state;
122 DaimondGateway.state = function () {
123 return Object.assign({}, real(), { authed: true });
124 };
125 return DaimondGateway.state().authed === true;
126 } catch (e) { return false; }
127 });
128 check(posed, 'a session can be posed, so the safe gate is the only thing left in the way');
129
130 const asked = [];
131 p.on('request', (r) => { if (/\/api\/sync/.test(r.url())) asked.push(r.method()); });
132
133 const answer = await p.evaluate(async () => {
134 try { return await DaimondSync.pull(); } catch (e) { return 'threw: ' + e; }
135 });
136 await p.evaluate(() => { try { DaimondSync.nudge(); } catch (e) {} });
137 await p.waitForTimeout(1500);
138
139 check(answer === -1 && asked.length === 0,
140 'with a safe start armed, the sync engine does not run at all',
141 `pull returned ${JSON.stringify(answer)}, ${asked.length} request(s) to the mailbox`);
142}
143
144// ── C. And says so, on a chip that turns it back off ────────────────
145{
146 const chip = await p.evaluate(() => {
147 const c = document.getElementById('sync-chip');
148 if (!c) return null;
149 return {
150 state: c.dataset.state,
151 text: (c.querySelector('.stext') || {}).textContent || '',
152 title: c.title || '',
153 shown: getComputedStyle(c).display !== 'none',
154 pointer: getComputedStyle(c).cursor,
155 };
156 });
157 check(!!chip && chip.state === 'off' && /safe/i.test(chip.text),
158 'the rail says sync is off, so a safe start is never a silent one',
159 JSON.stringify(chip));
160 check(!!chip && chip.pointer === 'pointer' && /click/i.test(chip.title),
161 'and the thing that says it is the thing that undoes it',
162 JSON.stringify(chip && chip.title));
163}
164
165// ── F. The trail records it, on this boot and not only the arming one ──
166{
167 const trail = await p.evaluate(() => { try { return DaimondTrail.text(); } catch (e) { return ''; } });
168 check(/safe start/.test(trail),
169 'a safe start is written into the durable trail',
170 JSON.stringify((trail.split('\n').filter((l) => /safe/.test(l)) || []).slice(-2)));
171 check(!/[A-Za-z0-9_-]{32,}/.test(trail),
172 'and the trail still carries nothing that looks like a key or a token',
173 JSON.stringify((trail.match(/[A-Za-z0-9_-]{32,}/) || [])[0] || 'none'));
174}
175
176// ── D. The lock screen offers it, and says which way the button goes ──
177{
178 await p.evaluate(() => {
179 try {
180 DaimondSafe.set(false, 'user');
181 DaimondTrail.clear();
182 for (var i = 0; i < 3; i++) DaimondTrail.note('boot', 'test');
183 DaimondCore.showIdentity('unlock');
184 } catch (e) {}
185 });
186 await p.waitForTimeout(300);
187 const offered = await p.evaluate(() => {
188 const b = document.getElementById('id-trail-safe');
189 const el = document.getElementById('id-trail');
190 return b && el ? {
191 label: b.textContent,
192 note: [...el.querySelectorAll('.id-trail-lead')].map((n) => n.textContent).join(' | ').slice(0, 90),
193 } : null;
194 });
195 check(!!offered, 'the lock screen offers a safe start where a looping device can reach it',
196 JSON.stringify(offered && offered.label));
197
198 // And with it already ON, the same button is the way BACK.
199 await p.evaluate(() => {
200 try { DaimondSafe.set(true, 'user'); DaimondCore.showIdentity('unlock'); } catch (e) {}
201 });
202 await p.waitForTimeout(300);
203 const back = await p.evaluate(() => {
204 const b = document.getElementById('id-trail-safe');
205 return b ? b.textContent : null;
206 });
207 check(!!offered && !!back && offered.label !== back,
208 'and reads as the way back once it is on, rather than offering the same thing twice',
209 JSON.stringify([offered && offered.label, back]));
210}
211
212// ── B. It arms itself on a loop, with nobody pressing anything ───────
213//
214// Through the module's own code path, which is what runs at a boot: clear the
215// flag, write three boot rows, and reload. safe.js reads the trail at script
216// load and must arm without being asked.
217{
218 await p.evaluate(() => {
219 try {
220 DaimondSafe.set(false, 'user');
221 DaimondTrail.clear();
222 // FOUR, and the count is worth explaining. A killed start is a `boot`
223 // with no `pagehide` between it and the boot before it, so N boots in a
224 // row are N-1 killed starts — the first has nothing before it to
225 // judge against. And the reload below is a REAL one, so it writes a
226 // real `pagehide` and its own boot does not count either. Four
227 // consecutive boots is therefore the smallest trail that presents
228 // three killed starts to a page that is about to load.
229 for (var i = 0; i < 4; i++) DaimondTrail.note('boot', 'test');
230 } catch (e) {}
231 });
232 await p.reload({ waitUntil: 'domcontentloaded' });
233 await p.waitForTimeout(400);
234 const armed = await p.evaluate(() => ({
235 on: window.DaimondSafe.on(),
236 why: window.DaimondSafe.why(),
237 killed: window.DaimondSafe.killed(),
238 }));
239 check(armed.on === true && armed.why === 'auto',
240 'three KILLED starts in ninety seconds arms a safe start with nobody pressing anything',
241 JSON.stringify(armed));
242
243 // NOT VACUOUS. It must NOT arm on an app that is merely being used.
244 await p.evaluate(() => {
245 try { DaimondSafe.set(false, 'user'); DaimondTrail.clear(); } catch (e) {}
246 });
247 await p.reload({ waitUntil: 'domcontentloaded' });
248 await p.waitForTimeout(400);
249 const quiet = await p.evaluate(() => ({ on: window.DaimondSafe.on(), killed: window.DaimondSafe.killed() }));
250 check(quiet.on === false,
251 'and a single ordinary start does not arm one',
252 JSON.stringify(quiet));
253
254 // AND THIS IS THE ONE THAT MATTERS. Three RELOADS in ninety seconds is a
255 // developer, a flaky connection, or this very suite driving the app — and
256 // counting boots alone could not tell that from a phone whose tab is being
257 // killed. Each reload leaves a `pagehide`; the phone's trail never has one.
258 // Without this distinction a safe start would arm inside the test suite and
259 // turn sync off for people whose app is working perfectly.
260 await p.evaluate(() => {
261 try { DaimondSafe.set(false, 'user'); DaimondTrail.clear(); } catch (e) {}
262 });
263 for (let i = 0; i < 3; i++) {
264 await p.reload({ waitUntil: 'domcontentloaded' });
265 await p.waitForTimeout(250);
266 }
267 const reloaded = await p.evaluate(() => ({
268 on: window.DaimondSafe.on(),
269 boots: window.DaimondSafe.boots(),
270 killed: window.DaimondSafe.killed(),
271 }));
272 check(reloaded.on === false && reloaded.boots >= 3 && reloaded.killed < 3,
273 'but three ORDINARY RELOADS do not — a reload says pagehide on its way out, and a killed tab cannot',
274 JSON.stringify(reloaded));
275}
276
277const noise = /favicon|401|402|426|502|Unauthorized|Payment|Bad Gateway/i;
278const errs = s.errs.filter((e) => !noise.test(e));
279check(errs.length === 0, 'no console errors', JSON.stringify(errs.slice(0, 3)));
280
281await s.close();
282try { fs.rmSync(PROFILE, { recursive: true, force: true }); } catch (e) { /* gone */ }
283
284console.log(out.join('\n'));
285const total = out.filter((l) => /^(PASS|FAIL)/.test(l)).length;
286if (BREAK) {
287 console.log(`\nBROKEN RUN: ${bad} of ${total} failed. `
288 + (bad > 0 ? 'Good — the gate in ready() is what makes a safe start mean anything.'
289 : 'BAD — a check that cannot fail is not evidence.'));
290 process.exit(bad > 0 ? 0 : 1);
291}
292console.log(bad === 0 ? `\nALL ${total} CHECKS PASSED` : `\n${bad} of ${total} FAILED`);
293process.exit(bad === 0 ? 0 : 1);