Files
Epicure/apps/web/lib/__tests__/tiers.test.ts
T
Arnaud 5a9e306357 fix: close SSRF/rebinding, IDOR, and stale-session authz gaps found in audit
Bump to 0.5.1. Fixes: unfollowed-redirect SSRF + DNS-rebinding in AI
url-import and webhook dispatch (new safeFetch with IP-pinned undici
dispatcher); cross-user photo deletion via unvalidated recipe/review
storage keys; comment-moderation and tier-quota checks trusting a
stale cached session role/tier instead of the DB.

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

174 lines
5.3 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { TierLimitError } from "../tiers";
const mockDb = vi.hoisted(() => ({
select: vi.fn(),
insert: vi.fn(),
execute: vi.fn(),
}));
vi.mock("@epicure/db", () => ({
db: mockDb,
tierDefinitions: { tier: "tier" },
users: { id: "id", tier: "tier" },
userUsage: {
userId: "user_id",
month: "month",
aiCallsUsed: "ai_calls_used",
recipeCount: "recipe_count",
storageUsedMb: "storage_used_mb",
},
eq: vi.fn((col, val) => ({ col, val, op: "eq" })),
and: vi.fn((...args) => ({ args, op: "and" })),
sql: vi.fn((strings, ...values) => ({ strings, values, op: "sql" })),
}));
// Import after mock
const { checkAndIncrementTierLimit, incrementUsage, refundAiCall } = await import("../tiers");
function makeChain(finalValue: unknown) {
const chain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue(finalValue),
};
return chain;
}
function makeInsertChain() {
const chain = {
values: vi.fn().mockReturnThis(),
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
};
return chain;
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("TierLimitError", () => {
it("has correct name and message", () => {
const err = new TierLimitError("aiCall", "free");
expect(err.name).toBe("TierLimitError");
expect(err.message).toContain("aiCall");
expect(err.message).toContain("free");
expect(err).toBeInstanceOf(Error);
});
});
describe("checkAndIncrementTierLimit", () => {
const tierDef = {
tier: "free",
maxRecipes: 10,
aiCallsPerMonth: 5,
storageMb: 100,
maxPublicRecipes: 3,
};
it("does not throw when the atomic upsert returns a row (under limit)", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.execute.mockResolvedValueOnce([{ aiCallsUsed: 4 }]);
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
});
it("throws TierLimitError when the upsert's WHERE clause excludes the row (limit reached)", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.execute.mockResolvedValueOnce([]);
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).rejects.toThrow(TierLimitError);
});
it("throws TierLimitError for recipe key when limit reached", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.execute.mockResolvedValueOnce([]);
await expect(checkAndIncrementTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError);
});
it("re-reads the current tier from the DB instead of trusting the caller's fallbackTier", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "pro" }]));
mockDb.select.mockReturnValueOnce(makeChain([{ ...tierDef, tier: "pro" }]));
mockDb.execute.mockResolvedValueOnce([{ aiCallsUsed: 4 }]);
// caller still thinks it's "free" (stale session), but DB says "pro"
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
});
it("does not throw when tier definition does not exist", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([]));
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
expect(mockDb.execute).not.toHaveBeenCalled();
});
});
describe("refundAiCall", () => {
it("issues a decrement-with-floor UPDATE for the current month", async () => {
mockDb.execute.mockResolvedValueOnce(undefined);
await refundAiCall("user1");
expect(mockDb.execute).toHaveBeenCalledTimes(1);
});
});
describe("incrementUsage", () => {
it("calls insert with correct aiCall initial values", async () => {
const chain = makeInsertChain();
mockDb.insert.mockReturnValue(chain);
await incrementUsage("user1", "aiCall");
expect(chain.values).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user1",
aiCallsUsed: 1,
recipeCount: 0,
storageUsedMb: 0,
})
);
});
it("calls insert with correct recipe initial values", async () => {
const chain = makeInsertChain();
mockDb.insert.mockReturnValue(chain);
await incrementUsage("user1", "recipe");
expect(chain.values).toHaveBeenCalledWith(
expect.objectContaining({
recipeCount: 1,
aiCallsUsed: 0,
storageUsedMb: 0,
})
);
});
it("respects custom amount", async () => {
const chain = makeInsertChain();
mockDb.insert.mockReturnValue(chain);
await incrementUsage("user1", "storage", 50);
expect(chain.values).toHaveBeenCalledWith(
expect.objectContaining({ storageUsedMb: 50 })
);
});
it("uses onConflictDoUpdate to increment (not overwrite)", async () => {
const chain = makeInsertChain();
mockDb.insert.mockReturnValue(chain);
await incrementUsage("user1", "aiCall");
expect(chain.onConflictDoUpdate).toHaveBeenCalledWith(
expect.objectContaining({
set: expect.objectContaining({ aiCallsUsed: expect.anything() }),
})
);
});
});