Files
runicgateway.com/test/beta.test.mjs
wtclaude 1313e748ae
All checks were successful
PR checks / checks (pull_request) Successful in 1m5s
feat(beta): phase 5 — the app page and the closed-beta signup
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>
2026-08-24 03:50:51 -05:00

320 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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']
);
});
});