feat(beta): phase 5 — the app page and the closed-beta signup
All checks were successful
PR checks / checks (pull_request) Successful in 1m5s

Builds `/app/` and `/beta/`, the SQLite signup store, the rate limiting and the
export CLI of PLAN.md §8, and adds this repository's first test suite.

Four decisions of record, D26–D29 (§8, "How phase 5 built the app and the beta"):

- D26 — the screenshot slot ships empty, reserved for phase 9. §10 promised
  `/app/` "the 14 existing screenshots"; they are a July trusted-device smoke
  test against an unseeded dev instance, captured before the theming work, and
  five of the fourteen are two-factor prompts. Shipping them would break D4.
  Phase 9 already builds the rig, so it gains an emulator pass.
- D27 — the public demo is the tester target. `ConnectScreen.kt` gates the whole
  app on a validated deployment address, so a tester needs somewhere to point it.
  The beta therefore waits on the demo VM, and the page says so.
- D28 — `/beta` handles its own POST; there is no `/api/beta-signup`. An endpoint
  cannot report a validation error without JavaScript. §6's diagram is amended.
- D29 — the APK and the beta get equal billing, and the APK link is off:
  `androidApk.serviceable` is false because the published v0.5.0 build does not
  work. The panel stays and states that plainly rather than being removed.

Three mechanisms the plan did not anticipate:

- `liveBrand()` — a server-rendered page never passes through the boot rewrite,
  so `/beta` reads the mounted brand.json itself. Pasting the Play opt-in URL in
  takes effect on the next request rather than the next restart.
- `checkLinks.mjs` derives on-demand routes from `prerender = false` in the
  source. A PLANNED_ROUTES entry would have been wrong: its reverse check fires
  when a route has been built, and an on-demand route never produces a file, so
  the entry could never rot out.
- `npm test` — the five existing checks all read built output, and none of this
  logic appears there. A honeypot can stop working and leave the build identical.

Also: `checkFacts.mjs` gains the APK assets and `minSdk`, and learns that RFC 2606
reserved domains are not contact addresses; the D13 rule is otherwise unchanged.

Verified end to end against the built server: every outcome renders with no
JavaScript, cross-origin POSTs are refused, a mounted opt-in URL appears without
a restart, and the export CLI round-trips.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-24 03:50:51 -05:00
parent fbd7bbe6fd
commit 1313e748ae
21 changed files with 3366 additions and 22 deletions

319
test/beta.test.mjs Normal file
View File

