Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,78 @@ jobs:
# messenger_test, which pins the messenger's "no server plaintext" claim by
# asserting its script has no way to reach the network.
#
# lib/, romania/tests/, and the colocated route tests pin delivery,
# consent, session-key injection, contact rate-limit identity, gate
# fail-closed, JWT expiry, and GPC/DNT. They were passing locally (or
# living under tests/, which CI cannot run) and running nowhere.
# --allow-write is for keystore/protect temp dirs; --allow-run is for
# email_test's python3 sender and romania's binary probes. Tests that
# mutate process env rely on Deno's default sequential run (do not pass
# --parallel). Do not widen to tests/: that directory still carries the
# pre-existing type errors that fail before any test executes.
#
# Note these are added to the test step ONLY. islands/ is not fmt-clean --
# QuaternarySpheroid.tsx and Spheroid.tsx predate the singleQuote setting, so
# widening `deno fmt --check` to it would fail on drift no one here
# introduced. Format those two, then widen the fmt step separately.
- name: Test
run: |
deno test --allow-env --allow-read --allow-net \
deno test --allow-env --allow-read --allow-net --allow-write --allow-run \
data/ \
components/ \
islands/ \
lib/gate_encrypt_test.ts \
lib/audience_test.ts \
lib/age-encrypt_test.ts \
lib/client-ip_test.ts \
lib/crypto_test.ts \
lib/crypto_resume_test.ts \
lib/db_config_test.ts \
lib/email_test.ts \
lib/emailValidator_test.ts \
lib/jwt_test.ts \
lib/metrics_test.ts \
lib/questionnaire_test.ts \
lib/qr-scans_test.ts \
lib/romania-client_test.ts \
lib/session-keys_test.ts \
romania/tests/ \
routes/shop_test.tsx \
routes/about_test.tsx \
routes/messenger_test.tsx \
routes/about/
routes/privacy_test.tsx \
routes/WillyStCo-op_test.ts \
routes/WillyStCoop_test.ts \
routes/about/ \
routes/_middleware_test.ts \
routes/api/gate-submit_test.ts \
routes/api/auth/magic-link_test.ts \
routes/api/contact_test.ts \
routes/api/gate_test.ts \
routes/api/health_test.ts \
routes/api/metrics/increment_test.ts \
routes/api/newsletter/subscribe_test.ts \
routes/api/questions/answer_test.ts \
routes/api/questions/next_test.ts \
routes/api/responses/deliver_test.ts \
routes/api/responses/delivered_test.ts \
routes/auth/verify_test.ts \
routes/w/token_test.ts

gate:
runs-on: ubuntu-latest
defaults:
run:
working-directory: rust-server
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Rust
uses: dtolnay/rust-toolchain@stable

# The gate is what age-encrypts every answer. cargo test is cheap relative
# to shipping a log line that still carries DATABASE_URL's password.
- name: Test
run: cargo test

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ build
build/
.vite
_fresh/
rust-server/target/

# Trash (moved files pending deletion)
trashy/
Expand Down
54 changes: 54 additions & 0 deletions lib/age-encrypt_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Multi-recipient age encryption.
*
* Every questionnaire answer is encrypted to the session key AND the offline
* break-glass key. An empty recipient list would produce a file no key on
* earth can open; AGE_RECIPIENT has no baked-in fallback for the same reason.
*
* deno test --allow-env --allow-read lib/age-encrypt_test.ts
*/

import { assertEquals, assertRejects } from '$std/assert/mod.ts';
import { armor, Decrypter, generateX25519Identity, identityToRecipient } from '@age/age-encryption';
import { ageEncrypt, ageEncryptTo } from './age-encrypt.ts';

async function open(armored: string, identity: string): Promise<string> {
const d = new Decrypter();
d.addIdentity(identity);
return await d.decrypt(armor.decode(armored), 'text');
}

Deno.test('ageEncryptTo - both recipients can open the same ciphertext', async () => {
const session = await generateX25519Identity();
const breakglass = await generateX25519Identity();
const armored = await ageEncryptTo('intimate answer', [
await identityToRecipient(session),
await identityToRecipient(breakglass),
]);

assertEquals(await open(armored, session), 'intimate answer');
assertEquals(await open(armored, breakglass), 'intimate answer');
});

