diff --git a/packages/@emulators/core/src/__tests__/webhooks.test.ts b/packages/@emulators/core/src/__tests__/webhooks.test.ts index 3ef41181..bb357bb5 100644 --- a/packages/@emulators/core/src/__tests__/webhooks.test.ts +++ b/packages/@emulators/core/src/__tests__/webhooks.test.ts @@ -152,6 +152,7 @@ describe("WebhookDispatcher", () => { }); afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -268,6 +269,36 @@ describe("WebhookDispatcher", () => { expect(body).toBe(JSON.stringify(payload)); }); + it("sets Stripe-Signature for stripe webhook subscriptions", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T00:00:00Z")); + + const d = new WebhookDispatcher(); + const secret = "whsec_test"; + d.register({ + url: "https://hooks.example/stripe", + events: ["customer.created"], + active: true, + owner: "stripe", + secret, + signatureScheme: "stripe", + }); + + const payload = { id: "evt_123", type: "customer.created" }; + await d.dispatch("customer.created", undefined, payload, "stripe"); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [, init] = mockFetch.mock.calls[0]!; + const body = (init as RequestInit).body as string; + const headers = (init as RequestInit).headers as Record; + const timestamp = 1704067200; + const expectedHmac = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex"); + expect(headers["Stripe-Signature"]).toBe(`t=${timestamp},v1=${expectedHmac}`); + expect(headers["X-Hub-Signature-256"]).toBeUndefined(); + expect(headers["X-GitHub-Event"]).toBeUndefined(); + expect(body).toBe(JSON.stringify(payload)); + }); + it("matches repo only when owner and repo align with the dispatch call", async () => { const d = new WebhookDispatcher(); d.register({ diff --git a/packages/@emulators/core/src/webhooks.ts b/packages/@emulators/core/src/webhooks.ts index c413df7c..3d79ccd5 100644 --- a/packages/@emulators/core/src/webhooks.ts +++ b/packages/@emulators/core/src/webhooks.ts @@ -1,11 +1,14 @@ import { createHmac } from "crypto"; +export type WebhookSignatureScheme = "github" | "stripe"; + export interface WebhookSubscription { id: number; url: string; events: string[]; active: boolean; secret?: string; + signatureScheme?: WebhookSignatureScheme; owner: string; repo?: string; } @@ -103,22 +106,13 @@ export class WebhookDispatcher { const body = JSON.stringify(payload); - const signatureHeaders: Record = {}; - if (sub.secret) { - const hmac = createHmac("sha256", sub.secret).update(body).digest("hex"); - signatureHeaders["X-Hub-Signature-256"] = `sha256=${hmac}`; - } + const headers = buildWebhookHeaders(sub, event, delivery.id, body); try { const start = Date.now(); const response = await fetch(sub.url, { method: "POST", - headers: { - "Content-Type": "application/json", - "X-GitHub-Event": event, - "X-GitHub-Delivery": String(delivery.id), - ...signatureHeaders, - }, + headers, body, signal: AbortSignal.timeout(10000), }); @@ -151,3 +145,34 @@ export class WebhookDispatcher { this.deliveryIdCounter = 1; } } + +function buildWebhookHeaders( + sub: WebhookSubscription, + event: string, + deliveryId: number, + body: string, +): Record { + if (sub.signatureScheme === "stripe") { + const headers: Record = { + "Content-Type": "application/json", + }; + if (sub.secret) { + const timestamp = Math.floor(Date.now() / 1000); + const payload = `${timestamp}.${body}`; + const signature = createHmac("sha256", sub.secret).update(payload).digest("hex"); + headers["Stripe-Signature"] = `t=${timestamp},v1=${signature}`; + } + return headers; + } + + const headers: Record = { + "Content-Type": "application/json", + "X-GitHub-Event": event, + "X-GitHub-Delivery": String(deliveryId), + }; + if (sub.secret) { + const hmac = createHmac("sha256", sub.secret).update(body).digest("hex"); + headers["X-Hub-Signature-256"] = `sha256=${hmac}`; + } + return headers; +} diff --git a/packages/@emulators/stripe/src/__tests__/stripe.test.ts b/packages/@emulators/stripe/src/__tests__/stripe.test.ts index 79e06c30..48551849 100644 --- a/packages/@emulators/stripe/src/__tests__/stripe.test.ts +++ b/packages/@emulators/stripe/src/__tests__/stripe.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { createHmac } from "crypto"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { Hono } from "hono"; import { Store, @@ -41,12 +42,15 @@ function auth(): Record { describe("Stripe plugin", () => { let app: Hono; - let webhooks: WebhookDispatcher; beforeEach(() => { const ctx = createTestApp(); app = ctx.app; - webhooks = ctx.webhooks; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); }); describe("customers", () => { @@ -480,5 +484,36 @@ describe("Stripe plugin", () => { expect(prices).toHaveLength(1); expect(prices[0].unit_amount).toBe(999); }); + + it("registers seeded webhooks with Stripe signature headers", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T00:00:00Z")); + + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", mockFetch); + + const store = new Store(); + const webhooks = new WebhookDispatcher(); + const secret = "whsec_test"; + seedFromConfig( + store, + base, + { + webhooks: [{ url: "https://hooks.example/stripe", events: ["customer.created"], secret }], + }, + webhooks, + ); + + await webhooks.dispatch("customer.created", undefined, { id: "evt_123" }, "stripe"); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [, init] = mockFetch.mock.calls[0]!; + const body = (init as RequestInit).body as string; + const headers = (init as RequestInit).headers as Record; + const timestamp = 1704067200; + const expectedHmac = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex"); + expect(headers["Stripe-Signature"]).toBe(`t=${timestamp},v1=${expectedHmac}`); + expect(headers["X-Hub-Signature-256"]).toBeUndefined(); + }); }); }); diff --git a/packages/@emulators/stripe/src/index.ts b/packages/@emulators/stripe/src/index.ts index 32d3a40f..27d82a1a 100644 --- a/packages/@emulators/stripe/src/index.ts +++ b/packages/@emulators/stripe/src/index.ts @@ -108,6 +108,7 @@ export function seedFromConfig( events: wh.events, active: true, secret: wh.secret, + signatureScheme: "stripe", owner: "stripe", }); }