oxedyne/daimond/dev/verify_applications.mjs
99.2 KiB, 1 run
created by r2519314175:235, 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_applications.mjs — the Applications panel, against a real gateway. |
| 2 | // |
| 3 | // `/api/admin?view=applications` has been served since the beta form was |
| 4 | // written, GET and POST, and until now NOTHING CALLED IT. The deployed gateway |
| 5 | // runs `beta_only` with the form open, so that form is the only route into the |
| 6 | // product, and every application it took landed in a store no console could |
| 7 | // read — accumulating towards `apply_max_total`, after which applicants are |
| 8 | // refused without being told why. So the properties worth proving are not |
| 9 | // "there is a tab" but these: |
| 10 | // |
| 11 | // * an application filed at the public endpoint REACHES the list; |
| 12 | // * a legal decision changes the status THE GATEWAY REPORTS, and the panel |
| 13 | // shows what the gateway holds rather than what was clicked; |
| 14 | // * the one decision the gateway refuses -- inviting somebody whose code has |
| 15 | // been redeemed -- is NOT OFFERED, and the refusal is real: the same post |
| 16 | // is made over the wire and answered 409; |
| 17 | // * an operator who is not the owner is offered no control, and the same |
| 18 | // post from their session is answered 403; |
| 19 | // * an empty queue SAYS SO, including whether the form is still taking any; |
| 20 | // * inviting somebody MINTS a code AND SENDS IT to the address on their |
| 21 | // application — measured at the mail server the gateway submitted to, not at |
| 22 | // a field the gateway set about itself; |
| 23 | // * a send that FAILED is visible as a failure rather than as silence, with |
| 24 | // the mail server's own words and a way to try again; |
| 25 | // * a resend sends the SAME code and mints no second one; |
| 26 | // * declining, and putting a row back to pending, send nothing at all; |
| 27 | // * an approval mints the TIER the panel was asked for: free with the control |
| 28 | // left alone, Pro with Pro chosen — measured on the passcode record and on |
| 29 | // the account that redeems it, not on the pulldown; |
| 30 | // * an invited row SAYS which tier its code grants, in the words the Beta |
| 31 | // panel uses, agreeing with the passcode record rather than with the |
| 32 | // pulldown — and a row showing no code says nothing, because 'free' about a |
| 33 | // credential nobody holds is a claim and not a default; |
| 34 | // * the sentence after a decision names a tier ONLY where that press minted |
| 35 | // one. A re-invite hands back the code already on the row and grants |
| 36 | // nothing, so a press on a row that already holds one has no tier to |
| 37 | // report, and it is the gateway that says which kind of press this was. |
| 38 | // |
| 39 | // Every one of those is measured against the gateway this repository builds: |
| 40 | // applications are filed through `/api/beta/apply`, decisions are read back |
| 41 | // through `view=applications`, and the passcode a decision mints is REDEEMED |
| 42 | // through `/api/passcode/redeem` — so "the code is real" is proved by using it |
| 43 | // rather than by its shape. Nothing here is stubbed. |
| 44 | // |
| 45 | // It also covers the `choice` knob repair made alongside the panel: the console |
| 46 | // never read the `options` every knob carries, so a `Kind::Choice` knob drawn |
| 47 | // by `knobEditor` got a number field. The only choice knob today is promoted to |
| 48 | // the Providers card, so it is reached here through the console's own |
| 49 | // `__provBreak=twice`, which is what puts it back in the Settings card. |
| 50 | // |
| 51 | // ── Running it ────────────────────────────────────────────────────── |
| 52 | // |
| 53 | // eval "$(bash dev/world.sh 12 --env)" # only to number its own two ports |
| 54 | // node dev/verify_applications.mjs |
| 55 | // node dev/verify_applications.mjs --break drop # the list ignores what it fetched |
| 56 | // node dev/verify_applications.mjs --break empty # an empty queue draws nothing |
| 57 | // node dev/verify_applications.mjs --break illegal # Invite offered on a redeemed row |
| 58 | // node dev/verify_applications.mjs --break owner # controls drawn for a non-owner |
| 59 | // node dev/verify_applications.mjs --break stale # the panel is not re-read after a decision |
| 60 | // node dev/verify_applications.mjs --break keepcode # a spent code goes on being shown |
| 61 | // node dev/verify_applications.mjs --break field # a field the gateway never sent is read |
| 62 | // node dev/verify_applications.mjs --break choice # the choice knob falls back to a number |
| 63 | // node dev/verify_applications.mjs --break ceiling # a full queue is not reported |
| 64 | // node dev/verify_applications.mjs --break nomail # the gateway is started with no |
| 65 | // invitation mailbox, so inviting |
| 66 | // mints and sends nothing |
| 67 | // node dev/verify_applications.mjs --break mute # the row says nothing about the send |
| 68 | // node dev/verify_applications.mjs --break sentok # a failed send is drawn as a success |
| 69 | // node dev/verify_applications.mjs --break noresend # no way to try a failed send again |
| 70 | // node dev/verify_applications.mjs --break resendmints # a resend mints a second live code |
| 71 | // node dev/verify_applications.mjs --break declinesends # declining sends the applicant a code |
| 72 | // node dev/verify_applications.mjs --break nocode # the message goes out without the code |
| 73 | // node dev/verify_applications.mjs --break bland # the message never says it is single-use |
| 74 | // node dev/verify_applications.mjs --break tierhidden # the tier control is withheld from an owner |
| 75 | // node dev/verify_applications.mjs --break tierfree # the panel ignores the tier that was chosen |
| 76 | // node dev/verify_applications.mjs --break tierpro # every approval gifts Pro, chosen or not |
| 77 | // node dev/verify_applications.mjs --break tierwords # the two panels word one decision differently |
| 78 | // node dev/verify_applications.mjs --break tiermute # a row never says which tier its code grants |
| 79 | // node dev/verify_applications.mjs --break tierswap # a row says the opposite of what was minted |
| 80 | // node dev/verify_applications.mjs --break tierguess # a tier is drawn on a row showing no code |
| 81 | // node dev/verify_applications.mjs --break tiersay # the sentence names a tier on a resend, |
| 82 | // which minted nothing |
| 83 | // node dev/verify_applications.mjs --break tiermum # the sentence never names the tier it minted |
| 84 | // node dev/verify_applications.mjs --stale-ok # measure a binary older than some |
| 85 | // source anyway; the staleness is |
| 86 | // still counted as a failure |
| 87 | // |
| 88 | // Each --break is a defect the checks below are supposed to catch. If a break |
| 89 | // runs green, the check for it is worthless and should be rewritten. |
| 90 | // |
| 91 | // ── Sending, and what the seam here does NOT prove ────────────────── |
| 92 | // |
| 93 | // Inviting somebody now emails them their code (gateway/src/handlers/invite.rs). |
| 94 | // A verifier must not put real mail on the wire, so this run stands up an SMTP |
| 95 | // server of its own on loopback and points the gateway at it: the gateway opens |
| 96 | // a genuine submission conversation, authenticates, and posts a genuine RFC 5322 |
| 97 | // document, and every assertion below about the message reads the bytes that |
| 98 | // server received. Nothing about the send is stubbed inside the gateway — there |
| 99 | // is no test hook in that path and this file adds none. |
| 100 | // |
| 101 | // WHAT THAT DOES NOT PROVE, and it is worth being blunt about it: |
| 102 | // |
| 103 | // * The fixture speaks in the clear. A deployed gateway speaks STARTTLS to |
| 104 | // Steel and refuses to send the mailbox password to a server that will not |
| 105 | // upgrade, and that refusal is exercised here only as a unit test of the |
| 106 | // configuration reader, never against a real server. |
| 107 | // * The fixture accepts any AUTH PLAIN. It therefore says nothing about |
| 108 | // whether the configured credential is one Steel will accept. |
| 109 | // * Nothing here is DELIVERED. Steel signs DKIM, queues and delivers; this |
| 110 | // server accepts and forgets. Whether an invitation reaches an inbox rather |
| 111 | // than a spam folder is not measured by anything in this file. |
| 112 | // |
| 113 | // So a green run here means "the gateway composes the right message and hands it |
| 114 | // to the mail server it was pointed at". The first invitation sent through a |
| 115 | // real deployment still has to be watched arriving. |
| 116 | // |
| 117 | // It starts a gateway AND a dev server of its own, in a working directory of |
| 118 | // its own, with an EMPTY store — the empty-queue check counts on that, and |
| 119 | // counting rows in a store somebody else has been writing to would measure |
| 120 | // their afternoon. Both ports are numbered off the world, so several lanes can |
| 121 | // run this at once, and neither is the world's own gateway port -- a store this |
| 122 | // run counts rows in cannot be one anything else is allowed to write to. |
| 123 | |
| 124 | import fs from 'node:fs'; |
| 125 | import os from 'node:os'; |
| 126 | import net from 'node:net'; |
| 127 | import path from 'node:path'; |
| 128 | import crypto from 'node:crypto'; |
| 129 | import { spawn } from 'node:child_process'; |
| 130 | import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 131 | import { staleSources, procLog, GWDIR, GWBIN } from './gwbin.mjs'; |
| 132 | import { signInFresh } from './session.mjs'; |
| 133 | |
| 134 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 135 | const ROOT = path.join(HERE, '..'); |
| 136 | |
| 137 | // A gateway AND a dev server of their own, both numbered off the world. |
| 138 | // |
| 139 | // The console needs a server that proxies `/api` to a gateway, and this run pins |
| 140 | // an owner in configuration and counts rows in an EMPTY store, so it cannot share |
| 141 | // a gateway with anybody -- not the world's, and not another lane's. So it |
| 142 | // numbers both ports off the world it was given and starts both itself. |
| 143 | // |
| 144 | // DELIBERATELY NOT `DAIMOND_GW_PORT`, the rule dev/verify_redeem.mjs and |
| 145 | // dev/verify_relay_e2e.mjs already state and this file was the last to break. |
| 146 | // It read the shared variable, and `dev/run_all.sh` exports one -- so inside a |
| 147 | // suite run this verifier quietly abandoned its own 9400 + N row and started its |
| 148 | // gateway on the world's gateway port instead, which is the one port in the |
| 149 | // register it must not be on. Its knob is its own now. |
| 150 | const WORLD = Math.max(0, Number(process.env.DAIMOND_PORT || 8777) - 8777); |
| 151 | const PORT = Number(process.env.DAIMOND_APPL_GW_PORT || (9440 + WORLD)); |
| 152 | const APP_PORT = Number(process.env.DAIMOND_APP_PORT || (8500 + WORLD)); |
| 153 | // The loopback SMTP server this run points the gateway at. Numbered off the |
| 154 | // world like the other two, and deliberately NOT 587 or 465 — a gateway will |
| 155 | // only dial an odd port with `invite_dev_insecure` set, and this run proves that |
| 156 | // switch is doing something by needing it. |
| 157 | const MAIL_PORT = Number(process.env.DAIMOND_MAIL_PORT || (9600 + WORLD)); |
| 158 | const GW = `http://127.0.0.1:${PORT}`; |
| 159 | const APP = `http://localhost:${APP_PORT}`; |
| 160 | const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond'); |
| 161 | const WORK = path.join(SCRATCH, 'verify_applications-gw'); |
| 162 | const GW_LOG = procLog('verify_applications'); |
| 163 | const SRV_LOG = procLog('verify_applications', 'serve'); |
| 164 | |
| 165 | // The binary under test. `DAIMOND_GW_BIN` exists because the release build is |
| 166 | // left behind whenever anybody builds with CARGO_TARGET_DIR pointed at their |
| 167 | // own slot -- see gwbin.mjs -- and a lane that may not run cargo still has to |
| 168 | // be able to name the build it is measuring. Whichever is used, it is REFUSED |
| 169 | // if it is older than the sources, for the reason that file gives: a gate that |
| 170 | // measures the wrong artefact passes things it never examined. |
| 171 | const BIN = process.env.DAIMOND_GW_BIN || GWBIN; |
| 172 | |
| 173 | const PW = process.env.DAIMOND_PW |
| 174 | || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs'); |
| 175 | const CHROME = process.env.DAIMOND_CHROME |
| 176 | || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`; |
| 177 | |
| 178 | /// Whether a stale binary may be measured anyway. See where it is used: it does |
| 179 | /// not silence the check, it only lets the rest of the run happen. |
| 180 | const STALE_OK = process.argv.includes('--stale-ok'); |
| 181 | |
| 182 | const BREAK = (() => { |
| 183 | const i = process.argv.indexOf('--break'); |
| 184 | const eq = process.argv.find(a => a.startsWith('--break=')); |
| 185 | if (eq) return eq.slice(8); |
| 186 | return i >= 0 ? (process.argv[i + 1] || '') : null; |
| 187 | })(); |
| 188 | |
| 189 | const ok = [], bad = []; |
| 190 | /// Record a check. `detail` is the evidence and is printed either way, so a |
| 191 | /// passing line still says what it saw. |
| 192 | const check = (name, pass, detail) => { |
| 193 | (pass ? ok : bad).push(name); |
| 194 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 195 | }; |
| 196 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 197 | |
| 198 | const procs = []; |
| 199 | function cleanup() { for (const p of procs) { try { p.kill('SIGKILL'); } catch (e) {} } } |
| 200 | function saidWhat() { GW_LOG.report(); SRV_LOG.report(); } |
| 201 | async function waitFor(fn, ms = 20000, gap = 250) { |
| 202 | const t0 = Date.now(); |
| 203 | for (;;) { |
| 204 | try { if (await fn()) return true; } catch (e) {} |
| 205 | if (Date.now() - t0 > ms) return false; |
| 206 | await sleep(gap); |
| 207 | } |
| 208 | } |
| 209 | /// Stop and report, so a failure never leaves a gateway holding :9002. |
| 210 | function die(msg) { |
| 211 | console.log(' FAIL ' + msg); |
| 212 | saidWhat(); |
| 213 | cleanup(); |
| 214 | console.log(''); |
| 215 | console.log(`passed ${ok.length}, failed ${bad.length + 1}`); |
| 216 | process.exit(1); |
| 217 | } |
| 218 | |
| 219 | // ── A mail server of its own ──────────────────────────────────────── |
| 220 | // |
| 221 | // The capture seam, and the header note above says what it does and does not |
| 222 | // prove. It speaks enough SMTP for a submission conversation — banner, EHLO with |
| 223 | // an AUTH advertisement, AUTH PLAIN, MAIL/RCPT/DATA, QUIT — and records what it |
| 224 | // was given. The gateway's side of that conversation is the real one: the real |
| 225 | // SMTP client, the real composer, real bytes on a real socket. |
| 226 | // |
| 227 | // It refuses any recipient in `refuse`, with a 5xx, which is how the FAILED path |
| 228 | // below is exercised. A mail server saying no to one address is the ordinary |
| 229 | // case a beta will meet — a typo'd domain, a mailbox that has gone — and a code |
| 230 | // minted for somebody nobody could write to is exactly the silence the send |
| 231 | // record exists to end. |
| 232 | |
| 233 | /// The address inside an SMTP `MAIL FROM:<…>` or `RCPT TO:<…>`. |
| 234 | function addrOf(line) { |
| 235 | const m = line.match(/<([^>]*)>/); |
| 236 | return m ? m[1].trim().toLowerCase() : ''; |
| 237 | } |
| 238 | |
| 239 | /// What the fixture records of one message, after the breaks have had their way |
| 240 | /// with it. |
| 241 | /// |
| 242 | /// `--break nocode` blanks the passcode out of the body and `--break bland` |
| 243 | /// strips the sentence saying it is single-use. Both are breaks of the CAPTURE |
| 244 | /// rather than of the gateway, and they prove exactly one thing: that the two |
| 245 | /// body checks below read the message rather than infer it from the envelope. |
| 246 | /// What holds the composer itself is the unit tests in `invite.rs`. |
| 247 | function recorded(body) { |
| 248 | let b = body; |
| 249 | if (BREAK === 'nocode') b = b.replace(/\b[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}\b/g, 'REDACTED'); |
| 250 | if (BREAK === 'bland') { |
| 251 | b = b.replace(/works once/g, 'exists') |
| 252 | .replace(/one account on one device/g, 'an account'); |
| 253 | } |
| 254 | return b; |
| 255 | } |
| 256 | |
| 257 | function startMailFixture() { |
| 258 | const box = { messages: [], auth: [], refuse: new Set(), server: null }; |
| 259 | box.server = net.createServer(sock => { |
| 260 | let buf = '', inData = false, data = [], from = '', rcpt = []; |
| 261 | const say = s => { try { sock.write(s + '\r\n'); } catch (e) {} }; |
| 262 | say('220 fixture.invalid ESMTP verify_applications'); |
| 263 | sock.on('data', chunk => { |
| 264 | buf += chunk.toString('utf8'); |
| 265 | for (;;) { |
| 266 | const i = buf.indexOf('\r\n'); |
| 267 | if (i < 0) break; |
| 268 | const line = buf.slice(0, i); |
| 269 | buf = buf.slice(i + 2); |
| 270 | if (inData) { |
| 271 | if (line === '.') { |
| 272 | inData = false; |
| 273 | box.messages.push({ |
| 274 | from, |
| 275 | rcpt: rcpt.slice(), |
| 276 | body: recorded(data.join('\r\n')), |
| 277 | at: Date.now(), |
| 278 | }); |
| 279 | data = []; |
| 280 | say('250 2.0.0 Ok: queued as FIXTURE' + box.messages.length); |
| 281 | } else { |
| 282 | // Undo the dot-stuffing the client applied on the way out. |
| 283 | data.push(line.startsWith('..') ? line.slice(1) : line); |
| 284 | } |
| 285 | continue; |
| 286 | } |
| 287 | const up = line.toUpperCase(); |
| 288 | if (up.startsWith('EHLO') || up.startsWith('HELO')) { |
| 289 | // Multi-line, with the last line separated by a space: the |
| 290 | // client reads AUTH off the extension list and will not send a |
| 291 | // password to a server that advertises no mechanism. |
| 292 | say('250-fixture.invalid'); |
| 293 | say('250 AUTH PLAIN LOGIN'); |
| 294 | } else if (up.startsWith('AUTH ')) { |
| 295 | box.auth.push(line.split(/\s+/)[1] || ''); |
| 296 | say('235 2.7.0 Authentication successful'); |
| 297 | } else if (up.startsWith('MAIL FROM')) { |
| 298 | from = addrOf(line); rcpt = []; |
| 299 | say('250 2.1.0 Ok'); |
| 300 | } else if (up.startsWith('RCPT TO')) { |
| 301 | const a = addrOf(line); |
| 302 | if (box.refuse.has(a)) { |
| 303 | say('550 5.1.1 <' + a + '>: no mailbox by that name here'); |
| 304 | } else { |
| 305 | rcpt.push(a); |
| 306 | say('250 2.1.5 Ok'); |
| 307 | } |
| 308 | } else if (up === 'DATA') { |
| 309 | inData = true; data = []; |
| 310 | say('354 End data with <CR><LF>.<CR><LF>'); |
| 311 | } else if (up === 'QUIT') { |
| 312 | say('221 2.0.0 Bye'); |
| 313 | sock.end(); |
| 314 | } else if (up === 'RSET' || up.startsWith('NOOP')) { |
| 315 | say('250 2.0.0 Ok'); |
| 316 | } else { |
| 317 | say('502 5.5.2 Command not implemented'); |
| 318 | } |
| 319 | } |
| 320 | }); |
| 321 | // A client that hangs up mid-conversation — which is what the SMTP |
| 322 | // client does after a refused recipient — is not an error here. |
| 323 | sock.on('error', () => {}); |
| 324 | }); |
| 325 | box.server.on('error', () => {}); |
| 326 | return box; |
| 327 | } |
| 328 | |
| 329 | /// The credential the fixture is given. Generated per run rather than written |
| 330 | /// down: it proves nothing to have a constant here, and a constant that looks |
| 331 | /// like a password in a repository is a thing somebody eventually reuses. |
| 332 | const MAIL_PW = 'fixture-' + crypto.randomBytes(9).toString('hex'); |
| 333 | /// The mailbox the gateway is configured to send invitations from. |
| 334 | const MAIL_FROM = 'beta@daimond.test'; |
| 335 | /// Where the message tells an applicant to put their code. |
| 336 | const MAIL_URL = 'https://daimond.test/'; |
| 337 | |
| 338 | /// The invitation configuration handed to the gateway's environment. |
| 339 | /// |
| 340 | /// Empty under `--break nomail`, which is a deployment nobody finished setting |
| 341 | /// up: it mints, it marks the row invited, and it writes to nobody. |
| 342 | function mailEnv() { |
| 343 | if (BREAK === 'nomail') return {}; |
| 344 | return { |
| 345 | DAIMOND_INVITE_FROM: MAIL_FROM, |
| 346 | DAIMOND_INVITE_HOST: '127.0.0.1', |
| 347 | DAIMOND_INVITE_PORT: String(MAIL_PORT), |
| 348 | DAIMOND_INVITE_SECURITY: 'plain', |
| 349 | DAIMOND_INVITE_USER: MAIL_FROM, |
| 350 | DAIMOND_INVITE_PASSWORD: MAIL_PW, |
| 351 | DAIMOND_INVITE_SIGNOFF: 'Jason', |
| 352 | DAIMOND_INVITE_APP_URL: MAIL_URL, |
| 353 | }; |
| 354 | } |
| 355 | |
| 356 | // ── A gateway of its own, on an empty store ───────────────────────── |
| 357 | // |
| 358 | // The deployed `app.jdat` with the port set and nothing else changed, the |
| 359 | // signing keys symlinked in (a code minted here has to be the same artefact a |
| 360 | // real one is), and no store at all: the first check below counts an EMPTY |
| 361 | // queue, which is only meaningful in a store nobody else has written to. |
| 362 | function buildWorkDir() { |
| 363 | fs.rmSync(WORK, { recursive: true, force: true }); |
| 364 | fs.mkdirSync(path.join(WORK, 'keys'), { recursive: true }); |
| 365 | for (const k of ['licence', 'stripe', 'openrouter']) { |
| 366 | const from = path.join(GWDIR, 'keys', k); |
| 367 | if (fs.existsSync(from)) fs.symlinkSync(from, path.join(WORK, 'keys', k)); |
| 368 | } |
| 369 | let cfg = fs.readFileSync(path.join(GWDIR, 'app.jdat'), 'utf8') |
| 370 | .replace(/"listen_port":\s*\(u16\|\d+\)/, `"listen_port": (u16|${PORT})`); |
| 371 | if (!cfg.includes(`(u16|${PORT})`)) { |
| 372 | die('could not set the listen port in the copied app.jdat — has its shape changed?'); |
| 373 | } |
| 374 | // TWO changes and no more, both about getting to the panel rather than about |
| 375 | // the panel. `beta_only` shuts /api/account, and the two accounts this run |
| 376 | // needs -- an owner and one lesser role -- are ordinary accounts; there is |
| 377 | // no passcode to let them in with until an owner exists to mint one. The |
| 378 | // door they come through is `verify_passcode`'s subject, not this file's. |
| 379 | const shut = cfg; |
| 380 | cfg = cfg.replace(/"beta_only":\s*"true"/, '"beta_only": "false"'); |
| 381 | if (cfg === shut) { |
| 382 | die('could not open registration in the copied app.jdat — has "beta_only" moved? ' |
| 383 | + 'Without it no account can be made and nothing below could be reached.'); |
| 384 | } |
| 385 | // A THIRD change, and it is the one that lets a loopback mail server be |
| 386 | // reached at all. `invite_dev_insecure` relaxes exactly three things — a host |
| 387 | // that is not public, a port that is not 587 or 465, and a conversation with |
| 388 | // no TLS — and it is ABSENT from the shipped config, so a deployed gateway |
| 389 | // refuses all three. It is a route setting rather than an environment |
| 390 | // variable precisely so a stray shell variable cannot turn it on, which is |
| 391 | // why this run has to write it into its own copy of the file. Everything else |
| 392 | // about the mailbox arrives through `mailEnv`. |
| 393 | const shipped = cfg; |
| 394 | cfg = cfg.replace(/("handler":\s*"admin",\s*"config":\s*\{)/, |
| 395 | '$1\n "invite_dev_insecure": "true",'); |
| 396 | if (cfg === shipped) { |
| 397 | die('could not set "invite_dev_insecure" on the admin route in the copied ' |
| 398 | + 'app.jdat — has the route\'s shape changed? Without it the gateway ' |
| 399 | + 'refuses to dial the loopback mail server, and every send check below ' |
| 400 | + 'would be measuring a configuration failure rather than the send path.'); |
| 401 | } |
| 402 | fs.writeFileSync(path.join(WORK, 'app.jdat'), cfg); |
| 403 | return WORK; |
| 404 | } |
| 405 | |
| 406 | let gw = null; |
| 407 | async function startGateway(ownerAccount) { |
| 408 | gw = spawn(BIN, [], { |
| 409 | cwd: WORK, |
| 410 | env: { |
| 411 | ...process.env, |
| 412 | APP_MODE: 'sandbox', |
| 413 | // The invitation mailbox, pointed at this run's own SMTP server. |
| 414 | // Empty under `--break nomail`, which is a gateway nobody finished |
| 415 | // configuring — it still mints, and it writes to nobody. |
| 416 | ...mailEnv(), |
| 417 | ...(ownerAccount ? { DAIMOND_OWNER_ACCOUNTS: ownerAccount } : {}), |
| 418 | }, |
| 419 | stdio: GW_LOG.stdio, |
| 420 | }); |
| 421 | procs.push(gw); |
| 422 | // Generous: an empty o3db spends twenty-odd seconds building its zones |
| 423 | // before anything listens, and a wait sized for a warm store reports "the |
| 424 | // gateway did not start" about one that was starting perfectly well. |
| 425 | return await waitFor(async () => (await fetch(`${GW}/api/health`)).ok, 120000); |
| 426 | } |
| 427 | async function stopGateway() { |
| 428 | if (!gw) return; |
| 429 | try { gw.kill('SIGKILL'); } catch (e) {} |
| 430 | await waitFor(async () => { |
| 431 | try { await fetch(`${GW}/api/health`); return false; } catch (e) { return true; } |
| 432 | }, 30000, 200); |
| 433 | await sleep(500); |
| 434 | gw = null; |
| 435 | } |
| 436 | |
| 437 | // ── Talking to the gateway directly ───────────────────────────────── |
| 438 | // |
| 439 | // Node keeps no cookie jar, so sessions are moved by hand. That is a feature |
| 440 | // here: it makes it impossible to use one account's session for another's |
| 441 | // request, which is exactly the mistake a role check must not make. |
| 442 | async function call(jar, method, url, body, xff) { |
| 443 | const headers = { 'x-daimond-api': '1' }; |
| 444 | if (jar && jar.cookie) headers.cookie = jar.cookie; |
| 445 | if (body !== undefined) headers['content-type'] = 'application/json'; |
| 446 | // There is no Steel in front of a development gateway, so every request |
| 447 | // otherwise shares one "unknown" bucket and a handful at once reads as a |
| 448 | // flood. Naming a source puts each applicant in a bucket of its own, which |
| 449 | // is what the per-source cap counts. |
| 450 | if (xff) headers['x-forwarded-for'] = xff; |
| 451 | const r = await fetch(GW + url, { |
| 452 | method, headers, |
| 453 | body: body === undefined ? undefined : JSON.stringify(body), |
| 454 | }); |
| 455 | let j = null; |
| 456 | try { j = await r.json(); } catch (e) {} |
| 457 | return { status: r.status, j }; |
| 458 | } |
| 459 | |
| 460 | /// A fresh device keypair and the two things the gateway asks of it. |
| 461 | function device() { |
| 462 | const kp = crypto.generateKeyPairSync('ed25519'); |
| 463 | const raw = kp.publicKey.export({ type: 'spki', format: 'der' }).subarray(-32); |
| 464 | const b64url = b => b.toString('base64') |
| 465 | .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); |
| 466 | return { |
| 467 | pub: b64url(raw), |
| 468 | alg: 'Ed25519', |
| 469 | sign: s => crypto.sign(null, Buffer.from(s, 'utf8'), kp.privateKey).toString('base64'), |
| 470 | }; |
| 471 | } |
| 472 | function binding(dev) { |
| 473 | const ts = Math.floor(Date.now() / 1000); |
| 474 | return { pubkey: dev.pub, alg: dev.alg, ts, |
| 475 | sig: dev.sign(`daimond-gw-account:v1:${dev.pub}:${ts}`) }; |
| 476 | } |
| 477 | |
| 478 | /// One application, filed exactly as the public form files one. |
| 479 | let applicant = 0; |
| 480 | async function apply(email, name, note, intent) { |
| 481 | applicant++; |
| 482 | const body = { email, name, note }; |
| 483 | if (intent) body.intent = intent; |
| 484 | // A source per applicant: the endpoint admits five submissions an hour from |
| 485 | // one, and a run refused by its own rate limiter would look exactly like the |
| 486 | // endpoint being broken. |
| 487 | return await call(null, 'POST', '/api/beta/apply', body, `203.0.113.${applicant}`); |
| 488 | } |
| 489 | |
| 490 | // ── The page ──────────────────────────────────────────────────────── |
| 491 | |
| 492 | /// What the Applications panel is showing, read out of the DOM. |
| 493 | /// |
| 494 | /// Read from the rendered page rather than from the console's own state: the |
| 495 | /// question every check below asks is what an operator can see and press, and |
| 496 | /// an internal array agreeing with the gateway proves nothing about that. |
| 497 | async function panel(page) { |
| 498 | return await page.evaluate(() => { |
| 499 | const host = document.getElementById('admin-ap-list'); |
| 500 | const rows = Array.from(host ? host.querySelectorAll('.admin-rel-row') : []); |
| 501 | return { |
| 502 | tab: !!(document.getElementById('tab-applications') |
| 503 | && !document.getElementById('tab-applications').hidden), |
| 504 | hint: (document.getElementById('admin-ap-hint') || {}).textContent || '', |
| 505 | gate: (document.getElementById('admin-ap-gate') || {}).textContent || '', |
| 506 | status: (document.getElementById('admin-ap-status') || {}).textContent || '', |
| 507 | // The standing warning that this gateway cannot send at all. Read |
| 508 | // only when it is SHOWN: the element is in the page either way, and |
| 509 | // its text is what a hidden one is still carrying. |
| 510 | mail: (() => { |
| 511 | const b = document.getElementById('admin-ap-mail'); |
| 512 | return b && !b.hidden ? b.textContent : ''; |
| 513 | })(), |
| 514 | note: (() => { |
| 515 | const n = document.getElementById('admin-ap-note'); |
| 516 | return n && !n.hidden ? n.textContent : ''; |
| 517 | })(), |
| 518 | empty: Array.from(host ? host.querySelectorAll('.admin-rel-empty') : []) |
| 519 | .map(e => e.textContent).join(' '), |
| 520 | html: host ? host.textContent : '', |
| 521 | read: Array.from(window.__apRead || []), |
| 522 | rows: rows.map(r => ({ |
| 523 | id: r.dataset.appId, |
| 524 | status: r.dataset.status, |
| 525 | text: r.textContent, |
| 526 | moves: Array.from(r.querySelectorAll('[data-move]')).map(b => b.dataset.move), |
| 527 | code: Array.from(r.querySelectorAll('.admin-ap-code .admin-op-id')) |
| 528 | .map(e => e.textContent), |
| 529 | // What the row says that code GRANTS. An array, so a row saying |
| 530 | // two things is as visible as a row saying nothing. |
| 531 | tier: Array.from(r.querySelectorAll('.admin-ap-tier-pill')) |
| 532 | .map(e => (e.textContent || '').trim()), |
| 533 | // What the row says became of the message carrying that code, |
| 534 | // and whether it offers to try again. `data-resend` and not |
| 535 | // `data-move` because a resend moves nothing: the row is |
| 536 | // already invited and stays invited. |
| 537 | sent: (r.querySelector('.admin-ap-sent') || {}).textContent || '', |
| 538 | resend: !!r.querySelector('[data-resend]'), |
| 539 | })), |
| 540 | }; |
| 541 | }); |
| 542 | } |
| 543 | |
| 544 | /// The row for an address, or null. |
| 545 | const rowFor = (p, email) => p.rows.find(r => r.text.includes(email)) || null; |
| 546 | |
| 547 | /// The tier control, MEASURED rather than merely found. |
| 548 | /// |
| 549 | /// `querySelector` says a control is in the document, which is the one thing |
| 550 | /// nobody doubts about a control written into the HTML. A pulldown put in a pane |
| 551 | /// that is not showing measures 0x0 and cannot be seen or pressed, and the |
| 552 | /// console's own `.admin-rel-add select { appearance: none }` has already turned |
| 553 | /// one into a box with nothing in it -- so the box, the computed paint and the |
| 554 | /// words all come back from here. |
| 555 | /// |
| 556 | /// The passcodes panel's own control comes back with it: the two are the same |
| 557 | /// decision, and the only way to hold them to one vocabulary is to read both. |
| 558 | async function tierControl(page, words) { |
| 559 | return await page.evaluate(w => { |
| 560 | const s = document.getElementById('admin-ap-tier'); |
| 561 | const lbl = document.getElementById('admin-ap-tier-lbl'); |
| 562 | const beta = document.getElementById('admin-beta-tier'); |
| 563 | const say = e => e ? Array.from(e.options).map(o => o.value + ': ' + o.textContent) : null; |
| 564 | // A break of the CAPTURE, not of the console: it rewords the applications |
| 565 | // pulldown in the page before it is read, which proves the comparison |
| 566 | // below is reading the two controls rather than a constant. |
| 567 | if (w && s) { |
| 568 | Array.from(s.options).forEach(o => { |
| 569 | o.textContent = o.value === 'pro' ? 'Pro' : 'Free'; |
| 570 | }); |
| 571 | } |
| 572 | if (!s) return { there: false, beta: say(beta) }; |
| 573 | const r = s.getBoundingClientRect(); |
| 574 | const cs = getComputedStyle(s); |
| 575 | return { |
| 576 | there: true, |
| 577 | tag: s.tagName, |
| 578 | value: s.value, |
| 579 | hidden: !!(lbl && lbl.hidden), |
| 580 | w: Math.round(r.width), |
| 581 | h: Math.round(r.height), |
| 582 | right: Math.round(r.right), |
| 583 | win: window.innerWidth, |
| 584 | // What it would look like to somebody: a control can be the right |
| 585 | // size and still be invisible. |
| 586 | // |
| 587 | // `offsetParent` and not only `visibility`, because the first draft of |
| 588 | // this went GREEN on a control inside a `hidden` label: `display: none` |
| 589 | // on an ancestor leaves the computed visibility 'visible' and the |
| 590 | // opacity 1, so a check named "it is actually visible" was reading the |
| 591 | // paint of something nobody could see. Only the 0x0 box caught it. |
| 592 | shown: !!s.offsetParent, |
| 593 | vis: cs.visibility, |
| 594 | op: cs.opacity, |
| 595 | ink: cs.color, |
| 596 | bg: cs.backgroundColor, |
| 597 | fs: cs.fontSize, |
| 598 | label: lbl ? (lbl.textContent || '').trim().split('\n')[0].trim() : '', |
| 599 | opts: say(s), |
| 600 | beta: say(beta), |
| 601 | }; |
| 602 | }, !!words); |
| 603 | } |
| 604 | |
| 605 | /// The tier pill on one row, MEASURED rather than merely found. |
| 606 | /// |
| 607 | /// The same care [`tierControl`] takes, and for the reason recorded there: the |
| 608 | /// first draft of that check went GREEN on a control inside a `hidden` ancestor, |
| 609 | /// because `display: none` up the tree leaves the computed visibility 'visible' |
| 610 | /// and the opacity 1. So the box and `offsetParent` decide it here and the |
| 611 | /// computed paint is corroboration beside them. |
| 612 | /// |
| 613 | /// Opacity is REPORTED and not required to be 1: `.admin-pill.muted` dims the |
| 614 | /// free pill to 0.65 on purpose, in both panels, and a check demanding 1 would |
| 615 | /// be asking the console to shout the cheap answer. |
| 616 | /// |
| 617 | /// `beside` is whether the pill sits in the row's code line. A tier drawn |
| 618 | /// anywhere else is a claim about the applicant rather than about the credential |
| 619 | /// in front of the operator. |
| 620 | async function tierPill(page, email) { |
| 621 | return await page.evaluate(a => { |
| 622 | const rows = Array.from(document.querySelectorAll('#admin-ap-list .admin-rel-row')); |
| 623 | const row = rows.find(r => r.textContent.includes(a.email)); |
| 624 | if (!row) return { row: false, there: false, n: 0 }; |
| 625 | const pills = Array.from(row.querySelectorAll('.admin-ap-tier-pill')); |
| 626 | const codeShown = !!row.querySelector('.admin-ap-code .admin-op-id'); |
| 627 | if (!pills.length) return { row: true, there: false, n: 0, codeShown }; |
| 628 | const p = pills[0]; |
| 629 | const r = p.getBoundingClientRect(); |
| 630 | const cs = getComputedStyle(p); |
| 631 | return { |
| 632 | row: true, |
| 633 | there: true, |
| 634 | n: pills.length, |
| 635 | text: (p.textContent || '').trim(), |
| 636 | w: Math.round(r.width), |
| 637 | h: Math.round(r.height), |
| 638 | right: Math.round(r.right), |
| 639 | win: window.innerWidth, |
| 640 | shown: !!p.offsetParent, |
| 641 | vis: cs.visibility, |
| 642 | op: cs.opacity, |
| 643 | ink: cs.color, |
| 644 | bg: cs.backgroundColor, |
| 645 | fs: cs.fontSize, |
| 646 | beside: !!p.closest('.admin-ap-code'), |
| 647 | codeShown, |
| 648 | }; |
| 649 | }, { email }); |
| 650 | } |
| 651 | |
| 652 | /// The bodies the console has POSTed to the applications view, newest last. |
| 653 | async function apPosts(page) { |
| 654 | return await page.evaluate(() => (window.__apPosts || []).map(b => { |
| 655 | try { return JSON.parse(b); } catch (e) { return { unparsed: String(b).slice(0, 80) }; } |
| 656 | })); |
| 657 | } |
| 658 | |
| 659 | /// Press one decision on one row, and wait for the panel to settle. |
| 660 | async function pressMove(page, email, move) { |
| 661 | const sel = await page.evaluate(a => { |
| 662 | const rows = Array.from(document.querySelectorAll('#admin-ap-list .admin-rel-row')); |
| 663 | const row = rows.find(r => r.textContent.includes(a.email)); |
| 664 | if (!row) return 'no row'; |
| 665 | const b = row.querySelector('[data-move="' + a.move + '"]'); |
| 666 | if (!b) return 'no control'; |
| 667 | b.click(); |
| 668 | return 'clicked'; |
| 669 | }, { email, move }); |
| 670 | await settled(page); |
| 671 | return sel; |
| 672 | } |
| 673 | |
| 674 | /// Press Resend on one row, and wait for the panel to settle. |
| 675 | /// |
| 676 | /// Separate from `pressMove` because it is a separate control: the row is |
| 677 | /// already invited, so it is offered no Invite button, and `data-resend` is |
| 678 | /// deliberately not one of the three moves. |
| 679 | async function pressResend(page, email) { |
| 680 | const sel = await page.evaluate(a => { |
| 681 | const rows = Array.from(document.querySelectorAll('#admin-ap-list .admin-rel-row')); |
| 682 | const row = rows.find(r => r.textContent.includes(a.email)); |
| 683 | if (!row) return 'no row'; |
| 684 | const b = row.querySelector('[data-resend]'); |
| 685 | if (!b) return 'no control'; |
| 686 | b.click(); |
| 687 | return 'clicked'; |
| 688 | }, { email }); |
| 689 | await settled(page); |
| 690 | return sel; |
| 691 | } |
| 692 | |
| 693 | /// Wait for the panel's status line to stop saying it is working. |
| 694 | /// |
| 695 | /// A decision posts and then re-reads the whole queue, and a send adds an SMTP |
| 696 | /// conversation in the middle of that — all round trips to real servers, so this |
| 697 | /// waits for the line to settle rather than guessing a delay. |
| 698 | async function settled(page) { |
| 699 | return await waitFor(async () => { |
| 700 | const s = await page.evaluate(() => |
| 701 | (document.getElementById('admin-ap-status') || {}).textContent || ''); |
| 702 | return s !== '' && s !== 'Saving…' && s !== 'Sending…'; |
| 703 | }, 25000, 150); |
| 704 | } |
| 705 | |
| 706 | /// Open the console in its own context, signing in a fresh account. |
| 707 | /// |
| 708 | /// The account is made in the browser, so the session is the browser's own -- |
| 709 | /// which is the only way the role checks below mean anything. |
| 710 | async function openConsole(browser) { |
| 711 | const ctx = await browser.newContext({ viewport: { width: 1400, height: 1200 } }); |
| 712 | const page = await ctx.newPage(); |
| 713 | // The recorder for "which fields did the console read". Every row the |
| 714 | // gateway sends is wrapped in a Proxy that notes each property taken off it, |
| 715 | // so a console reading a field nobody sent is caught by observation rather |
| 716 | // than by grepping the source for a shape. |
| 717 | await page.addInitScript(() => { |
| 718 | window.__apRead = new Set(); |
| 719 | const orig = Response.prototype.json; |
| 720 | Response.prototype.json = async function () { |
| 721 | const j = await orig.call(this); |
| 722 | if (j && Array.isArray(j.applications)) { |
| 723 | j.applications = j.applications.map(row => new Proxy(row, { |
| 724 | get(t, k) { |
| 725 | if (typeof k === 'string') window.__apRead.add(k); |
| 726 | return t[k]; |
| 727 | }, |
| 728 | })); |
| 729 | } |
| 730 | return j; |
| 731 | }; |
| 732 | // And what the console SENT. A decision's tier is nowhere in the reply -- |
| 733 | // it lands on the passcode the decision minted -- so the request is read |
| 734 | // here as well as the passcode at the gateway: one says the panel asked |
| 735 | // for the tier, the other says the gateway acted on it, and neither on its |
| 736 | // own tells the operator's story. |
| 737 | window.__apPosts = []; |
| 738 | const sent = window.fetch; |
| 739 | window.fetch = function (u, o) { |
| 740 | try { |
| 741 | const url = typeof u === 'string' ? u : ((u && u.url) || ''); |
| 742 | if (/view=applications/.test(url) && o && o.method === 'POST' |
| 743 | && typeof o.body === 'string') { |
| 744 | window.__apPosts.push(o.body); |
| 745 | } |
| 746 | } catch (e) {} |
| 747 | return sent.apply(this, arguments); |
| 748 | }; |
| 749 | }); |
| 750 | if (BREAK) await page.addInitScript(m => { window.__appBreak = m; }, BREAK); |
| 751 | const account = await signInFresh(page, APP); |
| 752 | return { ctx, page, account }; |
| 753 | } |
| 754 | |
| 755 | /// Load the console and wait for the queue to have been drawn once. |
| 756 | async function enterConsole(page) { |
| 757 | await page.goto(APP + '/console/#applications', { waitUntil: 'domcontentloaded' }); |
| 758 | const up = await page.waitForSelector('#admin-app:not([hidden])', { timeout: 20000 }) |
| 759 | .then(() => true).catch(() => false); |
| 760 | if (!up) return false; |
| 761 | // `refreshAll` fetches eleven views; the queue is loaded late in that |
| 762 | // sequence, so waiting for the app to appear is not waiting for this panel. |
| 763 | await waitFor(async () => await page.evaluate(() => { |
| 764 | const h = document.getElementById('admin-ap-hint'); |
| 765 | const l = document.getElementById('admin-ap-list'); |
| 766 | return !!(l && (l.children.length || (h && h.textContent))); |
| 767 | }), 25000, 200); |
| 768 | await sleep(250); |
| 769 | await buildIndex(page); |
| 770 | return true; |
| 771 | } |
| 772 | |
| 773 | /// Press "Build it now" if the gateway has never built this listing index. |
| 774 | /// |
| 775 | /// **The gateway does not walk its own store unless a person asks it to** — the owner's |
| 776 | /// decision, and `drawNeedsBuild` in `www/console/admin.js` says why: `beta_standing` used |
| 777 | /// to build the index on a device's UNLOCK, a request is cut off long before a whole-store |
| 778 | /// walk can finish, so the half-built table was thrown away every time and every beta |
| 779 | /// tester was answered "no intake". The console is the only thing that builds it now. |
| 780 | /// |
| 781 | /// So a check that wants rows has to do what an operator does. Without this the panel is |
| 782 | /// perfectly correct and perfectly empty, and the four console verifiers read "status 200 · |
| 783 | /// 0 rows" and report a product that is working as a product that is broken. |
| 784 | /// |
| 785 | /// Aimed at `data-act="build-index"` rather than at the button's words, which would put |
| 786 | /// this file's assertions at the mercy of somebody rewording a button. |
| 787 | async function buildIndex(page) { |
| 788 | const btn = await page.$('[data-act="build-index"]'); |
| 789 | if (!btn) return false; // already built, which is the ordinary case |
| 790 | // VISIBLE, not merely present. The run builds the index through the API before it opens |
| 791 | // the console, so this button is normally gone; when a panel that is still hidden holds |
| 792 | // a stale one, clicking it waits thirty seconds and then fails the whole run for |
| 793 | // something that did not need doing. Asked of the element, and the click is allowed to |
| 794 | // fail without taking the run with it. |
| 795 | if (!(await btn.isVisible().catch(() => false))) return false; |
| 796 | await btn.click({ timeout: 5000 }).catch(() => {}); |
| 797 | // The build holds the console for the length of a whole-store walk. It is small here -- |
| 798 | // a fixture store, not a real one -- but it is waited for rather than slept through. |
| 799 | await waitFor(async () => await page.evaluate( |
| 800 | () => !document.querySelector('[data-act="build-index"]')), 60000, 500); |
| 801 | await sleep(250); |
| 802 | return true; |
| 803 | } |
| 804 | |
| 805 | // ── The run ───────────────────────────────────────────────────────── |
| 806 | |
| 807 | (async () => { |
| 808 | const stale = staleSources(BIN); |
| 809 | if (stale === null) die('the gateway binary is not there — ' + BIN); |
| 810 | if (stale.length && !STALE_OK) { |
| 811 | die('the gateway binary is older than ' + stale.length + ' of its sources, so this ' |
| 812 | + 'would measure a build nobody is shipping: ' + stale.slice(0, 4).join(', ') |
| 813 | + '. Rebuild it, name a current one in DAIMOND_GW_BIN, or -- if you have read ' |
| 814 | + 'that list and none of it is what this file measures -- pass --stale-ok, ' |
| 815 | + 'which runs everything and still records this as a failure.'); |
| 816 | } |
| 817 | // Recorded either way, and RECORDED AS A FAILURE when it is stale. A run |
| 818 | // against a build nobody is shipping must not be able to print an all-green |
| 819 | // summary, whatever the runner believed about which sources mattered. |
| 820 | check('the gateway binary is current with every source it is built from', |
| 821 | stale.length === 0, |
| 822 | stale.length ? stale.length + ' newer: ' + stale.slice(0, 4).join(', ') : BIN); |
| 823 | console.log(' measuring ' + BIN); |
| 824 | |
| 825 | let stray = false; |
| 826 | try { stray = (await fetch(`${GW}/api/health`)).ok; } catch (e) {} |
| 827 | if (stray) { |
| 828 | die(`something is already answering on :${PORT}. This run pins an owner in ` |
| 829 | + 'configuration and counts an empty store, so it cannot share a gateway; ' |
| 830 | + 'set DAIMOND_APPL_GW_PORT to a free port, or run it in a world of its own.'); |
| 831 | } |
| 832 | try { stray = (await fetch(`${APP}/console/`)).ok; } catch (e) { stray = false; } |
| 833 | if (stray) { |
| 834 | die(`something is already serving on :${APP_PORT}, and it will be proxying to a ` |
| 835 | + 'gateway that is not this one. Set DAIMOND_APP_PORT to a free port.'); |
| 836 | } |
| 837 | buildWorkDir(); |
| 838 | |
| 839 | // The mail server this run points the gateway at, up BEFORE the gateway, so |
| 840 | // there is never a window in which an invitation could be composed and find |
| 841 | // nothing listening. |
| 842 | const mail = startMailFixture(); |
| 843 | // riley@example.org is the address this run makes the mail server refuse. |
| 844 | // Every deployment meets one: a typo'd domain, a mailbox that has gone. It |
| 845 | // is what produces the FAILED row below, and a code minted for somebody |
| 846 | // nobody could write to is precisely the silence the send record ends. |
| 847 | mail.refuse.add('riley@example.org'); |
| 848 | const mailUp = await new Promise(r => { |
| 849 | mail.server.listen(MAIL_PORT, '127.0.0.1', () => r(true)); |
| 850 | mail.server.on('error', () => r(false)); |
| 851 | }); |
| 852 | procs.push({ kill: () => { try { mail.server.close(); } catch (e) {} } }); |
| 853 | check('a mail server for the gateway to submit invitations to', |
| 854 | mailUp, `127.0.0.1:${MAIL_PORT}`); |
| 855 | if (!mailUp) die(`nothing could listen on :${MAIL_PORT} — set DAIMOND_MAIL_PORT`); |
| 856 | /// How many messages the mail server has taken. The oracle for every "did |
| 857 | /// this send" and every "did this send NOTHING" below. |
| 858 | const posted = () => mail.messages.length; |
| 859 | |
| 860 | // The dev server that serves the console, pointed at THIS gateway. Started |
| 861 | // rather than borrowed: a world's server proxies to the world's own gateway |
| 862 | // port, and the whole reason this run is on a port of its own is that it must |
| 863 | // meet an EMPTY store that nobody else is writing to. |
| 864 | procs.push(spawn('node', ['dev/serve.mjs'], { |
| 865 | cwd: ROOT, |
| 866 | env: { ...process.env, DAIMOND_PORT: String(APP_PORT), DAIMOND_GW_PORT: String(PORT) }, |
| 867 | stdio: SRV_LOG.stdio, |
| 868 | })); |
| 869 | check('the dev server serves the console', |
| 870 | await waitFor(async () => (await fetch(`${APP}/console/`)).ok, 15000), APP); |
| 871 | |
| 872 | if (!await startGateway(null)) { check('the gateway starts', false); die('no gateway'); } |
| 873 | check('the gateway starts', true, BIN); |
| 874 | |
| 875 | const { chromium } = await import(pathToFileURL(PW).href); |
| 876 | // DISPLAY is dropped: this session's is an X display forwarded over SSH, a |
| 877 | // headless Chrome still consults it, and when nothing answers no frame is |
| 878 | // ever produced -- so every rAF-based wait expires over a page that was |
| 879 | // ready half a minute earlier. dev/harness.mjs drops it for the same reason. |
| 880 | const env = { ...process.env }; |
| 881 | delete env.DISPLAY; |
| 882 | const browser = await chromium.launch({ |
| 883 | executablePath: CHROME, headless: true, args: ['--no-sandbox'], env }); |
| 884 | |
| 885 | try { |
| 886 | // The owner has to exist before the gateway that pins them, so an account |
| 887 | // is made against this process and the next one is told about it. |
| 888 | const boss = await openConsole(browser); |
| 889 | check('an account to be the owner', !!boss.account, boss.account); |
| 890 | const hand = await openConsole(browser); // the second console role |
| 891 | check('a second account, to hold a lesser role', !!hand.account, hand.account); |
| 892 | |
| 893 | await stopGateway(); |
| 894 | check('the gateway restarts with that account pinned as owner', |
| 895 | await startGateway(boss.account)); |
| 896 | |
| 897 | // The owner's session, borrowed from the browser so the oracles below can |
| 898 | // post AS THE OWNER without going through the page. The same cookie, so |
| 899 | // a check made here and a control drawn there cannot be about two |
| 900 | // different accounts. |
| 901 | const jar = { cookie: (await boss.ctx.cookies(APP)) |
| 902 | .map(c => `${c.name}=${c.value}`).join('; ') }; |
| 903 | const who = await call(jar, 'GET', '/api/admin?view=whoami'); |
| 904 | check('the gateway calls that account the owner', |
| 905 | !!who.j && who.j.role === 'owner', JSON.stringify(who.j)); |
| 906 | if (!who.j || who.j.role !== 'owner') die('the owner session did not survive the restart'); |
| 907 | |
| 908 | /// The queue as the GATEWAY reports it. The oracle for everything the |
| 909 | /// panel claims: a console agreeing with itself proves nothing. |
| 910 | const served = async () => (await call(jar, 'GET', '/api/admin?view=applications')).j; |
| 911 | |
| 912 | // BUILD THE LISTING INDEX ONCE, because the gateway no longer builds it on its own. |
| 913 | // |
| 914 | // The owner's decision: a whole-store walk is off the request path, so an unbuilt |
| 915 | // listing answers `needs_build` immediately and walks nothing (`handlers/admin.rs`, |
| 916 | // `drawNeedsBuild` in `www/console/admin.js`). `beta_standing` used to build it on a |
| 917 | // device's UNLOCK, a request is cut off long before such a walk can finish, and the |
| 918 | // half-built table was discarded every time -- so every beta tester was answered |
| 919 | // "no intake". The console is the only thing that builds it now, and an operator |
| 920 | // presses a button to do it. |
| 921 | // |
| 922 | // So this run does what an operator does, once, before it asks anything. Without it |
| 923 | // `served()` answers `{needs_build:true}` with no `total`, and every check below |
| 924 | // reads a product that is working as a product that is broken. |
| 925 | // All three, because this file reads all three. `Listing` has Applications, Passcodes |
| 926 | // and Reports, and each carries its own index: building the queue alone left every |
| 927 | // later check about a minted code reading "the passcode list grew by 0". |
| 928 | for (const view of ['applications', 'passcodes', 'reports']) { |
| 929 | await call(jar, 'GET', `/api/admin?view=${view}&build=1`); |
| 930 | } |
| 931 | |
| 932 | // ── An empty queue ────────────────────────────────────── |
| 933 | { |
| 934 | const s = await served(); |
| 935 | check('the gateway starts this run with an empty queue', |
| 936 | !!s && s.total === 0, JSON.stringify(s && s.total)); |
| 937 | |
| 938 | const up = await enterConsole(boss.page); |
| 939 | check('the console loads for the owner', up); |
| 940 | const p = await panel(boss.page); |
| 941 | check('an owner is given the Applications tab', p.tab); |
| 942 | check('an empty queue says it is empty rather than drawing nothing', |
| 943 | /no applications yet/i.test(p.empty), JSON.stringify(p.empty.slice(0, 90))); |
| 944 | // Which is the difference between "nobody has written in" and "nobody |
| 945 | // can": the form's own state, from the same reply. |
| 946 | check('the empty queue says whether the form is still taking applications', |
| 947 | /taking them|form is shut/i.test(p.empty), JSON.stringify(p.empty.slice(0, 120))); |
| 948 | check('and the panel says so in its own right, from the gateway\'s knob', |
| 949 | /form is open/i.test(p.gate) === (s.open === true), |
| 950 | `open=${s && s.open} · ${JSON.stringify(p.gate.slice(0, 60))}`); |
| 951 | } |
| 952 | |
| 953 | // ── Applications filed at the public endpoint ─────────── |
| 954 | const SAM = 'sam@example.com'; |
| 955 | const RILEY = 'riley@example.org'; |
| 956 | const JO = 'jo@example.net'; |
| 957 | { |
| 958 | const a = await apply(SAM, 'Sam Rivers', |
| 959 | 'I run a small legal practice and want client notes off a cloud.', 'test'); |
| 960 | check('the public form takes an application', |
| 961 | a.status === 200 && !!a.j && a.j.ok === true, |
| 962 | 'status ' + a.status + ' · ' + JSON.stringify(a.j)); |
| 963 | // A second apart, because `created_ts` is in SECONDS and the sort is |
| 964 | // on it: three applications filed inside one second are three equal |
| 965 | // keys, and "newest first" would then be whatever order the store |
| 966 | // happened to scan them in — which is not a property the list can be |
| 967 | // held to. See the note in the report about that tie. |
| 968 | await sleep(1100); |
| 969 | await apply(RILEY, 'Riley', 'Happy to test on an old iPad.', 'test'); |
| 970 | await sleep(1100); |
| 971 | await apply(JO, '', 'Tell me when it ships.', 'waitlist'); |
| 972 | |
| 973 | const s = await served(); |
| 974 | check('the gateway holds all three', !!s && s.total === 3, 'total ' + (s && s.total)); |
| 975 | |
| 976 | await boss.page.click('#admin-refresh', { force: true }); |
| 977 | await sleep(1500); |
| 978 | await waitFor(async () => (await panel(boss.page)).rows.length >= 3, 20000, 200); |
| 979 | const p = await panel(boss.page); |
| 980 | check('an application filed at the form reaches the list', |
| 981 | !!rowFor(p, SAM), p.rows.length + ' rows drawn'); |
| 982 | check('all three reach it', p.rows.length === 3, |
| 983 | p.rows.length + ' rows for ' + (s && s.total) + ' applications'); |
| 984 | |
| 985 | // Newest first, which is the order the gateway sorted them into. An |
| 986 | // operator working a queue reads the top of it, so the top has to be |
| 987 | // the person who wrote in most recently -- asserted against the |
| 988 | // timestamps rather than only against the reply's own order, which |
| 989 | // would agree with any order at all. |
| 990 | check('the list is drawn in the order the gateway sent', |
| 991 | p.rows.map(r => r.id).join(',') === (s.applications || []).map(a2 => a2.id).join(','), |
| 992 | p.rows.map(r => r.id.slice(0, 6)).join(',')); |
| 993 | const stamps = p.rows.map(r => |
| 994 | ((s.applications || []).find(a2 => a2.id === r.id) || {}).created_ts); |
| 995 | check('and that order is newest first', |
| 996 | stamps.every((t, i) => i === 0 || (stamps[i - 1] >= t)) && stamps.length === 3, |
| 997 | stamps.join(' ≥ ')); |
| 998 | check('the newest application is the one filed last', |
| 999 | !!p.rows[0] && p.rows[0].text.includes(JO), p.rows[0] && p.rows[0].id.slice(0, 6)); |
| 1000 | |
| 1001 | const sam = rowFor(p, SAM); |
| 1002 | check('a row carries the applicant\'s own words in full', |
| 1003 | !!sam && sam.text.includes('client notes off a cloud'), |
| 1004 | JSON.stringify((sam && sam.text.slice(0, 60)) || '')); |
| 1005 | check('a row carries the name they gave', |
| 1006 | !!sam && sam.text.includes('Sam Rivers')); |
| 1007 | check('a row carries the status the gateway reports', |
| 1008 | !!sam && sam.status === 'pending' && /pending/.test(sam.text), sam && sam.status); |
| 1009 | check('a row says when they wrote in', |
| 1010 | !!sam && /wrote in/.test(sam.text)); |
| 1011 | // The two invitations the landing page makes are different queues of |
| 1012 | // work, so a row that could not tell them apart would be a list the |
| 1013 | // operator has to sort by hand. |
| 1014 | const jo = rowFor(p, JO); |
| 1015 | check('a waitlist application is told apart from a test one', |
| 1016 | !!jo && /waitlist/.test(jo.text) && !!sam && /test/.test(sam.text), |
| 1017 | JSON.stringify((jo && jo.text.slice(0, 50)) || '')); |
| 1018 | |
| 1019 | // The shape, checked by watching what the console took off each row. |
| 1020 | const keys = Object.keys((s.applications || [])[0] || {}); |
| 1021 | const invented = p.read.filter(k => !keys.includes(k)); |
| 1022 | check('the console reads only fields the gateway actually sends', |
| 1023 | invented.length === 0, |
| 1024 | invented.length ? 'read ' + invented.join(',') + ' — sent ' + keys.join(',') |
| 1025 | : 'read ' + p.read.join(',')); |
| 1026 | check('and it reads the ones that carry the decision', |
| 1027 | ['email', 'status', 'note', 'intent', 'id'].every(k => p.read.includes(k)), |
| 1028 | p.read.join(',')); |
| 1029 | // The tier, on the ROW and not only on the passcode record. An operator |
| 1030 | // working the queue chooses it here, so a row has to be able to say what |
| 1031 | // was chosen for it -- and it has to be the GATEWAY saying it, which is |
| 1032 | // what the check above enforces: the console reads `pro` off every row it |
| 1033 | // draws, so a gateway that did not send it reddens that line rather than |
| 1034 | // this one. |
| 1035 | check('the gateway sends the tier on every row, not only the invited ones', |
| 1036 | (s.applications || []).length === 3 |
| 1037 | && (s.applications || []).every(a2 => typeof a2.pro === 'boolean'), |
| 1038 | (s.applications || []).map(a2 => a2.email + '=' + JSON.stringify(a2.pro)) |
| 1039 | .join(' · ')); |
| 1040 | check('and the console reads it, so what a row shows is the gateway\'s answer', |
| 1041 | p.read.includes('pro'), p.read.join(',')); |
| 1042 | // And a row says NOTHING where it is showing no code. A pill reading |
| 1043 | // 'free' beside a row nobody has decided is a claim about a grant that |
| 1044 | // has not been made, and the pending rows are the ones an operator spends |
| 1045 | // the most time looking at. |
| 1046 | // verifier: `--break tierguess` draws it on every row without a code. |
| 1047 | const idle = []; |
| 1048 | for (const e of [SAM, RILEY, JO]) idle.push(await tierPill(boss.page, e)); |
| 1049 | check('a row showing no code says nothing about a tier', |
| 1050 | idle.every(t => t.row === true && t.there === false), |
| 1051 | idle.map((t, i) => [SAM, RILEY, JO][i] + '=' |
| 1052 | + (t.there ? t.n + '×' + t.text : 'none')).join(' · ')); |
| 1053 | |
| 1054 | check('the panel counts what the gateway counted', |
| 1055 | /3 applications/.test(p.hint) && /3 still waiting/.test(p.hint), |
| 1056 | JSON.stringify(p.hint)); |
| 1057 | } |
| 1058 | |
| 1059 | // ── The tier control, before anything is decided ──────── |
| 1060 | // |
| 1061 | // An approval from this queue mints a passcode, and until now it always |
| 1062 | // minted a FREE one: the tier could only be chosen in the passcodes panel, |
| 1063 | // so a tester who was promised Pro needed a second code minted there and |
| 1064 | // handed over some other way. The control that closes that is a pulldown |
| 1065 | // beside the wave field, and the questions worth asking of it are the ones |
| 1066 | // a screenshot answers and `querySelector` does not: is it there, can it be |
| 1067 | // seen, and does it say what the other panel says. |
| 1068 | { |
| 1069 | // The passcodes panel's own control has to be in the document for the |
| 1070 | // comparison to mean anything -- `refreshAll` fetches eleven views and |
| 1071 | // this one is not the queue. |
| 1072 | await waitFor(async () => await boss.page.evaluate( |
| 1073 | () => !!document.getElementById('admin-beta-tier')), 20000, 250); |
| 1074 | const t = await tierControl(boss.page, BREAK === 'tierwords'); |
| 1075 | // verifier: `--break tierhidden` withholds it from an owner. |
| 1076 | check('the applications panel offers a tier control at all', |
| 1077 | t.there === true && t.hidden === false && t.tag === 'SELECT', |
| 1078 | t.there ? `${t.tag} hidden=${t.hidden}` : 'no #admin-ap-tier in the page'); |
| 1079 | // The 0x0 case, which is what a control in the wrong pane measures and |
| 1080 | // what `querySelector` calls present. A press needs a target. |
| 1081 | check('and it is drawn at a size an operator can hit', |
| 1082 | t.there === true && t.w >= 90 && t.h >= 16, |
| 1083 | t.there ? `${t.w}x${t.h}px at font-size ${t.fs}` : 'not measured'); |
| 1084 | check('and it is actually visible, in ink that is not its own background', |
| 1085 | t.there === true && t.shown === true && t.vis === 'visible' |
| 1086 | && Number(t.op) === 1 && t.ink !== t.bg, |
| 1087 | t.there ? `shown=${t.shown} · ${t.vis} · opacity ${t.op} · ${t.ink} on ${t.bg}` |
| 1088 | : 'not measured'); |
| 1089 | check('it is inside the window rather than off the side of it', |
| 1090 | t.there === true && t.right <= t.win + 1, |
| 1091 | t.there ? `ends at ${t.right} of ${t.win}px` : 'not measured'); |
| 1092 | // Decision 4: free unless somebody asks for Pro. A default that gifts |
| 1093 | // Pro is how the free surface goes on having no testers. |
| 1094 | check('and it opens on the free tier', |
| 1095 | t.value === 'free', JSON.stringify(t.value)); |
| 1096 | check('and it says which codes it governs', |
| 1097 | /tier/i.test(t.label) && /minted/i.test(t.label), JSON.stringify(t.label)); |
| 1098 | // One decision, one vocabulary. An operator who minted a Pro code in |
| 1099 | // the passcodes panel this morning must not have to work out whether |
| 1100 | // "Pro" here is the same offer. |
| 1101 | // verifier: `--break tierwords` rewords this panel's options in the |
| 1102 | // page before they are read. |
| 1103 | check('and it words the two tiers exactly as the passcodes panel does', |
| 1104 | !!t.opts && !!t.beta && t.opts.join(' | ') === t.beta.join(' | '), |
| 1105 | !t.beta ? 'the passcodes panel drew no tier control to compare with' |
| 1106 | : JSON.stringify(t.opts) + ' vs ' + JSON.stringify(t.beta)); |
| 1107 | } |
| 1108 | |
| 1109 | // ── A legal decision ──────────────────────────────────── |
| 1110 | { |
| 1111 | const before = posted(); |
| 1112 | const pressed = await pressMove(boss.page, JO, 'declined'); |
| 1113 | check('a pending row offers Decline', pressed === 'clicked', pressed); |
| 1114 | // Only inviting sends. A decline is a decision and nothing else, and |
| 1115 | // a gateway that wrote to somebody it had just turned down would be |
| 1116 | // worse than one that wrote to nobody. |
| 1117 | // verifier: `--break declinesends` posts an INVITE when Decline is |
| 1118 | // pressed, which puts a passcode in a declined applicant's hands. |
| 1119 | check('declining an application sends NOTHING', |
| 1120 | posted() === before, |
| 1121 | `the mail server took ${posted() - before} message(s) on a decline`); |
| 1122 | const s = await served(); |
| 1123 | const rec = (s.applications || []).find(a => a.email === JO); |
| 1124 | check('declining changes the status the GATEWAY reports', |
| 1125 | !!rec && rec.status === 'declined', rec && rec.status); |
| 1126 | check('and it records who decided it', |
| 1127 | !!rec && rec.decided_by === boss.account, rec && rec.decided_by); |
| 1128 | const p = await panel(boss.page); |
| 1129 | const row = rowFor(p, JO); |
| 1130 | check('the panel shows the status the gateway holds, not the one clicked', |
| 1131 | !!row && row.status === (rec && rec.status), row && row.status); |
| 1132 | check('a decided row no longer offers the status it holds', |
| 1133 | !!row && !row.moves.includes('declined'), row && row.moves.join(',')); |
| 1134 | check('and still offers the ways back', |
| 1135 | !!row && row.moves.includes('pending'), row && row.moves.join(',')); |
| 1136 | } |
| 1137 | |
| 1138 | // ── Inviting, which mints ─────────────────────────────── |
| 1139 | let code = ''; |
| 1140 | // What the mail server had taken before this invite, so the section |
| 1141 | // after it measures a DELTA. Anything that sent earlier — a break that |
| 1142 | // makes Decline send, say — would otherwise be counted as this invite's. |
| 1143 | let mailBefore = 0; |
| 1144 | { |
| 1145 | const before = await call(jar, 'GET', '/api/admin?view=passcodes'); |
| 1146 | mailBefore = posted(); |
| 1147 | const pressed = await pressMove(boss.page, SAM, 'invited'); |
| 1148 | check('a pending row offers Invite', pressed === 'clicked', pressed); |
| 1149 | |
| 1150 | const s = await served(); |
| 1151 | const rec = (s.applications || []).find(a => a.email === SAM); |
| 1152 | check('inviting changes the status the gateway reports', |
| 1153 | !!rec && rec.status === 'invited', rec && rec.status); |
| 1154 | check('inviting minted a passcode against the row', |
| 1155 | !!rec && typeof rec.code === 'string' && rec.code.length > 0, |
| 1156 | rec ? (rec.code ? 'a code is on the row' : 'none') : 'no row'); |
| 1157 | |
| 1158 | const after = await call(jar, 'GET', '/api/admin?view=passcodes'); |
| 1159 | const grew = ((after.j && after.j.passcodes) || []).length |
| 1160 | - ((before.j && before.j.passcodes) || []).length; |
| 1161 | check('the code is a real passcode in the cohort, not a decoration', |
| 1162 | grew === 1, 'the passcode list grew by ' + grew); |
| 1163 | // The label is the coherence the panel exists for: an operator should |
| 1164 | // never have to retype a name into another panel to act on what they |
| 1165 | // just read. |
| 1166 | const mine = ((after.j && after.j.passcodes) || []) |
| 1167 | .find(pc => (pc.label || '').includes(SAM)); |
| 1168 | check('the code is labelled with the applicant it was minted for', |
| 1169 | !!mine, mine ? mine.label : 'no passcode carries the address'); |
| 1170 | // THE DEFAULT. Nothing touched the tier control before this press, so |
| 1171 | // what it minted is what an operator gets by working the queue and |
| 1172 | // reading nothing: the free tier. Read off the passcode record, which |
| 1173 | // is where the field lives -- the application row never carries it. |
| 1174 | check('a decision taken with the tier control untouched mints a FREE code', |
| 1175 | !!mine && mine.pro === false, |
| 1176 | mine ? 'pro=' + JSON.stringify(mine.pro) : 'no passcode to read'); |
| 1177 | // And the console asked for it in those words. The request, because the |
| 1178 | // check above would also pass on a gateway that ignored the field and |
| 1179 | // defaulted to free on its own. |
| 1180 | // verifier: `--break tierfree` sends false whatever is chosen, which |
| 1181 | // leaves this green and reddens the Pro press below -- as it should. |
| 1182 | const asked = await apPosts(boss.page); |
| 1183 | const last = asked[asked.length - 1] || {}; |
| 1184 | check('and the console sent that tier with the decision', |
| 1185 | last.pro === false && last.status === 'invited', |
| 1186 | JSON.stringify(last)); |
| 1187 | |
| 1188 | const p = await panel(boss.page); |
| 1189 | const row = rowFor(p, SAM); |
| 1190 | check('the row shows the code, so it can be copied and sent', |
| 1191 | !!row && row.code.length === 1 && row.code[0].length > 0, |
| 1192 | row ? row.code.length + ' codes drawn' : 'no row'); |
| 1193 | check('the row says the code is shown until it is used', |
| 1194 | !!row && /shown until it is used/.test(row.text)); |
| 1195 | // A credential must not be repeated into a second place on the page. |
| 1196 | check('the status line does not repeat the code', |
| 1197 | !!row && !p.status.includes(row.code[0]), JSON.stringify(p.status)); |
| 1198 | |
| 1199 | // WHAT THAT CODE GRANTS, on the row. Until now the row carried a code |
| 1200 | // and said nothing about its tier, which is the half of the decision |
| 1201 | // this panel took over and the half an operator can get wrong in the |
| 1202 | // expensive direction. Measured rather than found, for the reason |
| 1203 | // `tierPill` records. |
| 1204 | // verifier: `--break tiermute` draws no pill at all, which is the row |
| 1205 | // exactly as it was. |
| 1206 | const tp = await tierPill(boss.page, SAM); |
| 1207 | check('an invited row says which tier its code grants', |
| 1208 | tp.there === true && tp.n === 1, |
| 1209 | tp.row ? (tp.there ? tp.n + '× ' + JSON.stringify(tp.text) |
| 1210 | : 'no .admin-ap-tier-pill on the row') : 'no row for ' + SAM); |
| 1211 | check('and it is a pill an operator can actually read', |
| 1212 | tp.there === true && tp.shown === true && tp.w >= 24 && tp.h >= 12 |
| 1213 | && tp.vis === 'visible' && Number(tp.op) >= 0.5 && tp.ink !== tp.bg |
| 1214 | && tp.right <= tp.win + 1, |
| 1215 | tp.there ? `${tp.w}x${tp.h}px · shown=${tp.shown} · ${tp.vis} · opacity ${tp.op}` |
| 1216 | + ` · ${tp.ink} on ${tp.bg} at ${tp.fs} · ends at ${tp.right} of ${tp.win}` |
| 1217 | : 'not measured'); |
| 1218 | // The oracle is the passcode record, not the pulldown: the pulldown says |
| 1219 | // what the NEXT mint would be, and an operator who has moved it since is |
| 1220 | // exactly the person this pill is for. |
| 1221 | // verifier: `--break tierswap` draws the opposite of what was minted. |
| 1222 | check('and it says what the gateway minted, not what the pulldown shows', |
| 1223 | !!mine && mine.pro === false && tp.text === 'free', |
| 1224 | (mine ? 'the record says pro=' + JSON.stringify(mine.pro) : 'no record') |
| 1225 | + ' · the row says ' + JSON.stringify(tp.text)); |
| 1226 | check('and it is drawn beside the code it describes', |
| 1227 | tp.beside === true && tp.codeShown === true, |
| 1228 | `beside the code=${tp.beside} · a code is shown=${tp.codeShown}`); |
| 1229 | // And the sentence says it too, because the operator's eyes are on the |
| 1230 | // status line at the moment the press comes back and may never reach the |
| 1231 | // row. It may only say it BECAUSE this press minted -- see the resend |
| 1232 | // section, which is the other half of that rule. |
| 1233 | // verifier: `--break tiermum` never names a tier. |
| 1234 | check('the status line names the tier that press minted', |
| 1235 | /a free passcode/.test(p.status) && !/\bPro\b/.test(p.status), |
| 1236 | JSON.stringify(p.status.slice(0, 110))); |
| 1237 | code = (row && row.code[0]) || ''; |
| 1238 | } |
| 1239 | |
| 1240 | // ── The invitation actually reaches the applicant ─────── |
| 1241 | // |
| 1242 | // The property this whole panel turned out to be missing. Everything |
| 1243 | // above proves a code was minted and drawn; none of it proves anybody |
| 1244 | // received one. Until tonight nothing sent it at all — the console said |
| 1245 | // "copy it and send it", and whether that happened was outside the app. |
| 1246 | // |
| 1247 | // Measured at the mail server the gateway submitted to, so "a send was |
| 1248 | // attempted" is bytes on a socket rather than a field the gateway set |
| 1249 | // about itself. |
| 1250 | let firstMsg = null; |
| 1251 | { |
| 1252 | check('inviting SENT a message', posted() === mailBefore + 1, |
| 1253 | (posted() - mailBefore) + ' message(s) reached the mail server on that invite'); |
| 1254 | firstMsg = mail.messages[mailBefore] || null; |
| 1255 | check('addressed to the address on the application', |
| 1256 | !!firstMsg && firstMsg.rcpt.length === 1 && firstMsg.rcpt[0] === SAM, |
| 1257 | firstMsg ? JSON.stringify(firstMsg.rcpt) : 'no message'); |
| 1258 | check('from the mailbox the gateway is configured with, not the applicant\'s', |
| 1259 | !!firstMsg && firstMsg.from === MAIL_FROM, |
| 1260 | firstMsg ? firstMsg.from : 'no message'); |
| 1261 | check('and the gateway proved itself to the mail server before sending', |
| 1262 | mail.auth.length >= 1 && mail.auth[0] === 'PLAIN', |
| 1263 | JSON.stringify(mail.auth)); |
| 1264 | |
| 1265 | const body = (firstMsg && firstMsg.body) || ''; |
| 1266 | // The code, and the code that is on THIS row: a message carrying |
| 1267 | // some other applicant's passcode would satisfy "a code was sent". |
| 1268 | // verifier: `--break nocode` blanks it out of what the mail server |
| 1269 | // records, which is a message that went out with nothing in it. |
| 1270 | check('the message carries the code that is on the row', |
| 1271 | !!code && body.includes(code), |
| 1272 | code ? (body.includes(code) ? 'the row\'s code is in the message' |
| 1273 | : 'the row\'s code is NOT in the message') : 'no code on the row'); |
| 1274 | // verifier: `--break bland` strips the sentence, which is an |
| 1275 | // applicant who does not know the code stops working when used. |
| 1276 | check('and says it works once, on one device', |
| 1277 | /works once/.test(body) && /one account on one device/.test(body), |
| 1278 | JSON.stringify(body.slice(body.indexOf('It opens'), body.indexOf('It opens') + 90))); |
| 1279 | check('and says where to put it', |
| 1280 | body.includes(MAIL_URL), MAIL_URL); |
| 1281 | // It opens by acknowledging that they applied. They may have been |
| 1282 | // waiting days, and an unexplained string of characters is not an |
| 1283 | // answer to that. |
| 1284 | check('and it acknowledges the application before it gives the code', |
| 1285 | body.indexOf('You wrote in') > 0 |
| 1286 | && (!code || body.indexOf('You wrote in') < body.indexOf(code)), |
| 1287 | 'acknowledgement at ' + body.indexOf('You wrote in') |
| 1288 | + ', code at ' + body.indexOf(code)); |
| 1289 | // Plain text and one part, which is what disposes of the tracking |
| 1290 | // pixel structurally: there is nowhere in a text message to put one. |
| 1291 | check('the message is plain text with no HTML part and no remote image', |
| 1292 | /Content-Type: text\/plain; charset=utf-8/.test(body) |
| 1293 | && !/text\/html/i.test(body) && !/<img/i.test(body), |
| 1294 | JSON.stringify((body.match(/Content-Type:.*/) || [''])[0])); |
| 1295 | // Exactly one URL, printed in full: no link whose text hides where |
| 1296 | // it goes, because there is no link at all. |
| 1297 | check('and carries exactly one URL, the one it tells you to open', |
| 1298 | (body.match(/https?:\/\//g) || []).length === 1, |
| 1299 | (body.match(/https?:\/\/\S*/g) || []).join(' · ')); |
| 1300 | // A message with no Date is not a valid RFC 5322 document and is |
| 1301 | // scored as spam by most of what would receive it. |
| 1302 | check('and it is a message a mail server will accept: Date, From, To, Message-ID', |
| 1303 | /\r\nDate: [A-Z][a-z]{2}, /.test('\r\n' + body) |
| 1304 | && /(^|\r\n)From: /.test(body) |
| 1305 | && /(^|\r\n)To: /.test(body) |
| 1306 | && /(^|\r\n)Message-ID: </.test(body), |
| 1307 | JSON.stringify(body.split('\r\n\r\n')[0].slice(0, 120))); |
| 1308 | |
| 1309 | // And what the gateway says about it, which is what the operator |
| 1310 | // reads. Silence here is the failure: a row that says "invited" and |
| 1311 | // nothing else is one an operator marks off as done. |
| 1312 | const s = await served(); |
| 1313 | const rec = (s.applications || []).find(a => a.email === SAM); |
| 1314 | check('the gateway records the send on the row', |
| 1315 | !!rec && rec.sent === 'sent' && rec.sent_to === SAM && rec.sent_ts > 0, |
| 1316 | rec ? `sent=${rec.sent} to=${rec.sent_to} n=${rec.sent_n}` : 'no row'); |
| 1317 | const p = await panel(boss.page); |
| 1318 | const row = rowFor(p, SAM); |
| 1319 | // verifier: `--break mute` draws no send line at all, which is the |
| 1320 | // panel exactly as it was before it could send. |
| 1321 | check('and the panel says the invitation went, and when', |
| 1322 | !!row && /Sent to sam@example\.com/.test(row.sent) && /\d/.test(row.sent), |
| 1323 | JSON.stringify((row && row.sent) || '')); |
| 1324 | check('the status line says it was sent, not that you should send it', |
| 1325 | /sent them their code/.test(p.status), JSON.stringify(p.status)); |
| 1326 | // Not vacuous: the standing warning is the thing an unconfigured |
| 1327 | // gateway shows, and this run's gateway is configured. |
| 1328 | // verifier: `--break nomail` starts it with no mailbox at all. |
| 1329 | check('and the panel does not warn that inviting sends nothing', |
| 1330 | p.mail === '' && s.mail_ready === true, |
| 1331 | `mail_ready=${s.mail_ready} · ${JSON.stringify(p.mail.slice(0, 70))}`); |
| 1332 | } |
| 1333 | |
| 1334 | // ── Resending, which must not mint ────────────────────── |
| 1335 | // |
| 1336 | // The other half of the failure story. A send that failed leaves a live |
| 1337 | // code nobody holds, so there has to be a way to try again — and that |
| 1338 | // way must send the SAME code. Two live codes for one applicant is one |
| 1339 | // credential unaccounted for, and the console has no way to say which of |
| 1340 | // them the person on the row is holding. |
| 1341 | { |
| 1342 | const before = posted(); |
| 1343 | const codesBefore = ((await call(jar, 'GET', '/api/admin?view=passcodes')).j |
| 1344 | || {}).passcodes || []; |
| 1345 | const pressed = await pressResend(boss.page, SAM); |
| 1346 | check('an invited row whose code is still live offers Resend', |
| 1347 | pressed === 'clicked', pressed); |
| 1348 | |
| 1349 | check('resending sends the message again', |
| 1350 | posted() === before + 1, |
| 1351 | `the mail server took ${posted() - before} further message(s)`); |
| 1352 | const again = mail.messages[mail.messages.length - 1]; |
| 1353 | check('and it is the SAME code, not a fresh one', |
| 1354 | !!again && !!code && again.body.includes(code), |
| 1355 | again ? (again.body.includes(code) ? 'the same code' |
| 1356 | : 'a different code went out') : 'no message'); |
| 1357 | check('and it went to the same applicant', |
| 1358 | !!again && again.rcpt[0] === SAM, again ? JSON.stringify(again.rcpt) : 'none'); |
| 1359 | |
| 1360 | // The oracle for "mints nothing", measured in the cohort rather than |
| 1361 | // on the row: a second code minted under a different label would not |
| 1362 | // show up on this application at all. |
| 1363 | // verifier: `--break resendmints` mints one on the way past. |
| 1364 | const codesAfter = ((await call(jar, 'GET', '/api/admin?view=passcodes')).j |
| 1365 | || {}).passcodes || []; |
| 1366 | check('and NO second passcode was minted', |
| 1367 | codesAfter.length === codesBefore.length, |
| 1368 | `the cohort went from ${codesBefore.length} to ${codesAfter.length}`); |
| 1369 | |
| 1370 | // THE SENTENCE THAT MUST NOT BE SAID. A resend hands back the code |
| 1371 | // already on the row and mints nothing, so there is no grant to report |
| 1372 | // and naming a tier here would describe a decision taken minutes ago as |
| 1373 | // though it had just been made. The panel's own status line, read at the |
| 1374 | // moment the press comes back. |
| 1375 | // verifier: `--break tiersay` names one whatever happened. |
| 1376 | const rp0 = await panel(boss.page); |
| 1377 | check('a resend mints nothing, so its sentence claims no tier', |
| 1378 | !/passcode/.test(rp0.status) && !/\bPro\b/.test(rp0.status) |
| 1379 | && !/\bfree\b/.test(rp0.status), |
| 1380 | JSON.stringify(rp0.status.slice(0, 110))); |
| 1381 | // Not vacuous: the line said something, and what it said was about this |
| 1382 | // send. A blank status line would satisfy the check above. |
| 1383 | check('and it does say the code went again', |
| 1384 | /again/.test(rp0.status) && rp0.status.includes(SAM), |
| 1385 | JSON.stringify(rp0.status.slice(0, 90))); |
| 1386 | // The row goes on saying it, though: the code is still live and the tier |
| 1387 | // is still the operator's business. Nothing minted, nothing changed. |
| 1388 | const tr = await tierPill(boss.page, SAM); |
| 1389 | check('while the row still says which tier that code grants', |
| 1390 | tr.there === true && tr.text === 'free', tr.there ? tr.text : 'no pill'); |
| 1391 | |
| 1392 | const s = await served(); |
| 1393 | const rec = (s.applications || []).find(a => a.email === SAM); |
| 1394 | check('the row counts the attempts, so a chased applicant is visible', |
| 1395 | !!rec && rec.sent_n === 2, rec ? String(rec.sent_n) : 'no row'); |
| 1396 | check('and the code on the row is unchanged', |
| 1397 | !!rec && rec.code === code, rec ? (rec.code === code ? 'unchanged' : 'changed') |
| 1398 | : 'no row'); |
| 1399 | } |
| 1400 | |
| 1401 | // ── A send the mail server refuses ────────────────────── |
| 1402 | // |
| 1403 | // THE CASE THE WHOLE DESIGN IS FOR. The mint succeeds, the decision is |
| 1404 | // written, and the message does not go. What must not happen is a row |
| 1405 | // that looks dealt with: the applicant is still waiting, the code exists, |
| 1406 | // and nobody but the store knows. |
| 1407 | { |
| 1408 | // Pro, chosen the way an operator chooses it: on the control, by a |
| 1409 | // press, before the row is decided. Riley's is also the send the mail |
| 1410 | // server refuses, which makes it the harder case for the tier too -- |
| 1411 | // the code is minted, nobody receives it, and what it grants still has |
| 1412 | // to be right when it is read off the row and sent by hand. |
| 1413 | // |
| 1414 | // `selectOption` and not a scripted `value =`: a control that cannot be |
| 1415 | // pressed is the failure being ruled out here, and this is caught |
| 1416 | // rather than thrown so a withheld control reddens this line instead of |
| 1417 | // ending the run. |
| 1418 | let took = 'set'; |
| 1419 | try { |
| 1420 | await boss.page.selectOption('#admin-ap-tier', 'pro', { timeout: 5000 }); |
| 1421 | } catch (e) { took = (e.message || 'could not be set').split('\n')[0]; } |
| 1422 | const chose = await boss.page.evaluate(() => { |
| 1423 | const s = document.getElementById('admin-ap-tier'); |
| 1424 | return s ? s.value : 'no control'; |
| 1425 | }); |
| 1426 | // verifier: `--break tierhidden` withholds the control, so there is |
| 1427 | // nothing to press. |
| 1428 | check('the tier control can be set to Pro by a press', chose === 'pro', |
| 1429 | took + ' · reads ' + chose); |
| 1430 | |
| 1431 | const before = posted(); |
| 1432 | const pressed = await pressMove(boss.page, RILEY, 'invited'); |
| 1433 | check('a pending row can be invited even where the send will fail', |
| 1434 | pressed === 'clicked', pressed); |
| 1435 | |
| 1436 | // What that press asked for, and what the gateway minted for it. |
| 1437 | // verifier: `--break tierfree` sends free whatever was chosen. |
| 1438 | const askedPro = await apPosts(boss.page); |
| 1439 | const lastPro = askedPro[askedPro.length - 1] || {}; |
| 1440 | check('approving with Pro chosen sends pro=true', |
| 1441 | lastPro.pro === true && lastPro.status === 'invited', |
| 1442 | JSON.stringify(lastPro)); |
| 1443 | const cohort = ((await call(jar, 'GET', '/api/admin?view=passcodes')).j |
| 1444 | || {}).passcodes || []; |
| 1445 | const rp = cohort.find(pc => (pc.label || '').includes(RILEY)); |
| 1446 | check('and the code the gateway minted for that row IS a Pro one', |
| 1447 | !!rp && rp.pro === true, |
| 1448 | rp ? 'pro=' + JSON.stringify(rp.pro) : 'no passcode carries the address'); |
| 1449 | // Not vacuous, and the guard against a check that reddens the wrong |
| 1450 | // thing: the free code minted a moment ago is still free. A tier that |
| 1451 | // leaked across rows would be worse than one that never worked. |
| 1452 | const sp = cohort.find(pc => (pc.label || '').includes(SAM)); |
| 1453 | check('and the free code minted before it is untouched by that choice', |
| 1454 | !!sp && sp.pro === false, |
| 1455 | sp ? 'pro=' + JSON.stringify(sp.pro) : 'no passcode for the first applicant'); |
| 1456 | // Put back, so nothing after this section inherits a tier it did not |
| 1457 | // ask for -- and so the panel is photographed as an operator finds it. |
| 1458 | try { |
| 1459 | await boss.page.selectOption('#admin-ap-tier', 'free', { timeout: 5000 }); |
| 1460 | } catch (e) {} |
| 1461 | check('the mail server refused it, so nothing was accepted', |
| 1462 | posted() === before, `${posted() - before} message(s) were taken`); |
| 1463 | // Read before anything else touches the page: this is what the |
| 1464 | // operator is looking at the moment the press comes back, and it is |
| 1465 | // the one place they will see it if they never scroll to the row. |
| 1466 | check('the status line says the code did NOT go out', |
| 1467 | /did NOT go out/.test((await panel(boss.page)).status), |
| 1468 | JSON.stringify((await panel(boss.page)).status.slice(0, 130))); |
| 1469 | // And WHICH code did not go out. This is the expensive row: a five-year |
| 1470 | // Pro licence minted, nobody holding it, and the only way it reaches |
| 1471 | // anybody now is an operator reading it off the row and writing by hand |
| 1472 | // -- so the sentence has to say what they are carrying. |
| 1473 | // verifier: `--break tiermum` names no tier; `--break tierfree` mints the |
| 1474 | // wrong one, which reddens this line and the two below it, correctly. |
| 1475 | check('and it names Pro, because Pro is what that press minted', |
| 1476 | /a Pro passcode/.test((await panel(boss.page)).status), |
| 1477 | JSON.stringify((await panel(boss.page)).status.slice(0, 130))); |
| 1478 | |
| 1479 | const s = await served(); |
| 1480 | const rec = (s.applications || []).find(a => a.email === RILEY); |
| 1481 | check('a refused send is recorded as a FAILURE, not as silence', |
| 1482 | !!rec && rec.sent === 'failed', |
| 1483 | rec ? `sent=${JSON.stringify(rec.sent)}` : 'no row'); |
| 1484 | check('and the row carries the reason the mail server gave', |
| 1485 | !!rec && /550|no mailbox/.test(rec.sent_why || ''), |
| 1486 | rec ? JSON.stringify((rec.sent_why || '').slice(0, 90)) : 'no row'); |
| 1487 | // The decision stands. Unwinding it would throw away a minted code |
| 1488 | // and leave the operator with nothing to resend. |
| 1489 | check('the decision still stands, so the code is not lost with the send', |
| 1490 | !!rec && rec.status === 'invited' && rec.code.length > 0, |
| 1491 | rec ? `${rec.status} · code ${rec.code ? 'present' : 'gone'}` : 'no row'); |
| 1492 | |
| 1493 | await boss.page.click('#admin-refresh', { force: true }); |
| 1494 | await sleep(1800); |
| 1495 | const p = await panel(boss.page); |
| 1496 | const row = rowFor(p, RILEY); |
| 1497 | // verifier: `--break mute` draws no send line at all, and |
| 1498 | // `--break sentok` draws this one as a success. |
| 1499 | check('the panel shows a failed send AS a failure', |
| 1500 | !!row && /SEND FAILED/.test(row.sent), |
| 1501 | JSON.stringify((row && row.sent.slice(0, 90)) || '')); |
| 1502 | check('and says nobody has the code', |
| 1503 | !!row && /Nobody has this code/.test(row.sent), |
| 1504 | JSON.stringify((row && row.sent.slice(-70)) || '')); |
| 1505 | check('and the code is still drawn, so it can be sent by hand', |
| 1506 | !!row && row.code.length === 1, |
| 1507 | row ? row.code.length + ' codes drawn' : 'no row'); |
| 1508 | // verifier: `--break noresend` withholds it, which is a failed send |
| 1509 | // with no way to try it again. |
| 1510 | check('and Resend is offered on the failed row', |
| 1511 | !!row && row.resend === true, row ? String(row.resend) : 'no row'); |
| 1512 | // The Pro pill, against the passcode record read at the top of this |
| 1513 | // block. The two tiers have to be told apart on sight, so this is |
| 1514 | // checked against the OTHER row as well: a panel drawing one pill |
| 1515 | // everywhere would satisfy either row alone. |
| 1516 | // verifier: `--break tierswap` swaps both; `--break tiermute` draws |
| 1517 | // neither. |
| 1518 | const tpro = await tierPill(boss.page, RILEY); |
| 1519 | const tfree = await tierPill(boss.page, SAM); |
| 1520 | check('a Pro row says Pro, and the free row beside it still says free', |
| 1521 | !!rp && rp.pro === true && tpro.text === 'Pro' && tfree.text === 'free', |
| 1522 | `the record says pro=${JSON.stringify(rp && rp.pro)} · ` |
| 1523 | + `${RILEY} shows ${JSON.stringify(tpro.text)} · ` |
| 1524 | + `${SAM} shows ${JSON.stringify(tfree.text)}`); |
| 1525 | check('and the Pro pill is one an operator can read', |
| 1526 | tpro.there === true && tpro.shown === true && tpro.w >= 24 && tpro.h >= 12 |
| 1527 | && tpro.vis === 'visible' && Number(tpro.op) >= 0.5 |
| 1528 | && tpro.ink !== tpro.bg && tpro.right <= tpro.win + 1, |
| 1529 | tpro.there ? `${tpro.w}x${tpro.h}px · shown=${tpro.shown} · ${tpro.vis}` |
| 1530 | + ` · opacity ${tpro.op} · ${tpro.ink} on ${tpro.bg} at ${tpro.fs}` |
| 1531 | : 'not measured'); |
| 1532 | void p; |
| 1533 | } |
| 1534 | |
| 1535 | // ── The code works, and then stops being shown ────────── |
| 1536 | { |
| 1537 | const sam = device(); |
| 1538 | const r = await call(null, 'POST', '/api/passcode/redeem', |
| 1539 | Object.assign({ code }, binding(sam)), '203.0.113.90'); |
| 1540 | check('the code minted from this panel actually opens the door', |
| 1541 | r.status === 200 && !!r.j && r.j.ok === true, |
| 1542 | 'status ' + r.status + ' · pro=' + (r.j && r.j.pro)); |
| 1543 | // The end of the free chain, and the reply's `pro` is whether a LICENCE |
| 1544 | // is now held rather than what the code was for -- so this is the |
| 1545 | // account, not the record: the default press let somebody in on the free |
| 1546 | // tier and gifted nothing. The gateway has a licence signing key in this |
| 1547 | // run (`buildWorkDir` symlinks it), so a false here is a decision and not |
| 1548 | // a missing key. |
| 1549 | check('and the account it opened holds no Pro licence, because the code was free', |
| 1550 | !!r.j && r.j.pro === false, 'pro=' + JSON.stringify(r.j && r.j.pro)); |
| 1551 | |
| 1552 | const s = await served(); |
| 1553 | const rec = (s.applications || []).find(a => a.email === SAM); |
| 1554 | check('the gateway stops sending a spent code', |
| 1555 | !!rec && rec.code === '' && rec.redeemed === true, |
| 1556 | rec ? `code=${JSON.stringify(rec.code)} redeemed=${rec.redeemed}` : 'no row'); |
| 1557 | |
| 1558 | await boss.page.click('#admin-refresh', { force: true }); |
| 1559 | await sleep(1800); |
| 1560 | const p = await panel(boss.page); |
| 1561 | const row = rowFor(p, SAM); |
| 1562 | // The count and never the code. This line goes into a log, and a |
| 1563 | // verifier that prints a credential to prove a console did not is |
| 1564 | // not much of an improvement on the console printing it. |
| 1565 | check('a spent code stops being shown, so it cannot be sent twice', |
| 1566 | !!row && row.code.length === 0, |
| 1567 | row ? row.code.length + ' codes still drawn on the row' : 'no row'); |
| 1568 | check('and the row says why it is gone', |
| 1569 | !!row && /has been used/.test(row.text), |
| 1570 | JSON.stringify((row && row.text.slice(-80)) || '')); |
| 1571 | // The tier goes with the code. A pill on a row holding nothing describes |
| 1572 | // a credential that cannot be handed to anybody, and the gateway still |
| 1573 | // sends the field for this row -- so the silence is the console's |
| 1574 | // decision and worth holding it to. |
| 1575 | // verifier: `--break tierguess` draws it on every row without a code, |
| 1576 | // this one included. |
| 1577 | const tspent = await tierPill(boss.page, SAM); |
| 1578 | check('a spent code takes its tier off the row with it', |
| 1579 | tspent.row === true && tspent.there === false, |
| 1580 | tspent.there ? tspent.n + '× ' + JSON.stringify(tspent.text) : 'no pill drawn'); |
| 1581 | check('though the gateway still says what it granted, for whoever asks next', |
| 1582 | !!rec && typeof rec.pro === 'boolean', |
| 1583 | rec ? 'pro=' + JSON.stringify(rec.pro) : 'no row'); |
| 1584 | } |
| 1585 | |
| 1586 | // ── The one decision the gateway refuses ──────────────── |
| 1587 | // |
| 1588 | // A redeemed applicant put back to pending. The row is pending, so |
| 1589 | // "already invited" is not what makes Invite wrong -- the gateway |
| 1590 | // refuses it because a second code would let somebody else in on their |
| 1591 | // name. Both halves are checked: that the control is absent, and that |
| 1592 | // the post it would have made is genuinely refused. |
| 1593 | { |
| 1594 | const s0 = await served(); |
| 1595 | const rec0 = (s0.applications || []).find(a => a.email === SAM); |
| 1596 | const beforeBack = posted(); |
| 1597 | const back = await call(jar, 'POST', '/api/admin?view=applications', |
| 1598 | { id: rec0.id, status: 'pending' }); |
| 1599 | check('a decided row can be put back to pending', back.status === 200, |
| 1600 | 'status ' + back.status); |
| 1601 | // Undoing a decision is not a decision to write to somebody. Only |
| 1602 | // inviting sends, and this row is already redeemed besides. |
| 1603 | check('putting a row back to pending sends NOTHING', |
| 1604 | posted() === beforeBack, |
| 1605 | `the mail server took ${posted() - beforeBack} message(s) on an undo`); |
| 1606 | |
| 1607 | await boss.page.click('#admin-refresh', { force: true }); |
| 1608 | await sleep(1800); |
| 1609 | const p = await panel(boss.page); |
| 1610 | const row = rowFor(p, SAM); |
| 1611 | check('the row is pending again, and redeemed', !!row && row.status === 'pending', |
| 1612 | row && row.status); |
| 1613 | check('a redeemed applicant is NOT offered Invite', |
| 1614 | !!row && !row.moves.includes('invited'), row && row.moves.join(',')); |
| 1615 | // Not vacuous: the row still has controls, so the absence above is a |
| 1616 | // decision rather than a panel that drew nothing. |
| 1617 | check('while the rest of that row\'s decisions are still offered', |
| 1618 | !!row && row.moves.includes('declined'), row && row.moves.join(',')); |
| 1619 | check('and the row says why the control is missing', |
| 1620 | !!row && /Already redeemed/.test(row.text), |
| 1621 | JSON.stringify((row && row.text.slice(-90)) || '')); |
| 1622 | |
| 1623 | // The oracle: the same post, made over the wire. |
| 1624 | const refused = await call(jar, 'POST', '/api/admin?view=applications', |
| 1625 | { id: rec0.id, status: 'invited' }); |
| 1626 | check('the gateway really refuses that invite, so the absence is right', |
| 1627 | refused.status === 409, |
| 1628 | 'status ' + refused.status + ' · ' + ((refused.j && refused.j.error) || '')); |
| 1629 | |
| 1630 | // And the closed set: a spelling the gateway does not know is refused |
| 1631 | // outright, which is why the panel offers three and never four. |
| 1632 | const nonsense = await call(jar, 'POST', '/api/admin?view=applications', |
| 1633 | { id: rec0.id, status: 'banished' }); |
| 1634 | check('a status the gateway does not know is refused, not filed', |
| 1635 | nonsense.status === 400, 'status ' + nonsense.status); |
| 1636 | const offered = new Set(p.rows.flatMap(r => r.moves)); |
| 1637 | check('every move the panel offers is one of the gateway\'s three statuses', |
| 1638 | [...offered].every(m => ['pending', 'invited', 'declined'].includes(m)), |
| 1639 | [...offered].join(',')); |
| 1640 | } |
| 1641 | |
| 1642 | // ── The ceiling, which is how this queue shuts the door ── |
| 1643 | // |
| 1644 | // `apply_max_total` refuses a NEW applicant with "Daimond is not taking |
| 1645 | // applications just now" and writes no row. Nothing deletes an |
| 1646 | // application and the counter behind the ceiling never goes down, so a |
| 1647 | // queue that has reached it stays there until the knob moves -- and |
| 1648 | // every panel in the console would otherwise go on saying the form was |
| 1649 | // open. Both halves are checked: that the endpoint really refuses, and |
| 1650 | // that the console says so. |
| 1651 | { |
| 1652 | const k = await call(jar, 'POST', '/api/admin?view=settings', |
| 1653 | { route: '/api/beta/apply', key: 'apply_max_total', value: '3' }); |
| 1654 | check('the ceiling is a knob the owner can move', k.status === 200, |
| 1655 | 'status ' + k.status); |
| 1656 | |
| 1657 | const over = await apply('fourth@example.com', 'Fourth', 'Let me in too.', 'test'); |
| 1658 | check('at the ceiling the form REFUSES a new applicant', |
| 1659 | over.status === 503, 'status ' + over.status |
| 1660 | + ' · ' + ((over.j && over.j.error) || '')); |
| 1661 | const s = await served(); |
| 1662 | check('and writes nothing, so the refusal is invisible in the queue', |
| 1663 | !!s && s.total === 3, 'total ' + (s && s.total)); |
| 1664 | |
| 1665 | await boss.page.click('#admin-refresh', { force: true }); |
| 1666 | await sleep(2000); |
| 1667 | const said = await boss.page.evaluate(() => { |
| 1668 | const b = document.getElementById('admin-ap-full'); |
| 1669 | return (b && !b.hidden) ? b.textContent : ''; |
| 1670 | }); |
| 1671 | check('the console says the form is refusing applicants', |
| 1672 | /REFUSING NEW APPLICANTS/.test(said), JSON.stringify(said.slice(0, 70))); |
| 1673 | check('and names the knob that is the only way out of it', |
| 1674 | /Applications held/.test(said) && /3/.test(said), |
| 1675 | JSON.stringify(said.slice(-90))); |
| 1676 | |
| 1677 | // Put it back, so the panel is photographed in its ordinary state |
| 1678 | // and the checks after this are not run against a shut form. |
| 1679 | await call(jar, 'POST', '/api/admin?view=settings', |
| 1680 | { route: '/api/beta/apply', key: 'apply_max_total', value: '' }); |
| 1681 | await boss.page.click('#admin-refresh', { force: true }); |
| 1682 | await sleep(2000); |
| 1683 | } |
| 1684 | |
| 1685 | // A picture of the queue in the state the checks left it: one row |
| 1686 | // redeemed and uninvitable, one invited whose invitation the mail server |
| 1687 | // refused, and one declined. The failed row is the one worth looking at |
| 1688 | // — whether it reads as a thing to go and fix is not something a check |
| 1689 | // can settle. Checks |
| 1690 | // prove properties and say nothing about whether the panel is legible, |
| 1691 | // which is a thing only a person looking at it can settle. |
| 1692 | { |
| 1693 | const shot = path.join(HERE, 'shots', 'applications.png'); |
| 1694 | try { |
| 1695 | await boss.page.screenshot({ path: shot, fullPage: true }); |
| 1696 | console.log(' shot ' + shot); |
| 1697 | } catch (e) { console.log(' shot not taken: ' + e.message); } |
| 1698 | // And at a phone width, where a row of decisions, a strip of |
| 1699 | // filters and a sentence carrying an email address all have to fold |
| 1700 | // rather than push the page sideways. Asserted as well as |
| 1701 | // photographed: a picture nobody opens proves nothing. |
| 1702 | await boss.page.setViewportSize({ width: 430, height: 900 }); |
| 1703 | await sleep(400); |
| 1704 | // Named, not merely counted: "the page is too wide" is not something |
| 1705 | // anybody can act on, and the element sticking out is. |
| 1706 | const wide = await boss.page.evaluate(() => { |
| 1707 | const win = window.innerWidth; |
| 1708 | const over = []; |
| 1709 | document.querySelectorAll('#view-applications *').forEach(e => { |
| 1710 | const r = e.getBoundingClientRect(); |
| 1711 | if (r.width > 0 && r.right > win + 1) { |
| 1712 | over.push(e.tagName.toLowerCase() + '.' + (e.className || '') |
| 1713 | + ' → ' + Math.round(r.right)); |
| 1714 | } |
| 1715 | }); |
| 1716 | return { doc: document.documentElement.scrollWidth, win, over: over.slice(0, 4) }; |
| 1717 | }); |
| 1718 | check('at 430px nothing in the queue reaches past the window', |
| 1719 | wide.over.length === 0, wide.over.join(' · ') |
| 1720 | || `the widest of it ends inside ${wide.win}px (document ${wide.doc})`); |
| 1721 | try { |
| 1722 | await boss.page.screenshot({ |
| 1723 | path: path.join(HERE, 'shots', 'applications-narrow.png'), fullPage: true }); |
| 1724 | } catch (e) {} |
| 1725 | await boss.page.setViewportSize({ width: 1400, height: 1200 }); |
| 1726 | await sleep(300); |
| 1727 | } |
| 1728 | |
| 1729 | // ── The role gate ─────────────────────────────────────── |
| 1730 | { |
| 1731 | const g = await call(jar, 'POST', '/api/admin?view=operators', |
| 1732 | { account_id: hand.account, role: 'operator', note: 'applications lane' }); |
| 1733 | check('the owner can grant the second account the operator role', |
| 1734 | g.status === 200, 'status ' + g.status); |
| 1735 | |
| 1736 | const up = await enterConsole(hand.page); |
| 1737 | check('the console loads for the operator', up); |
| 1738 | const p = await panel(hand.page); |
| 1739 | check('an operator is given the Applications tab', p.tab); |
| 1740 | check('an operator can read the queue', p.rows.length === 3, |
| 1741 | p.rows.length + ' rows'); |
| 1742 | check('an operator is offered NO decision control', |
| 1743 | p.rows.every(r => r.moves.length === 0), |
| 1744 | p.rows.map(r => r.moves.join('/')).join(' · ')); |
| 1745 | check('and is told why the controls are not there', |
| 1746 | /owner/.test(p.note), JSON.stringify(p.note.slice(0, 80))); |
| 1747 | // The tier goes with the decisions it describes. An operator cannot |
| 1748 | // mint, so a pulldown asking what to mint is a control that does |
| 1749 | // nothing, and the gateway answers this session 403 besides. |
| 1750 | const ot = await tierControl(hand.page, false); |
| 1751 | check('an operator is offered no tier control either', |
| 1752 | ot.there === false || ot.hidden === true, |
| 1753 | ot.there ? `hidden=${ot.hidden} · ${ot.w}x${ot.h}px` : 'not in the page'); |
| 1754 | |
| 1755 | // The oracle again: the post those controls would have made, from |
| 1756 | // this account's own session. |
| 1757 | const hjar = {}; |
| 1758 | const cookies = await hand.ctx.cookies(APP); |
| 1759 | hjar.cookie = cookies.map(c => `${c.name}=${c.value}`).join('; '); |
| 1760 | const mine = (await call(hjar, 'GET', '/api/admin?view=applications')).j; |
| 1761 | check('the gateway serves an operator the list', |
| 1762 | !!mine && Array.isArray(mine.applications) && mine.applications.length === 3, |
| 1763 | mine ? String(mine.total) : 'nothing'); |
| 1764 | const tried = await call(hjar, 'POST', '/api/admin?view=applications', |
| 1765 | { id: (mine.applications[0] || {}).id, status: 'declined' }); |
| 1766 | check('the gateway refuses an operator\'s decision, so the absence is right', |
| 1767 | tried.status === 403, 'status ' + tried.status |
| 1768 | + ' · ' + ((tried.j && tried.j.error) || '')); |
| 1769 | |
| 1770 | // A viewer is refused the READ, so they must not be given the tab. |
| 1771 | const v = await call(jar, 'POST', '/api/admin?view=operators', |
| 1772 | { account_id: hand.account, role: 'viewer', note: 'applications lane' }); |
| 1773 | check('the owner can drop that account to viewer', v.status === 200, |
| 1774 | 'status ' + v.status); |
| 1775 | const vjar = { cookie: hjar.cookie }; |
| 1776 | const vsee = await call(vjar, 'GET', '/api/admin?view=applications'); |
| 1777 | check('the gateway refuses a viewer the queue', vsee.status === 403, |
| 1778 | 'status ' + vsee.status); |
| 1779 | await hand.page.goto(APP + '/console/', { waitUntil: 'domcontentloaded' }); |
| 1780 | await sleep(2500); |
| 1781 | const vp = await panel(hand.page); |
| 1782 | check('a viewer is given no Applications tab at all', !vp.tab); |
| 1783 | } |
| 1784 | |
| 1785 | // ── What the Pro code actually grants ─────────────────── |
| 1786 | // |
| 1787 | // The far end of the chain the pulldown starts. Everything above proves |
| 1788 | // the panel asked for Pro and the gateway wrote `pro` on the passcode; |
| 1789 | // none of it proves the person holding that code gets anything. Riley's |
| 1790 | // code is the one minted with Pro chosen, and it is still live -- the mail |
| 1791 | // server refused the message, so nobody has used it. |
| 1792 | // |
| 1793 | // Last, deliberately: redeeming it spends the code and takes it off the |
| 1794 | // row, and the sections above are about a row that still carries one. |
| 1795 | { |
| 1796 | const cohort = ((await call(jar, 'GET', '/api/admin?view=passcodes')).j |
| 1797 | || {}).passcodes || []; |
| 1798 | const rp = cohort.find(pc => (pc.label || '').includes(RILEY)); |
| 1799 | check('the Pro code minted from the queue is still live to be used', |
| 1800 | !!rp && typeof rp.code === 'string' && rp.code.length > 0, |
| 1801 | rp ? (rp.code ? 'a live code' : 'no code on the record') : 'no passcode'); |
| 1802 | const nia = device(); |
| 1803 | const r = await call(null, 'POST', '/api/passcode/redeem', |
| 1804 | Object.assign({ code: (rp && rp.code) || '' }, binding(nia)), '203.0.113.91'); |
| 1805 | check('it opens the door', r.status === 200 && !!r.j && r.j.ok === true, |
| 1806 | 'status ' + r.status); |
| 1807 | // `pro` in this reply is whether a licence is NOW HELD, so this is the |
| 1808 | // grant and not the intention: the tier chosen on a pulldown in the |
| 1809 | // applications panel reached an account. |
| 1810 | // verifier: `--break tierfree` mints free here, and this is the line |
| 1811 | // that says what that costs the person on the row. |
| 1812 | check('and the account it opened HOLDS Pro, because the tier was chosen on the queue', |
| 1813 | !!r.j && r.j.pro === true, 'pro=' + JSON.stringify(r.j && r.j.pro)); |
| 1814 | } |
| 1815 | |
| 1816 | // ── The choice knob, repaired on the way past ─────────── |
| 1817 | // |
| 1818 | // `knobEditor` branched on bool and text and sent everything else to a |
| 1819 | // number field, so a `Kind::Choice` knob -- which carries the spellings |
| 1820 | // it admits precisely so the console can draw them -- got a box that |
| 1821 | // cannot hold a word. The only choice knob today is drawn by the |
| 1822 | // Providers card instead, so it is put back into the Settings card here |
| 1823 | // through the console's own `__provBreak=twice`. |
| 1824 | { |
| 1825 | const st = (await call(jar, 'GET', '/api/admin?view=settings')).j; |
| 1826 | let knob = null, route = ''; |
| 1827 | for (const g of (st && st.groups) || []) { |
| 1828 | for (const k of g.knobs || []) { |
| 1829 | if (k.kind === 'choice' && !knob) { knob = k; route = g.route; } |
| 1830 | } |
| 1831 | } |
| 1832 | check('the gateway sends a choice knob with the spellings it admits', |
| 1833 | !!knob && Array.isArray(knob.options) && knob.options.length > 1, |
| 1834 | knob ? knob.key + ' = ' + JSON.stringify(knob.options) : 'no choice knob'); |
| 1835 | |
| 1836 | if (knob) { |
| 1837 | const page = boss.page; |
| 1838 | // An init script, not an `evaluate`: the flag has to be there |
| 1839 | // before admin.js runs, and a navigation would wipe one set |
| 1840 | // after the fact. Through about:blank first, because the page is |
| 1841 | // already on /console/ and a goto that changes only the fragment |
| 1842 | // is a same-document navigation -- no reload, so no init script, |
| 1843 | // and the first run of this measured a console that had never |
| 1844 | // heard of the flag. |
| 1845 | await page.addInitScript(() => { window.__provBreak = 'twice'; }); |
| 1846 | await page.goto('about:blank'); |
| 1847 | await page.goto(APP + '/console/#settings', { waitUntil: 'domcontentloaded' }); |
| 1848 | await page.waitForSelector('#admin-app:not([hidden])', { timeout: 20000 }); |
| 1849 | await waitFor(async () => await page.evaluate(() => |
| 1850 | document.querySelectorAll('#admin-set-groups .admin-set-knob').length > 0), |
| 1851 | 20000, 200); |
| 1852 | await sleep(300); |
| 1853 | |
| 1854 | const ed = await page.evaluate(k => { |
| 1855 | const row = document.querySelector( |
| 1856 | '#admin-set-groups .admin-set-knob[data-knob="' + k.key + '"]'); |
| 1857 | if (!row) { |
| 1858 | return { found: false, options: [], tag: 'no row', type: '', value: '', |
| 1859 | rows: document.querySelectorAll('#admin-set-groups .admin-set-knob').length }; |
| 1860 | } |
| 1861 | const f = row.querySelector('.admin-set-edit .admin-set-input'); |
| 1862 | return { |
| 1863 | found: true, |
| 1864 | tag: f ? f.tagName : 'none', |
| 1865 | type: f ? (f.type || '') : '', |
| 1866 | value: f ? f.value : '', |
| 1867 | options: f && f.tagName === 'SELECT' |
| 1868 | ? Array.from(f.options).map(o => o.value).filter(v => v !== '') |
| 1869 | : [], |
| 1870 | }; |
| 1871 | }, knob); |
| 1872 | check('the Settings card draws that knob at all', ed.found, |
| 1873 | JSON.stringify(ed)); |
| 1874 | check('a choice knob is drawn as a pulldown, not a number field', |
| 1875 | ed.tag === 'SELECT', ed.tag + (ed.type ? '[' + ed.type + ']' : '')); |
| 1876 | check('its options are the gateway\'s own list and no other', |
| 1877 | ed.options.join(',') === knob.options.join(','), |
| 1878 | JSON.stringify(ed.options) + ' vs ' + JSON.stringify(knob.options)); |
| 1879 | check('and it shows the value that is actually in force', |
| 1880 | ed.value === String(knob.value), |
| 1881 | JSON.stringify(ed.value) + ' vs ' + JSON.stringify(knob.value)); |
| 1882 | |
| 1883 | // The repair that matters: choosing another and saving must STORE |
| 1884 | // it. Measured at the gateway, not at the row. |
| 1885 | const other = knob.options.find(o => o !== String(knob.value)); |
| 1886 | const saved = await page.evaluate(a => { |
| 1887 | const row = document.querySelector( |
| 1888 | '#admin-set-groups .admin-set-knob[data-knob="' + a.key + '"]'); |
| 1889 | if (!row) return 'no row'; |
| 1890 | const f = row.querySelector('.admin-set-edit .admin-set-input'); |
| 1891 | if (!f) return 'no field'; |
| 1892 | // Typed the way a person would: the value is put in and the |
| 1893 | // change announced, whichever control it turned out to be. |
| 1894 | f.value = a.other; |
| 1895 | f.dispatchEvent(new Event('input', { bubbles: true })); |
| 1896 | f.dispatchEvent(new Event('change', { bubbles: true })); |
| 1897 | const btns = Array.from(row.querySelectorAll('.admin-set-edit button')); |
| 1898 | const save = btns.find(b => b.textContent === 'Save'); |
| 1899 | if (!save) return 'no save'; |
| 1900 | save.click(); |
| 1901 | // Only a confirm step that is actually SHOWN may be pressed. |
| 1902 | // The buttons are in the page either way, and clicking a |
| 1903 | // hidden one sends whatever the editor was holding -- which |
| 1904 | // on the old number field is nothing at all, and would have |
| 1905 | // been reported here as a save. |
| 1906 | const ask = row.querySelector('.admin-set-confirm'); |
| 1907 | if (!ask || ask.hidden) { |
| 1908 | const m = row.querySelector('.admin-set-msg'); |
| 1909 | return 'the editor would not send it: ' + ((m && m.textContent) || 'no reason given'); |
| 1910 | } |
| 1911 | const yes = Array.from(ask.querySelectorAll('button')) |
| 1912 | .find(b => b.textContent === 'Confirm'); |
| 1913 | if (!yes) return 'no confirm'; |
| 1914 | yes.click(); |
| 1915 | return 'saved'; |
| 1916 | }, { key: knob.key, other }); |
| 1917 | await sleep(1800); |
| 1918 | const now = (await call(jar, 'GET', '/api/admin?view=settings')).j; |
| 1919 | let after = null; |
| 1920 | for (const g of (now && now.groups) || []) { |
| 1921 | for (const k of g.knobs || []) if (k.key === knob.key) after = k; |
| 1922 | } |
| 1923 | check('saving a choice STORES the chosen value rather than clearing it', |
| 1924 | !!after && String(after.value) === other && after.overridden === true, |
| 1925 | saved + ' · ' + (after ? `${after.value} (overridden ${after.overridden})` : 'gone') |
| 1926 | + ' · wanted ' + other); |
| 1927 | void route; |
| 1928 | } |
| 1929 | } |
| 1930 | |
| 1931 | // ── `minted`, the field the honest sentence rests on ──── |
| 1932 | // |
| 1933 | // Everything above reads it through the console's sentence. This reads the |
| 1934 | // field itself, over the wire, on the two presses that differ: the one that |
| 1935 | // mints and the one that finds a code already on the row. Without it the |
| 1936 | // console cannot tell an invitation from a resend, and a panel that named a |
| 1937 | // tier on both would be reporting a grant that a resend never makes. |
| 1938 | // |
| 1939 | // LAST, deliberately: it files a fourth application, and every count above |
| 1940 | // is about three. |
| 1941 | { |
| 1942 | const KIT = 'kit@example.dev'; |
| 1943 | const filed = await apply(KIT, 'Kit Alder', |
| 1944 | 'I would test the Pro surface on a slow connection.', 'test'); |
| 1945 | check('a fourth application can be filed now the ceiling is back', |
| 1946 | filed.status === 200, 'status ' + filed.status |
| 1947 | + ' · ' + JSON.stringify(filed.j)); |
| 1948 | const s4 = await served(); |
| 1949 | const kit = (s4.applications || []).find(a => a.email === KIT); |
| 1950 | check('and the gateway holds it', !!kit, 'total ' + (s4 && s4.total)); |
| 1951 | |
| 1952 | const mint = await call(jar, 'POST', '/api/admin?view=applications', |
| 1953 | { id: kit && kit.id, status: 'invited', wave: 1, pro: true }); |
| 1954 | check('a press that mints says so, and the row it hands back says what it granted', |
| 1955 | mint.status === 200 && !!mint.j && mint.j.minted === true |
| 1956 | && !!mint.j.application && mint.j.application.pro === true, |
| 1957 | 'status ' + mint.status |
| 1958 | + ' · minted=' + JSON.stringify(mint.j && mint.j.minted) |
| 1959 | + ' · pro=' + JSON.stringify(mint.j && mint.j.application |
| 1960 | && mint.j.application.pro)); |
| 1961 | // The same post again, which is exactly what Resend makes: the live code |
| 1962 | // is found and handed back, nothing is granted, and the reply says so. |
| 1963 | // It asks for the FREE tier this time, which is the sharper half -- a |
| 1964 | // resend cannot quietly downgrade a code somebody is already holding, |
| 1965 | // because it never reaches the mint. |
| 1966 | const resent = await call(jar, 'POST', '/api/admin?view=applications', |
| 1967 | { id: kit && kit.id, status: 'invited', wave: 1, pro: false }); |
| 1968 | check('a press that mints nothing says THAT, and leaves the tier it found alone', |
| 1969 | resent.status === 200 && !!resent.j && resent.j.minted === false |
| 1970 | && !!resent.j.application && resent.j.application.pro === true, |
| 1971 | 'status ' + resent.status |
| 1972 | + ' · minted=' + JSON.stringify(resent.j && resent.j.minted) |
| 1973 | + ' · pro=' + JSON.stringify(resent.j && resent.j.application |
| 1974 | && resent.j.application.pro)); |
| 1975 | } |
| 1976 | } catch (e) { |
| 1977 | check('the run completed', false, e && e.message); |
| 1978 | saidWhat(); |
| 1979 | } finally { |
| 1980 | try { await browser.close(); } catch (e) {} |
| 1981 | cleanup(); |
| 1982 | } |
| 1983 | |
| 1984 | console.log(''); |
| 1985 | console.log(`passed ${ok.length}, failed ${bad.length}` + (BREAK ? ` [--break ${BREAK}]` : '')); |
| 1986 | if (bad.length) { console.log('failures:'); bad.forEach(b => console.log(' - ' + b)); } |
| 1987 | process.exit(bad.length ? 1 : 0); |
| 1988 | })(); |