Files
Epicure/apps/web/lib/__tests__/webhooks.test.ts
T
Arnaud d2faf98ac1 fix: resolve TODO.md security/perf/test-coverage backlog
Fixes the 13-item codebase health scan backlog: wraps meal-plan
generation in a transaction, adds missing userId/GIN indexes, fixes
an IPv6-parsing gap in the webhook SSRF guard (and an identical
duplicated bug in the AI URL-import path, now consolidated onto one
implementation), paginates the collections list, dedupes the AI
recipe Zod schemas, wires up Stripe tier sync, rate-limits AI key
rotation, gets `pnpm typecheck` actually working, and adds test
coverage for the previously-untested admin/webhooks routes.

Two flagged issues (collection removeRecipeId IDOR, tier-limit race)
turned out to already be fixed/non-issues on inspection — noted in
TODO.md rather than silently dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 12:12:42 +02:00

157 lines
5.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import crypto from "crypto";
const mockWebhookSelect = vi.fn();
const mockInsert = vi.fn();
const mockInsertValues = vi.fn().mockResolvedValue(undefined);
vi.mock("@epicure/db", () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: mockWebhookSelect,
})),
})),
insert: vi.fn(() => ({ values: mockInsertValues })),
},
webhooks: {},
webhookDeliveries: {},
eq: vi.fn(),
and: vi.fn(),
}));
let fetchCalls: { url: string; body: string; headers: Record<string, string> }[] = [];
global.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const body = (init?.body as string) ?? "";
fetchCalls.push({
url: url as string,
body,
headers: (init?.headers as Record<string, string>) ?? {},
});
return new Response(null, { status: 200 });
});
const { dispatchWebhook } = await import("../webhooks");
beforeEach(() => {
fetchCalls = [];
vi.clearAllMocks();
mockInsert.mockReturnValue({ values: mockInsertValues });
global.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const body = (init?.body as string) ?? "";
fetchCalls.push({
url: url as string,
body,
headers: (init?.headers as Record<string, string>) ?? {},
});
return new Response(null, { status: 200 });
});
});
describe("dispatchWebhook", () => {
it("does not fetch when no matching webhooks", async () => {
mockWebhookSelect.mockResolvedValue([]);
await dispatchWebhook("user1", "recipe.created", { id: "r1" });
expect(global.fetch).not.toHaveBeenCalled();
});
it("sends POST to webhook URL with event and payload", async () => {
mockWebhookSelect.mockResolvedValue([
{ id: "wh1", url: "https://example.com/hook", secret: "mysecret", events: [], active: true },
]);
await dispatchWebhook("user1", "recipe.created", { id: "r1", title: "Test" });
expect(global.fetch).toHaveBeenCalledOnce();
const call = fetchCalls[0]!;
expect(call.url).toBe("https://example.com/hook");
const parsed = JSON.parse(call.body) as { event: string; payload: unknown };
expect(parsed.event).toBe("recipe.created");
expect(parsed.payload).toMatchObject({ id: "r1", title: "Test" });
});
it("includes valid HMAC-SHA256 signature header", async () => {
const secret = "test-webhook-secret";
mockWebhookSelect.mockResolvedValue([
{ id: "wh1", url: "https://example.com/hook", secret, events: [], active: true },
]);
await dispatchWebhook("user1", "recipe.updated", { id: "r2" });
const call = fetchCalls[0]!;
const sigHeader = call.headers["X-Epicure-Signature"];
expect(sigHeader).toBeTruthy();
expect(sigHeader).toMatch(/^sha256=/);
const expectedSig = "sha256=" + crypto.createHmac("sha256", secret).update(call.body).digest("hex");
expect(sigHeader).toBe(expectedSig);
});
it("sends X-Epicure-Event header", async () => {
mockWebhookSelect.mockResolvedValue([
{ id: "wh1", url: "https://example.com/hook", secret: "s", events: [], active: true },
]);
await dispatchWebhook("user1", "comment.added", {});
expect(fetchCalls[0]!.headers["X-Epicure-Event"]).toBe("comment.added");
});
it("filters webhooks by event subscription", async () => {
mockWebhookSelect.mockResolvedValue([
{ id: "wh1", url: "https://a.com/hook", secret: "s", events: ["recipe.deleted"], active: true },
{ id: "wh2", url: "https://b.com/hook", secret: "s", events: [], active: true },
]);
await dispatchWebhook("user1", "recipe.created", {});
// wh1 only subscribes to recipe.deleted, wh2 subscribes to all (empty = all)
expect(global.fetch).toHaveBeenCalledOnce();
expect(fetchCalls[0]!.url).toBe("https://b.com/hook");
});
it("records delivery success in DB", async () => {
const { db } = await import("@epicure/db");
mockWebhookSelect.mockResolvedValue([
{ id: "wh1", url: "https://example.com/hook", secret: "s", events: [], active: true },
]);
await dispatchWebhook("user1", "recipe.created", {});
expect(db.insert).toHaveBeenCalled();
const insertedRow = mockInsertValues.mock.calls[0]![0] as Record<string, unknown>;
expect(insertedRow.success).toBe(true);
expect(insertedRow.event).toBe("recipe.created");
});
it("records delivery failure when fetch throws", async () => {
const { db } = await import("@epicure/db");
global.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
mockWebhookSelect.mockResolvedValue([
{ id: "wh1", url: "https://example.com/hook", secret: "s", events: [], active: true },
]);
await dispatchWebhook("user1", "recipe.created", {});
expect(db.insert).toHaveBeenCalled();
const insertedRow = mockInsertValues.mock.calls[0]![0] as Record<string, unknown>;
expect(insertedRow.success).toBe(false);
expect(insertedRow.statusCode).toBe(0);
});
it("still dispatches remaining hooks if one fails (allSettled)", async () => {
global.fetch = vi.fn()
.mockRejectedValueOnce(new Error("Timeout"))
.mockResolvedValueOnce(new Response(null, { status: 200 }));
mockWebhookSelect.mockResolvedValue([
{ id: "wh1", url: "https://fail.com/hook", secret: "s", events: [], active: true },
{ id: "wh2", url: "https://ok.com/hook", secret: "s", events: [], active: true },
]);
await expect(dispatchWebhook("user1", "recipe.created", {})).resolves.toBeUndefined();
expect(global.fetch).toHaveBeenCalledTimes(2);
});
});