1
10import { describe, expect, it } from "vitest";
11import { createHmac } from "node:crypto";
12import {
13 isWebhookSubscribed,
14 webhookJobSchema
15} from "../processors/webhooks.processor.js";
16import {
17 parseWebhookSignatureHeader,
18 resolveSigningSecret,
19 signWebhookBody,
20 verifyWebhookSignatureValue,
21 webhookBackoffMs,
22 webhookSignatureHeader
23} from "@factory/shared";
24import { aes256gcmEncrypt } from "../lib/encrypt.js";
25
26const SECRET = "whsec_4ec0e5b8009b7229719e08a99a8f6264";
27const BODY = JSON.stringify({
28 id: "evt_deadbeef",
29 type: "record.updated",
30 workspaceId: "w_1",
31 data: { recordId: "r_1", status: "done" }
32});
33const TS = 1735689600;
34
35function expectedV1(): string {
36 return createHmac("sha256", SECRET).update(`${TS}.` + BODY).digest("hex");
37}
38
39describe("webhook signature scheme (shared, doc §4.2)", () => {
40 it("v1 = hmac_sha256_hex(secret, `${ts}.` + rawBody)", () => {
41 expect(signWebhookBody(SECRET, BODY, TS)).toBe(expectedV1());
42 });
43
44 it("header is `t=<ts>,v1=<sig>` (no space) and parses back", () => {
45 const header = webhookSignatureHeader(SECRET, BODY, TS);
46 expect(header).toBe(`t=${TS},v1=${expectedV1()}`);
47 expect(parseWebhookSignatureHeader(header)).toEqual({ t: TS, v1: expectedV1() });
48 });
49
50 it("verifies against the raw body; rejects tamper / wrong secret / wrong ts", () => {
51 const v1 = signWebhookBody(SECRET, BODY, TS);
52 expect(verifyWebhookSignatureValue(SECRET, BODY, TS, v1)).toBe(true);
53 expect(verifyWebhookSignatureValue(SECRET, BODY + "x", TS, v1)).toBe(false);
54 expect(verifyWebhookSignatureValue("whsec_wrong-secret-000000000000000000", BODY, TS, v1)).toBe(false);
55 expect(verifyWebhookSignatureValue(SECRET, BODY, TS + 1, v1)).toBe(false);
56 });
57});
58
59const MASTER = "test-master-key-0123456789abcdef";
60const RAW = "whsec_0a1b2c3d4e5f60718293a4b5c6d7e8f9";
61
62describe("resolveSigningSecret (C-13.8a storage precedence)", () => {
63 it("prefers secretEncrypted (the API's AES-GCM ciphertext)", () => {
64 const enc = Buffer.from(aes256gcmEncrypt(RAW, MASTER), "base64");
65 expect(resolveSigningSecret(MASTER, enc, "f".repeat(64))).toEqual({ secret: RAW, source: "encrypted" });
66 });
67
68 it("falls back to a legacy secretHash that holds the CIPHERTEXT", () => {
69 const enc = aes256gcmEncrypt(RAW, MASTER);
70 expect(resolveSigningSecret(MASTER, null, enc)).toEqual({ secret: RAW, source: "legacy-ciphertext" });
71 });
72
73 it("falls back to a bare 64-hex hash (sign with the hash, documented legacy)", () => {
74 const hash = "f".repeat(64);
75 expect(resolveSigningSecret(MASTER, null, hash)).toEqual({ secret: hash, source: "legacy-hash" });
76 });
77
78 it("returns null for an unrecoverable blob or a blank master key", () => {
79 expect(resolveSigningSecret(MASTER, null, "not-a-hash-or-ct")).toBeNull();
80 expect(resolveSigningSecret("", null, "f".repeat(64))).toBeNull();
81 });
82
83 it("encrypted + wrong key falls through to the next source", () => {
84 const enc = Buffer.from(aes256gcmEncrypt(RAW, "other-master-key-00000000000000"), "base64");
85 expect(resolveSigningSecret(MASTER, enc, "f".repeat(64))?.source).toBe("legacy-hash");
86 });
87});
88
89describe("subscription matching (exact + `*` wildcard)", () => {
90 it("matches exact event names", () => {
91 expect(isWebhookSubscribed(["page.created", "record.updated"], "record.updated")).toBe(true);
92 expect(isWebhookSubscribed(["page.created"], "record.updated")).toBe(false);
93 });
94 it("the `*` wildcard subscribes to everything", () => {
95 expect(isWebhookSubscribed(["*"], "page.created")).toBe(true);
96 expect(isWebhookSubscribed(["*", "page.created"], "record.updated")).toBe(true);
97 expect(isWebhookSubscribed([], "page.created")).toBe(false);
98 });
99});
100
101describe("backoff (worker env → shared webhookBackoffMs)", () => {
102 const noJitter = { jitter: () => 1, rand: () => 0 };
103 it("task default: 30s base, 15min cap, doubling", () => {
104 expect(webhookBackoffMs(1, noJitter)).toBe(30_000);
105 expect(webhookBackoffMs(3, noJitter)).toBe(120_000);
106 expect(webhookBackoffMs(6, noJitter)).toBe(900_000);
107 expect(webhookBackoffMs(8, noJitter)).toBe(900_000);
108 });
109 it("deployment override (the test suites set short values)", () => {
110 expect(webhookBackoffMs(1, { ...noJitter, baseMs: 250, maxMs: 2_000 })).toBe(250);
111 expect(webhookBackoffMs(4, { ...noJitter, baseMs: 250, maxMs: 2_000 })).toBe(2_000);
112 });
113});
114
115describe("webhook job schema", () => {
116 it("accepts the fan-out / test-send / resubmit shapes", () => {
117 expect(webhookJobSchema.parse({ kind: "row", webhookId: "w1", event: "page.created", payload: {} }).event).toBe("page.created");
118 expect(webhookJobSchema.parse({ kind: "row", webhookId: "w1", event: "e", payload: {}, deliveryId: "d1", eventId: "evt_1", attempt: 2, originDeliveryId: "d0" }).attempt).toBe(2);
119 expect(() => webhookJobSchema.parse({ kind: "direct", url: "https://x", event: "e" })).toThrow();
120 expect(() => webhookJobSchema.parse({ kind: "row", webhookId: "", event: "e" })).toThrow();
121 });
122});
123