oxedyne/daimond/dev/verify_chunkgw.mjs
29.5 KiB, 1 run
created by r2519314175:291, 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_chunkgw.mjs — the chunk transport survives a lapsed session, and a |
| 2 | // deletion the gateway will not carry out is NOTICED rather than swallowed. |
| 3 | // |
| 4 | // TWO HALVES, ONE FILE, because they are the same file's two ways of going |
| 5 | // quiet. |
| 6 | // |
| 7 | // (A) THE 401. chunks.js was the sixth copy of the gateway `fetch` wrapper and |
| 8 | // the only one with no answer to a 401 at all. An hour into a sitting the |
| 9 | // gateway's session is gone and `missing()` reports EVERY address as missing — |
| 10 | // so the next sync re-encrypts and re-uploads the whole corpus — `putChunks()` |
| 11 | // throws, and the commit that lets the gateway sweep never lands. Every check |
| 12 | // below asserts on the REQUEST TRACE and on observable work (were bytes |
| 13 | // re-uploaded? did the sweep happen?), never on `state.authed`, which is the |
| 14 | // flag that was lying. The session is ended SERVER-SIDE with a raw POST to |
| 15 | // /api/auth/logout, which leaves precisely the production state: a live page |
| 16 | // holding a cookie that names nothing. |
| 17 | // |
| 18 | // (B) THE SWEEP FLOOR. `sweep_chunks` used to delete whatever a commit did not |
| 19 | // name, on one request, and a client bug did exactly that to a real account. It |
| 20 | // now refuses any sweep over half the chunks an account holds: nothing is |
| 21 | // deleted, the commit still succeeds, and the reply carries `sweep_held_back`, |
| 22 | // `sweep_held` and a `sweep_token` that the identical commit may quote to carry |
| 23 | // the deletion out. No client sent the token, so large deletions were simply |
| 24 | // never collected — silently, and the storage ceilings are computed from the |
| 25 | // committed index rather than from chunks held, so held-back chunks are charged |
| 26 | // to no cap at all. |
| 27 | // |
| 28 | // The checks here are written so that a deletion which silently does not happen |
| 29 | // CANNOT pass: each one asserts both what the gateway still holds and what the |
| 30 | // client says about it, and the "names nothing" case exists precisely to be the |
| 31 | // deletion that must not be collected and must be reported. |
| 32 | // |
| 33 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and a daimond_gateway on :9002; it |
| 34 | // starts its own if none is up, and stops what it started. |
| 35 | import { spawn } from 'node:child_process'; |
| 36 | import fs from 'node:fs'; |
| 37 | import path from 'node:path'; |
| 38 | import { fileURLToPath } from 'node:url'; |
| 39 | import { requireFreshGateway, GWBIN, procLog, GWCWD } from './gwbin.mjs'; |
| 40 | import { open } from './harness.mjs'; |
| 41 | import { makePagePro } from './pro.mjs'; |
| 42 | import { GW_PORT, GW_URL } from './ports.mjs'; |
| 43 | |
| 44 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 45 | const GWDIR = path.resolve(__dirname, '..', 'gateway'); |
| 46 | const SRC = path.resolve(__dirname, '..', 'www', 'js', 'chunks.js'); |
| 47 | /// What the gateway says while this runs. Half of what is measured below is a |
| 48 | /// sweep the gateway declines, and the reason it declined is logged there and |
| 49 | /// nowhere else. Silent when this run reuses a gateway it did not start. |
| 50 | const GW_LOG = procLog('verify_chunkgw'); |
| 51 | |
| 52 | const ok = [], bad = []; |
| 53 | const check = (name, pass, detail) => { |
| 54 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 55 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 56 | }; |
| 57 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 58 | |
| 59 | async function waitFor(fn, ms = 20000, gap = 250) { |
| 60 | const t0 = Date.now(); |
| 61 | while (Date.now() - t0 < ms) { |
| 62 | try { if (await fn()) return true; } catch (e) { /* keep waiting */ } |
| 63 | await sleep(gap); |
| 64 | } |
| 65 | return false; |
| 66 | } |
| 67 | |
| 68 | requireFreshGateway(); |
| 69 | |
| 70 | // The tree's binary, and only that one. |
| 71 | // |
| 72 | // This file used to pick whichever of `gateway/target/release/` and a slot's |
| 73 | // `~/.cache/cargo-targets/gateway_target/release/` was newer, on the grounds |
| 74 | // that the tree copy might be stale -- which it might, since agents build with |
| 75 | // CARGO_TARGET_DIR pointed elsewhere. That was a workaround written before |
| 76 | // anything checked, and it is now the wrong half of a contradiction: every |
| 77 | // other verifier measures the tree copy, `requireFreshGateway` refuses when it |
| 78 | // is older than its sources, and `run_all.sh` builds it once before the run. |
| 79 | // Preferring a different binary here would mean one verifier in a gate |
| 80 | // measuring a build none of the others saw -- exactly the failure the guard was |
| 81 | // written to prevent, and harder to spot because it only shows up as a |
| 82 | // disagreement between two green runs. |
| 83 | // |
| 84 | // So the preference is gone. The refusal above is what handles a stale tree |
| 85 | // copy now, and it says how to fix it. |
| 86 | let gw = null; |
| 87 | const alreadyUp = await waitFor(async () => (await fetch(`${GW_URL}/api/health`)).ok, 800, 200); |
| 88 | if (alreadyUp) { |
| 89 | console.log(` ok using the gateway already on :${GW_PORT}`); |
| 90 | } else { |
| 91 | gw = spawn(GWBIN, [], { cwd: GWCWD, env: { ...process.env, APP_MODE: 'sandbox' }, stdio: GW_LOG.stdio }); |
| 92 | check('gateway starts', await waitFor(async () => (await fetch(`${GW_URL}/api/health`)).ok), GWBIN); |
| 93 | } |
| 94 | |
| 95 | const s = await open({ name: 'chunkgw', signIn: true, connect: false }); |
| 96 | const { page } = s; |
| 97 | |
| 98 | try { |
| 99 | await page.waitForFunction( |
| 100 | () => !!window.DaimondChunks && !!window.DaimondGateway && !!window.DaimondCore |
| 101 | && !!window.DaimondCloud && !!window.DaimondSync && DaimondGateway.state().authed, |
| 102 | null, { timeout: 15000 }, |
| 103 | ).catch(() => {}); |
| 104 | check('the chunk module and an authed session are live', |
| 105 | await page.evaluate(() => !!window.DaimondChunks && DaimondGateway.state().authed)); |
| 106 | |
| 107 | const lic = await makePagePro(page, GWDIR, GW_URL); |
| 108 | check('the account holds Pro, so the chunk store will accept an upload', |
| 109 | lic.pro === true, `webhook ${lic.status}, pro=${lic.pro}`); |
| 110 | |
| 111 | // ── The instrument ───────────────────────────────────────────────── |
| 112 | // Every /api/ request, with the chunk `op` pulled out of the body, so a |
| 113 | // commit can be told from a put in a trace where the URL is the same for |
| 114 | // both. `__realFetch` is kept back so this file's own questions never land |
| 115 | // in its own trace. |
| 116 | await page.evaluate(() => { |
| 117 | window.__seen = []; |
| 118 | const real = window.fetch; |
| 119 | window.__realFetch = real; |
| 120 | window.fetch = async function (u, o) { |
| 121 | const url = String((u && u.url) || u || ''); |
| 122 | const method = (o && o.method) || (u && u.method) || 'GET'; |
| 123 | let op = '', body = null; |
| 124 | try { body = JSON.parse((o && o.body) || 'null'); op = (body && body.op) || ''; } catch (e) {} |
| 125 | const r = await real.apply(this, arguments); |
| 126 | if (url.indexOf('/api/') !== -1) window.__seen.push({ url, method, status: r.status, op, body }); |
| 127 | return r; |
| 128 | }; |
| 129 | }); |
| 130 | // The wake channel would pull in the middle of a measurement. |
| 131 | await page.evaluate(() => { try { window.DaimondSync.wakeVia('off'); } catch (e) {} }); |
| 132 | |
| 133 | const trace = () => page.evaluate(() => window.__seen.map(e => ({ ...e, body: undefined }))); |
| 134 | const bodies = () => page.evaluate(() => window.__seen.map(e => e.body)); |
| 135 | const clear = () => page.evaluate(() => { window.__seen.length = 0; }); |
| 136 | const killSess = () => page.evaluate(() => window.__realFetch('/api/auth/logout', { |
| 137 | method: 'POST', credentials: 'same-origin', headers: { 'x-daimond-api': '1' }, |
| 138 | })); |
| 139 | /// The statuses seen for one chunk op, in order. |
| 140 | const ops = (tr, op) => tr.filter(e => e.url.indexOf('/api/chunk') !== -1 && e.op === op).map(e => e.status); |
| 141 | /// Refused, then served: the shape a wired caller leaves behind. A single |
| 142 | /// 200 is NOT a pass — it would mean the request never met the expiry. |
| 143 | const retried = st => st.length === 2 && st[0] === 401 && st[1] !== 401; |
| 144 | |
| 145 | // A settled sync, so the engine's own idle pushes are no-ops for the rest of |
| 146 | // the run and cannot commit an index over the top of a measurement. |
| 147 | await page.evaluate(() => window.DaimondSync.push()); |
| 148 | await sleep(400); |
| 149 | |
| 150 | // ── Page-side helpers ────────────────────────────────────────────── |
| 151 | // Chunks are seeded through the REAL upload path — sealed with the account |
| 152 | // key, addressed by the SHA-256 of the ciphertext, verified by the gateway — |
| 153 | // so what is committed against below is a genuine account store. |
| 154 | await page.evaluate(() => { |
| 155 | window.__seed = async function (n) { |
| 156 | const out = []; |
| 157 | for (let i = 0; i < n; i++) { |
| 158 | const ct = await DaimondIdentity.wrapBytes( |
| 159 | new TextEncoder().encode('seed-' + i + '-' + Math.random())); |
| 160 | out.push({ |
| 161 | addr: await DaimondChunks._sha256Hex(ct), |
| 162 | blob: DaimondChunks._b64urlEncode(ct), |
| 163 | size: ct.length, |
| 164 | }); |
| 165 | } |
| 166 | const r = await fetch('/api/chunk', { |
| 167 | method: 'POST', credentials: 'same-origin', |
| 168 | headers: { 'content-type': 'application/json', 'x-daimond-api': '1' }, |
| 169 | body: JSON.stringify({ op: 'put', chunks: out.map(c => ({ addr: c.addr, blob: c.blob })) }), |
| 170 | }); |
| 171 | const j = await r.json(); |
| 172 | if (!j || !j.ok) throw new Error('seed put failed: ' + r.status); |
| 173 | return out; |
| 174 | }; |
| 175 | /// A manifests map naming `chunks`, one file per chunk — the shape |
| 176 | /// `DaimondCloud.index()` hands `commit`. |
| 177 | window.__manifests = function (chunks) { |
| 178 | const m = {}; |
| 179 | chunks.forEach((c, i) => { |
| 180 | m['seed-' + i + '.bin'] = { v: 2, size: c.size, key: 'k' + i, chunks: [{ addr: c.addr, size: c.size }] }; |
| 181 | }); |
| 182 | return m; |
| 183 | }; |
| 184 | /// Which of these addresses the gateway still holds. |
| 185 | window.__held = async function (addrs) { |
| 186 | const r = await fetch('/api/chunk', { |
| 187 | method: 'POST', credentials: 'same-origin', |
| 188 | headers: { 'content-type': 'application/json', 'x-daimond-api': '1' }, |
| 189 | body: JSON.stringify({ op: 'have', addrs }), |
| 190 | }); |
| 191 | const j = await r.json(); |
| 192 | const gone = new Set(j.missing || []); |
| 193 | return addrs.filter(a => !gone.has(a)); |
| 194 | }; |
| 195 | /// The account's current sync blob version — what a commit must name. |
| 196 | window.__version = async function () { |
| 197 | const r = await fetch('/api/sync', { credentials: 'same-origin', headers: { 'x-daimond-api': '1' } }); |
| 198 | const j = await r.json(); |
| 199 | return j.version | 0; |
| 200 | }; |
| 201 | /// Wipe the account's chunk store, so each case starts from a known one. |
| 202 | /// Through the commit path with an explicit confirmation, which is the |
| 203 | /// only way to empty it — and it is the client's own escape hatch, so |
| 204 | /// the reset exercises that too. |
| 205 | window.__wipe = async function () { |
| 206 | const v = await window.__version(); |
| 207 | await DaimondChunks.commit({}, v, null); |
| 208 | if (DaimondChunks.state().standing) await DaimondChunks.confirmHeldSweep(); |
| 209 | return DaimondChunks.state(); |
| 210 | }; |
| 211 | }); |
| 212 | |
| 213 | // ═══ (A) A lapsed session ══════════════════════════════════════════ |
| 214 | |
| 215 | await killSess(); |
| 216 | const unaware = await page.evaluate(async () => { |
| 217 | const r = await window.__realFetch('/api/chunk', { |
| 218 | method: 'POST', credentials: 'same-origin', |
| 219 | headers: { 'content-type': 'application/json', 'x-daimond-api': '1' }, |
| 220 | body: JSON.stringify({ op: 'have', addrs: [] }), |
| 221 | }); |
| 222 | return { status: r.status, authed: DaimondGateway.state().authed }; |
| 223 | }); |
| 224 | check('the session really is gone on the gateway (a bare /api/chunk is 401)', |
| 225 | unaware.status === 401, 'status=' + unaware.status); |
| 226 | check('and the page still believes it is signed in — which is the bug', |
| 227 | unaware.authed === true); |
| 228 | |
| 229 | // (A1) `have`, and the harm a refused one does. The file is offloaded once |
| 230 | // with a live session, which fills the chunk map; the session is then killed |
| 231 | // and the SAME file offloaded again. With the 401 answered, every chunk is |
| 232 | // recognised and NOTHING is uploaded. Unanswered, `missing()` reports every |
| 233 | // address as missing and the whole file is re-encrypted and sent again — |
| 234 | // which is the observable, not a flag. |
| 235 | await page.evaluate(() => DaimondGateway.bootstrap()); // a live session to seed from. |
| 236 | const f1 = await page.evaluate(async () => { |
| 237 | const body = new Uint8Array(600 * 1024).map((_, i) => (i * 7) % 251); |
| 238 | window.__file = new File([body], 'big.bin'); |
| 239 | try { |
| 240 | const m = await DaimondChunks.offloadFile('big.bin', window.__file); |
| 241 | return { chunks: m.chunks.length }; |
| 242 | } catch (e) { return { chunks: 0, err: (e && e.message) || String(e) }; } |
| 243 | }); |
| 244 | check('a 600 KiB file offloads into several chunks', f1.chunks >= 2, |
| 245 | f1.chunks + ' chunks' + (f1.err ? ' — ' + f1.err : '')); |
| 246 | |
| 247 | await killSess(); |
| 248 | await clear(); |
| 249 | const f2 = await page.evaluate(() => DaimondChunks.offloadFile('big.bin', window.__file) |
| 250 | .catch(e => ({ chunks: [], err: (e && e.message) || String(e) }))); |
| 251 | const trA1 = await trace(); |
| 252 | check('have: the refused query is re-authenticated and asked again', |
| 253 | retried(ops(trA1, 'have')), 'statuses ' + JSON.stringify(ops(trA1, 'have'))); |
| 254 | check('have: and NOTHING is re-uploaded — the corpus is not resent over an expiry', |
| 255 | ops(trA1, 'put').length === 0, ops(trA1, 'put').length + ' put requests'); |
| 256 | check('have: the same manifest comes back, chunk for chunk', |
| 257 | f2.chunks.length === f1.chunks); |
| 258 | |
| 259 | // (A2) `put`: a file the store has never seen, offloaded over a dead |
| 260 | // session. It used to throw `chunk put failed: 401`. |
| 261 | await killSess(); |
| 262 | await clear(); |
| 263 | const f3 = await page.evaluate(async () => { |
| 264 | const body = new Uint8Array(400 * 1024).map((_, i) => (i * 13 + 5) % 251); |
| 265 | try { |
| 266 | const m = await DaimondChunks.offloadFile('other.bin', new File([body], 'other.bin')); |
| 267 | return { ok: true, chunks: m.chunks.length }; |
| 268 | } catch (e) { return { ok: false, err: (e && e.message) || String(e) }; } |
| 269 | }); |
| 270 | const trA2 = await trace(); |
| 271 | check('put: the refused upload is re-authenticated and sent again', |
| 272 | retried(ops(trA2, 'put').slice(0, 2)), 'statuses ' + JSON.stringify(ops(trA2, 'put'))); |
| 273 | check('put: and the offload finishes rather than throwing "chunk put failed: 401"', |
| 274 | f3.ok === true, f3.ok ? '' : f3.err); |
| 275 | |
| 276 | // (A3) `commit`: the request whose loss means the gateway never sweeps. |
| 277 | // Set up on a live session — the helpers below are raw `fetch` on purpose, |
| 278 | // so they measure rather than repair — and kill it only once the account's |
| 279 | // store is in a known state. |
| 280 | await page.evaluate(() => DaimondGateway.bootstrap()); |
| 281 | await page.evaluate(() => window.__wipe()); |
| 282 | await page.evaluate(() => window.__seed(4).then(c => (window.__c = c))); |
| 283 | await page.evaluate(async () => { |
| 284 | const v = await window.__version(); |
| 285 | await DaimondChunks.commit(window.__manifests(window.__c), v, null); |
| 286 | }); |
| 287 | // The version is read while there is still a session to read it with. It is |
| 288 | // a plain GET on /api/sync and a lapsed one answers 401, which would have |
| 289 | // this commit name version 0 and be refused as STALE rather than for the |
| 290 | // reason under test — a 409 that would look like a pass on a careless check. |
| 291 | await page.evaluate(async () => { window.__v = await window.__version(); }); |
| 292 | await killSess(); |
| 293 | await clear(); |
| 294 | const commitA = await page.evaluate(() => |
| 295 | // Three of four live: one goes, which is what an edit looks like and is |
| 296 | // well under the floor, so the sweep must simply happen. |
| 297 | DaimondChunks.commit(window.__manifests(window.__c.slice(0, 3)), window.__v, null)); |
| 298 | const trA3 = await trace(); |
| 299 | check('commit: the refused commit is re-authenticated and sent again', |
| 300 | retried(ops(trA3, 'commit')), 'statuses ' + JSON.stringify(ops(trA3, 'commit'))); |
| 301 | check('commit: and the sweep it authorises actually lands', |
| 302 | commitA && commitA.swept === 1, JSON.stringify(commitA && { swept: commitA.swept })); |
| 303 | const goneA = await page.evaluate(() => window.__held([window.__c[3].addr])); |
| 304 | check('commit: the gateway no longer holds the chunk the commit dropped', |
| 305 | goneA.length === 0, goneA.length + ' still held'); |
| 306 | |
| 307 | // (A4) LATE-BOUND, NEVER CAPTURED. `DaimondGateway.gwFetch` is replaced at |
| 308 | // runtime; a file that captured it into a local at load would sail straight |
| 309 | // past the replacement, and a file that looks it up per call cannot. |
| 310 | const bound = await page.evaluate(async () => { |
| 311 | const real = DaimondGateway.gwFetch; |
| 312 | let seen = 0; |
| 313 | DaimondGateway.gwFetch = function () { seen++; return real.apply(this, arguments); }; |
| 314 | const v = await window.__version(); |
| 315 | await DaimondChunks.commit(window.__manifests(window.__c.slice(0, 3)), v, null); |
| 316 | DaimondGateway.gwFetch = real; |
| 317 | return seen; |
| 318 | }); |
| 319 | check('every chunk request goes through the CURRENT DaimondGateway.gwFetch', |
| 320 | bound >= 1, bound + ' calls saw the replacement'); |
| 321 | |
| 322 | // (A5) And the file keeps no private copy of the two things gateway.js owns. |
| 323 | const src = fs.readFileSync(SRC, 'utf8'); |
| 324 | check('chunks.js carries no CLIENT_API of its own', |
| 325 | !/CLIENT_API/.test(src)); |
| 326 | check('chunks.js makes no bare fetch() call', |
| 327 | !/[^.\w]fetch\s*\(/.test(src.replace(/gwFetch\s*\(/g, 'gwF(')), |
| 328 | (src.match(/[^.\w]fetch\s*\(/g) || []).join(' ')); |
| 329 | |
| 330 | // ═══ (B) A sweep the gateway will not carry out ════════════════════ |
| 331 | |
| 332 | // A session to run the rest on, whatever (A) left behind. The two halves are |
| 333 | // independent on purpose: breaking the 401 wiring must not take the sweep |
| 334 | // checks down with it, or a red run says nothing about which half moved. |
| 335 | await page.evaluate(() => DaimondGateway.bootstrap()); |
| 336 | |
| 337 | // (B0) The gateway under test really has a floor. Without this, every check |
| 338 | // below would pass against an older binary for entirely the wrong reason. |
| 339 | await page.evaluate(() => window.__wipe()); |
| 340 | await page.evaluate(() => window.__seed(4).then(c => (window.__c = c))); |
| 341 | await page.evaluate(async () => { |
| 342 | const v = await window.__version(); |
| 343 | await DaimondChunks.commit(window.__manifests(window.__c), v, null); |
| 344 | }); |
| 345 | const floor = await page.evaluate(async () => { |
| 346 | // Straight to the gateway, around the client, so what is measured is the |
| 347 | // contract and not this client's answer to it. |
| 348 | const v = await window.__version(); |
| 349 | const r = await window.__realFetch('/api/chunk', { |
| 350 | method: 'POST', credentials: 'same-origin', |
| 351 | headers: { 'content-type': 'application/json', 'x-daimond-api': '1' }, |
| 352 | body: JSON.stringify({ |
| 353 | op: 'commit', blob_version: v, |
| 354 | chunks: [{ addr: window.__c[0].addr, size: window.__c[0].size, tier: 'p' }], |
| 355 | }), |
| 356 | }); |
| 357 | return { status: r.status, j: await r.json() }; |
| 358 | }); |
| 359 | check('the gateway refuses a sweep over the half floor, and says so', |
| 360 | floor.status === 200 && floor.j.ok === true && floor.j.swept === 0 |
| 361 | && floor.j.sweep_held_back === 3 && floor.j.sweep_held === 4 && !!floor.j.sweep_token, |
| 362 | JSON.stringify(floor.j)); |
| 363 | const survived = await page.evaluate(() => window.__held(window.__c.map(c => c.addr))); |
| 364 | check('and it deleted nothing at all', survived.length === 4, survived.length + ' of 4 held'); |
| 365 | |
| 366 | // (B1) An ordinary sweep is untouched by any of this: without this check the |
| 367 | // client would be indistinguishable from one that never sweeps. |
| 368 | await page.evaluate(() => window.__wipe()); |
| 369 | await page.evaluate(() => window.__seed(8).then(c => (window.__c = c))); |
| 370 | await page.evaluate(async () => { |
| 371 | const v = await window.__version(); |
| 372 | await DaimondChunks.commit(window.__manifests(window.__c), v, null); |
| 373 | }); |
| 374 | await clear(); |
| 375 | const ord = await page.evaluate(async () => { |
| 376 | const v = await window.__version(); |
| 377 | const j = await DaimondChunks.commit(window.__manifests(window.__c.slice(0, 7)), v, null); |
| 378 | return { j, st: DaimondChunks.state() }; |
| 379 | }); |
| 380 | const trB1 = await trace(); |
| 381 | check('an ordinary sweep still collects its garbage, on one request', |
| 382 | ord.j && ord.j.swept === 1 && ops(trB1, 'commit').length === 1, |
| 383 | JSON.stringify({ swept: ord.j && ord.j.swept, requests: ops(trB1, 'commit').length })); |
| 384 | check('and nothing is left standing over it', |
| 385 | ord.st.standing === false && ord.st.heldBack === 0, JSON.stringify(ord.st)); |
| 386 | |
| 387 | // (B2) A large deletion this client can account for is confirmed, and the |
| 388 | // chunks REALLY GO. Two requests that agree, with the gateway's answer in |
| 389 | // between — and never a third. |
| 390 | await page.evaluate(() => window.__wipe()); |
| 391 | await page.evaluate(() => window.__seed(4).then(c => (window.__c = c))); |
| 392 | await page.evaluate(async () => { |
| 393 | const v = await window.__version(); |
| 394 | await DaimondChunks.commit(window.__manifests(window.__c), v, null); |
| 395 | }); |
| 396 | await clear(); |
| 397 | const conf = await page.evaluate(async () => { |
| 398 | const before = DaimondChunks.state().confirmed; |
| 399 | const v = await window.__version(); |
| 400 | const j = await DaimondChunks.commit(window.__manifests(window.__c.slice(0, 1)), v, null); |
| 401 | return { j, before, st: DaimondChunks.state(), held: await window.__held(window.__c.map(c => c.addr)) }; |
| 402 | }); |
| 403 | const trB2 = await trace(); |
| 404 | check('a large deletion the client can account for is confirmed and runs', |
| 405 | conf.j && conf.j.swept === 3, JSON.stringify(conf.j && { swept: conf.j.swept, back: conf.j.sweep_held_back })); |
| 406 | check('and the gateway really has stopped holding those chunks', |
| 407 | conf.held.length === 1, conf.held.length + ' of 4 still held'); |
| 408 | check('it cost exactly TWO commits — the interlock was honoured, not bypassed', |
| 409 | ops(trB2, 'commit').length === 2, JSON.stringify(ops(trB2, 'commit'))); |
| 410 | check('and nothing is left standing, with the confirmation counted', |
| 411 | conf.st.standing === false && conf.st.confirmed === conf.before + 1, JSON.stringify(conf.st)); |
| 412 | |
| 413 | // (B3) The second commit is IDENTICAL bar the token. A rebuilt body would |
| 414 | // name a different deletion and the token would not match it. |
| 415 | const bs = (await bodies()).filter(b => b && b.op === 'commit'); |
| 416 | const same = bs.length === 2 |
| 417 | && JSON.stringify({ ...bs[0], sweep_token: undefined }) === JSON.stringify({ ...bs[1], sweep_token: undefined }) |
| 418 | && !bs[0].sweep_token && !!bs[1].sweep_token; |
| 419 | check('the confirmation repeats the identical commit, adding only the token', |
| 420 | same, bs.length + ' commit bodies'); |
| 421 | |
| 422 | // (B4) THE ONE THAT MUST BE ABLE TO FAIL. An index naming nothing is the |
| 423 | // sharpest form of a client that knows nothing declaring the account empty. |
| 424 | // This client never confirms one — so the deletion does NOT happen, and the |
| 425 | // whole point of this check is that the app SAYS SO. A test that only |
| 426 | // asserted the chunks survived would pass just as well over silence. |
| 427 | await page.evaluate(() => window.__wipe()); |
| 428 | await page.evaluate(() => window.__seed(4).then(c => (window.__c = c))); |
| 429 | await page.evaluate(async () => { |
| 430 | const v = await window.__version(); |
| 431 | await DaimondChunks.commit(window.__manifests(window.__c), v, null); |
| 432 | }); |
| 433 | await clear(); |
| 434 | const nil = await page.evaluate(async () => { |
| 435 | const v = await window.__version(); |
| 436 | const j = await DaimondChunks.commit({}, v, null); |
| 437 | const chip = document.getElementById('chunk-chip'); |
| 438 | return { |
| 439 | j, st: DaimondChunks.state(), |
| 440 | held: (await window.__held(window.__c.map(c => c.addr))).length, |
| 441 | chip: chip ? { |
| 442 | shown: getComputedStyle(chip).display !== 'none', |
| 443 | tag: chip.tagName, |
| 444 | text: (chip.textContent || '').trim(), |
| 445 | title: chip.title || '', |
| 446 | } : null, |
| 447 | }; |
| 448 | }); |
| 449 | const trB4 = await trace(); |
| 450 | check('an index naming nothing empties nothing', nil.held === 4, nil.held + ' of 4 held'); |
| 451 | check('and the client does not insist: exactly ONE commit, never a loop', |
| 452 | ops(trB4, 'commit').length === 1, JSON.stringify(ops(trB4, 'commit'))); |
| 453 | check('the deletion that did not happen is RECORDED, with its reason', |
| 454 | nil.st.standing === true && nil.st.why === 'names_nothing' |
| 455 | && nil.st.heldBack === 4 && nil.st.held === 4, JSON.stringify(nil.st)); |
| 456 | check('and it is SAID: a standing chip in the top bar, not a console line', |
| 457 | !!nil.chip && nil.chip.shown === true && nil.chip.text.length > 0, |
| 458 | JSON.stringify(nil.chip && { shown: nil.chip.shown, text: nil.chip.text })); |
| 459 | check('the chip is a translated sentence, not a bare key', |
| 460 | !!nil.chip && !/^chunks\./.test(nil.chip.text) && !/^chunks\./.test(nil.chip.title), |
| 461 | nil.chip && nil.chip.text); |
| 462 | check('and its hover carries both numbers, so the size of it is knowable', |
| 463 | !!nil.chip && nil.chip.title.indexOf('4') !== -1 && nil.chip.title.length > 40, |
| 464 | nil.chip && nil.chip.title.slice(0, 70)); |
| 465 | // It was a `role="status"` div until 2026-08-14, and `confirmHeldSweep` had no |
| 466 | // production caller anywhere -- so this chip told people a deletion was |
| 467 | // standing and gave them nothing that could carry it out. What the button DOES |
| 468 | // is proved in dev/verify_chunks.mjs, which can drive a click without a |
| 469 | // gateway; what is asserted here is that it has not been demoted back to a |
| 470 | // notice, because that is the change this file would otherwise not notice. |
| 471 | check('and the chip is a control, not a pill nobody can press', |
| 472 | !!nil.chip && nil.chip.tag === 'BUTTON', nil.chip && nil.chip.tag); |
| 473 | // A deletion left standing is written down, so a reload does not abandon it: |
| 474 | // only the gateway can mint that token, and this client cannot re-derive one. |
| 475 | const kept = await page.evaluate(() => localStorage.getItem('daimond-chunk-held')); |
| 476 | check('and the standing deletion is written down, so a reload keeps it', |
| 477 | !!kept && nil.st.persisted === true, kept ? (kept.length + ' bytes') : 'nothing written'); |
| 478 | |
| 479 | // (B5) The operator's escape hatch really carries that deletion out. |
| 480 | const forced = await page.evaluate(async () => { |
| 481 | const j = await DaimondChunks.confirmHeldSweep(); |
| 482 | return { |
| 483 | j, st: DaimondChunks.state(), |
| 484 | held: (await window.__held(window.__c.map(c => c.addr))).length, |
| 485 | saved: localStorage.getItem('daimond-chunk-held'), |
| 486 | }; |
| 487 | }); |
| 488 | check('a person confirming it deletes what the client would not on its own', |
| 489 | forced.j && forced.j.swept === 4 && forced.held === 0, |
| 490 | JSON.stringify({ swept: forced.j && forced.j.swept, held: forced.held })); |
| 491 | check('and the standing notice clears with it', |
| 492 | forced.st.standing === false, JSON.stringify(forced.st)); |
| 493 | check('and what was written down goes too, so it cannot rise again next boot', |
| 494 | forced.saved === null, String(forced.saved)); |
| 495 | |
| 496 | // (B6) A device that may not declare a live set does not confirm one either. |
| 497 | // sync gates the FIRST commit on this; the client re-asserts it immediately |
| 498 | // before the destructive second request, which is the window where it moves. |
| 499 | await page.evaluate(() => window.__wipe()); |
| 500 | await page.evaluate(() => window.__seed(4).then(c => (window.__c = c))); |
| 501 | await page.evaluate(async () => { |
| 502 | const v = await window.__version(); |
| 503 | await DaimondChunks.commit(window.__manifests(window.__c), v, null); |
| 504 | }); |
| 505 | await clear(); |
| 506 | const unmerged = await page.evaluate(async () => { |
| 507 | const real = DaimondCore.syncMayCommitChunks; |
| 508 | DaimondCore.syncMayCommitChunks = function () { return false; }; |
| 509 | const v = await window.__version(); |
| 510 | const j = await DaimondChunks.commit(window.__manifests(window.__c.slice(0, 1)), v, null); |
| 511 | DaimondCore.syncMayCommitChunks = real; |
| 512 | return { j, st: DaimondChunks.state(), held: (await window.__held(window.__c.map(c => c.addr))).length }; |
| 513 | }); |
| 514 | const trB6 = await trace(); |
| 515 | check('a device whose index is not a merged one does not confirm a deletion', |
| 516 | unmerged.held === 4 && unmerged.st.standing === true && unmerged.st.why === 'not_merged', |
| 517 | JSON.stringify({ held: unmerged.held, why: unmerged.st.why })); |
| 518 | check('and it made no second attempt at it', |
| 519 | ops(trB6, 'commit').length === 1, JSON.stringify(ops(trB6, 'commit'))); |
| 520 | |
| 521 | // (B7) Where the two parties cannot agree on the size of the corpus, the |
| 522 | // client does not insist on the largest deletion the gateway will accept. |
| 523 | // Here the index names a chunk the gateway has never held, so the gateway's |
| 524 | // held-minus-doomed and the client's own count disagree by one. |
| 525 | await page.evaluate(() => window.__wipe()); |
| 526 | await page.evaluate(() => window.__seed(4).then(c => (window.__c = c))); |
| 527 | await page.evaluate(async () => { |
| 528 | const v = await window.__version(); |
| 529 | await DaimondChunks.commit(window.__manifests(window.__c), v, null); |
| 530 | }); |
| 531 | await clear(); |
| 532 | const phantom = await page.evaluate(async () => { |
| 533 | const v = await window.__version(); |
| 534 | const m = window.__manifests(window.__c.slice(0, 1)); |
| 535 | m['ghost.bin'] = { v: 2, size: 9, key: 'kg', chunks: [{ addr: 'ab'.repeat(32), size: 9 }] }; |
| 536 | const j = await DaimondChunks.commit(m, v, null); |
| 537 | return { j, st: DaimondChunks.state(), held: (await window.__held(window.__c.map(c => c.addr))).length }; |
| 538 | }); |
| 539 | const trB7 = await trace(); |
| 540 | check('an index the gateway cannot account for does not authorise a deletion', |
| 541 | phantom.held === 4 && phantom.st.standing === true && phantom.st.why === 'unaccounted', |
| 542 | JSON.stringify({ held: phantom.held, why: phantom.st.why })); |
| 543 | check('and that too is one request, not two', |
| 544 | ops(trB7, 'commit').length === 1, JSON.stringify(ops(trB7, 'commit'))); |
| 545 | |
| 546 | // (B8) The notice is standing, not permanent: the next commit that collects |
| 547 | // clears it. Without this the chip would be a scar rather than a state. |
| 548 | const cleared = await page.evaluate(async () => { |
| 549 | const v = await window.__version(); |
| 550 | const j = await DaimondChunks.commit(window.__manifests(window.__c.slice(0, 3)), v, null); |
| 551 | const chip = document.getElementById('chunk-chip'); |
| 552 | return { j, st: DaimondChunks.state(), shown: chip ? getComputedStyle(chip).display !== 'none' : null }; |
| 553 | }); |
| 554 | check('a later commit that does collect clears the standing notice', |
| 555 | cleared.j && cleared.j.swept === 1 && cleared.st.standing === false && cleared.shown === false, |
| 556 | JSON.stringify({ swept: cleared.j && cleared.j.swept, standing: cleared.st.standing, shown: cleared.shown })); |
| 557 | |
| 558 | // (B9) Nothing was raised over the app through any of it. A deletion the |
| 559 | // gateway declined is a report, not an interruption. |
| 560 | const modals = await page.evaluate(() => [...document.querySelectorAll('.modal, .pair-scrim, .daimond-toast')] |
| 561 | .filter(m => getComputedStyle(m).display !== 'none') |
| 562 | .map(m => (m.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 60))); |
| 563 | check('nothing was raised over the app through the whole run', |
| 564 | modals.length === 0, modals.join(' | ')); |
| 565 | |
| 566 | const errs = s.errs.filter(e => |
| 567 | !/favicon|ERR_|Failed to load resource|401|402|404|409|413|426|502|Unauthorized/.test(e) |
| 568 | && !/WebSocket connection to '[^']*\/api\/sync\/ws/.test(e)); |
| 569 | check('no unexpected console errors', errs.length === 0, errs.slice(0, 3).join(' | ')); |
| 570 | } catch (e) { |
| 571 | check('no exception during the run', false, String((e && e.stack) || e)); |
| 572 | } finally { |
| 573 | try { await s.close(); } catch (e) { /* ignore */ } |
| 574 | if (gw) { try { gw.kill('SIGTERM'); } catch (e) { /* ignore */ } } |
| 575 | } |
| 576 | |
| 577 | console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed'); |
| 578 | if (bad.length) { |
| 579 | bad.forEach(b => console.log(' FAILED: ' + b)); |
| 580 | GW_LOG.report(); |
| 581 | process.exit(1); |
| 582 | } |