import { describe, it, expect } from "vitest"; import { AUDIT_CSV_HEADER, renderAuditCsv, auditCsvLine, resolveActorNames, type AuditCsvRow } from "./audit-csv.js"; const row = (over: Partial = {}): AuditCsvRow => ({ id: "row-1", createdAt: new Date("2026-01-02T03:04:05.000Z"), actorType: "USER", actorId: "u1", workspaceId: "ws1", resourceType: "page", resourceId: "p1", action: "page.created", ipAddress: "1.2.3.4", userAgent: "agent", metadata: { title: "Hi" }, ...over }); describe("audit-csv (the stable 13-column contract)", () => { it("emits the 13-column header", () => { const cols = AUDIT_CSV_HEADER.split(","); expect(cols).toHaveLength(13); expect(cols[0]).toBe("id"); expect(cols[11]).toBe("user_agent"); }); it("renders a row with all 13 cells", () => { const csv = renderAuditCsv([row()], new Map([["u1", "Alice"]]), new Map([["ws1", "Acme"]])); const lines = csv.trim().split("\n"); expect(lines).toHaveLength(2); const cells = lines[1]!.split(","); expect(cells).toHaveLength(13); expect(cells[0]).toBe("row-1"); expect(cells[2]).toBe("USER"); expect(cells[3]).toBe("u1"); expect(cells[4]).toBe("Alice"); expect(cells[5]).toBe("ws1"); expect(cells[6]).toBe("Acme"); expect(cells[9]).toBe("page.created"); }); it("quotes user_agent (and metadata) always, even when safe", () => { const csv = renderAuditCsv([row({ userAgent: "plain" })], new Map(), new Map()); expect(csv).toContain(`"plain"`); // metadata is compact JSON, RFC-4180-quoted with doubled inner quotes. expect(csv).toContain(`"{""title"":""Hi""}"`); }); it("RFC-4180: quotes fields with commas / quotes / newlines and escapes quotes", () => { const csv = renderAuditCsv( [row({ resourceType: 'a,"b"', metadata: { k: 'x\ny' } })], new Map(), new Map() ); expect(csv).toContain(`"a,""b"""`); expect(csv).toContain("\n"); // embedded newline inside quotes }); it("resolves actor names: USER → name, else '-'", () => { const names = resolveActorNames( [row(), row({ actorType: "SYSTEM", actorId: "worker" }), row({ actorType: "SERVICE" as never, actorId: "s" })], new Map([["u1", "Alice"]]) ); expect(names).toEqual(["Alice", "-", "-"]); }); it("unknown user → '-' (never crashes, never empty string)", () => { const names = resolveActorNames([row()], new Map()); expect(names).toEqual(["-"]); }); it("renderAuditCsv is header + rows + trailing newline", () => { const csv = renderAuditCsv([row(), row({ id: "row-2" })], new Map(), new Map()); expect(csv.startsWith(AUDIT_CSV_HEADER + "\n")).toBe(true); expect(csv.endsWith("\n")).toBe(true); expect(csv.split("\n").length).toBe(4); // header + 2 + final empty }); it("empty rows → header + newline only", () => { expect(renderAuditCsv([], new Map(), new Map())).toBe(AUDIT_CSV_HEADER + "\n"); }); it("auditCsvLine renders a single row without a header (the worker streaming path)", () => { const line = auditCsvLine(row(), "Alice", new Map([["ws1", "Acme"]])); const cells = line.split(","); expect(cells).toHaveLength(13); expect(cells[4]).toBe("Alice"); }); it("nullish metadata renders as {} (quoted — the metadata column is always quoted)", () => { const line = auditCsvLine(row({ metadata: undefined as never }), "-", new Map()); expect(line.split(",").pop()).toBe(`"{}"`); }); });