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:
@@ -4,6 +4,7 @@ import { TierLimitError } from "../tiers";
|
||||
const mockDb = vi.hoisted(() => ({
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@epicure/db", () => ({
|
||||
@@ -22,7 +23,7 @@ vi.mock("@epicure/db", () => ({
|
||||
}));
|
||||
|
||||
// Import after mock
|
||||
const { checkTierLimit, incrementUsage } = await import("../tiers");
|
||||
const { checkAndIncrementTierLimit, incrementUsage } = await import("../tiers");
|
||||
|
||||
function makeChain(finalValue: unknown) {
|
||||
const chain = {
|
||||
@@ -54,7 +55,7 @@ describe("TierLimitError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkTierLimit", () => {
|
||||
describe("checkAndIncrementTierLimit", () => {
|
||||
const tierDef = {
|
||||
tier: "free",
|
||||
maxRecipes: 10,
|
||||
@@ -63,50 +64,32 @@ describe("checkTierLimit", () => {
|
||||
maxPublicRecipes: 3,
|
||||
};
|
||||
|
||||
it("does not throw when usage is under limit", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(makeChain([tierDef]))
|
||||
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 3, recipeCount: 2, storageUsedMb: 0 }]));
|
||||
it("does not throw when the atomic upsert returns a row (under limit)", async () => {
|
||||
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
|
||||
mockDb.execute.mockResolvedValueOnce([{ aiCallsUsed: 4 }]);
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
||||
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws TierLimitError when aiCall limit reached", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(makeChain([tierDef]))
|
||||
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 5, recipeCount: 0, storageUsedMb: 0 }]));
|
||||
it("throws TierLimitError when the upsert's WHERE clause excludes the row (limit reached)", async () => {
|
||||
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
|
||||
mockDb.execute.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "aiCall")).rejects.toThrow(TierLimitError);
|
||||
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).rejects.toThrow(TierLimitError);
|
||||
});
|
||||
|
||||
it("throws TierLimitError when recipe limit reached", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(makeChain([tierDef]))
|
||||
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 0, recipeCount: 10, storageUsedMb: 0 }]));
|
||||
it("throws TierLimitError for recipe key when limit reached", async () => {
|
||||
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
|
||||
mockDb.execute.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError);
|
||||
});
|
||||
|
||||
it("does not throw when no usage row exists (treats as zero)", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(makeChain([tierDef]))
|
||||
.mockReturnValueOnce(makeChain([]));
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
||||
await expect(checkAndIncrementTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError);
|
||||
});
|
||||
|
||||
it("does not throw when tier definition does not exist", async () => {
|
||||
mockDb.select.mockReturnValueOnce(makeChain([]));
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not throw for storage key (no limit enforced)", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(makeChain([tierDef]))
|
||||
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 0, recipeCount: 0, storageUsedMb: 9999 }]));
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "storage")).resolves.toBeUndefined();
|
||||
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
||||
expect(mockDb.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isPrivateAddress } from "../validate-webhook-url";
|
||||
|
||||
describe("isPrivateAddress", () => {
|
||||
it("flags IPv4 private/reserved ranges", () => {
|
||||
expect(isPrivateAddress("127.0.0.1")).toBe(true);
|
||||
expect(isPrivateAddress("10.1.2.3")).toBe(true);
|
||||
expect(isPrivateAddress("172.16.0.1")).toBe(true);
|
||||
expect(isPrivateAddress("192.168.1.1")).toBe(true);
|
||||
expect(isPrivateAddress("169.254.1.1")).toBe(true);
|
||||
expect(isPrivateAddress("224.0.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows public IPv4 addresses", () => {
|
||||
expect(isPrivateAddress("8.8.8.8")).toBe(false);
|
||||
expect(isPrivateAddress("1.1.1.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed on malformed IPv4 octets", () => {
|
||||
expect(isPrivateAddress("999.999.999.999")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags IPv6 loopback and unspecified addresses in any compression form", () => {
|
||||
expect(isPrivateAddress("::1")).toBe(true);
|
||||
expect(isPrivateAddress("::")).toBe(true);
|
||||
expect(isPrivateAddress("0:0:0:0:0:0:0:1")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags IPv6 unique-local (fc00::/7) and link-local (fe80::/10) ranges", () => {
|
||||
expect(isPrivateAddress("fc00::1")).toBe(true);
|
||||
expect(isPrivateAddress("fd12:3456::1")).toBe(true);
|
||||
expect(isPrivateAddress("fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")).toBe(true);
|
||||
expect(isPrivateAddress("fe80::1")).toBe(true);
|
||||
expect(isPrivateAddress("fe00::1")).toBe(false);
|
||||
});
|
||||
|
||||
it("recurses into IPv4-mapped IPv6 addresses, including non-compressed forms", () => {
|
||||
expect(isPrivateAddress("::ffff:127.0.0.1")).toBe(true);
|
||||
expect(isPrivateAddress("::ffff:10.0.0.5")).toBe(true);
|
||||
expect(isPrivateAddress("::ffff:8.8.8.8")).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed on a malformed IPv4-mapped address (out-of-range octets)", () => {
|
||||
expect(isPrivateAddress("::ffff:999.999.999.999")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows public IPv6 addresses", () => {
|
||||
expect(isPrivateAddress("2001:4860:4860::8888")).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed on unparseable input", () => {
|
||||
expect(isPrivateAddress("not-an-ip")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ vi.mock("@epicure/db", () => ({
|
||||
|
||||
let fetchCalls: { url: string; body: string; headers: Record<string, string> }[] = [];
|
||||
|
||||
global.fetch = vi.fn(async (url: RequestInfo, init?: RequestInit) => {
|
||||
global.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body = (init?.body as string) ?? "";
|
||||
fetchCalls.push({
|
||||
url: url as string,
|
||||
@@ -38,7 +38,7 @@ beforeEach(() => {
|
||||
fetchCalls = [];
|
||||
vi.clearAllMocks();
|
||||
mockInsert.mockReturnValue({ values: mockInsertValues });
|
||||
global.fetch = vi.fn(async (url: RequestInfo, init?: RequestInit) => {
|
||||
global.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body = (init?.body as string) ?? "";
|
||||
fetchCalls.push({
|
||||
url: url as string,
|
||||
|
||||
Reference in New Issue
Block a user