/** * TASK-0130 — unit: shared outbound webhook delivery core. * * Signature scheme (doc §4.2), header parser tolerance, backoff math * (30s·2^(n-1) cap 15min ±20% jitter), response classification (2xx/4xx/5xx), * envelope shape, and the SSRF guard — all pure/injected (no network). */ import { createHmac } from "node:crypto"; import { describe, expect, it } from "vitest"; import { WEBHOOK_BACKOFF_BASE_MS, WEBHOOK_BACKOFF_MAX_MS, buildDeliveryEnvelope, classifyDeliveryResult, newDeliveryId, parseWebhookSignatureHeader, postWebhookDelivery, resolveSigningSecret, signWebhookBody, verifyWebhookSignatureValue, webhookBackoffMs, webhookSignatureHeader, assertSafeDeliveryUrl, SsrfError } from "./webhook-delivery.js"; import { aes256gcmEncrypt } from "./crypto.js"; const SECRET = "whsec_0a1b2c3d4e5f60718293a4b5c6d7e8f9a1b2c3d4e5f60718293a4b5c6d7e8f9"; const BODY = '{"id":"evt_x","type":"page.created"}'; const TS = 1_735_824_130; describe("signature scheme (doc §4.2)", () => { it("v1 = HMAC-SHA256_hex(secret, `.`)", () => { const expected = createHmac("sha256", SECRET).update(`${TS}.${BODY}`).digest("hex"); expect(signWebhookBody(SECRET, BODY, TS)).toBe(expected); }); it("header is the canonical `t=,v1=` (no space)", () => { const header = webhookSignatureHeader(SECRET, BODY, TS); expect(header).toBe(`t=${TS},v1=${signWebhookBody(SECRET, BODY, TS)}`); }); it("verifyWebhookSignatureValue roundtrips + rejects tamper/wrong secret", () => { 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_other", BODY, TS, v1)).toBe(false); expect(verifyWebhookSignatureValue(SECRET, BODY, TS, "zz")).toBe(false); }); it("parser accepts canonical + the `; ` / `, ` shorthand, rejects garbage", () => { const v1 = signWebhookBody(SECRET, BODY, TS); expect(parseWebhookSignatureHeader(`t=${TS},v1=${v1}`)).toEqual({ t: TS, v1 }); expect(parseWebhookSignatureHeader(`t=${TS}; v1=${v1}`)).toEqual({ t: TS, v1 }); expect(parseWebhookSignatureHeader(`t=${TS}, v1=${v1}`)).toEqual({ t: TS, v1 }); expect(parseWebhookSignatureHeader(undefined)).toBeNull(); expect(parseWebhookSignatureHeader("")).toBeNull(); expect(parseWebhookSignatureHeader("garbage")).toBeNull(); expect(parseWebhookSignatureHeader("t=1,v1=zz")).toBeNull(); }); }); describe("resolveSigningSecret (C-13.8a)", () => { const MASTER = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const RAW = "whsec_cross-app-raw-secret"; it("prefers secretEncrypted (Uint8Array or base64 string)", () => { const enc = aes256gcmEncrypt(RAW, MASTER); expect(resolveSigningSecret(MASTER, Buffer.from(enc, "base64"), "f".repeat(64))).toEqual({ secret: RAW, source: "encrypted" }); expect(resolveSigningSecret(MASTER, enc, "f".repeat(64))?.source).toBe("encrypted"); }); it("falls back to a secretHash that actually holds the CIPHERTEXT (Phase 11)", () => { 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)", () => { const h = "a".repeat(64); expect(resolveSigningSecret(MASTER, null, h)).toEqual({ secret: h, source: "legacy-hash" }); }); it("returns null for unrecoverable blobs / blank master key", () => { expect(resolveSigningSecret(MASTER, null, "garbage-not-a-hash-or-ct")).toBeNull(); expect(resolveSigningSecret("", Buffer.from("x", "base64"), "f".repeat(64))).toBeNull(); }); }); describe("backoff (task: min(30s·2^(n-1), 15min) ±20%)", () => { const noJitter = { jitter: () => 1, rand: () => 0 }; it("doubles per attempt from 30s and caps at 15min", () => { expect(webhookBackoffMs(1, noJitter)).toBe(30_000); expect(webhookBackoffMs(2, noJitter)).toBe(60_000); expect(webhookBackoffMs(3, noJitter)).toBe(120_000); expect(webhookBackoffMs(4, noJitter)).toBe(240_000); expect(webhookBackoffMs(5, noJitter)).toBe(480_000); expect(webhookBackoffMs(6, noJitter)).toBe(900_000); // cap expect(webhookBackoffMs(7, noJitter)).toBe(900_000); expect(webhookBackoffMs(8, noJitter)).toBe(900_000); }); it("jitter stays within ±20%", () => { for (let attempt = 1; attempt <= 8; attempt++) { const base = Math.min(WEBHOOK_BACKOFF_BASE_MS * 2 ** (attempt - 1), WEBHOOK_BACKOFF_MAX_MS); for (let i = 0; i < 50; i++) { const got = webhookBackoffMs(attempt); expect(got).toBeGreaterThanOrEqual(Math.floor(base * 0.8)); expect(got).toBeLessThanOrEqual(Math.ceil(base * 1.2)); } } }); it("honors custom base/cap (test overrides)", () => { expect(webhookBackoffMs(1, { ...noJitter, baseMs: 1_000 })).toBe(1_000); expect(webhookBackoffMs(5, { ...noJitter, baseMs: 1_000, maxMs: 2_000 })).toBe(2_000); }); }); describe("response classification (doc §4.1)", () => { it("2xx → DELIVERED, 4xx → REJECTED, 5xx/0+error → FAILED", () => { expect(classifyDeliveryResult({ status: 200, latencyMs: 5 })).toBe("DELIVERED"); expect(classifyDeliveryResult({ status: 204, latencyMs: 5 })).toBe("DELIVERED"); expect(classifyDeliveryResult({ status: 400, latencyMs: 5 })).toBe("REJECTED"); expect(classifyDeliveryResult({ status: 404, latencyMs: 5 })).toBe("REJECTED"); expect(classifyDeliveryResult({ status: 500, latencyMs: 5 })).toBe("FAILED"); expect(classifyDeliveryResult({ status: 502, latencyMs: 5 })).toBe("FAILED"); expect(classifyDeliveryResult({ status: 0, latencyMs: 1, transportError: "timeout after 10000ms" })).toBe("FAILED"); }); }); describe("envelope (doc §3)", () => { it("has the documented keys in order and mints evt_ ids", () => { const env = buildDeliveryEnvelope({ type: "page.created", workspaceId: "wks_1", data: { pageId: "pg_1" } }); expect(Object.keys(env)).toEqual(["id", "type", "workspaceId", "occurredAt", "data"]); expect(env.id).toMatch(/^evt_/); expect(env.data).toEqual({ pageId: "pg_1" }); }); it("adds workspace/actor/dataDelta/test only when provided; stable id wins", () => { const env = buildDeliveryEnvelope({ id: "evt_stable", type: "page.updated", workspaceId: "wks_1", workspace: { id: "wks_1", name: "WS", slug: "ws" }, data: { pageId: "pg_1" }, dataDelta: { title: { old: "a", new: "b" } }, actor: { type: "user", id: "usr_1" }, test: true }); expect(env.id).toBe("evt_stable"); expect(env.workspace).toEqual({ id: "wks_1", name: "WS", slug: "ws" }); expect(env.dataDelta).toEqual({ title: { old: "a", new: "b" } }); expect(env.actor).toEqual({ type: "user", id: "usr_1" }); expect(env.test).toBe(true); }); it("newDeliveryId is a uuid", () => { expect(newDeliveryId()).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); }); }); describe("transport + SSRF (injected fetch, no network)", () => { const target = { url: "https://hooks.example.com/factory", secret: SECRET, event: "page.created", envelope: buildDeliveryEnvelope({ type: "page.created", workspaceId: "wks_1", data: { pageId: "pg_1" } }), deliveryId: newDeliveryId(), attempt: 1 }; it("sends the documented headers + signs the exact bytes sent", async () => { let seenBody = ""; let seenHeaders: Record = {}; const fakeFetch = (async (_url: string, init: RequestInit) => { seenBody = String(init.body); seenHeaders = Object.fromEntries( Object.entries((init.headers as Record) ?? {}).map(([k, v]) => [k.toLowerCase(), v]) ); return new Response("ok", { status: 200 }); }) as unknown as typeof fetch; const res = await postWebhookDelivery(target, { fetchImpl: fakeFetch, allowPrivate: true }); expect(res.status).toBe(200); expect(JSON.parse(seenBody)).toEqual(target.envelope); expect(seenHeaders["user-agent"]).toBe("factory-webhooks/1.0"); expect(seenHeaders["x-factory-event"]).toBe("page.created"); expect(seenHeaders["x-factory-event-id"]).toBe(target.envelope.id); expect(seenHeaders["x-factory-delivery-id"]).toBe(target.deliveryId); expect(seenHeaders["x-factory-attempt"]).toBe("1"); // The signature must verify against the EXACT bytes on the wire. const parsed = parseWebhookSignatureHeader(seenHeaders["x-factory-signature"]!); expect(parsed).not.toBeNull(); expect(verifyWebhookSignatureValue(SECRET, seenBody, parsed!.t, parsed!.v1)).toBe(true); }); it("timeout → transportError (status 0) → FAILED classification", async () => { // A fake fetch that hangs until the caller's signal aborts (like real fetch). const hanging = (async (_url: string, init: RequestInit) => new Promise((_resolve, reject) => { const signal = init.signal as AbortSignal | undefined; if (signal?.aborted) { reject(Object.assign(new Error("aborted"), { name: "AbortError" })); return; } signal?.addEventListener("abort", () => reject(Object.assign(new Error("aborted"), { name: "AbortError" })), { once: true }); })) as unknown as typeof fetch; const res = await postWebhookDelivery(target, { fetchImpl: hanging, timeoutMs: 20, allowPrivate: true }); expect(res.status).toBe(0); expect(res.transportError).toMatch(/timeout/); expect(classifyDeliveryResult(res)).toBe("FAILED"); }); it("SSRF: private/loopback IP targets are rejected before any fetch", async () => { await expect(assertSafeDeliveryUrl("http://127.0.0.1:1/dead", false)).rejects.toThrow(SsrfError); await expect(assertSafeDeliveryUrl("http://10.1.2.3/x", false)).rejects.toThrow(SsrfError); await expect(assertSafeDeliveryUrl("http://192.168.0.1/x", false)).rejects.toThrow(SsrfError); await expect(assertSafeDeliveryUrl("http://169.254.169.254/latest/meta-data", false)).rejects.toThrow(SsrfError); await expect(assertSafeDeliveryUrl("http://localhost/x", false)).rejects.toThrow(SsrfError); await expect(assertSafeDeliveryUrl("file:///etc/passwd", false)).rejects.toThrow(); // The dev override lets local test receivers through. await expect(assertSafeDeliveryUrl("http://127.0.0.1:9999/hook", true)).resolves.toBeUndefined(); }); });