import { createServer, type IncomingMessage, type Server, type ServerResponse, } from "node:http"; import { describe, expect, it } from "@suaveplan/testing/runner"; import { verifySignature } from "@suaveplan/webhook-client"; import { createWebhookService, MemoryDeadLetterSink, MemoryEndpointStore, resetWebhookInstruments, } from "../../src/index.js"; interface CapturedRequest { method: string; url: string; headers: Record; body: string; } interface TestReceiver { readonly url: string; readonly requests: CapturedRequest[]; close(): Promise; } type RequestHandler = ( req: IncomingMessage, res: ServerResponse, attempt: number ) => void; async function startReceiver(handler: RequestHandler): Promise { const requests: CapturedRequest[] = []; let attempt = 0; const server: Server = createServer((req, res) => { let body = ""; req.on("data", (chunk) => { body += chunk; }); req.on("end", () => { attempt += 1; const captured: CapturedRequest = { method: req.method ?? "GET", url: req.url ?? "/", headers: Object.fromEntries( Object.entries(req.headers).map(([k, v]) => [ k, Array.isArray(v) ? v.join(",") : (v ?? ""), ]) ), body, }; requests.push(captured); handler(req, res, attempt); }); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve) ); const address = server.address(); if (address === null || typeof address === "string") { throw new Error("failed to bind test receiver"); } const url = `http://127.0.0.1:${address.port}/hooks`; return { url, requests, close: () => new Promise((resolve, reject) => { server.close((err) => (err ? reject(err) : resolve())); }), }; } function noopLogger() { return { debug: () => undefined, info: () => undefined, warn: () => undefined, error: () => undefined, }; } describe("integration: webhook delivery", () => { it("sends a signed payload with canonical headers", { timeout: 30_000, }, async () => { const receiver = await startReceiver((_, res) => { res.writeHead(200, { "content-type": "text/plain" }); res.end("ok"); }); try { resetWebhookInstruments(); const store = new MemoryEndpointStore({ clock: () => 0, idFactory: () => "ep-1", }); const service = createWebhookService({ endpointStore: store, deadLetterSink: new MemoryDeadLetterSink(), maxAttempts: 1, initialRetryDelayMs: 1, maxRetryDelayMs: 100, idempotencyGuard: null, logger: noopLogger(), clock: () => 0, fetch: globalThis.fetch.bind(globalThis), }); await service.register({ tenantId: null, url: receiver.url, secret: "shared-secret", events: ["order.created"], }); const result = await service.send("ep-1", "order.created", { orderId: 42, }); expect(result.status).toBe("delivered"); expect(receiver.requests).toHaveLength(1); const req = receiver.requests[0]; if (!req) throw new Error("missing request"); expect(req.method).toBe("POST"); expect(req.headers["x-webhook-event"]).toBe("order.created"); expect(req.headers["x-webhook-id"]).toBeTruthy(); expect(req.headers["x-webhook-timestamp"]).toBeTruthy(); const sig = req.headers["x-hub-signature-256"]; expect(sig).toMatch(/^sha256=/); const verified = await verifySignature( req.body, sig as string, "shared-secret", "sha256" ); expect(verified).toBe(true); expect(JSON.parse(req.body).data).toEqual({ orderId: 42 }); } finally { await receiver.close(); } }); });