/** * @factory/shared/automation — unit tests (TASK-0110/0111 contract tests). * * Covers: cron validation matrix, status machine, condition-tree validation * (depth/node caps + malformed), loop guard, webhook signature * (build/verify/stale/tamper/missing), interpolation (7 families + unknown + * overflow-free), truncateJson caps, run backoff schedule, key helpers. */ import { describe, expect, it } from "vitest"; import { createHmac } from "node:crypto"; import { AUTOMATION_MAX_DEPTH, AUTOMATION_STATUS_TRANSITIONS, buildWebhookSignature, cronCheck, dedupeKey, emailCapKey, generateWebhookSecret, interpolate, isValidAutomationTransition, loopGuard, normalizeConditionValue, runBackoffSeconds, truncateJson, validateActionDto, validateConditionTree, validateTriggerConfig, verifyWebhookSignature, webhookSecretHash, ActionConfigSchemas } from "./automation.js"; // --------------------------------------------------------------------------- // Cron // --------------------------------------------------------------------------- describe("cronCheck", () => { it("accepts standard 5-field expressions", () => { for (const expr of ["* * * * *", "0 9 * * *", "*/5 * * * *", "0 0 * * 1", "30 4 1 1 *"]) { const r = cronCheck(expr); expect(r.ok, expr).toBe(true); expect(r.nextFire).toBeInstanceOf(Date); } }); it("accepts 6-field (seconds) expressions at ≥ 60 s intervals", () => { expect(cronCheck("0 * * * * *").ok).toBe(true); // every minute (seconds field present) expect(cronCheck("0 */30 * * * *").ok).toBe(true); // every 30 MINUTES (field 2 = minutes) }); it("rejects 4-field expressions (CRON_INVALID)", () => { expect(cronCheck("* * * *").code).toBe("CRON_INVALID"); }); it("accepts */30 minutes (5-field, 30-min interval)", () => { expect(cronCheck("*/30 * * * *").ok).toBe(true); }); it("rejects intervals < 60 s (CRON_TOO_FREQUENT)", () => { // 6-field (seconds) expressions firing sub-minute; the 5-field // "0 */30 * * * *" (every 30 MINUTES) is valid and covered above. for (const expr of ["*/30 * * * * *", "0,30 * * * * *", "* * * * * *"]) { const r = cronCheck(expr); expect(r.ok, expr).toBe(false); expect(r.code, expr).toBe("CRON_TOO_FREQUENT"); } }); it("rejects invalid expressions (CRON_INVALID)", () => { for (const expr of ["", "not a cron", "* * * *", "61 * * * *", "*/0 * * * *", "a b c d e"]) { const r = cronCheck(expr); expect(r.ok, expr).toBe(false); expect(r.code, expr).toBe("CRON_INVALID"); } }); }); // --------------------------------------------------------------------------- // Status machine // --------------------------------------------------------------------------- describe("automation status machine", () => { it("accepts the 6 valid transitions", () => { expect(isValidAutomationTransition("DRAFT", "ACTIVE")).toBe(true); expect(isValidAutomationTransition("ACTIVE", "PAUSED")).toBe(true); expect(isValidAutomationTransition("PAUSED", "ACTIVE")).toBe(true); expect(isValidAutomationTransition("ACTIVE", "ARCHIVED")).toBe(true); expect(isValidAutomationTransition("PAUSED", "ARCHIVED")).toBe(true); expect(isValidAutomationTransition("ARCHIVED", "ACTIVE")).toBe(true); }); it("rejects invalid pairs (incl. DRAFT→PAUSED/ARCHIVED, all no-ops)", () => { // DRAFT→ARCHIVED is NOT a status transition (spec §2: DRAFT rows are // removed via the DELETE endpoint, which archives as a separate // operation). The 6 valid transitions are the ONLY valid ones. expect(isValidAutomationTransition("DRAFT", "PAUSED")).toBe(false); expect(isValidAutomationTransition("DRAFT", "ARCHIVED")).toBe(false); expect(isValidAutomationTransition("ACTIVE", "DRAFT")).toBe(false); expect(isValidAutomationTransition("PAUSED", "DRAFT")).toBe(false); expect(isValidAutomationTransition("ARCHIVED", "PAUSED")).toBe(false); expect(isValidAutomationTransition("ARCHIVED", "DRAFT")).toBe(false); // no-ops (from === to) are always invalid (client bug). expect(isValidAutomationTransition("DRAFT", "DRAFT")).toBe(false); expect(isValidAutomationTransition("ARCHIVED", "ARCHIVED")).toBe(false); expect(isValidAutomationTransition("PAUSED", "PAUSED")).toBe(false); expect(isValidAutomationTransition("ACTIVE", "ACTIVE")).toBe(false); }); it("exposes the transition table", () => { expect(AUTOMATION_STATUS_TRANSITIONS.ACTIVE).toEqual(expect.arrayContaining(["PAUSED", "ARCHIVED"])); expect(AUTOMATION_STATUS_TRANSITIONS.ARCHIVED).toEqual(["ACTIVE"]); }); }); // --------------------------------------------------------------------------- // Trigger config validation // --------------------------------------------------------------------------- describe("validateTriggerConfig", () => { it("accepts valid configs per type", () => { expect(validateTriggerConfig("RECORD_CREATED", { databaseId: "db_0000000000001" }).ok).toBe(true); expect(validateTriggerConfig("RECORD_CREATED", {}).ok).toBe(true); expect(validateTriggerConfig("PROPERTY_CHANGED", { properties: ["Priority"] }).ok).toBe(true); expect(validateTriggerConfig("SCHEDULE", { cron: "0 9 * * *" }).ok).toBe(true); expect(validateTriggerConfig("MANUAL", {}).ok).toBe(true); expect(validateTriggerConfig("WEBHOOK", {}).ok).toBe(true); }); it("rejects PROPERTY_CHANGED without properties", () => { expect(validateTriggerConfig("PROPERTY_CHANGED", {}).ok).toBe(false); }); it("rejects SCHEDULE with too-frequent cron (CRON_TOO_FREQUENT surfaced)", () => { const r = validateTriggerConfig("SCHEDULE", { cron: "*/30 * * * * *" }); // 6-field: every 30 s expect(r.ok).toBe(false); }); it("rejects SCHEDULE with invalid cron", () => { expect(validateTriggerConfig("SCHEDULE", { cron: "garbage" }).ok).toBe(false); }); it("rejects unknown extra keys on strict schemas", () => { expect(validateTriggerConfig("MANUAL", { foo: 1 }).ok).toBe(false); }); }); // --------------------------------------------------------------------------- // Condition tree validation (TASK-0111 caps) // --------------------------------------------------------------------------- const pred = (field: string, operator: string, value?: unknown): Record => value === undefined ? { field: { ref: "property", name: field }, operator } : { field: { ref: "property", name: field }, operator, value }; describe("validateConditionTree", () => { it("accepts a bare predicate", () => { expect(validateConditionTree(pred("Priority", "eq", "High")).ok).toBe(true); }); it("accepts empty tree (always true)", () => { expect(validateConditionTree(null).ok).toBe(true); expect(validateConditionTree(undefined).ok).toBe(true); }); it("accepts an AND of two predicates", () => { const tree = { op: "AND", children: [pred("a", "eq", 1), pred("b", "is_empty")] }; expect(validateConditionTree(tree).ok).toBe(true); }); it("requires value for non-empty operators", () => { expect(validateConditionTree(pred("a", "eq")).ok).toBe(false); expect(validateConditionTree(pred("a", "is_empty")).ok).toBe(true); expect(validateConditionTree(pred("a", "is_not_empty")).ok).toBe(true); }); it("rejects AND/OR with < 2 children and NOT with != 1", () => { expect(validateConditionTree({ op: "AND", children: [pred("a", "is_empty")]}).ok).toBe(false); expect(validateConditionTree({ op: "OR", children: [] }).ok).toBe(false); expect(validateConditionTree({ op: "NOT", child: pred("a", "is_empty") }).ok).toBe(true); expect(validateConditionTree({ op: "NOT" }).ok).toBe(false); // missing child expect(validateConditionTree({ op: "NOT", children: [pred("a", "is_empty")] }).ok).toBe(false); // old shape rejected }); it("rejects unknown operators", () => { expect(validateConditionTree({ field: { ref: "property", name: "a" }, operator: "like", value: "x" }).ok).toBe(false); }); it("rejects depth 6 groups (depth 5 ok)", () => { let depth5 = pred("a", "is_empty"); let depth6 = pred("a", "is_empty"); for (let i = 0; i < 5; i++) depth5 = { op: "NOT", child: depth5 }; for (let i = 0; i < 6; i++) depth6 = { op: "NOT", child: depth6 }; const r5 = validateConditionTree(depth5); expect(r5.ok).toBe(true); expect(r5.depth).toBe(5); const r6 = validateConditionTree(depth6); expect(r6.ok).toBe(false); expect(r6.code).toBe("CONDITION_TOO_COMPLEX"); }); it("enforces the 100-node cap (wide trees, depth 2)", () => { // A 2-level tree stays at depth 2 while the node count grows: // AND( [AND(10 leaves)] × 8, AND(k leaves) ) = 1 + 8 + 80 + 1 + k = 90 + k. // k=10 → 100 nodes (ok); k=11 → 101 nodes (CONDITION_TOO_COMPLEX). const leaf = () => pred("a", "is_empty"); const wide = (k: number): unknown => ({ op: "AND", children: [ ...Array.from({ length: 8 }, () => ({ op: "AND", children: Array.from({ length: 10 }, leaf) })), { op: "AND", children: Array.from({ length: k }, leaf) } ] }); const r100 = validateConditionTree(wide(10)); expect(r100.ok).toBe(true); expect(r100.nodes).toBe(100); const r101 = validateConditionTree(wide(11)); expect(r101.ok).toBe(false); expect(r101.code).toBe("CONDITION_TOO_COMPLEX"); expect(r101.nodes).toBe(101); }); it("rejects malformed nodes (missing field, bad ref)", () => { expect(validateConditionTree({ operator: "eq", value: 1 }).ok).toBe(false); expect(validateConditionTree({ field: { ref: "nope", name: "a" }, operator: "eq", value: 1 }).ok).toBe(false); expect(validateConditionTree("just a string").ok).toBe(false); }); }); describe("normalizeConditionValue", () => { const opts = { options: [{ id: "o1", name: "High" }, { id: "o2", name: "Low" }] }; it("SELECT/STATUS → option name", () => { expect(normalizeConditionValue("SELECT", opts, "o1")).toBe("High"); expect(normalizeConditionValue("STATUS", opts, "o2")).toBe("Low"); expect(normalizeConditionValue("SELECT", opts, null)).toBeNull(); }); it("MULTI_SELECT → sorted name array", () => { expect(normalizeConditionValue("MULTI_SELECT", opts, ["o2", "o1"])).toEqual(["High", "Low"]); }); it("PERSON → user-id array", () => { expect(normalizeConditionValue("PERSON", null, ["u1", "u2"])).toEqual(["u1", "u2"]); }); it("DATE range → start..end string", () => { expect(normalizeConditionValue("DATE", null, { start: "2026-01-01", end: "2026-01-31" })).toBe("2026-01-01..2026-01-31"); }); it("NUMBER/CHECKBOX passthrough (typed)", () => { expect(normalizeConditionValue("NUMBER", null, 42)).toBe(42); expect(normalizeConditionValue("CHECKBOX", null, true)).toBe(true); }); }); // --------------------------------------------------------------------------- // Action config validation (all 9 types) // --------------------------------------------------------------------------- describe("validateActionDto", () => { const base = { retries: 3, timeoutMs: 30_000 } as const; const call = (type: string, config: Record, extra: Record = {}) => validateActionDto({ type: type as never, config, ...base, ...extra }); it("UPDATE_RECORD: valid + documented invalids", () => { expect(call("UPDATE_RECORD", { recordId: "from_trigger", propertyValues: { Status: "In Progress" } }).ok).toBe(true); expect(call("UPDATE_RECORD", { propertyValues: {} }).ok).toBe(false); // empty const twentyOne: Record = Object.fromEntries(Array.from({ length: 21 }, (_, i) => [`p${i}`, "x"])); expect(call("UPDATE_RECORD", { propertyValues: twentyOne }).ok).toBe(false); // > 20 }); it("SEND_WEBHOOK: http:// rejected (https only)", () => { expect(call("SEND_WEBHOOK", { url: "http://example.com/hook" }).ok).toBe(false); expect(call("SEND_WEBHOOK", { url: "https://example.com/hook" }).ok).toBe(true); expect(call("SEND_WEBHOOK", { url: "https://example.com/hook", payload: Object.fromEntries(Array.from({ length: 70_000 }, (_, i) => [`k${i}`, "x"])) }).ok).toBe(false); // > 64 KB payload }); it("SEND_EMAIL: to must be email or token; cc ≤ 5", () => { expect(call("SEND_EMAIL", { to: "a@b.co", subject: "s", body: "b" }).ok).toBe(true); expect(call("SEND_EMAIL", { to: "not-an-email", subject: "s", body: "b" }).ok).toBe(false); expect(call("SEND_EMAIL", { to: "a@b.co", subject: "s", body: "b", cc: ["1@x.co", "2@x.co", "3@x.co", "4@x.co", "5@x.co", "6@x.co"] }).ok).toBe(false); }); it("CHANGE_STATUS / ASSIGN_USER shapes (propertyId per spec TASK-0112)", () => { const prop = "cp12345678901234567890123456789"; expect(call("CHANGE_STATUS", { recordId: "from_trigger", propertyId: prop, value: "Done" }).ok).toBe(true); expect(call("ASSIGN_USER", { propertyId: prop, userId: "trigger_user" }).ok).toBe(true); expect(call("ASSIGN_USER", { propertyId: prop }).ok).toBe(false); // userId required expect(call("ASSIGN_USER", { propertyId: "not-a-cuid", userId: "u1" }).ok).toBe(false); }); it("CREATE_PAGE title 1..200 + interpolation depth 4 rejected", () => { expect(call("CREATE_PAGE", { title: "Page {{record.Name}}" }).ok).toBe(true); expect(call("CREATE_PAGE", { title: "x".repeat(201) }).ok).toBe(false); expect(call("CREATE_PAGE", { title: "{{a.b.c.d}}" }).ok).toBe(false); }); it("CREATE_NOTIFICATION link must be an internal path", () => { expect(call("CREATE_NOTIFICATION", { to: "automation_creator", type: "AUTOMATION", body: "hi", link: "/w/x/db" }).ok).toBe(true); expect(call("CREATE_NOTIFICATION", { to: "automation_creator", type: "AUTOMATION", body: "hi", link: "https://x.co" }).ok).toBe(false); }); it("stubs validate config (CALL_INTEGRATION / RUN_AI_ACTION)", () => { expect(call("CALL_INTEGRATION", { integrationId: "c1234567890abc", operation: "sync" }).ok).toBe(true); expect(call("CALL_INTEGRATION", { integrationId: "x", operation: "sync" }).ok).toBe(false); expect(call("RUN_AI_ACTION", { prompt: "sum {{record.Title}}", task: "summarize" }).ok).toBe(true); expect(call("RUN_AI_ACTION", { prompt: "x", task: "nope" }).ok).toBe(false); }); it("retries 1..8 + timeoutMs 1000..300000 enforced", () => { expect(call("CREATE_PAGE", { title: "t" }, { retries: 0 }).ok).toBe(false); expect(call("CREATE_PAGE", { title: "t" }, { retries: 9 }).ok).toBe(false); expect(call("CREATE_PAGE", { title: "t" }, { retries: 1 }).ok).toBe(true); expect(call("CREATE_PAGE", { title: "t" }, { retries: 8 }).ok).toBe(true); expect(call("CREATE_PAGE", { title: "t" }, { timeoutMs: 500 }).ok).toBe(false); expect(call("CREATE_PAGE", { title: "t" }, { timeoutMs: 300_001 }).ok).toBe(false); expect(call("CREATE_PAGE", { title: "t" }, { timeoutMs: 300_000 }).ok).toBe(true); }); it("unknown action type rejected", () => { expect(call("SEND_SMS", {}).ok).toBe(false); }); it("exposes the 9 config schemas", () => { expect(Object.keys(ActionConfigSchemas)).toHaveLength(9); }); }); // --------------------------------------------------------------------------- // Loop guard (TASK-0113 contract) // --------------------------------------------------------------------------- describe("loopGuard", () => { it("allows events without automation context (depth 0)", () => { expect(loopGuard(null, "a1")).toEqual({ blocked: false }); expect(loopGuard({}, "a1")).toEqual({ blocked: false }); }); it("blocks self-trigger (same automationId)", () => { expect(loopGuard({ automationId: "a1", depth: 0 }, "a1").blocked).toBe(true); expect(loopGuard({ automationId: "a1", depth: 4 }, "a1")).toEqual({ blocked: true, reason: "self_trigger", depth: 4 }); }); it("allows other automations' events within the budget", () => { expect(loopGuard({ automationId: "a2", depth: 1 }, "a1").blocked).toBe(false); expect(loopGuard({ automationId: "a2", depth: 4 }, "a1").blocked).toBe(false); }); it("blocks depth >= 5", () => { expect(AUTOMATION_MAX_DEPTH).toBe(5); expect(loopGuard({ automationId: "a2", depth: 5 }, "a1")).toEqual({ blocked: true, reason: "depth_limit", depth: 5 }); expect(loopGuard({ automationId: "a2", depth: 99 }, "a1")).toEqual({ blocked: true, reason: "depth_limit", depth: 99 }); }); it("self-trigger wins over depth (both true → self_trigger)", () => { const d = loopGuard({ automationId: "a1", depth: 99 }, "a1"); expect(d.blocked).toBe(true); if (d.blocked) expect(d.reason).toBe("self_trigger"); }); }); // --------------------------------------------------------------------------- // Webhook signature (TASK-0110 §5) // --------------------------------------------------------------------------- describe("webhook signature", () => { const secret = generateWebhookSecret(); const body = JSON.stringify({ hello: "world" }); it("secret format: whsec_ + 64 hex", () => { expect(secret).toMatch(/^whsec_[0-9a-f]{64}$/); expect(webhookSecretHash(secret)).toMatch(/^[0-9a-f]{64}$/); }); it("build → verify round-trip", () => { const t = Math.floor(Date.now() / 1000); const header = buildWebhookSignature(secret, t, body); expect(verifyWebhookSignature(secret, header, body, t * 1000)).toBeNull(); }); it("tampered body → WEBHOOK_SIGNATURE_INVALID", () => { const t = Math.floor(Date.now() / 1000); const header = buildWebhookSignature(secret, t, body); expect(verifyWebhookSignature(secret, header, body + "x", t * 1000)).toBe("WEBHOOK_SIGNATURE_INVALID"); }); it("stale timestamp (> 5 min) → WEBHOOK_TIMESTAMP_STALE", () => { const t = Math.floor(Date.now() / 1000) - 10 * 60; // 10 min old const header = buildWebhookSignature(secret, t, body); expect(verifyWebhookSignature(secret, header, body, Date.now())).toBe("WEBHOOK_TIMESTAMP_STALE"); }); it("timestamp within ±5 min is accepted", () => { const t = Math.floor(Date.now() / 1000) - 4 * 60; const header = buildWebhookSignature(secret, t, body); expect(verifyWebhookSignature(secret, header, body, Date.now())).toBeNull(); }); it("wrong secret → WEBHOOK_SIGNATURE_INVALID", () => { const t = Math.floor(Date.now() / 1000); const header = buildWebhookSignature(secret, t, body); expect(verifyWebhookSignature(generateWebhookSecret(), header, body, t * 1000)).toBe("WEBHOOK_SIGNATURE_INVALID"); }); it("missing/malformed header → WEBHOOK_SIGNATURE_INVALID", () => { expect(verifyWebhookSignature(secret, undefined, body)).toBe("WEBHOOK_SIGNATURE_INVALID"); expect(verifyWebhookSignature(secret, "garbage", body)).toBe("WEBHOOK_SIGNATURE_INVALID"); expect(verifyWebhookSignature(secret, "t=123,v1=zzz", body)).toBe("WEBHOOK_SIGNATURE_INVALID"); }); it("verification is over t.rawBody (not parsed JSON)", () => { const t = Math.floor(Date.now() / 1000); const raw = '{"a":1, "b": 2 }'; // non-canonical spacing const expected = createHmac("sha256", secret).update(`${t}.${raw}`).digest("hex"); const header = `t=${t},v1=${expected}`; expect(verifyWebhookSignature(secret, header, raw, t * 1000)).toBeNull(); expect(verifyWebhookSignature(secret, header, '{"a":1,"b":2}', t * 1000)).toBe("WEBHOOK_SIGNATURE_INVALID"); }); }); // --------------------------------------------------------------------------- // Interpolation (TASK-0112 §9) // --------------------------------------------------------------------------- describe("interpolate", () => { const vars = { record: { id: "r1", Title: "My Record", Priority: "High" }, event: { type: "record.created", payload: { category: "bug", deep: { x: 1 } } }, input: { note: "hello" }, automation: { id: "a1", name: "Auto" }, run: { id: "run1" } }; it("resolves all 7 key families", () => { const { value } = interpolate( "{{record.Title}}|{{record.id}}|{{event.type}}|{{event.payload.category}}|{{input.note}}|{{automation.id}}|{{run.id}}", vars ); expect(value).toBe("My Record|r1|record.created|bug|hello|a1|run1"); }); it("unknown key → empty string + warning", () => { const { value, warnings } = interpolate("{{missing.thing}}", vars); expect(value).toBe(""); expect(warnings).toEqual(["unknown interpolation key: {{missing.thing}}"]); }); it("no warnings for known families with missing leaves", () => { const { value, warnings } = interpolate("[{{record.Absent}}]", vars); expect(value).toBe("[]"); expect(warnings).toEqual([]); }); it("object values JSON-stringified", () => { const { value } = interpolate("{{record}}", vars); expect(value).toBe(JSON.stringify(vars.record)); }); it("leaves non-token text intact", () => { expect(interpolate("no tokens here", vars).value).toBe("no tokens here"); }); }); // --------------------------------------------------------------------------- // Truncation + backoff + keys // --------------------------------------------------------------------------- describe("truncateJson", () => { it("passes small values through untouched", () => { const r = truncateJson({ a: 1 }, 1024); expect(r.truncated).toBe(false); expect(r.value).toEqual({ a: 1 }); }); it("truncates oversized objects with a flag", () => { const big = { small: 1, big: "x".repeat(10_000) }; const r = truncateJson(big, 512); expect(r.truncated).toBe(true); const obj = r.value as Record; expect(obj.truncated).toBe(true); expect(obj.small).toBe(1); expect(obj.big).toBe("[truncated]"); expect(new TextEncoder().encode(JSON.stringify(obj)).length).toBeLessThanOrEqual(512 + 64); // flag overhead tolerance }); it("truncates oversized non-objects to a capped string", () => { const r = truncateJson("y".repeat(10_000), 512); expect(r.truncated).toBe(true); expect(typeof r.value).toBe("string"); expect((r.value as string).endsWith("…[truncated]")).toBe(true); expect(new TextEncoder().encode(r.value as string).length).toBeLessThanOrEqual(512); }); it("handles null", () => { expect(truncateJson(null, 16).value).toBeNull(); }); }); describe("runBackoffSeconds", () => { it("schedule: 2^(n-1) capped at 600 s", () => { const expected = [1, 2, 4, 8, 16, 32, 64, 128]; for (let n = 1; n <= 8; n++) expect(runBackoffSeconds(n)).toBe(expected[n - 1]); expect(runBackoffSeconds(10)).toBe(512); // 2^9 expect(runBackoffSeconds(11)).toBe(600); // 2^10 = 1024 → capped expect(runBackoffSeconds(20)).toBe(600); }); it("attempt 0 / invalid → 0", () => { expect(runBackoffSeconds(0)).toBe(0); expect(runBackoffSeconds(-3)).toBe(0); }); }); describe("key helpers", () => { it("dedupe key = auto:dedupe:{automationId}:{eventId}", () => { expect(dedupeKey("a1", "e1")).toBe("auto:dedupe:a1:e1"); }); it("email cap key includes UTC date", () => { expect(emailCapKey("ws1", new Date("2026-09-30T00:00:00Z"))).toBe("auto:email:ws1:2026-09-30"); }); });