Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

audit-csv.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

audit-csv.test.tsBlame321 lines · 1 contributor
611431dClaude1/**
2 * Block J26 — Audit log CSV export. Pure helpers + route smoke tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import {
7 csvCell,
8 csvRow,
9 csvDocument,
10 formatAuditCsv,
11 auditCsvFilename,
12 AUDIT_CSV_COLUMNS,
13 CSV_INJECTION_CHARS,
14 __internal,
15 type AuditCsvRow,
16} from "../lib/audit-csv";
17
18describe("audit-csv — csvCell", () => {
19 it("emits empty string for null/undefined", () => {
20 expect(csvCell(null)).toBe("");
21 expect(csvCell(undefined)).toBe("");
22 });
23
24 it("passes through simple strings unchanged", () => {
25 expect(csvCell("hello")).toBe("hello");
26 expect(csvCell("with spaces")).toBe("with spaces");
27 expect(csvCell("a/b_c")).toBe("a/b_c");
28 });
29
30 it("coerces numbers and booleans via String()", () => {
31 expect(csvCell(42)).toBe("42");
32 expect(csvCell(0)).toBe("0");
33 expect(csvCell(true)).toBe("true");
34 expect(csvCell(false)).toBe("false");
35 });
36
37 it("ISO-serialises Date objects", () => {
38 const d = new Date("2025-04-01T12:34:56.789Z");
39 expect(csvCell(d)).toBe("2025-04-01T12:34:56.789Z");
40 });
41
42 it("treats invalid Date as empty", () => {
43 expect(csvCell(new Date("not-a-date"))).toBe("");
44 });
45
46 it("quotes cells that contain commas", () => {
47 expect(csvCell("a,b")).toBe('"a,b"');
48 });
49
50 it("quotes cells that contain newlines", () => {
51 expect(csvCell("a\nb")).toBe('"a\nb"');
52 expect(csvCell("a\r\nb")).toBe('"a\r\nb"');
53 });
54
55 it("quotes cells that contain a CR on its own (the injection char still leads, gets prefixed)", () => {
56 // `\r` is both an injection trigger AND a quote trigger — expected behaviour:
57 // prefix `'`, then quote the whole cell.
58 expect(csvCell("\rboom")).toBe(`"'\rboom"`);
59 });
60
61 it("escapes embedded double quotes", () => {
62 expect(csvCell('a"b')).toBe('"a""b"');
63 expect(csvCell('""')).toBe('""""""');
64 });
65
66 it("CSV-injection guard prefixes leading =", () => {
67 expect(csvCell("=SUM(A1)")).toBe("'=SUM(A1)");
68 });
69
70 it("CSV-injection guard + quote trigger combine correctly", () => {
71 // leading = AND an embedded comma → prefix lives inside the quotes
72 expect(csvCell("=1,2")).toBe(`"'=1,2"`);
73 });
74
75 it("CSV-injection guard prefixes leading +", () => {
76 expect(csvCell("+1234")).toBe("'+1234");
77 });
78
79 it("CSV-injection guard prefixes leading -", () => {
80 expect(csvCell("-5")).toBe("'-5");
81 });
82
83 it("CSV-injection guard prefixes leading @", () => {
84 expect(csvCell("@name")).toBe("'@name");
85 });
86
87 it("CSV-injection guard prefixes leading tab", () => {
88 expect(csvCell("\tdata")).toBe("'\tdata");
89 });
90
91 it("does NOT prefix for benign leading chars", () => {
92 expect(csvCell("!bang")).toBe("!bang");
93 expect(csvCell("#hash")).toBe("#hash");
94 expect(csvCell("a=1")).toBe("a=1"); // equals not at position 0
95 expect(csvCell(" leading-space")).toBe(" leading-space");
96 });
97
98 it("handles Unicode without mangling", () => {
99 expect(csvCell("héllo 🚀")).toBe("héllo 🚀");
100 });
101
102 it("empty string stays empty", () => {
103 expect(csvCell("")).toBe("");
104 });
105
106 it("exposes the trigger set for documentation/testing", () => {
107 expect(CSV_INJECTION_CHARS.has("=")).toBe(true);
108 expect(CSV_INJECTION_CHARS.has("+")).toBe(true);
109 expect(CSV_INJECTION_CHARS.has("-")).toBe(true);
110 expect(CSV_INJECTION_CHARS.has("@")).toBe(true);
111 expect(CSV_INJECTION_CHARS.has("\t")).toBe(true);
112 expect(CSV_INJECTION_CHARS.has("\r")).toBe(true);
113 expect(CSV_INJECTION_CHARS.has("a")).toBe(false);
114 expect(CSV_INJECTION_CHARS.has("=")).toBe(true);
115 });
116});
117
118describe("audit-csv — csvRow", () => {
119 it("joins cells with comma and terminates with CRLF", () => {
120 expect(csvRow(["a", "b", "c"])).toBe("a,b,c\r\n");
121 });
122
123 it("empty array yields just the CRLF", () => {
124 expect(csvRow([])).toBe("\r\n");
125 });
126
127 it("nulls become empty cells", () => {
128 expect(csvRow([null, "x", undefined])).toBe(",x,\r\n");
129 });
130
131 it("quotes are applied per-cell", () => {
132 expect(csvRow(["a,b", 'c"d', "e"])).toBe('"a,b","c""d",e\r\n');
133 });
134});
135
136describe("audit-csv — csvDocument", () => {
137 it("concatenates rows into a full document", () => {
138 expect(
139 csvDocument([
140 ["a", "b"],
141 ["1", "2"],
142 ])
143 ).toBe("a,b\r\n1,2\r\n");
144 });
145
146 it("empty input yields empty string", () => {
147 expect(csvDocument([])).toBe("");
148 });
149});
150
151describe("audit-csv — formatAuditCsv", () => {
152 const sampleRow: AuditCsvRow = {
153 id: "aud_1",
154 action: "branch.rename",
155 targetType: "branch",
156 targetId: "refs/heads/old",
157 ip: "203.0.113.1",
158 userAgent: "Mozilla/5.0",
159 metadata: '{"from":"old","to":"new"}',
160 createdAt: new Date("2025-04-01T12:00:00.000Z"),
161 actor: "alice",
162 };
163
164 it("writes header row exactly once", () => {
165 const csv = formatAuditCsv([sampleRow]);
166 const lines = csv.split("\r\n");
167 expect(lines[0]).toBe(
168 "id,when,actor,action,targetType,targetId,ip,userAgent,metadata"
169 );
170 });
171
172 it("writes a single data row after the header", () => {
173 const csv = formatAuditCsv([sampleRow]);
174 const lines = csv.split("\r\n").filter(Boolean);
175 expect(lines).toHaveLength(2);
176 expect(lines[1]).toBe(
177 'aud_1,2025-04-01T12:00:00.000Z,alice,branch.rename,branch,refs/heads/old,203.0.113.1,Mozilla/5.0,"{""from"":""old"",""to"":""new""}"'
178 );
179 });
180
181 it("empty rows array still writes the header", () => {
182 const csv = formatAuditCsv([]);
183 expect(csv).toBe(
184 "id,when,actor,action,targetType,targetId,ip,userAgent,metadata\r\n"
185 );
186 });
187
188 it("nullable fields become empty cells", () => {
189 const row: AuditCsvRow = {
190 id: "x",
191 action: "login",
192 targetType: null,
193 targetId: null,
194 ip: null,
195 userAgent: null,
196 metadata: null,
197 createdAt: new Date("2025-01-01T00:00:00Z"),
198 actor: null,
199 };
200 const csv = formatAuditCsv([row]);
201 const dataLine = csv.split("\r\n")[1]!;
202 expect(dataLine).toBe("x,2025-01-01T00:00:00.000Z,,login,,,,,");
203 });
204
205 it("accepts string dates and normalises to ISO", () => {
206 const row: AuditCsvRow = {
207 ...sampleRow,
208 createdAt: "2025-04-01T12:00:00Z",
209 };
210 const csv = formatAuditCsv([row]);
211 expect(csv).toContain("2025-04-01T12:00:00.000Z");
212 });
213
214 it("keeps unparseable string createdAt verbatim", () => {
215 const row: AuditCsvRow = {
216 ...sampleRow,
217 createdAt: "not-a-date",
218 };
219 const csv = formatAuditCsv([row]);
220 // Appears as a plain cell, not quoted (no commas/quotes/newlines).
221 const lines = csv.split("\r\n").filter(Boolean);
222 expect(lines[1]).toContain(",not-a-date,");
223 });
224
225 it("CSV-injection-guards formula-like actor names", () => {
226 const row: AuditCsvRow = { ...sampleRow, actor: "=cmd|evil" };
227 const csv = formatAuditCsv([row]);
228 // actor cell needs both quoting (pipe is fine — but quote is needed because
229 // of the prefix + `|`? No — `|` isn't a trigger). Just prefix `'`.
230 // However since actor contains no `,"` newline, we only see the prefix.
231 expect(csv).toContain(",'=cmd|evil,");
232 });
233
234 it("exposes canonical column list", () => {
235 expect(AUDIT_CSV_COLUMNS).toEqual([
236 "id",
237 "when",
238 "actor",
239 "action",
240 "targetType",
241 "targetId",
242 "ip",
243 "userAgent",
244 "metadata",
245 ]);
246 });
247
248 it("each row has exactly 9 cells (commas outside quoted regions)", () => {
249 const tricky: AuditCsvRow = {
250 id: "aud_tricky",
251 action: "x",
252 targetType: "t",
253 targetId: "id",
254 ip: "1.1.1.1",
255 userAgent: "mx, browser",
256 metadata: '{"a":"b,c"}',
257 createdAt: new Date("2025-04-01T00:00:00Z"),
258 actor: "name,with,commas",
259 };
260 const csv = formatAuditCsv([tricky]);
261 const dataLine = csv.split("\r\n")[1]!;
262 // Strip out quoted regions then count commas — must be exactly 8.
263 const stripped = dataLine.replace(/"[^"]*"/g, "X");
264 expect(stripped.match(/,/g)?.length).toBe(8);
265 });
266});
267
268describe("audit-csv — auditCsvFilename", () => {
269 it("slug + ISO timestamp form", () => {
270 const fn = auditCsvFilename("personal", new Date("2025-04-01T12:00:00Z"));
271 expect(fn).toBe("audit-personal-2025-04-01T12-00-00-000Z.csv");
272 });
273
274 it("sanitises arbitrary scope strings", () => {
275 const fn = auditCsvFilename(
276 "alice/repo?bad",
277 new Date("2025-04-01T00:00:00Z")
278 );
279 expect(fn).toMatch(/^audit-alice-repo-bad-2025-04-01T00-00-00-000Z\.csv$/);
280 });
281
282 it("falls back to 'audit' on empty or all-bad scope", () => {
283 const fn = auditCsvFilename("///", new Date("2025-04-01T00:00:00Z"));
284 expect(fn).toBe("audit-audit-2025-04-01T00-00-00-000Z.csv");
285 });
286
287 it("uses current time when `now` omitted", () => {
288 const fn = auditCsvFilename("personal");
289 expect(fn).toMatch(
290 /^audit-personal-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.csv$/
291 );
292 });
293});
294
295describe("audit-csv — routes", () => {
296 it("GET /settings/audit.csv returns 302 when unauthenticated", async () => {
297 const { default: app } = await import("../app");
298 const res = await app.request("/settings/audit.csv");
299 // requireAuth redirects to /login for unauthenticated web requests.
300 expect([302, 401, 403]).toContain(res.status);
301 });
302
303 it("GET /:owner/:repo/settings/audit.csv is guarded (never 500)", async () => {
304 const { default: app } = await import("../app");
305 const res = await app.request("/alice/repo/settings/audit.csv");
306 expect([302, 401, 403, 404]).toContain(res.status);
307 });
308});
309
310describe("audit-csv — __internal parity", () => {
311 it("re-exports helpers", () => {
312 expect(__internal.csvCell).toBe(csvCell);
313 expect(__internal.csvRow).toBe(csvRow);
314 expect(__internal.csvDocument).toBe(csvDocument);
315 expect(__internal.formatAuditCsv).toBe(formatAuditCsv);
316 expect(__internal.auditCsvFilename).toBe(auditCsvFilename);
317 expect(__internal.AUDIT_CSV_COLUMNS).toBe(AUDIT_CSV_COLUMNS);
318 expect(__internal.CSV_INJECTION_CHARS).toBe(CSV_INJECTION_CHARS);
319 expect(typeof __internal.normaliseCreated).toBe("function");
320 });
321});