oxedyne/daimond/dev/verify_relay_e2e.mjs
33.4 KiB, 3 runs
created by r2519314175:639, 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 | // dev/verify_relay_e2e.mjs -- the two halves meeting: a message sealed in one |
| 2 | // browser, carried by a RUNNING GATEWAY, and opened in another. |
| 3 | // |
| 4 | // WHY THIS FILE EXISTS. Every other verifier of the messaging client carries the |
| 5 | // bytes itself. `verify_post.mjs` says so at the top -- "no server in the path at |
| 6 | // all" -- and `verify_trust.mjs` and `verify_group.mjs` both assert that no |
| 7 | // gateway was even running. That is the right shape for what they prove: the |
| 8 | // seal must not need a server, and a group that only works with one has put the |
| 9 | // server inside the cryptography. But it leaves one thing untested, and it is |
| 10 | // the thing that breaks: the CLIENT's idea of `/api/post` and the GATEWAY's idea |
| 11 | // of `/api/post` were written from the contract by different lanes, each was |
| 12 | // exercised only against itself, and each was honestly correct in isolation. Two |
| 13 | // halves that have never spoken cannot be said to fit. |
| 14 | // |
| 15 | // So the property here is not the seal and not the roster. It is: THE WIRE. |
| 16 | // Every request below goes through the real front door to a real gateway process |
| 17 | // with a real store, and the answers are read by the real client. |
| 18 | // |
| 19 | // WHAT THIS RUN OWNS. Its own gateway, on its own port, in its own directory, |
| 20 | // with an EMPTY store; and its own dev server pointed at that gateway. Nothing |
| 21 | // is shared, because the checks below count rows in a postbox and counting them |
| 22 | // in a store somebody else is writing to would measure their afternoon. The |
| 23 | // browser-only worlds (`dev/world.sh N`) number the app at 8777+N and the mock |
| 24 | // provider at 9099+N; this takes world 5's app port and a gateway port well |
| 25 | // clear of both, so a run here does not collide with anybody's browser work. |
| 26 | // |
| 27 | // bash dev/world.sh 5 --down # this file starts its own server |
| 28 | // node dev/verify_relay_e2e.mjs |
| 29 | // DAIMOND_RELAY_PORT=8920 DAIMOND_RELAY_GW_PORT=9509 node dev/verify_relay_e2e.mjs |
| 30 | // |
| 31 | // Those two are the ONLY ports this file honours, and `dev/gate.sh` derives them |
| 32 | // from the world number so a suite run never collides with the world's own |
| 33 | // server. See the constants below for what went wrong when it read the world's. |
| 34 | // |
| 35 | // A fault injected on purpose, so a check can be shown going red. Each break |
| 36 | // names the checks it MUST redden, and a run where those stayed green fails -- |
| 37 | // "something went red" is not enough when the run already has a standing failure |
| 38 | // in it, which this one does (§5). A break credited with catching a defect it |
| 39 | // never saw is the exact shape `dev/verify_conformance.mjs` was rewritten around. |
| 40 | // |
| 41 | // --break=echo the plaintext rides along beside the envelope -> §2 one check |
| 42 | // --break=anyone any key opens any envelope -> §3 one check |
| 43 | // --break=noack no collect-and-ack round is run -> §4 TWO checks, |
| 44 | // and it cannot be otherwise: one behaviour violates both |
| 45 | // sentences -- the relay was not told, and the relay is still |
| 46 | // holding it. |
| 47 | // |
| 48 | // WHAT THIS FILE DOES NOT PROVE, said here rather than left to be assumed: |
| 49 | // |
| 50 | // * Nothing about the seal's strength. §1 of `verify_post.mjs` owns that, with |
| 51 | // no server in the path, which is where it belongs. What is asserted here is |
| 52 | // that the SAME seal survives a round trip through the relay -- a different |
| 53 | // claim, and the one nobody had made. |
| 54 | // * Nothing about parking or the doorbell. Those are `verify_post.mjs` §7 and |
| 55 | // `verify_doorbell.mjs`, both against a routed browser rather than a gateway. |
| 56 | // * Nothing about production. This is loopback with `dev_insecure` on and the |
| 57 | // beta opened, which is what `dev/gwbin.mjs` builds for every gateway-driving |
| 58 | // verifier in the tree. |
| 59 | |
| 60 | import fs from 'node:fs'; |
| 61 | import os from 'node:os'; |
| 62 | import path from 'node:path'; |
| 63 | import { spawn } from 'node:child_process'; |
| 64 | import { fileURLToPath } from 'node:url'; |
| 65 | import { requireFreshGateway, GWBIN, GWDIR, openBeta, procLog } from './gwbin.mjs'; |
| 66 | |
| 67 | // `harness.mjs` reads the app's URL into a module CONST at import time, so this |
| 68 | // file cannot set `DAIMOND_APP` in its own body and be believed: an `import` |
| 69 | // statement is evaluated before any of it runs. The first draft did exactly that |
| 70 | // and every browser went to :8777 -- world 0's server, proxying to a gateway on |
| 71 | // :9002 that this run never started -- while the refusal check above happily |
| 72 | // confirmed :8782 was free. The whole run then measured the wrong relay and said |
| 73 | // so only in one URL, in one passing line's detail. So the harness is imported |
| 74 | // BELOW, dynamically, after the environment it reads is set. |
| 75 | let open, errors; |
| 76 | |
| 77 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 78 | const ROOT = path.join(HERE, '..'); |
| 79 | |
| 80 | // 9502: clear of the dev servers (8777+N), the mock providers (9099+N), the |
| 81 | // shared gateway (9002) and verify_passcode's own (9420). A "spare" port inside |
| 82 | // one of those ranges answers a health probe with somebody else's process. |
| 83 | // |
| 84 | // DELIBERATELY NOT `DAIMOND_PORT` OR `DAIMOND_GW_PORT`, the same rule and for the |
| 85 | // same reason as dev/verify_redeem.mjs. This file starts a dev server of its own |
| 86 | // pointed at a gateway of its own, and `dev/run_all.sh` runs inside a world that |
| 87 | // has already exported `DAIMOND_PORT` for the server everything else shares. |
| 88 | // Reading it made this verifier try to seize the suite's own port, find it held, |
| 89 | // and refuse -- correctly, and every single time. Under `dev/gate.sh`'s default |
| 90 | // world 9 that port is 8786, and the whole of this file's output on the |
| 91 | // 2026-08-17 gate was one refusal naming two variables it had not been given, |
| 92 | // while the same file passes 32 checks standalone. A collision by construction, |
| 93 | // not a flake. So the knobs are its own, and the world it runs in cannot reach in |
| 94 | // and move them; `gate.sh` derives them from the world number. |
| 95 | const GW_PORT = Number(process.env.DAIMOND_RELAY_GW_PORT || 9502); |
| 96 | const GW_URL = `http://127.0.0.1:${GW_PORT}`; |
| 97 | const APP_PORT = Number(process.env.DAIMOND_RELAY_PORT || 8782); // world 5 |
| 98 | const APP_URL = `http://localhost:${APP_PORT}`; |
| 99 | const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond'); |
| 100 | const WORK = path.join(SCRATCH, 'verify_relay_e2e-gw'); |
| 101 | const GW_LOG = procLog('verify_relay_e2e'); |
| 102 | const SRV_LOG = procLog('verify_relay_e2e', 'server'); |
| 103 | |
| 104 | const BREAK = (process.argv.find(a => a.startsWith('--break=')) || '').slice(8); |
| 105 | /// What each break must turn red, by the leading words of the check's name. |
| 106 | /// |
| 107 | /// Named rather than counted. This file has a standing failure in §5 -- a real |
| 108 | /// defect in the app, reported below -- so a run with ANY break already ends with |
| 109 | /// something red, and "the run failed" would credit every break with catching it. |
| 110 | const AIMS = { |
| 111 | echo: ['and the words are nowhere'], |
| 112 | anyone: ['a third identity holding the same bytes'], |
| 113 | noack: ['the relay is told it may let go', 'and the relay is then holding nothing'], |
| 114 | }; |
| 115 | if (BREAK && !(BREAK in AIMS)) { |
| 116 | console.log(` --break=${BREAK} is not one of: ${Object.keys(AIMS).join(', ')}`); |
| 117 | process.exit(2); |
| 118 | } |
| 119 | |
| 120 | const ok = [], bad = []; |
| 121 | /// Record a check. `detail` is the evidence and is printed either way; `why` is |
| 122 | /// what went wrong and is printed only when it did, so a passing line can never |
| 123 | /// be read as a failure. |
| 124 | const check = (name, pass, detail, why) => { |
| 125 | (pass ? ok : bad).push(name); |
| 126 | const tail = pass ? (detail ? ' — ' + detail : '') |
| 127 | : ' — ' + [why, detail].filter(Boolean).join(' · '); |
| 128 | console.log((pass ? ' ok ' : ' FAIL ') + name + tail); |
| 129 | }; |
| 130 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 131 | |
| 132 | /// Poll until `fn` answers true, or give up. Returns whether it did. |
| 133 | async function waitFor(fn, ms = 12000, gap = 150) { |
| 134 | const t0 = Date.now(); |
| 135 | for (;;) { |
| 136 | try { if (await fn()) return true; } catch (e) { /* not up yet */ } |
| 137 | if (Date.now() - t0 > ms) return false; |
| 138 | await sleep(gap); |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | /// Is anything listening on a loopback port? |
| 143 | async function held(port) { |
| 144 | return await waitFor(async () => { |
| 145 | const r = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) }); |
| 146 | return !!r; |
| 147 | }, 600, 200); |
| 148 | } |
| 149 | |
| 150 | |
| 151 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 152 | // │ THIS RUN'S OWN GATEWAY AND ITS OWN FRONT DOOR │ |
| 153 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 154 | |
| 155 | /// Build the gateway's working directory: the deployed config with the port |
| 156 | /// changed and the beta opened, the real signing keys symlinked in, and an empty |
| 157 | /// store. |
| 158 | /// |
| 159 | /// The database key is NOT symlinked. The store here is new, so its at-rest key |
| 160 | /// must be new too; pointing a fresh store at the live key either fails or -- |
| 161 | /// far worse -- succeeds against the live store. |
| 162 | function buildWorkDir() { |
| 163 | fs.rmSync(WORK, { recursive: true, force: true }); |
| 164 | fs.mkdirSync(path.join(WORK, 'keys'), { recursive: true }); |
| 165 | for (const k of ['licence', 'stripe', 'openrouter']) { |
| 166 | const from = path.join(GWDIR, 'keys', k); |
| 167 | if (fs.existsSync(from)) fs.symlinkSync(from, path.join(WORK, 'keys', k)); |
| 168 | } |
| 169 | let cfg = fs.readFileSync(path.join(GWDIR, 'app.jdat'), 'utf8') |
| 170 | .replace(/"listen_port":\s*\(u16\|\d+\)/, `"listen_port": (u16|${GW_PORT})`); |
| 171 | if (!cfg.includes(`(u16|${GW_PORT})`)) { |
| 172 | console.log(' FAIL could not set the listen port in the copied app.jdat — ' |
| 173 | + 'has its shape changed? A gateway left on the deployed port would be ' |
| 174 | + 'measured instead of this one.'); |
| 175 | process.exit(1); |
| 176 | } |
| 177 | // Registration must be open: both browsers mint a fresh keypair, and the |
| 178 | // deployed config answers a fresh keypair `403 the beta is closed`, which |
| 179 | // would leave this run measuring a shut door. |
| 180 | cfg = openBeta(cfg, 'verify_relay_e2e'); |
| 181 | fs.writeFileSync(path.join(WORK, 'app.jdat'), cfg); |
| 182 | return WORK; |
| 183 | } |
| 184 | |
| 185 | let gw = null, srv = null; |
| 186 | |
| 187 | /// Stop everything this run started, and only what this run started. |
| 188 | /// |
| 189 | /// The browser profiles go too. `harness.mjs` names one `pw/<name>-<pid>` and |
| 190 | /// leaves it on disk, which is right for a fixed profile a run means to reuse and |
| 191 | /// pure litter for a pid-named one: three browsers a run, and ten runs of this |
| 192 | /// file in one afternoon left thirty directories in a `pw/` already holding 1.2 GB |
| 193 | /// across a hundred. Removed by exact name rather than by pattern, so a run cannot |
| 194 | /// delete another agent's profile while they are driving it. |
| 195 | function cleanup() { |
| 196 | for (const p of [gw, srv]) { try { if (p) p.kill(); } catch (e) { /* already gone */ } } |
| 197 | gw = null; srv = null; |
| 198 | for (const n of ['relay-a', 'relay-b', 'relay-c']) { |
| 199 | try { fs.rmSync(path.join(SCRATCH, 'pw', `${n}-${process.pid}`), { recursive: true, force: true }); } |
| 200 | catch (e) { /* never made, or already gone */ } |
| 201 | } |
| 202 | } |
| 203 | process.on('exit', cleanup); |
| 204 | for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { cleanup(); process.exit(1); }); |
| 205 | |
| 206 | |
| 207 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 208 | // │ A DEVICE │ |
| 209 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 210 | |
| 211 | /// Bring up one browser on its own profile, signed in, with an identity, a |
| 212 | /// sealing key and a card -- and an account on THIS run's gateway. |
| 213 | /// |
| 214 | /// `connect: false` because the mock LLM provider has nothing to do with the |
| 215 | /// relay, and asking for one would make this file fail when a mock is not up. |
| 216 | /// Nothing is injected: post.js, trust.js and group.js are asserted in §0 and |
| 217 | /// come off the page as the browser assembled it. |
| 218 | async function device(name) { |
| 219 | const s = await open({ name, signIn: true, connect: false }); |
| 220 | // Every request the page makes, with its body, so §3 can read what actually |
| 221 | // left the browser rather than what the client says it sent. |
| 222 | s.seen = []; |
| 223 | s.page.on('request', r => { |
| 224 | let body = ''; |
| 225 | try { body = r.postData() || ''; } catch (e) { /* not a body we can read */ } |
| 226 | s.seen.push({ url: r.url(), method: r.method(), body }); |
| 227 | }); |
| 228 | await s.page.waitForFunction( |
| 229 | () => !!window.DaimondPost && !!window.DaimondTrust && !!window.DaimondGroup |
| 230 | && !!window.DaimondIdentity && !!window.DaimondGateway, |
| 231 | null, { timeout: 20000 }, |
| 232 | ).catch(() => { throw new Error( |
| 233 | `${name}: the page did not assemble — post.js, trust.js or group.js is missing ` |
| 234 | + 'from www/index.html. Nothing here injects them; see §0.'); }); |
| 235 | await s.page.evaluate(async () => { |
| 236 | await window.DaimondIdentity.ensureSealingKey(); |
| 237 | await window.DaimondIdentity.mintCard(); |
| 238 | }); |
| 239 | // The account is taken through the app's own bootstrap, which is the round a |
| 240 | // real device makes, rather than by posting to /api/account from here. |
| 241 | const authed = await s.page.evaluate(async () => { |
| 242 | try { await window.DaimondGateway.bootstrap(); } catch (e) { /* read the state */ } |
| 243 | return window.DaimondGateway.state().authed === true; |
| 244 | }); |
| 245 | s.authed = authed; |
| 246 | // BOTH SPELLINGS, because the app uses both and they are not interchangeable: |
| 247 | // `DaimondPost.send({to})` takes base64url (it is the account key the relay |
| 248 | // looks up), and `DaimondGroup.create(name, keys)` takes lower-case hex. |
| 249 | // |
| 250 | // This comment used to say the wrong spelling was dropped silently, because it |
| 251 | // was: `if (!isHex(k, 32) || seen[k]) continue;`, with neither case added to |
| 252 | // `missing`, so the first run of this file made a group of one and could not |
| 253 | // tell. FIXED at www/js/group.js:653 -- a key that is not a key is carried out |
| 254 | // in `bad` and named. The note is corrected rather than left standing: a |
| 255 | // comment that outlives its defect tells the next reader a gap is open, which |
| 256 | // is the species this lane's own audit caught in share.js:133. |
| 257 | s.pub = await s.page.evaluate(() => window.DaimondIdentity.publicKeyB64url()); |
| 258 | s.hex = await s.page.evaluate(async () => { |
| 259 | const raw = await window.DaimondIdentity.publicKeyRaw(); |
| 260 | return Array.from(raw).map(b => (b + 256).toString(16).slice(1)).join(''); |
| 261 | }); |
| 262 | return s; |
| 263 | } |
| 264 | |
| 265 | /// The one place a plaintext word is chosen, so §3 and §4 cannot drift apart. |
| 266 | const WORDS = 'pelican semaphore ' + Math.random().toString(36).slice(2, 10); |
| 267 | const GWORDS = 'thimble cartography ' + Math.random().toString(36).slice(2, 10); |
| 268 | |
| 269 | |
| 270 | (async () => { |
| 271 | // The gateway must be the one the code under test describes. This refuses on |
| 272 | // a stale binary, which is the whole reason `dev/gwbin.mjs` exists: a |
| 273 | // verifier measuring a build nobody is shipping produces numbers that are |
| 274 | // harder to disbelieve than an absent result. |
| 275 | requireFreshGateway(); |
| 276 | |
| 277 | if (await held(GW_PORT)) { |
| 278 | console.log(` FAIL something is already answering on :${GW_PORT}. This run needs its ` |
| 279 | + 'OWN gateway with an EMPTY store — it counts rows in a postbox, and counting ' |
| 280 | + 'them in somebody else\'s store would measure their afternoon. Set ' |
| 281 | + 'DAIMOND_RELAY_GW_PORT to a free port.'); |
| 282 | process.exit(1); |
| 283 | } |
| 284 | if (await held(APP_PORT)) { |
| 285 | console.log(` FAIL something is already serving on :${APP_PORT}. This run starts its own ` |
| 286 | + `dev server so it can point /api at :${GW_PORT}; a server started by ` |
| 287 | + `dev/world.sh proxies to that world's OWN gateway port instead, and the ` |
| 288 | + `browser would then be ` |
| 289 | + 'talking to a gateway this file did not start. Run `bash dev/world.sh 5 --down` ' |
| 290 | + 'first, or set DAIMOND_RELAY_PORT to a free port.'); |
| 291 | process.exit(1); |
| 292 | } |
| 293 | |
| 294 | const cwd = buildWorkDir(); |
| 295 | gw = spawn(GWBIN, [], { cwd, env: { ...process.env, APP_MODE: 'sandbox' }, stdio: GW_LOG.stdio }); |
| 296 | const gwUp = await waitFor(async () => (await fetch(`${GW_URL}/api/health`)).ok); |
| 297 | check('this run\'s own gateway is up, on an empty store', gwUp, `${GW_URL} — ${cwd}`); |
| 298 | if (!gwUp) { GW_LOG.report(); cleanup(); process.exit(1); } |
| 299 | |
| 300 | srv = spawn(process.execPath, [path.join(HERE, 'serve.mjs')], { |
| 301 | cwd: ROOT, |
| 302 | env: { ...process.env, DAIMOND_PORT: String(APP_PORT), DAIMOND_GW_PORT: String(GW_PORT) }, |
| 303 | stdio: SRV_LOG.stdio, |
| 304 | }); |
| 305 | const srvUp = await waitFor(async () => (await fetch(`${APP_URL}/index.html`)).ok); |
| 306 | check('and its own front door, proxying /api to it', srvUp, `${APP_URL} → :${GW_PORT}`); |
| 307 | if (!srvUp) { SRV_LOG.report(); cleanup(); process.exit(1); } |
| 308 | |
| 309 | // The browsers must reach the app through THIS server, whatever the ambient |
| 310 | // world variables say. The harness is loaded HERE, after the variable is set, |
| 311 | // because it reads it once at import time -- see the note beside the import. |
| 312 | process.env.DAIMOND_APP = APP_URL; |
| 313 | ({ open, errors } = await import('./harness.mjs')); |
| 314 | |
| 315 | let A = null, B = null, C = null; |
| 316 | try { |
| 317 | // ── 0. The seams are in the app ──────────────────────────────── |
| 318 | console.log('\n0. the client is the shipped one, and the relay is this run\'s'); |
| 319 | const html = fs.readFileSync(path.join(ROOT, 'www', 'index.html'), 'utf8'); |
| 320 | for (const f of ['post', 'trust', 'group']) { |
| 321 | check(`www/index.html carries a script tag for js/${f}.js`, |
| 322 | new RegExp('<script[^>]+src=["\']js/' + f + '\\.js["\']').test(html), |
| 323 | '', 'nothing here injects it, so every section below would be untestable'); |
| 324 | } |
| 325 | // The front door really is proxying, and to the gateway this run started. |
| 326 | // A dev server whose proxy target were 9002 would answer the same shape |
| 327 | // from a DIFFERENT process, which is exactly the confusion being closed. |
| 328 | const viaDoor = await fetch(`${APP_URL}/api/health`); |
| 329 | const direct = await fetch(`${GW_URL}/api/health`); |
| 330 | check('/api through the front door reaches this run\'s gateway', |
| 331 | viaDoor.ok && direct.ok && (await viaDoor.text()) === (await direct.text()), |
| 332 | `door ${viaDoor.status}, direct ${direct.status}`); |
| 333 | |
| 334 | A = await device('relay-a'); |
| 335 | B = await device('relay-b'); |
| 336 | C = await device('relay-c'); // the third identity, who must never open it |
| 337 | check('three devices, three identities, three accounts on ONE relay', |
| 338 | A.authed && B.authed && C.authed, |
| 339 | `A ${A.authed} B ${B.authed} C ${C.authed}`, |
| 340 | 'a device with no gateway session cannot send or collect'); |
| 341 | check('and the three keys really are different', |
| 342 | A.pub && B.pub && C.pub && new Set([A.pub, B.pub, C.pub]).size === 3); |
| 343 | |
| 344 | // ── 1. First contact, carried by this file and by nothing else ── |
| 345 | console.log('\n1. first contact — the cards cross the table, not the network'); |
| 346 | const cardOf = s => s.page.evaluate(() => window.DaimondTrust.cardText()); |
| 347 | const readCard = (s, text) => s.page.evaluate(async (t) => { |
| 348 | const card = window.DaimondTrust.parse(t); |
| 349 | if (!card) return null; |
| 350 | await window.DaimondTrust.record(card, window.DaimondTrust.ROUTE.QR); |
| 351 | await window.DaimondPost.refreshPeople(); |
| 352 | return { key: card.key }; |
| 353 | }, text); |
| 354 | |
| 355 | const [aCard, bCard] = [await cardOf(A), await cardOf(B)]; |
| 356 | const aSees = await readCard(A, bCard); |
| 357 | const bSees = await readCard(B, aCard); |
| 358 | check('each device read the other\'s card', !!aSees && !!bSees); |
| 359 | // The safety numbers are computed on each device and compared HERE. |
| 360 | const aNum = await A.page.evaluate(() => window.DaimondIdentity.publicKeyB64url()); |
| 361 | check('and each now holds a sealing key for the other', |
| 362 | await A.page.evaluate(p => (window.DaimondPost.people() || []) |
| 363 | .some(x => x.pub === p), B.pub) |
| 364 | && await B.page.evaluate(p => (window.DaimondPost.people() || []) |
| 365 | .some(x => x.pub === p), A.pub), |
| 366 | '', 'without a card there is no key to seal to and §2 would test nothing'); |
| 367 | |
| 368 | // ── 2. A message, through the real relay ─────────────────────── |
| 369 | console.log('\n2. A seals, the gateway carries, B opens'); |
| 370 | // STOP B'S BACKGROUND AUTO-COLLECT for this raw-delivery measurement. The |
| 371 | // errand listener (daimond.js `startErrandListener`) opens a long-poll park |
| 372 | // on unlock; when A's message lands, that park's own `round()` collects and |
| 373 | // FILES it before B's manual `collect()` below is ever called -- so the |
| 374 | // manual collect, the path a person's browser takes, would find the box |
| 375 | // already emptied (got=0) and `takeRow` would dedup the re-collect to |
| 376 | // nothing. Parking is the peer-errand door, not this person-to-person |
| 377 | // delivery path. Stopped BEFORE A sends, so nothing collects the row ahead of |
| 378 | // the manual collect. This does not weaken the check: if delivery or filing |
| 379 | // actually broke, the manual collect still returns got=0 (or files the wrong |
| 380 | // body) and the two assertions below still go red. |
| 381 | await B.page.evaluate(() => { try { window.DaimondPost.parkStop(); } catch (e) {} }); |
| 382 | A.seen.length = 0; |
| 383 | const sent = await A.page.evaluate(async ({ to, body }) => { |
| 384 | const r = await window.DaimondPost.send({ to, body }); |
| 385 | return { ok: r.ok === true, why: r.why || '', addr: r.addr || '' }; |
| 386 | }, { to: B.pub, body: WORDS }); |
| 387 | check('A\'s send is accepted by the relay', sent.ok, sent.addr, sent.why); |
| 388 | |
| 389 | // What actually left the browser. The plaintext must be in NONE of it: |
| 390 | // a client that sent the words alongside the envelope would pass every |
| 391 | // open-it-again check in this file and every one in verify_post. |
| 392 | const posts = A.seen.filter(r => r.method === 'POST' && /\/api\/post(\?|$)/.test(r.url)); |
| 393 | check('exactly one POST /api/post left the browser', posts.length === 1, |
| 394 | `${posts.length} — ` + posts.map(p => p.url.replace(APP_URL, '')).join(' ')); |
| 395 | const wire = posts.map(p => p.url + ' ' + p.body).join('\n'); |
| 396 | const leaked = BREAK === 'echo' ? wire + ' ' + WORDS : wire; |
| 397 | check('and the words are nowhere in what it sent', |
| 398 | !leaked.includes(WORDS) && !leaked.includes(WORDS.split(' ')[0]), |
| 399 | `${wire.length} bytes on the wire`, |
| 400 | 'the plaintext rode along beside the envelope'); |
| 401 | |
| 402 | // WHAT THE RELAY ITSELF IS HOLDING, read raw rather than through the |
| 403 | // client's bookkeeping. `DaimondPost.collect()` answers |
| 404 | // `{ok, got, notes, unreadable, more}` and CONSUMES what it reads -- it |
| 405 | // advances `through`, so a second call answers zero and a check that |
| 406 | // counted rows off it would report an empty box for a message that had |
| 407 | // arrived perfectly. So the box is read with a bare GET, which is what the |
| 408 | // client's own `call('GET')` makes, and the client's collect is measured |
| 409 | // separately by what it returns and what it stores. |
| 410 | const boxOf = (s) => s.page.evaluate(async () => { |
| 411 | const r = await fetch('/api/post?since=0', { |
| 412 | credentials: 'same-origin', headers: { 'x-daimond-api': '1' }, |
| 413 | }); |
| 414 | const j = await r.json().catch(() => null); |
| 415 | return { status: r.status, rows: (j && j.rows) || [], seq: (j && j.seq) || 0 }; |
| 416 | }); |
| 417 | |
| 418 | const bBox = await boxOf(B); |
| 419 | check('the relay is holding exactly one row for B', bBox.rows.length === 1, |
| 420 | `status=${bBox.status} rows=${bBox.rows.length} seq=${bBox.seq}`, |
| 421 | 'the envelope did not reach the recipient\'s postbox'); |
| 422 | const cBox = await boxOf(C); |
| 423 | check('and nothing at all for C', cBox.rows.length === 0, `rows=${cBox.rows.length}`); |
| 424 | |
| 425 | // ── 3. B opens it, C cannot ──────────────────────────────────── |
| 426 | console.log('\n3. the seal survives the round trip'); |
| 427 | const row = bBox.rows[0] || null; |
| 428 | check('the row the relay handed back names the sender and carries an envelope', |
| 429 | !!row && row.from_pub === A.pub && row.kind === 'post' |
| 430 | && typeof row.envelope === 'string' && row.envelope.length > 0, |
| 431 | row ? `kind=${row.kind} from=${String(row.from_pub).slice(0, 10)}…` : 'no row'); |
| 432 | |
| 433 | // Through the client's own collect, which is the path a person's browser |
| 434 | // takes: it reads the box, opens what it finds and files it. |
| 435 | const took = await B.page.evaluate(() => window.DaimondPost.collect()); |
| 436 | check('B\'s own collect takes exactly one message off the relay', |
| 437 | took.ok === true && took.got === 1, |
| 438 | `got=${took.got} notes=${took.notes} unreadable=${took.unreadable}`, |
| 439 | took.why || 'the client did not file the message it was handed'); |
| 440 | const filed = await B.page.evaluate(async (from) => { |
| 441 | const st = await window.DaimondPost.read(); |
| 442 | const msgs = (st && st.msgs) || {}; |
| 443 | const k = Object.keys(msgs).find(k => msgs[k].dir === 'in' && msgs[k].from === from); |
| 444 | return k ? { body: msgs[k].body, read: msgs[k].read, seq: msgs[k].seq } : null; |
| 445 | }, A.pub); |
| 446 | check('and what it filed is the exact words A typed', |
| 447 | !!filed && filed.body === WORDS, |
| 448 | filed ? JSON.stringify(filed.body).slice(0, 60) : 'nothing was filed', |
| 449 | 'the message did not survive the relay'); |
| 450 | |
| 451 | // The negative that makes the positive mean something. C holds every |
| 452 | // module B holds and a perfectly good identity; what C does not hold is |
| 453 | // the key, and the relay cannot supply one. |
| 454 | const byC = row ? await C.page.evaluate(async (r) => { |
| 455 | try { |
| 456 | const got = await window.DaimondPost.open(r.envelope, r.addr); |
| 457 | return { opened: true, body: (got && got.post && got.post.body) || '' }; |
| 458 | } catch (e) { return { opened: false, why: String((e && e.message) || e) }; } |
| 459 | }, row) : { opened: false, why: 'no row' }; |
| 460 | const cOpened = BREAK === 'anyone' ? { opened: true, body: WORDS } : byC; |
| 461 | check('a third identity holding the same bytes cannot open it', |
| 462 | cOpened.opened !== true, cOpened.opened ? 'C READ IT: ' + cOpened.body : cOpened.why, |
| 463 | 'the seal is decoration'); |
| 464 | |
| 465 | // ── 4. The ack, and what it does to the box ──────────────────── |
| 466 | console.log('\n4. ack — the relay lets go only after the message is safe'); |
| 467 | // A collect is not a commit. Until something is acked the relay keeps the |
| 468 | // row, so that a device which read a message and then lost it has not lost |
| 469 | // the only copy. Asserted BEFORE the ack, because "the box is empty at the |
| 470 | // end" is equally true of a relay that dropped it on the collect. |
| 471 | const stillThere = await boxOf(B); |
| 472 | check('a collect on its own does not release the row', stillThere.rows.length === 1, |
| 473 | `rows=${stillThere.rows.length}`, |
| 474 | 'the relay let go of a message before being told it was safe elsewhere'); |
| 475 | |
| 476 | // THROUGH `round()`, which is collect-then-ack and is what the app itself |
| 477 | // calls -- on a park wake (post.js:1626) and on a panel refresh (:2072). |
| 478 | // Calling `ack()` once instead measured a transient and flapped: `entitled` |
| 479 | // in sync.js starts OPTIMISTICALLY TRUE and is only cleared by a 402 seen on |
| 480 | // a push, so a free account's first ack takes the committed-parcel path, |
| 481 | // the gateway refuses the push 402 (gateway/src/handlers/sync.rs:297), the |
| 482 | // version does not move and `ackThrough` answers `not_committed`. The second |
| 483 | // round has `entitled` false and solo-acks. It self-heals and it fails in the |
| 484 | // safe direction -- the relay KEEPS the row -- so this walks the app's path |
| 485 | // and REPORTS the number of rounds rather than hiding it in a retry loop. A |
| 486 | // count above one is information, and a count that never releases is a wedge. |
| 487 | const rounds = []; |
| 488 | if (BREAK !== 'noack') { |
| 489 | for (let i = 0; i < 3; i++) { |
| 490 | const r = await B.page.evaluate(() => window.DaimondPost.round() |
| 491 | .then(x => ({ acked: (x && x.acked) | 0, why: (x && x.why) || '' })) |
| 492 | .catch(e => ({ acked: 0, why: String(e && e.message || e) }))); |
| 493 | rounds.push(r); |
| 494 | if (r.acked >= 1) break; |
| 495 | } |
| 496 | } |
| 497 | const released = rounds.some(r => r.acked >= 1); |
| 498 | check('the relay is told it may let go, by the app\'s own collect-and-ack round', |
| 499 | released, |
| 500 | `${rounds.length} round(s): ` + rounds.map(r => `acked=${r.acked}${r.why ? '/' + r.why : ''}`).join(' → '), |
| 501 | BREAK === 'noack' ? 'no round was run' : 'the relay was never told it could let go'); |
| 502 | const after = await boxOf(B); |
| 503 | check('and the relay is then holding nothing', after.rows.length === 0, |
| 504 | `rows=${after.rows.length}`, |
| 505 | 'the relay is still holding a message it has been told is safe'); |
| 506 | |
| 507 | // ── 5. A group, through the same relay ───────────────────────── |
| 508 | console.log('\n5. one group message, one envelope, delivered per member'); |
| 509 | const made = await A.page.evaluate(async (keys) => { |
| 510 | const r = await window.DaimondGroup.create('relay e2e', keys); |
| 511 | return { ok: r.ok === true, gid: r.gid || '', sent: r.sent | 0, |
| 512 | members: r.members | 0, why: r.why || '' }; |
| 513 | }, [B.hex]); |
| 514 | check('A makes a group naming B, and the roster reaches B', |
| 515 | made.ok && !!made.gid && made.members === 2 && made.sent === 1, |
| 516 | `members=${made.members} sent=${made.sent} ${String(made.gid).slice(0, 12)}…`, |
| 517 | made.why || 'the roster named fewer people than it was given'); |
| 518 | |
| 519 | // THE CREATOR'S OWN STATE. `create` applies its own roster through |
| 520 | // `consume`, which files an unknown group as `invited` -- so the creator |
| 521 | // comes out of `create` NOT joined, and `sealTo` refuses them with "Join |
| 522 | // this group before writing to it." Nothing in the app then calls `join`: |
| 523 | // the panel's Make button (www/js/group.js, the `group-make` branch) says |
| 524 | // "Made, and N people have been told" and stops. `dev/verify_group.mjs` |
| 525 | // only passes because IT calls `DaimondGroup.join(gid)` on the creator's |
| 526 | // own page straight after `create` -- a line the app does not have. |
| 527 | const canWrite = made.gid ? await A.page.evaluate(async ({ gid, body }) => { |
| 528 | const r = await window.DaimondPost.send({ group: gid, body }); |
| 529 | return { ok: r.ok === true, sent: r.sent | 0, why: r.why || '' }; |
| 530 | }, { gid: made.gid, body: GWORDS }) : { ok: false, why: 'no group' }; |
| 531 | check('A can write to the group A just made', canWrite.ok, `sent=${canWrite.sent}`, |
| 532 | canWrite.why + ' — the creator is left `invited` in their own group; ' |
| 533 | + 'www/js/group.js\'s create() never joins and neither does the panel'); |
| 534 | |
| 535 | // Joined by hand so the REST of this section still measures the wire. The |
| 536 | // step above is the app's to make and its absence is reported, not papered |
| 537 | // over: this line is the workaround and is labelled as one. |
| 538 | if (made.gid && !canWrite.ok) { |
| 539 | await A.page.evaluate(g => window.DaimondGroup.join(g), made.gid); |
| 540 | } |
| 541 | const gsent = made.gid ? (canWrite.ok ? canWrite |
| 542 | : await A.page.evaluate(async ({ gid, body }) => { |
| 543 | const r = await window.DaimondPost.send({ group: gid, body }); |
| 544 | return { ok: r.ok === true, sent: r.sent | 0, why: r.why || '' }; |
| 545 | }, { gid: made.gid, body: GWORDS })) : { ok: false, why: 'no group' }; |
| 546 | check('one group message is sealed once and carried by the relay', |
| 547 | gsent.ok && gsent.sent >= 1, `sent=${gsent.sent}`, gsent.why); |
| 548 | |
| 549 | // B has to hear the roster before it can hear the message, and both come |
| 550 | // down the same box in the order they were delivered. |
| 551 | const bGroup = await B.page.evaluate(async () => { |
| 552 | const r = await window.DaimondPost.collect(); |
| 553 | const st = await window.DaimondPost.read(); |
| 554 | const msgs = (st && st.msgs) || {}; |
| 555 | const gs = await window.DaimondGroup.list(); |
| 556 | return { |
| 557 | got: r.got | 0, notes: r.notes | 0, |
| 558 | bodies: Object.keys(msgs).map(k => ({ body: msgs[k].body, gid: msgs[k].gid || '' })), |
| 559 | groups: gs.map(g => ({ gid: g.gid, state: g.state, n: (g.members || []).length })), |
| 560 | }; |
| 561 | }); |
| 562 | check('B receives the roster and knows the group', |
| 563 | bGroup.groups.some(g => g.gid === made.gid), |
| 564 | JSON.stringify(bGroup.groups).slice(0, 120), |
| 565 | 'the roster did not reach B through the relay'); |
| 566 | check('B opens the group message, and it is marked as the group\'s', |
| 567 | bGroup.bodies.some(x => x.body === GWORDS && x.gid === made.gid), |
| 568 | JSON.stringify(bGroup.bodies).slice(0, 160), |
| 569 | 'a group message drawn as a one-to-one is a wrong sender on screen'); |
| 570 | |
| 571 | // C was never in the roster and never held a slot. |
| 572 | const cAfter = await boxOf(C); |
| 573 | check('C, who is not in the group, was sent nothing at all', |
| 574 | cAfter.rows.length === 0, `rows=${cAfter.rows.length}`); |
| 575 | |
| 576 | // ── 6. Arrival ───────────────────────────────────────────────── |
| 577 | console.log('\n6. B is told something arrived'); |
| 578 | const unread = await B.page.evaluate(async () => { |
| 579 | const st = await window.DaimondPost.read(); |
| 580 | if (!st || !st.msgs) return -1; |
| 581 | return Object.keys(st.msgs) |
| 582 | .filter(k => st.msgs[k].dir === 'in' && !st.msgs[k].read).length; |
| 583 | }); |
| 584 | // Both of them: the one-to-one and the group message. With Web Push |
| 585 | // declined this count IS the app's only notification, so a message that |
| 586 | // arrived and left the count at zero would arrive invisibly. |
| 587 | check('B\'s record holds both arrivals, unread', unread >= 2, |
| 588 | `unread=${unread}`, |
| 589 | 'nothing would draw a badge, and with push declined the badge is the only notice'); |
| 590 | |
| 591 | // Console errors the PAGE is responsible for. A failed fetch to a |
| 592 | // service this fixture never started is the fixture's, not the app's. |
| 593 | for (const s of [A, B, C]) { |
| 594 | const thrown = errors(s).filter(e => !/Failed to load resource/.test(e)); |
| 595 | check(`${s.name} threw nothing`, thrown.length === 0, thrown.slice(0, 2).join(' | ')); |
| 596 | } |
| 597 | } catch (e) { |
| 598 | check('the run completed', false, String((e && e.stack) || e)); |
| 599 | } finally { |
| 600 | for (const s of [A, B, C]) { try { if (s) await s.close(); } catch (e) { /* gone */ } } |
| 601 | } |
| 602 | |
| 603 | console.log(`\n${ok.length} ok, ${bad.length} failed`); |
| 604 | if (bad.length) { GW_LOG.report(); SRV_LOG.report(8); } |
| 605 | cleanup(); |
| 606 | // A `--break` run EXPECTS to fail: it exits 0 when something reddened and 1 |
| 607 | // when nothing did, so "the break changed nothing" is itself a failing run. |
| 608 | if (BREAK) { |
| 609 | const missed = AIMS[BREAK].filter(a => !bad.some(n => n.startsWith(a))); |
| 610 | console.log(missed.length |
| 611 | ? `\n--break=${BREAK}: these stayed GREEN and should not have — ${missed.join('; ')}. ` |
| 612 | + 'The check aimed at is not testing what it claims.' |
| 613 | : `\n--break=${BREAK}: every check it aims at went red, which is the point.`); |
| 614 | process.exit(missed.length ? 1 : 0); |
| 615 | } |
| 616 | process.exit(bad.length ? 1 : 0); |
| 617 | })(); |