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