1
24import { afterAll, beforeAll, describe, expect, it } from "vitest";
25import {
26 AUTOMATION_MAX_ACTIONS,
27 AUTOMATION_MAX_TRIGGERS,
28 buildWebhookSignature
29} from "@factory/shared";
30import { createTestApp, type TestAppHandle } from "./helpers/api";
31import {
32 addMemberViaSeam,
33 authHeaders,
34 countAuditEvents,
35 createWorkspace,
36 createUser
37} from "./helpers/seed";
38
39let app: TestAppHandle;
40
41beforeAll(async () => {
42 app = await createTestApp();
43}, 180_000);
44afterAll(async () => {
45 await app?.close();
46});
47
48
49
50
51
52const json = async (res: Response): Promise<Record<string, unknown>> =>
53 (await res.json().catch(() => ({}))) as Record<string, unknown>;
54
55
56const errCode = (body: Record<string, unknown>): string | undefined =>
57 (body.error as { code?: string } | undefined)?.code;
58
59
60const errDetails = (body: Record<string, unknown>): { field?: string }[] =>
61 ((body.error as { details?: { field?: string }[] } | undefined)?.details ?? []);
62
63
64const validAction = (i = 0) => ({
65 type: "UPDATE_RECORD",
66 config: { recordId: "from_trigger", propertyValues: { Title: `done-${i}` } },
67 retries: 3,
68 timeoutMs: 30_000
69});
70
71
76async function newWorkspace(name: string) {
77 const owner = await createUser(app.request, { name });
78 const ws = await createWorkspace(app.request, authHeaders(owner.accessToken!), name);
79 return { owner, headers: authHeaders(owner.accessToken!), workspaceId: ws.workspaceId };
80}
81
82
83async function createAutomation(
84 headers: Record<string, string>,
85 workspaceId: string,
86 body: Record<string, unknown> = { name: "Auto" },
87) {
88 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/automations`, {
89 headers,
90 body
91 });
92 const b = await json(res);
93 return { res, body: b };
94}
95
96
97async function setStatus(headers: Record<string, string>, id: string, status: string) {
98 const res = await app.request("POST", `/api/v1/automations/${id}/status`, {
99 headers,
100 body: { status }
101 });
102 return [res.status, await json(res)] as const;
103}
104
105
106async function attachWebhook(headers: Record<string, string>, id: string, extraTriggers: unknown[] = []) {
107 const res = await app.request("PUT", `/api/v1/automations/${id}/triggers`, {
108 headers,
109 body: { triggers: [{ type: "WEBHOOK", config: {} }, ...extraTriggers] }
110 });
111 expect(res.status).toBe(200);
112 const b = await json(res);
113 const wh = (b.webhooks as { webhookId: string; secret: string; url: string }[])[0];
114 expect(wh, "webhook secret returned once").toBeTruthy();
115 return wh!;
116}
117
118
119async function fireWebhook(webhookId: string, secret: string, payload: unknown, tsSeconds?: number) {
120 const raw = JSON.stringify(payload);
121 const ts = tsSeconds ?? Math.floor(Date.now() / 1000);
122 const res = await app.request("POST", `/api/v1/webhooks/incoming/${webhookId}`, {
123 body: payload,
124 headers: { "x-fs-signature": buildWebhookSignature(secret, ts, raw) }
125 });
126 return [res.status, await json(res)] as const;
127}
128
129
130
131
132
133describe("automation CRUD", () => {
134 let headers: Record<string, string>;
135 let workspaceId: string;
136
137 beforeAll(async () => {
138 ({ headers, workspaceId } = await newWorkspace("CRUD"));
139 });
140
141 it("creates a DRAFT automation (201) with default fields", async () => {
142 const { res, body } = await createAutomation(headers, workspaceId, {
143 name: "Inbox triage",
144 description: "Routes new leads"
145 });
146 expect(res.status).toBe(201);
147 expect(body.status).toBe("DRAFT");
148 expect(body.name).toBe("Inbox triage");
149 expect(body.description).toBe("Routes new leads");
150 expect(body.databaseId).toBeNull();
151 expect(body.triggers).toEqual([]);
152 expect(body.condition).toBeNull();
153 expect(body.actions).toEqual([]);
154 expect(body.latestRun).toBeNull();
155 expect(typeof body.id).toBe("string");
156 });
157
158 it("rejects invalid names (zod 400)", async () => {
159 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/automations`, {
160 headers,
161 body: { name: "" }
162 });
163 expect(res.status).toBe(400);
164 const body = await json(res);
165 expect(errCode(body)).toBe("VALIDATION_ERROR");
166 });
167
168 it("creates with inline sub-resources (triggers + condition + actions)", async () => {
169 const { res, body } = await createAutomation(headers, workspaceId, {
170 name: "With parts",
171 triggers: [{ type: "RECORD_CREATED", config: {} }],
172 condition: { field: { ref: "property", name: "Status" }, operator: "eq", value: "Open" },
173 actions: [validAction(1), validAction(2)]
174 });
175 expect(res.status).toBe(201);
176 expect(body.triggers).toHaveLength(1);
177 expect((body.condition as Record<string, unknown>).field).toBeTruthy();
178 expect(body.actions).toHaveLength(2);
179 });
180
181 it("lists automations (default limit 50) newest-first with latestRun", async () => {
182 const res = await app.request("GET", `/api/v1/workspaces/${workspaceId}/automations`, { headers });
183 expect(res.status).toBe(200);
184 const body = await json(res);
185 const data = body.data as Record<string, unknown>[];
186 expect(data.length).toBe(2);
187 expect(body.nextCursor).toBeNull();
188
189 const t0 = new Date(data[1]!.createdAt as string).getTime();
190 const t1 = new Date(data[0]!.createdAt as string).getTime();
191 expect(t0).toBeLessThanOrEqual(t1);
192 expect(data[0]!.name).toBe("With parts");
193 expect(data[0]!.latestRun).toBeNull();
194 expect(data[0]!.triggers).toEqual(["RECORD_CREATED"]);
195 expect(data[0]!.actions).toEqual(["UPDATE_RECORD", "UPDATE_RECORD"]);
196 });
197
198 it("respects the status filter", async () => {
199 const res = await app.request("GET", `/api/v1/workspaces/${workspaceId}/automations?status=DRAFT`, { headers });
200 const body = await json(res);
201 expect((body.data as unknown[]).length).toBe(2);
202
203 const none = await app.request("GET", `/api/v1/workspaces/${workspaceId}/automations?status=ACTIVE`, { headers });
204 expect(((await json(none)).data as unknown[]).length).toBe(0);
205 });
206
207 it("pages with a cursor (limit=1 → 2 pages)", async () => {
208 const p1 = await app.request("GET", `/api/v1/workspaces/${workspaceId}/automations?limit=1`, { headers });
209 const b1 = await json(p1);
210 expect((b1.data as unknown[]).length).toBe(1);
211 expect(typeof b1.nextCursor).toBe("string");
212
213 const p2 = await app.request("GET", `/api/v1/workspaces/${workspaceId}/automations?limit=1&cursor=${encodeURIComponent(b1.nextCursor as string)}`, { headers });
214 const b2 = await json(p2);
215 const d1 = (b1.data as Record<string, unknown>)[0]!.id;
216 const d2 = (b2.data as Record<string, unknown>)[0]!.id;
217 expect(d2).not.toBe(d1);
218 expect(b2.nextCursor).toBeNull();
219 });
220
221 it("rejects an invalid cursor (400)", async () => {
222 const res = await app.request("GET", `/api/v1/workspaces/${workspaceId}/automations?cursor=abc123def`, { headers });
223 expect(res.status).toBe(400);
224 expect(errCode(await json(res))).toBe("VALIDATION_ERROR");
225 });
226
227 it("422s a create with a foreign or fabricated databaseId (server-side scope check)", async () => {
228 const other = await newWorkspace("OtherWs");
229
230
231 const dbRes = await app.request("POST", `/api/v1/workspaces/${other.workspaceId}/pages`, {
232 headers: other.headers,
233 body: { title: "Foreign DB", type: "database" }
234 });
235 expect([200, 201]).toContain(dbRes.status);
236 const foreignDb = ((await json(dbRes)) as { databaseId: string }).databaseId;
237
238 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/automations`, {
239 headers,
240 body: { name: "Cross ws", databaseId: foreignDb }
241 });
242 expect(res.status).toBe(422);
243 expect(errCode(await json(res))).toBe("UNPROCESSABLE_ENTITY");
244
245 const res2 = await app.request("POST", `/api/v1/workspaces/${workspaceId}/automations`, {
246 headers,
247 body: { name: "Ghost db", databaseId: "zzzzzzzzzzzzzzzzzzzzzz" }
248 });
249 expect(res2.status).toBe(422);
250 });
251
252 it("gets full detail (incl. latestRun) by id", async () => {
253 const list = await json(await app.request("GET", `/api/v1/workspaces/${workspaceId}/automations?status=DRAFT&limit=1`, { headers }));
254 const id = (list.data as Record<string, unknown>)[0]!.id as string;
255 const res = await app.request("GET", `/api/v1/automations/${id}`, { headers });
256 expect(res.status).toBe(200);
257 const body = await json(res);
258 expect(body.id).toBe(id);
259 expect(body.actions).toHaveLength(2);
260 expect(body.latestRun).toBeNull();
261 });
262
263 it("returns 404 for a non-existent automation (no existence leak)", async () => {
264 const res = await app.request("GET", `/api/v1/automations/${"x".repeat(20)}`, { headers });
265 expect(res.status).toBe(404);
266 expect(errCode(await json(res))).toBe("NOT_FOUND");
267 });
268
269 it("patches name/description", async () => {
270 const { res, body: created } = await createAutomation(headers, workspaceId, { name: "Patched" });
271 const aid = created.id as string;
272 const res2 = await app.request("PATCH", `/api/v1/automations/${aid}`, {
273 headers,
274 body: { name: "Patched v2", description: "updated" }
275 });
276 expect(res2.status).toBe(200);
277 const b2 = await json(res2);
278 expect(b2.name).toBe("Patched v2");
279 expect(b2.description).toBe("updated");
280 });
281
282 it("deletes → ARCHIVED (soft; { archived: true })", async () => {
283 const { res, body: created } = await createAutomation(headers, workspaceId, { name: "Doomed" });
284 const aid = created.id as string;
285 const del = await app.request("DELETE", `/api/v1/automations/${aid}`, { headers });
286 expect(del.status).toBe(200);
287 expect(await json(del)).toEqual({ archived: true });
288
289 const after = await app.request("GET", `/api/v1/automations/${aid}`, { headers });
290 expect((await json(after)).status).toBe("ARCHIVED");
291 });
292
293 it("does not hard-delete: the row still exists (re-activation allowed)", async () => {
294 const { res, body: created } = await createAutomation(headers, workspaceId, { name: "Doomed2" });
295 const aid = created.id as string;
296 await app.request("DELETE", `/api/v1/automations/${aid}`, { headers });
297 const count = await app.prisma.automation.count({ where: { id: aid } });
298 expect(count).toBe(1);
299 const [s] = await setStatus(headers, aid, "ACTIVE");
300 expect(s).toBe(200);
301 });
302});
303
304
305
306
307
308describe("automation authorization", () => {
309 let ownerHeaders: Record<string, string>;
310 let workspaceId: string;
311 let ownerId: string;
312 let guestHeaders: Record<string, string>;
313 let automationId: string;
314
315 beforeAll(async () => {
316 const { owner, headers, workspaceId: ws } = await newWorkspace("Auth");
317 ownerHeaders = headers;
318 workspaceId = ws;
319 ownerId = owner.userId;
320 const { body } = await createAutomation(headers, ws, { name: "Protected" });
321 automationId = body.id as string;
322
323 const guest = await createUser(app.request, { name: "Guest" });
324 await addMemberViaSeam(app.request, app.internalServiceToken, ws, guest.email, "GUEST");
325 guestHeaders = authHeaders(guest.accessToken!);
326 });
327
328 it("a non-member cannot list (403) nor read (403, no leak)", async () => {
329 const stranger = await createUser(app.request, { name: "Stranger" });
330 const sh = authHeaders(stranger.accessToken!);
331 const list = await app.request("GET", `/api/v1/workspaces/${workspaceId}/automations`, { headers: sh });
332 expect(list.status).toBe(403);
333 const get = await app.request("GET", `/api/v1/automations/${automationId}`, { headers: sh });
334 expect(get.status).toBe(403);
335 expect(errCode(await json(get))).toBe("FORBIDDEN");
336 });
337
338 it("a non-member cannot create (403)", async () => {
339 const stranger = await createUser(app.request, { name: "Stranger2" });
340 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/automations`, {
341 headers: authHeaders(stranger.accessToken!),
342 body: { name: "Nope" }
343 });
344 expect(res.status).toBe(403);
345 });
346
347 it("a GUEST can read but not write (VIEW-only 403)", async () => {
348 const read = await app.request("GET", `/api/v1/automations/${automationId}`, { headers: guestHeaders });
349 expect(read.status).toBe(200);
350 const list = await app.request("GET", `/api/v1/workspaces/${workspaceId}/automations`, { headers: guestHeaders });
351 expect(list.status).toBe(200);
352
353 const patch = await app.request("PATCH", `/api/v1/automations/${automationId}`, { headers: guestHeaders, body: { name: "hack" } });
354 expect(patch.status).toBe(403);
355 const del = await app.request("DELETE", `/api/v1/automations/${automationId}`, { headers: guestHeaders });
356 expect(del.status).toBe(403);
357 const create = await app.request("POST", `/api/v1/workspaces/${workspaceId}/automations`, { headers: guestHeaders, body: { name: "guest auto" } });
358 expect(create.status).toBe(403);
359 const status = await app.request("POST", `/api/v1/automations/${automationId}/status`, { headers: guestHeaders, body: { status: "ACTIVE" } });
360 expect(status.status).toBe(403);
361 });
362
363 it("the OWNER can write (baseline for the GUEST contrast)", async () => {
364 const res = await app.request("PATCH", `/api/v1/automations/${automationId}`, {
365 headers: ownerHeaders,
366 body: { description: "owner wrote" }
367 });
368 expect(res.status).toBe(200);
369 });
370
371 it("an unknown automation id → 404 even for the owner (no cross-ws leak)", async () => {
372 const res = await app.request("GET", `/api/v1/automations/${"y".repeat(20)}`, { headers: ownerHeaders });
373 expect(res.status).toBe(404);
374 });
375
376 it("audits the write (automation.updated exists for the owner)", async () => {
377 const n = await countAuditEvents(app.prisma, { actorId: ownerId, action: "automation.updated" });
378 expect(n).toBeGreaterThanOrEqual(1);
379 });
380});
381
382
383
384
385
386describe("automation status machine", () => {
387 let headers: Record<string, string>;
388 let workspaceId: string;
389 let ownerId: string;
390 let id: string;
391
392 beforeAll(async () => {
393 const { owner, headers: h, workspaceId: ws } = await newWorkspace("Status");
394 headers = h;
395 workspaceId = ws;
396 ownerId = owner.userId;
397 const { body } = await createAutomation(headers, ws, { name: "Machine" });
398 id = body.id as string;
399 });
400
401 it("walks all 6 valid transitions in one path", async () => {
402
403 let [s, b] = await setStatus(headers, id, "ACTIVE");
404 expect(s).toBe(200);
405 expect(b.status).toBe("ACTIVE");
406
407 [s, b] = await setStatus(headers, id, "PAUSED");
408 expect(s).toBe(200);
409 expect(b.status).toBe("PAUSED");
410
411 [s, b] = await setStatus(headers, id, "ACTIVE");
412 expect(s).toBe(200);
413 expect(b.status).toBe("ACTIVE");
414
415 [s, b] = await setStatus(headers, id, "ARCHIVED");
416 expect(s).toBe(200);
417 expect(b.status).toBe("ARCHIVED");
418
419 [s, b] = await setStatus(headers, id, "ACTIVE");
420 expect(s).toBe(200);
421 expect(b.status).toBe("ACTIVE");
422
423 [s] = await setStatus(headers, id, "PAUSED");
424 expect(s).toBe(200);
425 [s] = await setStatus(headers, id, "ARCHIVED");
426 expect(s).toBe(200);
427 });
428
429 it("rejects every invalid pair with 409 (each pair on a fresh row)", async () => {
430
431 const invalid: [string, string][] = [
432 ["DRAFT", "PAUSED"],
433 ["DRAFT", "ARCHIVED"],
434 ["ACTIVE", "DRAFT"],
435 ["PAUSED", "DRAFT"],
436 ["ARCHIVED", "DRAFT"],
437 ["ARCHIVED", "PAUSED"],
438 ["ACTIVE", "ACTIVE"],
439 ["DRAFT", "DRAFT"],
440 ["PAUSED", "PAUSED"],
441 ["ARCHIVED", "ARCHIVED"]
442 ];
443 for (const [from, to] of invalid) {
444 const { body } = await createAutomation(headers, workspaceId, { name: `T ${from}-${to}` });
445 const tid = body.id as string;
446
447 if (from === "ACTIVE") await setStatus(headers, tid, "ACTIVE");
448 else if (from === "PAUSED") {
449 await setStatus(headers, tid, "ACTIVE");
450 await setStatus(headers, tid, "PAUSED");
451 } else if (from === "ARCHIVED") {
452 await setStatus(headers, tid, "ACTIVE");
453 await setStatus(headers, tid, "ARCHIVED");
454 }
455 const [s, b] = await setStatus(headers, tid, to);
456 expect(s, `${from} → ${to} must 409`).toBe(409);
457 expect(errCode(b)).toBe("CONFLICT");
458 }
459 });
460
461 it("audits each transition (automation.status_changed with from/to)", async () => {
462 const n = await countAuditEvents(app.prisma, { actorId: ownerId, action: "automation.status_changed" });
463
464
465 expect(n).toBeGreaterThanOrEqual(7);
466
467 const row = await app.prisma.auditLog.findFirst({
468 where: { actorId: ownerId, action: "automation.status_changed", resourceId: id },
469 orderBy: { createdAt: "desc" }
470 });
471 expect(row).not.toBeNull();
472 const meta = row!.metadata as Record<string, unknown>;
473 expect(typeof meta.from).toBe("string");
474 expect(typeof meta.to).toBe("string");
475 expect(meta.to).not.toBe(meta.from);
476 });
477});
478
479
480
481
482
483describe("sub-resource PUTs", () => {
484 let headers: Record<string, string>;
485 let workspaceId: string;
486 let id: string;
487
488 beforeAll(async () => {
489 const { headers: h, workspaceId: ws } = await newWorkspace("Subres");
490 headers = h;
491 workspaceId = ws;
492 const { body } = await createAutomation(headers, ws, { name: "Subs" });
493 id = body.id as string;
494 });
495
496 describe("PUT triggers", () => {
497 it("validates each trigger config (path-located 422)", async () => {
498
499 const res = await app.request("PUT", `/api/v1/automations/${id}/triggers`, {
500 headers,
501 body: { triggers: [{ type: "PROPERTY_CHANGED", config: {} }] }
502 });
503 expect(res.status).toBe(422);
504 const b = await json(res);
505 expect(errCode(b)).toBe("UNPROCESSABLE_ENTITY");
506 const details = errDetails(b);
507 expect(details?.some((d) => (d.field ?? "").startsWith("triggers[0].config."))).toBe(true);
508 });
509
510 it("rejects >3 triggers (zod 400)", async () => {
511 const triggers = Array.from({ length: AUTOMATION_MAX_TRIGGERS + 1 }, () => ({ type: "MANUAL", config: {} }));
512 const res = await app.request("PUT", `/api/v1/automations/${id}/triggers`, { headers, body: { triggers } });
513 expect(res.status).toBe(400);
514 });
515
516 it("rejects an unknown trigger type (400)", async () => {
517 const res = await app.request("PUT", `/api/v1/automations/${id}/triggers`, {
518 headers,
519 body: { triggers: [{ type: "EXPLOSION", config: {} }] }
520 });
521 expect(res.status).toBe(400);
522 });
523
524 it("accepts a valid set (replace semantics) and returns sanitized triggers", async () => {
525 const res = await app.request("PUT", `/api/v1/automations/${id}/triggers`, {
526 headers,
527 body: {
528 triggers: [
529 { type: "RECORD_CREATED", config: {} },
530 { type: "PROPERTY_CHANGED", config: { properties: ["Status"] } }
531 ]
532 }
533 });
534 expect(res.status).toBe(200);
535 const b = await json(res);
536 expect(b.webhooks).toEqual([]);
537 expect((b.triggers as unknown[]).length).toBe(2);
538
539
540 const res2 = await app.request("PUT", `/api/v1/automations/${id}/triggers`, {
541 headers,
542 body: { triggers: [{ type: "PAGE_CREATED", config: {} }] }
543 });
544 expect(res2.status).toBe(200);
545 const detail = await app.request("GET", `/api/v1/automations/${id}`, { headers });
546 expect(((await json(detail)).triggers as unknown[]).length).toBe(1);
547 });
548
549 it("validates SCHEDULE cron (CRON_TOO_FREQUENT → 422)", async () => {
550
551 const res = await app.request("PUT", `/api/v1/automations/${id}/triggers`, {
552 headers,
553 body: { triggers: [{ type: "SCHEDULE", config: { cron: "*/5 * * * * *" } }] }
554 });
555 expect(res.status).toBe(422);
556
557 const ok = await app.request("PUT", `/api/v1/automations/${id}/triggers`, {
558 headers,
559 body: { triggers: [{ type: "SCHEDULE", config: { cron: "0 0 * * * *" } }] }
560 });
561 expect(ok.status).toBe(200);
562 });
563 });
564
565 describe("PUT conditions", () => {
566 const pred = (name = "Status", op = "eq", value = "Open") => ({
567 field: { ref: "property", name },
568 operator: op,
569 value
570 });
571
572 it("accepts null (removes the condition) and a valid tree", async () => {
573 let res = await app.request("PUT", `/api/v1/automations/${id}/conditions`, { headers, body: { condition: pred() } });
574 expect(res.status).toBe(200);
575 let b = await json(res);
576 expect((b.condition as Record<string, unknown>).operator).toBe("eq");
577
578 res = await app.request("PUT", `/api/v1/automations/${id}/conditions`, { headers, body: { condition: null } });
579 expect(res.status).toBe(200);
580 b = await json(res);
581 expect(b.condition).toBeNull();
582 const detail = await json(await app.request("GET", `/api/v1/automations/${id}`, { headers }));
583 expect(detail.condition).toBeNull();
584 });
585
586 it("rejects a tree deeper than 5 (CONDITION_TOO_COMPLEX, 422)", async () => {
587
588 let node = pred();
589 for (let i = 0; i < 6; i++) node = { op: "AND", children: [node, pred(`P${i}`, "is_empty")] };
590 const res = await app.request("PUT", `/api/v1/automations/${id}/conditions`, { headers, body: { condition: node } });
591 expect(res.status).toBe(422);
592 const b = await json(res);
593 expect(errCode(b)).toBe("UNPROCESSABLE_ENTITY");
594 });
595
596 it("rejects a structurally invalid tree (CONDITION_INVALID, 422)", async () => {
597
598 const res = await app.request("PUT", `/api/v1/automations/${id}/conditions`, {
599 headers,
600 body: { condition: { op: "AND", children: [pred()] } }
601 });
602 expect(res.status).toBe(422);
603 });
604 });
605
606 describe("PUT actions", () => {
607 it("accepts a valid ordered list (defaults for omitted knobs)", async () => {
608 const res = await app.request("PUT", `/api/v1/automations/${id}/actions`, {
609 headers,
610 body: { actions: [{ type: "UPDATE_RECORD", config: { propertyValues: { Title: "x" } } }, validAction(2)] }
611 });
612 expect(res.status).toBe(200);
613 const b = await json(res);
614 const a0 = (b.actions as Record<string, unknown>)[0];
615
616 expect((a0.config as Record<string, unknown>).recordId).toBe("from_trigger");
617 expect(a0.retries).toBe(3);
618 expect(a0.timeoutMs).toBe(30_000);
619 });
620
621 it("validates each action config (path-located 422)", async () => {
622
623 const res = await app.request("PUT", `/api/v1/automations/${id}/actions`, {
624 headers,
625 body: { actions: [{ type: "UPDATE_RECORD", config: { propertyValues: {} } }] }
626 });
627 expect(res.status).toBe(422);
628 const b = await json(res);
629 expect(errCode(b)).toBe("UNPROCESSABLE_ENTITY");
630 const details = errDetails(b);
631 expect(details?.some((d) => (d.field ?? "").startsWith("actions[0].config."))).toBe(true);
632 });
633
634 it("rejects >50 actions (zod 400)", async () => {
635 const actions = Array.from({ length: AUTOMATION_MAX_ACTIONS + 1 }, (_, i) => validAction(i));
636 const res = await app.request("PUT", `/api/v1/automations/${id}/actions`, { headers, body: { actions } });
637 expect(res.status).toBe(400);
638 });
639
640 it("rejects out-of-range retries/timeoutMs (422)", async () => {
641 const res = await app.request("PUT", `/api/v1/automations/${id}/actions`, {
642 headers,
643 body: { actions: [{ type: "UPDATE_RECORD", config: { propertyValues: { A: 1 } }, retries: 9 }] }
644 });
645 expect(res.status).toBe(422);
646 const res2 = await app.request("PUT", `/api/v1/automations/${id}/actions`, {
647 headers,
648 body: { actions: [{ type: "UPDATE_RECORD", config: { propertyValues: { A: 1 } }, timeoutMs: 400_000 }] }
649 });
650 expect(res2.status).toBe(422);
651 });
652 });
653});
654
655
656
657
658
659describe("incoming webhook", () => {
660 let ownerHeaders: Record<string, string>;
661 let workspaceId: string;
662 let ownerId: string;
663
664 beforeAll(async () => {
665 const { owner, headers: h, workspaceId: ws } = await newWorkspace("Webhook");
666 ownerHeaders = h;
667 workspaceId = ws;
668 ownerId = owner.userId;
669 });
670
671 it("mints the secret ONCE on first save and exposes url + webhookId", async () => {
672 const { body } = await createAutomation(ownerHeaders, workspaceId, { name: "Hook" });
673 const id = body.id as string;
674 const wh = await attachWebhook(ownerHeaders, id);
675 expect(wh.secret.startsWith("whsec_")).toBe(true);
676 expect(wh.webhookId).toMatch(/^[0-9a-f]{24}$/);
677 expect(wh.url).toBe(`http://localhost:4000/api/v1/webhooks/incoming/${wh.webhookId}`);
678
679
680
681 const detail = await json(await app.request("GET", `/api/v1/automations/${id}`, { headers: ownerHeaders }));
682 const trigger = (detail.triggers as Record<string, unknown>[]).find((t) => t.type === "WEBHOOK")!;
683 const cfg = trigger.config as Record<string, unknown>;
684 expect(cfg.secret).toBeUndefined();
685 expect(cfg.secretEncrypted).toBeUndefined();
686 expect(cfg.secretHash).toBeUndefined();
687 expect(cfg.webhookId).toBe(wh.webhookId);
688 expect(cfg.url).toBe(wh.url);
689
690
691
692
693
694 const resave = await app.request("PUT", `/api/v1/automations/${id}/triggers`, {
695 headers: ownerHeaders,
696 body: { triggers: [{ type: "WEBHOOK", config: { webhookId: wh.webhookId } }] }
697 });
698 expect(resave.status).toBe(200);
699 expect((await json(resave)).webhooks).toEqual([]);
700
701 const [as] = await setStatus(ownerHeaders, id, "ACTIVE");
702 expect(as).toBe(200);
703 const [fireStatus] = await fireWebhook(wh.webhookId, wh.secret, { survived: "resave" });
704 expect(fireStatus, "original secret must still verify after re-save").toBe(200);
705 });
706
707 it("accepts a valid signed payload → 200 {accepted:true} + PENDING run", async () => {
708 const { body } = await createAutomation(ownerHeaders, workspaceId, { name: "Hook2" });
709 const id = body.id as string;
710 const wh = await attachWebhook(ownerHeaders, id);
711
712 const [s] = await setStatus(ownerHeaders, id, "ACTIVE");
713 expect(s).toBe(200);
714
715 const [status, b] = await fireWebhook(wh.webhookId, wh.secret, { hello: "world" });
716 expect(status).toBe(200);
717 expect(b).toEqual({ accepted: true });
718
719 const run = await app.prisma.automationRun.findFirst({ where: { automationId: id } });
720 expect(run).not.toBeNull();
721 expect(run!.status).toBe("PENDING");
722 expect((run!.triggerEvent as Record<string, unknown>).type).toBe("webhook");
723 expect(((run!.triggerEvent as Record<string, unknown>).payload as Record<string, unknown>).hello).toBe("world");
724 });
725
726 it("404s an unknown webhookId (no existence leak)", async () => {
727 const res = await app.request("POST", `/api/v1/webhooks/incoming/${"0".repeat(24)}`, {
728 body: { x: 1 },
729 headers: { "x-fs-signature": buildWebhookSignature("whsec_deadbeef", Math.floor(Date.now() / 1000), JSON.stringify({ x: 1 })) }
730 });
731 expect(res.status).toBe(404);
732 expect(errCode(await json(res))).toBe("NOT_FOUND");
733 });
734
735 it("401s a tampered body (signature over different bytes)", async () => {
736 const { body } = await createAutomation(ownerHeaders, workspaceId, { name: "Hook3" });
737 const id = body.id as string;
738 const wh = await attachWebhook(ownerHeaders, id);
739 await setStatus(ownerHeaders, id, "ACTIVE");
740
741
742 const ts = Math.floor(Date.now() / 1000);
743 const goodSig = buildWebhookSignature(wh.secret, ts, JSON.stringify({ a: 1 }));
744 const res = await app.request("POST", `/api/v1/webhooks/incoming/${wh.webhookId}`, {
745 body: { a: 2 },
746 headers: { "x-fs-signature": goodSig }
747 });
748 expect(res.status).toBe(401);
749 expect(errCode(await json(res))).toBe("WEBHOOK_SIGNATURE_INVALID");
750 });
751
752 it("401s a stale timestamp (±5 min window)", async () => {
753 const { body } = await createAutomation(ownerHeaders, workspaceId, { name: "Hook4" });
754 const id = body.id as string;
755 const wh = await attachWebhook(ownerHeaders, id);
756 await setStatus(ownerHeaders, id, "ACTIVE");
757
758 const stale = Math.floor(Date.now() / 1000) - 360;
759 const [s, b] = await fireWebhook(wh.webhookId, wh.secret, { x: 1 }, stale);
760 expect(s).toBe(401);
761 expect(errCode(b)).toBe("WEBHOOK_TIMESTAMP_STALE");
762 });
763
764 it("401s a missing signature header", async () => {
765 const { body } = await createAutomation(ownerHeaders, workspaceId, { name: "Hook5" });
766 const id = body.id as string;
767 const wh = await attachWebhook(ownerHeaders, id);
768 await setStatus(ownerHeaders, id, "ACTIVE");
769
770 const res = await app.request("POST", `/api/v1/webhooks/incoming/${wh.webhookId}`, { body: { x: 1 } });
771 expect(res.status).toBe(401);
772 expect(errCode(await json(res))).toBe("WEBHOOK_SIGNATURE_INVALID");
773 });
774
775 it("404s a DRAFT automation's webhook (not registered yet)", async () => {
776 const { body } = await createAutomation(ownerHeaders, workspaceId, { name: "Hook6-draft" });
777 const id = body.id as string;
778 const wh = await attachWebhook(ownerHeaders, id);
779
780 const res = await app.request("POST", `/api/v1/webhooks/incoming/${wh.webhookId}`, {
781 body: { x: 1 },
782 headers: { "x-fs-signature": buildWebhookSignature(wh.secret, Math.floor(Date.now() / 1000), JSON.stringify({ x: 1 })) }
783 });
784 expect(res.status).toBe(404);
785 });
786
787 it("404s an ARCHIVED automation's webhook (endpoint removed)", async () => {
788 const { body } = await createAutomation(ownerHeaders, workspaceId, { name: "Hook7" });
789 const id = body.id as string;
790 const wh = await attachWebhook(ownerHeaders, id);
791 await setStatus(ownerHeaders, id, "ACTIVE");
792 await setStatus(ownerHeaders, id, "ARCHIVED");
793 const [s] = await fireWebhook(wh.webhookId, wh.secret, { x: 1 });
794 expect(s).toBe(404);
795 });
796
797 it("rate-limits to 60/min per webhookId (61st → 429)", async () => {
798 const { body } = await createAutomation(ownerHeaders, workspaceId, { name: "Hook8" });
799 const id = body.id as string;
800 const wh = await attachWebhook(ownerHeaders, id);
801 await setStatus(ownerHeaders, id, "ACTIVE");
802
803 let saw429 = false;
804 for (let i = 0; i < 61; i++) {
805 const [s] = await fireWebhook(wh.webhookId, wh.secret, { i });
806 if (i < 60) {
807 expect(s, `request ${i + 1} should be accepted`).toBe(200);
808 } else {
809 expect(s, "61st request should be 429").toBe(429);
810 saw429 = true;
811 }
812 }
813 expect(saw429).toBe(true);
814
815 const runs = await app.prisma.automationRun.count({ where: { automationId: id } });
816 expect(runs).toBe(60);
817 });
818
819 it("regenerates the secret (old invalidated, new works, audited)", async () => {
820 const { body } = await createAutomation(ownerHeaders, workspaceId, { name: "Hook9" });
821 const id = body.id as string;
822 const wh = await attachWebhook(ownerHeaders, id);
823 await setStatus(ownerHeaders, id, "ACTIVE");
824 const oldSecret = wh.secret;
825
826 const reg = await app.request("POST", `/api/v1/automations/${id}/webhook/regenerate`, { headers: ownerHeaders });
827 expect(reg.status).toBe(200);
828 const rb = await json(reg);
829 const newSecret = rb.secret as string;
830 expect(newSecret.startsWith("whsec_")).toBe(true);
831 expect(newSecret).not.toBe(oldSecret);
832 expect(rb.webhookId).toBe(wh.webhookId);
833
834
835 const old = await fireWebhook(wh.webhookId, oldSecret, { old: true });
836 expect(old[0]).toBe(401);
837 expect(old[1].error).toBeTruthy();
838
839 const neu = await fireWebhook(wh.webhookId, newSecret, { fresh: true });
840 expect(neu[0]).toBe(200);
841 expect(neu[1]).toEqual({ accepted: true });
842
843 const n = await countAuditEvents(app.prisma, {
844 actorId: ownerId,
845 action: "automation.webhook_secret_regenerated"
846 });
847 expect(n).toBeGreaterThanOrEqual(1);
848 });
849
850 it("requires a WEBHOOK trigger for regenerate (404 otherwise)", async () => {
851 const { body } = await createAutomation(ownerHeaders, workspaceId, { name: "Hook10" });
852 const id = body.id as string;
853 const res = await app.request("POST", `/api/v1/automations/${id}/webhook/regenerate`, { headers: ownerHeaders });
854 expect(res.status).toBe(404);
855 });
856});
857
858
859
860
861
862describe("manual run + runs history", () => {
863 let headers: Record<string, string>;
864 let workspaceId: string;
865 let ownerId: string;
866 let id: string;
867
868 beforeAll(async () => {
869 const { owner, headers: h, workspaceId: ws } = await newWorkspace("Runs");
870 headers = h;
871 workspaceId = ws;
872 ownerId = owner.userId;
873 const { body } = await createAutomation(headers, ws, {
874 name: "Manual",
875 actions: [validAction(0)]
876 });
877 id = body.id as string;
878 });
879
880 it("422s a manual run when the automation has no actions", async () => {
881 const { body } = await createAutomation(headers, workspaceId, { name: "NoActions" });
882 const empty = body.id as string;
883 const res = await app.request("POST", `/api/v1/automations/${empty}/run`, { headers, body: {} });
884 expect(res.status).toBe(422);
885 expect(errCode(await json(res))).toBe("UNPROCESSABLE_ENTITY");
886 });
887
888 it("202 + runId; creates a PENDING run (DRAFT dry-run path)", async () => {
889 const res = await app.request("POST", `/api/v1/automations/${id}/run`, {
890 headers,
891 body: { input: { from: "test" } }
892 });
893 expect(res.status).toBe(202);
894 const b = await json(res);
895 expect(typeof b.runId).toBe("string");
896 const runId = b.runId as string;
897
898 const run = await app.prisma.automationRun.findUnique({ where: { id: runId } });
899 expect(run).not.toBeNull();
900 expect(run!.status).toBe("PENDING");
901 expect(run!.retryCount).toBe(0);
902 expect((run!.triggerEvent as Record<string, unknown>).type).toBe("manual");
903 expect((run!.triggerEvent as Record<string, unknown>).input).toEqual({ from: "test" });
904
905 const n = await countAuditEvents(app.prisma, { actorId: ownerId, action: "automation.manual_triggered" });
906 expect(n).toBeGreaterThanOrEqual(1);
907 });
908
909 it("lists runs (desc) with triggerType + a nextCursor", async () => {
910
911 for (let i = 0; i < 3; i++) {
912 const r = await app.request("POST", `/api/v1/automations/${id}/run`, { headers, body: {} });
913 expect(r.status).toBe(202);
914 await new Promise((r2) => setTimeout(r2, 5));
915 }
916 const res = await app.request("GET", `/api/v1/automations/${id}/runs`, { headers });
917 expect(res.status).toBe(200);
918 const b = await json(res);
919 expect((b.data as unknown[]).length).toBe(4);
920 const first = (b.data as Record<string, unknown>)[0];
921 expect(first.triggerType).toBe("manual");
922 expect(b.nextCursor).toBeNull();
923
924 const paged = await app.request("GET", `/api/v1/automations/${id}/runs?limit=2`, { headers });
925 const pb = await json(paged);
926 expect((pb.data as unknown[]).length).toBe(2);
927 expect(typeof pb.nextCursor).toBe("string");
928 });
929
930 it("filters runs by status", async () => {
931 const res = await app.request("GET", `/api/v1/automations/${id}/runs?status=PENDING`, { headers });
932 const b = await json(res);
933 expect((b.data as Record<string, unknown>[]).every((r) => r.status === "PENDING")).toBe(true);
934 expect((b.data as unknown[]).length).toBe(4);
935 });
936
937 it("returns run detail (input/output/logs) by id", async () => {
938 const list = await json(await app.request("GET", `/api/v1/automations/${id}/runs`, { headers }));
939 const runId = (list.data as Record<string, unknown>)[0]!.id as string;
940 const res = await app.request("GET", `/api/v1/automations/${id}/runs/${runId}`, { headers });
941 expect(res.status).toBe(200);
942 const b = await json(res);
943 expect(b.id).toBe(runId);
944 expect(b.logs).toEqual([]);
945 expect(b.input).toBeTruthy();
946 expect(b.output).toBeNull();
947 });
948
949 it("404s a run from another automation (cross-automation isolation)", async () => {
950 const { body } = await createAutomation(headers, workspaceId, { name: "Other", actions: [validAction(9)] });
951 const otherId = body.id as string;
952 const otherRun = await (await app.request("POST", `/api/v1/automations/${otherId}/run`, { headers, body: {} })).json();
953 const list = await json(await app.request("GET", `/api/v1/automations/${id}/runs`, { headers }));
954 const foreignRunId = (otherRun as { runId: string }).runId;
955 const res = await app.request("GET", `/api/v1/automations/${id}/runs/${foreignRunId}`, { headers });
956 expect(res.status).toBe(404);
957 });
958
959 it("retry: 409 for PENDING/SUCCEEDED, 200 for FAILED (resets + re-enqueues)", async () => {
960 const list = await json(await app.request("GET", `/api/v1/automations/${id}/runs`, { headers }));
961 const runId = (list.data as Record<string, unknown>)[0]!.id as string;
962
963
964 const p = await app.request("POST", `/api/v1/automations/${id}/runs/${runId}/retry`, { headers });
965 expect(p.status).toBe(409);
966
967
968 await app.prisma.automationRun.update({
969 where: { id: runId },
970 data: { status: "FAILED", error: { message: "boom" }, retryCount: 2, finishedAt: new Date() }
971 });
972 const ok = await app.request("POST", `/api/v1/automations/${id}/runs/${runId}/retry`, { headers });
973 expect(ok.status).toBe(200);
974 const ob = await json(ok);
975 expect(ob.runId).toBe(runId);
976 expect(ob.status).toBe("PENDING");
977 const after = await app.prisma.automationRun.findUnique({ where: { id: runId } });
978 expect(after!.status).toBe("PENDING");
979 expect(after!.retryCount).toBe(0);
980 expect(after!.error).toBeNull();
981
982
983 await app.prisma.automationRun.update({ where: { id: runId }, data: { status: "SUCCEEDED" } });
984 const s = await app.request("POST", `/api/v1/automations/${id}/runs/${runId}/retry`, { headers });
985 expect(s.status).toBe(409);
986
987
988 await app.prisma.automationRun.update({ where: { id: runId }, data: { status: "DEAD_LETTER", error: { fatal: true } } });
989 const d = await app.request("POST", `/api/v1/automations/${id}/runs/${runId}/retry`, { headers });
990 expect(d.status).toBe(200);
991
992 const n = await countAuditEvents(app.prisma, { actorId: ownerId, action: "automation.run_retried" });
993 expect(n).toBeGreaterThanOrEqual(2);
994 });
995
996 it("a GUEST cannot run/retry (403)", async () => {
997 const guest = await createUser(app.request, { name: "GuestRuns" });
998 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST");
999 const gh = authHeaders(guest.accessToken!);
1000 const list = await app.request("GET", `/api/v1/automations/${id}/runs`, { headers: gh });
1001
1002
1003 expect(list.status).toBe(403);
1004 const run = await app.request("POST", `/api/v1/automations/${id}/run`, { headers: gh, body: {} });
1005 expect(run.status).toBe(403);
1006 });
1007});
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020describe("scoped-database permission model", () => {
1021 let ownerHeaders: Record<string, string>;
1022 let workspaceId: string;
1023 let dbA: string;
1024 let dbB: string;
1025 let guestHeaders: Record<string, string>;
1026
1027 beforeAll(async () => {
1028 const { owner, headers: h, workspaceId: ws } = await newWorkspace("Scoped");
1029 ownerHeaders = h;
1030 workspaceId = ws;
1031 const mk = async (title: string) => {
1032
1033 const res = await app.request("POST", `/api/v1/workspaces/${ws}/pages`, {
1034 headers: h,
1035 body: { title, type: "database" }
1036 });
1037 expect([200, 201]).toContain(res.status);
1038 return (await json(res)) as { databaseId: string };
1039 };
1040 dbA = (await mk("DB A")).databaseId;
1041 dbB = (await mk("DB B")).databaseId;
1042
1043 const guest = await createUser(app.request, { name: "ScopedGuest" });
1044 await addMemberViaSeam(app.request, app.internalServiceToken, ws, guest.email, "GUEST");
1045 await app.prisma.permission.create({
1046 data: { workspaceId: ws, granteeType: "user", granteeId: guest.userId, resourceType: "database", resourceId: dbA, level: "EDIT" }
1047 });
1048 guestHeaders = authHeaders(guest.accessToken!);
1049 });
1050
1051 it("can write an automation scoped to the granted database (dbA)", async () => {
1052 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/automations`, {
1053 headers: guestHeaders,
1054 body: { name: "dbA auto", databaseId: dbA }
1055 });
1056 expect(res.status).toBe(201);
1057 const id = (await json(res)).id as string;
1058 const patch = await app.request("PATCH", `/api/v1/automations/${id}`, { headers: guestHeaders, body: { description: "granted" } });
1059 expect(patch.status).toBe(200);
1060 });
1061
1062 it("CANNOT write an automation scoped to a database without the grant (dbB)", async () => {
1063 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/automations`, {
1064 headers: guestHeaders,
1065 body: { name: "dbB auto", databaseId: dbB }
1066 });
1067 expect(res.status).toBe(403);
1068 expect(errCode(await json(res))).toBe("FORBIDDEN");
1069
1070 const owned = await createAutomation(ownerHeaders, workspaceId, { name: "Owner dbB", databaseId: dbB });
1071 expect(owned.res.status).toBe(201);
1072 const patch = await app.request("PATCH", `/api/v1/automations/${owned.body.id as string}`, {
1073 headers: guestHeaders,
1074 body: { description: "nope" }
1075 });
1076 expect(patch.status).toBe(403);
1077 });
1078
1079 it("CANNOT write an unscoped automation (no workspace-level EDIT baseline)", async () => {
1080 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/automations`, {
1081 headers: guestHeaders,
1082 body: { name: "unscoped" }
1083 });
1084 expect(res.status).toBe(403);
1085 });
1086
1087 it("CAN still read automations (VIEW baseline)", async () => {
1088 const list = await app.request("GET", `/api/v1/workspaces/${workspaceId}/automations`, { headers: guestHeaders });
1089 expect(list.status).toBe(200);
1090 const b = await json(list);
1091 expect((b.data as unknown[]).length).toBeGreaterThanOrEqual(2);
1092 });
1093
1094 it("an explicit NONE grant on the database hard-stops even the granted one", async () => {
1095
1096
1097
1098 const grant = await app.prisma.permission.findFirst({ where: { resourceType: "database", resourceId: dbA, level: "EDIT" } });
1099 expect(grant).not.toBeNull();
1100 await app.prisma.permission.update({ where: { id: grant!.id }, data: { level: "NONE" } });
1101 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/automations`, {
1102 headers: guestHeaders,
1103 body: { name: "dbA after deny", databaseId: dbA }
1104 });
1105 expect(res.status).toBe(403);
1106 });
1107});
1108