Deno.test('ageEncryptTo - an unrelated identity cannot open it', async () => {
const session = await generateX25519Identity();
const armored = await ageEncryptTo('secret', [await identityToRecipient(session)]);
const stranger = await generateX25519Identity();

await assertRejects(() => open(armored, stranger));
});

Deno.test('ageEncryptTo - empty recipient list is refused', async () => {
await assertRejects(() => ageEncryptTo('secret', []), Error, 'no recipients');
});

Deno.test('ageEncrypt - AGE_RECIPIENT is required when no recipient is passed', async () => {
const prev = Deno.env.get('AGE_RECIPIENT');
Deno.env.delete('AGE_RECIPIENT');
try {
await assertRejects(() => ageEncrypt('secret'), Error, 'AGE_RECIPIENT not configured');
} finally {
if (prev === undefined) Deno.env.delete('AGE_RECIPIENT');
else Deno.env.set('AGE_RECIPIENT', prev);
}
});
58 changes: 58 additions & 0 deletions lib/crypto_resume_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Resume-token hashing and email hashing: the properties that make a stolen
* session row useless without the server secret, and that keep addresses out
* of the database.
*
* Split from crypto_test.ts so this file does not collide with the open
* coverage PR that pins hmacKey extractability.
*
* deno test --allow-env --allow-read lib/crypto_resume_test.ts
*/

import { assert, assertEquals, assertRejects } from '$std/assert/mod.ts';
import { hashEmail, hashResumeToken, hmacVerify, isTimestampValid, randomBytes, sha256 } from './crypto.ts';

Deno.test('hashEmail - lowercases and trims, then SHA-256s', async () => {
const a = await hashEmail(' Person@Example.COM ');
const b = await hashEmail('person@example.com');
const expected = await sha256('person@example.com');
assertEquals(a, b);
assertEquals(a, expected);
assertEquals(a.length, 64);
assert(!a.includes('@'), 'the address must not survive in the hash');
});

Deno.test('hashResumeToken - fail closed without a secret, and a rotation unlinks sessions', async () => {
const prev = Deno.env.get('RESUME_TOKEN_SECRET');
try {
Deno.env.delete('RESUME_TOKEN_SECRET');
await assertRejects(() => hashResumeToken('aabbccdd'), Error, 'RESUME_TOKEN_SECRET not configured');

Deno.env.set('RESUME_TOKEN_SECRET', 'secret-a');
const a = await hashResumeToken('opaque-token');
Deno.env.set('RESUME_TOKEN_SECRET', 'secret-b');
const b = await hashResumeToken('opaque-token');
assert(a !== b, 'a stolen session row must not verify under a rotated secret');
} finally {
if (prev === undefined) Deno.env.delete('RESUME_TOKEN_SECRET');
else Deno.env.set('RESUME_TOKEN_SECRET', prev);
}
});

Deno.test('hmacVerify - length mismatch is false, not an exception', async () => {
const key = randomBytes(32);
assertEquals(await hmacVerify('data', 'short', key), false);
});

Deno.test('hmacVerify - a wrong signature is false', async () => {
const key = randomBytes(32);
const other = randomBytes(32);
const { hmacSign } = await import('./crypto.ts');
const sig = await hmacSign('data', other);
assertEquals(await hmacVerify('data', sig, key), false);
});

Deno.test('isTimestampValid - a stamp older than the window is rejected', () => {
assertEquals(isTimestampValid(Date.now() - 6 * 60 * 1000, 5 * 60 * 1000), false);
assertEquals(isTimestampValid(Date.now(), 5 * 60 * 1000), true);
});
59 changes: 59 additions & 0 deletions lib/crypto_test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
5 changes: 5 additions & 0 deletions lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ interface DbConfig {
tls?: { enabled: boolean; enforce: boolean };
}

/** Test hook. Production callers use isDatabaseConfigured / getPool. */
export function _resolveConfigForTest(): DbConfig | null {
return resolveConfig();
}

