← back to NationalPaperHangers
tests/compliance.test.js
142 lines
// Coverage for the security primitives shipped this push:
// - lib/booking-token.js (HMAC sign/verify for /bookings/:uuid IDOR-gate)
// - lib/compliance.js (CAN-SPAM pre-flight + suppression + unsubscribe tokens)
// - routes/unsubscribe (token consumption, suppression write)
//
// Read-only against the live local DB except for the unsubscribe-token write,
// which inserts then immediately reverses (or short-circuits via DRY-RUN).
'use strict';
const { test, after } = require('node:test');
const assert = require('node:assert/strict');
const supertest = require('supertest');
const app = require('./app');
const db = require('../lib/db');
const bookingToken = require('../lib/booking-token');
const compliance = require('../lib/compliance');
const request = supertest(app);
// ─────────────────────────────────────────────────────────────────────────────
// booking-token: HMAC sign/verify
// ─────────────────────────────────────────────────────────────────────────────
test('bookingToken: sign + verify round-trip', () => {
const uuid = '00000000-0000-4000-8000-000000000001';
const sig = bookingToken.sign(uuid);
assert.equal(typeof sig, 'string');
assert.equal(sig.length, 24);
assert.equal(bookingToken.verify(uuid, sig), true);
});
test('bookingToken: rejects mutated signature', () => {
const uuid = '00000000-0000-4000-8000-000000000002';
const sig = bookingToken.sign(uuid);
const tampered = sig.slice(0, -1) + (sig.slice(-1) === 'a' ? 'b' : 'a');
assert.equal(bookingToken.verify(uuid, tampered), false);
});
test('bookingToken: rejects sig from a different uuid', () => {
const sig = bookingToken.sign('aaa');
assert.equal(bookingToken.verify('bbb', sig), false);
});
test('bookingToken: rejects empty/missing input', () => {
assert.equal(bookingToken.verify('uuid', ''), false);
assert.equal(bookingToken.verify('', 'sig'), false);
assert.equal(bookingToken.verify(null, null), false);
});
// ─────────────────────────────────────────────────────────────────────────────
// compliance: assertSendCompliance fails closed without MAILING_ADDRESS
// ─────────────────────────────────────────────────────────────────────────────
test('compliance: assertSendCompliance throws no_mailing_address when MAILING_ADDRESS unset', async () => {
const original = process.env.MAILING_ADDRESS;
delete process.env.MAILING_ADDRESS;
try {
await assert.rejects(
() => compliance.assertSendCompliance({ campaign: 'unit_test' }),
err => err.code === 'no_mailing_address'
);
} finally {
if (original !== undefined) process.env.MAILING_ADDRESS = original;
}
});
test('compliance: assertSendCompliance throws no_campaign on missing campaign', async () => {
await assert.rejects(
() => compliance.assertSendCompliance({}),
err => err.code === 'no_campaign'
);
});
test('compliance: assertSendCompliance rejects placeholder MAILING_ADDRESS values', async () => {
const original = process.env.MAILING_ADDRESS;
for (const placeholder of ['TBD', 'placeholder', '', 'localhost', 'TODO fill in']) {
process.env.MAILING_ADDRESS = placeholder;
await assert.rejects(
() => compliance.assertSendCompliance({ campaign: 'unit_test' }),
err => err.code === 'no_mailing_address',
`expected reject for "${placeholder}"`
);
}
if (original !== undefined) process.env.MAILING_ADDRESS = original;
else delete process.env.MAILING_ADDRESS;
});
// ─────────────────────────────────────────────────────────────────────────────
// compliance: unsubscribe token mint → consume → suppression
// ─────────────────────────────────────────────────────────────────────────────
test('compliance: mint → consume unsubscribe token, then suppression check sees recipient', async () => {
const fakeEmail = `unit-test-${Date.now()}@example.invalid`;
const token = await compliance.mintUnsubscribeToken({
channel: 'email', identifier: fakeEmail, campaign: 'unit_test'
});
assert.equal(typeof token, 'string');
assert.ok(token.length >= 16);
const consumed = await compliance.consumeUnsubscribeToken(token);
assert.ok(consumed, 'expected consume to return the row');
assert.equal(consumed.identifier, fakeEmail);
assert.equal(consumed.channel, 'email');
// Mirror what routes/unsubscribe.js does on consume — record suppression.
await compliance.addSuppression({
channel: 'email', identifier: fakeEmail, reason: 'unsubscribe', source: 'unit_test'
});
const suppressed = await compliance.isSuppressed({ channel: 'email', identifier: fakeEmail });
assert.equal(suppressed, true);
// Cleanup so the test row doesn't pollute production data.
await db.query('DELETE FROM comms_suppression WHERE identifier = $1', [fakeEmail]);
await db.query('DELETE FROM unsubscribe_tokens WHERE identifier = $1', [fakeEmail]);
});
test('compliance: consumeUnsubscribeToken returns null for unknown token', async () => {
const r = await compliance.consumeUnsubscribeToken('not-a-real-token-xyz-12345');
assert.equal(r, null);
});
// ─────────────────────────────────────────────────────────────────────────────
// /unsubscribe HTTP — bad token paths
// ─────────────────────────────────────────────────────────────────────────────
test('GET /unsubscribe with no token returns 400', async () => {
const res = await request.get('/unsubscribe');
assert.equal(res.status, 400);
});
test('GET /unsubscribe with garbage token returns 400', async () => {
const res = await request.get('/unsubscribe?token=garbage-not-real-token');
assert.equal(res.status, 400);
});
after(async () => {
await db.pool.end();
});