1import { describe, it, expect } from "vitest";
2import {
3 AUDIT_CSV_HEADER,
4 renderAuditCsv,
5 auditCsvLine,
6 resolveActorNames,
7 type AuditCsvRow
8} from "./audit-csv.js";
9
10const row = (over: Partial<AuditCsvRow> = {}): AuditCsvRow => ({
11 id: "row-1",
12 createdAt: new Date("2026-01-02T03:04:05.000Z"),
13 actorType: "USER",
14 actorId: "u1",
15 workspaceId: "ws1",
16 resourceType: "page",
17 resourceId: "p1",
18 action: "page.created",
19 ipAddress: "1.2.3.4",
20 userAgent: "agent",
21 metadata: { title: "Hi" },
22 ...over
23});
24
25describe("audit-csv (the stable 13-column contract)", () => {
26 it("emits the 13-column header", () => {
27 const cols = AUDIT_CSV_HEADER.split(",");
28 expect(cols).toHaveLength(13);
29 expect(cols[0]).toBe("id");
30 expect(cols[11]).toBe("user_agent");
31 });
32
33 it("renders a row with all 13 cells", () => {
34 const csv = renderAuditCsv([row()], new Map([["u1", "Alice"]]), new Map([["ws1", "Acme"]]));
35 const lines = csv.trim().split("\n");
36 expect(lines).toHaveLength(2);
37 const cells = lines[1]!.split(",");
38 expect(cells).toHaveLength(13);
39 expect(cells[0]).toBe("row-1");
40 expect(cells[2]).toBe("USER");
41 expect(cells[3]).toBe("u1");
42 expect(cells[4]).toBe("Alice");
43 expect(cells[5]).toBe("ws1");
44 expect(cells[6]).toBe("Acme");
45 expect(cells[9]).toBe("page.created");
46 });
47
48 it("quotes user_agent (and metadata) always, even when safe", () => {
49 const csv = renderAuditCsv([row({ userAgent: "plain" })], new Map(), new Map());
50 expect(csv).toContain(`"plain"`);
51
52 expect(csv).toContain(`"{""title"":""Hi""}"`);
53 });
54
55 it("RFC-4180: quotes fields with commas / quotes / newlines and escapes quotes", () => {
56 const csv = renderAuditCsv(
57 [row({ resourceType: 'a,"b"', metadata: { k: 'x\ny' } })],
58 new Map(),
59 new Map()
60 );
61 expect(csv).toContain(`"a,""b"""`);
62 expect(csv).toContain("\n");
63 });
64
65 it("resolves actor names: USER → name, else '-'", () => {
66 const names = resolveActorNames(
67 [row(), row({ actorType: "SYSTEM", actorId: "worker" }), row({ actorType: "SERVICE" as never, actorId: "s" })],
68 new Map([["u1", "Alice"]])
69 );
70 expect(names).toEqual(["Alice", "-", "-"]);
71 });
72
73 it("unknown user → '-' (never crashes, never empty string)", () => {
74 const names = resolveActorNames([row()], new Map());
75 expect(names).toEqual(["-"]);
76 });
77
78 it("renderAuditCsv is header + rows + trailing newline", () => {
79 const csv = renderAuditCsv([row(), row({ id: "row-2" })], new Map(), new Map());
80 expect(csv.startsWith(AUDIT_CSV_HEADER + "\n")).toBe(true);
81 expect(csv.endsWith("\n")).toBe(true);
82 expect(csv.split("\n").length).toBe(4);
83 });
84
85 it("empty rows → header + newline only", () => {
86 expect(renderAuditCsv([], new Map(), new Map())).toBe(AUDIT_CSV_HEADER + "\n");
87 });
88
89 it("auditCsvLine renders a single row without a header (the worker streaming path)", () => {
90 const line = auditCsvLine(row(), "Alice", new Map([["ws1", "Acme"]]));
91 const cells = line.split(",");
92 expect(cells).toHaveLength(13);
93 expect(cells[4]).toBe("Alice");
94 });
95
96 it("nullish metadata renders as {} (quoted — the metadata column is always quoted)", () => {
97 const line = auditCsvLine(row({ metadata: undefined as never }), "-", new Map());
98 expect(line.split(",").pop()).toBe(`"{}"`);
99 });
100});
101