1
8import { createHmac } from "node:crypto";
9import { describe, expect, it } from "vitest";
10import {
11 WEBHOOK_BACKOFF_BASE_MS,
12 WEBHOOK_BACKOFF_MAX_MS,
13 buildDeliveryEnvelope,
14 classifyDeliveryResult,
15 newDeliveryId,
16 parseWebhookSignatureHeader,
17 postWebhookDelivery,
18 resolveSigningSecret,
19 signWebhookBody,
20 verifyWebhookSignatureValue,
21 webhookBackoffMs,
22 webhookSignatureHeader,
23 assertSafeDeliveryUrl,
24 SsrfError
25} from "./webhook-delivery.js";
26import { aes256gcmEncrypt } from "./crypto.js";
27
28const SECRET = "whsec_0a1b2c3d4e5f60718293a4b5c6d7e8f9a1b2c3d4e5f60718293a4b5c6d7e8f9";
29const BODY = '{"id":"evt_x","type":"page.created"}';
30const TS = 1_735_824_130;
31
32describe("signature scheme (doc §4.2)", () => {
33 it("v1 = HMAC-SHA256_hex(secret, `<ts>.<body>`)", () => {
34 const expected = createHmac("sha256", SECRET).update(`${TS}.${BODY}`).digest("hex");
35 expect(signWebhookBody(SECRET, BODY, TS)).toBe(expected);
36 });
37
38 it("header is the canonical `t=<ts>,v1=<hex>` (no space)", () => {
39 const header = webhookSignatureHeader(SECRET, BODY, TS);
40 expect(header).toBe(`t=${TS},v1=${signWebhookBody(SECRET, BODY, TS)}`);
41 });
42
43 it("verifyWebhookSignatureValue roundtrips + rejects tamper/wrong secret", () => {
44 const v1 = signWebhookBody(SECRET, BODY, TS);
45 expect(verifyWebhookSignatureValue(SECRET, BODY, TS, v1)).toBe(true);
46 expect(verifyWebhookSignatureValue(SECRET, BODY + "x", TS, v1)).toBe(false);
47 expect(verifyWebhookSignatureValue("whsec_other", BODY, TS, v1)).toBe(false);
48 expect(verifyWebhookSignatureValue(SECRET, BODY, TS, "zz")).toBe(false);
49 });
50
51 it("parser accepts canonical + the `; ` / `, ` shorthand, rejects garbage", () => {
52 const v1 = signWebhookBody(SECRET, BODY, TS);
53 expect(parseWebhookSignatureHeader(`t=${TS},v1=${v1}`)).toEqual({ t: TS, v1 });
54 expect(parseWebhookSignatureHeader(`t=${TS}; v1=${v1}`)).toEqual({ t: TS, v1 });
55 expect(parseWebhookSignatureHeader(`t=${TS}, v1=${v1}`)).toEqual({ t: TS, v1 });
56 expect(parseWebhookSignatureHeader(undefined)).toBeNull();
57 expect(parseWebhookSignatureHeader("")).toBeNull();
58 expect(parseWebhookSignatureHeader("garbage")).toBeNull();
59 expect(parseWebhookSignatureHeader("t=1,v1=zz")).toBeNull();
60 });
61});
62
63describe("resolveSigningSecret (C-13.8a)", () => {
64 const MASTER = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
65 const RAW = "whsec_cross-app-raw-secret";
66
67 it("prefers secretEncrypted (Uint8Array or base64 string)", () => {
68 const enc = aes256gcmEncrypt(RAW, MASTER);
69 expect(resolveSigningSecret(MASTER, Buffer.from(enc, "base64"), "f".repeat(64))).toEqual({
70 secret: RAW,
71 source: "encrypted"
72 });
73 expect(resolveSigningSecret(MASTER, enc, "f".repeat(64))?.source).toBe("encrypted");
74 });
75
76 it("falls back to a secretHash that actually holds the CIPHERTEXT (Phase 11)", () => {
77 const enc = aes256gcmEncrypt(RAW, MASTER);
78 expect(resolveSigningSecret(MASTER, null, enc)).toEqual({ secret: RAW, source: "legacy-ciphertext" });
79 });
80
81 it("falls back to a bare 64-hex hash (sign with the hash)", () => {
82 const h = "a".repeat(64);
83 expect(resolveSigningSecret(MASTER, null, h)).toEqual({ secret: h, source: "legacy-hash" });
84 });
85
86 it("returns null for unrecoverable blobs / blank master key", () => {
87 expect(resolveSigningSecret(MASTER, null, "garbage-not-a-hash-or-ct")).toBeNull();
88 expect(resolveSigningSecret("", Buffer.from("x", "base64"), "f".repeat(64))).toBeNull();
89 });
90});
91
92describe("backoff (task: min(30s·2^(n-1), 15min) ±20%)", () => {
93 const noJitter = { jitter: () => 1, rand: () => 0 };
94 it("doubles per attempt from 30s and caps at 15min", () => {
95 expect(webhookBackoffMs(1, noJitter)).toBe(30_000);
96 expect(webhookBackoffMs(2, noJitter)).toBe(60_000);
97 expect(webhookBackoffMs(3, noJitter)).toBe(120_000);
98 expect(webhookBackoffMs(4, noJitter)).toBe(240_000);
99 expect(webhookBackoffMs(5, noJitter)).toBe(480_000);
100 expect(webhookBackoffMs(6, noJitter)).toBe(900_000);
101 expect(webhookBackoffMs(7, noJitter)).toBe(900_000);
102 expect(webhookBackoffMs(8, noJitter)).toBe(900_000);
103 });
104
105 it("jitter stays within ±20%", () => {
106 for (let attempt = 1; attempt <= 8; attempt++) {
107 const base = Math.min(WEBHOOK_BACKOFF_BASE_MS * 2 ** (attempt - 1), WEBHOOK_BACKOFF_MAX_MS);
108 for (let i = 0; i < 50; i++) {
109 const got = webhookBackoffMs(attempt);
110 expect(got).toBeGreaterThanOrEqual(Math.floor(base * 0.8));
111 expect(got).toBeLessThanOrEqual(Math.ceil(base * 1.2));
112 }
113 }
114 });
115
116 it("honors custom base/cap (test overrides)", () => {
117 expect(webhookBackoffMs(1, { ...noJitter, baseMs: 1_000 })).toBe(1_000);
118 expect(webhookBackoffMs(5, { ...noJitter, baseMs: 1_000, maxMs: 2_000 })).toBe(2_000);
119 });
120});
121
122describe("response classification (doc §4.1)", () => {
123 it("2xx → DELIVERED, 4xx → REJECTED, 5xx/0+error → FAILED", () => {
124 expect(classifyDeliveryResult({ status: 200, latencyMs: 5 })).toBe("DELIVERED");
125 expect(classifyDeliveryResult({ status: 204, latencyMs: 5 })).toBe("DELIVERED");
126 expect(classifyDeliveryResult({ status: 400, latencyMs: 5 })).toBe("REJECTED");
127 expect(classifyDeliveryResult({ status: 404, latencyMs: 5 })).toBe("REJECTED");
128 expect(classifyDeliveryResult({ status: 500, latencyMs: 5 })).toBe("FAILED");
129 expect(classifyDeliveryResult({ status: 502, latencyMs: 5 })).toBe("FAILED");
130 expect(classifyDeliveryResult({ status: 0, latencyMs: 1, transportError: "timeout after 10000ms" })).toBe("FAILED");
131 });
132});
133
134describe("envelope (doc §3)", () => {
135 it("has the documented keys in order and mints evt_ ids", () => {
136 const env = buildDeliveryEnvelope({ type: "page.created", workspaceId: "wks_1", data: { pageId: "pg_1" } });
137 expect(Object.keys(env)).toEqual(["id", "type", "workspaceId", "occurredAt", "data"]);
138 expect(env.id).toMatch(/^evt_/);
139 expect(env.data).toEqual({ pageId: "pg_1" });
140 });
141
142 it("adds workspace/actor/dataDelta/test only when provided; stable id wins", () => {
143 const env = buildDeliveryEnvelope({
144 id: "evt_stable",
145 type: "page.updated",
146 workspaceId: "wks_1",
147 workspace: { id: "wks_1", name: "WS", slug: "ws" },
148 data: { pageId: "pg_1" },
149 dataDelta: { title: { old: "a", new: "b" } },
150 actor: { type: "user", id: "usr_1" },
151 test: true
152 });
153 expect(env.id).toBe("evt_stable");
154 expect(env.workspace).toEqual({ id: "wks_1", name: "WS", slug: "ws" });
155 expect(env.dataDelta).toEqual({ title: { old: "a", new: "b" } });
156 expect(env.actor).toEqual({ type: "user", id: "usr_1" });
157 expect(env.test).toBe(true);
158 });
159
160 it("newDeliveryId is a uuid", () => {
161 expect(newDeliveryId()).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
162 });
163});
164
165describe("transport + SSRF (injected fetch, no network)", () => {
166 const target = {
167 url: "https://hooks.example.com/factory",
168 secret: SECRET,
169 event: "page.created",
170 envelope: buildDeliveryEnvelope({ type: "page.created", workspaceId: "wks_1", data: { pageId: "pg_1" } }),
171 deliveryId: newDeliveryId(),
172 attempt: 1
173 };
174
175 it("sends the documented headers + signs the exact bytes sent", async () => {
176 let seenBody = "";
177 let seenHeaders: Record<string, string | null> = {};
178 const fakeFetch = (async (_url: string, init: RequestInit) => {
179 seenBody = String(init.body);
180 seenHeaders = Object.fromEntries(
181 Object.entries((init.headers as Record<string, string>) ?? {}).map(([k, v]) => [k.toLowerCase(), v])
182 );
183 return new Response("ok", { status: 200 });
184 }) as unknown as typeof fetch;
185 const res = await postWebhookDelivery(target, { fetchImpl: fakeFetch, allowPrivate: true });
186 expect(res.status).toBe(200);
187 expect(JSON.parse(seenBody)).toEqual(target.envelope);
188 expect(seenHeaders["user-agent"]).toBe("factory-webhooks/1.0");
189 expect(seenHeaders["x-factory-event"]).toBe("page.created");
190 expect(seenHeaders["x-factory-event-id"]).toBe(target.envelope.id);
191 expect(seenHeaders["x-factory-delivery-id"]).toBe(target.deliveryId);
192 expect(seenHeaders["x-factory-attempt"]).toBe("1");
193
194 const parsed = parseWebhookSignatureHeader(seenHeaders["x-factory-signature"]!);
195 expect(parsed).not.toBeNull();
196 expect(verifyWebhookSignatureValue(SECRET, seenBody, parsed!.t, parsed!.v1)).toBe(true);
197 });
198
199 it("timeout → transportError (status 0) → FAILED classification", async () => {
200
201 const hanging = (async (_url: string, init: RequestInit) =>
202 new Promise<Response>((_resolve, reject) => {
203 const signal = init.signal as AbortSignal | undefined;
204 if (signal?.aborted) {
205 reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
206 return;
207 }
208 signal?.addEventListener("abort", () => reject(Object.assign(new Error("aborted"), { name: "AbortError" })), { once: true });
209 })) as unknown as typeof fetch;
210 const res = await postWebhookDelivery(target, { fetchImpl: hanging, timeoutMs: 20, allowPrivate: true });
211 expect(res.status).toBe(0);
212 expect(res.transportError).toMatch(/timeout/);
213 expect(classifyDeliveryResult(res)).toBe("FAILED");
214 });
215
216 it("SSRF: private/loopback IP targets are rejected before any fetch", async () => {
217 await expect(assertSafeDeliveryUrl("http://127.0.0.1:1/dead", false)).rejects.toThrow(SsrfError);
218 await expect(assertSafeDeliveryUrl("http://10.1.2.3/x", false)).rejects.toThrow(SsrfError);
219 await expect(assertSafeDeliveryUrl("http://192.168.0.1/x", false)).rejects.toThrow(SsrfError);
220 await expect(assertSafeDeliveryUrl("http://169.254.169.254/latest/meta-data", false)).rejects.toThrow(SsrfError);
221 await expect(assertSafeDeliveryUrl("http://localhost/x", false)).rejects.toThrow(SsrfError);
222 await expect(assertSafeDeliveryUrl("file:///etc/passwd", false)).rejects.toThrow();
223
224 await expect(assertSafeDeliveryUrl("http://127.0.0.1:9999/hook", true)).resolves.toBeUndefined();
225 });
226});
227