/** * TASK-0133 — mandatory audit-event COMPLETENESS (the phase exit criterion * "audit log covers all mandatory events"). * * The table is generated from `packages/shared/src/events/audit-catalog.ts` * (the single source of truth — the same list the audit viewer's action * autocomplete reads). For EVERY catalog entry this spec performs the * documented `fire` operation against a seeded workspace/org and asserts an * `AuditLog` row with the catalog `name` action + EVERY catalog-required * `metadata` field present. A missing (or field-dropping) emitter → the row * is absent → the test FAILS THE BUILD. * * Driving surface per event (the `fire` id keys the dispatch table): * - the plain API surface (pages/records/shares/keys/webhooks/oauth/scim) * for 16 events; * - the REAL emitter via a service/worker seam for the 4 with no public * HTTP surface for the event: * * sso.saml / sso.oidc → `SsoProvisioningService.provision()` (the * SAML-ACS / OIDC-callback path is what calls it; driving the real * method keeps the test honest — a stubbed `sso.login.*` write would * not prove the IdP path emits it). * * automation.run → the worker `handleRunAutomation` pipeline (a * terminal run is what the catalog names; the depth-0 `recordTerminal` * writes `automation.executed`). * * ai.budget → `AiBudgetGuardService.onBudgetExceeded()` (the * 402-AI_BUDGET_EXCEEDED path that calls it). * - file.uploaded is S3-dependent (the `complete` path HEADs the object in * MinIO); it is asserted when MinIO is available and skipped (documented, * non-failing) when it is not — exactly like the rest of the file suite. * * A single `it` loops the whole catalog so the table stays the contract: * adding an event to `audit-catalog.ts` without a working emitter here fails * the build (the spec's own "missing emitter fails CI" guarantee). */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createHash, randomBytes } from "node:crypto"; import { PrismaClient } from "@prisma/client"; import { Redis } from "ioredis"; import { getRedisUrl } from "./helpers/containers"; import { createTestApp, type TestAppHandle } from "./helpers/api"; // The catalog is the CONTRACT — import it from @factory/shared (the same // source the API/worker consume) and never re-list the events here: adding an // event to the catalog without a working emitter below fails the build. import { AUDIT_CATALOG } from "@factory/shared"; // Worker pipeline + executor seam from SOURCE (the monorepo test path; the // worker ships no build) — same convention as automation-pipeline / // files-pipeline. const { handleRunAutomation } = await import( /* webpackIgnore: true */ "../../apps/worker/src/processors/automations/pipeline.ts" ); const { registerTestExecutor, clearTestExecutors } = await import( /* webpackIgnore: true */ "../../apps/worker/src/processors/automations/executors/registry.ts" ); // The real API emitters (imported by class so the test can call them through // the booted production AppModule — `app.get(Service)`). const { SsoProvisioningService } = await import( /* webpackIgnore: true */ "../../apps/api/src/modules/sso/provisioning.service.ts" ); const { AiBudgetGuardService } = await import( /* webpackIgnore: true */ "../../apps/api/src/modules/ai/budget-guard.service.ts" ); // ── MinIO gate (file.uploaded) ─────────────────────────────────────────── const S3_ENDPOINT = (process.env.S3_ENDPOINT ?? "http://localhost:9000").replace(/\/+$/, ""); async function minioUp(url: string): Promise { try { const res = await fetch(`${url}/minio/health/live`, { signal: AbortSignal.timeout(4_000) }); return res.ok; } catch { return false; } } const minioReady = await minioUp(S3_ENDPOINT); // ── state ───────────────────────────────────────────────────────────────── let app: TestAppHandle; let prisma: PrismaClient; let workerRedis: Redis | undefined; const auth = (token: string) => ({ Authorization: `Bearer ${token}`, "Content-Type": "application/json" }); interface Seed { email: string; name: string; token: string; userId: string; orgId: string; orgSlug: string; wsId: string; dbId: string; } const S = { seed: {} as Seed }; const fired = new Set(); // Catalog NAMES of infra-gated events (e.g. `file.uploaded` when MinIO is // unavailable) — the loop skips their `expectRow` so a documented infra gap // never fails the build, but every NON-skipped event is fully asserted. const skipped = new Set(); const skipReasons: Record = {}; const slug = () => `ac-${randomBytes(5).toString("hex")}`; const uid = () => randomBytes(6).toString("hex"); /** * Read a response body ONCE (it is a stream — calling `.text()`/`.json()` * twice throws "Body has already been read"). Returns `{ status, raw, data }` * so the caller can assert on `status` + use the parsed `data`. */ async function read(res: Response | Promise): Promise<{ status: number; raw: string; data: unknown }> { const r = await res; // app.request returns a Promise const raw = await r.text(); let data: unknown = {}; try { data = JSON.parse(raw); } catch { data = raw; // non-JSON (e.g. HTML consent page) — keep the raw text } return { status: r.status, raw, data }; } async function createAppUser(name: string): Promise<{ email: string; name: string; token: string; userId: string }> { const email = `${slug()}@ac.com`; const pw = "pw-123456789"; const reg = await read(app.request("POST", "/api/v1/auth/register", { body: { email, name, password: pw } })); expect(reg.status, reg.raw).toBe(201); const login = await read(app.request("POST", "/api/v1/auth/login", { body: { email, password: pw } })); const lb = login.data as { accessToken: string; user: { id: string } }; expect(login.status, login.raw).toBe(200); return { email, name, token: lb.accessToken, userId: lb.user.id }; } /** * The newest AuditLog row for an action (any scope); null when absent. Some * emitters (SCIM) write the row fire-and-forget (`void prisma…create`), so * the row may land a tick after the HTTP response — retry briefly before * concluding the event did not fire. */ async function latest(action: string): Promise<{ metadata: unknown } | null> { for (let i = 0; i < 20; i++) { const row = await prisma.auditLog.findFirst({ where: { action }, orderBy: { createdAt: "desc" } }); if (row) return row; if (i === 19) return null; await new Promise((r) => setTimeout(r, 50)); } return null; } /** * Assert the action fired with EVERY catalog-required metadata field present * (a field may hold null/"" — presence, not non-emptiness, is the contract; * the emitters always populate them). */ function assertFired(action: string, required: string[]): void { const row = firedRows.get(action); if (!row) throw new Error(`audit completeness: no ${action} row`); const meta = (row.metadata ?? {}) as Record; for (const f of required) { if (!(f in meta)) throw new Error(`audit completeness: ${action} missing required metadata.${f} (got ${JSON.stringify(Object.keys(meta))})`); } fired.add(action); } const firedRows = new Map(); async function expectRow(action: string, required: string[]): Promise { const row = await latest(action); if (row) firedRows.set(action, row); assertFired(action, required); } // ── fire dispatch (the `fire` id → the real operation) ─────────────────── async function fire(id: string): Promise { const { seed } = S; const h = auth(seed.token); switch (id) { case "login": { // A fresh login (register + login) — the catalog `user.login` row. // The email is asserted by the generic expectRow; we capture it here // only to keep the "a real login happened" intent explicit. await createAppUser("Completeness Login"); return; } case "page.create": { const p = await read(app.request("POST", `/api/v1/workspaces/${seed.wsId}/pages`, { headers: h, body: { title: "Completeness page" } })); expect(p.status, p.raw).toBe(201); return; } case "page.update": { const p = await read(app.request("POST", `/api/v1/workspaces/${seed.wsId}/pages`, { headers: h, body: { title: "to update" } })); const pageId = (p.data as { id?: string }).id; const up = await read(app.request("PATCH", `/api/v1/pages/${pageId}`, { headers: h, body: { title: "renamed" } })); expect([200, 201].includes(up.status), up.raw).toBe(true); return; } case "page.trash": { const p = await read(app.request("POST", `/api/v1/workspaces/${seed.wsId}/pages`, { headers: h, body: { title: "to trash" } })); const pageId = (p.data as { id?: string }).id; const del = await read(app.request("DELETE", `/api/v1/pages/${pageId}`, { headers: h })); expect(del.status, del.raw).toBe(200); return; } case "share.create": { const p = await read(app.request("POST", `/api/v1/workspaces/${seed.wsId}/pages`, { headers: h, body: { title: "to share" } })); const pageId = (p.data as { id?: string }).id; const s = await read(app.request("POST", "/api/v1/shares", { headers: h, body: { workspaceId: seed.wsId, targetType: "page", targetId: pageId, kind: "link", level: "VIEW" } })); expect([200, 201].includes(s.status), s.raw).toBe(true); return; } case "permission.change": { // A role change on the second (seam-added) member. const member = await createAppUser("Perm Member"); await app.request("POST", `/internal/workspaces/${seed.wsId}/members`, { headers: { Authorization: `Bearer ${app.internalServiceToken}`, "Content-Type": "application/json" }, body: { email: member.email, role: "MEMBER" } }); const patch = await read(app.request("PATCH", `/api/v1/workspaces/${seed.wsId}/members/${member.userId}`, { headers: h, body: { role: "ADMIN" } })); expect(patch.status, patch.raw).toBe(200); return; } case "database.create": { // A database is a PAGE of type "database" (databases.service seeds it // through the pages route) — that fires `database.created`. const db = await read(app.request("POST", `/api/v1/workspaces/${seed.wsId}/pages`, { headers: h, body: { title: "Completeness DB 2", type: "database" } })); expect([200, 201].includes(db.status), db.raw).toBe(true); return; } case "record.create": { // The title is set via the `title` field (not a property — TITLE is // read-only through properties); the catalog only needs `databaseId`. const r = await read(app.request("POST", `/api/v1/databases/${seed.dbId}/records`, { headers: h, body: { title: "r-create" } })); expect([200, 201].includes(r.status), r.raw).toBe(true); return; } case "record.update": { const r = await read(app.request("POST", `/api/v1/databases/${seed.dbId}/records`, { headers: h, body: { title: "r-update" } })); const rec = (r.data as { id: string }).id; // flat record DTO const up = await read(app.request("PATCH", `/api/v1/records/${rec}`, { headers: h, body: { title: "r-updated" } })); expect(up.status, up.raw).toBe(200); return; } case "file.upload": { if (!minioReady) { skipped.add("file.uploaded"); skipReasons["file.uploaded"] = "MinIO unavailable"; return; } const bytes = "completeness-bytes".repeat(32); const presign = await read(app.request("POST", "/api/v1/files/uploads", { headers: { ...h, "x-factory-workspace": seed.wsId }, body: { name: "completeness.txt", mimeType: "text/plain", size: Buffer.byteLength(bytes) } })); expect(presign.status, presign.raw).toBe(201); const upload = presign.data as { uploadUrl: string; headers: Record }; const put = await fetch(upload.uploadUrl, { method: "PUT", headers: upload.headers, body: bytes }); expect(put.status, await put.text()).toBe(200); return; } case "api_key.create": { const k = await read(app.request("POST", `/api/v1/workspaces/${seed.wsId}/api-keys`, { headers: h, body: { name: "completeness-key", scopes: ["READ"] } })); expect([200, 201].includes(k.status), k.raw).toBe(true); return; } case "webhook.create": { 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"] } })); expect([200, 201].includes(w.status), w.raw).toBe(true); return; } case "webhook.revoke": { const w = await read(app.request("POST", `/api/v1/workspaces/${seed.wsId}/webhooks`, { headers: h, body: { url: "https://hooks.example.com/completeness-revoke" } })); const whId = (w.data as { id?: string }).id; const del = await read(app.request("DELETE", `/api/v1/workspaces/${seed.wsId}/webhooks/${whId}`, { headers: h })); expect(del.status, del.raw).toBe(200); return; } case "scim.create": { await scimCreate("comp-scim-create"); return; } case "scim.deactivate": { await scimDeactivate(); return; } case "ai.budget": { const guard = app.get?.(AiBudgetGuardService) as unknown as { onBudgetExceeded: (ws: string, used: number, limit: number, req?: unknown) => Promise }; if (!guard) throw new Error("AiBudgetGuardService not resolvable from the app module"); await guard.onBudgetExceeded(seed.wsId, 100, 100, undefined); return; } case "automation.run": { await fireAutomationRun(); return; } case "sso.saml": case "sso.oidc": { const protocol = id === "sso.saml" ? "SAML" : "OIDC"; await fireSsoLogin(protocol); return; } case "oauth.token": case "oauth.revoke": { await fireOAuth(id); return; } default: throw new Error(`audit completeness: unknown fire id "${id}" (catalog drift — update the dispatch table)`); } } // ── SCIM (shared org, enable-once) ──────────────────────────────────────── let scimToken: string; async function enableScim(): Promise { // `userEmailDomain` maps `@`-less SCIM `userName`s → `@` // (the enable schema has no `name` field; only these three options). const r = await read(app.request("POST", `/api/v1/orgs/${S.seed.orgId}/scim`, { headers: auth(S.seed.token), body: { userEmailDomain: "scim-completeness.test" } })); if (r.status !== 201 && r.status !== 200) throw new Error(`enable scim failed (${r.status}): ${r.raw}`); scimToken = (r.data as { token?: string }).token as string; return scimToken; } const scimHeaders = (token: string) => ({ Authorization: `Bearer ${token}`, "Content-Type": "application/scim+json" }); async function scimCreate(userBase: string): Promise { const res = await app.request("POST", "/scim/v2/Users", { headers: scimHeaders(scimToken), body: { schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], userName: `${userBase}.${uid()}`, active: true } }); const body = (await res.json()) as unknown; // read ONCE expect(res.status, JSON.stringify(body)).toBe(201); return body; } async function scimDeactivate(): Promise { // A fresh user (the deactivate path suspends a SCIM-created user). const created = (await scimCreate("comp-scim-deact")) as { id: string }; const del = await read(app.request("DELETE", `/scim/v2/Users/${created.id}`, { headers: scimHeaders(scimToken) })); // SCIM DELETE = deactivate: 200 + the (retained) User resource, `active:false`. expect(del.status, del.raw).toBe(200); } // ── SSO login (real provision(); the ACS/IDP-callback path is the caller) ─ async function fireSsoLogin(protocol: "SAML" | "OIDC"): Promise { const svc = app.get?.(SsoProvisioningService) as unknown as { provision: (i: unknown) => Promise }; if (!svc) throw new Error("SsoProvisioningService not resolvable from the app module"); const origin = `https://idp-${uid()}.example.test`; const config = protocol === "SAML" ? { entityId: `${origin}/entity`, ssoUrl: `${origin}/sso/saml`, acsUrl: `${app.baseUrl}/saml/acs` } : { 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" }; // One provider per org (`organizationId` UNIQUE) — the SAML leg already // created one, so clear it before the OIDC leg (the provision path only // reads THIS provider row; the audit row is what the catalog asserts). await prisma.ssoProvider.deleteMany({ where: { organizationId: S.seed.orgId } }); const provider = await prisma.ssoProvider.create({ data: { organizationId: S.seed.orgId, type: protocol as never, name: `${protocol} Completeness`, config: config as never, enforce: false } }); await svc.provision({ req: { ip: "127.0.0.1", headers: { "user-agent": "completeness-test" } }, // issueTokens → setRefreshCookie only calls `res.setHeader` (no status / // send) — a no-op stub is enough; no network is touched. res: { setHeader: () => undefined }, organizationId: S.seed.orgId, provider: { id: provider.id, type: protocol, name: provider.name, config: config as Record }, externalId: `ext-${uid()}`, email: `sso-${protocol.toLowerCase()}-${uid()}@ac.com`, displayName: "SSO Completeness User" }); } // ── OAuth2 (the full authorize→token→revoke flow, PKCE + confidential) ──── const s256 = (v: string) => createHash("sha256").update(v).digest("base64url"); async function fireOAuth(which: "oauth.token" | "oauth.revoke"): Promise { const seed = S.seed; const redirectUri = `http://localhost:9999/${uid()}.cb`; const appRes = await app.request("POST", `/api/v1/workspaces/${seed.wsId}/oauth/apps`, { headers: auth(seed.token), body: { name: `Completeness App ${uid()}`, scopes: ["pages:read"], redirectUris: [redirectUri] } }); const reg = (await appRes.json()) as { clientId: string; clientSecret?: string }; expect(appRes.status, JSON.stringify(reg)).toBe(201); const verifier = randomBytes(32).toString("base64url"); const challenge = s256(verifier); const params = new URLSearchParams({ response_type: "code", client_id: reg.clientId, redirect_uri: redirectUri, scope: "pages:read", state: "cs", code_challenge: challenge, code_challenge_method: "S256" }); const consent = await app.request("GET", `/api/v1/oauth/authorize?${params.toString()}`, { headers: auth(seed.token), redirect: "manual" }); const html = await consent.text(); // read ONCE — the body is a stream expect(consent.status, html.slice(0, 200)).toBe(200); const consentToken = html.match(/name="consent_token" value="([^"]+)"/)?.[1]; expect(consentToken, "consent page must carry consent_token").toBeTruthy(); const form = new URLSearchParams({ client_id: reg.clientId, redirect_uri: redirectUri, scope: "pages:read", state: "cs", decision: "allow", consent_token: consentToken!, code_challenge: challenge, code_challenge_method: "S256" }); const approve = await fetch(`${app.baseUrl}/api/v1/oauth/authorize`, { method: "POST", headers: { ...auth(seed.token), "Content-Type": "application/x-www-form-urlencoded" }, body: form.toString(), redirect: "manual" }); expect(approve.status, "authorize must 302 to the redirect with a code").toBe(302); const code = new URL(approve.headers.get("location")!).searchParams.get("code"); expect(code, "authorize must return a code").toMatch(/^oc_/); // Token exchange (PKCE + the confidential client's secret, as the proven // oauth.integration spec does). const tok = await app.request("POST", "/api/v1/oauth/token", { headers: { "Content-Type": "application/json" }, body: { grant_type: "authorization_code", code: code!, code_verifier: verifier, redirect_uri: redirectUri, client_id: reg.clientId, client_secret: reg.clientSecret } }); const t = (await tok.json()) as { access_token?: string; refresh_token?: string }; expect(tok.status, JSON.stringify(t)).toBe(200); expect(t.access_token, "token endpoint must return an access token").toBeTruthy(); if (which === "oauth.revoke") { // Revoke the family → the catalog `oauth.token_revoked` (the 202 // `oauth.token_issued` was already written above for THIS leg). await app.request("POST", "/api/v1/oauth/revoke", { headers: { "Content-Type": "application/json" }, body: { token: t.refresh_token ?? t.access_token, token_type_hint: "refresh_token" } }); } } // ── automation.executed (the real worker terminal path) ────────────────── async function fireAutomationRun(): Promise { const seed = S.seed; const db = await prisma.database.findUnique({ where: { id: seed.dbId } }); if (!db) throw new Error("seed database missing"); const id = `auto_ac_${uid()}`; const runId = `run_ac_${uid()}`; await prisma.automation.create({ data: { id, workspaceId: seed.wsId, name: "Completeness Automation", status: "ACTIVE", databaseId: seed.dbId, createdById: seed.userId, triggers: { create: [{ type: "RECORD_CREATED", config: { databaseId: seed.dbId }, position: 0 }] }, // Nested create via the relation — the FK `automationId` is implicit // (Prisma rejects it as an explicit argument here). `retries` is the // default 3 (the shared schema rejects 0) — it never matters here, the // test executor succeeds on the first attempt. actions: { create: [{ type: "CREATE_PAGE", config: { title: "Completeness auto page" }, position: 0 }] } } }); await prisma.automationRun.create({ data: { id: runId, workspaceId: seed.wsId, automationId: id, triggerEvent: { recordId: "seed" } as never, status: "PENDING" } }); const deps = { prisma, redis: workerRedis!, env: { AUTO_RUN_MAX_RETRIES: 2 } as never }; const job = { runId, automationId: id, workspaceId: seed.wsId, triggerEvent: { recordId: "seed" }, eventId: `evt_${uid()}` }; // A no-op executor: the pipeline validates the known action type and // executes our override (returns a terminal SUCCEEDED result), so // `recordTerminal` fires the depth-0 `automation.executed` event. registerTestExecutor("CREATE_PAGE", { type: "CREATE_PAGE", execute: async () => ({ output: { ok: true }, emittedEvents: [], audit: null }) }); const status = await handleRunAutomation(deps as never, { data: job }); clearTestExecutors(); expect(status).toBe("SUCCEEDED"); } // ── bootstrap ───────────────────────────────────────────────────────────── beforeAll(async () => { app = await createTestApp(); prisma = app.prisma; try { const redisUrl = await getRedisUrl(); workerRedis = new Redis(redisUrl); } catch { workerRedis = undefined; } // Shared seed: owner + org + workspace + database. const owner = await createAppUser("Completeness Owner"); const org = await read(app.request("POST", "/api/v1/orgs", { headers: auth(owner.token), body: { name: `Completeness Org ${uid()}` } })); expect(org.status, org.raw).toBe(201); const ws = await read(app.request("POST", "/api/v1/workspaces", { headers: auth(owner.token), body: { name: `Completeness WS ${uid()}` } })); expect(ws.status, ws.raw).toBe(201); 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" } })); expect([200, 201].includes(db.status), db.raw).toBe(true); S.seed = { email: owner.email, name: owner.name, token: owner.token, userId: owner.userId, orgId: (org.data as { id: string }).id, orgSlug: (org.data as { id: string; slug: string }).slug, wsId: (ws.data as { id: string }).id, dbId: (db.data as { databaseId: string }).databaseId }; // SCIM needs a token (enable-once, org-scoped). scimToken = await enableScim(); }, 180_000); afterAll(async () => { await workerRedis?.quit().catch(() => {}); await app?.close(); }); describe("TASK-0133 mandatory audit-event completeness", () => { it("fires EVERY catalog event with its required metadata (catalog = the contract)", async () => { for (const entry of AUDIT_CATALOG) { // eslint-disable-next-line no-await-in-loop await fire(entry.fire); if (skipped.has(entry.name)) continue; // infra-gated (e.g. no MinIO) // eslint-disable-next-line no-await-in-loop await expectRow(entry.name, entry.metadata); } if (skipped.size) { // Document the infra-gated skips (MinIO unavailable) without failing the // build — the rest of the catalog is fully asserted. const list = [...skipped].map((n) => `${n} (${skipReasons[n] ?? "infra"})`).join(", "); console.warn(`[audit-completeness] infra-gated skips (asserted when the infra is available): ${list}`); } // The build guarantee: no catalog event may be missing an emitter // (infra-gated events are excluded from the count). expect(fired.size, `missing emitters: ${AUDIT_CATALOG.filter((e) => !fired.has(e.name) && !skipped.has(e.name)).map((e) => e.name).join(", ")}`).toBe( AUDIT_CATALOG.length - skipped.size ); }); });