1
10import { describe, it, expect, vi, beforeEach } from "vitest";
11import { AUDIT_CSV_HEADER } from "@factory/shared/events/audit-csv";
12import { runAuditExport, ASYNC_ROW_CAP } from "../processors/audit-export.js";
13
14const putCalls = vi.hoisted(
15 () => [] as Array<{ key: string; body: Buffer; type: string }>
16);
17
18vi.mock("../lib/s3.js", () => ({
19 S3Client: class {
20 putObject = vi.fn(async (key: string, body: Buffer, type: string) => {
21 putCalls.push({ key, body, type });
22 });
23 }
24}));
25
26vi.mock("../config.js", () => ({
27 loadEnv: () => ({
28 S3_ENDPOINT: "http://localhost:9000",
29 S3_REGION: "us-east-1",
30 S3_BUCKET_FILES: "files",
31 S3_BUCKET: "files",
32 S3_ACCESS_KEY: "test",
33 S3_SECRET_KEY: "test"
34 })
35}));
36
37interface FakeRow {
38 id: string;
39 createdAt: Date;
40 actorType: string;
41 actorId: string;
42 workspaceId: string | null;
43 resourceType: string;
44 resourceId: string;
45 action: string;
46 ipAddress: string | null;
47 userAgent: string | null;
48 metadata: unknown;
49}
50
51function makeRows(n: number, wsId: string): FakeRow[] {
52 const base = 1_700_000_000_000;
53 return Array.from({ length: n }, (_, i) => ({
54
55 id: `row${String(i).padStart(7, "0")}`,
56
57 createdAt: new Date(base - i * 1_000),
58 actorType: "USER",
59 actorId: `u${i % 3}`,
60 workspaceId: wsId,
61 resourceType: "page",
62 resourceId: "page1",
63 action: "test.action",
64 ipAddress: "1.2.3.4",
65 userAgent: "ua",
66 metadata: { n: i }
67 }));
68}
69
70function makePrisma(allRows: FakeRow[], wsId: string) {
71 const update = vi.fn(async () => ({}));
72 const logCreate = vi.fn(async () => ({}));
73
74 const wsFindMany = vi.fn(async (a: { where: { organizationId?: string; id?: { in: string[] } } }) =>
75 a.where.organizationId
76 ? [{ id: wsId }]
77 : a.where.id!.in.map((id) => ({ id, name: `WS ${id}` }))
78 );
79 const userFindMany = vi.fn(async (a: { where: { id: { in: string[] } } }) =>
80 a.where.id.in.map((id) => ({ id, name: `User ${id}` }))
81 );
82 const auditLogFindMany = vi.fn(
83 async (args: { where: { AND: [unknown, Record<string, unknown> | undefined] }; take?: number }) => {
84 let rows = [...allRows];
85 const cur = args.where.AND[1];
86 if (cur && "OR" in cur && cur.OR) {
87 const ors = cur.OR as Array<Record<string, unknown>>;
88 const before = (ors[0]!.createdAt as { lt: Date }).lt;
89 const sameTs = ors[1]!.createdAt as Date;
90 const idBefore = (ors[1]!.id as { lt: string }).lt;
91 rows = rows.filter(
92 (r) => r.createdAt < before || (r.createdAt.getTime() === sameTs.getTime() && r.id < idBefore)
93 );
94 }
95 rows.sort(
96 (x, y) => y.createdAt.getTime() - x.createdAt.getTime() || (x.id < y.id ? 1 : -1)
97 );
98 return rows.slice(0, args.take ?? 10);
99 }
100 );
101 return {
102 auditExport: {
103 findUnique: vi.fn(async () => ({ id: "exp1", organizationId: "org1", filters: null })),
104 update
105 },
106 workspace: { findMany: wsFindMany },
107 user: { findMany: userFindMany },
108 auditLog: { findMany: auditLogFindMany, create: logCreate }
109 };
110}
111
112const csvLines = (body: Buffer) => body.toString("utf8").split("\n");
113
114beforeEach(() => {
115 putCalls.length = 0;
116});
117
118describe("runAuditExport (unit — mocked prisma + S3)", () => {
119 it("paginates with the keyset cursor across batches (no truncation under the cap)", async () => {
120 const wsId = "ws1";
121 const all = makeRows(5_005, wsId);
122 const prisma = makePrisma(all, wsId);
123 const res = await runAuditExport(prisma as never, {} as never, "org1", "exp1");
124
125 expect(res).toMatchObject({ exportId: "exp1", status: "ready", rows: 5_005, truncated: false });
126 expect(res.key).toBe("exports/org1/exp1.csv");
127
128
129 const findMany = prisma.auditLog.findMany as ReturnType<typeof vi.fn>;
130 expect(findMany).toHaveBeenCalledTimes(2);
131 const secondCursor = (findMany.mock.calls[1]?.[0] as { where: { AND: [unknown, { OR: unknown[] }] } })
132 .where.AND[1]!;
133 expect(secondCursor.OR).toHaveLength(2);
134
135 expect((secondCursor.OR[1] as { id: { lt: string } }).id.lt).toBe(all[5_000 - 1]!.id);
136
137
138 expect(putCalls).toHaveLength(1);
139 expect(putCalls[0]!.key).toBe("exports/org1/exp1.csv");
140 expect(putCalls[0]!.type).toBe("text/csv; charset=utf-8");
141 const lines = csvLines(putCalls[0]!.body);
142 expect(lines[0]).toBe(AUDIT_CSV_HEADER);
143 expect(lines.length).toBe(5_005 + 1 + 1);
144
145 expect(lines[1]).toContain("User u0");
146
147
148 const update = prisma.auditExport.update as ReturnType<typeof vi.fn>;
149 expect(update.mock.calls[0]?.[0].data).toMatchObject({ status: "ready", rows: 5_005, truncated: false });
150
151 const create = prisma.auditLog.create as ReturnType<typeof vi.fn>;
152 const auditCall = create.mock.calls[0]?.[0].data as { action: string; metadata: { rows: number; truncated: boolean; key: string } };
153 expect(auditCall.action).toBe("audit.export.completed");
154 expect(auditCall.metadata).toMatchObject({ rows: 5_005, truncated: false, key: "exports/org1/exp1.csv" });
155 });
156
157 it("truncates at the row cap (injectable): stops paginating, flags truncated", async () => {
158 const wsId = "ws1";
159 const all = makeRows(7, wsId);
160 const prisma = makePrisma(all, wsId);
161 const res = await runAuditExport(prisma as never, {} as never, "org1", "exp1", { rowCap: 3 });
162
163 expect(res).toMatchObject({ status: "ready", rows: 3, truncated: true });
164
165 const findMany = prisma.auditLog.findMany as ReturnType<typeof vi.fn>;
166 expect(findMany).toHaveBeenCalledTimes(1);
167
168 const lines = csvLines(putCalls[0]!.body);
169 expect(lines[0]).toBe(AUDIT_CSV_HEADER);
170 expect(lines.length).toBe(3 + 1 + 1);
171 const update = prisma.auditExport.update as ReturnType<typeof vi.fn>;
172 expect(update.mock.calls[0]?.[0].data).toMatchObject({ status: "ready", rows: 3, truncated: true });
173 });
174
175 it("fails closed on a cross-org export row (no S3 write, failed audit)", async () => {
176 const wsId = "ws1";
177 const prisma = makePrisma(makeRows(2, wsId), wsId);
178 (prisma.auditExport.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue({
179 id: "exp1",
180 organizationId: "other-org",
181 filters: null
182 });
183
184 await expect(runAuditExport(prisma as never, {} as never, "org1", "exp1")).rejects.toThrow(
185 "export row not found (or cross-org)"
186 );
187 expect(putCalls).toHaveLength(0);
188 const update = prisma.auditExport.update as ReturnType<typeof vi.fn>;
189 expect(update.mock.calls[0]?.[0].data).toMatchObject({ status: "failed" });
190 const create = prisma.auditLog.create as ReturnType<typeof vi.fn>;
191 expect((create.mock.calls[0]?.[0].data as { action: string }).action).toBe("audit.export.failed");
192 });
193
194 it("keeps the 500k production cap as the default", () => {
195 expect(ASYNC_ROW_CAP).toBe(500_000);
196 });
197});
198