Oregami
Repositories/oxedyne/daimond

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.
35import { spawn } from 'node:child_process';
36import fs from 'node:fs';
37import path from 'node:path';
38import { fileURLToPath } from 'node:url';
39import { requireFreshGateway, GWBIN, procLog, GWCWD } from './gwbin.mjs';
40import { open } from './harness.mjs';
41import { makePagePro } from './pro.mjs';
42import { GW_PORT, GW_URL } from './ports.mjs';
43
44const __dirname = path.dirname(fileURLToPath(import.meta.url));
45const GWDIR = path.resolve(__dirname, '..', 'gateway');
46const 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.
50const GW_LOG = procLog('verify_chunkgw');
51
52const ok = [], bad = [];
53const check = (name, pass, detail) => {
54 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
55 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
56};
57const sleep = ms => new Promise(r => setTimeout(r, ms));
58
59async 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
68requireFreshGateway();
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.
86let gw = null;
87const alreadyUp = await waitFor(async () => (await fetch(`${GW_URL}/api/health`)).ok, 800, 200);
88if (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
95const s = await open({ name: 'chunkgw', signIn: true, connect: false });
96const { page } = s;
97
98try {
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
577console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed');
578if (bad.length) {
579 bad.forEach(b => console.log(' FAILED: ' + b));
580 GW_LOG.report();
581 process.exit(1);
582}