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>
This commit is contained in:
Arnaud
2026-07-02 12:12:42 +02:00
parent 2154512e54
commit d2faf98ac1
38 changed files with 7598 additions and 315 deletions
@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mockAdminSession = { user: { id: "admin-1", role: "admin" } };
vi.mock("next/headers", () => ({
headers: vi.fn().mockResolvedValue(new Headers()),
}));
vi.mock("@/lib/auth/server", () => ({
auth: { api: { getSession: vi.fn() } },
}));
vi.mock("@/lib/site-settings", () => ({
setSiteSetting: vi.fn().mockResolvedValue(undefined),
}));
const { mockSelectChain, mockInsertValues } = vi.hoisted(() => {
const mockSelectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([{ role: "admin" }]),
};
return { mockSelectChain, mockInsertValues: vi.fn().mockResolvedValue(undefined) };
});
vi.mock("@epicure/db", () => ({
db: {
select: vi.fn(() => mockSelectChain),
insert: vi.fn(() => ({ values: mockInsertValues })),
},
users: { id: "id", role: "role" },
auditLogs: {},
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
}));
const { auth } = await import("@/lib/auth/server");
const { setSiteSetting } = await import("@/lib/site-settings");
import { PUT } from "../route";
function makeRequest(body: unknown) {
return new NextRequest("http://localhost/api/v1/admin/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(auth.api.getSession).mockResolvedValue(mockAdminSession as never);
mockSelectChain.where.mockResolvedValue([{ role: "admin" }]);
});
describe("PUT /api/v1/admin/settings", () => {
it("returns 403 when caller is not an admin", async () => {
mockSelectChain.where.mockResolvedValue([{ role: "user" }]);
const res = await PUT(makeRequest({ OPENAI_API_KEY: "sk-1" }));
expect(res.status).toBe(403);
});
it("returns 403 when there is no session", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(null as never);
const res = await PUT(makeRequest({ OPENAI_API_KEY: "sk-1" }));
expect(res.status).toBe(403);
});
it("updates allowed keys and writes an audit log", async () => {
const res = await PUT(makeRequest({ OPENAI_API_KEY: "sk-1" }));
expect(res.status).toBe(200);
expect(vi.mocked(setSiteSetting)).toHaveBeenCalledWith("OPENAI_API_KEY", "sk-1", "admin-1");
expect(mockInsertValues).toHaveBeenCalled();
});
it("silently ignores keys not in the allow-list", async () => {
const res = await PUT(makeRequest({ NOT_A_REAL_KEY: "x" }));
expect(res.status).toBe(200);
expect(vi.mocked(setSiteSetting)).not.toHaveBeenCalled();
expect(mockInsertValues).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
const mockAdminSession = { user: { id: "admin-1", role: "admin" } };
vi.mock("@/lib/api-auth", () => ({
requireAdmin: vi.fn(),
}));
vi.mock("@/lib/email", () => ({
sendEmail: vi.fn().mockResolvedValue(undefined),
verifyEmailHtml: vi.fn((url: string) => `<a href="${url}">verify</a>`),
}));
const { requireAdmin } = await import("@/lib/api-auth");
const { sendEmail } = await import("@/lib/email");
import { POST } from "../route";
function makeRequest(body: unknown) {
return new NextRequest("http://localhost/api/v1/admin/test-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
const ORIGINAL_ENV = process.env["BETTER_AUTH_URL"];
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue({ session: mockAdminSession as never, response: null });
process.env["BETTER_AUTH_URL"] = "https://epicure.example.com";
});
afterEach(() => {
if (ORIGINAL_ENV === undefined) delete process.env["BETTER_AUTH_URL"];
else process.env["BETTER_AUTH_URL"] = ORIGINAL_ENV;
});
describe("POST /api/v1/admin/test-email", () => {
it("returns 403 when caller is not an admin", async () => {
vi.mocked(requireAdmin).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
} as never);
const res = await POST(makeRequest({ to: "user@example.com" }));
expect(res.status).toBe(403);
});
it("returns 400 when 'to' is missing", async () => {
const res = await POST(makeRequest({}));
expect(res.status).toBe(400);
});
it("returns 500 when BETTER_AUTH_URL is not configured", async () => {
delete process.env["BETTER_AUTH_URL"];
const res = await POST(makeRequest({ to: "user@example.com" }));
expect(res.status).toBe(500);
expect(sendEmail).not.toHaveBeenCalled();
});
it("sends the test email using the configured base URL", async () => {
const res = await POST(makeRequest({ to: "user@example.com" }));
expect(res.status).toBe(200);
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({ to: "user@example.com" })
);
});
});
@@ -9,11 +9,16 @@ export async function POST(req: NextRequest) {
const { to } = await req.json() as { to: string };
if (!to) return NextResponse.json({ error: "Missing 'to'" }, { status: 400 });
const baseUrl = process.env["BETTER_AUTH_URL"];
if (!baseUrl) {
return NextResponse.json({ error: "BETTER_AUTH_URL is not configured" }, { status: 500 });
}
try {
await sendEmail({
to,
subject: "Epicure — test email",
html: verifyEmailHtml(`${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3001"}/verify-email?token=test`),
html: verifyEmailHtml(`${baseUrl}/verify-email?token=test`),
});
return NextResponse.json({ ok: true });
} catch (err) {
@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mockAdminSession = { user: { id: "admin-1", role: "admin" } };
vi.mock("@/lib/api-auth", () => ({
requireAdmin: vi.fn(),
}));
const { mockUpdateChain, mockInsertValues } = vi.hoisted(() => {
const mockUpdateChain = {
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([{ id: "target-1", role: "moderator", tier: "free" }]),
};
return { mockUpdateChain, mockInsertValues: vi.fn().mockResolvedValue(undefined) };
});
vi.mock("@epicure/db", () => ({
db: {
update: vi.fn(() => mockUpdateChain),
insert: vi.fn(() => ({ values: mockInsertValues })),
},
users: { id: "id", role: "role", tier: "tier" },
auditLogs: {},
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
}));
const { requireAdmin } = await import("@/lib/api-auth");
import { PATCH } from "../route";
function makeRequest(body: unknown) {
return new NextRequest("http://localhost/api/v1/admin/users/target-1", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
const ctx = { params: Promise.resolve({ id: "target-1" }) };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue({ session: mockAdminSession as never, response: null });
mockUpdateChain.returning.mockResolvedValue([{ id: "target-1", role: "moderator", tier: "free" }]);
});
describe("PATCH /api/v1/admin/users/[id]", () => {
it("returns 403 when caller is not an admin", async () => {
vi.mocked(requireAdmin).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
} as never);
const res = await PATCH(makeRequest({ role: "admin" }), ctx);
expect(res.status).toBe(403);
});
it("returns 400 for an invalid role", async () => {
const res = await PATCH(makeRequest({ role: "superuser" }), ctx);
expect(res.status).toBe(400);
});
it("returns 400 for an invalid tier", async () => {
const res = await PATCH(makeRequest({ tier: "enterprise" }), ctx);
expect(res.status).toBe(400);
});
it("returns 404 when the target user does not exist", async () => {
mockUpdateChain.returning.mockResolvedValue([]);
const res = await PATCH(makeRequest({ role: "moderator" }), ctx);
expect(res.status).toBe(404);
});
it("updates the user and writes an audit log", async () => {
const res = await PATCH(makeRequest({ role: "moderator" }), ctx);
expect(res.status).toBe(200);
const body = await res.json() as { user: { id: string } };
expect(body.user.id).toBe("target-1");
expect(mockInsertValues).toHaveBeenCalledWith(
expect.objectContaining({ action: "admin.user.update", targetId: "target-1" })
);
});
});
+4
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { db, userAiKeys, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { encrypt } from "@/lib/encrypt";
import { applyRateLimit } from "@/lib/rate-limit";
const VALID_PROVIDERS = ["openai", "anthropic", "openrouter", "ollama"] as const;
@@ -27,6 +28,9 @@ export async function POST(req: Request) {
const { session, response } = await requireSession();
if (response) return response;
const limited = await applyRateLimit(`rl:ai-keys:${session!.user.id}`, 5, 3600);
if (limited) return limited;
const body = PostSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: body.error.flatten() }, { status: 400 });
@@ -78,71 +78,73 @@ export async function POST(req: NextRequest) {
const createdEntries: Array<{ id: string; day: string; mealType: string; recipeId: string; recipeTitle: string }> = [];
for (const entry of plan.entries) {
// Create draft recipe
const recipeId = crypto.randomUUID();
await db.insert(recipes).values({
id: recipeId,
authorId: userId,
title: entry.recipe.title,
description: entry.recipe.description,
baseServings: entry.servings,
visibility: "private",
aiGenerated: true,
difficulty: entry.recipe.difficulty ?? null,
prepMins: entry.recipe.prepMins ?? null,
cookMins: entry.recipe.cookMins ?? null,
});
await db.transaction(async (tx) => {
for (const entry of plan.entries) {
// Create draft recipe
const recipeId = crypto.randomUUID();
await tx.insert(recipes).values({
id: recipeId,
authorId: userId,
title: entry.recipe.title,
description: entry.recipe.description,
baseServings: entry.servings,
visibility: "private",
aiGenerated: true,
difficulty: entry.recipe.difficulty ?? null,
prepMins: entry.recipe.prepMins ?? null,
cookMins: entry.recipe.cookMins ?? null,
});
if (entry.recipe.ingredients.length > 0) {
await db.insert(recipeIngredients).values(
entry.recipe.ingredients.map((ing, i) => ({
id: crypto.randomUUID(),
recipeId,
rawName: ing.rawName,
quantity: ing.quantity != null ? String(ing.quantity) : null,
unit: ing.unit ?? null,
order: i,
}))
);
if (entry.recipe.ingredients.length > 0) {
await tx.insert(recipeIngredients).values(
entry.recipe.ingredients.map((ing, i) => ({
id: crypto.randomUUID(),
recipeId,
rawName: ing.rawName,
quantity: ing.quantity != null ? String(ing.quantity) : null,
unit: ing.unit ?? null,
order: i,
}))
);
}
if (entry.recipe.steps.length > 0) {
await tx.insert(recipeSteps).values(
entry.recipe.steps.map((step, i) => ({
id: crypto.randomUUID(),
recipeId,
instruction: step.instruction,
order: i,
}))
);
}
// Remove any existing entry for this day+mealType, then insert new
const existingEntry = await tx.query.mealPlanEntries.findFirst({
where: and(
eq(mealPlanEntries.mealPlanId, mealPlan!.id),
eq(mealPlanEntries.day, entry.day),
eq(mealPlanEntries.mealType, entry.mealType)
),
});
if (existingEntry) {
await tx.delete(mealPlanEntries).where(eq(mealPlanEntries.id, existingEntry.id));
}
const entryId = crypto.randomUUID();
await tx.insert(mealPlanEntries).values({
id: entryId,
mealPlanId: mealPlan!.id,
day: entry.day,
mealType: entry.mealType,
recipeId,
servings: entry.servings,
});
createdEntries.push({ id: entryId, day: entry.day, mealType: entry.mealType, recipeId, recipeTitle: entry.recipe.title });
}
if (entry.recipe.steps.length > 0) {
await db.insert(recipeSteps).values(
entry.recipe.steps.map((step, i) => ({
id: crypto.randomUUID(),
recipeId,
instruction: step.instruction,
order: i,
}))
);
}
// Remove any existing entry for this day+mealType, then insert new
const existingEntry = await db.query.mealPlanEntries.findFirst({
where: and(
eq(mealPlanEntries.mealPlanId, mealPlan!.id),
eq(mealPlanEntries.day, entry.day),
eq(mealPlanEntries.mealType, entry.mealType)
),
});
if (existingEntry) {
await db.delete(mealPlanEntries).where(eq(mealPlanEntries.id, existingEntry.id));
}
const entryId = crypto.randomUUID();
await db.insert(mealPlanEntries).values({
id: entryId,
mealPlanId: mealPlan!.id,
day: entry.day,
mealType: entry.mealType,
recipeId,
servings: entry.servings,
});
createdEntries.push({ id: entryId, day: entry.day, mealType: entry.mealType, recipeId, recipeTitle: entry.recipe.title });
}
});
return NextResponse.json({ weekStart: parsed.data.weekStart, entries: createdEntries });
}
+33 -8
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, collections, eq, desc } from "@epicure/db";
import { db, collections, eq, desc, sql } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
const Schema = z.object({
@@ -9,17 +9,42 @@ const Schema = z.object({
isPublic: z.boolean().default(false),
});
export async function GET(_req: NextRequest) {
export async function GET(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const rows = await db.query.collections.findMany({
where: eq(collections.userId, session!.user.id),
orderBy: desc(collections.updatedAt),
with: { recipes: { limit: 4, with: { recipe: { with: { photos: true } } } } },
});
const { searchParams } = req.nextUrl;
return NextResponse.json(rows);
const limitRaw = searchParams.get("limit");
const limit = Math.min(
limitRaw !== null && !Number.isNaN(Number(limitRaw))
? Math.max(1, Number(limitRaw))
: 20,
50
);
const offsetRaw = searchParams.get("offset");
const offset =
offsetRaw !== null && !Number.isNaN(Number(offsetRaw))
? Math.max(0, Number(offsetRaw))
: 0;
const where = eq(collections.userId, session!.user.id);
const [rows, countResult] = await Promise.all([
db.query.collections.findMany({
where,
orderBy: desc(collections.updatedAt),
with: { recipes: { limit: 4, with: { recipe: { with: { photos: true } } } } },
limit,
offset,
}),
db.select({ total: sql<number>`count(*)::int` }).from(collections).where(where),
]);
const total = countResult[0]?.total ?? 0;
return NextResponse.json({ data: rows, total, limit, offset });
}
export async function POST(req: NextRequest) {
@@ -12,7 +12,7 @@ vi.mock("@/lib/api-auth", () => ({
}));
vi.mock("@/lib/tiers", () => ({
checkTierLimit: vi.fn(),
checkAndIncrementTierLimit: vi.fn(),
incrementUsage: vi.fn(),
TierLimitError: class TierLimitError extends Error {},
}));
@@ -134,9 +134,9 @@ describe("POST /api/v1/recipes", () => {
});
it("returns 403 when tier limit reached", async () => {
const { checkTierLimit } = await import("@/lib/tiers");
const { checkAndIncrementTierLimit } = await import("@/lib/tiers");
const { TierLimitError } = await import("@/lib/tiers");
vi.mocked(checkTierLimit).mockRejectedValue(new TierLimitError("recipe", "free"));
vi.mocked(checkAndIncrementTierLimit).mockRejectedValue(new TierLimitError("recipe", "free"));
const res = await POST(makeRequest("POST", validRecipe));
expect(res.status).toBe(403);
+2 -1
View File
@@ -85,7 +85,8 @@ export async function GET(req: NextRequest) {
}
for (const tag of dietaryTags) {
conditions.push(sql`${recipes.dietaryTags}->>${tag} = 'true'`);
// Containment (@>) instead of ->> text extraction so the GIN index on dietaryTags is actually used.
conditions.push(sql`${recipes.dietaryTags} @> ${JSON.stringify({ [tag]: true })}::jsonb`);
}
const where = and(...conditions);
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mockSession = { user: { id: "user-1" } };
vi.mock("@/lib/api-auth", () => ({
requireSession: vi.fn(),
}));
vi.mock("@/lib/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn().mockResolvedValue(null),
}));
const { mockSelectChain, mockDeleteChain, mockUpdateChain } = vi.hoisted(() => {
const mockSelectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([{ id: "wh-1" }]),
};
const mockDeleteChain = { where: vi.fn().mockResolvedValue(undefined) };
const mockUpdateChain = { set: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValue(undefined) };
return { mockSelectChain, mockDeleteChain, mockUpdateChain };
});
vi.mock("@epicure/db", () => ({
db: {
select: vi.fn(() => mockSelectChain),
delete: vi.fn(() => mockDeleteChain),
update: vi.fn(() => mockUpdateChain),
},
webhooks: { id: "id", userId: "user_id", url: "url", events: "events", active: "active", createdAt: "created_at" },
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
and: vi.fn((...args) => ({ args, op: "and" })),
}));
const { requireSession } = await import("@/lib/api-auth");
const { validateWebhookUrl } = await import("@/lib/validate-webhook-url");
import { DELETE, PATCH } from "../route";
function makeRequest(method: string, body?: unknown) {
return new NextRequest("http://localhost/api/v1/webhooks/wh-1", {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
}
const ctx = { params: Promise.resolve({ id: "wh-1" }) };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null });
vi.mocked(validateWebhookUrl).mockResolvedValue(null);
mockSelectChain.limit.mockResolvedValue([{ id: "wh-1" }]);
});
describe("DELETE /api/v1/webhooks/[id]", () => {
it("returns 404 when the webhook does not belong to the caller", async () => {
mockSelectChain.limit.mockResolvedValue([]);
const res = await DELETE(makeRequest("DELETE"), ctx);
expect(res.status).toBe(404);
});
it("returns 204 on successful deletion", async () => {
const res = await DELETE(makeRequest("DELETE"), ctx);
expect(res.status).toBe(204);
});
});
describe("PATCH /api/v1/webhooks/[id]", () => {
it("returns 404 when the webhook does not belong to the caller", async () => {
mockSelectChain.limit.mockResolvedValue([]);
const res = await PATCH(makeRequest("PATCH", { active: false }), ctx);
expect(res.status).toBe(404);
});
it("returns 400 when the new URL fails SSRF validation", async () => {
vi.mocked(validateWebhookUrl).mockResolvedValue("Webhook URL must not point to a private or reserved address");
const res = await PATCH(makeRequest("PATCH", { url: "http://169.254.169.254/" }), ctx);
expect(res.status).toBe(400);
});
it("returns 400 when there are no fields to update", async () => {
const res = await PATCH(makeRequest("PATCH", {}), ctx);
expect(res.status).toBe(400);
});
it("returns 200 and applies the update", async () => {
const res = await PATCH(makeRequest("PATCH", { active: false }), ctx);
expect(res.status).toBe(200);
expect(mockUpdateChain.set).toHaveBeenCalledWith(expect.objectContaining({ active: false }));
});
});
@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mockSession = { user: { id: "user-1" } };
vi.mock("@/lib/api-auth", () => ({
requireSession: vi.fn(),
}));
const { mockFindFirst, mockSelectChain } = vi.hoisted(() => {
const mockSelectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([]),
};
return { mockFindFirst: vi.fn(), mockSelectChain };
});
vi.mock("@epicure/db", () => ({
db: {
select: vi.fn(() => mockSelectChain),
query: { webhooks: { findFirst: mockFindFirst } },
},
webhooks: { id: "id", userId: "user_id" },
webhookDeliveries: { webhookId: "webhook_id", createdAt: "created_at" },
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
and: vi.fn((...args) => ({ args, op: "and" })),
desc: vi.fn((a) => ({ a, op: "desc" })),
}));
const { requireSession } = await import("@/lib/api-auth");
import { GET } from "../route";
const ctx = { params: Promise.resolve({ id: "wh-1" }) };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null });
mockFindFirst.mockResolvedValue({ id: "wh-1" });
mockSelectChain.limit.mockResolvedValue([]);
});
describe("GET /api/v1/webhooks/[id]/deliveries", () => {
it("returns 401 when not authenticated", async () => {
vi.mocked(requireSession).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
} as never);
const res = await GET(new NextRequest("http://localhost/api/v1/webhooks/wh-1/deliveries"), ctx);
expect(res.status).toBe(401);
});
it("returns 404 when the webhook does not belong to the caller", async () => {
mockFindFirst.mockResolvedValue(undefined);
const res = await GET(new NextRequest("http://localhost/api/v1/webhooks/wh-1/deliveries"), ctx);
expect(res.status).toBe(404);
});
it("returns 200 with the delivery history", async () => {
mockSelectChain.limit.mockResolvedValue([{ id: "del-1", event: "recipe.created" }]);
const res = await GET(new NextRequest("http://localhost/api/v1/webhooks/wh-1/deliveries"), ctx);
expect(res.status).toBe(200);
const body = await res.json() as unknown[];
expect(body).toHaveLength(1);
});
});
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mockSession = { user: { id: "user-1" } };
vi.mock("@/lib/api-auth", () => ({
requireSession: vi.fn(),
}));
vi.mock("@/lib/webhooks", () => ({
dispatchWebhook: vi.fn().mockResolvedValue(undefined),
}));
const { mockWebhookFindFirst, mockDeliveryFindFirst } = vi.hoisted(() => ({
mockWebhookFindFirst: vi.fn(),
mockDeliveryFindFirst: vi.fn(),
}));
vi.mock("@epicure/db", () => ({
db: {
query: {
webhooks: { findFirst: mockWebhookFindFirst },
webhookDeliveries: { findFirst: mockDeliveryFindFirst },
},
},
webhooks: { id: "id", userId: "user_id" },
webhookDeliveries: { id: "id", webhookId: "webhook_id" },
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
and: vi.fn((...args) => ({ args, op: "and" })),
}));
const { requireSession } = await import("@/lib/api-auth");
const { dispatchWebhook } = await import("@/lib/webhooks");
import { POST } from "../route";
const VALID_DELIVERY_ID = "11111111-1111-1111-1111-111111111111";
function makeRequest(body?: unknown) {
return new NextRequest("http://localhost/api/v1/webhooks/wh-1/redeliver", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
}
const ctx = { params: Promise.resolve({ id: "wh-1" }) };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null });
mockWebhookFindFirst.mockResolvedValue({ id: "wh-1" });
mockDeliveryFindFirst.mockResolvedValue({
id: VALID_DELIVERY_ID,
event: "recipe.created",
payload: { recipeId: "r-1" },
});
});
describe("POST /api/v1/webhooks/[id]/redeliver", () => {
it("returns 401 when not authenticated", async () => {
vi.mocked(requireSession).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
} as never);
const res = await POST(makeRequest({ deliveryId: VALID_DELIVERY_ID }), ctx);
expect(res.status).toBe(401);
});
it("returns 404 when the webhook does not belong to the caller", async () => {
mockWebhookFindFirst.mockResolvedValue(undefined);
const res = await POST(makeRequest({ deliveryId: VALID_DELIVERY_ID }), ctx);
expect(res.status).toBe(404);
});
it("returns 400 when deliveryId is not a valid UUID", async () => {
const res = await POST(makeRequest({ deliveryId: "not-a-uuid" }), ctx);
expect(res.status).toBe(400);
});
it("returns 404 when the delivery is not found", async () => {
mockDeliveryFindFirst.mockResolvedValue(undefined);
const res = await POST(makeRequest({ deliveryId: VALID_DELIVERY_ID }), ctx);
expect(res.status).toBe(404);
});
it("replays the delivery payload via dispatchWebhook", async () => {
const res = await POST(makeRequest({ deliveryId: VALID_DELIVERY_ID }), ctx);
expect(res.status).toBe(200);
expect(dispatchWebhook).toHaveBeenCalledWith("user-1", "recipe.created", { recipeId: "r-1" });
});
});
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mockSession = { user: { id: "user-1" } };
vi.mock("@/lib/api-auth", () => ({
requireSession: vi.fn(),
}));
vi.mock("@/lib/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn().mockResolvedValue(null),
}));
const { mockSelectChain, mockInsertValues } = vi.hoisted(() => {
const mockSelectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([]),
};
return { mockSelectChain, mockInsertValues: vi.fn().mockResolvedValue(undefined) };
});
vi.mock("@epicure/db", () => ({
db: {
select: vi.fn(() => mockSelectChain),
insert: vi.fn(() => ({ values: mockInsertValues })),
},
webhooks: { id: "id", userId: "user_id", url: "url", events: "events", active: "active", createdAt: "created_at" },
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
}));
const { requireSession } = await import("@/lib/api-auth");
const { validateWebhookUrl } = await import("@/lib/validate-webhook-url");
import { GET, POST } from "../route";
function makeRequest(method: string, body?: unknown) {
return new NextRequest("http://localhost/api/v1/webhooks", {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null });
vi.mocked(validateWebhookUrl).mockResolvedValue(null);
mockSelectChain.where.mockResolvedValue([]);
});
describe("GET /api/v1/webhooks", () => {
it("returns 401 when not authenticated", async () => {
vi.mocked(requireSession).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
} as never);
const res = await GET();
expect(res.status).toBe(401);
});
it("returns 200 with the user's webhooks", async () => {
mockSelectChain.where.mockResolvedValue([{ id: "wh-1", userId: "user-1" }]);
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json() as unknown[];
expect(body).toHaveLength(1);
});
});
describe("POST /api/v1/webhooks", () => {
const validBody = { url: "https://example.com/hook", events: ["recipe.created"] };
it("returns 400 on validation error", async () => {
const res = await POST(makeRequest("POST", { url: "" }));
expect(res.status).toBe(400);
});
it("returns 400 when the URL fails SSRF validation", async () => {
vi.mocked(validateWebhookUrl).mockResolvedValue("Webhook URL must not point to a private or reserved address");
const res = await POST(makeRequest("POST", validBody));
expect(res.status).toBe(400);
});
it("returns 401 when not authenticated", async () => {
vi.mocked(requireSession).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
} as never);
const res = await POST(makeRequest("POST", validBody));
expect(res.status).toBe(401);
});
it("returns 201 and creates the webhook", async () => {
const res = await POST(makeRequest("POST", validBody));
expect(res.status).toBe(201);
const body = await res.json() as { url: string; secret: string };
expect(body.url).toBe(validBody.url);
expect(body.secret).toBeTruthy();
expect(mockInsertValues).toHaveBeenCalled();
});
});
+15 -7
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { db, users, eq } from "@epicure/db";
// Stripe webhook handler — verifies stripe-signature header using HMAC-SHA256.
// Handles:
@@ -74,16 +75,23 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
// TODO: wire up DB calls when Stripe billing is fully configured
switch (event.type) {
case "checkout.session.completed":
// upgrade user to pro
console.log("[stripe-webhook] checkout.session.completed", event.data.object["id"]);
case "checkout.session.completed": {
// client_reference_id is set to our internal userId when the Checkout Session is created.
const userId = event.data.object["client_reference_id"];
const customerId = event.data.object["customer"];
if (typeof userId === "string" && typeof customerId === "string") {
await db.update(users).set({ tier: "pro", stripeCustomerId: customerId }).where(eq(users.id, userId));
}
break;
case "customer.subscription.deleted":
// downgrade user to free
console.log("[stripe-webhook] customer.subscription.deleted", event.data.object["id"]);
}
case "customer.subscription.deleted": {
const customerId = event.data.object["customer"];
if (typeof customerId === "string") {
await db.update(users).set({ tier: "free" }).where(eq(users.stripeCustomerId, customerId));
}
break;
}
default:
// ignore unhandled event types
break;