Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/@emulators/core/src/__tests__/webhooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ describe("WebhookDispatcher", () => {
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

Expand Down Expand Up @@ -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<string, string>;
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({
Expand Down
47 changes: 36 additions & 11 deletions packages/@emulators/core/src/webhooks.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down Expand Up @@ -103,22 +106,13 @@ export class WebhookDispatcher {

const body = JSON.stringify(payload);

const signatureHeaders: Record<string, string> = {};
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),
});
Expand Down Expand Up @@ -151,3 +145,34 @@ export class WebhookDispatcher {
this.deliveryIdCounter = 1;
}
}

function buildWebhookHeaders(
sub: WebhookSubscription,
event: string,
deliveryId: number,
body: string,
): Record<string, string> {
if (sub.signatureScheme === "stripe") {
const headers: Record<string, string> = {
"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<string, string> = {
"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;
}
41 changes: 38 additions & 3 deletions packages/@emulators/stripe/src/__tests__/stripe.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -41,12 +42,15 @@ function auth(): Record<string, string> {

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", () => {
Expand Down Expand Up @@ -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<string, string>;
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();
});
});
});
1 change: 1 addition & 0 deletions packages/@emulators/stripe/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export function seedFromConfig(
events: wh.events,
active: true,
secret: wh.secret,
signatureScheme: "stripe",
owner: "stripe",
});
}
Expand Down