/** * AI Software Factory — Integration: Automations (Phase 11, TASK-0110) * * Covers the automation MANAGEMENT API end-to-end against Testcontainers * Postgres + Redis: * * - CRUD (workspace-scoped list/create, automation-scoped get/patch/delete), * - server-side authorization (non-member 403/404, GUEST VIEW-only 403, * scoped-database EDIT grant, workspace-level EDIT without a database), * - the status machine (all 6 valid transitions; the 9 invalid pairs → 409), * - sub-resource PUTs (trigger/condition/action zod validation, path-located * 422 details, replace-set semantics), * - incoming-webhook lifecycle (secret minted ONCE, signature verification, * tamper/stale/unknown → 401/404, per-webhookId rate limit → 429, * regenerate invalidates the old secret, secret never leaks), * - manual run (202 + PENDING run row, DRAFT dry-run, no-actions 422), * - runs history (list cursor/limit/status, detail + logs, retry rules), * - audit events (created / status_changed / manual_triggered / retry). * * The trigger matcher, schedule registration and the run EXECUTION pipeline * are worker-side (TASK-0113) — the API-side contract (enqueue `run-automation` * with the TASK-0113 job shape) is covered by the PENDING-run assertions. */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { AUTOMATION_MAX_ACTIONS, AUTOMATION_MAX_TRIGGERS, buildWebhookSignature } from "@factory/shared"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, countAuditEvents, createWorkspace, createUser } from "./helpers/seed"; let app: TestAppHandle; beforeAll(async () => { app = await createTestApp(); }, 180_000); afterAll(async () => { await app?.close(); }); // --------------------------------------------------------------------------- // Shared fixtures // --------------------------------------------------------------------------- const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; /** The error envelope's `code` (the API's canonical `{ error: {...} }` shape). */ const errCode = (body: Record): string | undefined => (body.error as { code?: string } | undefined)?.code; /** The error envelope's path-located `details[]`. */ const errDetails = (body: Record): { field?: string }[] => ((body.error as { details?: { field?: string }[] } | undefined)?.details ?? []); /** A minimal valid UPDATE_RECORD action (valid on any automation). */ const validAction = (i = 0) => ({ type: "UPDATE_RECORD", config: { recordId: "from_trigger", propertyValues: { Title: `done-${i}` } }, retries: 3, timeoutMs: 30_000 }); /** * Create a fresh workspace + its owner. Each describe block that needs * isolation (rate limiting, secret regeneration) gets its own workspace so * per-webhookId Redis counters never bleed between tests. */ async function newWorkspace(name: string) { const owner = await createUser(app.request, { name }); const ws = await createWorkspace(app.request, authHeaders(owner.accessToken!), name); return { owner, headers: authHeaders(owner.accessToken!), workspaceId: ws.workspaceId }; } /** Create an automation (via the API) in the given workspace. */ async function createAutomation( headers: Record, workspaceId: string, body: Record = { name: "Auto" }, ) { const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/automations`, { headers, body }); const b = await json(res); return { res, body: b }; } /** Set status; returns [status, body]. */ async function setStatus(headers: Record, id: string, status: string) { const res = await app.request("POST", `/api/v1/automations/${id}/status`, { headers, body: { status } }); return [res.status, await json(res)] as const; } /** Attach a webhook trigger and return the one-time secret + webhookId. */ async function attachWebhook(headers: Record, id: string, extraTriggers: unknown[] = []) { const res = await app.request("PUT", `/api/v1/automations/${id}/triggers`, { headers, body: { triggers: [{ type: "WEBHOOK", config: {} }, ...extraTriggers] } }); expect(res.status).toBe(200); const b = await json(res); const wh = (b.webhooks as { webhookId: string; secret: string; url: string }[])[0]; expect(wh, "webhook secret returned once").toBeTruthy(); return wh!; } /** Fire the incoming webhook with a valid signature (returns [status, body]). */ async function fireWebhook(webhookId: string, secret: string, payload: unknown, tsSeconds?: number) { const raw = JSON.stringify(payload); const ts = tsSeconds ?? Math.floor(Date.now() / 1000); const res = await app.request("POST", `/api/v1/webhooks/incoming/${webhookId}`, { body: payload, // the helper JSON.stringifies — byte-identical to `raw` headers: { "x-fs-signature": buildWebhookSignature(secret, ts, raw) } }); return [res.status, await json(res)] as const; } // --------------------------------------------------------------------------- // CRUD // --------------------------------------------------------------------------- describe("automation CRUD", () => { let headers: Record; let workspaceId: string; beforeAll(async () => { ({ headers, workspaceId } = await newWorkspace("CRUD")); }); it("creates a DRAFT automation (201) with default fields", async () => { const { res, body } = await createAutomation(headers, workspaceId, { name: "Inbox triage", description: "Routes new leads" }); expect(res.status).toBe(201); expect(body.status).toBe("DRAFT"); expect(body.name).toBe("Inbox triage"); expect(body.description).toBe("Routes new leads"); expect(body.databaseId).toBeNull(); expect(body.triggers).toEqual([]); expect(body.condition).toBeNull(); expect(body.actions).toEqual([]); expect(body.latestRun).toBeNull(); expect(typeof body.id).toBe("string"); }); it("rejects invalid names (zod 400)", async () => { const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/automations`, { headers, body: { name: "" } }); expect(res.status).toBe(400); const body = await json(res); expect(errCode(body)).toBe("VALIDATION_ERROR"); }); it("creates with inline sub-resources (triggers + condition + actions)", async () => { const { res, body } = await createAutomation(headers, workspaceId, { name: "With parts", triggers: [{ type: "RECORD_CREATED", config: {} }], condition: { field: { ref: "property", name: "Status" }, operator: "eq", value: "Open" }, actions: [validAction(1), validAction(2)] }); expect(res.status).toBe(201); expect(body.triggers).toHaveLength(1); expect((body.condition as Record).field).toBeTruthy(); expect(body.actions).toHaveLength(2); }); it("lists automations (default limit 50) newest-first with latestRun", async () => { const res = await app.request("GET", `/api/v1/workspaces/${workspaceId}/automations`, { headers }); expect(res.status).toBe(200); const body = await json(res); const data = body.data as Record[]; expect(data.length).toBe(2); expect(body.nextCursor).toBeNull(); // Newest first (createdAt desc). const t0 = new Date(data[1]!.createdAt as string).getTime(); const t1 = new Date(data[0]!.createdAt as string).getTime(); expect(t0).toBeLessThanOrEqual(t1); expect(data[0]!.name).toBe("With parts"); expect(data[0]!.latestRun).toBeNull(); expect(data[0]!.triggers).toEqual(["RECORD_CREATED"]); expect(data[0]!.actions).toEqual(["UPDATE_RECORD", "UPDATE_RECORD"]); }); it("respects the status filter", async () => { const res = await app.request("GET", `/api/v1/workspaces/${workspaceId}/automations?status=DRAFT`, { headers }); const body = await json(res); expect((body.data as unknown[]).length).toBe(2); const none = await app.request("GET", `/api/v1/workspaces/${workspaceId}/automations?status=ACTIVE`, { headers }); expect(((await json(none)).data as unknown[]).length).toBe(0); }); it("pages with a cursor (limit=1 → 2 pages)", async () => { const p1 = await app.request("GET", `/api/v1/workspaces/${workspaceId}/automations?limit=1`, { headers }); const b1 = await json(p1); expect((b1.data as unknown[]).length).toBe(1); expect(typeof b1.nextCursor).toBe("string"); const p2 = await app.request("GET", `/api/v1/workspaces/${workspaceId}/automations?limit=1&cursor=${encodeURIComponent(b1.nextCursor as string)}`, { headers }); const b2 = await json(p2); const d1 = (b1.data as Record)[0]!.id; const d2 = (b2.data as Record)[0]!.id; expect(d2).not.toBe(d1); // no overlap across pages expect(b2.nextCursor).toBeNull(); }); it("rejects an invalid cursor (400)", async () => { const res = await app.request("GET", `/api/v1/workspaces/${workspaceId}/automations?cursor=abc123def`, { headers }); expect(res.status).toBe(400); expect(errCode(await json(res))).toBe("VALIDATION_ERROR"); }); it("422s a create with a foreign or fabricated databaseId (server-side scope check)", async () => { const other = await newWorkspace("OtherWs"); // A database is a page of type "database" (there is no POST /databases // route — see the forms suite's setupDb). const dbRes = await app.request("POST", `/api/v1/workspaces/${other.workspaceId}/pages`, { headers: other.headers, body: { title: "Foreign DB", type: "database" } }); expect([200, 201]).toContain(dbRes.status); const foreignDb = ((await json(dbRes)) as { databaseId: string }).databaseId; // A database from ANOTHER workspace must be rejected (no cross-ws scope). const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/automations`, { headers, body: { name: "Cross ws", databaseId: foreignDb } }); expect(res.status).toBe(422); expect(errCode(await json(res))).toBe("UNPROCESSABLE_ENTITY"); // A fabricated id is rejected too. const res2 = await app.request("POST", `/api/v1/workspaces/${workspaceId}/automations`, { headers, body: { name: "Ghost db", databaseId: "zzzzzzzzzzzzzzzzzzzzzz" } }); expect(res2.status).toBe(422); }); it("gets full detail (incl. latestRun) by id", async () => { const list = await json(await app.request("GET", `/api/v1/workspaces/${workspaceId}/automations?status=DRAFT&limit=1`, { headers })); const id = (list.data as Record)[0]!.id as string; const res = await app.request("GET", `/api/v1/automations/${id}`, { headers }); expect(res.status).toBe(200); const body = await json(res); expect(body.id).toBe(id); expect(body.actions).toHaveLength(2); expect(body.latestRun).toBeNull(); }); it("returns 404 for a non-existent automation (no existence leak)", async () => { const res = await app.request("GET", `/api/v1/automations/${"x".repeat(20)}`, { headers }); expect(res.status).toBe(404); expect(errCode(await json(res))).toBe("NOT_FOUND"); }); it("patches name/description", async () => { const { res, body: created } = await createAutomation(headers, workspaceId, { name: "Patched" }); const aid = created.id as string; const res2 = await app.request("PATCH", `/api/v1/automations/${aid}`, { headers, body: { name: "Patched v2", description: "updated" } }); expect(res2.status).toBe(200); const b2 = await json(res2); expect(b2.name).toBe("Patched v2"); expect(b2.description).toBe("updated"); }); it("deletes → ARCHIVED (soft; { archived: true })", async () => { const { res, body: created } = await createAutomation(headers, workspaceId, { name: "Doomed" }); const aid = created.id as string; const del = await app.request("DELETE", `/api/v1/automations/${aid}`, { headers }); expect(del.status).toBe(200); expect(await json(del)).toEqual({ archived: true }); const after = await app.request("GET", `/api/v1/automations/${aid}`, { headers }); expect((await json(after)).status).toBe("ARCHIVED"); }); it("does not hard-delete: the row still exists (re-activation allowed)", async () => { const { res, body: created } = await createAutomation(headers, workspaceId, { name: "Doomed2" }); const aid = created.id as string; await app.request("DELETE", `/api/v1/automations/${aid}`, { headers }); const count = await app.prisma.automation.count({ where: { id: aid } }); expect(count).toBe(1); const [s] = await setStatus(headers, aid, "ACTIVE"); expect(s).toBe(200); }); }); // --------------------------------------------------------------------------- // Authorization (server-side) // --------------------------------------------------------------------------- describe("automation authorization", () => { let ownerHeaders: Record; let workspaceId: string; let ownerId: string; let guestHeaders: Record; let automationId: string; beforeAll(async () => { const { owner, headers, workspaceId: ws } = await newWorkspace("Auth"); ownerHeaders = headers; workspaceId = ws; ownerId = owner.userId; const { body } = await createAutomation(headers, ws, { name: "Protected" }); automationId = body.id as string; // GUEST is a workspace member with VIEW only. const guest = await createUser(app.request, { name: "Guest" }); await addMemberViaSeam(app.request, app.internalServiceToken, ws, guest.email, "GUEST"); guestHeaders = authHeaders(guest.accessToken!); }); it("a non-member cannot list (403) nor read (403, no leak)", async () => { const stranger = await createUser(app.request, { name: "Stranger" }); const sh = authHeaders(stranger.accessToken!); const list = await app.request("GET", `/api/v1/workspaces/${workspaceId}/automations`, { headers: sh }); expect(list.status).toBe(403); const get = await app.request("GET", `/api/v1/automations/${automationId}`, { headers: sh }); expect(get.status).toBe(403); expect(errCode(await json(get))).toBe("FORBIDDEN"); }); it("a non-member cannot create (403)", async () => { const stranger = await createUser(app.request, { name: "Stranger2" }); const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/automations`, { headers: authHeaders(stranger.accessToken!), body: { name: "Nope" } }); expect(res.status).toBe(403); }); it("a GUEST can read but not write (VIEW-only 403)", async () => { const read = await app.request("GET", `/api/v1/automations/${automationId}`, { headers: guestHeaders }); expect(read.status).toBe(200); const list = await app.request("GET", `/api/v1/workspaces/${workspaceId}/automations`, { headers: guestHeaders }); expect(list.status).toBe(200); const patch = await app.request("PATCH", `/api/v1/automations/${automationId}`, { headers: guestHeaders, body: { name: "hack" } }); expect(patch.status).toBe(403); const del = await app.request("DELETE", `/api/v1/automations/${automationId}`, { headers: guestHeaders }); expect(del.status).toBe(403); const create = await app.request("POST", `/api/v1/workspaces/${workspaceId}/automations`, { headers: guestHeaders, body: { name: "guest auto" } }); expect(create.status).toBe(403); const status = await app.request("POST", `/api/v1/automations/${automationId}/status`, { headers: guestHeaders, body: { status: "ACTIVE" } }); expect(status.status).toBe(403); }); it("the OWNER can write (baseline for the GUEST contrast)", async () => { const res = await app.request("PATCH", `/api/v1/automations/${automationId}`, { headers: ownerHeaders, body: { description: "owner wrote" } }); expect(res.status).toBe(200); }); it("an unknown automation id → 404 even for the owner (no cross-ws leak)", async () => { const res = await app.request("GET", `/api/v1/automations/${"y".repeat(20)}`, { headers: ownerHeaders }); expect(res.status).toBe(404); }); it("audits the write (automation.updated exists for the owner)", async () => { const n = await countAuditEvents(app.prisma, { actorId: ownerId, action: "automation.updated" }); expect(n).toBeGreaterThanOrEqual(1); }); }); // --------------------------------------------------------------------------- // Status machine // --------------------------------------------------------------------------- describe("automation status machine", () => { let headers: Record; let workspaceId: string; let ownerId: string; let id: string; beforeAll(async () => { const { owner, headers: h, workspaceId: ws } = await newWorkspace("Status"); headers = h; workspaceId = ws; ownerId = owner.userId; const { body } = await createAutomation(headers, ws, { name: "Machine" }); id = body.id as string; }); it("walks all 6 valid transitions in one path", async () => { // DRAFT → ACTIVE let [s, b] = await setStatus(headers, id, "ACTIVE"); expect(s).toBe(200); expect(b.status).toBe("ACTIVE"); // ACTIVE → PAUSED [s, b] = await setStatus(headers, id, "PAUSED"); expect(s).toBe(200); expect(b.status).toBe("PAUSED"); // PAUSED → ACTIVE [s, b] = await setStatus(headers, id, "ACTIVE"); expect(s).toBe(200); expect(b.status).toBe("ACTIVE"); // ACTIVE → ARCHIVED [s, b] = await setStatus(headers, id, "ARCHIVED"); expect(s).toBe(200); expect(b.status).toBe("ARCHIVED"); // ARCHIVED → ACTIVE (re-arm) [s, b] = await setStatus(headers, id, "ACTIVE"); expect(s).toBe(200); expect(b.status).toBe("ACTIVE"); // ACTIVE → PAUSED → ARCHIVED (completes the graph) [s] = await setStatus(headers, id, "PAUSED"); expect(s).toBe(200); [s] = await setStatus(headers, id, "ARCHIVED"); expect(s).toBe(200); }); it("rejects every invalid pair with 409 (each pair on a fresh row)", async () => { // All 16 pairs minus the 6 valid ones = 10 invalid (incl. no-ops). const invalid: [string, string][] = [ ["DRAFT", "PAUSED"], ["DRAFT", "ARCHIVED"], ["ACTIVE", "DRAFT"], ["PAUSED", "DRAFT"], ["ARCHIVED", "DRAFT"], ["ARCHIVED", "PAUSED"], ["ACTIVE", "ACTIVE"], ["DRAFT", "DRAFT"], ["PAUSED", "PAUSED"], ["ARCHIVED", "ARCHIVED"] ]; for (const [from, to] of invalid) { const { body } = await createAutomation(headers, workspaceId, { name: `T ${from}-${to}` }); const tid = body.id as string; // Drive the fresh DRAFT row to `from` (the only valid entry path is via ACTIVE). if (from === "ACTIVE") await setStatus(headers, tid, "ACTIVE"); else if (from === "PAUSED") { await setStatus(headers, tid, "ACTIVE"); await setStatus(headers, tid, "PAUSED"); } else if (from === "ARCHIVED") { await setStatus(headers, tid, "ACTIVE"); await setStatus(headers, tid, "ARCHIVED"); } const [s, b] = await setStatus(headers, tid, to); expect(s, `${from} → ${to} must 409`).toBe(409); expect(errCode(b)).toBe("CONFLICT"); } }); it("audits each transition (automation.status_changed with from/to)", async () => { const n = await countAuditEvents(app.prisma, { actorId: ownerId, action: "automation.status_changed" }); // 7 valid transitions in the first test + 6 status sets driving fresh rows // (ACTIVE / PAUSED / ARCHIVED per row) + the re-arm above. Lower-bound: expect(n).toBeGreaterThanOrEqual(7); // Spot-check the metadata shape of one row. const row = await app.prisma.auditLog.findFirst({ where: { actorId: ownerId, action: "automation.status_changed", resourceId: id }, orderBy: { createdAt: "desc" } }); expect(row).not.toBeNull(); const meta = row!.metadata as Record; expect(typeof meta.from).toBe("string"); expect(typeof meta.to).toBe("string"); expect(meta.to).not.toBe(meta.from); }); }); // --------------------------------------------------------------------------- // Sub-resource PUTs (validation contract) // --------------------------------------------------------------------------- describe("sub-resource PUTs", () => { let headers: Record; let workspaceId: string; let id: string; beforeAll(async () => { const { headers: h, workspaceId: ws } = await newWorkspace("Subres"); headers = h; workspaceId = ws; const { body } = await createAutomation(headers, ws, { name: "Subs" }); id = body.id as string; }); describe("PUT triggers", () => { it("validates each trigger config (path-located 422)", async () => { // PROPERTY_CHANGED requires a non-empty `properties` array. const res = await app.request("PUT", `/api/v1/automations/${id}/triggers`, { headers, body: { triggers: [{ type: "PROPERTY_CHANGED", config: {} }] } }); expect(res.status).toBe(422); const b = await json(res); expect(errCode(b)).toBe("UNPROCESSABLE_ENTITY"); const details = errDetails(b); expect(details?.some((d) => (d.field ?? "").startsWith("triggers[0].config."))).toBe(true); }); it("rejects >3 triggers (zod 400)", async () => { const triggers = Array.from({ length: AUTOMATION_MAX_TRIGGERS + 1 }, () => ({ type: "MANUAL", config: {} })); const res = await app.request("PUT", `/api/v1/automations/${id}/triggers`, { headers, body: { triggers } }); expect(res.status).toBe(400); }); it("rejects an unknown trigger type (400)", async () => { const res = await app.request("PUT", `/api/v1/automations/${id}/triggers`, { headers, body: { triggers: [{ type: "EXPLOSION", config: {} }] } }); expect(res.status).toBe(400); }); it("accepts a valid set (replace semantics) and returns sanitized triggers", async () => { const res = await app.request("PUT", `/api/v1/automations/${id}/triggers`, { headers, body: { triggers: [ { type: "RECORD_CREATED", config: {} }, { type: "PROPERTY_CHANGED", config: { properties: ["Status"] } } ] } }); expect(res.status).toBe(200); const b = await json(res); expect(b.webhooks).toEqual([]); expect((b.triggers as unknown[]).length).toBe(2); // Replace: a new set of 1 trigger removes the previous 2. const res2 = await app.request("PUT", `/api/v1/automations/${id}/triggers`, { headers, body: { triggers: [{ type: "PAGE_CREATED", config: {} }] } }); expect(res2.status).toBe(200); const detail = await app.request("GET", `/api/v1/automations/${id}`, { headers }); expect(((await json(detail)).triggers as unknown[]).length).toBe(1); }); it("validates SCHEDULE cron (CRON_TOO_FREQUENT → 422)", async () => { // 5s interval < 60s minimum. const res = await app.request("PUT", `/api/v1/automations/${id}/triggers`, { headers, body: { triggers: [{ type: "SCHEDULE", config: { cron: "*/5 * * * * *" } }] } }); expect(res.status).toBe(422); // A valid hourly cron passes. const ok = await app.request("PUT", `/api/v1/automations/${id}/triggers`, { headers, body: { triggers: [{ type: "SCHEDULE", config: { cron: "0 0 * * * *" } }] } }); expect(ok.status).toBe(200); }); }); describe("PUT conditions", () => { const pred = (name = "Status", op = "eq", value = "Open") => ({ field: { ref: "property", name }, operator: op, value }); it("accepts null (removes the condition) and a valid tree", async () => { let res = await app.request("PUT", `/api/v1/automations/${id}/conditions`, { headers, body: { condition: pred() } }); expect(res.status).toBe(200); let b = await json(res); expect((b.condition as Record).operator).toBe("eq"); res = await app.request("PUT", `/api/v1/automations/${id}/conditions`, { headers, body: { condition: null } }); expect(res.status).toBe(200); b = await json(res); expect(b.condition).toBeNull(); const detail = await json(await app.request("GET", `/api/v1/automations/${id}`, { headers })); expect(detail.condition).toBeNull(); }); it("rejects a tree deeper than 5 (CONDITION_TOO_COMPLEX, 422)", async () => { // Nest 6 AND groups (depth 6 > 5). let node = pred(); for (let i = 0; i < 6; i++) node = { op: "AND", children: [node, pred(`P${i}`, "is_empty")] }; const res = await app.request("PUT", `/api/v1/automations/${id}/conditions`, { headers, body: { condition: node } }); expect(res.status).toBe(422); const b = await json(res); expect(errCode(b)).toBe("UNPROCESSABLE_ENTITY"); }); it("rejects a structurally invalid tree (CONDITION_INVALID, 422)", async () => { // AND with a single child. const res = await app.request("PUT", `/api/v1/automations/${id}/conditions`, { headers, body: { condition: { op: "AND", children: [pred()] } } }); expect(res.status).toBe(422); }); }); describe("PUT actions", () => { it("accepts a valid ordered list (defaults for omitted knobs)", async () => { const res = await app.request("PUT", `/api/v1/automations/${id}/actions`, { headers, body: { actions: [{ type: "UPDATE_RECORD", config: { propertyValues: { Title: "x" } } }, validAction(2)] } }); expect(res.status).toBe(200); const b = await json(res); const a0 = (b.actions as Record)[0]; // recordId defaulted to "from_trigger"; retries/timeoutMs defaulted. expect((a0.config as Record).recordId).toBe("from_trigger"); expect(a0.retries).toBe(3); expect(a0.timeoutMs).toBe(30_000); }); it("validates each action config (path-located 422)", async () => { // UPDATE_RECORD requires ≥ 1 propertyValues entry. const res = await app.request("PUT", `/api/v1/automations/${id}/actions`, { headers, body: { actions: [{ type: "UPDATE_RECORD", config: { propertyValues: {} } }] } }); expect(res.status).toBe(422); const b = await json(res); expect(errCode(b)).toBe("UNPROCESSABLE_ENTITY"); const details = errDetails(b); expect(details?.some((d) => (d.field ?? "").startsWith("actions[0].config."))).toBe(true); }); it("rejects >50 actions (zod 400)", async () => { const actions = Array.from({ length: AUTOMATION_MAX_ACTIONS + 1 }, (_, i) => validAction(i)); const res = await app.request("PUT", `/api/v1/automations/${id}/actions`, { headers, body: { actions } }); expect(res.status).toBe(400); }); it("rejects out-of-range retries/timeoutMs (422)", async () => { const res = await app.request("PUT", `/api/v1/automations/${id}/actions`, { headers, body: { actions: [{ type: "UPDATE_RECORD", config: { propertyValues: { A: 1 } }, retries: 9 }] } }); expect(res.status).toBe(422); const res2 = await app.request("PUT", `/api/v1/automations/${id}/actions`, { headers, body: { actions: [{ type: "UPDATE_RECORD", config: { propertyValues: { A: 1 } }, timeoutMs: 400_000 }] } }); expect(res2.status).toBe(422); }); }); }); // --------------------------------------------------------------------------- // Incoming webhook (signature, rate limit, secret lifecycle) // --------------------------------------------------------------------------- describe("incoming webhook", () => { let ownerHeaders: Record; let workspaceId: string; let ownerId: string; beforeAll(async () => { const { owner, headers: h, workspaceId: ws } = await newWorkspace("Webhook"); ownerHeaders = h; workspaceId = ws; ownerId = owner.userId; }); it("mints the secret ONCE on first save and exposes url + webhookId", async () => { const { body } = await createAutomation(ownerHeaders, workspaceId, { name: "Hook" }); const id = body.id as string; const wh = await attachWebhook(ownerHeaders, id); expect(wh.secret.startsWith("whsec_")).toBe(true); expect(wh.webhookId).toMatch(/^[0-9a-f]{24}$/); expect(wh.url).toBe(`http://localhost:4000/api/v1/webhooks/incoming/${wh.webhookId}`); // The secret is NEVER in a subsequent GET (sanitized) nor re-issued on // re-save (only the new webhooks list contains a fresh secret). const detail = await json(await app.request("GET", `/api/v1/automations/${id}`, { headers: ownerHeaders })); const trigger = (detail.triggers as Record[]).find((t) => t.type === "WEBHOOK")!; const cfg = trigger.config as Record; expect(cfg.secret).toBeUndefined(); expect(cfg.secretEncrypted).toBeUndefined(); expect(cfg.secretHash).toBeUndefined(); expect(cfg.webhookId).toBe(wh.webhookId); expect(cfg.url).toBe(wh.url); // Re-save (editor replace-set) preserves the stored secret: the response // contains NO new secret, and a signed POST with the ORIGINAL secret // still works — proving the KMS-wrapped secret survived the replace-set // (regression: the editor's core operation must not clobber it). const resave = await app.request("PUT", `/api/v1/automations/${id}/triggers`, { headers: ownerHeaders, body: { triggers: [{ type: "WEBHOOK", config: { webhookId: wh.webhookId } }] } }); expect(resave.status).toBe(200); expect((await json(resave)).webhooks).toEqual([]); const [as] = await setStatus(ownerHeaders, id, "ACTIVE"); expect(as).toBe(200); const [fireStatus] = await fireWebhook(wh.webhookId, wh.secret, { survived: "resave" }); expect(fireStatus, "original secret must still verify after re-save").toBe(200); }); it("accepts a valid signed payload → 200 {accepted:true} + PENDING run", async () => { const { body } = await createAutomation(ownerHeaders, workspaceId, { name: "Hook2" }); const id = body.id as string; const wh = await attachWebhook(ownerHeaders, id); // Activate (the endpoint only fires for ACTIVE automations). const [s] = await setStatus(ownerHeaders, id, "ACTIVE"); expect(s).toBe(200); const [status, b] = await fireWebhook(wh.webhookId, wh.secret, { hello: "world" }); expect(status).toBe(200); expect(b).toEqual({ accepted: true }); const run = await app.prisma.automationRun.findFirst({ where: { automationId: id } }); expect(run).not.toBeNull(); expect(run!.status).toBe("PENDING"); expect((run!.triggerEvent as Record).type).toBe("webhook"); expect(((run!.triggerEvent as Record).payload as Record).hello).toBe("world"); }); it("404s an unknown webhookId (no existence leak)", async () => { const res = await app.request("POST", `/api/v1/webhooks/incoming/${"0".repeat(24)}`, { body: { x: 1 }, headers: { "x-fs-signature": buildWebhookSignature("whsec_deadbeef", Math.floor(Date.now() / 1000), JSON.stringify({ x: 1 })) } }); expect(res.status).toBe(404); expect(errCode(await json(res))).toBe("NOT_FOUND"); }); it("401s a tampered body (signature over different bytes)", async () => { const { body } = await createAutomation(ownerHeaders, workspaceId, { name: "Hook3" }); const id = body.id as string; const wh = await attachWebhook(ownerHeaders, id); await setStatus(ownerHeaders, id, "ACTIVE"); // Sign {"a":1} but send {"a":2} — the raw bytes differ from the MAC input. const ts = Math.floor(Date.now() / 1000); const goodSig = buildWebhookSignature(wh.secret, ts, JSON.stringify({ a: 1 })); const res = await app.request("POST", `/api/v1/webhooks/incoming/${wh.webhookId}`, { body: { a: 2 }, headers: { "x-fs-signature": goodSig } }); expect(res.status).toBe(401); expect(errCode(await json(res))).toBe("WEBHOOK_SIGNATURE_INVALID"); }); it("401s a stale timestamp (±5 min window)", async () => { const { body } = await createAutomation(ownerHeaders, workspaceId, { name: "Hook4" }); const id = body.id as string; const wh = await attachWebhook(ownerHeaders, id); await setStatus(ownerHeaders, id, "ACTIVE"); const stale = Math.floor(Date.now() / 1000) - 360; // 6 minutes ago const [s, b] = await fireWebhook(wh.webhookId, wh.secret, { x: 1 }, stale); expect(s).toBe(401); expect(errCode(b)).toBe("WEBHOOK_TIMESTAMP_STALE"); }); it("401s a missing signature header", async () => { const { body } = await createAutomation(ownerHeaders, workspaceId, { name: "Hook5" }); const id = body.id as string; const wh = await attachWebhook(ownerHeaders, id); await setStatus(ownerHeaders, id, "ACTIVE"); const res = await app.request("POST", `/api/v1/webhooks/incoming/${wh.webhookId}`, { body: { x: 1 } }); expect(res.status).toBe(401); expect(errCode(await json(res))).toBe("WEBHOOK_SIGNATURE_INVALID"); }); it("404s a DRAFT automation's webhook (not registered yet)", async () => { const { body } = await createAutomation(ownerHeaders, workspaceId, { name: "Hook6-draft" }); const id = body.id as string; const wh = await attachWebhook(ownerHeaders, id); // Deliberately left in DRAFT. const res = await app.request("POST", `/api/v1/webhooks/incoming/${wh.webhookId}`, { body: { x: 1 }, headers: { "x-fs-signature": buildWebhookSignature(wh.secret, Math.floor(Date.now() / 1000), JSON.stringify({ x: 1 })) } }); expect(res.status).toBe(404); }); it("404s an ARCHIVED automation's webhook (endpoint removed)", async () => { const { body } = await createAutomation(ownerHeaders, workspaceId, { name: "Hook7" }); const id = body.id as string; const wh = await attachWebhook(ownerHeaders, id); await setStatus(ownerHeaders, id, "ACTIVE"); await setStatus(ownerHeaders, id, "ARCHIVED"); const [s] = await fireWebhook(wh.webhookId, wh.secret, { x: 1 }); expect(s).toBe(404); }); it("rate-limits to 60/min per webhookId (61st → 429)", async () => { const { body } = await createAutomation(ownerHeaders, workspaceId, { name: "Hook8" }); const id = body.id as string; const wh = await attachWebhook(ownerHeaders, id); await setStatus(ownerHeaders, id, "ACTIVE"); let saw429 = false; for (let i = 0; i < 61; i++) { const [s] = await fireWebhook(wh.webhookId, wh.secret, { i }); if (i < 60) { expect(s, `request ${i + 1} should be accepted`).toBe(200); } else { expect(s, "61st request should be 429").toBe(429); saw429 = true; } } expect(saw429).toBe(true); // The rate-limited request created NO run (rejected before enqueue). const runs = await app.prisma.automationRun.count({ where: { automationId: id } }); expect(runs).toBe(60); }); it("regenerates the secret (old invalidated, new works, audited)", async () => { const { body } = await createAutomation(ownerHeaders, workspaceId, { name: "Hook9" }); const id = body.id as string; const wh = await attachWebhook(ownerHeaders, id); await setStatus(ownerHeaders, id, "ACTIVE"); const oldSecret = wh.secret; const reg = await app.request("POST", `/api/v1/automations/${id}/webhook/regenerate`, { headers: ownerHeaders }); expect(reg.status).toBe(200); const rb = await json(reg); const newSecret = rb.secret as string; expect(newSecret.startsWith("whsec_")).toBe(true); expect(newSecret).not.toBe(oldSecret); expect(rb.webhookId).toBe(wh.webhookId); // Old secret now fails (401). const old = await fireWebhook(wh.webhookId, oldSecret, { old: true }); expect(old[0]).toBe(401); expect(old[1].error).toBeTruthy(); // New secret works. const neu = await fireWebhook(wh.webhookId, newSecret, { fresh: true }); expect(neu[0]).toBe(200); expect(neu[1]).toEqual({ accepted: true }); const n = await countAuditEvents(app.prisma, { actorId: ownerId, action: "automation.webhook_secret_regenerated" }); expect(n).toBeGreaterThanOrEqual(1); }); it("requires a WEBHOOK trigger for regenerate (404 otherwise)", async () => { const { body } = await createAutomation(ownerHeaders, workspaceId, { name: "Hook10" }); const id = body.id as string; const res = await app.request("POST", `/api/v1/automations/${id}/webhook/regenerate`, { headers: ownerHeaders }); expect(res.status).toBe(404); }); }); // --------------------------------------------------------------------------- // Manual run + runs history // --------------------------------------------------------------------------- describe("manual run + runs history", () => { let headers: Record; let workspaceId: string; let ownerId: string; let id: string; beforeAll(async () => { const { owner, headers: h, workspaceId: ws } = await newWorkspace("Runs"); headers = h; workspaceId = ws; ownerId = owner.userId; const { body } = await createAutomation(headers, ws, { name: "Manual", actions: [validAction(0)] }); id = body.id as string; }); it("422s a manual run when the automation has no actions", async () => { const { body } = await createAutomation(headers, workspaceId, { name: "NoActions" }); const empty = body.id as string; const res = await app.request("POST", `/api/v1/automations/${empty}/run`, { headers, body: {} }); expect(res.status).toBe(422); expect(errCode(await json(res))).toBe("UNPROCESSABLE_ENTITY"); }); it("202 + runId; creates a PENDING run (DRAFT dry-run path)", async () => { const res = await app.request("POST", `/api/v1/automations/${id}/run`, { headers, body: { input: { from: "test" } } }); expect(res.status).toBe(202); const b = await json(res); expect(typeof b.runId).toBe("string"); const runId = b.runId as string; const run = await app.prisma.automationRun.findUnique({ where: { id: runId } }); expect(run).not.toBeNull(); expect(run!.status).toBe("PENDING"); expect(run!.retryCount).toBe(0); expect((run!.triggerEvent as Record).type).toBe("manual"); expect((run!.triggerEvent as Record).input).toEqual({ from: "test" }); const n = await countAuditEvents(app.prisma, { actorId: ownerId, action: "automation.manual_triggered" }); expect(n).toBeGreaterThanOrEqual(1); }); it("lists runs (desc) with triggerType + a nextCursor", async () => { // Seed a few more runs so pagination is exercised. for (let i = 0; i < 3; i++) { const r = await app.request("POST", `/api/v1/automations/${id}/run`, { headers, body: {} }); expect(r.status).toBe(202); await new Promise((r2) => setTimeout(r2, 5)); // distinct createdAt } const res = await app.request("GET", `/api/v1/automations/${id}/runs`, { headers }); expect(res.status).toBe(200); const b = await json(res); expect((b.data as unknown[]).length).toBe(4); const first = (b.data as Record)[0]; expect(first.triggerType).toBe("manual"); expect(b.nextCursor).toBeNull(); // 4 < limit 50 const paged = await app.request("GET", `/api/v1/automations/${id}/runs?limit=2`, { headers }); const pb = await json(paged); expect((pb.data as unknown[]).length).toBe(2); expect(typeof pb.nextCursor).toBe("string"); }); it("filters runs by status", async () => { const res = await app.request("GET", `/api/v1/automations/${id}/runs?status=PENDING`, { headers }); const b = await json(res); expect((b.data as Record[]).every((r) => r.status === "PENDING")).toBe(true); expect((b.data as unknown[]).length).toBe(4); }); it("returns run detail (input/output/logs) by id", async () => { const list = await json(await app.request("GET", `/api/v1/automations/${id}/runs`, { headers })); const runId = (list.data as Record)[0]!.id as string; const res = await app.request("GET", `/api/v1/automations/${id}/runs/${runId}`, { headers }); expect(res.status).toBe(200); const b = await json(res); expect(b.id).toBe(runId); expect(b.logs).toEqual([]); expect(b.input).toBeTruthy(); expect(b.output).toBeNull(); }); it("404s a run from another automation (cross-automation isolation)", async () => { const { body } = await createAutomation(headers, workspaceId, { name: "Other", actions: [validAction(9)] }); const otherId = body.id as string; const otherRun = await (await app.request("POST", `/api/v1/automations/${otherId}/run`, { headers, body: {} })).json(); const list = await json(await app.request("GET", `/api/v1/automations/${id}/runs`, { headers })); const foreignRunId = (otherRun as { runId: string }).runId; const res = await app.request("GET", `/api/v1/automations/${id}/runs/${foreignRunId}`, { headers }); expect(res.status).toBe(404); }); it("retry: 409 for PENDING/SUCCEEDED, 200 for FAILED (resets + re-enqueues)", async () => { const list = await json(await app.request("GET", `/api/v1/automations/${id}/runs`, { headers })); const runId = (list.data as Record)[0]!.id as string; // PENDING → not retryable. const p = await app.request("POST", `/api/v1/automations/${id}/runs/${runId}/retry`, { headers }); expect(p.status).toBe(409); // Force FAILED + error, then retry → 200, reset to PENDING. await app.prisma.automationRun.update({ where: { id: runId }, data: { status: "FAILED", error: { message: "boom" }, retryCount: 2, finishedAt: new Date() } }); const ok = await app.request("POST", `/api/v1/automations/${id}/runs/${runId}/retry`, { headers }); expect(ok.status).toBe(200); const ob = await json(ok); expect(ob.runId).toBe(runId); expect(ob.status).toBe("PENDING"); const after = await app.prisma.automationRun.findUnique({ where: { id: runId } }); expect(after!.status).toBe("PENDING"); expect(after!.retryCount).toBe(0); expect(after!.error).toBeNull(); // Force SUCCEEDED → retry 409 (terminal, not retryable). await app.prisma.automationRun.update({ where: { id: runId }, data: { status: "SUCCEEDED" } }); const s = await app.request("POST", `/api/v1/automations/${id}/runs/${runId}/retry`, { headers }); expect(s.status).toBe(409); // Force DEAD_LETTER → retry 200. await app.prisma.automationRun.update({ where: { id: runId }, data: { status: "DEAD_LETTER", error: { fatal: true } } }); const d = await app.request("POST", `/api/v1/automations/${id}/runs/${runId}/retry`, { headers }); expect(d.status).toBe(200); const n = await countAuditEvents(app.prisma, { actorId: ownerId, action: "automation.run_retried" }); expect(n).toBeGreaterThanOrEqual(2); }); it("a GUEST cannot run/retry (403)", async () => { const guest = await createUser(app.request, { name: "GuestRuns" }); await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST"); const gh = authHeaders(guest.accessToken!); const list = await app.request("GET", `/api/v1/automations/${id}/runs`, { headers: gh }); // Runs history is an EDIT surface (the editor needs it for history); a // VIEW-only guest is denied. expect(list.status).toBe(403); const run = await app.request("POST", `/api/v1/automations/${id}/run`, { headers: gh, body: {} }); expect(run.status).toBe(403); }); }); // --------------------------------------------------------------------------- // Scoped-database permission model (spec §1: EDIT on the scoped database) // // The permission model is ROLE-DRIVEN for members (MEMBER → EDIT at // workspace level); per-database grants matter for a GUEST (VIEW baseline) // who has been granted EDIT on ONE specific database: // - automation scoped to the granted DB → writable // - automation scoped to ANOTHER DB → 403 (the grant doesn't leak) // - unscoped automation → 403 (no baseline EDIT) // --------------------------------------------------------------------------- describe("scoped-database permission model", () => { let ownerHeaders: Record; let workspaceId: string; let dbA: string; let dbB: string; let guestHeaders: Record; beforeAll(async () => { const { owner, headers: h, workspaceId: ws } = await newWorkspace("Scoped"); ownerHeaders = h; workspaceId = ws; const mk = async (title: string) => { // A database is a page of type "database" (no POST /databases route). const res = await app.request("POST", `/api/v1/workspaces/${ws}/pages`, { headers: h, body: { title, type: "database" } }); expect([200, 201]).toContain(res.status); return (await json(res)) as { databaseId: string }; }; dbA = (await mk("DB A")).databaseId; dbB = (await mk("DB B")).databaseId; // GUEST (VIEW baseline) with an explicit EDIT grant on dbA ONLY. const guest = await createUser(app.request, { name: "ScopedGuest" }); await addMemberViaSeam(app.request, app.internalServiceToken, ws, guest.email, "GUEST"); await app.prisma.permission.create({ data: { workspaceId: ws, granteeType: "user", granteeId: guest.userId, resourceType: "database", resourceId: dbA, level: "EDIT" } }); guestHeaders = authHeaders(guest.accessToken!); }); it("can write an automation scoped to the granted database (dbA)", async () => { const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/automations`, { headers: guestHeaders, body: { name: "dbA auto", databaseId: dbA } }); expect(res.status).toBe(201); const id = (await json(res)).id as string; const patch = await app.request("PATCH", `/api/v1/automations/${id}`, { headers: guestHeaders, body: { description: "granted" } }); expect(patch.status).toBe(200); }); it("CANNOT write an automation scoped to a database without the grant (dbB)", async () => { const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/automations`, { headers: guestHeaders, body: { name: "dbB auto", databaseId: dbB } }); expect(res.status).toBe(403); expect(errCode(await json(res))).toBe("FORBIDDEN"); // …and can't write an OWNER-created automation that is dbB-scoped. const owned = await createAutomation(ownerHeaders, workspaceId, { name: "Owner dbB", databaseId: dbB }); expect(owned.res.status).toBe(201); const patch = await app.request("PATCH", `/api/v1/automations/${owned.body.id as string}`, { headers: guestHeaders, body: { description: "nope" } }); expect(patch.status).toBe(403); }); it("CANNOT write an unscoped automation (no workspace-level EDIT baseline)", async () => { const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/automations`, { headers: guestHeaders, body: { name: "unscoped" } }); expect(res.status).toBe(403); }); it("CAN still read automations (VIEW baseline)", async () => { const list = await app.request("GET", `/api/v1/workspaces/${workspaceId}/automations`, { headers: guestHeaders }); expect(list.status).toBe(200); const b = await json(list); expect((b.data as unknown[]).length).toBeGreaterThanOrEqual(2); }); it("an explicit NONE grant on the database hard-stops even the granted one", async () => { // Flip the guest's dbA grant to an explicit NONE (TASK-0104 explicit-deny). // UPDATE, not create: the Permission unique key is (workspace, grantee, // resource) and does NOT include level — a second row would be P2002. const grant = await app.prisma.permission.findFirst({ where: { resourceType: "database", resourceId: dbA, level: "EDIT" } }); expect(grant).not.toBeNull(); await app.prisma.permission.update({ where: { id: grant!.id }, data: { level: "NONE" } }); const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/automations`, { headers: guestHeaders, body: { name: "dbA after deny", databaseId: dbA } }); expect(res.status).toBe(403); }); });