/** * The async audit-export core with a MOCKED Prisma + S3 (no MinIO for a * unit — the object round-trip is covered by the S3-backed integration * spec). Asserts the SEMANTICS: the cursor/keyset pagination across * batches, the row-cap truncation (small injectable cap — seeding 500k rows * is not a realistic unit fixture), the 13-column CSV shape, the DB row * flip to `ready`/`failed`, the completion audit row, and the exact object * key the API's single-use presign later signs. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { AUDIT_CSV_HEADER } from "@factory/shared/events/audit-csv"; import { runAuditExport, ASYNC_ROW_CAP } from "../processors/audit-export.js"; const putCalls = vi.hoisted( () => [] as Array<{ key: string; body: Buffer; type: string }> ); vi.mock("../lib/s3.js", () => ({ S3Client: class { putObject = vi.fn(async (key: string, body: Buffer, type: string) => { putCalls.push({ key, body, type }); }); } })); vi.mock("../config.js", () => ({ loadEnv: () => ({ S3_ENDPOINT: "http://localhost:9000", S3_REGION: "us-east-1", S3_BUCKET_FILES: "files", S3_BUCKET: "files", S3_ACCESS_KEY: "test", S3_SECRET_KEY: "test" }) })); interface FakeRow { id: string; createdAt: Date; actorType: string; actorId: string; workspaceId: string | null; resourceType: string; resourceId: string; action: string; ipAddress: string | null; userAgent: string | null; metadata: unknown; } function makeRows(n: number, wsId: string): FakeRow[] { const base = 1_700_000_000_000; return Array.from({ length: n }, (_, i) => ({ // Lexicographic ids (the keyset tie-break orders by id). id: `row${String(i).padStart(7, "0")}`, // i=0 is the NEWEST (the cursor walks createdAt DESC, id DESC). createdAt: new Date(base - i * 1_000), actorType: "USER", actorId: `u${i % 3}`, workspaceId: wsId, resourceType: "page", resourceId: "page1", action: "test.action", ipAddress: "1.2.3.4", userAgent: "ua", metadata: { n: i } })); } function makePrisma(allRows: FakeRow[], wsId: string) { const update = vi.fn(async () => ({})); const logCreate = vi.fn(async () => ({})); // Two shapes: org workspaces (by organizationId) + name lookups (by id in). const wsFindMany = vi.fn(async (a: { where: { organizationId?: string; id?: { in: string[] } } }) => a.where.organizationId ? [{ id: wsId }] : a.where.id!.in.map((id) => ({ id, name: `WS ${id}` })) ); const userFindMany = vi.fn(async (a: { where: { id: { in: string[] } } }) => a.where.id.in.map((id) => ({ id, name: `User ${id}` })) ); const auditLogFindMany = vi.fn( async (args: { where: { AND: [unknown, Record | undefined] }; take?: number }) => { let rows = [...allRows]; const cur = args.where.AND[1]; if (cur && "OR" in cur && cur.OR) { const ors = cur.OR as Array>; const before = (ors[0]!.createdAt as { lt: Date }).lt; const sameTs = ors[1]!.createdAt as Date; const idBefore = (ors[1]!.id as { lt: string }).lt; rows = rows.filter( (r) => r.createdAt < before || (r.createdAt.getTime() === sameTs.getTime() && r.id < idBefore) ); } rows.sort( (x, y) => y.createdAt.getTime() - x.createdAt.getTime() || (x.id < y.id ? 1 : -1) ); return rows.slice(0, args.take ?? 10); } ); return { auditExport: { findUnique: vi.fn(async () => ({ id: "exp1", organizationId: "org1", filters: null })), update }, workspace: { findMany: wsFindMany }, user: { findMany: userFindMany }, auditLog: { findMany: auditLogFindMany, create: logCreate } }; } const csvLines = (body: Buffer) => body.toString("utf8").split("\n"); beforeEach(() => { putCalls.length = 0; }); describe("runAuditExport (unit — mocked prisma + S3)", () => { it("paginates with the keyset cursor across batches (no truncation under the cap)", async () => { const wsId = "ws1"; const all = makeRows(5_005, wsId); // BATCH=5000 → 2 batches const prisma = makePrisma(all, wsId); const res = await runAuditExport(prisma as never, {} as never, "org1", "exp1"); expect(res).toMatchObject({ exportId: "exp1", status: "ready", rows: 5_005, truncated: false }); expect(res.key).toBe("exports/org1/exp1.csv"); // Two batches (5000 + 5); the second query is anchored after the first // batch's last row (the keyset cursor). const findMany = prisma.auditLog.findMany as ReturnType; expect(findMany).toHaveBeenCalledTimes(2); const secondCursor = (findMany.mock.calls[1]?.[0] as { where: { AND: [unknown, { OR: unknown[] }] } }) .where.AND[1]!; expect(secondCursor.OR).toHaveLength(2); // The cursor's id anchor is the first batch's last (oldest) row. expect((secondCursor.OR[1] as { id: { lt: string } }).id.lt).toBe(all[5_000 - 1]!.id); // The CSV: header + 5005 rows, no embedded newlines (metadata is JSON). expect(putCalls).toHaveLength(1); expect(putCalls[0]!.key).toBe("exports/org1/exp1.csv"); expect(putCalls[0]!.type).toBe("text/csv; charset=utf-8"); const lines = csvLines(putCalls[0]!.body); expect(lines[0]).toBe(AUDIT_CSV_HEADER); expect(lines.length).toBe(5_005 + 1 + 1); // + trailing empty element // USER actor names are resolved from the bulk user lookup. expect(lines[1]).toContain("User u0"); // The DB row flips to ready with the exact counts. const update = prisma.auditExport.update as ReturnType; expect(update.mock.calls[0]?.[0].data).toMatchObject({ status: "ready", rows: 5_005, truncated: false }); // The completion audit row carries the same facts. const create = prisma.auditLog.create as ReturnType; const auditCall = create.mock.calls[0]?.[0].data as { action: string; metadata: { rows: number; truncated: boolean; key: string } }; expect(auditCall.action).toBe("audit.export.completed"); expect(auditCall.metadata).toMatchObject({ rows: 5_005, truncated: false, key: "exports/org1/exp1.csv" }); }); it("truncates at the row cap (injectable): stops paginating, flags truncated", async () => { const wsId = "ws1"; const all = makeRows(7, wsId); const prisma = makePrisma(all, wsId); const res = await runAuditExport(prisma as never, {} as never, "org1", "exp1", { rowCap: 3 }); expect(res).toMatchObject({ status: "ready", rows: 3, truncated: true }); // Stopped after the FIRST batch (the cap is hit mid-batch). const findMany = prisma.auditLog.findMany as ReturnType; expect(findMany).toHaveBeenCalledTimes(1); // The CSV holds exactly cap rows. const lines = csvLines(putCalls[0]!.body); expect(lines[0]).toBe(AUDIT_CSV_HEADER); expect(lines.length).toBe(3 + 1 + 1); const update = prisma.auditExport.update as ReturnType; expect(update.mock.calls[0]?.[0].data).toMatchObject({ status: "ready", rows: 3, truncated: true }); }); it("fails closed on a cross-org export row (no S3 write, failed audit)", async () => { const wsId = "ws1"; const prisma = makePrisma(makeRows(2, wsId), wsId); (prisma.auditExport.findUnique as ReturnType).mockResolvedValue({ id: "exp1", organizationId: "other-org", filters: null }); await expect(runAuditExport(prisma as never, {} as never, "org1", "exp1")).rejects.toThrow( "export row not found (or cross-org)" ); expect(putCalls).toHaveLength(0); // nothing was uploaded const update = prisma.auditExport.update as ReturnType; expect(update.mock.calls[0]?.[0].data).toMatchObject({ status: "failed" }); const create = prisma.auditLog.create as ReturnType; expect((create.mock.calls[0]?.[0].data as { action: string }).action).toBe("audit.export.failed"); }); it("keeps the 500k production cap as the default", () => { expect(ASYNC_ROW_CAP).toBe(500_000); }); });