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:
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user