← back to Butlr

test/dnc-check.test.js

171 lines

#!/usr/bin/env node
// Unit test for lib/dnc-check.js — the DNC pre-flight gate.
//
// Covers:
//   • E.164 normalization across input formats
//   • Federal DNC hit when number is in snapshot
//   • Internal-suppression hit
//   • DRY_RUN env bypass
//   • Missing snapshot → throws DNC_SNAPSHOT_MISSING (fail closed)
//   • addToInternalSuppression is idempotent + atomic
//   • Non-US numbers skip (length != 10)

const fs = require('fs');
const os = require('os');
const path = require('path');
const assert = require('assert');

function ok(name, fn) {
  try { fn(); console.log(`  ✓ ${name}`); return 1; }
  catch (e) { console.error(`  ✗ ${name}`); console.error('     ', e.message); return 0; }
}

let pass = 0, total = 0;
const origDryRun = process.env.TWILIO_DRY_RUN;
const origDncFile = process.env.DNC_FILE;

// Helper: spin up an isolated env per test scenario.
function withTmp(setup, body) {
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'butlr-dnc-test-'));
  const dncFile = path.join(tmp, 'national-dnc.txt');
  const suppressionFile = path.join(tmp, 'dnc-suppression.json');
  setup({ tmp, dncFile, suppressionFile });
  process.env.DNC_FILE = dncFile;
  // Force module re-load so it picks up our env-overridden DNC_FILE
  delete require.cache[require.resolve('../lib/dnc-check')];
  const dnc = require('../lib/dnc-check');
  // Test-only: rebind INTERNAL_SUPPRESSION to our tmp via fs redirect.
  const realWrite = fs.writeFileSync, realRead = fs.readFileSync, realRename = fs.renameSync, realExists = fs.existsSync;
  fs.writeFileSync = (p, ...rest) => {
    if (String(p).includes('dnc-suppression')) return realWrite(suppressionFile + (String(p).endsWith('.tmp') ? '.tmp' : ''), ...rest);
    return realWrite(p, ...rest);
  };
  fs.readFileSync = (p, ...rest) => {
    if (String(p).includes('dnc-suppression')) return realRead(suppressionFile, ...rest);
    return realRead(p, ...rest);
  };
  fs.renameSync = (a, b) => {
    const rewrite = (p) => String(p).includes('dnc-suppression') ? suppressionFile + (String(p).endsWith('.tmp') ? '.tmp' : '') : p;
    return realRename(rewrite(a), rewrite(b));
  };
  fs.existsSync = (p) => {
    if (String(p).includes('dnc-suppression')) return realExists(suppressionFile);
    return realExists(p);
  };
  try { return body(dnc, { tmp, dncFile, suppressionFile }); }
  finally {
    fs.writeFileSync = realWrite; fs.readFileSync = realRead; fs.renameSync = realRename; fs.existsSync = realExists;
    fs.rmSync(tmp, { recursive: true, force: true });
  }
}

// ── 1. Normalization ────────────────────────────────────────────────
delete process.env.TWILIO_DRY_RUN;
total++; pass += ok('e164Digits normalizes US 10-digit forms', () => {
  withTmp(({ dncFile }) => { fs.writeFileSync(dncFile, ''); }, (dnc) => {
    assert.strictEqual(dnc.e164Digits('+14155551212'), '4155551212');
    assert.strictEqual(dnc.e164Digits('14155551212'),  '4155551212');
    assert.strictEqual(dnc.e164Digits('(415) 555-1212'), '4155551212');
    assert.strictEqual(dnc.e164Digits('415.555.1212'),   '4155551212');
    assert.strictEqual(dnc.e164Digits('4155551212'),     '4155551212');
    assert.strictEqual(dnc.e164Digits(''),               '');
    assert.strictEqual(dnc.e164Digits('+447911123456'),  '', 'non-US (11 digit) returns empty');
  });
});

// ── 2. Federal DNC hit ──────────────────────────────────────────────
total++; pass += ok('federal DNC hit blocks the number', () => {
  withTmp(({ dncFile }) => {
    fs.writeFileSync(dncFile, '+14155551212\n+13105551111\n');
  }, (dnc) => {
    const result = dnc.checkPhone('+14155551212', 'call');
    assert.strictEqual(result.allowed, false);
    assert.strictEqual(result.reason, 'federal_dnc');
    assert.strictEqual(result.source, 'ftc_donotcall');
  });
});

