/** * Smoke tests for the audit-log viewer route (DC-050). * * Mirrors the caddy-upstreams.routes.test.js pattern: build the router with * stubbed dependencies, hit it via a tiny express app, assert the response * shape and the audit-logger calls. */ const express = require('express'); const FIXTURE_ENTRIES = [ { id: 'a1', timestamp: '2026-08-17T10:00:00.000Z', ip: '1.1.1.1', action: 'service.create', resource: 'plex', details: { userId: 'u-1', userEmail: 'admin@sami' }, outcome: 'success', }, { id: 'a2', timestamp: '2026-08-17T11:00:00.000Z', ip: '1.1.1.1', action: 'auth.totp-setup', resource: 'u-1', details: { userId: 'u-1', userEmail: 'admin@sami' }, outcome: 'success', }, { id: 'a3', timestamp: '2026-08-17T12:00:00.000Z', ip: '2.2.2.2', action: 'auth.api-key-generate', resource: 'unknown', details: { userId: null }, outcome: 'failure', }, { id: 'a4', timestamp: '2026-08-17T13:00:00.000Z', ip: '1.1.1.1', action: 'backup.execute', resource: 'all-apps', details: {}, outcome: 'success', }, { id: 'a5', timestamp: '2026-08-17T14:00:00.000Z', ip: '3.3.3.3', action: 'caddy.add-site', resource: 'test.sami', details: {}, outcome: 'failure', }, ]; function buildFakeAuditLogger(entries = FIXTURE_ENTRIES) { return { query: jest.fn(async ({ limit = 50, offset = 0, action } = {}) => { let e = entries; if (action) e = e.filter((x) => x.action && x.action.startsWith(action)); return e.slice(offset, offset + limit); }), clear: jest.fn(async () => {}), // log() is called by the DELETE handler to record `audit.clear` BEFORE // clearing — the act of clearing is itself an audit-worthy event. log: jest.fn(async () => {}), }; } describe('routes/audit-log', () => { function buildRouter(logger) { const mod = require('../../routes/audit-log'); return mod({ asyncHandler: (fn) => async (req, res, next) => { try { await fn(req, res, next); } catch (e) { next(e); } }, auditLogger: logger, }); } test('router builds with the expected paths', () => { const logger = buildFakeAuditLogger(); const router = buildRouter(logger); expect(router).toBeDefined(); expect(typeof router.use).toBe('function'); const paths = router.stack .filter((l) => l.route) .map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`)) .flat(); expect(paths).toEqual(expect.arrayContaining([ 'GET /audit-logs', 'GET /audit-logs/actions', 'DELETE /audit-logs', ])); }); test('GET /audit-logs returns all entries when no filters', async () => { const logger = buildFakeAuditLogger(); const app = express(); app.use(express.json()); app.use(buildRouter(logger)); const server = app.listen(0); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=10`); const body = await res.json(); server.close(); expect(res.status).toBe(200); expect(body.success).toBe(true); expect(body.entries).toHaveLength(5); expect(body.total).toBe(5); expect(body.hasMore).toBe(false); expect(body.filters).toEqual({ action: null, since: null, until: null, outcome: null }); }); test('GET /audit-logs respects limit + offset', async () => { const logger = buildFakeAuditLogger(); const app = express(); app.use(express.json()); app.use(buildRouter(logger)); const server = app.listen(0); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=2&offset=0`); const body = await res.json(); server.close(); expect(body.entries).toHaveLength(2); expect(body.entries[0].id).toBe('a1'); expect(body.hasMore).toBe(true); const server2 = app.listen(0); const { port: port2 } = server2.address(); const res2 = await fetch(`http://127.0.0.1:${port2}/audit-logs?limit=2&offset=4`); const body2 = await res2.json(); server2.close(); expect(body2.entries).toHaveLength(1); expect(body2.entries[0].id).toBe('a5'); expect(body2.hasMore).toBe(false); }); test('GET /audit-logs?action=auth filters server-side via auditLogger.query', async () => { const logger = buildFakeAuditLogger(); const app = express(); app.use(express.json()); app.use(buildRouter(logger)); const server = app.listen(0); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/audit-logs?action=auth`); const body = await res.json(); server.close(); expect(res.status).toBe(200); expect(body.entries).toHaveLength(2); expect(body.entries.every((e) => e.action.startsWith('auth'))).toBe(true); // The action filter MUST be pushed down to the audit-logger so we don't // load the full 1000-entry store when the operator filters by category. expect(logger.query).toHaveBeenCalledWith(expect.objectContaining({ action: 'auth' })); }); test('GET /audit-logs rejects unknown action prefix with 400', async () => { const logger = buildFakeAuditLogger(); const app = express(); app.use(express.json()); app.use(buildRouter(logger)); const server = app.listen(0); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/audit-logs?action=pwnz`); server.close(); expect(res.status).toBe(400); const body = await res.json(); expect(body.success).toBe(false); expect(body.error).toMatch(/action must be one of/); }); test('GET /audit-logs filters by since (date >= since)', async () => { const logger = buildFakeAuditLogger(); const app = express(); app.use(express.json()); app.use(buildRouter(logger)); const server = app.listen(0); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T13:00:00.000Z`); const body = await res.json(); server.close(); expect(body.entries).toHaveLength(2); // a4 + a5 expect(body.entries.map((e) => e.id)).toEqual(['a4', 'a5']); }); test('GET /audit-logs filters by outcome=failure', async () => { const logger = buildFakeAuditLogger(); const app = express(); app.use(express.json()); app.use(buildRouter(logger)); const server = app.listen(0); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/audit-logs?outcome=failure`); const body = await res.json(); server.close(); expect(body.entries).toHaveLength(2); // a3 + a5 expect(body.entries.every((e) => e.outcome === 'failure')).toBe(true); }); test('GET /audit-logs rejects since > until with 400', async () => { const logger = buildFakeAuditLogger(); const app = express(); app.use(express.json()); app.use(buildRouter(logger)); const server = app.listen(0); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T20:00:00Z&until=2026-08-17T10:00:00Z`); server.close(); expect(res.status).toBe(400); const body = await res.json(); expect(body.success).toBe(false); expect(body.error).toMatch(/since must be <= until/); }); test('GET /audit-logs rejects malformed ISO 8601 with 400', async () => { const logger = buildFakeAuditLogger(); const app = express(); app.use(express.json()); app.use(buildRouter(logger)); const server = app.listen(0); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=not-a-date`); server.close(); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/since must be ISO 8601/); }); test('GET /audit-logs caps limit at 500 (no DoS via huge page)', async () => { const logger = buildFakeAuditLogger(); const app = express(); app.use(express.json()); app.use(buildRouter(logger)); const server = app.listen(0); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=99999`); const body = await res.json(); server.close(); expect(body.limit).toBe(500); }); test('GET /audit-logs/actions returns distinct action prefixes', async () => { const logger = buildFakeAuditLogger(); const app = express(); app.use(express.json()); app.use(buildRouter(logger)); const server = app.listen(0); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/audit-logs/actions`); const body = await res.json(); server.close(); expect(res.status).toBe(200); expect(body.success).toBe(true); expect(body.prefixes).toEqual(['auth', 'backup', 'caddy', 'service']); }); test('DELETE /audit-logs requires confirm=CLEAR body', async () => { const logger = buildFakeAuditLogger(); const app = express(); app.use(express.json()); app.use(buildRouter(logger)); const server = app.listen(0); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: '{}', }); const body = await res.json(); server.close(); expect(res.status).toBe(400); expect(body.success).toBe(false); expect(body.error).toMatch(/confirm: "CLEAR"/); expect(logger.clear).not.toHaveBeenCalled(); }); test('DELETE /audit-logs with confirm=CLEAR calls auditLogger.clear()', async () => { const logger = buildFakeAuditFixtureSafe(); const app = express(); app.use(express.json()); app.use(buildRouter(logger)); const server = app.listen(0); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: 'CLEAR' }), }); const body = await res.json(); server.close(); expect(res.status).toBe(200); expect(body.cleared).toBe(true); expect(logger.clear).toHaveBeenCalledTimes(1); }); test('module.exports throws when auditLogger is missing query()', () => { const mod = require('../../routes/audit-log'); expect(() => mod({ asyncHandler: (fn) => fn, auditLogger: {} })) .toThrow(/auditLogger with query/); }); // ── GLM round-1 defect regressions ─────────────────────────────────────── test('GET /audit-logs does NOT amputate the store when limit*5 < MAX_ENTRIES (cap-truncation fix)', async () => { // Round-1 [HIGH]: route previously fetched `limit * 5` entries from // the store and computed total/hasMore over that truncated slice. // With MAX_ENTRIES=1000 and limit=50, the cap was 250 — silently // hiding entries 251-1000. The fix fetches the full store (1000). const entries = Array.from({ length: 1000 }, (_, i) => ({ id: `bulk-${i}`, timestamp: new Date(Date.parse('2026-08-17T00:00:00Z') + i * 1000).toISOString(), ip: '9.9.9.9', action: 'service.create', resource: `svc-${i}`, details: {}, outcome: i % 3 === 0 ? 'failure' : 'success', })); const logger = buildFakeAuditLogger(entries); const app = express(); app.use(express.json()); app.use(buildRouter(logger)); const server = app.listen(0); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=50&offset=200`); const body = await res.json(); server.close(); expect(body.total).toBe(1000); // full store, not 250 expect(body.hasMore).toBe(true); // still more after offset 200 expect(body.truncated).toBe(true); // signal that store was at cap }); test('GET /audit-logs compares ISO timestamps numerically (lexicographic compare fix)', async () => { // Round-1 [MEDIUM]: '10:00:00.000Z' < '10:00:00Z' is false lexicographically // (the latter is a strict substring, breaking `>=`). Fix: use Date.parse(). const fixedEntries = [ { id: 'b1', timestamp: '2026-08-17T10:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' }, { id: 'b2', timestamp: '2026-08-17T12:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' }, ]; const logger = buildFakeAuditLogger(fixedEntries); const app = express(); app.use(express.json()); app.use(buildRouter(logger)); const server = app.listen(0); const { port } = server.address(); // Same instant as b1 in a different ISO format — must be included. const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T10:00:00Z`); const body = await res.json(); server.close(); expect(body.total).toBe(2); expect(body.entries.map((e) => e.id)).toEqual(['b1', 'b2']); }); test('GET /audit-logs accepts ISO with positive UTC offset (numeric compare fix)', async () => { const fixedEntries = [ { id: 'c1', timestamp: '2026-08-17T10:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' }, { id: 'c2', timestamp: '2026-08-17T11:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' }, { id: 'c3', timestamp: '2026-08-17T12:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' }, ]; const logger = buildFakeAuditLogger(fixedEntries); const app = express(); app.use(express.json()); app.use(buildRouter(logger)); const server = app.listen(0); const { port } = server.address(); // 11:00+02:00 = 09:00Z. Filter for entries AFTER 09:00Z. Expect c1 + c2 + c3. const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T11:00:00%2B02:00`); const body = await res.json(); server.close(); expect(body.total).toBe(3); }); test('GET /audit-logs/actions only surfaces whitelisted prefixes', async () => { // Round-1 [LOW]: dropdown advertised prefixes (e.g. `logs`, `events`) // that GET /audit-logs?action=logs would then 400. Fix: intersect with // the whitelist before returning. const mixedEntries = [ { id: 'd1', timestamp: '2026-08-17T10:00:00Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' }, { id: 'd2', timestamp: '2026-08-17T10:01:00Z', ip: '', action: 'logs.something', resource: '', details: {}, outcome: 'success' }, { id: 'd3', timestamp: '2026-08-17T10:02:00Z', ip: '', action: 'events.publish', resource: '', details: {}, outcome: 'success' }, ]; const logger = buildFakeAuditLogger(mixedEntries); const app = express(); app.use(express.json()); app.use(buildRouter(logger)); const server = app.listen(0); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/audit-logs/actions`); const body = await res.json(); server.close(); expect(body.prefixes).toEqual(['service']); // logs/events filtered out }); test('DELETE /audit-logs writes audit.clear BEFORE AND AFTER clear() — re-injection preserves the forensic breadcrumb', async () => { // GLM round-2 [MEDIUM]: a naive "log before clear()" self-erases — // clear() wipes the entry that was just written. Fix: log before // clear() (catches any failure path), then clear(), then log AGAIN // so the entry survives as the single row visible to the viewer. const logger = buildFakeAuditLogger(); const app = express(); app.use(express.json()); app.use(buildRouter(logger)); const server = app.listen(0); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: 'CLEAR' }), }); server.close(); expect(res.status).toBe(200); // log() runs TWICE — once before clear (catches failure paths) and // once after clear (re-injects the forensic breadcrumb). expect(logger.log).toHaveBeenCalledTimes(2); expect(logger.log).toHaveBeenNthCalledWith(1, expect.objectContaining({ action: 'audit.clear', resource: 'audit-log.json', outcome: 'success', })); expect(logger.log).toHaveBeenNthCalledWith(2, expect.objectContaining({ action: 'audit.clear', resource: 'audit-log.json', outcome: 'success', })); // Ordering: log → clear → log (second log runs AFTER clear). const logOrders = logger.log.mock.invocationCallOrder; const clearOrder = logger.clear.mock.invocationCallOrder[0]; expect(logOrders[0]).toBeLessThan(clearOrder); expect(logOrders[1]).toBeGreaterThan(clearOrder); }); test('DELETE /audit-logs still calls clear() even if auditLogger.log() throws', async () => { // A failing audit-log write must NOT block the operator's clear. const logger = buildFakeAuditLogger(); logger.log.mockRejectedValueOnce(new Error('disk full')); const app = express(); app.use(express.json()); app.use(buildRouter(logger)); const server = app.listen(0); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: 'CLEAR' }), }); server.close(); expect(res.status).toBe(200); expect(logger.clear).toHaveBeenCalledTimes(1); }); }); // Tiny helper — separated so the second clear test has a fresh mock. function buildFakeAuditFixtureSafe() { return buildFakeAuditLogger(); }