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