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>
320 lines
12 KiB
JavaScript
320 lines
12 KiB
JavaScript
/**
|
||
* 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 1–4 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']
|
||
);
|
||
});
|
||
});
|