import { describe, it, expect, beforeEach, vi } from "vitest"; // Shared mock state for the service-role client. Lives in a hoisted block // so the vi.mock factory below can close over it. const h = vi.hoisted(() => ({ state: { owned: null as { id: string } | null, ownedCustomField: null as { id: string } | null, automations: [] as Record[], steps: [] as Record[], fromCalls: [] as string[], updateCalls: [] as { table: string; filters: [string, string, unknown][] }[], upsertCalls: [] as { table: string; payload: unknown }[], }, })); vi.mock("./admin-client", () => { const { state } = h; function resolve(ops: { table: string; type: string; payload?: unknown; filters: [string, string, unknown][]; }) { const { table, type } = ops; if (table === "contacts") { if (type === "update") { state.updateCalls.push({ table, filters: ops.filters }); return { data: null, error: null }; } // ownership guard / condition read return { data: state.owned, error: null }; } if (table === "custom_fields") { // account-scoped ownership lookup for a custom field definition return { data: state.ownedCustomField, error: null }; } if (table === "contact_custom_values") { if (type === "upsert") { state.upsertCalls.push({ table, payload: ops.payload }); return { data: null, error: null }; } return { data: null, error: null }; } if (table === "automations") return { data: state.automations, error: null }; if (table === "automation_logs") { if (type === "insert") return { data: { id: "log1" }, error: null }; if (type === "update") return { data: null, error: null }; return { data: { steps_executed: [], status: "success" }, error: null }; } if (table === "automation_steps") return { data: state.steps, error: null }; return { data: null, error: null }; } function builder(table: string) { const ops = { table, type: "select", payload: undefined as unknown, filters: [] as [string, string, unknown][], }; const b: Record = { select: () => b, insert: (p: unknown) => ((ops.type = "insert"), (ops.payload = p), b), update: (p: unknown) => ((ops.type = "update"), (ops.payload = p), b), delete: () => ((ops.type = "delete"), b), upsert: (p: unknown) => ((ops.type = "upsert"), (ops.payload = p), b), eq: (k: string, v: unknown) => (ops.filters.push(["eq", k, v]), b), gte: () => b, is: () => b, order: () => b, limit: () => b, single: () => Promise.resolve(resolve(ops)), maybeSingle: () => Promise.resolve(resolve(ops)), then: (onF: (v: unknown) => unknown, onR?: (e: unknown) => unknown) => Promise.resolve(resolve(ops)).then(onF, onR), }; return b; } return { supabaseAdmin: () => ({ from: (t: string) => { state.fromCalls.push(t); return builder(t); }, rpc: () => Promise.resolve({ error: null }), }), }; }); vi.mock("./meta-send", () => ({ engineSendText: vi.fn(async () => ({ whatsapp_message_id: "m1" })), engineSendTemplate: vi.fn(async () => ({ whatsapp_message_id: "m1" })), })); import { runAutomationsForTrigger } from "./engine"; const ACCOUNT = "acct-1"; beforeEach(() => { h.state.owned = null; h.state.ownedCustomField = null; h.state.automations = []; h.state.steps = []; h.state.fromCalls = []; h.state.updateCalls = []; h.state.upsertCalls = []; }); describe("runAutomationsForTrigger — tenant isolation", () => { it("refuses to dispatch when the contact is not in the account (GHSA-63cv-2c49-m5v3)", async () => { // Ownership lookup returns nothing — the contact belongs to another tenant. h.state.owned = null; // If the guard failed, this automation would run an update_contact_field step. h.state.automations = [automationWithUpdateStep()]; h.state.steps = [updateStep()]; await runAutomationsForTrigger({ accountId: ACCOUNT, triggerType: "new_message_received", contactId: "victim-contact-uuid", context: { message_text: "manual trigger" }, }); // Bailed at the guard: never fetched automations, never wrote a contact. expect(h.state.fromCalls).toContain("contacts"); expect(h.state.fromCalls).not.toContain("automations"); expect(h.state.updateCalls).toHaveLength(0); }); it("proceeds past the guard when the contact belongs to the account", async () => { h.state.owned = { id: "c1" }; h.state.automations = []; // no matching automations; just prove we got past the guard await runAutomationsForTrigger({ accountId: ACCOUNT, triggerType: "new_message_received", contactId: "c1", context: {}, }); expect(h.state.fromCalls).toContain("automations"); }); it("scopes the update_contact_field write to the automation's account", async () => { h.state.owned = { id: "c1" }; h.state.automations = [automationWithUpdateStep()]; h.state.steps = [updateStep()]; await runAutomationsForTrigger({ accountId: ACCOUNT, triggerType: "new_message_received", contactId: "c1", context: {}, }); expect(h.state.updateCalls).toHaveLength(1); const filters = h.state.updateCalls[0].filters; expect(filters).toContainEqual(["eq", "id", "c1"]); expect(filters).toContainEqual(["eq", "account_id", ACCOUNT]); }); }); describe("update_contact_field — custom fields", () => { it("upserts contact_custom_values when the field is account-owned", async () => { h.state.owned = { id: "c1" }; h.state.ownedCustomField = { id: "cf1" }; h.state.automations = [automationWithUpdateStep()]; h.state.steps = [customStep("custom:cf1", "Premium")]; await runAutomationsForTrigger({ accountId: ACCOUNT, triggerType: "new_message_received", contactId: "c1", context: {}, }); // No direct contacts column write for a custom field. expect(h.state.updateCalls).toHaveLength(0); expect(h.state.upsertCalls).toHaveLength(1); expect(h.state.upsertCalls[0].payload).toEqual({ contact_id: "c1", custom_field_id: "cf1", value: "Premium", }); }); it("interpolates {{ vars.* }} into the custom value", async () => { h.state.owned = { id: "c1" }; h.state.ownedCustomField = { id: "cf1" }; h.state.automations = [automationWithUpdateStep()]; h.state.steps = [customStep("custom:cf1", "{{ vars.source }}")]; await runAutomationsForTrigger({ accountId: ACCOUNT, triggerType: "new_message_received", contactId: "c1", context: { vars: { source: "WhatsApp Ad" } }, }); expect(h.state.upsertCalls).toHaveLength(1); expect( (h.state.upsertCalls[0].payload as { value: string }).value, ).toBe("WhatsApp Ad"); }); it("refuses to write a custom field from another account", async () => { h.state.owned = { id: "c1" }; h.state.ownedCustomField = null; // account-scoped lookup finds nothing h.state.automations = [automationWithUpdateStep()]; h.state.steps = [customStep("custom:foreign-cf", "x")]; await runAutomationsForTrigger({ accountId: ACCOUNT, triggerType: "new_message_received", contactId: "c1", context: {}, }); expect(h.state.upsertCalls).toHaveLength(0); expect(h.state.updateCalls).toHaveLength(0); }); }); describe("send_webhook — SSRF guard (GHSA-8jqh-598v-rfxc)", () => { it("refuses a private / link-local destination and never calls fetch", async () => { const fetchSpy = vi.fn(async () => ({ ok: true, status: 200 })); vi.stubGlobal("fetch", fetchSpy); h.state.owned = { id: "c1" }; h.state.automations = [automationWithUpdateStep()]; // Aimed at the cloud metadata endpoint — the classic SSRF target. h.state.steps = [webhookStep("http://169.254.169.254/latest/meta-data/")]; await runAutomationsForTrigger({ accountId: ACCOUNT, triggerType: "new_message_received", contactId: "c1", context: {}, }); // The automation matched and its steps were loaded (so we genuinely // reached the send_webhook case)... expect(h.state.fromCalls).toContain("automation_steps"); // ...yet the guard blocked it before any outbound request left the box. expect(fetchSpy).not.toHaveBeenCalled(); vi.unstubAllGlobals(); }); }); function webhookStep(url: string) { return { id: "s1", automation_id: "a1", step_type: "send_webhook", position: 0, parent_step_id: null, step_config: { url, headers: { "Metadata-Flavor": "Google" }, body_template: "{}" }, }; } function automationWithUpdateStep() { return { id: "a1", account_id: ACCOUNT, user_id: "u1", trigger_type: "new_message_received", trigger_config: {}, is_active: true, }; } function updateStep() { return { id: "s1", automation_id: "a1", step_type: "update_contact_field", position: 0, parent_step_id: null, step_config: { field: "company", value: "pwned-by-automation" }, }; } function customStep(field: string, value: string) { return { id: "s1", automation_id: "a1", step_type: "update_contact_field", position: 0, parent_step_id: null, step_config: { field, value }, }; }