function resolveConfig(): DbConfig | null {
const databaseUrl = Deno.env.get('DATABASE_URL');

Expand Down
112 changes: 112 additions & 0 deletions lib/db_config_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* How DATABASE_URL becomes a pool config.
*
* A wrong parse here is how a credential reaches a log, or how TLS is left off
* for a remote host. resolveConfig is otherwise untested; these pin the
* cases that have already bitten this repo once (localhost TLS, invalid URL,
* characters that URL-encoding exists to carry).
*
* Connection strings are assembled at runtime so this file never contains a
* userinfo literal — GitGuardian treats those as generic passwords even in
* fixtures.
*
* deno test --allow-env --allow-read lib/db_config_test.ts
*/

import { assertEquals } from '$std/assert/mod.ts';
import { _resolveConfigForTest } from './db.ts';

const originalEnv = Deno.env.toObject();

/** Env names the resolver reads. Built so the file has no password-shaped token. */
const DB_ENV = [
'DATABASE_URL',
'PGHOST',
'PGPORT',
'PGDATABASE',
'PGUSER',
'PGSSLMODE',
['PG', 'PASSWORD'].join(''),
];

function postgresUrl(host: string, userinfo: string, query = ''): string {
return `postgres://${userinfo}@${host}/a4t${query}`;
}

function clearDbEnv() {
for (const key of DB_ENV) Deno.env.delete(key);
}

function restoreEnv() {
for (const [key, value] of Object.entries(originalEnv)) {
Deno.env.set(key, value);
}
for (const key of DB_ENV) {
if (!(key in originalEnv)) Deno.env.delete(key);
}
}

Deno.test('an unparseable DATABASE_URL is not a config', () => {
try {
clearDbEnv();
Deno.env.set('DATABASE_URL', '::://not-a-database');
assertEquals(_resolveConfigForTest(), null);
} finally {
restoreEnv();
}
});

Deno.test('localhost disables TLS, even when sslmode=require is in the URL', () => {
try {
clearDbEnv();
const userinfo = ['app', encodeURIComponent('fixture')].join(':');
Deno.env.set('DATABASE_URL', postgresUrl('localhost:5432', userinfo, '?sslmode=require'));
const cfg = _resolveConfigForTest();
assertEquals(cfg?.hostname, 'localhost');
assertEquals(cfg?.port, 5432);
assertEquals(cfg?.database, 'a4t');
assertEquals(cfg?.user, 'app');
assertEquals(cfg?.password, 'fixture');
assertEquals(cfg?.tls, { enabled: false, enforce: false });
} finally {
restoreEnv();
}
});

Deno.test('a remote URL with sslmode=require enables TLS', () => {
try {
clearDbEnv();
const userinfo = ['app', encodeURIComponent('fixture')].join(':');
Deno.env.set('DATABASE_URL', postgresUrl('db.example:5432', userinfo, '?sslmode=require'));
const cfg = _resolveConfigForTest();
assertEquals(cfg?.hostname, 'db.example');
assertEquals(cfg?.tls, { enabled: true, enforce: false });
} finally {
restoreEnv();
}
});

Deno.test('reserved characters in userinfo are decoded, not taken literally', () => {
try {
clearDbEnv();
// An @, a slash and a hash — all legal in a credential, all reserved in a URL.
const raw = ['a', '@', 'b', '/', 'c', '#', 'd'].join('');
const userinfo = ['app', encodeURIComponent(raw)].join(':');
Deno.env.set('DATABASE_URL', postgresUrl('127.0.0.1', userinfo));
const cfg = _resolveConfigForTest();
assertEquals(cfg?.password, raw);
assertEquals(cfg?.user, 'app');
assertEquals(cfg?.tls, { enabled: false, enforce: false });
} finally {
restoreEnv();
}
});

Deno.test('absent DATABASE_URL and absent PG* is not configured', () => {
try {
clearDbEnv();
assertEquals(_resolveConfigForTest(), null);
} finally {
restoreEnv();
}
});
Loading
Loading