oxedyne/daimond/dev/verify_redeem.mjs
40.9 KiB, 1 run
created by r2519314175:631, 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_redeem.mjs — the beta passcode, from the browser, against a real |
| 2 | // gateway with the beta really shut. |
| 3 | // |
| 4 | // WHAT THIS DEFENDS. The gateway has had `/api/passcode/redeem` and the |
| 5 | // registration gate in front of `/api/account` for some time, and until now |
| 6 | // nothing in the browser called either. A stranger was refused and told nothing |
| 7 | // -- `bootstrap()` wrapped the whole registration in a try/catch and turned a |
| 8 | // deliberate 403 into `offline`, so a refused person landed in BYOK-only mode |
| 9 | // believing the app was broken -- and a person HOLDING a code had nowhere to |
| 10 | // type it. Four properties come out of that, and each one is a way for this to |
| 11 | // go quietly wrong again: |
| 12 | // |
| 13 | // 1. A REFUSAL IS NOT SILENCE. The gate answers with a machine-readable |
| 14 | // `reason`; the browser must read it and say which refusal it was. A |
| 15 | // client that reports a 403 as "the account service is unreachable" points |
| 16 | // the user at their own network for a decision the server took on purpose. |
| 17 | // 2. THERE IS A DOOR, AND IT IS REACHABLE FROM THE REFUSAL. Not a screen |
| 18 | // somewhere that a refused person would have to go looking for. |
| 19 | // 3. AND IT IS STILL THERE LATER. The code usually arrives after the refusal, |
| 20 | // not with it, so the way in has to survive the dialog being dismissed. |
| 21 | // 4. A REFUSED CODE SAYS WHICH REFUSAL IT WAS. The gateway distinguishes a |
| 22 | // code it never issued from one already spent from one that has run out, |
| 23 | // because those send a person to three different places. A client that |
| 24 | // collapses them into one friendly sentence throws that away, and the |
| 25 | // throwing-away is invisible -- everything still "works". |
| 26 | // |
| 27 | // And the decisive one, which is the author's own case end to end: a fresh |
| 28 | // device, a closed gateway, a valid code, and the app ends up SIGNED IN WITH |
| 29 | // PRO -- the same state an ordinary registration reaches, not a second one. |
| 30 | // |
| 31 | // ── The gateway is real, and closed the way production is ─────────── |
| 32 | // |
| 33 | // The critical section that spends a code IS the thing being relied on, so a |
| 34 | // stubbed `/api/passcode/redeem` would prove nothing about it. This starts its |
| 35 | // own gateway on :9412 with an empty store of its own, and closes the beta by |
| 36 | // setting `beta_only` in the COPIED `app.jdat`'s route configuration -- which is |
| 37 | // how jarrah is closed as of today, and a different path through |
| 38 | // `settings::try_bool` from the console override the gateway's own tests drive. |
| 39 | // Both paths deserve to be exercised and only one of them was. |
| 40 | // |
| 41 | // It does NOT touch :9002. Nothing here may go near the shipped gateway. |
| 42 | // |
| 43 | // ── How each check is shown red ───────────────────────────────────── |
| 44 | // |
| 45 | // `--break <name>` serves a deliberately damaged copy of a source file to the |
| 46 | // real page, through `page.route`, so the browser loads it as it loads any other |
| 47 | // script. A break whose anchor does not appear exactly once aborts the run: a |
| 48 | // check proved against code that was never broken is not proved at all. |
| 49 | // |
| 50 | // node dev/verify_redeem.mjs --break blind # the `reason` is not read: the |
| 51 | // # refusal reads as offline and |
| 52 | // # nothing is drawn (1, 2, 3) |
| 53 | // node dev/verify_redeem.mjs --break offline # the reason IS read and still |
| 54 | // # reported as offline (1) |
| 55 | // node dev/verify_redeem.mjs --break onewording # every refused code gets one |
| 56 | // # sentence (4) |
| 57 | // node dev/verify_redeem.mjs --break nosignin # the code is spent and the app |
| 58 | // # never signs in (the decisive one) |
| 59 | // node dev/verify_redeem.mjs --break drawalways # a passcode field is offered to |
| 60 | // # a device that already has an |
| 61 | // # account (the "no control that |
| 62 | // # would refuse" checks) |
| 63 | // node dev/verify_redeem.mjs --break nohook # the Credits drawer's entry is |
| 64 | // # gone (3) |
| 65 | // node dev/verify_redeem.mjs # and then, clean |
| 66 | // |
| 67 | // ── Running it ────────────────────────────────────────────────────── |
| 68 | // |
| 69 | // cd gateway && env -u CARGO_TARGET_DIR cargo build --release |
| 70 | // node dev/verify_redeem.mjs |
| 71 | // |
| 72 | // It owns world 9 (the app on :8786) and :9412, and starts both itself: the dev |
| 73 | // server has to be pointed at THIS gateway rather than at :9002, which |
| 74 | // `dev/world.sh` does not do. Headless, with DISPLAY dropped by the harness. |
| 75 | import fs from 'node:fs'; |
| 76 | import os from 'node:os'; |
| 77 | import path from 'node:path'; |
| 78 | import crypto from 'node:crypto'; |
| 79 | import { spawn } from 'node:child_process'; |
| 80 | import { fileURLToPath } from 'node:url'; |
| 81 | import { requireFreshGateway, procLog, GWDIR, GWBIN } from './gwbin.mjs'; |
| 82 | |
| 83 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 84 | const ROOT = path.join(HERE, '..'); |
| 85 | const WWW = path.join(ROOT, 'www'); |
| 86 | |
| 87 | // ── The world, fixed before anything reads it ─────────────────────── |
| 88 | // |
| 89 | // harness.mjs reads these at import time, so they are set here and the harness |
| 90 | // is imported below with `await import`. World 9, deliberately: 3, 5, 7, 11 and |
| 91 | // 13 are other lanes'. |
| 92 | // |
| 93 | // DELIBERATELY NOT `DAIMOND_PORT`. This file starts a dev server of its own, |
| 94 | // pointed at its own gateway, and `dev/run_all.sh` is run inside a world that |
| 95 | // has already exported `DAIMOND_PORT` for the server everything else shares. |
| 96 | // Reading that would make this verifier try to seize the suite's own port, |
| 97 | // find it held, and refuse -- so the two knobs it honours are its own, and |
| 98 | // the world it is run in cannot reach in and move them. |
| 99 | const APP_PORT = Number(process.env.DAIMOND_REDEEM_PORT || 8786); |
| 100 | const GW_PORT = Number(process.env.DAIMOND_REDEEM_GW_PORT || 9412); |
| 101 | const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond/w9'); |
| 102 | process.env.DAIMOND_PORT = String(APP_PORT); |
| 103 | process.env.DAIMOND_APP = `http://localhost:${APP_PORT}`; |
| 104 | process.env.DAIMOND_GW_PORT = String(GW_PORT); |
| 105 | process.env.DAIMOND_SCRATCH = SCRATCH; |
| 106 | |
| 107 | const GW = `http://127.0.0.1:${GW_PORT}`; |
| 108 | const APP = process.env.DAIMOND_APP; |
| 109 | const WORK = path.join(SCRATCH, 'verify_redeem-gw'); |
| 110 | const LOG = procLog('verify_redeem'); |
| 111 | const SRV = procLog('verify_redeem', 'server'); |
| 112 | |
| 113 | const BREAK = (() => { |
| 114 | const i = process.argv.indexOf('--break'); |
| 115 | if (i > 0) return String(process.argv[i + 1] || ''); |
| 116 | const eq = process.argv.find(a => a.startsWith('--break=')); |
| 117 | return eq ? eq.slice(8) : ''; |
| 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. |
| 123 | const check = (name, pass, detail, why) => { |
| 124 | (pass ? ok : bad).push(name); |
| 125 | const tail = pass ? (detail ? ' — ' + detail : '') |
| 126 | : ' — ' + [why, detail].filter(Boolean).join(' · '); |
| 127 | console.log((pass ? ' ok ' : ' FAIL ') + name + tail); |
| 128 | }; |
| 129 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 130 | |
| 131 | // ── The breaks ─────────────────────────────────────────────────────── |
| 132 | // |
| 133 | // Real edits to real files, served in place of them. Several edits may name the |
| 134 | // same file; they are applied together, so a break needing two lines is still |
| 135 | // one damaged copy rather than two routes fighting over one URL. |
| 136 | |
| 137 | const BREAKS = { |
| 138 | // The `reason` field is never read, which is exactly the state the client |
| 139 | // shipped in: every refusal reduces to "offline" and the app says nothing |
| 140 | // anybody can act on. |
| 141 | blind: [{ |
| 142 | file: 'js/gateway.js', |
| 143 | find: "\t\t\treason: (j && j.reason) || '',", |
| 144 | with: "\t\t\treason: '',", |
| 145 | }], |
| 146 | // The reason IS read, and the refusal is still reported as a gateway that |
| 147 | // could not be reached. Only the half of check 1 that is about `offline` |
| 148 | // should fall; the dialog still appears, so this isolates it. |
| 149 | offline: [{ |
| 150 | file: 'js/gateway.js', |
| 151 | find: '\t\t\t\t\tstate.offline = false;\n\t\t\t\t\tstate.refused = reg.reason;', |
| 152 | with: '\t\t\t\t\tstate.offline = true;\n\t\t\t\t\tstate.refused = reg.reason;', |
| 153 | }], |
| 154 | // One friendly sentence for every refused code. The redemption still fails |
| 155 | // correctly and the user is still told no; what is lost is WHICH no, which |
| 156 | // is the whole reason the gateway tells them apart. |
| 157 | onewording: [{ |
| 158 | file: 'js/gateway.js', |
| 159 | find: "\t\t\tcase 'spent': return t('beta.err_spent');", |
| 160 | with: "\t\t\tcase 'spent': return t('beta.err_unknown');", |
| 161 | }], |
| 162 | // The code is spent and the app never takes the account it just bought. The |
| 163 | // redemption returns 200, the dialog says "you are in", and the app is not. |
| 164 | nosignin: [{ |
| 165 | file: 'js/gateway.js', |
| 166 | find: '\t\tvar authed = await bootstrap();', |
| 167 | with: '\t\tvar authed = false;', |
| 168 | }], |
| 169 | // A passcode field wherever the block is drawn, account or no account. |
| 170 | drawalways: [{ |
| 171 | file: 'js/passcode.js', |
| 172 | find: '\t\tif (s.authed) return;', |
| 173 | with: '\t\tif (s.authed && false) return;', |
| 174 | }], |
| 175 | // The Credits drawer's entry, and only that. The hook daimond.js calls is |
| 176 | // removed AND the standing redraws are, so nothing but the drawer could |
| 177 | // fill the block -- and nothing does. The dialog is untouched, so the |
| 178 | // refusal checks stay green and check 3 falls alone. |
| 179 | // It CASCADES, and that is worth saying rather than leaving to be |
| 180 | // discovered: with the drawer's entry gone the dialog cannot be reopened |
| 181 | // from it, so the code-typing checks that drive through it have nothing to |
| 182 | // type into and fall behind it. What the break isolates is which check falls |
| 183 | // FIRST -- the drawer entry -- and that the refusal itself is untouched: |
| 184 | // checks 1 and 2 stay green, so the dialog is demonstrably still working |
| 185 | // when the drawer stops carrying it. |
| 186 | nohook: [ |
| 187 | { |
| 188 | file: 'js/passcode.js', |
| 189 | find: '\twindow.DaimondCredits = { render: render };', |
| 190 | with: '\twindow.DaimondCredits = null;', |
| 191 | }, |
| 192 | { |
| 193 | file: 'js/passcode.js', |
| 194 | find: "\t\twindow.addEventListener('daimond:authed', refresh);", |
| 195 | with: "\t\tvoid 0;", |
| 196 | }, |
| 197 | { |
| 198 | file: 'js/passcode.js', |
| 199 | find: '\t\trefresh();\n\t}\n\n\t// ── Public surface', |
| 200 | with: '\t}\n\n\t// ── Public surface', |
| 201 | }, |
| 202 | { |
| 203 | file: 'js/passcode.js', |
| 204 | find: '\t\trefresh();\n\t\tif (!reason || !canSign()) return;', |
| 205 | with: '\t\tif (!reason || !canSign()) return;', |
| 206 | }, |
| 207 | { |
| 208 | file: 'js/passcode.js', |
| 209 | find: '\t\t\tsetTimeout(refresh, 0);', |
| 210 | with: '\t\t\tvoid 0;', |
| 211 | }, |
| 212 | ], |
| 213 | }; |
| 214 | |
| 215 | if (BREAK && !BREAKS[BREAK]) { |
| 216 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 217 | process.exit(2); |
| 218 | } |
| 219 | |
| 220 | /// Every file a break touches, each with all of its edits applied, or a hard |
| 221 | /// stop. An anchor that does not appear exactly once means nothing was broken |
| 222 | /// and the run below would prove the opposite of what it claims. |
| 223 | function damagedFiles() { |
| 224 | const byFile = new Map(); |
| 225 | for (const spec of BREAKS[BREAK]) { |
| 226 | if (!byFile.has(spec.file)) byFile.set(spec.file, fs.readFileSync(path.join(WWW, spec.file), 'utf8')); |
| 227 | const src = byFile.get(spec.file); |
| 228 | const n = src.split(spec.find).length - 1; |
| 229 | if (n !== 1) { |
| 230 | console.error(`break '${BREAK}': an anchor appears ${n} times in ${spec.file}, ` |
| 231 | + 'so nothing was broken and the run below would prove nothing.'); |
| 232 | process.exit(2); |
| 233 | } |
| 234 | byFile.set(spec.file, src.replace(spec.find, spec.with)); |
| 235 | } |
| 236 | return byFile; |
| 237 | } |
| 238 | |
| 239 | // ── A gateway of its own, closed the way production is ─────────────── |
| 240 | |
| 241 | /// Build the working directory, and hand back its path. |
| 242 | /// |
| 243 | /// `closed` writes `beta_only` into the `/api/account` route's configuration -- |
| 244 | /// the same place jarrah's `app.jdat` now carries it. The gateway's own tests |
| 245 | /// close the beta through a console override in the store; this exercises the |
| 246 | /// other half of `settings::try_bool`, which is the half production runs on. |
| 247 | function buildWorkDir(closed) { |
| 248 | fs.rmSync(WORK, { recursive: true, force: true }); |
| 249 | fs.mkdirSync(path.join(WORK, 'keys'), { recursive: true }); |
| 250 | // Every key EXCEPT the database's: the store is new, so its at-rest key must |
| 251 | // be new too. The licence key matters here -- a gifted Pro has to be signed |
| 252 | // with the same key a bought one is, or the client would be believing |
| 253 | // something this run invented. |
| 254 | for (const k of ['licence', 'stripe', 'openrouter']) { |
| 255 | const from = path.join(GWDIR, 'keys', k); |
| 256 | if (fs.existsSync(from)) fs.symlinkSync(from, path.join(WORK, 'keys', k)); |
| 257 | } |
| 258 | writeConfig(closed); |
| 259 | return WORK; |
| 260 | } |
| 261 | |
| 262 | /// Write `app.jdat` into the working directory with the port moved and the beta |
| 263 | /// open or shut. Called twice: the owner account has to be minted while the door |
| 264 | /// is open, because a closed gateway refuses it too. |
| 265 | function writeConfig(closed) { |
| 266 | let cfg = fs.readFileSync(path.join(GWDIR, 'app.jdat'), 'utf8') |
| 267 | .replace(/"listen_port":\s*\(u16\|\d+\)/, `"listen_port": (u16|${GW_PORT})`); |
| 268 | if (!cfg.includes(`(u16|${GW_PORT})`)) { |
| 269 | console.log(' FAIL could not set the listen port in the copied app.jdat — ' |
| 270 | + 'has its shape changed?'); |
| 271 | process.exit(1); |
| 272 | } |
| 273 | // Asserted on the KEY being there, not on the text changing. The open pass |
| 274 | // writes the value the shipped file already carries, so "nothing moved" is |
| 275 | // the correct outcome there and treating it as a failure stopped the first |
| 276 | // run of this file before it had started. |
| 277 | const key = /"beta_only":\s*"(true|false)"/g; |
| 278 | const found = (cfg.match(key) || []).length; |
| 279 | if (found !== 1) { |
| 280 | console.log(` FAIL the /api/account route in app.jdat carries beta_only ${found} times, ` |
| 281 | + 'so this run could not close the beta. The gate is the whole subject of this file ' |
| 282 | + 'and a run that could not set it would measure nothing.'); |
| 283 | process.exit(1); |
| 284 | } |
| 285 | const want = `"beta_only": "${closed ? 'true' : 'false'}"`; |
| 286 | const next = cfg.replace(/"beta_only":\s*"(true|false)"/, want); |
| 287 | if (!next.includes(want)) { |
| 288 | console.log(' FAIL could not set beta_only in the copied app.jdat.'); |
| 289 | process.exit(1); |
| 290 | } |
| 291 | fs.writeFileSync(path.join(WORK, 'app.jdat'), next); |
| 292 | } |
| 293 | |
| 294 | // ── Device identities, exactly as the app builds them ─────────────── |
| 295 | |
| 296 | /// A fresh device keypair, and the two things the gateway asks of it. Used for |
| 297 | /// the owner account and the gatecrasher; the DEVICE UNDER TEST is the browser's |
| 298 | /// own, minted by identity.js. |
| 299 | function device() { |
| 300 | const kp = crypto.generateKeyPairSync('ed25519'); |
| 301 | const raw = kp.publicKey.export({ type: 'spki', format: 'der' }).subarray(-32); |
| 302 | const b64url = b => b.toString('base64') |
| 303 | .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); |
| 304 | return { |
| 305 | pub: b64url(raw), |
| 306 | alg: 'Ed25519', |
| 307 | sign: s => crypto.sign(null, Buffer.from(s, 'utf8'), kp.privateKey).toString('base64'), |
| 308 | }; |
| 309 | } |
| 310 | |
| 311 | /// The binding proof `/api/account` and `/api/passcode/redeem` both demand. |
| 312 | function binding(dev) { |
| 313 | const ts = Math.floor(Date.now() / 1000); |
| 314 | return { pubkey: dev.pub, alg: dev.alg, ts, sig: dev.sign(`daimond-gw-account:v1:${dev.pub}:${ts}`) }; |
| 315 | } |
| 316 | |
| 317 | /// One HTTP call to the gateway, carrying a cookie jar this file owns. |
| 318 | async function call(jar, method, url, body, xff) { |
| 319 | const headers = { 'x-daimond-api': '1' }; |
| 320 | if (jar && jar.cookie) headers.cookie = jar.cookie; |
| 321 | if (body !== undefined) headers['content-type'] = 'application/json'; |
| 322 | // Talking to the gateway directly there is no Steel in front to append one, |
| 323 | // so every unnamed request shares the single "unknown" bucket -- which is |
| 324 | // the bucket the BROWSER is in, through the dev server's proxy. Anything |
| 325 | // here that could be refused names an address of its own, so it cannot spend |
| 326 | // the browser's attempts. |
| 327 | if (xff) headers['x-forwarded-for'] = xff; |
| 328 | const r = await fetch(GW + url, { |
| 329 | method, headers, |
| 330 | body: body === undefined ? undefined : JSON.stringify(body), |
| 331 | }); |
| 332 | const set = r.headers.getSetCookie ? r.headers.getSetCookie() : []; |
| 333 | if (jar && set.length) jar.cookie = set.map(c => c.split(';')[0]).join('; '); |
| 334 | let j = null; |
| 335 | try { j = await r.json(); } catch (e) {} |
| 336 | return { status: r.status, j }; |
| 337 | } |
| 338 | |
| 339 | /// Prove possession of the key and take a session cookie. |
| 340 | async function session(jar, dev) { |
| 341 | const ch = await call(jar, 'POST', '/api/auth/challenge', { pubkey: dev.pub, alg: dev.alg }); |
| 342 | if (!ch.j || !ch.j.challenge) return false; |
| 343 | const v = await call(jar, 'POST', '/api/auth/verify', { |
| 344 | challenge_id: ch.j.challenge_id, |
| 345 | sig: dev.sign(ch.j.challenge), |
| 346 | }); |
| 347 | return v.status === 200; |
| 348 | } |
| 349 | |
| 350 | const procs = []; |
| 351 | function cleanup() { for (const p of procs) { try { p.kill('SIGKILL'); } catch (e) {} } } |
| 352 | |
| 353 | async function waitFor(fn, ms = 20000, gap = 250) { |
| 354 | const t0 = Date.now(); |
| 355 | for (;;) { |
| 356 | try { if (await fn()) return true; } catch (e) {} |
| 357 | if (Date.now() - t0 > ms) return false; |
| 358 | await sleep(gap); |
| 359 | } |
| 360 | } |
| 361 | |
| 362 | /// Start the gateway in the working directory, and wait for it to serve. |
| 363 | /// |
| 364 | /// Generous, because an empty o3db spends twenty-odd seconds initialising its |
| 365 | /// zones before anything listens. |
| 366 | async function startGateway(cwd, ownerAccount) { |
| 367 | const gw = spawn(GWBIN, [], { |
| 368 | cwd, |
| 369 | env: { |
| 370 | ...process.env, |
| 371 | APP_MODE: 'sandbox', |
| 372 | ...(ownerAccount ? { DAIMOND_OWNER_ACCOUNTS: ownerAccount } : {}), |
| 373 | }, |
| 374 | stdio: LOG.stdio, |
| 375 | }); |
| 376 | procs.push(gw); |
| 377 | const up = await waitFor(async () => (await fetch(`${GW}/api/health`)).ok, 120000); |
| 378 | return { gw, up }; |
| 379 | } |
| 380 | |
| 381 | /// Stop it, and wait for the PORT to be free rather than for the process to be |
| 382 | /// signalled: a second gateway started on the strength of a `kill` returning |
| 383 | /// meets `AddrInUse` and dies, and the run that follows measures nothing. |
| 384 | async function stopGateway(gw) { |
| 385 | try { gw.kill('SIGKILL'); } catch (e) {} |
| 386 | await waitFor(async () => { |
| 387 | try { await fetch(`${GW}/api/health`); return false; } |
| 388 | catch (e) { return true; } |
| 389 | }, 30000, 200); |
| 390 | await sleep(500); |
| 391 | } |
| 392 | |
| 393 | /// The dev server, pointed at THIS gateway rather than at :9002. |
| 394 | async function startServer() { |
| 395 | const srv = spawn(process.execPath, [path.join(HERE, 'serve.mjs')], { |
| 396 | cwd: ROOT, |
| 397 | env: { ...process.env, DAIMOND_PORT: String(APP_PORT), DAIMOND_GW_PORT: String(GW_PORT) }, |
| 398 | stdio: SRV.stdio, |
| 399 | }); |
| 400 | procs.push(srv); |
| 401 | return await waitFor(async () => (await fetch(APP + '/index.html')).ok, 20000); |
| 402 | } |
| 403 | |
| 404 | (async () => { |
| 405 | requireFreshGateway(); |
| 406 | |
| 407 | for (const [what, url] of [['gateway', `${GW}/api/health`], ['app server', APP + '/index.html']]) { |
| 408 | let stray = false; |
| 409 | try { stray = (await fetch(url)).ok; } catch (e) {} |
| 410 | if (stray) { |
| 411 | console.log(` FAIL something is already answering as the ${what} on ${url}. This ` |
| 412 | + 'suite starts both itself, pins an owner and closes the beta in configuration, ' |
| 413 | + 'so it cannot share either. Free the port, or set DAIMOND_REDEEM_PORT / ' |
| 414 | + 'DAIMOND_REDEEM_GW_PORT.'); |
| 415 | process.exit(1); |
| 416 | } |
| 417 | } |
| 418 | |
| 419 | // ── The gateway: open, then shut ──────────────────────────── |
| 420 | // |
| 421 | // The owner has to exist before the process that pins them, and before the |
| 422 | // door closes -- a closed gateway refuses the owner's own registration just |
| 423 | // as it refuses everyone else's. So: open, mint the owner, close, restart. |
| 424 | const cwd = buildWorkDir(false); |
| 425 | let started = await startGateway(cwd, null); |
| 426 | check('gateway starts', started.up); |
| 427 | if (!started.up) { LOG.report(); cleanup(); process.exit(1); } |
| 428 | |
| 429 | const bossDev = device(); |
| 430 | const boss = { dev: bossDev, jar: {} }; |
| 431 | const made = await call(boss.jar, 'POST', '/api/account', binding(bossDev)); |
| 432 | boss.id = made.j && made.j.account_id; |
| 433 | check('an account to be the owner, made while the beta is still open', !!boss.id, boss.id); |
| 434 | if (!boss.id) { LOG.report(); cleanup(); process.exit(1); } |
| 435 | |
| 436 | await stopGateway(started.gw); |
| 437 | writeConfig(true); |
| 438 | started = await startGateway(cwd, boss.id); |
| 439 | check('gateway restarts with the beta CLOSED in app.jdat and that account as owner', |
| 440 | started.up); |
| 441 | if (!started.up) { LOG.report(); cleanup(); process.exit(1); } |
| 442 | await session(boss.jar, bossDev); |
| 443 | const who = await call(boss.jar, 'GET', '/api/admin?view=whoami'); |
| 444 | check('the console recognises the owner', who.j && who.j.role === 'owner', |
| 445 | JSON.stringify(who.j)); |
| 446 | |
| 447 | // BUILD THE LISTING INDEXES ONCE, because the gateway no longer builds them itself. |
| 448 | // The owner's decision: a whole-store walk is off the request path, so an unbuilt |
| 449 | // listing answers `needs_build` immediately and walks nothing. The console is the only |
| 450 | // thing that builds one, by an operator pressing a button; this run does the same. |
| 451 | // Without it the passcode list reads "status 200 · 0 rows" and a working product is |
| 452 | // reported as broken. See dev/verify_applications.mjs for the whole reason. |
| 453 | for (const view of ['applications', 'passcodes', 'reports']) { |
| 454 | await call(boss.jar, 'GET', `/api/admin?view=${view}&build=1`); |
| 455 | } |
| 456 | |
| 457 | // The gate bites before a browser is anywhere near it. Asserted here as well |
| 458 | // as through the app, because a client-side check that passed against a |
| 459 | // gateway which was never shut would be the worst kind of green. |
| 460 | const outsider = await call(null, 'POST', '/api/account', binding(device()), '198.51.100.201'); |
| 461 | check('THE GATE BITES: a fresh device with a real signature is refused an account', |
| 462 | outsider.status === 403 && !!outsider.j && outsider.j.reason === 'beta_only', |
| 463 | 'status ' + outsider.status + ' · ' + JSON.stringify(outsider.j && outsider.j.reason), |
| 464 | 'the beta is configured closed and a stranger was still registered'); |
| 465 | |
| 466 | /// Mint a passcode from the console, as the operator does. |
| 467 | /// |
| 468 | /// `pro` is the tier the panel's own pulldown sends. Since 2026-08-17 a code |
| 469 | /// grants the FREE tier unless somebody asks for Pro, so the author's code |
| 470 | /// below asks for it deliberately: the decisive check further down is about |
| 471 | /// the GATE and the sign-in, and a free code would have turned it red for a |
| 472 | /// reason that has nothing to do with either. The free tier gets a device and |
| 473 | /// a phase of its own at the end. |
| 474 | async function mint(label, pro) { |
| 475 | const r = await call(boss.jar, 'POST', '/api/admin?view=passcodes', |
| 476 | { label, wave: 1, pro: pro === true }); |
| 477 | return (r.j && r.j.passcode && r.j.passcode.code) || ''; |
| 478 | } |
| 479 | |
| 480 | const spentCode = await mint('Spent before the browser saw it'); |
| 481 | const goodCode = await mint('The author, on a fresh device', true); |
| 482 | const freeCode = await mint('A free tester, on a fresh device'); |
| 483 | check('three passcodes minted from the console', |
| 484 | !!spentCode && !!goodCode && !!freeCode, |
| 485 | spentCode + ' / ' + goodCode + ' / ' + freeCode); |
| 486 | if (!spentCode || !goodCode || !freeCode) { LOG.report(); cleanup(); process.exit(1); } |
| 487 | |
| 488 | // One of them is spent by somebody else, from an address of its own, so the |
| 489 | // browser meets a code that is genuinely gone rather than one this file |
| 490 | // merely calls spent. |
| 491 | const crasher = device(); |
| 492 | const took = await call(null, 'POST', '/api/passcode/redeem', |
| 493 | Object.assign({ code: spentCode }, binding(crasher)), '198.51.100.202'); |
| 494 | check('one of them is spent by another device first', took.status === 200, |
| 495 | 'status ' + took.status); |
| 496 | |
| 497 | // ── The app ───────────────────────────────────────────────── |
| 498 | |
| 499 | const up = await startServer(); |
| 500 | check('the dev server is up and proxying to this gateway', up, APP); |
| 501 | if (!up) { SRV.report(); cleanup(); process.exit(1); } |
| 502 | |
| 503 | const { open, shot, signInAs, errors } = await import('./harness.mjs'); |
| 504 | const PROFILE = path.join(SCRATCH, 'pw', 'redeem' + (BREAK ? '-' + BREAK : '')); |
| 505 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 506 | |
| 507 | const damaged = BREAK ? damagedFiles() : new Map(); |
| 508 | const s = await open({ |
| 509 | name: 'redeem', |
| 510 | profile: PROFILE, |
| 511 | signIn: false, // the gate itself is the subject; sign in below, watching |
| 512 | connect: false, |
| 513 | defaults: false, |
| 514 | route: async (page) => { |
| 515 | for (const [file, body] of damaged) { |
| 516 | await page.route('**/' + file, r => r.fulfill({ |
| 517 | status: 200, contentType: 'application/javascript', body, |
| 518 | })); |
| 519 | } |
| 520 | }, |
| 521 | }); |
| 522 | const { page } = s; |
| 523 | |
| 524 | /// The gateway's answer to the registration round, as the browser saw it. |
| 525 | const registrations = []; |
| 526 | page.on('response', async (r) => { |
| 527 | if (!/\/api\/account(\?|$)/.test(r.url()) || r.request().method() !== 'POST') return; |
| 528 | let j = null; |
| 529 | try { j = await r.json(); } catch (e) {} |
| 530 | registrations.push({ status: r.status(), reason: (j && j.reason) || '' }); |
| 531 | }); |
| 532 | |
| 533 | /// What the gateway module holds about this device. |
| 534 | const gwState = () => page.evaluate(() => { |
| 535 | try { return window.DaimondGateway.state(); } catch (e) { return {}; } |
| 536 | }); |
| 537 | /// The text of an element, or '' when it is not there. |
| 538 | const textOf = (sel) => page.evaluate((q) => { |
| 539 | const n = document.querySelector(q); |
| 540 | return n ? (n.textContent || '').trim() : ''; |
| 541 | }, sel); |
| 542 | /// A catalogue sentence, so a check can compare what is on screen against |
| 543 | /// what the app is supposed to be saying rather than against a copy of it |
| 544 | /// pasted in here -- which would go stale silently. |
| 545 | const says = (key) => page.evaluate(k => window.DaimondI18n.t(k), key); |
| 546 | |
| 547 | try { |
| 548 | // ── Nothing is offered where nothing could work ────── |
| 549 | // |
| 550 | // Before there is an identity there is no key to sign a redemption with, |
| 551 | // so a passcode field would be a control that refuses the moment it is |
| 552 | // used. Asked before sign-in, which is the only moment this state exists. |
| 553 | const beforeAny = await page.evaluate(() => { |
| 554 | try { window.DaimondPasscode.render(); } catch (e) { return 'threw: ' + e.message; } |
| 555 | const h = document.getElementById('credits-beta'); |
| 556 | return h ? h.innerHTML.trim() : 'missing'; |
| 557 | }); |
| 558 | check('no passcode field before there is a key to sign one with', |
| 559 | beforeAny === '', JSON.stringify(beforeAny), |
| 560 | 'a control was drawn that could only have refused'); |
| 561 | |
| 562 | // ── The refusal ───────────────────────────────────── |
| 563 | await signInAs(s, 'redeem'); |
| 564 | await page.waitForTimeout(3000); // the bootstrap, and the dialog behind it |
| 565 | |
| 566 | const reg = registrations[registrations.length - 1] || {}; |
| 567 | check('the app\'s own registration round is refused by the closed beta', |
| 568 | reg.status === 403 && reg.reason === 'beta_only', |
| 569 | 'status ' + reg.status + ' · reason ' + JSON.stringify(reg.reason)); |
| 570 | |
| 571 | let st = await gwState(); |
| 572 | check('1. the app knows it was REFUSED, and says which refusal', |
| 573 | st.refused === 'beta_only', 'refused ' + JSON.stringify(st.refused), |
| 574 | 'the reason came back on the wire and nothing in the client read it'); |
| 575 | check('1. and does not report it as being offline', |
| 576 | st.offline === false && st.authed === false, |
| 577 | 'offline ' + st.offline + ' · authed ' + st.authed, |
| 578 | 'a deliberate refusal was reported as a gateway that could not be reached, ' |
| 579 | + 'which sends the user to look at their own network'); |
| 580 | |
| 581 | // The rail's account row. It must not be claiming the service is |
| 582 | // unreachable, because it is not: it answered. |
| 583 | const unreachable = await says('astat.service_unreachable'); |
| 584 | const accountRow = await textOf('#astat-account'); |
| 585 | check('1. and the status row does not claim the service is unreachable', |
| 586 | !!accountRow && accountRow.indexOf(unreachable) < 0, |
| 587 | JSON.stringify(accountRow), 'the row is telling the user the wrong thing'); |
| 588 | |
| 589 | // ── 2. The refusal carries the way in ─────────────── |
| 590 | const dialog = await page.$('.beta-scrim'); |
| 591 | check('2. the refusal puts itself on screen', !!dialog, null, |
| 592 | 'the user was dropped into browser-only mode and told nothing at all'); |
| 593 | const dialogText = await textOf('.beta-box'); |
| 594 | // Asserted by MEANING: the sentence the gateway itself insists on -- that |
| 595 | // only the account is closed and the app works without one -- has to |
| 596 | // survive into what the user reads, in whatever words the catalogue uses. |
| 597 | const honesty = await says('beta.lead_beta_only'); |
| 598 | check('2. and says only the ACCOUNT is closed, not the app', |
| 599 | dialogText.indexOf(honesty) >= 0 && honesty.indexOf('no account at all') >= 0, |
| 600 | null, 'the refusal reads as the app being shut'); |
| 601 | const hasField = await page.$('#beta-code-input'); |
| 602 | const hasGo = await page.evaluate(() => { |
| 603 | const b = [].slice.call(document.querySelectorAll('.beta-box .beta-btn')); |
| 604 | return b.some(x => x.textContent.trim() === window.DaimondI18n.t('beta.redeem')); |
| 605 | }); |
| 606 | check('2. and carries the code field and the button, from the refusal itself', |
| 607 | !!hasField && hasGo, 'field ' + !!hasField + ' · button ' + hasGo); |
| 608 | await shot(s, 'redeem-refusal'); |
| 609 | |
| 610 | // ── 3. And it is still findable afterwards ────────── |
| 611 | // |
| 612 | // Dismissed, the way somebody without a code in hand dismisses it. The |
| 613 | // code arrives days later, and the way in has to be somewhere they would |
| 614 | // look: the Credits drawer, which is where this app answers "what account |
| 615 | // have I got". Reached through the SAME call the status row makes. |
| 616 | await page.keyboard.press('Escape'); |
| 617 | await page.waitForTimeout(300); |
| 618 | check('3. the dialog can be dismissed', !(await page.$('.beta-scrim'))); |
| 619 | // The two calls the status row makes, in its order: the rail, then the |
| 620 | // Credits view. Driven through the app's own entry points so this is the |
| 621 | // path a user takes and not a private one. |
| 622 | await page.evaluate(() => { |
| 623 | try { window.DaimondPanels.show('rail'); } catch (e) { /* narrow window */ } |
| 624 | window.DaimondAdmin.credits(''); |
| 625 | }); |
| 626 | await page.waitForTimeout(500); |
| 627 | const drawerBtn = await page.$('#beta-open'); |
| 628 | check('3. the Credits drawer carries the way in afterwards', !!drawerBtn, null, |
| 629 | 'somebody who gets a code after being refused has nowhere to type it'); |
| 630 | await shot(s, 'redeem-drawer'); |
| 631 | // A direct DOM click, as the harness does elsewhere: the drawer animates, |
| 632 | // and Playwright's stability check waits for a second frame that a card |
| 633 | // mid-transition has not produced yet. |
| 634 | await page.evaluate(() => { |
| 635 | const b = document.getElementById('beta-open'); |
| 636 | if (b) b.click(); |
| 637 | }); |
| 638 | await page.waitForTimeout(500); |
| 639 | check('3. and it opens the same screen', !!(await page.$('#beta-code-input'))); |
| 640 | |
| 641 | /// Type a code into the open dialog and press Redeem. Returns the error |
| 642 | /// line, or '' when it was accepted. |
| 643 | /// |
| 644 | /// WAITS FOR AN OUTCOME rather than for a fixed interval. A redemption |
| 645 | /// that is accepted goes on to a whole registration round -- register, |
| 646 | /// challenge, verify, balance, licence -- and a fixed sleep short of that |
| 647 | /// would read an empty error line as a success while the request was |
| 648 | /// still in the air, which is the same green whatever happens next. |
| 649 | async function tryCode(code) { |
| 650 | // The value is set on the element and the button clicked in the DOM, |
| 651 | // for the reason the harness gives about forced clicks: a card that |
| 652 | // is animating has not produced the second frame Playwright's |
| 653 | // stability check waits for. Nothing in this dialog listens for an |
| 654 | // `input` event -- the submit reads `input.value` -- so a plain |
| 655 | // assignment is the same thing a person's typing leaves behind. |
| 656 | await page.evaluate((c) => { |
| 657 | const i = document.getElementById('beta-code-input'); |
| 658 | if (i) i.value = c; |
| 659 | const b = [].slice.call(document.querySelectorAll('.beta-box .beta-btn')); |
| 660 | const go = b.filter(x => !x.classList.contains('ghost')).pop(); |
| 661 | if (go) go.click(); |
| 662 | }, code); |
| 663 | const settled = await waitFor(async () => await page.evaluate(() => { |
| 664 | const box = document.querySelector('.beta-box'); |
| 665 | if (!box) return true; // dismissed under us |
| 666 | const err = box.querySelector('.beta-err'); |
| 667 | if (err && err.textContent.trim()) return true; // refused, and said so |
| 668 | return !err; // the confirmation replaced the form |
| 669 | }), 30000, 200); |
| 670 | if (!settled) return '(the dialog never answered)'; |
| 671 | return await textOf('.beta-err'); |
| 672 | } |
| 673 | |
| 674 | // ── 4. Which refusal it was ───────────────────────── |
| 675 | const mistyped = await tryCode('zzzz-zzzz-zzz7'); |
| 676 | const wantMis = await says('beta.err_unknown'); |
| 677 | check('4. a mistyped code is told it was not one we issued', |
| 678 | !!mistyped && mistyped === wantMis, JSON.stringify(mistyped), |
| 679 | 'a typo was reported as something else, or as nothing'); |
| 680 | |
| 681 | await sleep(800); // under the rate ceiling |
| 682 | const spent = await tryCode(spentCode); |
| 683 | const wantSpent = await says('beta.err_spent'); |
| 684 | check('4. a spent code is told it was already used', |
| 685 | !!spent && spent === wantSpent, JSON.stringify(spent)); |
| 686 | check('4. and the two are DIFFERENT sentences, so a tester can tell which happened', |
| 687 | !!mistyped && !!spent && mistyped !== spent, null, |
| 688 | 'both refusals said the same thing, which hides the one fact the person needs'); |
| 689 | |
| 690 | // ── The decisive one ──────────────────────────────── |
| 691 | // |
| 692 | // A fresh device, a closed gateway, a valid code -- and the app ends up |
| 693 | // signed in with Pro. The same key the gate refused a minute ago, so the |
| 694 | // 403 above cannot have been anything but the gate. |
| 695 | await sleep(800); |
| 696 | const accepted = await tryCode(goodCode); |
| 697 | check('a valid code is accepted', accepted === '', JSON.stringify(accepted)); |
| 698 | await page.waitForTimeout(2500); |
| 699 | const doneTitle = await says('beta.done_title'); |
| 700 | const boxText = await textOf('.beta-box'); |
| 701 | check('and the screen says so', boxText.indexOf(doneTitle) >= 0, JSON.stringify(boxText.slice(0, 80))); |
| 702 | await shot(s, 'redeem-done'); |
| 703 | |
| 704 | st = await gwState(); |
| 705 | check('THE APP IS SIGNED IN', st.authed === true, |
| 706 | 'authed ' + st.authed + ' · offline ' + st.offline, |
| 707 | 'the code was spent and the app never took the account it bought'); |
| 708 | check('with Pro on it', st.pro === true, 'pro ' + JSON.stringify(st.pro), |
| 709 | 'the beta grant is a five-year Pro licence and the client is not seeing it'); |
| 710 | check('and the refusal is forgotten', !st.refused && st.offline === false, |
| 711 | 'refused ' + JSON.stringify(st.refused) + ' · offline ' + st.offline); |
| 712 | |
| 713 | // The oracle: the CONSOLE says which account spent the code, and the |
| 714 | // browser says which account it holds a session on. Neither is derived |
| 715 | // from the other. |
| 716 | const mine = await page.evaluate(() => fetch('/api/account', { |
| 717 | credentials: 'same-origin', headers: { 'x-daimond-api': '1' }, |
| 718 | }).then(r => r.json()).catch(() => ({}))); |
| 719 | const listed = await call(boss.jar, 'GET', '/api/admin?view=passcodes'); |
| 720 | const row = ((listed.j && listed.j.passcodes) || []) |
| 721 | .find(p => p.label === 'The author, on a fresh device'); |
| 722 | check('the account the browser holds is the one the console says redeemed the code', |
| 723 | !!row && !!mine.account_id && row.redeemed_by === mine.account_id, |
| 724 | (row && row.redeemed_by) + ' vs ' + mine.account_id); |
| 725 | check('and the console no longer shows a spent code', !!row && row.code === '', |
| 726 | JSON.stringify(row && row.code)); |
| 727 | |
| 728 | // ── Nothing that would refuse ─────────────────────── |
| 729 | await page.evaluate(() => { |
| 730 | const c = document.querySelector('.beta-scrim'); |
| 731 | if (c) c.remove(); |
| 732 | window.DaimondAdmin.credits(''); |
| 733 | }); |
| 734 | await page.waitForTimeout(600); |
| 735 | // THE SUBJECT IS THE OFFER, NOT THE CONTAINER. This read `textOf('#credits-beta') |
| 736 | // === ''` until 2026-08-15 and went red the day the telemetry consent card |
| 737 | // started drawing in that host -- which is not an offer of a passcode, and is |
| 738 | // where withdrawal deliberately lives (`passcode.js:475-483`). It was also |
| 739 | // vacuous in the other direction: `textOf` answers '' for a node that is |
| 740 | // MISSING exactly as for one that is empty, so a host that vanished would have |
| 741 | // passed it. So: assert the host EXISTS, then assert that neither thing which |
| 742 | // offers a code is inside it. |
| 743 | const after = await page.evaluate(() => { |
| 744 | const h = document.getElementById('credits-beta'); |
| 745 | if (!h) return { host: false }; |
| 746 | return { |
| 747 | host: true, |
| 748 | open: !!h.querySelector('#beta-open'), |
| 749 | field: !!h.querySelector('#beta-code-input'), |
| 750 | text: (h.textContent || '').trim().slice(0, 120), |
| 751 | }; |
| 752 | }); |
| 753 | check('the Credits beta block is still on the page to be judged', |
| 754 | after.host === true, JSON.stringify(after), |
| 755 | 'the host went missing, which the old emptiness test would have called a pass'); |
| 756 | check('nothing offers a passcode to a device that now has an account', |
| 757 | after.host === true && !after.open && !after.field, JSON.stringify(after), |
| 758 | 'a way to enter a code was left on screen for a device with nothing left to redeem'); |
| 759 | |
| 760 | // Uncaught exceptions only. Console errors are not the signal here: a |
| 761 | // closed gateway refuses several routes on purpose and the modules that |
| 762 | // meet those refusals say so on the console, which is correct behaviour |
| 763 | // and would make this line red for the very state the file is testing. |
| 764 | const errs = errors(s).filter(e => /^pageerror:/.test(e)); |
| 765 | check('nothing on the page threw', errs.length === 0, errs.slice(0, 3).join(' | ')); |
| 766 | } finally { |
| 767 | await s.close().catch(() => {}); |
| 768 | } |
| 769 | |
| 770 | // ── The free tier, on a device of its own ─────────────────── |
| 771 | // |
| 772 | // The most-read screen in the beta, and until 2026-08-17 nobody had ever seen |
| 773 | // it: every code gifted Pro, so `beta.done_plain` was drawn in the source and |
| 774 | // unreachable in fact. A free code now reaches it, which makes what it SAYS a |
| 775 | // property worth holding -- so this redeems one and reads the screen. |
| 776 | // |
| 777 | // A second browser, after the first has closed, because the redemption is |
| 778 | // bound to a device key and the device above has spent its code. Sequential |
| 779 | // rather than side by side: two Chromiums at once on this box is memory |
| 780 | // nobody needs to spend on a phase this short. |
| 781 | const FREEPROF = path.join(SCRATCH, 'pw', 'redeemfree' + (BREAK ? '-' + BREAK : '')); |
| 782 | fs.rmSync(FREEPROF, { recursive: true, force: true }); |
| 783 | const f = await open({ |
| 784 | name: 'redeemfree', |
| 785 | profile: FREEPROF, |
| 786 | signIn: false, |
| 787 | connect: false, |
| 788 | defaults: false, |
| 789 | }); |
| 790 | try { |
| 791 | const fpage = f.page; |
| 792 | const fsays = (key) => fpage.evaluate(k => window.DaimondI18n.t(k), key); |
| 793 | const ftext = (sel) => fpage.evaluate((q) => { |
| 794 | const n = document.querySelector(q); |
| 795 | return n ? (n.textContent || '').trim() : ''; |
| 796 | }, sel); |
| 797 | await signInAs(f, 'redeemfree'); |
| 798 | await fpage.waitForTimeout(3000); // the bootstrap, and the refusal behind it |
| 799 | const gate = await fpage.$('.beta-scrim'); |
| 800 | check('the free device meets the same closed door', !!gate); |
| 801 | |
| 802 | await fpage.evaluate((c) => { |
| 803 | const i = document.getElementById('beta-code-input'); |
| 804 | if (i) i.value = c; |
| 805 | const b = [].slice.call(document.querySelectorAll('.beta-box .beta-btn')); |
| 806 | const go = b.filter(x => !x.classList.contains('ghost')).pop(); |
| 807 | if (go) go.click(); |
| 808 | }, freeCode); |
| 809 | const settled = await waitFor(async () => await fpage.evaluate(() => { |
| 810 | const box = document.querySelector('.beta-box'); |
| 811 | if (!box) return true; |
| 812 | const err = box.querySelector('.beta-err'); |
| 813 | if (err && err.textContent.trim()) return true; |
| 814 | return !err; |
| 815 | }), 30000, 200); |
| 816 | check('a free code is accepted', settled && (await ftext('.beta-err')) === '', |
| 817 | JSON.stringify(await ftext('.beta-err'))); |
| 818 | await fpage.waitForTimeout(2500); |
| 819 | |
| 820 | const fst = await fpage.evaluate(() => { |
| 821 | try { return window.DaimondGateway.state(); } catch (e) { return {}; } |
| 822 | }); |
| 823 | check('THE FREE APP IS SIGNED IN TOO', fst.authed === true, |
| 824 | 'authed ' + fst.authed + ' · offline ' + fst.offline, |
| 825 | 'a free code spent itself and the app never took the account it made'); |
| 826 | check('and holds no Pro, which is what free means', |
| 827 | fst.pro === false, 'pro ' + JSON.stringify(fst.pro), |
| 828 | 'a free code granted the licence anyway, so the free tier has no testers again'); |
| 829 | |
| 830 | // The SENTENCE, asserted by meaning: the screen must be the plain one and |
| 831 | // must not be the Pro one. Both halves, because a panel that drew neither |
| 832 | // would pass the first on its own -- and `done_plain` said almost nothing |
| 833 | // until today, which is the defect this half exists to keep fixed. |
| 834 | const box = await ftext('.beta-box'); |
| 835 | const plain = await fsays('beta.done_plain'); |
| 836 | const proly = await fsays('beta.done_pro'); |
| 837 | check('the confirmation is the free sentence, not the Pro one', |
| 838 | box.indexOf(plain) >= 0 && box.indexOf(proly) < 0, |
| 839 | JSON.stringify(box.slice(0, 160))); |
| 840 | // And that the sentence is worth reading. A free tester has to be told |
| 841 | // which tier they are on and what the other one adds, so the sentence must |
| 842 | // NAME Pro -- the one word every locale keeps untranslated, which is why |
| 843 | // this holds in all eight rather than only in English. Asserted on the |
| 844 | // catalogue and not on the screen for the same reason. |
| 845 | check('and it names the tier the reader has not got, rather than stopping at "you have an account"', |
| 846 | plain.indexOf('Pro') >= 0, JSON.stringify(plain.slice(0, 120)), |
| 847 | 'the free confirmation says only that an account exists, which is the ' |
| 848 | + 'screen nobody had ever seen and tells a tester nothing about what they hold'); |
| 849 | await shot(f, 'redeem-done-free'); |
| 850 | |
| 851 | const ferrs = errors(f).filter(e => /^pageerror:/.test(e)); |
| 852 | check('nothing on the free page threw', ferrs.length === 0, ferrs.slice(0, 3).join(' | ')); |
| 853 | } finally { |
| 854 | await f.close().catch(() => {}); |
| 855 | } |
| 856 | |
| 857 | if (bad.length) { LOG.report(); SRV.report(); } |
| 858 | cleanup(); |
| 859 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 860 | process.exit(bad.length ? 1 : 0); |
| 861 | })().catch(async (e) => { |
| 862 | console.log(' FAIL the run threw — ' + (e && e.stack || e)); |
| 863 | LOG.report(); |
| 864 | SRV.report(); |
| 865 | cleanup(); |
| 866 | process.exit(1); |
| 867 | }); |