/** * TASK-0130 — the worker's webhook signature scheme now DELEGATES to * `@factory/shared` (`webhook-delivery.ts`) — the full contract (doc §4.2 * scheme, header tolerance, envelope, transport, SSRF, backoff) is unit * tested there. This file pins the worker's remaining local surface: * - resolveSigningSecret precedence (C-13.8a), * - subscription matching (exact + `*` wildcard), * - the shared backoff math under the worker's env override. */ import { describe, expect, it } from "vitest"; import { createHmac } from "node:crypto"; import { isWebhookSubscribed, webhookJobSchema } from "../processors/webhooks.processor.js"; import { parseWebhookSignatureHeader, resolveSigningSecret, signWebhookBody, verifyWebhookSignatureValue, webhookBackoffMs, webhookSignatureHeader } from "@factory/shared"; import { aes256gcmEncrypt } from "../lib/encrypt.js"; const SECRET = "whsec_4ec0e5b8009b7229719e08a99a8f6264"; const BODY = JSON.stringify({ id: "evt_deadbeef", type: "record.updated", workspaceId: "w_1", data: { recordId: "r_1", status: "done" } }); const TS = 1735689600; // 2025-01-01T00:00:00Z function expectedV1(): string { return createHmac("sha256", SECRET).update(`${TS}.` + BODY).digest("hex"); } describe("webhook signature scheme (shared, doc §4.2)", () => { it("v1 = hmac_sha256_hex(secret, `${ts}.` + rawBody)", () => { expect(signWebhookBody(SECRET, BODY, TS)).toBe(expectedV1()); }); it("header is `t=,v1=` (no space) and parses back", () => { const header = webhookSignatureHeader(SECRET, BODY, TS); expect(header).toBe(`t=${TS},v1=${expectedV1()}`); expect(parseWebhookSignatureHeader(header)).toEqual({ t: TS, v1: expectedV1() }); }); it("verifies against the raw body; rejects tamper / wrong secret / wrong ts", () => { const v1 = signWebhookBody(SECRET, BODY, TS); expect(verifyWebhookSignatureValue(SECRET, BODY, TS, v1)).toBe(true); expect(verifyWebhookSignatureValue(SECRET, BODY + "x", TS, v1)).toBe(false); expect(verifyWebhookSignatureValue("whsec_wrong-secret-000000000000000000", BODY, TS, v1)).toBe(false); expect(verifyWebhookSignatureValue(SECRET, BODY, TS + 1, v1)).toBe(false); }); }); const MASTER = "test-master-key-0123456789abcdef"; const RAW = "whsec_0a1b2c3d4e5f60718293a4b5c6d7e8f9"; describe("resolveSigningSecret (C-13.8a storage precedence)", () => { it("prefers secretEncrypted (the API's AES-GCM ciphertext)", () => { const enc = Buffer.from(aes256gcmEncrypt(RAW, MASTER), "base64"); expect(resolveSigningSecret(MASTER, enc, "f".repeat(64))).toEqual({ secret: RAW, source: "encrypted" }); }); it("falls back to a legacy secretHash that holds the CIPHERTEXT", () => { const enc = aes256gcmEncrypt(RAW, MASTER); expect(resolveSigningSecret(MASTER, null, enc)).toEqual({ secret: RAW, source: "legacy-ciphertext" }); }); it("falls back to a bare 64-hex hash (sign with the hash, documented legacy)", () => { const hash = "f".repeat(64); expect(resolveSigningSecret(MASTER, null, hash)).toEqual({ secret: hash, source: "legacy-hash" }); }); it("returns null for an unrecoverable blob or a blank master key", () => { expect(resolveSigningSecret(MASTER, null, "not-a-hash-or-ct")).toBeNull(); expect(resolveSigningSecret("", null, "f".repeat(64))).toBeNull(); }); it("encrypted + wrong key falls through to the next source", () => { const enc = Buffer.from(aes256gcmEncrypt(RAW, "other-master-key-00000000000000"), "base64"); expect(resolveSigningSecret(MASTER, enc, "f".repeat(64))?.source).toBe("legacy-hash"); }); }); describe("subscription matching (exact + `*` wildcard)", () => { it("matches exact event names", () => { expect(isWebhookSubscribed(["page.created", "record.updated"], "record.updated")).toBe(true); expect(isWebhookSubscribed(["page.created"], "record.updated")).toBe(false); }); it("the `*` wildcard subscribes to everything", () => { expect(isWebhookSubscribed(["*"], "page.created")).toBe(true); expect(isWebhookSubscribed(["*", "page.created"], "record.updated")).toBe(true); expect(isWebhookSubscribed([], "page.created")).toBe(false); }); }); describe("backoff (worker env → shared webhookBackoffMs)", () => { const noJitter = { jitter: () => 1, rand: () => 0 }; it("task default: 30s base, 15min cap, doubling", () => { expect(webhookBackoffMs(1, noJitter)).toBe(30_000); expect(webhookBackoffMs(3, noJitter)).toBe(120_000); expect(webhookBackoffMs(6, noJitter)).toBe(900_000); // capped expect(webhookBackoffMs(8, noJitter)).toBe(900_000); }); it("deployment override (the test suites set short values)", () => { expect(webhookBackoffMs(1, { ...noJitter, baseMs: 250, maxMs: 2_000 })).toBe(250); expect(webhookBackoffMs(4, { ...noJitter, baseMs: 250, maxMs: 2_000 })).toBe(2_000); }); }); describe("webhook job schema", () => { it("accepts the fan-out / test-send / resubmit shapes", () => { expect(webhookJobSchema.parse({ kind: "row", webhookId: "w1", event: "page.created", payload: {} }).event).toBe("page.created"); expect(webhookJobSchema.parse({ kind: "row", webhookId: "w1", event: "e", payload: {}, deliveryId: "d1", eventId: "evt_1", attempt: 2, originDeliveryId: "d0" }).attempt).toBe(2); expect(() => webhookJobSchema.parse({ kind: "direct", url: "https://x", event: "e" })).toThrow(); expect(() => webhookJobSchema.parse({ kind: "row", webhookId: "", event: "e" })).toThrow(); }); });