// ── 3. Federal DNC miss allows ─────────────────────────────────────
total++; pass += ok('federal DNC miss allows', () => {
  withTmp(({ dncFile }) => {
    fs.writeFileSync(dncFile, '+14155551212\n');
  }, (dnc) => {
    const result = dnc.checkPhone('+18002274825', 'call');  // Capital One
    assert.strictEqual(result.allowed, true);
  });
});

// ── 4. Internal suppression hit ─────────────────────────────────────
total++; pass += ok('internal suppression blocks even if not in federal', () => {
  withTmp(({ dncFile, suppressionFile }) => {
    fs.writeFileSync(dncFile, '');
    fs.writeFileSync(suppressionFile, JSON.stringify([
      { phone: '+13105551212', hash: 'x', reason: 'stop_keyword', source: 'sms', added_at: '2026-05-13' },
    ]));
  }, (dnc) => {
    const result = dnc.checkPhone('+13105551212', 'sms');
    assert.strictEqual(result.allowed, false);
    assert.strictEqual(result.reason, 'internal_suppression');
  });
});

// ── 5. Missing snapshot → throws (fail closed) ──────────────────────
total++; pass += ok('missing snapshot throws DNC_SNAPSHOT_MISSING', () => {
  withTmp(() => { /* don't create dncFile */ }, (dnc) => {
    let threw = null;
    try { dnc.checkPhone('+14155551212', 'call'); }
    catch (e) { threw = e; }
    assert.ok(threw, 'should have thrown');
    assert.strictEqual(threw.code, 'DNC_SNAPSHOT_MISSING');
  });
});

// ── 6. DRY_RUN env bypasses everything ──────────────────────────────
total++; pass += ok('TWILIO_DRY_RUN=1 bypasses gate entirely', () => {
  process.env.TWILIO_DRY_RUN = '1';
  withTmp(() => { /* no snapshot */ }, (dnc) => {
    const result = dnc.checkPhone('+14155551212', 'call');
    assert.strictEqual(result.allowed, true);
    assert.strictEqual(result.dry_run_bypass, true);
  });
  delete process.env.TWILIO_DRY_RUN;
});

// ── 7. addToInternalSuppression idempotent ──────────────────────────
total++; pass += ok('addToInternalSuppression is idempotent', () => {
  withTmp(({ dncFile }) => { fs.writeFileSync(dncFile, ''); }, (dnc) => {
    const r1 = dnc.addToInternalSuppression('+14155551212', 'stop_kw', 'sms');
    assert.strictEqual(r1.added, true);
    const r2 = dnc.addToInternalSuppression('+14155551212', 'manual', 'cli');
    assert.strictEqual(r2.already, true);
    // Now check it actually blocks
    const result = dnc.checkPhone('+14155551212', 'call');
    assert.strictEqual(result.allowed, false);
  });
});

// ── 8. Non-US numbers pass through ─────────────────────────────────
total++; pass += ok('non-US numbers (length != 10) skip DNC check', () => {
  withTmp(({ dncFile }) => { fs.writeFileSync(dncFile, ''); }, (dnc) => {
    const result = dnc.checkPhone('+447911123456', 'call');  // UK number
    assert.strictEqual(result.allowed, true);
  });
});

// ── 9. checkPhone w/ no phone → blocked w/ no_phone reason ─────────
total++; pass += ok('missing phone → allowed=false, reason=no_phone', () => {
  withTmp(({ dncFile }) => { fs.writeFileSync(dncFile, ''); }, (dnc) => {
    const result = dnc.checkPhone('', 'call');
    assert.strictEqual(result.allowed, false);
    assert.strictEqual(result.reason, 'no_phone');
  });
});

// Restore env
if (origDryRun !== undefined) process.env.TWILIO_DRY_RUN = origDryRun;
if (origDncFile !== undefined) process.env.DNC_FILE = origDncFile;
else delete process.env.DNC_FILE;

console.log(`\n${pass}/${total} passed`);
process.exit(pass === total ? 0 : 1);