← 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();
});