1
35import { afterAll, beforeAll, describe, expect, it } from "vitest";
36import { createHash, randomBytes } from "node:crypto";
37import { PrismaClient } from "@prisma/client";
38import { Redis } from "ioredis";
39import { getRedisUrl } from "./helpers/containers";
40import { createTestApp, type TestAppHandle } from "./helpers/api";
41
42
43
44import { AUDIT_CATALOG } from "@factory/shared";
45
46
47
48
49const { handleRunAutomation } = await import(
50 "../../apps/worker/src/processors/automations/pipeline.ts"
51);
52const { registerTestExecutor, clearTestExecutors } = await import(
53 "../../apps/worker/src/processors/automations/executors/registry.ts"
54);
55
56
57
58const { SsoProvisioningService } = await import(
59 "../../apps/api/src/modules/sso/provisioning.service.ts"
60);
61const { AiBudgetGuardService } = await import(
62 "../../apps/api/src/modules/ai/budget-guard.service.ts"
63);
64
65
66const S3_ENDPOINT = (process.env.S3_ENDPOINT ?? "http://localhost:9000").replace(/\/+$/, "");
67async function minioUp(url: string): Promise<boolean> {
68 try {
69 const res = await fetch(`${url}/minio/health/live`, { signal: AbortSignal.timeout(4_000) });
70 return res.ok;
71 } catch {
72 return false;
73 }
74}
75const minioReady = await minioUp(S3_ENDPOINT);
76
77
78let app: TestAppHandle;
79let prisma: PrismaClient;
80let workerRedis: Redis | undefined;
81const auth = (token: string) => ({ Authorization: `Bearer ${token}`, "Content-Type": "application/json" });
82
83interface Seed {
84 email: string;
85 name: string;
86 token: string;
87 userId: string;
88 orgId: string;
89 orgSlug: string;
90 wsId: string;
91 dbId: string;
92}
93const S = { seed: {} as Seed };
94const fired = new Set<string>();
95
96
97
98const skipped = new Set<string>();
99const skipReasons: Record<string, string> = {};
100
101const slug = () => `ac-${randomBytes(5).toString("hex")}`;
102const uid = () => randomBytes(6).toString("hex");
103
104
109async function read(res: Response | Promise<Response>): Promise<{ status: number; raw: string; data: unknown }> {
110 const r = await res;
111 const raw = await r.text();
112 let data: unknown = {};
113 try {
114 data = JSON.parse(raw);
115 } catch {
116 data = raw;
117 }
118 return { status: r.status, raw, data };
119}
120
121async function createAppUser(name: string): Promise<{ email: string; name: string; token: string; userId: string }> {
122 const email = `${slug()}@ac.com`;
123 const pw = "pw-123456789";
124 const reg = await read(app.request("POST", "/api/v1/auth/register", { body: { email, name, password: pw } }));
125 expect(reg.status, reg.raw).toBe(201);
126 const login = await read(app.request("POST", "/api/v1/auth/login", { body: { email, password: pw } }));
127 const lb = login.data as { accessToken: string; user: { id: string } };
128 expect(login.status, login.raw).toBe(200);
129 return { email, name, token: lb.accessToken, userId: lb.user.id };
130}
131
132
138async function latest(action: string): Promise<{ metadata: unknown } | null> {
139 for (let i = 0; i < 20; i++) {
140 const row = await prisma.auditLog.findFirst({ where: { action }, orderBy: { createdAt: "desc" } });
141 if (row) return row;
142 if (i === 19) return null;
143 await new Promise((r) => setTimeout(r, 50));
144 }
145 return null;
146}
147
148
153function assertFired(action: string, required: string[]): void {
154 const row = firedRows.get(action);
155 if (!row) throw new Error(`audit completeness: no ${action} row`);
156 const meta = (row.metadata ?? {}) as Record<string, unknown>;
157 for (const f of required) {
158 if (!(f in meta)) throw new Error(`audit completeness: ${action} missing required metadata.${f} (got ${JSON.stringify(Object.keys(meta))})`);
159 }
160 fired.add(action);
161}
162const firedRows = new Map<string, { metadata: unknown }>();
163async function expectRow(action: string, required: string[]): Promise<void> {
164 const row = await latest(action);
165 if (row) firedRows.set(action, row);
166 assertFired(action, required);
167}
168
169
170async function fire(id: string): Promise<void> {
171 const { seed } = S;
172 const h = auth(seed.token);
173
174 switch (id) {
175 case "login": {
176
177
178
179 await createAppUser("Completeness Login");
180 return;
181 }
182
183 case "page.create": {
184 const p = await read(app.request("POST", `/api/v1/workspaces/${seed.wsId}/pages`, { headers: h, body: { title: "Completeness page" } }));
185 expect(p.status, p.raw).toBe(201);
186 return;
187 }
188 case "page.update": {
189 const p = await read(app.request("POST", `/api/v1/workspaces/${seed.wsId}/pages`, { headers: h, body: { title: "to update" } }));
190 const pageId = (p.data as { id?: string }).id;
191 const up = await read(app.request("PATCH", `/api/v1/pages/${pageId}`, { headers: h, body: { title: "renamed" } }));
192 expect([200, 201].includes(up.status), up.raw).toBe(true);
193 return;
194 }
195 case "page.trash": {
196 const p = await read(app.request("POST", `/api/v1/workspaces/${seed.wsId}/pages`, { headers: h, body: { title: "to trash" } }));
197 const pageId = (p.data as { id?: string }).id;
198 const del = await read(app.request("DELETE", `/api/v1/pages/${pageId}`, { headers: h }));
199 expect(del.status, del.raw).toBe(200);
200 return;
201 }
202 case "share.create": {
203 const p = await read(app.request("POST", `/api/v1/workspaces/${seed.wsId}/pages`, { headers: h, body: { title: "to share" } }));
204 const pageId = (p.data as { id?: string }).id;
205 const s = await read(app.request("POST", "/api/v1/shares", { headers: h, body: { workspaceId: seed.wsId, targetType: "page", targetId: pageId, kind: "link", level: "VIEW" } }));
206 expect([200, 201].includes(s.status), s.raw).toBe(true);
207 return;
208 }
209 case "permission.change": {
210
211 const member = await createAppUser("Perm Member");
212 await app.request("POST", `/internal/workspaces/${seed.wsId}/members`, {
213 headers: { Authorization: `Bearer ${app.internalServiceToken}`, "Content-Type": "application/json" },
214 body: { email: member.email, role: "MEMBER" }
215 });
216 const patch = await read(app.request("PATCH", `/api/v1/workspaces/${seed.wsId}/members/${member.userId}`, { headers: h, body: { role: "ADMIN" } }));
217 expect(patch.status, patch.raw).toBe(200);
218 return;
219 }
220 case "database.create": {
221
222
223 const db = await read(app.request("POST", `/api/v1/workspaces/${seed.wsId}/pages`, { headers: h, body: { title: "Completeness DB 2", type: "database" } }));
224 expect([200, 201].includes(db.status), db.raw).toBe(true);
225 return;
226 }
227 case "record.create": {
228
229
230 const r = await read(app.request("POST", `/api/v1/databases/${seed.dbId}/records`, { headers: h, body: { title: "r-create" } }));
231 expect([200, 201].includes(r.status), r.raw).toBe(true);
232 return;
233 }
234 case "record.update": {
235 const r = await read(app.request("POST", `/api/v1/databases/${seed.dbId}/records`, { headers: h, body: { title: "r-update" } }));
236 const rec = (r.data as { id: string }).id;
237 const up = await read(app.request("PATCH", `/api/v1/records/${rec}`, { headers: h, body: { title: "r-updated" } }));
238 expect(up.status, up.raw).toBe(200);
239 return;
240 }
241 case "file.upload": {
242 if (!minioReady) {
243 skipped.add("file.uploaded");
244 skipReasons["file.uploaded"] = "MinIO unavailable";
245 return;
246 }
247 const bytes = "completeness-bytes".repeat(32);
248 const presign = await read(app.request("POST", "/api/v1/files/uploads", {
249 headers: { ...h, "x-factory-workspace": seed.wsId },
250 body: { name: "completeness.txt", mimeType: "text/plain", size: Buffer.byteLength(bytes) }
251 }));
252 expect(presign.status, presign.raw).toBe(201);
253 const upload = presign.data as { uploadUrl: string; headers: Record<string, string> };
254 const put = await fetch(upload.uploadUrl, { method: "PUT", headers: upload.headers, body: bytes });
255 expect(put.status, await put.text()).toBe(200);
256 return;
257 }
258 case "api_key.create": {
259 const k = await read(app.request("POST", `/api/v1/workspaces/${seed.wsId}/api-keys`, { headers: h, body: { name: "completeness-key", scopes: ["READ"] } }));
260 expect([200, 201].includes(k.status), k.raw).toBe(true);
261 return;
262 }
263 case "webhook.create": {
264 const w = await read(app.request("POST", `/api/v1/workspaces/${seed.wsId}/webhooks`, { headers: h, body: { url: "https://hooks.example.com/completeness", events: ["page.created"] } }));
265 expect([200, 201].includes(w.status), w.raw).toBe(true);
266 return;
267 }
268 case "webhook.revoke": {
269 const w = await read(app.request("POST", `/api/v1/workspaces/${seed.wsId}/webhooks`, { headers: h, body: { url: "https://hooks.example.com/completeness-revoke" } }));
270 const whId = (w.data as { id?: string }).id;
271 const del = await read(app.request("DELETE", `/api/v1/workspaces/${seed.wsId}/webhooks/${whId}`, { headers: h }));
272 expect(del.status, del.raw).toBe(200);
273 return;
274 }
275 case "scim.create": {
276 await scimCreate("comp-scim-create");
277 return;
278 }
279 case "scim.deactivate": {
280 await scimDeactivate();
281 return;
282 }
283 case "ai.budget": {
284 const guard = app.get?.(AiBudgetGuardService) as unknown as { onBudgetExceeded: (ws: string, used: number, limit: number, req?: unknown) => Promise<void> };
285 if (!guard) throw new Error("AiBudgetGuardService not resolvable from the app module");
286 await guard.onBudgetExceeded(seed.wsId, 100, 100, undefined);
287 return;
288 }
289 case "automation.run": {
290 await fireAutomationRun();
291 return;
292 }
293 case "sso.saml":
294 case "sso.oidc": {
295 const protocol = id === "sso.saml" ? "SAML" : "OIDC";
296 await fireSsoLogin(protocol);
297 return;
298 }
299 case "oauth.token":
300 case "oauth.revoke": {
301 await fireOAuth(id);
302 return;
303 }
304 default:
305 throw new Error(`audit completeness: unknown fire id "${id}" (catalog drift — update the dispatch table)`);
306 }
307}
308
309
310let scimToken: string;
311async function enableScim(): Promise<string> {
312
313
314 const r = await read(app.request("POST", `/api/v1/orgs/${S.seed.orgId}/scim`, {
315 headers: auth(S.seed.token),
316 body: { userEmailDomain: "scim-completeness.test" }
317 }));
318 if (r.status !== 201 && r.status !== 200) throw new Error(`enable scim failed (${r.status}): ${r.raw}`);
319 scimToken = (r.data as { token?: string }).token as string;
320 return scimToken;
321}
322const scimHeaders = (token: string) => ({ Authorization: `Bearer ${token}`, "Content-Type": "application/scim+json" });
323async function scimCreate(userBase: string): Promise<unknown> {
324 const res = await app.request("POST", "/scim/v2/Users", {
325 headers: scimHeaders(scimToken),
326 body: { schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], userName: `${userBase}.${uid()}`, active: true }
327 });
328 const body = (await res.json()) as unknown;
329 expect(res.status, JSON.stringify(body)).toBe(201);
330 return body;
331}
332async function scimDeactivate(): Promise<void> {
333
334 const created = (await scimCreate("comp-scim-deact")) as { id: string };
335 const del = await read(app.request("DELETE", `/scim/v2/Users/${created.id}`, { headers: scimHeaders(scimToken) }));
336
337 expect(del.status, del.raw).toBe(200);
338}
339
340
341async function fireSsoLogin(protocol: "SAML" | "OIDC"): Promise<void> {
342 const svc = app.get?.(SsoProvisioningService) as unknown as { provision: (i: unknown) => Promise<unknown> };
343 if (!svc) throw new Error("SsoProvisioningService not resolvable from the app module");
344 const origin = `https://idp-${uid()}.example.test`;
345 const config =
346 protocol === "SAML"
347 ? { entityId: `${origin}/entity`, ssoUrl: `${origin}/sso/saml`, acsUrl: `${app.baseUrl}/saml/acs` }
348 : { issuer: origin, clientId: "completeness-client", authorizationEndpoint: `${origin}/authorize`, tokenEndpoint: `${origin}/token`, jwksUri: `${origin}/jwks`, redirectUri: `${app.baseUrl}/sso/oidc/${S.seed.orgId}/callback`, scope: "openid email profile" };
349
350
351
352 await prisma.ssoProvider.deleteMany({ where: { organizationId: S.seed.orgId } });
353 const provider = await prisma.ssoProvider.create({
354 data: { organizationId: S.seed.orgId, type: protocol as never, name: `${protocol} Completeness`, config: config as never, enforce: false }
355 });
356 await svc.provision({
357 req: { ip: "127.0.0.1", headers: { "user-agent": "completeness-test" } },
358
359
360 res: { setHeader: () => undefined },
361 organizationId: S.seed.orgId,
362 provider: { id: provider.id, type: protocol, name: provider.name, config: config as Record<string, unknown> },
363 externalId: `ext-${uid()}`,
364 email: `sso-${protocol.toLowerCase()}-${uid()}@ac.com`,
365 displayName: "SSO Completeness User"
366 });
367}
368
369
370const s256 = (v: string) => createHash("sha256").update(v).digest("base64url");
371
372async function fireOAuth(which: "oauth.token" | "oauth.revoke"): Promise<void> {
373 const seed = S.seed;
374 const redirectUri = `http://localhost:9999/${uid()}.cb`;
375 const appRes = await app.request("POST", `/api/v1/workspaces/${seed.wsId}/oauth/apps`, {
376 headers: auth(seed.token),
377 body: { name: `Completeness App ${uid()}`, scopes: ["pages:read"], redirectUris: [redirectUri] }
378 });
379 const reg = (await appRes.json()) as { clientId: string; clientSecret?: string };
380 expect(appRes.status, JSON.stringify(reg)).toBe(201);
381
382 const verifier = randomBytes(32).toString("base64url");
383 const challenge = s256(verifier);
384 const params = new URLSearchParams({
385 response_type: "code",
386 client_id: reg.clientId,
387 redirect_uri: redirectUri,
388 scope: "pages:read",
389 state: "cs",
390 code_challenge: challenge,
391 code_challenge_method: "S256"
392 });
393 const consent = await app.request("GET", `/api/v1/oauth/authorize?${params.toString()}`, { headers: auth(seed.token), redirect: "manual" });
394 const html = await consent.text();
395 expect(consent.status, html.slice(0, 200)).toBe(200);
396 const consentToken = html.match(/name="consent_token" value="([^"]+)"/)?.[1];
397 expect(consentToken, "consent page must carry consent_token").toBeTruthy();
398 const form = new URLSearchParams({
399 client_id: reg.clientId,
400 redirect_uri: redirectUri,
401 scope: "pages:read",
402 state: "cs",
403 decision: "allow",
404 consent_token: consentToken!,
405 code_challenge: challenge,
406 code_challenge_method: "S256"
407 });
408 const approve = await fetch(`${app.baseUrl}/api/v1/oauth/authorize`, {
409 method: "POST",
410 headers: { ...auth(seed.token), "Content-Type": "application/x-www-form-urlencoded" },
411 body: form.toString(),
412 redirect: "manual"
413 });
414 expect(approve.status, "authorize must 302 to the redirect with a code").toBe(302);
415 const code = new URL(approve.headers.get("location")!).searchParams.get("code");
416 expect(code, "authorize must return a code").toMatch(/^oc_/);
417
418
419
420 const tok = await app.request("POST", "/api/v1/oauth/token", {
421 headers: { "Content-Type": "application/json" },
422 body: {
423 grant_type: "authorization_code",
424 code: code!,
425 code_verifier: verifier,
426 redirect_uri: redirectUri,
427 client_id: reg.clientId,
428 client_secret: reg.clientSecret
429 }
430 });
431 const t = (await tok.json()) as { access_token?: string; refresh_token?: string };
432 expect(tok.status, JSON.stringify(t)).toBe(200);
433 expect(t.access_token, "token endpoint must return an access token").toBeTruthy();
434
435 if (which === "oauth.revoke") {
436
437
438 await app.request("POST", "/api/v1/oauth/revoke", {
439 headers: { "Content-Type": "application/json" },
440 body: { token: t.refresh_token ?? t.access_token, token_type_hint: "refresh_token" }
441 });
442 }
443}
444
445
446async function fireAutomationRun(): Promise<void> {
447 const seed = S.seed;
448 const db = await prisma.database.findUnique({ where: { id: seed.dbId } });
449 if (!db) throw new Error("seed database missing");
450 const id = `auto_ac_${uid()}`;
451 const runId = `run_ac_${uid()}`;
452 await prisma.automation.create({
453 data: {
454 id,
455 workspaceId: seed.wsId,
456 name: "Completeness Automation",
457 status: "ACTIVE",
458 databaseId: seed.dbId,
459 createdById: seed.userId,
460 triggers: { create: [{ type: "RECORD_CREATED", config: { databaseId: seed.dbId }, position: 0 }] },
461
462
463
464
465 actions: { create: [{ type: "CREATE_PAGE", config: { title: "Completeness auto page" }, position: 0 }] }
466 }
467 });
468 await prisma.automationRun.create({
469 data: { id: runId, workspaceId: seed.wsId, automationId: id, triggerEvent: { recordId: "seed" } as never, status: "PENDING" }
470 });
471 const deps = { prisma, redis: workerRedis!, env: { AUTO_RUN_MAX_RETRIES: 2 } as never };
472 const job = { runId, automationId: id, workspaceId: seed.wsId, triggerEvent: { recordId: "seed" }, eventId: `evt_${uid()}` };
473
474
475
476 registerTestExecutor("CREATE_PAGE", {
477 type: "CREATE_PAGE",
478 execute: async () => ({ output: { ok: true }, emittedEvents: [], audit: null })
479 });
480 const status = await handleRunAutomation(deps as never, { data: job });
481 clearTestExecutors();
482 expect(status).toBe("SUCCEEDED");
483}
484
485
486beforeAll(async () => {
487 app = await createTestApp();
488 prisma = app.prisma;
489 try {
490 const redisUrl = await getRedisUrl();
491 workerRedis = new Redis(redisUrl);
492 } catch {
493 workerRedis = undefined;
494 }
495
496
497 const owner = await createAppUser("Completeness Owner");
498 const org = await read(app.request("POST", "/api/v1/orgs", { headers: auth(owner.token), body: { name: `Completeness Org ${uid()}` } }));
499 expect(org.status, org.raw).toBe(201);
500 const ws = await read(app.request("POST", "/api/v1/workspaces", { headers: auth(owner.token), body: { name: `Completeness WS ${uid()}` } }));
501 expect(ws.status, ws.raw).toBe(201);
502 const db = await read(app.request("POST", `/api/v1/workspaces/${(ws.data as { id: string }).id}/pages`, { headers: auth(owner.token), body: { title: "Completeness DB", type: "database" } }));
503 expect([200, 201].includes(db.status), db.raw).toBe(true);
504
505 S.seed = {
506 email: owner.email,
507 name: owner.name,
508 token: owner.token,
509 userId: owner.userId,
510 orgId: (org.data as { id: string }).id,
511 orgSlug: (org.data as { id: string; slug: string }).slug,
512 wsId: (ws.data as { id: string }).id,
513 dbId: (db.data as { databaseId: string }).databaseId
514 };
515
516
517 scimToken = await enableScim();
518}, 180_000);
519
520afterAll(async () => {
521 await workerRedis?.quit().catch(() => {});
522 await app?.close();
523});
524
525describe("TASK-0133 mandatory audit-event completeness", () => {
526 it("fires EVERY catalog event with its required metadata (catalog = the contract)", async () => {
527 for (const entry of AUDIT_CATALOG) {
528
529 await fire(entry.fire);
530 if (skipped.has(entry.name)) continue;
531
532 await expectRow(entry.name, entry.metadata);
533 }
534
535 if (skipped.size) {
536
537
538 const list = [...skipped].map((n) => `${n} (${skipReasons[n] ?? "infra"})`).join(", ");
539 console.warn(`[audit-completeness] infra-gated skips (asserted when the infra is available): ${list}`);
540 }
541
542
543
544 expect(fired.size, `missing emitters: ${AUDIT_CATALOG.filter((e) => !fired.has(e.name) && !skipped.has(e.name)).map((e) => e.name).join(", ")}`).toBe(
545 AUDIT_CATALOG.length - skipped.size
546 );
547 });
548});
549