oxedyne/daimond/dev/verify_sessionrenew.mjs
25.4 KiB, 1 run
created by r2519314175:671, 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_sessionrenew.mjs — a gateway session that ends mid-sitting is taken |
| 2 | // again, without a reload; and when it cannot be, the user is told and the |
| 3 | // device stops hammering the door. |
| 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 the only thing that |
| 7 | // ever minted one was `DaimondGateway.bootstrap()`, called once per unlock. So |
| 8 | // an hour into a sitting every request became a 401, and every path swallowed |
| 9 | // it: the pull hid the chip, the push logged one console line, the wake channel |
| 10 | // reconnected on a backoff for ever, and the account dot went on being drawn |
| 11 | // from `state.authed`, which nothing ever cleared. One real account: session |
| 12 | // opened 07:27:28, gone at 08:27:30, then four hours and fifty minutes of |
| 13 | // refused wake upgrades at about two hundred and forty an hour, and seven |
| 14 | // pushes of the user's work discarded with nothing said anywhere. |
| 15 | // |
| 16 | // HOW HONESTLY THIS REPRODUCES IT. The session is ended SERVER-SIDE with a raw |
| 17 | // POST to /api/auth/logout — not `DaimondGateway.logout()`, which would tell the |
| 18 | // client. That leaves precisely the production state: a live page that believes |
| 19 | // it is signed in, holding a cookie that names nothing. Everything below runs |
| 20 | // against the REAL gateway on :9002 over that state. |
| 21 | // |
| 22 | // 1. The client does not notice by itself — asserted, so that a pass below |
| 23 | // cannot come from the session having quietly survived. |
| 24 | // 2. A push after the session died lands anyway: the version advances, the |
| 25 | // work is in the mailbox, and the PAGE WAS NEVER RELOADED (a marker set |
| 26 | // before the kill is still in the window). |
| 27 | // 3. One renewal, however many callers were refused at once. |
| 28 | // 4. When the renewal cannot work, the chip says so — held, with the reason on |
| 29 | // hover, and no dialog over the app — and the app stops claiming a session |
| 30 | // it does not have. |
| 31 | // 5. The wake channel STOPS. That is the free consequence of clearing |
| 32 | // `state.authed`, and it is measured rather than assumed: no /api/sync |
| 33 | // request and no new WebSocket after the teardown. |
| 34 | // 6. And it comes back on its own. A gateway that was down for a minute must |
| 35 | // not cost the tab the rest of the day, because every trigger that would |
| 36 | // retry is itself gated on there being a session. |
| 37 | // 7. The standing refusals keep their order: a parcel that will not fit is |
| 38 | // true whatever the session is doing, so it is still the thing said first. |
| 39 | import { open, chat } from './harness.mjs'; |
| 40 | import { makePagePro } from './pro.mjs'; |
| 41 | import { GW_PORT, GW_URL } from './ports.mjs'; |
| 42 | import path from 'node:path'; |
| 43 | import { fileURLToPath } from 'node:url'; |
| 44 | |
| 45 | const ok = [], bad = []; |
| 46 | const check = (name, pass, detail) => { |
| 47 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 48 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 49 | }; |
| 50 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 51 | |
| 52 | /// Is the gateway answering? |
| 53 | async function gatewayUp() { |
| 54 | try { |
| 55 | const r = await fetch(`${GW_URL}/api/health`, { signal: AbortSignal.timeout(2000) }); |
| 56 | return r.ok; |
| 57 | } catch (e) { return false; } |
| 58 | } |
| 59 | |
| 60 | if (!await gatewayUp()) { |
| 61 | console.log(`SKIP verify_sessionrenew — no gateway on :${GW_PORT}, this world's own ` |
| 62 | + `(build it: cd gateway && cargo build --release, then dev/devgw.sh)`); |
| 63 | process.exit(0); |
| 64 | } |
| 65 | |
| 66 | const s = await open({ name: 'sessrenew', signIn: true, connect: true }); |
| 67 | const { page } = s; |
| 68 | |
| 69 | /// Push, and wait until the parcel has actually LANDED. A single push() that |
| 70 | /// finds another in flight only reschedules, so awaiting it proves nothing. |
| 71 | async function pushLanded(pg, ms = 8000) { |
| 72 | return await pg.evaluate(async (limit) => { |
| 73 | const v0 = window.DaimondSync.state().version; |
| 74 | const t0 = Date.now(); |
| 75 | while (window.DaimondSync.state().version <= v0 && Date.now() - t0 < limit) { |
| 76 | await window.DaimondSync.push(); |
| 77 | await new Promise(r => setTimeout(r, 150)); |
| 78 | } |
| 79 | return window.DaimondSync.state().version > v0; |
| 80 | }, ms); |
| 81 | } |
| 82 | |
| 83 | /// End the session on the GATEWAY without telling the client — what an expiry |
| 84 | /// looks like from inside the page. |
| 85 | const killSession = () => page.evaluate(async () => { |
| 86 | await window.__realFetch('/api/auth/logout', { |
| 87 | method: 'POST', credentials: 'same-origin', headers: { 'x-daimond-api': '1' }, |
| 88 | }); |
| 89 | }); |
| 90 | |
| 91 | /// A genuine local change, so a push is not skipped as a no-op. |
| 92 | /// |
| 93 | /// A message-tombstone for an id that never existed. Transcripts moved into |
| 94 | /// IndexedDB, so the old trick of editing `daimond-chats` in localStorage no |
| 95 | /// longer changes anything `collectSync()` reads; the tombstone maps are still |
| 96 | /// localStorage and still travel in the parcel. Inert by construction — it names |
| 97 | /// nothing — so it moves the parcel and touches no work. |
| 98 | const bump = tag => page.evaluate((t) => { |
| 99 | const k = 'daimond-msgs-deleted'; |
| 100 | const m = JSON.parse(localStorage.getItem(k) || '{}'); |
| 101 | m['ghost-' + t] = Date.now(); |
| 102 | localStorage.setItem(k, JSON.stringify(m)); |
| 103 | return true; |
| 104 | }, tag); |
| 105 | |
| 106 | const chipOf = () => page.evaluate(() => { |
| 107 | const c = document.getElementById('sync-chip'); |
| 108 | if (!c) return null; |
| 109 | return { |
| 110 | state: c.dataset.state || '', |
| 111 | text: (c.querySelector('.stext') || {}).textContent || '', |
| 112 | title: c.title || '', |
| 113 | shown: c.style.display !== 'none', |
| 114 | }; |
| 115 | }); |
| 116 | |
| 117 | try { |
| 118 | await page.waitForFunction( |
| 119 | () => !!window.DaimondSync && !!window.DaimondCore && !!window.DaimondGateway |
| 120 | && DaimondGateway.state().authed, |
| 121 | null, { timeout: 12000 }, |
| 122 | ).catch(() => {}); |
| 123 | check('a gateway session exists to begin with', |
| 124 | await page.evaluate(() => DaimondGateway.state().authed)); |
| 125 | |
| 126 | // Sync is a Pro capability, so without the licence the gateway answers 402 |
| 127 | // and there is no 401 to measure. |
| 128 | const GWDIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'gateway'); |
| 129 | const lic = await makePagePro(page, GWDIR); |
| 130 | check('the account holds Pro, so sync may run at all', lic.pro === true, |
| 131 | `webhook ${lic.status}, pro=${lic.pro}`); |
| 132 | |
| 133 | // Something real to lose, and a first parcel in the mailbox. |
| 134 | await chat(s, 'Remember the codeword SESSMARK-1 for later.'); |
| 135 | await pushLanded(page); |
| 136 | |
| 137 | // Instruments: how many sessions were minted, how many /api/sync requests |
| 138 | // were made, and how many WebSockets were opened. `__realFetch` is kept back |
| 139 | // so this file can ask the gateway things without its own questions landing |
| 140 | // in its own counters. |
| 141 | // |
| 142 | // `__sessRenewPage` is what says the recovery below happened in THIS page. A |
| 143 | // reload would take it with it, and "recovered after a reload" is not a fix, |
| 144 | // it is the workaround the user already had. |
| 145 | await page.evaluate(() => { |
| 146 | window.__sessRenewPage = 'alive'; |
| 147 | window.__gw = { verify: 0, account: 0, sync: 0, socket: 0 }; |
| 148 | const real = window.fetch; |
| 149 | window.__realFetch = real; |
| 150 | window.fetch = function (u, o) { |
| 151 | const url = String((u && u.url) || u || ''); |
| 152 | if (url.indexOf('/api/auth/verify') !== -1) window.__gw.verify++; |
| 153 | if (url.indexOf('/api/account') !== -1) window.__gw.account++; |
| 154 | if (url.indexOf('/api/sync') !== -1) window.__gw.sync++; |
| 155 | return real.apply(this, arguments); |
| 156 | }; |
| 157 | const WS = window.WebSocket; |
| 158 | window.WebSocket = function (url, p) { window.__gw.socket++; return p ? new WS(url, p) : new WS(url); }; |
| 159 | window.WebSocket.prototype = WS.prototype; |
| 160 | }); |
| 161 | // The wake channel is a caller like any other, and it would mint a session of |
| 162 | // its own in the middle of the two measurements below. It is turned off for |
| 163 | // them and brought back at (5), which is where it is the thing under test. |
| 164 | await page.evaluate(() => window.DaimondSync.wakeVia('off')); |
| 165 | |
| 166 | // ── (1) The client does not notice ───────────────────────────────── |
| 167 | await killSession(); |
| 168 | const unaware = await page.evaluate(async () => { |
| 169 | const r = await window.__realFetch('/api/sync', { |
| 170 | credentials: 'same-origin', headers: { 'x-daimond-api': '1' }, |
| 171 | }); |
| 172 | return { status: r.status, authed: DaimondGateway.state().authed }; |
| 173 | }); |
| 174 | check('the session really is gone on the gateway (a bare /api/sync is 401)', |
| 175 | unaware.status === 401, 'status=' + unaware.status); |
| 176 | check('and the page still believes it is signed in — which is the bug', |
| 177 | unaware.authed === true); |
| 178 | |
| 179 | // ── (2) Sync recovers, in the same page ──────────────────────────── |
| 180 | const before = await page.evaluate(() => ({ |
| 181 | version: window.DaimondSync.state().version, verify: window.__gw.verify, |
| 182 | })); |
| 183 | // Real work, made AFTER the session died: a turn through the model, appended |
| 184 | // to a transcript and persisted exactly as a user's would be. A synthetic |
| 185 | // edit would prove the request recovered; this proves the WORK travelled. |
| 186 | const MARK2 = 'SESSMARK-' + '2'; |
| 187 | await chat(s, 'And remember the codeword ' + MARK2 + ' too.'); |
| 188 | // The property, tested so it cannot pass by a timing accident: the renewed |
| 189 | // push LANDS -- the mailbox moves past where the work started -- AND the client |
| 190 | // then reports that same version and HOLDS it. `pushLanded` used to sample the |
| 191 | // client's own cursor once, after `chat()` had already let the work push, so a |
| 192 | // correct engine that settled the version before the sample read as "no |
| 193 | // advance", and the lost-update regression passed only because its cursor |
| 194 | // happened to dip below that late sample and recover inside the window. Both |
| 195 | // are timing, not the property. |
| 196 | // |
| 197 | // So this measures the client cursor against the MAILBOX (a `__realFetch` GET, |
| 198 | // the authority the client cannot fake) across a settle window LONGER than the |
| 199 | // engine's ~5s catch-up throttle. The regression leaves the client a step |
| 200 | // behind the mailbox it just wrote to for those whole seconds -- caught here |
| 201 | // whether or not a later pull recovers it -- while a correct engine never lags. |
| 202 | // The dip is floored at the throttle, so no sampling rate or machine speed can |
| 203 | // slip past it. See www/js/sync.js `adoptVersion`. |
| 204 | const watch = await page.evaluate(async (start) => { |
| 205 | const readServer = async () => { |
| 206 | const r = await window.__realFetch('/api/sync', { |
| 207 | credentials: 'same-origin', headers: { 'x-daimond-api': '1' }, |
| 208 | }); |
| 209 | if (r.status !== 200) return -1; |
| 210 | const j = await r.json(); |
| 211 | return j.version | 0; |
| 212 | }; |
| 213 | // Phase 1: drive the push until the MAILBOX itself moves past the start. |
| 214 | const t0 = Date.now(); |
| 215 | let serverV = start, landed = false; |
| 216 | while (Date.now() - t0 < 12000) { |
| 217 | await window.DaimondSync.push(); |
| 218 | const v = await readServer(); |
| 219 | if (v > serverV) serverV = v; |
| 220 | if (serverV > start) { landed = true; break; } |
| 221 | await new Promise(r => setTimeout(r, 150)); |
| 222 | } |
| 223 | // Phase 2: over a window that outlasts the catch-up throttle, and with no |
| 224 | // push of our own to move it, the client cursor must equal the mailbox at |
| 225 | // every sample. Any moment a step behind is the lost update. |
| 226 | let lagged = false, maxLag = 0, samples = 0; |
| 227 | let clientFinal = window.DaimondSync.state().version; |
| 228 | const settleEnd = Date.now() + 8000; |
| 229 | while (Date.now() < settleEnd) { |
| 230 | const cv = window.DaimondSync.state().version; |
| 231 | const sv = await readServer(); |
| 232 | if (sv > serverV) serverV = sv; |
| 233 | clientFinal = cv; |
| 234 | samples++; |
| 235 | if (serverV > start && cv < serverV) { lagged = true; maxLag = Math.max(maxLag, serverV - cv); } |
| 236 | await new Promise(r => setTimeout(r, 200)); |
| 237 | } |
| 238 | return { landed, serverV, clientFinal, lagged, maxLag, samples, start }; |
| 239 | }, before.version); |
| 240 | const after = await page.evaluate(async () => { |
| 241 | const r = await window.__realFetch('/api/sync', { |
| 242 | credentials: 'same-origin', headers: { 'x-daimond-api': '1' }, |
| 243 | }); |
| 244 | const j = r.status === 200 ? await r.json() : {}; |
| 245 | let plain = ''; |
| 246 | try { plain = await window.DaimondIdentity.unwrap(j.blob); } catch (e) { plain = ''; } |
| 247 | return { |
| 248 | version: j.version | 0, |
| 249 | carries: plain, |
| 250 | // `state.authed` is what the app SAYS, and it is what was lying before |
| 251 | // the fix -- so it is not evidence on its own. The status of a request |
| 252 | // the gateway actually served is. |
| 253 | authed: DaimondGateway.state().authed, |
| 254 | served: r.status, |
| 255 | verify: window.__gw.verify, |
| 256 | pageSame: window.__sessRenewPage === 'alive', |
| 257 | }; |
| 258 | }); |
| 259 | check('a push after the session died lands — the mailbox advances past where the work started', |
| 260 | watch.landed && watch.serverV > watch.start && after.version > before.version, |
| 261 | 'mailbox ' + watch.start + ' -> ' + watch.serverV); |
| 262 | check('and the client holds that version, never a step behind the mailbox it just wrote — no lost update', |
| 263 | watch.lagged === false && watch.clientFinal === watch.serverV, |
| 264 | watch.lagged |
| 265 | ? 'client fell ' + watch.maxLag + ' behind the mailbox during the settle window (lost update)' |
| 266 | : 'client=' + watch.clientFinal + ' mailbox=' + watch.serverV + ' held across ' + watch.samples + ' samples'); |
| 267 | check('and the mailbox really holds the work that was pushed over the dead session', |
| 268 | after.carries.indexOf(MARK2) !== -1, |
| 269 | after.carries ? 'blob opened, ' + after.carries.length + ' bytes' : 'blob did not open'); |
| 270 | check('the client is signed in again, without anybody asking it to be', |
| 271 | after.authed === true && after.served === 200, |
| 272 | 'authed=' + after.authed + ' /api/sync=' + after.served); |
| 273 | check('one session was taken to do it, not one per refused request', |
| 274 | after.verify - before.verify === 1, (after.verify - before.verify) + ' /api/auth/verify calls'); |
| 275 | check('and it was the SAME page throughout — no reload', |
| 276 | after.pageSame === true); |
| 277 | |
| 278 | // ── (3) One renewal, however many callers are refused at once ────── |
| 279 | await killSession(); |
| 280 | const flight = await page.evaluate(async () => { |
| 281 | const v0 = window.__gw.verify; |
| 282 | // Four independent gateway reads, all of which believe there is a session |
| 283 | // and all of which are about to be told there is not. |
| 284 | await Promise.all([ |
| 285 | DaimondGateway.refreshBalance(), |
| 286 | DaimondGateway.refreshLicence(), |
| 287 | DaimondGateway.ledger(), |
| 288 | DaimondGateway.autoReload(), |
| 289 | ]); |
| 290 | const r = await window.__realFetch('/api/sync', { |
| 291 | credentials: 'same-origin', headers: { 'x-daimond-api': '1' }, |
| 292 | }); |
| 293 | return { minted: window.__gw.verify - v0, authed: DaimondGateway.state().authed, served: r.status }; |
| 294 | }); |
| 295 | check('four callers refused in the same moment mint ONE session between them', |
| 296 | flight.minted === 1, flight.minted + ' minted'); |
| 297 | check('and all four end up on a session the gateway will actually serve', |
| 298 | flight.authed === true && flight.served === 200, |
| 299 | 'authed=' + flight.authed + ' /api/sync=' + flight.served); |
| 300 | |
| 301 | // ── (4) When it cannot be renewed, the user is told ──────────────── |
| 302 | // The session is ended AND the way back is blocked, which is what a gateway |
| 303 | // that is down, a revoked binding or a network that has gone all look like. |
| 304 | // The engine must not go quiet about it. |
| 305 | // |
| 306 | // The wake channel is brought back first, because (5) measures it going away |
| 307 | // and a channel that was never open proves nothing. |
| 308 | await page.evaluate(() => window.DaimondSync.wakeVia('')); |
| 309 | await page.waitForFunction(() => window.DaimondSync.wake().open === true, |
| 310 | null, { timeout: 20000 }).catch(() => {}); |
| 311 | const chanUp = await page.evaluate(() => window.DaimondSync.wake()); |
| 312 | check('the wake channel is open before the session is taken away', |
| 313 | chanUp.open === true, JSON.stringify(chanUp)); |
| 314 | |
| 315 | await killSession(); |
| 316 | const told = await page.evaluate(async () => { |
| 317 | const gated = window.fetch; |
| 318 | // Only the authentication is blocked; /api/sync answers its honest 401. |
| 319 | window.fetch = function (u, o) { |
| 320 | const url = String((u && u.url) || u || ''); |
| 321 | if (url.indexOf('/api/auth/') !== -1 || url.indexOf('/api/account') !== -1) { |
| 322 | return Promise.reject(new TypeError('blocked for the test')); |
| 323 | } |
| 324 | return gated.apply(this, arguments); |
| 325 | }; |
| 326 | window.__unblock = function () { window.fetch = gated; }; |
| 327 | await window.DaimondSync.pull(); |
| 328 | await new Promise(r => setTimeout(r, 200)); |
| 329 | const c = document.getElementById('sync-chip'); |
| 330 | const modals = [...document.querySelectorAll('.modal')] |
| 331 | .filter(m => getComputedStyle(m).display !== 'none') |
| 332 | .map(m => (m.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 60)); |
| 333 | // A bland title, read back out of the same attribute by the same path, so the |
| 334 | // hover check below can be shown going red. Set and restored with no await |
| 335 | // between, so no repaint of the chip can land in the gap and nothing else in |
| 336 | // this run can see it. |
| 337 | let blinded = null; |
| 338 | if (c) { |
| 339 | const was = c.title; |
| 340 | c.title = 'Sync paused.'; |
| 341 | blinded = document.getElementById('sync-chip').title; |
| 342 | c.title = was; |
| 343 | } |
| 344 | return { |
| 345 | chip: c ? { state: c.dataset.state || '', text: (c.querySelector('.stext') || {}).textContent || '', |
| 346 | title: c.title || '', shown: c.style.display !== 'none' } : null, |
| 347 | blinded: blinded, |
| 348 | api: window.DaimondSync.state(), |
| 349 | authed: DaimondGateway.state().authed, |
| 350 | modals: modals, |
| 351 | }; |
| 352 | }); |
| 353 | check('a session that cannot be renewed puts a held, stalled state on the chip', |
| 354 | !!(told.chip && told.chip.shown && told.chip.state === 'stalled'), |
| 355 | JSON.stringify(told.chip)); |
| 356 | // ── What the hover has to carry ──────────────────────────────────── |
| 357 | // |
| 358 | // A chip that says only "Sync paused" tells a person their work has stopped and |
| 359 | // nothing else. The hover owes them two facts: WHY it stopped — this device has no |
| 360 | // session — and WHAT HAPPENS NEXT, so that they know whether to sit still or act. |
| 361 | // |
| 362 | // Those two facts are the property; the sentence is not. This matched `signed in` |
| 363 | // literally and went red on 2026-08-11 when a concision pass turned "no longer signed |
| 364 | // in to" into "signed out of" — the same two facts, better said. Each fact is now |
| 365 | // asked for in any of the ways English states it. |
| 366 | const HOVER = { |
| 367 | 'that this device has no session': |
| 368 | /signed[- ]out|not signed in|no longer signed in|could not sign in|cannot sign in|signed in again/i, |
| 369 | 'what happens next': |
| 370 | /resume|comes? back|carry on|carries on|will sign in|starts? again|picks? up again|as soon as/i, |
| 371 | }; |
| 372 | const hoverSays = (s) => Object.entries(HOVER).filter(([, re]) => !re.test(String(s || ''))).map(([k]) => k); |
| 373 | const hoverGaps = hoverSays(told.chip && told.chip.title); |
| 374 | check('and the hover says the device is not signed in, and what happens next', |
| 375 | !!(told.chip && told.chip.title) && hoverGaps.length === 0, |
| 376 | hoverGaps.length ? 'the hover never says: ' + hoverGaps.join('; ') + ' — ' |
| 377 | + (told.chip && told.chip.title || '').replace(/\n/g, ' | ').slice(0, 160) |
| 378 | : (told.chip && told.chip.title || '').replace(/\n/g, ' | ').slice(0, 160)); |
| 379 | check('and that is a check that can fail — a hover saying only “paused” is caught', |
| 380 | told.blinded !== null && hoverSays(told.blinded).length === Object.keys(HOVER).length |
| 381 | && told.chip.title !== told.blinded, |
| 382 | told.blinded === null ? 'there was no chip to blind' |
| 383 | : '“' + told.blinded + '” is missing both facts'); |
| 384 | check('the engine reports it through its own surface, named', |
| 385 | !!(told.api && told.api.stalled === true && told.api.stalledWhy === 'signed_out' |
| 386 | && told.api.sessionGone === true), |
| 387 | JSON.stringify({ stalled: told.api.stalled, why: told.api.stalledWhy })); |
| 388 | check('the app stops claiming to be connected', told.authed === false); |
| 389 | check('and nothing was raised over the app — nobody asked for the round that failed', |
| 390 | told.modals.length === 0, told.modals.join(' | ')); |
| 391 | |
| 392 | // ── (5) The wake channel stops hammering ─────────────────────────── |
| 393 | // `ready()` is false the moment `state.authed` is cleared, so `wakeWanted()` |
| 394 | // is false and the supervisor tears the channel down on its next tick. That |
| 395 | // is the difference between a device that is quiet and one that is refused an |
| 396 | // upgrade every fifteen seconds for five hours. |
| 397 | await page.waitForFunction(() => window.DaimondSync.wake().open === false, |
| 398 | null, { timeout: 25000 }).catch(() => {}); |
| 399 | const quiet0 = await page.evaluate(() => ({ |
| 400 | wake: window.DaimondSync.wake(), sync: window.__gw.sync, socket: window.__gw.socket, |
| 401 | })); |
| 402 | check('the wake channel that WAS open is shut once there is no session', |
| 403 | chanUp.open === true && quiet0.wake.open === false, JSON.stringify(quiet0.wake)); |
| 404 | await sleep(8000); |
| 405 | const quiet1 = await page.evaluate(() => ({ |
| 406 | wake: window.DaimondSync.wake(), sync: window.__gw.sync, |
| 407 | socket: window.__gw.socket, account: window.__gw.account, |
| 408 | })); |
| 409 | check('and it stays shut: no /api/sync request in the eight seconds after', |
| 410 | quiet1.sync === quiet0.sync, quiet0.sync + ' -> ' + quiet1.sync); |
| 411 | check('and no further WebSocket upgrade is attempted', |
| 412 | quiet1.socket === quiet0.socket, quiet0.socket + ' -> ' + quiet1.socket); |
| 413 | check('the standing retry that will bring it back is bounded, not a spin', |
| 414 | quiet1.account <= 8, quiet1.account + ' /api/account attempts in all'); |
| 415 | |
| 416 | // ── (6) And it comes back on its own ─────────────────────────────── |
| 417 | // Nothing else in the app would ever ask again: every trigger is gated on |
| 418 | // there being a session, so without a retry of its own the tab is signed out |
| 419 | // until somebody reloads it. |
| 420 | await page.evaluate(() => { window.__unblock(); }); |
| 421 | // Asked of the GATEWAY, not of `state.authed`: the flag was the thing lying |
| 422 | // before the fix, so a live session is the only honest evidence. |
| 423 | let back = null; |
| 424 | for (let i = 0; i < 45 && !(back && back.authed && back.served === 200); i++) { |
| 425 | await sleep(2000); |
| 426 | back = await page.evaluate(async () => { |
| 427 | const r = await window.__realFetch('/api/sync', { |
| 428 | credentials: 'same-origin', headers: { 'x-daimond-api': '1' }, |
| 429 | }); |
| 430 | return { authed: DaimondGateway.state().authed, served: r.status }; |
| 431 | }); |
| 432 | } |
| 433 | check('the tab signs itself back in once the way is open again, unprompted', |
| 434 | !!(back && back.authed === true && back.served === 200), JSON.stringify(back)); |
| 435 | await bump('SESSMARK-3'); |
| 436 | const moved = await pushLanded(page, 15000); |
| 437 | const healed = await page.evaluate(() => ({ |
| 438 | api: window.DaimondSync.state(), pageSame: window.__sessRenewPage === 'alive', |
| 439 | })); |
| 440 | check('and pushes again once it has', moved === true, 'version ' + healed.api.version); |
| 441 | check('the standing refusal is cleared, not left over a working engine', |
| 442 | healed.api.sessionGone === false && healed.api.stalledWhy !== 'signed_out', |
| 443 | JSON.stringify({ why: healed.api.stalledWhy })); |
| 444 | check('still the same page — the whole recovery needed no reload', |
| 445 | healed.pageSame === true); |
| 446 | const chipEnd = await chipOf(); |
| 447 | check('the chip does not sit on a stall the engine has cleared', |
| 448 | !!(chipEnd && (chipEnd.state === 'synced' || !chipEnd.shown)), JSON.stringify(chipEnd)); |
| 449 | |
| 450 | // ── (7) The standing refusals keep their order ───────────────────── |
| 451 | // A parcel over the gateway's ceiling is a state of the parcel, true whatever |
| 452 | // the session is doing, and a person cannot act on it by signing in. So it |
| 453 | // stays on the chip when the session goes as well. The 413 is stubbed: the |
| 454 | // real ceiling is 32 MB and building that to test a status chip would cost |
| 455 | // more than the behaviour it proves. |
| 456 | await page.evaluate(() => window.DaimondSync.wakeVia('off')); |
| 457 | await bump('rank-1'); |
| 458 | const ranked = await page.evaluate(async () => { |
| 459 | const gated = window.fetch; |
| 460 | window.fetch = function (u, o) { |
| 461 | const url = String((u && u.url) || u || ''); |
| 462 | if (url.indexOf('/api/sync') !== -1 && o && o.method === 'POST') { |
| 463 | return Promise.resolve(new Response(JSON.stringify({ ok: false, error: 'too large' }), |
| 464 | { status: 413, headers: { 'content-type': 'application/json' } })); |
| 465 | } |
| 466 | return gated.apply(this, arguments); |
| 467 | }; |
| 468 | await window.DaimondSync.push(); |
| 469 | const c = () => { |
| 470 | const e = document.getElementById('sync-chip'); |
| 471 | return e ? { state: e.dataset.state || '', title: e.title || '', shown: e.style.display !== 'none' } : null; |
| 472 | }; |
| 473 | const big = c(); |
| 474 | // Now take the session away underneath it, and block the way back. |
| 475 | await window.__realFetch('/api/auth/logout', { |
| 476 | method: 'POST', credentials: 'same-origin', headers: { 'x-daimond-api': '1' }, |
| 477 | }); |
| 478 | window.fetch = function (u, o) { |
| 479 | const url = String((u && u.url) || u || ''); |
| 480 | if (url.indexOf('/api/auth/') !== -1 || url.indexOf('/api/account') !== -1) { |
| 481 | return Promise.reject(new TypeError('blocked for the test')); |
| 482 | } |
| 483 | return gated.apply(this, arguments); |
| 484 | }; |
| 485 | await window.DaimondSync.pull(); |
| 486 | await new Promise(r => setTimeout(r, 200)); |
| 487 | const both = c(); |
| 488 | const api = window.DaimondSync.state(); |
| 489 | window.fetch = gated; |
| 490 | return { big, both, api }; |
| 491 | }); |
| 492 | check('a 413 stalls the chip on its own', !!(ranked.big && ranked.big.state === 'stalled' |
| 493 | && /too large/i.test(ranked.big.title)), JSON.stringify(ranked.big).slice(0, 120)); |
| 494 | check('and a session going as well does not push the parcel refusal off the chip', |
| 495 | !!(ranked.both && /too large/i.test(ranked.both.title) && !/signed in/i.test(ranked.both.title)), |
| 496 | (ranked.both && ranked.both.title || '').replace(/\n/g, ' | ').slice(0, 140)); |
| 497 | check('the engine names the same one first', ranked.api.stalledWhy === 'too_big', |
| 498 | ranked.api.stalledWhy + ' (sessionGone=' + ranked.api.sessionGone + ')'); |
| 499 | check('while still knowing the session is gone underneath it', |
| 500 | ranked.api.sessionGone === true); |
| 501 | |
| 502 | // Console noise from a deliberately refused round is expected; anything else |
| 503 | // is not. |
| 504 | const errs = s.errs.filter(e => |
| 505 | !/favicon|ERR_|Failed to load resource|401|402|409|413|426|502|Unauthorized|blocked for the test/.test(e) |
| 506 | && !/WebSocket connection to '[^']*\/api\/sync\/ws/.test(e)); |
| 507 | check('no unexpected console errors through the whole recovery', errs.length === 0, |
| 508 | errs.slice(0, 3).join(' | ')); |
| 509 | } finally { |
| 510 | await s.close(); |
| 511 | } |
| 512 | |
| 513 | console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed'); |
| 514 | if (bad.length) { bad.forEach(b => console.log(' FAILED: ' + b)); process.exit(1); } |