From 59ea86aac3ba6f907f215ba985c3be5d0a2e83f7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 20:34:12 +0000 Subject: [PATCH 1/4] test(delivery,auth): cover the callback that never stamped, and the JWT that must not carry an address The delivery confirmation route, the key-box callback, and questionnaire JWTs shipped without tests. Pin fail-closed auth, write-once pdf_delivered_at, GPC/DNT opt-out, and session tokens that verify as hashes not mailboxes. Read KEYBOX_RENDER_* and RENDER_CALLBACK_* on each call (same boot-order trap jwt.ts already documents) so the tests can set them, and so a missing .env at import cannot lock the consent path into "unavailable" forever. Co-authored-by: Ralphie B. --- lib/crypto_test.ts | 59 ++++++++ lib/emailValidator_test.ts | 64 ++++++++ lib/jwt_test.ts | 129 ++++++++++++++++ lib/metrics_test.ts | 54 +++++++ lib/questionnaire_test.ts | 65 ++++++++ lib/romania-client.ts | 23 ++- lib/romania-client_test.ts | 118 +++++++++++++++ romania/render-service.ts | 14 +- romania/tests/callback_test.ts | 135 +++++++++++++++++ routes/_middleware_test.ts | 151 ++++++++++++++++++ routes/api/responses/delivered_test.ts | 202 +++++++++++++++++++++++++ 11 files changed, 1003 insertions(+), 11 deletions(-) create mode 100644 lib/crypto_test.ts create mode 100644 lib/emailValidator_test.ts create mode 100644 lib/jwt_test.ts create mode 100644 lib/metrics_test.ts create mode 100644 lib/questionnaire_test.ts create mode 100644 lib/romania-client_test.ts create mode 100644 romania/tests/callback_test.ts create mode 100644 routes/_middleware_test.ts create mode 100644 routes/api/responses/delivered_test.ts diff --git a/lib/crypto_test.ts b/lib/crypto_test.ts new file mode 100644 index 00000000..e6c44817 --- /dev/null +++ b/lib/crypto_test.ts @@ -0,0 +1,59 @@ +/** + * Crypto helpers the rest of the site takes as given. + * + * hmacKey's non-extractability is the audience counter's "the salt does not + * outlive the window" claim: if the key can be exported, the raw bytes are + * still in the process after the buffer is zeroed. hashEmail's normalisation + * is what makes "one respondent per mailbox" true rather than decorative. + * + * deno test --allow-env lib/crypto_test.ts + */ + +import { assert, assertEquals, assertRejects } from '$std/assert/mod.ts'; +import { + decrypt, + deriveKey, + encrypt, + hashEmail, + hmacKey, + hmacSign, + hmacVerify, + isTimestampValid, + randomBytes, +} from './crypto.ts'; + +Deno.test('hmacKey is not extractable — the audience salt cannot be read back', async () => { + const raw = randomBytes(32); + const key = await hmacKey(raw); + raw.fill(0); + assertEquals(key.extractable, false); + await assertRejects(() => crypto.subtle.exportKey('raw', key)); +}); + +Deno.test('hashEmail lowercases and trims, so the same mailbox is one hash', async () => { + assertEquals(await hashEmail(' Alex@Example.COM '), await hashEmail('alex@example.com')); + assert((await hashEmail('alex@example.com')).length === 64); +}); + +Deno.test('encrypt/decrypt round-trips and a wrong key fails closed', async () => { + const salt = randomBytes(16); + const key = await deriveKey('passphrase', salt); + const other = await deriveKey('other', salt); + const cipher = await encrypt('intimate answer', key); + assertEquals(await decrypt(cipher, key), 'intimate answer'); + await assertRejects(() => decrypt(cipher, other)); +}); + +Deno.test('hmacVerify rejects a truncated signature without throwing', async () => { + const secret = randomBytes(32); + const sig = await hmacSign('payload', secret); + assertEquals(await hmacVerify('payload', sig, secret), true); + assertEquals(await hmacVerify('payload', sig.slice(0, 8), secret), false); + assertEquals(await hmacVerify('other', sig, secret), false); +}); + +Deno.test('isTimestampValid rejects a stamp outside the window', () => { + const now = Date.now(); + assertEquals(isTimestampValid(now, 1000), true); + assertEquals(isTimestampValid(now - 5000, 1000), false); +}); diff --git a/lib/emailValidator_test.ts b/lib/emailValidator_test.ts new file mode 100644 index 00000000..7ec00738 --- /dev/null +++ b/lib/emailValidator_test.ts @@ -0,0 +1,64 @@ +/** + * Email validation and Gmail normalisation. + * + * This is the gate in front of magic-link and newsletter: a miss here is + * either an address that cannot be mailed, a plus-alias that duplicates a + * respondent, or a scattered-dot Gmail that was meant to be rejected. + * + * deno test lib/emailValidator_test.ts + */ + +import { assertEquals } from '$std/assert/mod.ts'; +import { isSuspiciousGmailParts, normalizeEmailParts, validateEmail } from './emailValidator.ts'; + +Deno.test('validateEmail accepts a well-formed address and lowercases it', () => { + assertEquals(validateEmail(' Alex@Example.COM '), { + valid: true, + normalized: 'alex@example.com', + }); +}); + +Deno.test('validateEmail rejects an empty local, a missing domain, and a second @', () => { + for (const email of ['', '@x.com', 'x@', 'a@b@c.com', 'not-an-email', 'missing@domain']) { + assertEquals(validateEmail(email).valid, false, `should reject ${email}`); + } +}); + +Deno.test('validateEmail rejects spaces and HTML metacharacters', () => { + assertEquals(validateEmail('spaces in@email.com').valid, false); + assertEquals(validateEmail('a