Oregami
Repositories/oxedyne/daimond

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
124import fs from 'node:fs';
125import os from 'node:os';
126import net from 'node:net';
127import path from 'node:path';
128import crypto from 'node:crypto';
129import { spawn } from 'node:child_process';
130import { fileURLToPath, pathToFileURL } from 'node:url';
131import { staleSources, procLog, GWDIR, GWBIN } from './gwbin.mjs';
132import { signInFresh } from './session.mjs';
133
134const HERE = path.dirname(fileURLToPath(import.meta.url));
135const 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.
150const WORLD = Math.max(0, Number(process.env.DAIMOND_PORT || 8777) - 8777);
151const PORT = Number(process.env.DAIMOND_APPL_GW_PORT || (9440 + WORLD));
152const 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.
157const MAIL_PORT = Number(process.env.DAIMOND_MAIL_PORT || (9600 + WORLD));
158const GW = `http://127.0.0.1:${PORT}`;
159const APP = `http://localhost:${APP_PORT}`;
160const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond');
161const WORK = path.join(SCRATCH, 'verify_applications-gw');
162const GW_LOG = procLog('verify_applications');
163const 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.
171const BIN = process.env.DAIMOND_GW_BIN || GWBIN;
172
173const PW = process.env.DAIMOND_PW
174 || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
175const 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.
180const STALE_OK = process.argv.includes('--stale-ok');
181
182const 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
189const 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.
192const check = (name, pass, detail) => {
193 (pass ? ok : bad).push(name);
194 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
195};
196const sleep = ms => new Promise(r => setTimeout(r, ms));
197
198const procs = [];
199function cleanup() { for (const p of procs) { try { p.kill('SIGKILL'); } catch (e) {} } }
200function saidWhat() { GW_LOG.report(); SRV_LOG.report(); }
201async 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.
210function 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:<…>`.
234function 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`.
247function 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
257function 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.
332const MAIL_PW = 'fixture-' + crypto.randomBytes(9).toString('hex');
333/// The mailbox the gateway is configured to send invitations from.
334const MAIL_FROM = 'beta@daimond.test';
335/// Where the message tells an applicant to put their code.
336const 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.
342function 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.
362function 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
406let gw = null;
407async 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}
427async 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.
442async 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.
461function 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}
472function 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.
479let applicant = 0;
480async 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.
497async 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.
545const 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.
558async 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.
620async 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.
653async 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.
660async 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.
679async 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.
698async 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.
710async 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.
756async 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.
787async 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})();