@@ -0,0 +1,319 @@
/**
* The signup path, tested where it makes decisions. PLAN.md §8, phase 5.
*
* ---------------------------------------------------------------------------------------
* WHY THIS REPOSITORY HAS A TEST SUITE NOW, HAVING NOT NEEDED ONE FOR FOUR PHASES
* ---------------------------------------------------------------------------------------
* Phases 14 are pages, and pages are checked by the five scripts in §12: a broken link, a
* stale version, a colour literal, a drifted brand contract. Every one of those defects is
* visible in the built output, which is exactly why a check that reads the built output
* catches them.
*
* Phase 5 is the first thing here that is neither a page nor visible in one. Whether a
* honeypot is checked before the rate limit, whether a duplicate is answered idempotently,
* whether the cap counts removed rows — none of that shows up in `dist`, none of it is
* exercised by loading the page, and all of it is the kind of logic that is wrong quietly.
* The honeypot in particular has the worst failure mode available: it can stop working
* entirely and nothing anywhere gets slower, redder or noisier.
*
* So: `node --test`, the same runner the `website` server uses, against a scratch database.
*
* ---------------------------------------------------------------------------------------
* THE ENVIRONMENT HAS TO BE SET BEFORE THE IMPORTS
* ---------------------------------------------------------------------------------------
* `betaStore.mjs` resolves `DB_PATH` and the salt at module scope, and `beta.mjs` reads the
* limits the same way. A dynamic import after `process.env` is set is therefore not a
* stylistic choice — a static import would bind a developer's real `data/beta.sqlite` and
* this file would quietly test, and pollute, the actual list.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { after, before, beforeEach, describe, it } from 'node:test';
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-beta-'));
process.env.BETA_DB = path.join(scratch, 'beta.sqlite');
process.env.BETA_IP_SALT = 'test-salt';
process.env.BETA_FORM_KEY = 'test-form-key';
process.env.BETA_TOTAL_CAP = '5';
process.env.BETA_PER_HOUR = '3';
process.env.BETA_PER_DAY = '4';
process.env.BETA_MIN_SECONDS = '2';
let store;
let signup;
let data;
before(async () => {
store = await import('../src/lib/betaStore.mjs');
signup = await import('../src/lib/betaSignup.mjs');
data = await import('../src/data/beta.mjs');
});
after(() => {
store.close();
fs.rmSync(scratch, { recursive: true, force: true });
});
beforeEach(() => {
const db = store.open();
db.exec('DELETE FROM signups; DELETE FROM attempts;');
});
/** A form as the handler sees it, aged past the minimum by default. */
function form(overrides = {}, ageSeconds = 5) {
const { fields } = data;
const map = new Map([
[fields.EMAIL, 'person@example.com'],
[fields.CONSENT, 'yes'],
[fields.HONEYPOT, ''],
[fields.ISSUED, signup.issueFormToken(Date.now() - ageSeconds * 1000)],
]);
for (const [key, value] of Object.entries(overrides)) map.set(key, value);
return map;
}
const post = (overrides, ageSeconds, ip = '198.51.100.7') =>
signup.submit({ form: form(overrides, ageSeconds), ip, userAgent: 'test' });
describe('address validation', () => {
it('accepts the shapes a person types, including a plus-alias', () => {
for (const value of ['a@b.co', 'first.last+play@example.co.uk', 'UPPER@Example.COM']) {
assert.ok(signup.normaliseEmail(value), `${value} should be accepted`);
}
});
it('lower-cases, so a CSV does not carry capitalisation as though it mattered', () => {
assert.equal(signup.normaliseEmail(' Person@Example.COM '), 'person@example.com');
});
it('rejects what would break a pasted tester list', () => {
for (const value of [
'',
'no-at-sign',
'two@@example.com',
'trailing@example',
'a b@example.com',
'comma,injection@example.com',
`${'x'.repeat(250)}@example.com`,
]) {
assert.equal(signup.normaliseEmail(value), null, `${value} should be rejected`);
}
});
});
describe('the form token', () => {
it('round-trips an age', () => {
const read = signup.readFormToken(signup.issueFormToken(Date.now() - 30_000));
assert.ok(read);
assert.ok(read.ageSeconds >= 29 && read.ageSeconds < 32);
});
it('refuses a token it did not sign — a bot cannot mint its own timestamp', () => {
assert.equal(signup.readFormToken(`${Date.now()}.deadbeef`), null);
assert.equal(signup.readFormToken(String(Date.now())), null);
assert.equal(signup.readFormToken(''), null);
assert.equal(signup.readFormToken(undefined), null);
});
it('refuses a valid signature over a tampered timestamp', () => {
const token = signup.issueFormToken(Date.now() - 5000);
const [, mac] = token.split('.');
assert.equal(signup.readFormToken(`${Date.now() - 900_000}.${mac}`), null);
});
});
describe('a good submission', () => {
it('is added, and stores the consent wording rather than a version', () => {
const result = post();
assert.equal(result.outcome, signup.OUTCOME.ADDED);
const row = store.open().prepare('SELECT * FROM signups').get();
assert.equal(row.email, 'person@example.com');
assert.equal(row.status, store.STATUS.NEW);
assert.equal(row.consent_text, data.CONSENT_TEXT);
});
it('never stores the IP address itself', () => {
post({}, 5, '203.0.113.9');
const row = store.open().prepare('SELECT ip_hash FROM signups').get();
assert.ok(!row.ip_hash.includes('203.0.113.9'));
assert.equal(row.ip_hash.length, 64);
assert.equal(row.ip_hash, store.hashIp('203.0.113.9'));
});
});
describe('duplicates are answered idempotently', () => {
it('is the same outcome shape whether or not the address was already there', () => {
assert.equal(post().outcome, signup.OUTCOME.ADDED);
assert.equal(post().outcome, signup.OUTCOME.DUPLICATE);
// Both render the identical screen — §8's rule, so the form cannot be used to ask
// whether a given address is enrolled. This asserts the property the page relies on.
assert.ok(signup.isSuccess(signup.OUTCOME.ADDED));
assert.ok(signup.isSuccess(signup.OUTCOME.DUPLICATE));
assert.equal(store.liveCount(), 1);
});
it('treats a differently-cased address as the same person', () => {
post();
assert.equal(post({ [data.fields.EMAIL]: 'PERSON@EXAMPLE.COM' }).outcome, signup.OUTCOME.DUPLICATE);
assert.equal(store.liveCount(), 1);
});
it('treats a re-signup after removal as new, because removal erased the address', () => {
post();
store.removeSignup('person@example.com');
// Not a duplicate: there is nothing left in the store to match against, which is the
// point of erasing rather than flagging. See removeSignup's note.
assert.equal(post().outcome, signup.OUTCOME.ADDED);
const rows = store.open().prepare('SELECT status FROM signups ORDER BY id').all();
assert.deepEqual(
rows.map((row) => row.status),
[store.STATUS.REMOVED, store.STATUS.NEW]
);
});
});
describe('the honeypot', () => {
it('writes nothing and reports success', () => {
const result = post({ [data.fields.HONEYPOT]: 'http://spam.example' });
assert.equal(result.outcome, signup.OUTCOME.DECOY);
assert.ok(signup.isSuccess(result.outcome), 'a bot must be told it succeeded');
assert.equal(store.liveCount(), 0);
});
it('costs no rate-limit budget, so it cannot be used to lock out a shared address', () => {
for (let i = 0; i < 10; i += 1) post({ [data.fields.HONEYPOT]: 'x' });
assert.equal(post().outcome, signup.OUTCOME.ADDED);
});
});
describe('timing', () => {
it('refuses a submission faster than a person could make', () => {
assert.equal(post({}, 0).outcome, signup.OUTCOME.TOO_FAST);
assert.equal(store.liveCount(), 0);
});
it('refuses a form rendered too long ago', () => {
const stale = signup.issueFormToken(Date.now() - (data.limits.maxSeconds + 60) * 1000);
assert.equal(post({ [data.fields.ISSUED]: stale }).outcome, signup.OUTCOME.STALE);
});
});
describe('the rate limit', () => {
it('counts attempts rather than successes, so bad addresses are not free', () => {
const bad = { [data.fields.EMAIL]: 'nope' };
assert.equal(post(bad).outcome, signup.OUTCOME.INVALID_EMAIL);
assert.equal(post(bad).outcome, signup.OUTCOME.INVALID_EMAIL);
assert.equal(post(bad).outcome, signup.OUTCOME.INVALID_EMAIL);
assert.equal(post().outcome, signup.OUTCOME.LIMITED, 'the hourly budget is spent');
});
it('is per connection, not global', () => {
for (let i = 0; i < 3; i += 1) post({ [data.fields.EMAIL]: 'nope' }, 5, '198.51.100.1');
assert.equal(post({}, 5, '198.51.100.1').outcome, signup.OUTCOME.LIMITED);
assert.equal(post({}, 5, '198.51.100.2').outcome, signup.OUTCOME.ADDED);
});
it('tells a limited caller nothing about any address', () => {
post();
for (let i = 0; i < 3; i += 1) post({ [data.fields.EMAIL]: 'nope' }, 5, '198.51.100.3');
// The address IS on the list; a limited caller must still be told only that they are
// limited. Ordering the limit ahead of the lookup is what makes that true.
const result = post({}, 5, '198.51.100.3');
assert.equal(result.outcome, signup.OUTCOME.LIMITED);
assert.equal(result.email, undefined);
});
});
describe('the global cap', () => {
const fill = (n) => {
for (let i = 0; i < n; i += 1) post({ [data.fields.EMAIL]: `p${i}@example.com` }, 5, `10.0.0.${i}`);
};
it('closes the form at the cap', () => {
fill(5);
assert.equal(store.liveCount(), 5);
assert.ok(store.isFull());
assert.equal(post({ [data.fields.EMAIL]: 'late@example.com' }, 5, '10.0.1.1').outcome, signup.OUTCOME.FULL);
});
it('does not count a removed row against it', () => {
fill(5);
store.removeSignup('p0@example.com');
assert.equal(store.liveCount(), 4);
assert.ok(!store.isFull());
assert.equal(post({ [data.fields.EMAIL]: 'late@example.com' }, 5, '10.0.1.2').outcome, signup.OUTCOME.ADDED);
});
});
describe('consent', () => {
it('is required, and an unticked box records nothing', () => {
assert.equal(post({ [data.fields.CONSENT]: '' }).outcome, signup.OUTCOME.NO_CONSENT);
assert.equal(store.liveCount(), 0);
});
});
describe('removal', () => {
it('overwrites the address rather than flagging the row', () => {
post();
const result = store.removeSignup('person@example.com');
assert.ok(result.removed);
const row = store.open().prepare('SELECT * FROM signups WHERE id = ?').get(result.id);
assert.equal(row.status, store.STATUS.REMOVED);
assert.equal(row.ip_hash, '');
assert.ok(!row.email.includes('person@example.com'), 'the address must be gone, not marked');
assert.match(row.note, /^removed /);
});
it('is indistinguishable from never having been there, once done', () => {
post();
store.removeSignup('person@example.com');
// Asking again gives exactly the answer a stranger's address gives. That is not a gap
// in the implementation — it is what "the address is gone" has to mean, and the test
// exists to stop somebody "fixing" it by keeping a hash of the removed address.
assert.deepEqual(store.removeSignup('person@example.com'), { removed: false });
assert.deepEqual(store.removeSignup('nobody@example.com'), { removed: false });
});
});
describe('the export', () => {
it('takes new rows and marks them, so a second export does not repeat them', () => {
post({ [data.fields.EMAIL]: 'a@example.com' }, 5, '10.1.0.1');
post({ [data.fields.EMAIL]: 'b@example.com' }, 5, '10.1.0.2');
const first = store.pending();
assert.equal(first.length, 2);
store.markExported(first.map((row) => row.id));
assert.equal(store.pending().length, 0);
assert.equal(store.pending({ all: true }).length, 2);
});
it('leaves removed rows out of --all as well as out of the default', () => {
post({ [data.fields.EMAIL]: 'a@example.com' }, 5, '10.1.1.1');
post({ [data.fields.EMAIL]: 'b@example.com' }, 5, '10.1.1.2');
store.removeSignup('a@example.com');
assert.deepEqual(
store.pending({ all: true }).map((row) => row.email),
['b@example.com']
);
});
});