← back to Lifestyle Asset Intel

tests/audit.test.js

366 lines

// audit.test.js — verifies the immutable valuation_calls audit log.

'use strict';

require('dotenv').config();
process.env.PGHOST = process.env.PGHOST || '/tmp';
process.env.PGDATABASE = process.env.PGDATABASE || 'lifestyle_asset_intel';

const { test, after } = require('node:test');
const assert = require('node:assert/strict');

const app = require('../server');
const { pool } = require('../lib/db');
const { hashToken } = require('../lib/audit');
const { METHODOLOGY_VERSION } = require('../lib/valuation');

let server;
let baseUrl;

function ready() {
  return new Promise((resolve) => {
    server = app.listen(0, '127.0.0.1', () => {
      baseUrl = `http://127.0.0.1:${server.address().port}`;
      resolve();
    });
  });
}
function get(path, headers = {}) {
  return fetch(baseUrl + path, { headers });
}
async function getJson(path, headers = {}) {
  const r = await get(path, headers);
  let json = null; const text = await r.text();
  try { json = JSON.parse(text); } catch {}
  return { status: r.status, json, text };
}

test('hashToken: bare string → null', () => {
  assert.equal(hashToken(null), null);
  assert.equal(hashToken(''), null);
  assert.equal(hashToken('not-a-bearer'), null);
});
test('hashToken: bearer → 64-char sha256 hex digest', () => {
  const out = hashToken('Bearer abc123');
  assert.equal(typeof out, 'string');
  assert.equal(out.length, 64);
  assert.match(out, /^[0-9a-f]{64}$/);
  // Same input → same output (deterministic)
  assert.equal(hashToken('Bearer abc123'), out);
  // Different input → different output
  assert.notEqual(hashToken('Bearer abc456'), out);
});

