/** * The trail: an allowed change and a refused one each leave a row, through * the real server; only an admin reads them; and a trail that cannot be * written costs a log line, never the request. */ import { afterAll, expect, spyOn, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { startDrawing } from "./actions.ts"; import type { AuditRow } from "./audit.ts"; import { openDb } from "./db.ts"; import { serve } from "./server.ts"; import { sign } from "./who.ts"; const KEY = "a-key-only-the-forge-holds"; const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-audit-")), identityKey: KEY, }); const base = `http://${server.hostname}:${server.port}`; afterAll(() => server.stop(true)); const as = ( id: string, name: string, role: "user" | "admin" = "user", ): Record => { const identity = `${id}|${name}|${role}|${Date.now() + 60_000}`; return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY), }; }; const alice = as("u1", "alice"); const bob = as("u2", "bob"); const root = as("u0", "root", "admin"); const call = ( path: string, init: RequestInit = {}, who: Record = {}, ) => fetch(base + path, { ...init, headers: { "content-type": "application/json", ...who }, }); const trail = async (): Promise => (await (await call("/api/audit", {}, root)).json()) as AuditRow[]; test("an allowed change and a refused one each leave a row", async () => { const made = await call( "/api/drawings", { method: "POST", body: JSON.stringify({ prompt: "kept" }) }, alice, ); expect(made.status).toBe(202); const note = (await made.json()) as { id: number }; // Refused at the door: a stranger may create nothing. expect( ( await call("/api/drawings", { method: "POST", body: JSON.stringify({ prompt: "x" }), }) ).status, ).toBe(401); // Refused by the action: bob could delete a drawing of his own, not alice's. expect( (await call(`/api/drawings/${note.id}`, { method: "DELETE" }, bob)).status, ).toBe(403); // Refused as cross-site, before the table is asked. const forged = await fetch(`${base}/api/drawings/${note.id}`, { method: "DELETE", headers: { ...alice, "sec-fetch-site": "cross-site" }, }); expect(forged.status).toBe(403); const rows = (await trail()).slice(0, 4).reverse(); expect( rows.map( ({ who_name, role, resource, verb, target, allowed, outcome }) => ({ who_name, role, resource, verb, target, allowed, outcome, }), ), ).toEqual([ { who_name: "alice", role: "user", resource: "drawing", verb: "create", target: String(note.id), allowed: 1, outcome: "ok", }, { who_name: null, role: "anon", resource: "drawing", verb: "create", target: null, allowed: 0, outcome: "signin_required", }, { who_name: "bob", role: "user", resource: "drawing", verb: "delete", target: String(note.id), allowed: 0, outcome: "forbidden", }, { who_name: "alice", role: "user", resource: "drawing", verb: "delete", target: null, allowed: 0, outcome: "cross_site", }, ]); for (const row of rows) expect(Date.parse(row.at)).toBeGreaterThan(Date.now() - 60_000); }); test("reading is not an event: a GET leaves no row", async () => { const before = (await trail()).length; await call("/api/drawings"); await call("/", {}, alice); expect((await trail()).length).toBe(before); }); test("only an admin reads the trail, at every door the table names", async () => { expect((await call("/api/audit")).status).toBe(401); expect((await call("/api/audit", {}, alice)).status).toBe(403); const rows = await trail(); expect(rows.length).toBeGreaterThan(0); expect((await call(`/api/audit/${rows[0]!.id}`, {}, root)).status).toBe(200); expect((await call(`/api/audit/${rows[0]!.id}`, {}, alice)).status).toBe(403); expect((await call("/api/audit/999999", {}, root)).status).toBe(404); // Nobody writes the trail through a door: not even an admin. expect( (await call("/api/audit", { method: "POST", body: "{}" }, root)).status, ).toBe(403); expect( (await call(`/api/audit/${rows[0]!.id}`, { method: "DELETE" }, root)) .status, ).toBe(403); // Paging back: `before` an id is everything older. const older = (await ( await call(`/api/audit?before=${rows[0]!.id}&limit=1`, {}, root) ).json()) as AuditRow[]; expect(older.map((r) => r.id)).toEqual([rows[1]!.id]); }); test("a trail that cannot be written is a log line, and the change still happens", () => { const db = openDb(mkdtempSync(join(tmpdir(), "app-audit-broken-"))); db.exec("DROP TABLE audit"); const said = spyOn(console, "error").mockImplementation(() => {}); try { const added = startDrawing( db, { role: "user", id: "u1", name: "alice" }, { prompt: "still here" }, ); expect(added.ok).toBe(true); expect(said).toHaveBeenCalledTimes(1); const line = JSON.parse(String(said.mock.calls[0]![0])); expect(line.audit).toMatchObject({ who_name: "alice", resource: "drawing", verb: "create", allowed: 1, outcome: "ok", }); expect(line.unrecorded).toContain("audit"); } finally { said.mockRestore(); } });