Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_gwretry.mjs

35.7 KiB, 1 run

created by r2519314175:463, 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_gwretry.mjs — the four files that hold their own gateway `fetch` meet a
2// 401 with one re-authentication and one retry, and the two that must NOT do
3// that still do not.
4//
5// THE BUG THIS EXISTS FOR. The gateway's session lives exactly an hour
6// (SESSION_TTL_SECS, gateway/src/handlers/common.rs) and nothing refreshes it on
7// use. `DaimondGateway.reauth()` was written for precisely this and wired into
8// sync.js alone; mail.js, tools.js, pairing.js and passkey.js each kept their own
9// `fetch` and each treated the 401 as something else entirely. An hour into a
10// sitting: the Tools panel said the account service could not be reached, the
11// mail panel said it could not tell whether Email was unlocked and offered the
12// Pro pitch to an account holding Pro, "Link another device" said "Sign in on
13// this device before linking another" with no control anywhere that would do
14// that, and removing a passkey left the gateway's copy of its sealed bundle in
15// place — a passkey the user believes they revoked, still able to adopt the
16// account. Nothing recovered short of a reload.
17//
18// HOW HONESTLY THIS REPRODUCES IT. The session is ended SERVER-SIDE with a raw
19// POST to /api/auth/logout — not `DaimondGateway.logout()`, which would tell the
20// client. That leaves precisely the production state: a live page that believes
21// it is signed in, holding a cookie that names nothing. Every check below runs
22// against the REAL gateway on :9002 over that state, through the real panels,
23// and asserts on the REQUEST TRACE — the endpoint was refused, and then it was
24// served — rather than on any flag the app keeps about itself. `state.authed` is
25// the flag that was lying, so it is never the evidence.
26//
27// The two stubbed checks are marked as stubbed, and they are stubbed for a
28// reason: what they prove is that a path was DELIBERATELY LEFT OUT of the
29// retry, and the gateway will not produce the 401 that would show it. See (7).
30import { open } from './harness.mjs';
31import { GW_PORT, GW_URL } from './ports.mjs';
32
33const ok = [], bad = [];
34const check = (name, pass, detail) => {
35 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
36 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
37};
38const sleep = ms => new Promise(r => setTimeout(r, ms));
39
40/// What a drive that never came back is worth.
41///
42/// A UNIQUE VALUE, not null and not undefined, because the failure this file
43/// exists to catch does not throw -- it PARKS, and a sentinel that reads as an
44/// ordinary empty answer would let a parked drive satisfy the very checks below
45/// that test for "not null". Compared by identity everywhere it can reach.
46const PARKED = { parked: true };
47
48/// Drive the app, and give up rather than hang.
49///
50/// EVERY `page.evaluate` THAT RUNS APP CODE GOES THROUGH THIS. On 2026-08-12 a
51/// deadlock inside `DaimondGateway` left `DaimondTools.reload()` pending for
52/// ever; the bare `await page.evaluate(...)` on the next line never settled, the
53/// suite's own timeout eventually killed the browser out from under it, and what
54/// the log said was "Target page, context or browser has been closed" -- the
55/// wreckage, naming neither the check nor the call. Two days of gates reported
56/// that. A parked drive is now an ordinary FAILED CHECK, named, and every check
57/// after it is still measured: the page's main thread is alive throughout --
58/// that is what makes this a parked promise rather than a blocked renderer, and
59/// it is how the two were told apart -- so Playwright can go on asking it
60/// questions.
61///
62/// The abandoned promise keeps its own rejection handler, because it is still
63/// pending when the browser closes and an unhandled rejection there would take
64/// the process down with a second misleading message.
65async function drive(p, label, ms = 20000) {
66 p.catch(() => {}); // it outlives us; it must not take the process with it
67 const out = await Promise.race([p.catch(e => 'ERROR ' + ((e && e.message) || e)),
68 sleep(ms).then(() => PARKED)]);
69 if (out === PARKED) console.log(' (parked: ' + label + ' did not come back in ' + ms + 'ms)');
70 return out;
71}
72
73/// Is the gateway answering?
74async function gatewayUp() {
75 try {
76 const r = await fetch(`${GW_URL}/api/health`, { signal: AbortSignal.timeout(2000) });
77 return r.ok;
78 } catch (e) { return false; }
79}
80
81if (!await gatewayUp()) {
82 console.log(`SKIP verify_gwretry — no gateway on :${GW_PORT}, this world's own `
83 + `(build it: cd gateway && cargo build --release, then dev/devgw.sh)`);
84 process.exit(0);
85}
86
87const s = await open({ name: 'gwretry', signIn: true, connect: true });
88const { page } = s;
89
90/// End the session on the GATEWAY without telling the client — what an expiry
91/// looks like from inside the page.
92const killSession = () => page.evaluate(async () => {
93 await window.__realFetch('/api/auth/logout', {
94 method: 'POST', credentials: 'same-origin', headers: { 'x-daimond-api': '1' },
95 });
96});
97
98/// Every request the page made since the trace was last cleared, with the status
99/// it came back with. This is what the checks below read: an endpoint that was
100/// refused and then served is the wiring working, and it is visible from outside
101/// whatever the app believes about itself.
102const trace = () => page.evaluate(() => window.__seen.slice());
103const clearTrace = () => page.evaluate(() => { window.__seen.length = 0; });
104
105/// The statuses seen for one endpoint, in order. `method` narrows it where an
106/// endpoint is used more than one way.
107function statuses(tr, path, method) {
108 return tr.filter(e => e.url.indexOf(path) !== -1 && (!method || e.method === method))
109 .map(e => e.status);
110}
111
112/// Refused, then served: the shape a wired caller leaves behind. A single 200
113/// is NOT a pass — it would mean the request never met the expiry this test set
114/// up, and the check would be proving nothing.
115function retried(st) {
116 return st.length === 2 && st[0] === 401 && st[1] !== 401;
117}
118
119/// Wait until the page stops talking to the gateway, or give up.
120///
121/// THE BOOT IS NOT OVER WHEN `state.authed` GOES TRUE. `bootstrap()` stamps the
122/// flag and then goes on reading the balance and the licence, and daimond.js's
123/// `connectGateway` answers the session it has just been handed by reloading the
124/// Tools panel and starting sync. Every one of those lands after the wait at the
125/// top of this file is satisfied. A measurement begun in that window sees the
126/// BOOT's tools read in its own trace and reads it as a second re-ask -- `[401,
127/// 200, 200]` where the rule is one refusal and one retry -- so the file asks
128/// its questions of a page that is doing nothing else.
129async function settled(quiet = 800, cap = 15000) {
130 const t0 = Date.now();
131 let last = -1, since = Date.now();
132 while (Date.now() - t0 < cap) {
133 const n = await page.evaluate(() => window.__seen.length);
134 if (n !== last) { last = n; since = Date.now(); }
135 else if (Date.now() - since >= quiet) return true;
136 await sleep(100);
137 }
138 return false;
139}
140
141/// Wait until an endpoint has been asked at least `n` times, or give up.
142async function until(path, n, ms = 8000) {
143 const t0 = Date.now();
144 while (Date.now() - t0 < ms) {
145 const tr = await trace();
146 if (statuses(tr, path).length >= n) return true;
147 await sleep(150);
148 }
149 return false;
150}
151
152try {
153 await page.waitForFunction(
154 () => !!window.DaimondGateway && !!window.DaimondTools && !!window.DaimondMail
155 && !!window.DaimondPairing && !!window.DaimondPasskey
156 && DaimondGateway.state().authed,
157 null, { timeout: 15000 },
158 ).catch(() => {});
159 check('a gateway session exists to begin with',
160 await page.evaluate(() => DaimondGateway.state().authed));
161
162 // The instrument. `__realFetch` is kept back so this file can ask the gateway
163 // things without its own questions landing in its own trace.
164 await page.evaluate(() => {
165 window.__gwRetryPage = 'alive';
166 window.__seen = [];
167 const real = window.fetch;
168 window.__realFetch = real;
169 window.fetch = async function (u, o) {
170 const url = String((u && u.url) || u || '');
171 const method = (o && o.method) || (u && u.method) || 'GET';
172 const r = await real.apply(this, arguments);
173 if (url.indexOf('/api/') !== -1) window.__seen.push({ url, method, status: r.status });
174 return r;
175 };
176 });
177 // Sync is a caller like any other and would mint a session of its own in the
178 // middle of a measurement. It is turned off throughout.
179 await page.evaluate(() => { try { window.DaimondSync.wakeVia('off'); } catch (e) {} });
180 // The boot's own gateway traffic must be finished before anything below
181 // clears a trace and reads it; see `settled`.
182 await settled();
183
184 // ── (1) The session really is gone, and the page does not notice ───
185 await killSession();
186 const unaware = await page.evaluate(async () => {
187 const r = await window.__realFetch('/api/tools', {
188 credentials: 'same-origin', headers: { 'x-daimond-api': '1' },
189 });
190 return { status: r.status, authed: DaimondGateway.state().authed };
191 });
192 check('the session really is gone on the gateway (a bare /api/tools is 401)',
193 unaware.status === 401, 'status=' + unaware.status);
194 check('and the page still believes it is signed in — which is the bug',
195 unaware.authed === true);
196
197 // ── (2) tools.js ───────────────────────────────────────────────────
198 await clearTrace();
199 await drive(page.evaluate(() => window.DaimondTools.reload()), 'tools.reload()');
200 await until('/api/tools', 2);
201 const tools = await page.evaluate(() => ({
202 err: (document.querySelector('#tools-body .tools-err') || {}).textContent || '',
203 page: window.__gwRetryPage,
204 }));
205 const trTools = statuses(await trace(), '/api/tools');
206 check('tools: the refused read is re-authenticated and asked again',
207 retried(trTools), 'statuses ' + JSON.stringify(trTools));
208 check('tools: and the panel does not tell the user the service is unreachable',
209 tools.err === '', tools.err.slice(0, 90));
210 check('tools: it was the SAME page throughout — no reload',
211 tools.page === 'alive');
212
213 // ── (3) mail.js ────────────────────────────────────────────────────
214 await killSession();
215 await clearTrace();
216 await drive(page.evaluate(() => window.DaimondMail.onOpen()), 'mail.onOpen()');
217 await until('/api/mail/accounts', 2);
218 await sleep(300);
219 const trMail = statuses(await trace(), '/api/mail/accounts');
220 check('mail: the refused entitlement read is re-authenticated and asked again',
221 retried(trMail), 'statuses ' + JSON.stringify(trMail));
222 const mailSaid = await page.evaluate(() =>
223 ((document.getElementById('mail-state') || {}).textContent || '').replace(/\s+/g, ' ').trim());
224 check('mail: and the panel does not say it cannot tell whether Email is unlocked',
225 !/cannot tell whether/i.test(mailSaid), mailSaid.slice(0, 90) || '(empty)');
226
227 // ── (4) pairing.js — a real side effect, over a dead session ───────
228 // The strongest of the four: the retried request MINTS something, and the
229 // proof it worked is that the thing it minted can be spent.
230 await killSession();
231 await clearTrace();
232 const paired = await drive(page.evaluate(async () => {
233 try { return { ok: true, res: await window.DaimondPairing.create() }; }
234 catch (e) { return { ok: false, err: (e && e.message) || String(e) }; }
235 }), 'pairing.create()');
236 const trPair = statuses(await trace(), '/api/pair', 'POST');
237 check('pairing: the refused park is re-authenticated and asked again',
238 retried(trPair), 'statuses ' + JSON.stringify(trPair));
239 check('pairing: and a code comes back rather than "sign in on this device first"',
240 paired.ok === true && !!(paired.res && paired.res.code),
241 paired.ok ? 'code ' + (paired.res.code || '').length + ' chars' : paired.err);
242 check('pairing: exactly ONE code was minted, not one per attempt',
243 trPair.filter(x => x === 200).length === 1, JSON.stringify(trPair));
244 // The bundle really is parked: the code is redeemed against the gateway from
245 // outside the page. A code that redeems is a request that was actually served.
246 const spent = await page.evaluate(async (code) => {
247 const r = await window.__realFetch('/api/pair/redeem', {
248 method: 'POST', credentials: 'same-origin',
249 headers: { 'content-type': 'application/json', 'x-daimond-api': '1' },
250 body: JSON.stringify({ code }),
251 });
252 const j = r.status === 200 ? await r.json() : {};
253 return { status: r.status, bundle: !!(j && j.bundle) };
254 }, (paired.res && paired.res.code) || '');
255 check('pairing: and the code the retry produced really opens a parked bundle',
256 spent.status === 200 && spent.bundle === true, JSON.stringify(spent));
257
258 // ── (5) passkey.js — the revocation really lands ───────────────────
259 // A planted record, so `remove()` has a handle to drop without a biometric
260 // gesture. What is under test is the DELETE reaching the gateway, not the
261 // authenticator.
262 await page.evaluate(() => {
263 localStorage.setItem('daimond-passkey', JSON.stringify({ v: 2, cred: 'AAAA', blob: 'x' }));
264 });
265 await killSession();
266 await clearTrace();
267 await drive(page.evaluate(() => window.DaimondPasskey.remove()), 'passkey.remove()');
268 await until('/api/passkey-blob', 2);
269 const trBlob = statuses(await trace(), '/api/passkey-blob', 'DELETE');
270 check('passkey: the refused revocation is re-authenticated and sent again',
271 retried(trBlob), 'statuses ' + JSON.stringify(trBlob));
272
273 // ── (6) One renewal, however many files are refused at once ────────
274 await page.evaluate(() => {
275 localStorage.setItem('daimond-passkey', JSON.stringify({ v: 2, cred: 'AAAA', blob: 'x' }));
276 });
277 await killSession();
278 await clearTrace();
279 await drive(page.evaluate(async () => {
280 await Promise.all([
281 window.DaimondTools.reload(),
282 window.DaimondPairing.create().catch(() => {}),
283 window.DaimondPasskey.remove(),
284 Promise.resolve(window.DaimondMail.onOpen()),
285 ]);
286 }), 'all four panels at once');
287 await sleep(800);
288 const trAll = await trace();
289 const minted = statuses(trAll, '/api/auth/verify', 'POST').length;
290 check('four files refused in the same moment mint ONE session between them',
291 minted === 1, minted + ' /api/auth/verify calls');
292 check('and all four end up on a session the gateway will actually serve',
293 retried(statuses(trAll, '/api/tools'))
294 && retried(statuses(trAll, '/api/mail/accounts'))
295 && retried(statuses(trAll, '/api/pair', 'POST'))
296 && retried(statuses(trAll, '/api/passkey-blob', 'DELETE')),
297 JSON.stringify({
298 tools: statuses(trAll, '/api/tools'),
299 mail: statuses(trAll, '/api/mail/accounts'),
300 pair: statuses(trAll, '/api/pair', 'POST'),
301 passkey: statuses(trAll, '/api/passkey-blob', 'DELETE'),
302 }));
303
304 // ── (7) What must NOT be retried ───────────────────────────────────
305 // Both of these endpoints are UNauthenticated at the gateway (pair.rs's
306 // `redeem_impl`, passkey_blob.rs's `read`), so the real gateway will never
307 // answer them 401 and the omission cannot be shown against it. The 401 is
308 // therefore stubbed — and a stub is the right instrument here, because what
309 // is being proved is that a path was deliberately left out of the retry, not
310 // that the retry works.
311 //
312 // A redeem code is single-use: a blanket wrapper would offer it a second
313 // time. And both run on a device MID-ADOPTION, which has no unlocked identity
314 // and no account — `reauth()` there does nothing except stamp `state.authed`
315 // false on a device whose gateway account does not yet exist.
316 // It takes TWO probes, because the two things a wrongly-wired redeem would do
317 // are only visible under opposite conditions. With the way back OPEN a
318 // renewal succeeds and the code is offered a second time; with it BLOCKED no
319 // second offer happens, but the failed renewal stamps `state.authed` false.
320 // One probe would leave half of this untested — and passing for the wrong
321 // reason, which is the same as not testing it.
322
323 // (7a) The way back is open: a wrongly-wired redeem renews and re-offers.
324 const heldOpen = await drive(page.evaluate(async () => {
325 const gated = window.fetch;
326 let calls = { redeem: 0, renew: 0 };
327 window.fetch = function (u, o) {
328 const url = String((u && u.url) || u || '');
329 // `/api/account` is the FIRST call `bootstrap()` makes, so it is what
330 // says a renewal was attempted at all. `/api/auth/verify` is the last,
331 // and would miss a renewal that fell over before it.
332 if (url.indexOf('/api/account') !== -1) calls.renew++;
333 if (url.indexOf('/api/pair/redeem') !== -1) {
334 calls.redeem++;
335 return Promise.resolve(new Response(JSON.stringify({ ok: false, error: 'stubbed' }),
336 { status: 401, headers: { 'content-type': 'application/json' } }));
337 }
338 return gated.apply(this, arguments);
339 };
340 try { await window.DaimondPairing.redeem('ZZZZZZ'); } catch (e) { /* expected */ }
341 window.fetch = gated;
342 return calls;
343 }), 'pairing.redeem() with the way back open');
344 check('a refused redeem does NOT re-authenticate — there is nothing to authenticate with',
345 heldOpen.renew === 0, heldOpen.renew + ' renewal attempts');
346 check('and the single-use code is offered exactly once, never a second time',
347 heldOpen.redeem === 1, heldOpen.redeem + ' redeem attempts');
348
349 // (7b) The way back is blocked: a wrongly-wired redeem signs the device out.
350 const heldShut = await drive(page.evaluate(async () => {
351 const gated = window.fetch;
352 let blob = 0;
353 window.fetch = function (u, o) {
354 const url = String((u && u.url) || u || '');
355 if (url.indexOf('/api/auth/') !== -1 || url.indexOf('/api/account') !== -1) {
356 return Promise.reject(new TypeError('blocked for the test'));
357 }
358 if (url.indexOf('/api/pair/redeem') !== -1
359 || (url.indexOf('/api/passkey-blob') !== -1 && (!o || !o.method || o.method === 'GET'))) {
360 blob++;
361 return Promise.resolve(new Response(JSON.stringify({ ok: false, error: 'stubbed' }),
362 { status: 401, headers: { 'content-type': 'application/json' } }));
363 }
364 return gated.apply(this, arguments);
365 };
366 const before = DaimondGateway.state().authed;
367 try { await window.DaimondPairing.redeem('ZZZZZZ'); } catch (e) { /* expected */ }
368 // The read sits behind `adoptWithPasskey`, which would need an
369 // authenticator; the endpoint is reached the same way a stub reaches it.
370 try { await window.fetch('/api/passkey-blob?h=' + 'A'.repeat(22), { headers: {} }); }
371 catch (e) { /* expected */ }
372 window.fetch = gated;
373 return { before, after: DaimondGateway.state().authed, blob };
374 }), 'pairing.redeem() with the way back blocked');
375 check('and neither path signs the device out on the way past — nothing to sign out of yet',
376 heldShut !== PARKED && heldShut.before === heldShut.after,
377 heldShut.before + ' -> ' + heldShut.after + ', ' + heldShut.blob + ' refusals seen');
378
379 // ── (8) gateway.js's OWN callers keep the round they paid for ──────
380 // The file that owns the renewal was the last one still throwing its answer
381 // away: `post()`/`get()` called `reauth()` on a 401 and fell straight through
382 // to the `throw`, so refreshBalance, refreshLicence, ledger, autoReload and
383 // operatorRole each lost their round at the hour mark HAVING JUST PAID for a
384 // new session. Two of those losses are worse than a blank — `state.pro` going
385 // null HIDES the Pro row, and `operatorRole()` caches its null for the rest of
386 // the unlock — so the checks below read the answer as well as the trace.
387 // `/api/balance` and `/api/licence` are the two reads `bootstrap()` makes at
388 // the end of ITSELF, so the renewal puts a 200 of its own into the trace
389 // beside the retry and "refused, then served" no longer tells the two apart.
390 // For these two the evidence is the ANSWER — which is the user-visible thing
391 // in any case, since `state.pro` coming back null is what hides the Pro row.
392 await killSession();
393 await clearTrace();
394 const lic = await drive(page.evaluate(async () => {
395 const pro = await DaimondGateway.refreshLicence();
396 return { ret: pro, state: DaimondGateway.state().pro };
397 }), 'refreshLicence()');
398 // `lic !== PARKED` first, and the same guard on the three below it: a drive
399 // that never came back has no `.ret` at all, and `undefined !== null` is true.
400 // Without it the deadlock this file now catches would PASS four checks.
401 check('licence: a refused read still ANSWERS, so the Pro row is drawn rather than hidden',
402 lic !== PARKED && lic.ret !== null && lic.state !== null, JSON.stringify(lic));
403
404 await killSession();
405 await clearTrace();
406 const bal = await drive(page.evaluate(() => DaimondGateway.refreshBalance()), 'refreshBalance()');
407 check('balance: a refused read still answers with a figure rather than "unknown"',
408 bal !== PARKED && bal !== null, String(bal));
409
410 // The ledger is asked by nobody but the Spending view, so its trace is clean.
411 await killSession();
412 await clearTrace();
413 const led = await drive(page.evaluate(() => DaimondGateway.ledger().then(e => e.length)), 'ledger()');
414 const trLed = statuses(await trace(), '/api/ledger');
415 check('ledger: the refused read is re-authenticated and asked again',
416 retried(trLed), 'statuses ' + JSON.stringify(trLed) + ', ' + led + ' entries');
417
418 // So is the auto-reload read, and it has the same shape of loss: a null here
419 // is the settings panel saying there is no card and no standing instruction,
420 // on an account that may have both.
421 await killSession();
422 await clearTrace();
423 const ar = await drive(page.evaluate(() => DaimondGateway.autoReload()), 'autoReload()');
424 const trAr = statuses(await trace(), '/api/autoreload', 'GET');
425 check('auto-reload: the refused read is re-authenticated and asked again',
426 retried(trAr), 'statuses ' + JSON.stringify(trAr));
427 check('auto-reload: and the settings come back rather than null',
428 ar !== PARKED && ar !== null, JSON.stringify(ar).slice(0, 60));
429
430 // The console entry. `operatorRole()` remembers its answer for the whole
431 // unlock, so a null taken from a refusal is not re-asked — a signed-in
432 // operator's way into the console simply disappeared until they locked and
433 // unlocked again. The answer for this account is legitimately null, so what is
434 // asserted is that the question REACHED a session that would answer it.
435 await drive(page.evaluate(() => DaimondGateway.logout()), 'logout()');
436 await drive(page.evaluate(() => DaimondGateway.bootstrap()), 'bootstrap()');
437 await killSession();
438 await clearTrace();
439 await drive(page.evaluate(() => DaimondGateway.operatorRole()), 'operatorRole()');
440 const trWho = statuses(await trace(), '/api/admin');
441 check('operator role: the refused read is re-authenticated and asked again',
442 retried(trWho), 'statuses ' + JSON.stringify(trWho));
443
444 // ── (9) buyPro — a payment path, retried once and only once ────────
445 // A raw `fetch` with no 401 handling at all, so an expired session on the Pro
446 // button ended the purchase then and there. What the user was shown is the
447 // gateway's own "No valid session." — the 401 body carries an `error`
448 // (`common::err_response`), so the file's "came back without a URL" fallback
449 // is only reached when something between here and the gateway refuses without
450 // one. Either way it is a purchase lost to a session that could have been
451 // renewed in a round trip.
452 //
453 // The RETRY is real: the 401 comes from the live gateway and the renewal is
454 // the live renewal. Only the SECOND response is stubbed, and only because a
455 // served one would create a hosted Stripe session and navigate this page away
456 // mid-test. What is under test is that the request went again at all.
457 await killSession();
458 await clearTrace();
459 const pro = await drive(page.evaluate(async () => {
460 const gated = window.fetch;
461 let asked = 0;
462 window.fetch = function (u, o) {
463 const url = String((u && u.url) || u || '');
464 if (url.indexOf('/api/checkout/pro') !== -1 && ++asked > 1) {
465 window.__seen.push({ url, method: 'POST', status: 500 });
466 return Promise.resolve(new Response(JSON.stringify({ ok: false, error: 'stubbed' }),
467 { status: 500, headers: { 'content-type': 'application/json' } }));
468 }
469 return gated.apply(this, arguments);
470 };
471 let err = '';
472 try { await DaimondGateway.buyPro(); } catch (e) { err = (e && e.message) || String(e); }
473 window.fetch = gated;
474 return { err, asked };
475 }), 'buyPro()');
476 const trPro = statuses(await trace(), '/api/checkout/pro', 'POST');
477 check('buyPro: the refused checkout is re-authenticated and asked again',
478 retried(trPro), 'statuses ' + JSON.stringify(trPro));
479 check('buyPro: and asked exactly twice, never a third time',
480 pro.asked === 2, pro.asked + ' attempts');
481 check('buyPro: and the purchase is not ended by the gateway\'s bare refusal',
482 pro !== PARKED && !/No valid session|without a URL/i.test(pro.err), pro.err);
483
484 // ── (10) One renewal that threw must not wedge the tab ─────────────
485 // `reauthing` was cleared on the way past a VALUE. An attempt that threw left
486 // the rejected promise standing, and every later `reauth()` took the
487 // single-flight arm and re-threw it — no session again, ever, short of a
488 // reload. `bootstrap()` catches broadly, but the lines before its `try` do not:
489 // `publicKeyB64url()` is one of them, and it reads localStorage, which throws
490 // outright where storage access is denied. That is what is simulated here.
491 const wedge = await drive(page.evaluate(async () => {
492 const real = DaimondIdentity.publicKeyB64url;
493 DaimondIdentity.publicKeyB64url = function () { throw new Error('storage denied'); };
494 let first = '';
495 try { await DaimondGateway.reauth(); } catch (e) { first = (e && e.message) || String(e); }
496 DaimondIdentity.publicKeyB64url = real;
497 let second = null, threw = '';
498 try { second = await DaimondGateway.reauth(); } catch (e) { threw = (e && e.message) || String(e); }
499 return { first, second, threw };
500 }), 'reauth() after one that threw');
501 check('a renewal that THREW does not wedge every renewal after it',
502 wedge.threw === '', 'the next reauth() threw: ' + wedge.threw);
503 check('and the very next renewal takes a session, on the same page',
504 wedge.second === true && await page.evaluate(() => DaimondGateway.state().authed),
505 'reauth() returned ' + JSON.stringify(wedge.second));
506
507 // ── (11) sync.js, the fifth copy ───────────────────────────────────
508 // Sync held its own version of the retry in a different shape (`once()` plus a
509 // 401 arm in `call()`) and now goes through the same one helper. Checked here
510 // rather than left to verify_sessionrenew, which passes either way: the engine
511 // re-schedules its own rounds, so a pull that was refused and never re-sent is
512 // covered by the NEXT pull a second later. That is recovery, but it is not
513 // this rule, and a test that cannot tell them apart cannot protect it.
514 // The evidence is the ROUND's own outcome as well as the trace, because the
515 // renewal raises `daimond:authed` and the engine answers that with a pull of
516 // its own — so a second 200 in the trace proves nothing about the first
517 // request. `pull()` returns -1 for a round that learned nothing, which is
518 // exactly what a refusal that was never re-sent leaves behind.
519 await killSession();
520 await clearTrace();
521 const v = await drive(page.evaluate(() => window.DaimondSync.pull()), 'sync.pull()');
522 const trSync = statuses(await trace(), '/api/sync', 'GET');
523 check('sync: the refused pull is re-authenticated and finishes its OWN round',
524 v >= 0 && trSync[0] === 401 && trSync.indexOf(200) > 0,
525 'pull() returned ' + v + ', statuses ' + JSON.stringify(trSync));
526
527 // ── (12) Nothing was raised over the app ───────────────────────────
528 // Four callers each putting up their own "you are signed out" dialog would be
529 // worse than the silence being fixed. The telling is `state.authed` going
530 // false, which the Admin drawer's Account row and the sync chip already draw.
531 const modals = await page.evaluate(() => [...document.querySelectorAll('.modal, .pair-scrim')]
532 .filter(m => getComputedStyle(m).display !== 'none')
533 .map(m => (m.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 60)));
534 check('nothing was raised over the app through the whole recovery',
535 modals.length === 0, modals.join(' | '));
536
537 // ── (13) Two renewals in flight at once must not park the tab ──
538 // THE DEFECT THIS IS HERE FOR, and it is the one that made this whole file
539 // hang for two days. `bootstrap()` is reached from five places -- daimond.js
540 // at unlock, tools.js and mail.js when a panel opens on no session,
541 // passcode.js after a redemption, and `reauth()` itself -- and two of them
542 // landing together used to run two whole bootstraps. Whether a call was the
543 // bootstrap's OWN was answered from one boolean, so the first of the two to
544 // finish said no bootstrap was running at all, and the second one's balance
545 // and licence reads -- still in flight, still its own -- renewed instead. The
546 // renewal they joined is the one that second bootstrap is INSIDE, `reauth()`
547 // is single-flight, and so it awaited itself. Nothing settled again: every
548 // panel that met the expired session afterwards joined the same parked
549 // promise, no request left the page and no timer fired. A page open an hour,
550 // silent, until it was reloaded.
551 //
552 // HELD RATHER THAN RACED, and that is the whole reason this reproduces. The
553 // two bootstraps' tails used to be separated by four milliseconds of network
554 // luck, which is not a test. `/api/balance` and `/api/licence` are the two
555 // reads a bootstrap makes as its LAST steps and an expired session answers
556 // both 401, so the FIRST of them is held open here while the second bootstrap
557 // runs to completion underneath it. Releasing it then puts the first
558 // bootstrap's own read exactly where the defect lives: after somebody else's
559 // bootstrap has finished. Everything before the tail -- the registration, the
560 // challenge, the verify -- is the real gateway.
561 //
562 // LAST, on purpose. When this fails it fails by PARKING, which leaves the
563 // renewal wedged, so nothing measured after it would mean anything.
564 await killSession();
565 await clearTrace();
566 const twin = await drive(page.evaluate(async () => {
567 const nap = ms => new Promise(r => setTimeout(r, ms));
568 const real = window.fetch;
569 let release = null, tail = 0, on = true;
570 const held = new Promise(r => { release = r; });
571 const four01 = () => new Response(JSON.stringify({ ok: false, error: 'stubbed' }),
572 { status: 401, headers: { 'content-type': 'application/json' } });
573 window.fetch = function (u, o) {
574 const url = String((u && u.url) || u || '');
575 if (on && (url.indexOf('/api/balance') !== -1 || url.indexOf('/api/licence') !== -1)) {
576 // The first tail read is held; every one after it answers at once,
577 // so the SECOND bootstrap runs through and finishes while the first
578 // is still standing in its own tail.
579 return (++tail === 1) ? held.then(four01) : Promise.resolve(four01());
580 }
581 return real.apply(this, arguments);
582 };
583
584 const first = DaimondGateway.reauth(); // its bootstrap parks at the balance read
585 await nap(1500);
586 const second = DaimondGateway.bootstrap(); // a panel's own call, in the same moment
587 await Promise.race([second, nap(2500)]); // let it finish, and clear whatever it set
588 release();
589 const out = await Promise.race([
590 Promise.all([first, second]).then(() => 'settled'),
591 nap(8000).then(() => 'parked'),
592 ]);
593 on = false;
594 window.fetch = real;
595 return { out, tail };
596 }), 'a held bootstrap tail beside a second bootstrap', 25000);
597 // `tail` as well as the outcome, and that is not belt and braces. On a device
598 // the gateway refuses an account to, no bootstrap ever reaches its tail, no
599 // read is held, and both renewals fall over in milliseconds -- which is
600 // "settled" and proves nothing whatever. The count says the straddle this
601 // check is about actually happened.
602 check('a bootstrap whose own tail read is refused does not park the tab',
603 twin !== PARKED && twin.out === 'settled' && twin.tail >= 2,
604 twin === PARKED ? 'the drive itself parked' : JSON.stringify(twin));
605 // And the page can still take a session, which is the user-visible half: a
606 // wedged renewal is only a defect because everything after it is refused.
607 const after = await drive(page.evaluate(() => DaimondGateway.reauth()),
608 'reauth() after the pair', 20000);
609 check('and a renewal after that still takes a session',
610 after === true, String(after === PARKED ? 'parked' : after));
611
612 // ── (10) A renewal that JOINS a bootstrap ──────────────────────────
613 //
614 // The other half of single-flight, and the one that has to be measured
615 // rather than reasoned about. `reauth()` used to clear `state.authed` on the
616 // way in, which is honest when it is the call about to go and ask. It is not
617 // honest when a bootstrap is already running: that attempt sets the flag TRUE
618 // the moment its verify returns, and spends the next two round trips on its
619 // balance and licence reads. A renewal arriving in that window cleared a
620 // session that existed, joined the attempt that had taken it, was told
621 // `true`, and left the false standing -- with the licence read inside the
622 // bootstrap short-circuiting on the same false, so `pro` came out null too.
623 //
624 // The user-visible shape was `verify_redeem`: a passcode spent, the account
625 // returned, the session opened in the gateway's own log, "You are in" on the
626 // screen, and the app signed out. Deterministic here because the tail is HELD
627 // -- and the whole round below is the real gateway, no stub.
628 const joined = await drive(page.evaluate(async () => {
629 const nap = ms => new Promise(r => setTimeout(r, ms));
630 const real = window.fetch;
631 // Drain whatever is in flight, so the interleave below is the only one.
632 await DaimondGateway.bootstrap();
633 await DaimondGateway.reauth();
634 await nap(200);
635 let on = true;
636 window.fetch = function (u, o) {
637 const url = String((u && u.url) || u || '');
638 // The bootstrap's own tail, stretched. Answered for real afterwards:
639 // the point is the window, not a refusal.
640 if (on && url.indexOf('/api/balance') !== -1) {
641 return nap(1500).then(() => real.apply(window, [u, o]));
642 }
643 return real.apply(this, arguments);
644 };
645 const boot = DaimondGateway.bootstrap();
646 await nap(600); // it has its session and is in the tail
647 const mid = DaimondGateway.state().authed;
648 const ren = DaimondGateway.reauth(); // lands inside somebody else's bootstrap
649 const rets = await Promise.all([boot, ren]);
650 on = false;
651 window.fetch = real;
652 const st = DaimondGateway.state();
653 // The oracle is the GATEWAY, not the flag: does the session the bootstrap
654 // took actually serve a request?
655 const r = await window.__realFetch('/api/tools', {
656 credentials: 'same-origin', headers: { 'x-daimond-api': '1' },
657 });
658 return { mid: mid, rets: rets, authed: st.authed, pro: st.pro, served: r.status };
659 }), 'a renewal joining a bootstrap in flight', 30000);
660 check('a renewal that joins a bootstrap does not sign the app out of the session it took',
661 joined !== PARKED && joined.mid === true && joined.rets[0] === true
662 && joined.served === 200 && joined.authed === true,
663 joined === PARKED ? 'the drive itself parked' : JSON.stringify(joined));
664 // And the licence read at the end of that bootstrap was not skipped on a flag
665 // somebody else had cleared: `null` is "not asked", which is how the Pro row
666 // went blank on a device that holds Pro.
667 check('and the bootstrap it joined still finished its own licence read',
668 joined !== PARKED && joined.pro !== null,
669 joined === PARKED ? 'parked' : 'pro ' + JSON.stringify(joined.pro));
670
671 const errs = s.errs.filter(e =>
672 !/favicon|ERR_|Failed to load resource|401|402|404|409|413|426|502|Unauthorized|stubbed/.test(e)
673 && !/WebSocket connection to '[^']*\/api\/sync\/ws/.test(e));
674 check('no unexpected console errors', errs.length === 0, errs.slice(0, 3).join(' | '));
675} finally {
676 await s.close();
677}
678
679console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed');
680if (bad.length) { bad.forEach(b => console.log(' FAILED: ' + b)); process.exit(1); }