test('GET /api/valuation/:slug records a row in valuation_calls', async () => {
  await ready();
  // Use a unique user-agent so we can locate THIS test's row without
  // racing other test files (node --test runs files in parallel).
  const TEST_UA = `audit-test/${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
  const before = await pool.query(`SELECT COUNT(*)::int AS n FROM valuation_calls`);
  const r = await getJson('/api/valuation/birkin-30-togo-gold-ghw-us', {
    'user-agent': TEST_UA,
    'authorization': 'Bearer test-token-xyz'
  });
  assert.equal(r.status, 200);

  // Audit write is fire-and-forget on res.finish — give it a moment.
  await new Promise((res) => setTimeout(res, 200));

  const row = await pool.query(
    `SELECT route, method, asset_slug, response_status, methodology_version,
            user_agent, caller_token_hash, latency_ms, request_id
       FROM valuation_calls
      WHERE user_agent = $1
      ORDER BY called_at DESC LIMIT 1`,
    [TEST_UA]
  );
  const r0 = row.rows[0];
  assert.ok(r0, 'audit row inserted');
  assert.equal(r0.method, 'GET');
  assert.equal(r0.asset_slug, 'birkin-30-togo-gold-ghw-us');
  assert.equal(r0.response_status, 200);
  assert.equal(r0.methodology_version, METHODOLOGY_VERSION);
  assert.equal(r0.user_agent, TEST_UA);
  assert.match(r0.caller_token_hash, /^[0-9a-f]{64}$/);
  assert.ok(typeof r0.latency_ms === 'number' && r0.latency_ms >= 0);
  assert.match(r0.route, /\/api\/valuation/, 'route is the template path');
  assert.match(r0.request_id, /^[0-9a-f-]{36}$/);

  const after = await pool.query(`SELECT COUNT(*)::int AS n FROM valuation_calls`);
  assert.ok(after.rows[0].n > before.rows[0].n);
});

test('GET /api/health is NOT logged (skipped per SKIP_PATHS)', async () => {
  // Filter on route='/api/health' so we don't race against other parallel
  // test files writing audit rows for OTHER routes during the await.
  const HEALTH_FILTER = `route = '/api/health'`;
  const before = await pool.query(
    `SELECT COUNT(*)::int AS n FROM valuation_calls WHERE ${HEALTH_FILTER}`
  );
  const r = await getJson('/api/health');
  assert.equal(r.status, 200);
  await new Promise((res) => setTimeout(res, 200));
  const after = await pool.query(
    `SELECT COUNT(*)::int AS n FROM valuation_calls WHERE ${HEALTH_FILTER}`
  );
  assert.equal(after.rows[0].n, before.rows[0].n, 'health probe should not be audited');
});

test('GET /api/audit/recent returns recent calls in DESC order', async () => {
  // Generate a couple known calls.
  await getJson('/api/valuation/birkin-25-togo-gold-ghw-us');
  await getJson('/api/valuation/kelly-25-sellier-epsom-black-ghw-us');
  await new Promise((res) => setTimeout(res, 250));

  const r = await getJson('/api/audit/recent?limit=10');
  assert.equal(r.status, 200);
  assert.ok(Array.isArray(r.json.calls));
  assert.ok(r.json.calls.length >= 2);
  for (let i = 1; i < r.json.calls.length; i++) {
    const prev = new Date(r.json.calls[i - 1].called_at).getTime();
    const cur = new Date(r.json.calls[i].called_at).getTime();
    assert.ok(prev >= cur, 'descending called_at order');
  }
});

test('GET /api/audit/recent?asset_slug filters', async () => {
  const r = await getJson('/api/audit/recent?asset_slug=birkin-25-togo-gold-ghw-us');
  assert.equal(r.status, 200);
  // All returned rows should match the filter (or there are zero).
  for (const c of r.json.calls) {
    assert.equal(c.asset_slug, 'birkin-25-togo-gold-ghw-us');
  }
});

test('GET /api/audit/recent?route filters by route substring (case-insensitive) and composes with asset_slug', async () => {
  // Prime the table with at least one /api/valuation row + one non-matching row.
  await getJson('/api/valuation/birkin-30-togo-gold-ghw-us');
  await getJson('/api/assets');
  await new Promise((res) => setTimeout(res, 250));

  // Substring match — uppercase input proves case-insensitivity.
  const r = await getJson('/api/audit/recent?route=VALUATION&limit=50');
  assert.equal(r.status, 200);
  assert.equal(r.json.route, 'VALUATION');
  assert.ok(r.json.calls.length >= 1, 'at least one /api/valuation row');
  for (const c of r.json.calls) {
    assert.match(c.route.toLowerCase(), /valuation/);
  }

  // Composes cleanly with asset_slug.
  const r2 = await getJson(
    '/api/audit/recent?route=valuation&asset_slug=birkin-30-togo-gold-ghw-us'
  );
  assert.equal(r2.status, 200);
  for (const c of r2.json.calls) {
    assert.match(c.route, /\/api\/valuation/);
    assert.equal(c.asset_slug, 'birkin-30-togo-gold-ghw-us');
  }

  // Empty string route should NOT filter (behaves like no param).
  const r3 = await getJson('/api/audit/recent?route=&limit=5');
  assert.equal(r3.status, 200);
  assert.equal(r3.json.route, null);
});

test('GET /api/audit/recent?method filters by HTTP method (case-insensitive) and composes', async () => {
  // Prime the audit table with a couple GET rows.
  await getJson('/api/valuation/birkin-30-togo-gold-ghw-us');
  await getJson('/api/assets');
  await new Promise((res) => setTimeout(res, 250));

  // Lowercase input proves case-insensitivity; response should normalize to upper.
  const r = await getJson('/api/audit/recent?method=get&limit=20');
  assert.equal(r.status, 200);
  assert.equal(r.json.method, 'GET');
  assert.ok(r.json.calls.length >= 1, 'at least one GET row');
  for (const c of r.json.calls) {
    assert.equal(c.method, 'GET');
  }

  // Composes cleanly with route filter.
  const r2 = await getJson('/api/audit/recent?method=GET&route=valuation&limit=20');
  assert.equal(r2.status, 200);
  for (const c of r2.json.calls) {
    assert.equal(c.method, 'GET');
    assert.match(c.route, /\/api\/valuation/);
  }

  // No matches for a method we never call against the audit route.
  const r3 = await getJson('/api/audit/recent?method=PUT&limit=20');
  assert.equal(r3.status, 200);
  assert.equal(r3.json.method, 'PUT');
  for (const c of r3.json.calls) {
    assert.equal(c.method, 'PUT');
  }

  // Empty string method should NOT filter (behaves like no param).
  const r4 = await getJson('/api/audit/recent?method=&limit=5');
  assert.equal(r4.status, 200);
  assert.equal(r4.json.method, null);
});

test('GET /api/audit/recent?status filters by exact code and class-prefix, composes', async () => {
  // Prime the table with a known 200 row and a known 404 row.
  await getJson('/api/valuation/birkin-30-togo-gold-ghw-us');               // 200
  await getJson('/api/valuation/this-slug-definitely-does-not-exist-xyz');  // 404
  await new Promise((res) => setTimeout(res, 250));

  // Exact 3-digit code.
  const r200 = await getJson('/api/audit/recent?status=200&limit=50');
  assert.equal(r200.status, 200);
  assert.equal(r200.json.status, 200);
  assert.ok(r200.json.calls.length >= 1, 'at least one 200 row');
  for (const c of r200.json.calls) assert.equal(c.response_status, 200);

  // Exact 404.
  const r404 = await getJson('/api/audit/recent?status=404&limit=50');
  assert.equal(r404.status, 200);
  assert.equal(r404.json.status, 404);
  for (const c of r404.json.calls) assert.equal(c.response_status, 404);

  // 1-digit class prefix → 4xx (matches the 404 row).
  const r4xx = await getJson('/api/audit/recent?status=4&limit=50');
  assert.equal(r4xx.status, 200);
  assert.equal(r4xx.json.status, '4xx');
  assert.ok(r4xx.json.calls.length >= 1, 'at least one 4xx row');
  for (const c of r4xx.json.calls) {
    assert.ok(c.response_status >= 400 && c.response_status <= 499,
      `expected 4xx, got ${c.response_status}`);
  }

  // Composes cleanly with route filter.
  const rCombo = await getJson('/api/audit/recent?status=200&route=valuation&limit=20');
  assert.equal(rCombo.status, 200);
  for (const c of rCombo.json.calls) {
    assert.equal(c.response_status, 200);
    assert.match(c.route, /\/api\/valuation/);
  }

  // Invalid status (non-numeric) is silently ignored → behaves like no filter.
  const rJunk = await getJson('/api/audit/recent?status=banana&limit=3');
  assert.equal(rJunk.status, 200);
  assert.equal(rJunk.json.status, null);

  // Empty status is silently ignored too.
  const rEmpty = await getJson('/api/audit/recent?status=&limit=3');
  assert.equal(rEmpty.status, 200);
  assert.equal(rEmpty.json.status, null);
});

test('GET /api/audit/recent?since filters by ISO timestamp lower bound and composes', async () => {
  // Capture a cutoff BEFORE we make new calls.
  await new Promise((res) => setTimeout(res, 50));
  const cutoff = new Date().toISOString();
  await new Promise((res) => setTimeout(res, 50));

  // Generate at least one call AFTER the cutoff.
  await getJson('/api/valuation/birkin-30-togo-gold-ghw-us');
  await new Promise((res) => setTimeout(res, 250));

  const r = await getJson(`/api/audit/recent?since=${encodeURIComponent(cutoff)}&limit=100`);
  assert.equal(r.status, 200);
  assert.equal(r.json.since, cutoff);
  assert.ok(r.json.calls.length >= 1, 'at least one row after cutoff');
  for (const c of r.json.calls) {
    assert.ok(new Date(c.called_at).getTime() >= new Date(cutoff).getTime(),
      `expected called_at >= cutoff, got ${c.called_at}`);
  }

  // Composes with route filter.
  const r2 = await getJson(
    `/api/audit/recent?since=${encodeURIComponent(cutoff)}&route=valuation&limit=20`
  );
  assert.equal(r2.status, 200);
  for (const c of r2.json.calls) {
    assert.match(c.route, /\/api\/valuation/);
    assert.ok(new Date(c.called_at).getTime() >= new Date(cutoff).getTime());
  }

  // Far-future cutoff returns zero rows.
  const future = new Date(Date.now() + 1000 * 60 * 60 * 24 * 365).toISOString();
  const rEmpty = await getJson(`/api/audit/recent?since=${encodeURIComponent(future)}&limit=10`);
  assert.equal(rEmpty.status, 200);
  assert.equal(rEmpty.json.calls.length, 0);

  // Unparseable since is silently ignored → echoes null and behaves like no filter.
  const rJunk = await getJson('/api/audit/recent?since=not-a-date&limit=5');
  assert.equal(rJunk.status, 200);
  assert.equal(rJunk.json.since, null);

  // Empty since is silently ignored.
  const rBlank = await getJson('/api/audit/recent?since=&limit=5');
  assert.equal(rBlank.status, 200);
  assert.equal(rBlank.json.since, null);
});

test('GET /api/audit/recent?until filters by ISO timestamp upper bound and composes', async () => {
  // Generate at least one call BEFORE the cutoff.
  await getJson('/api/valuation/birkin-30-togo-gold-ghw-us');
  await new Promise((res) => setTimeout(res, 250));

  // Capture a cutoff AFTER those calls landed.
  const cutoff = new Date().toISOString();
  await new Promise((res) => setTimeout(res, 50));

  // Generate at least one call AFTER the cutoff (must be excluded).
  await getJson('/api/valuation/birkin-30-togo-gold-ghw-us');
  await new Promise((res) => setTimeout(res, 250));

  const r = await getJson(`/api/audit/recent?until=${encodeURIComponent(cutoff)}&limit=200`);
  assert.equal(r.status, 200);
  assert.equal(r.json.until, cutoff);
  assert.ok(r.json.calls.length >= 1, 'at least one row before cutoff');
  for (const c of r.json.calls) {
    assert.ok(new Date(c.called_at).getTime() <= new Date(cutoff).getTime(),
      `expected called_at <= cutoff, got ${c.called_at}`);
  }

  // Composes with since to bracket a window.
  const windowLo = new Date(Date.now() - 1000 * 60 * 60).toISOString(); // 1h ago
  const r2 = await getJson(
    `/api/audit/recent?since=${encodeURIComponent(windowLo)}&until=${encodeURIComponent(cutoff)}&limit=50`
  );
  assert.equal(r2.status, 200);
  assert.equal(r2.json.since, windowLo);
  assert.equal(r2.json.until, cutoff);
  for (const c of r2.json.calls) {
    const t = new Date(c.called_at).getTime();
    assert.ok(t >= new Date(windowLo).getTime() && t <= new Date(cutoff).getTime(),
      `expected called_at within window, got ${c.called_at}`);
  }

  // Far-past cutoff returns zero rows.
  const ancient = new Date(Date.now() - 1000 * 60 * 60 * 24 * 365).toISOString();
  const rEmpty = await getJson(`/api/audit/recent?until=${encodeURIComponent(ancient)}&limit=10`);
  assert.equal(rEmpty.status, 200);
  assert.equal(rEmpty.json.calls.length, 0);

  // Unparseable until is silently ignored → echoes null and behaves like no filter.
  const rJunk = await getJson('/api/audit/recent?until=not-a-date&limit=5');
  assert.equal(rJunk.status, 200);
  assert.equal(rJunk.json.until, null);

  // Empty until is silently ignored.
  const rBlank = await getJson('/api/audit/recent?until=&limit=5');
  assert.equal(rBlank.status, 200);
  assert.equal(rBlank.json.until, null);
});

test('valuation_calls has no UPDATE or DELETE called by app code', async () => {
  // Sanity: app should never mutate the table. A grep would verify
  // this at code-review time, but the fastest check at runtime is that
  // no rows have changed timestamps drifting (there's no updated_at
  // column — the table is INSERT-only by schema design).
  const r = await pool.query(`
    SELECT column_name FROM information_schema.columns
     WHERE table_name = 'valuation_calls' AND column_name IN ('updated_at','deleted_at')
  `);
  assert.equal(r.rows.length, 0, 'audit table must remain INSERT-only');
});

after(async () => {
  if (server) await new Promise((res) => server.close(res));
  await pool.end();
});