Update features and dependencies

This commit is contained in:
Arnaud
2026-07-01 11:10:37 +02:00
parent 9d9dfb46c6
commit 8b57a3fd87
107 changed files with 14654 additions and 458 deletions
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { encrypt, decrypt } from "../encrypt";
describe("encrypt / decrypt", () => {
it("round-trips a short string", () => {
const plain = "hello world";
expect(decrypt(encrypt(plain))).toBe(plain);
});
it("round-trips an empty string", () => {
expect(decrypt(encrypt(""))).toBe("");
});
it("round-trips a unicode string", () => {
const plain = "café ☕ résumé";
expect(decrypt(encrypt(plain))).toBe(plain);
});
it("round-trips a long string (API key-sized)", () => {
const plain = "sk-proj-" + "a".repeat(128);
expect(decrypt(encrypt(plain))).toBe(plain);
});
it("produces different ciphertexts for same input (random IV)", () => {
const plain = "same input";
const c1 = encrypt(plain);
const c2 = encrypt(plain);
expect(c1).not.toBe(c2);
// but both decrypt to same value
expect(decrypt(c1)).toBe(plain);
expect(decrypt(c2)).toBe(plain);
});
it("ciphertext format is iv:authTag:encrypted (3 hex segments)", () => {
const ct = encrypt("test");
const parts = ct.split(":");
expect(parts).toHaveLength(3);
parts.forEach((p) => expect(p).toMatch(/^[0-9a-f]+$/));
});
it("throws on malformed ciphertext (wrong segments)", () => {
expect(() => decrypt("notvalid")).toThrow("Invalid ciphertext format");
expect(() => decrypt("a:b")).toThrow("Invalid ciphertext format");
});
it("throws on tampered ciphertext (auth tag mismatch)", () => {
const ct = encrypt("sensitive");
const parts = ct.split(":");
// flip a byte in the encrypted payload
const tampered = parts[0] + ":" + parts[1] + ":" + "00" + parts[2]!.slice(2);
expect(() => decrypt(tampered)).toThrow();
});
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, it, expect } from "vitest";
import { formatQuantity, scaleQuantity } from "../fractions";
describe("formatQuantity", () => {
it("returns '0' for 0", () => {
expect(formatQuantity(0)).toBe("0");
});
it("returns whole numbers as strings", () => {
expect(formatQuantity(1)).toBe("1");
expect(formatQuantity(4)).toBe("4");
});
it("rounds near-integer values up", () => {
expect(formatQuantity(0.97)).toBe("1");
expect(formatQuantity(2.96)).toBe("3");
});
it("formats known fractions with unicode symbols", () => {
expect(formatQuantity(0.5)).toBe("½");
expect(formatQuantity(0.25)).toBe("¼");
expect(formatQuantity(0.75)).toBe("¾");
expect(formatQuantity(0.333)).toBe("⅓");
expect(formatQuantity(0.667)).toBe("⅔");
expect(formatQuantity(0.125)).toBe("⅛");
});
it("formats mixed numbers", () => {
expect(formatQuantity(1.5)).toBe("1 ½");
expect(formatQuantity(2.25)).toBe("2 ¼");
expect(formatQuantity(3.75)).toBe("3 ¾");
});
it("falls back to one decimal for values that don't match a fraction", () => {
// 0.4375 is midpoint between ⅜ (0.375) and ½ (0.5), diff=0.0625 from both — exceeds 0.06 threshold
expect(formatQuantity(0.4375)).toMatch(/^0\.\d$/);
});
});
describe("scaleQuantity", () => {
it("returns empty string for null/empty base quantity", () => {
expect(scaleQuantity(null, 4, 2)).toBe("");
expect(scaleQuantity("", 4, 2)).toBe("");
});
it("returns base quantity unchanged for non-numeric strings", () => {
expect(scaleQuantity("to taste", 4, 2)).toBe("to taste");
});
it("scales down by half", () => {
expect(scaleQuantity("4", 4, 2)).toBe("2");
});
it("scales up by double", () => {
expect(scaleQuantity("2", 2, 4)).toBe("4");
});
it("handles base=0 gracefully (no division by zero)", () => {
expect(scaleQuantity("3", 0, 4)).toBe("3");
});
it("produces fraction symbols when result is fractional", () => {
expect(scaleQuantity("1", 2, 1)).toBe("½");
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const pipelineChain = vi.hoisted(() => ({
zadd: vi.fn().mockReturnThis(),
zremrangebyscore: vi.fn().mockReturnThis(),
zcard: vi.fn().mockReturnThis(),
expire: vi.fn().mockReturnThis(),
exec: vi.fn(),
}));
vi.mock("../redis", () => ({
getRedis: vi.fn(() => ({ pipeline: vi.fn(() => pipelineChain) })),
}));
vi.mock("next/server", () => ({
NextResponse: {
json: vi.fn((body: unknown, init?: { status?: number }) => ({ body, init, isNextResponse: true })),
},
}));
import { NextResponse } from "next/server";
const { applyRateLimit } = await import("../rate-limit");
beforeEach(() => {
vi.clearAllMocks();
});
describe("applyRateLimit", () => {
it("returns null when under the limit", async () => {
pipelineChain.exec.mockResolvedValue([null, null, [null, 3], null]);
const result = await applyRateLimit("test-key", 10, 60);
expect(result).toBeNull();
});
it("returns 429 response when over the limit", async () => {
pipelineChain.exec.mockResolvedValue([null, null, [null, 11], null]);
const result = await applyRateLimit("test-key", 10, 60);
expect(result).not.toBeNull();
expect(NextResponse.json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.any(String) }),
expect.objectContaining({ status: 429 })
);
});
it("returns null when exactly at limit (count === limit is allowed)", async () => {
pipelineChain.exec.mockResolvedValue([null, null, [null, 10], null]);
const result = await applyRateLimit("test-key", 10, 60);
expect(result).toBeNull();
});
it("calls pipeline with zadd, zremrangebyscore, zcard, expire", async () => {
pipelineChain.exec.mockResolvedValue([null, null, [null, 1], null]);
await applyRateLimit("my-key", 5, 30);
expect(pipelineChain.zadd).toHaveBeenCalled();
expect(pipelineChain.zremrangebyscore).toHaveBeenCalled();
expect(pipelineChain.zcard).toHaveBeenCalled();
expect(pipelineChain.expire).toHaveBeenCalled();
});
});
@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const mockFindFirst = vi.fn();
const mockFindMany = vi.fn();
const mockInsert = vi.fn();
const mockDelete = vi.fn();
vi.mock("@epicure/db", () => ({
db: {
query: {
siteSettings: {
findFirst: mockFindFirst,
findMany: mockFindMany,
},
},
insert: mockInsert,
delete: mockDelete,
},
siteSettings: { key: "key", value: "value", isSecret: "is_secret", updatedAt: "updated_at", updatedById: "updated_by_id" },
eq: vi.fn((a, b) => ({ a, b })),
}));
const insertChain = {
values: vi.fn().mockReturnThis(),
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
};
const deleteChain = {
where: vi.fn().mockResolvedValue(undefined),
};
const { getSiteSetting, setSiteSetting, isSecretKey } = await import("../site-settings");
beforeEach(() => {
vi.clearAllMocks();
mockInsert.mockReturnValue(insertChain);
mockDelete.mockReturnValue(deleteChain);
});
afterEach(() => {
delete process.env["OPENAI_API_KEY"];
delete process.env["ANTHROPIC_API_KEY"];
});
describe("isSecretKey", () => {
it("marks API keys as secret", () => {
expect(isSecretKey("OPENAI_API_KEY")).toBe(true);
expect(isSecretKey("ANTHROPIC_API_KEY")).toBe(true);
expect(isSecretKey("VAPID_PRIVATE_KEY")).toBe(true);
});
it("marks non-secret settings as public", () => {
expect(isSecretKey("OPENROUTER_DEFAULT_MODEL")).toBe(false);
expect(isSecretKey("OLLAMA_BASE_URL")).toBe(false);
});
});
describe("getSiteSetting", () => {
it("returns DB value when present (plain)", async () => {
mockFindFirst.mockResolvedValue({ value: "gpt-4o", isSecret: false });
const val = await getSiteSetting("OPENROUTER_DEFAULT_MODEL");
expect(val).toBe("gpt-4o");
});
it("decrypts DB value when secret", async () => {
// Pre-encrypt something using the real encrypt function
const { encrypt } = await import("../encrypt");
const ciphertext = encrypt("sk-real-key");
mockFindFirst.mockResolvedValue({ value: ciphertext, isSecret: true });
const val = await getSiteSetting("OPENAI_API_KEY");
expect(val).toBe("sk-real-key");
});
it("falls back to env var when no DB row", async () => {
mockFindFirst.mockResolvedValue(null);
process.env["OPENAI_API_KEY"] = "sk-from-env";
const val = await getSiteSetting("OPENAI_API_KEY");
expect(val).toBe("sk-from-env");
});
it("returns null when neither DB nor env is set", async () => {
mockFindFirst.mockResolvedValue(null);
const val = await getSiteSetting("OPENAI_API_KEY");
expect(val).toBeNull();
});
it("falls back to env when DB throws", async () => {
mockFindFirst.mockRejectedValue(new Error("DB error"));
process.env["ANTHROPIC_API_KEY"] = "sk-anthropic-env";
const val = await getSiteSetting("ANTHROPIC_API_KEY");
expect(val).toBe("sk-anthropic-env");
});
});
describe("setSiteSetting", () => {
it("deletes the row when value is null", async () => {
await setSiteSetting("OPENAI_API_KEY", null, "admin-user");
expect(mockDelete).toHaveBeenCalled();
expect(mockInsert).not.toHaveBeenCalled();
});
it("deletes the row when value is empty string", async () => {
await setSiteSetting("OPENAI_API_KEY", "", "admin-user");
expect(mockDelete).toHaveBeenCalled();
});
it("inserts encrypted value for secret key", async () => {
await setSiteSetting("OPENAI_API_KEY", "sk-test", "admin-user");
expect(mockInsert).toHaveBeenCalled();
const inserted = insertChain.values.mock.calls[0]![0] as Record<string, unknown>;
expect(inserted.isSecret).toBe(true);
// stored value should not be plain text
expect(inserted.value).not.toBe("sk-test");
expect(typeof inserted.value).toBe("string");
});
it("inserts plain value for non-secret key", async () => {
await setSiteSetting("OPENROUTER_DEFAULT_MODEL", "gpt-4o", "admin-user");
const inserted = insertChain.values.mock.calls[0]![0] as Record<string, unknown>;
expect(inserted.isSecret).toBe(false);
expect(inserted.value).toBe("gpt-4o");
});
});
+168
View File
@@ -0,0 +1,168 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TierLimitError } from "../tiers";
const mockDb = vi.hoisted(() => ({
select: vi.fn(),
insert: vi.fn(),
}));
vi.mock("@epicure/db", () => ({
db: mockDb,
tierDefinitions: { 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 { checkTierLimit, incrementUsage } = 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("checkTierLimit", () => {
const tierDef = {
tier: "free",
maxRecipes: 10,
aiCallsPerMonth: 5,
storageMb: 100,
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 }]));
await expect(checkTierLimit("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 }]));
await expect(checkTierLimit("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 }]));
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();
});
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();
});
});
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() }),
})
);
});
});
+156
View File
@@ -0,0 +1,156 @@
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, 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, 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);
});
});