fix(api): put Retry-After and the rate-limit triple on every 429

A refused client had nothing to back off against. `rateLimitHeaders()`
emitted `X-RateLimit-Remaining` and an ISO `X-RateLimit-Reset`, no
`Retry-After` and no cap, and only about 70 of the 199 route files that
consult a limiter attached even those two. The rest answered with a bare
prose envelope, including every endpoint an offline replay queue hits:
the four batch ingests, `/api/sync/changes` and `/api/auth/refresh`. A
first-run Apple Health import trips the 60-per-minute batch ceiling
reliably, and the only strategy left to the client was a blind backoff.

Threading headers through 129 handlers is 129 edits the next new route
forgets, so the verdict is carried instead: `checkRateLimit` records it
in a request-scoped cell and `apiHandler` dresses any 429 that leaves
without headers. A route that already built its own is left alone —
`Retry-After` is the presence test, and it comes from the same builder.

`Retry-After` is whole seconds rounding UP (RFC 9110 §10.2.3), floored
at zero. `X-RateLimit-Limit` is new and needs the cap, so the limiter
result carries it. `X-RateLimit-Reset` keeps its ISO form: it is the
wrong convention for the name, but it is on the wire today and changing
it is a decision of its own.

The cell lives in its own leaf module because 177 test files replace
`@/lib/rate-limit` wholesale with a mock factory, and `apiHandler` must
not depend on anything those factories stand in for.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 03:55:46 +02:00
1 parent 1d372df3a0
commit 08100b6207
56 files changed
+681 -161

No files matched your search

@@ -32,6 +32,7 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -192,6 +193,7 @@ describe("PATCH /api/auth/me/diabetes", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
@@ -24,6 +24,7 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -247,6 +248,7 @@ describe("PATCH /api/auth/me/disable-coach", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
@@ -36,6 +36,7 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -197,6 +198,7 @@ describe("PATCH /api/auth/me/glucose-unit", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
@@ -35,6 +35,7 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -397,6 +398,7 @@ describe("PATCH — envelope and transport", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
@@ -35,6 +35,7 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -300,6 +301,7 @@ describe("PATCH /api/auth/me/modules", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
@@ -26,6 +26,7 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -587,6 +588,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
@@ -68,6 +68,7 @@ beforeEach(() => {
vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 60_000,
ip: "1.2.3.4",
@@ -137,6 +138,7 @@ describe("POST /api/c/[token]/unlock", () => {
});
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
ip: "1.2.3.4",
@@ -55,7 +55,12 @@ import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { checkConsentRateLimit } from "@/lib/rate-limit";
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
const RL_OK = {
allowed: true,
limit: 20,
remaining: 19,
resetAt: Date.now() + 60_000,
};
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
mockImplementation: (impl: (fn: TxFn) => unknown) => void;
@@ -109,6 +114,7 @@ describe("POST /api/consent/ai", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkConsentRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -54,7 +54,12 @@ import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { checkConsentRateLimit } from "@/lib/rate-limit";
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
const RL_OK = {
allowed: true,
limit: 20,
remaining: 19,
resetAt: Date.now() + 60_000,
};
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
mockImplementation: (impl: (fn: TxFn) => unknown) => void;
@@ -99,6 +104,7 @@ describe("GET /api/consent/ai/latest", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkConsentRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -197,6 +203,7 @@ describe("DELETE /api/consent/ai/latest", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkConsentRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -43,7 +43,12 @@ const SESSION_OK = {
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
const RL_OK = {
allowed: true,
limit: 20,
remaining: 19,
resetAt: Date.now() + 60_000,
};
/** The mount heal posts no body and no content-type. */
function mkHeal(): Request {
@@ -79,6 +84,7 @@ describe("POST /api/consent/ai/web", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkConsentRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -24,6 +24,7 @@ vi.mock("@/lib/modules/gate", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({
allowed: true,
limit: 60,
remaining: 39,
resetAt: Date.now() + 60_000,
})),
@@ -159,6 +160,7 @@ describe("POST /api/daily/digest/dismiss", () => {
const { checkRateLimit } = await import("@/lib/rate-limit");
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -168,6 +168,7 @@ beforeEach(() => {
vi.mocked(prisma.extractedFact.count).mockResolvedValue(0 as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 5,
resetAt: Date.now() + 60 * 60 * 1000,
});
@@ -304,6 +305,7 @@ describe("POST /api/documents/inbound/[id]/extract — stored mode", () => {
it("consumes the shared document-AI bucket honestly", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30 * 60_000,
});
@@ -128,6 +128,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 5,
resetAt: Date.now() + 60 * 60 * 1000,
});
@@ -222,6 +223,7 @@ describe("POST /api/documents/inbound/[id]/index — bucket honesty", () => {
const resetAt = Date.now() + 47 * 60_000;
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt,
});
@@ -309,6 +311,7 @@ describe("POST /api/documents/inbound/[id]/index — staging continuation", () =
it("does not reach staging when the index itself was refused", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -71,6 +71,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3_600_000,
});
@@ -190,6 +190,7 @@ describe("GET /api/export/measurements", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -229,6 +230,7 @@ describe("GET /api/export/measurements", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -258,6 +260,7 @@ describe("GET /api/export/measurements", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -289,6 +292,7 @@ describe("GET /api/export/measurements", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -327,6 +331,7 @@ describe("GET /api/export/medications", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -370,6 +375,7 @@ describe("GET /api/export/medications", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -390,6 +396,7 @@ describe("GET /api/export/medications", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -425,6 +432,7 @@ describe("GET /api/export/medications", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -452,6 +460,7 @@ describe("GET /api/export/mood", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -492,6 +501,7 @@ describe("GET /api/export/full-backup", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -562,6 +572,7 @@ describe("GET /api/export/full-backup", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -130,6 +130,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3_600_000,
});
@@ -117,6 +117,7 @@ beforeEach(() => {
// v1.15.20 — default to an allowing analytics-read budget.
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 119,
resetAt: Date.now() + 60_000,
});
@@ -103,6 +103,7 @@ beforeEach(() => {
// v1.15.20 — default to an allowing analytics-read budget.
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 119,
resetAt: Date.now() + 60_000,
});
@@ -229,6 +230,7 @@ describe("GET /api/insights/correlations", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -188,6 +188,7 @@ beforeEach(() => {
selfContextText.mockResolvedValue(null);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -366,6 +367,7 @@ describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => {
expect(limit).toBe(10);
return {
allowed: callCount <= 10,
limit: 10,
remaining: Math.max(0, 10 - callCount),
resetAt: Date.now() + 3600_000,
};
@@ -392,6 +394,7 @@ describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => {
expect(limit).toBe(3);
return {
allowed: callCount <= 3,
limit: 3,
remaining: Math.max(0, 3 - callCount),
resetAt: Date.now() + 3600_000,
};
@@ -673,6 +676,7 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)",
mockBriefinglessFreshCache();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 3600_000,
});
@@ -691,6 +695,7 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)",
mockBriefinglessFreshCache();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 3600_000,
});
@@ -50,6 +50,7 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
});
@@ -114,6 +115,7 @@ describe("POST /api/internal/web-vitals", () => {
it("returns 429 when the rate-limit fires", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -37,12 +37,14 @@ vi.mock("@/lib/auth/issue-token", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(async () => ({
allowed: true,
limit: 60,
remaining: 100,
resetAt: Date.now() + 60_000,
ip: "1.2.3.4",
})),
checkRateLimit: vi.fn(async () => ({
allowed: true,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
})),
@@ -103,6 +105,7 @@ beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -215,6 +218,7 @@ describe("authorization_code grant", () => {
it("rejects a replayed code (single-use jti already claimed)", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
+2 -10
View File
@@ -302,11 +302,7 @@ export async function GET(request: NextRequest): Promise<Response> {
{ error: "temporarily_unavailable" },
{
status: 429,
headers: rateLimitHeaders({
allowed: false,
remaining: rl.remaining,
resetAt: rl.resetAt,
}),
headers: rateLimitHeaders(rl),
},
);
}
@@ -407,11 +403,7 @@ export async function POST(request: NextRequest): Promise<Response> {
{ error: "temporarily_unavailable" },
{
status: 429,
headers: rateLimitHeaders({
allowed: false,
remaining: rl.remaining,
resetAt: rl.resetAt,
}),
headers: rateLimitHeaders(rl),
},
);
}
+1 -5
View File
@@ -80,11 +80,7 @@ export async function POST(request: NextRequest): Promise<Response> {
{ error: "temporarily_unavailable" },
{
status: 429,
headers: rateLimitHeaders({
allowed: false,
remaining: rl.remaining,
resetAt: rl.resetAt,
}),
headers: rateLimitHeaders(rl),
},
);
}
+1 -5
View File
@@ -222,11 +222,7 @@ export async function POST(request: NextRequest): Promise<Response> {
{ error: "temporarily_unavailable" },
{
status: 429,
headers: rateLimitHeaders({
allowed: false,
remaining: rl.remaining,
resetAt: rl.resetAt,
}),
headers: rateLimitHeaders(rl),
},
);
}
@@ -189,6 +189,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
@@ -186,6 +186,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
@@ -159,6 +159,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(session as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
});
@@ -114,6 +114,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
@@ -194,6 +194,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
@@ -271,6 +272,7 @@ describe("POST /api/measurements/batch — syncTrigger diagnostic (iOS #66)", ()
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
@@ -148,6 +148,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
@@ -151,6 +151,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
@@ -46,6 +46,7 @@ vi.mock("@/lib/cache/server-cache", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -139,6 +140,7 @@ beforeEach(() => {
);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
});
@@ -416,6 +418,7 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => {
it("returns 429 when the per-user rate limit is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -30,6 +30,7 @@ vi.mock("@/lib/medications/route-guards", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
@@ -92,6 +93,7 @@ beforeEach(() => {
vi.mocked(assertMedicationOwnership).mockResolvedValue(null);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
});
@@ -134,6 +136,7 @@ describe("GET /api/medications/[id]/dose-history", () => {
it("returns 429 when the per-user cap is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -38,6 +38,7 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
@@ -85,6 +86,7 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
});
@@ -251,6 +253,7 @@ describe("POST /api/medications/[id]/glp1", () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -39,6 +39,7 @@ vi.mock("@/lib/medications/route-guards", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
@@ -124,6 +125,7 @@ beforeEach(() => {
vi.mocked(assertMedicationOwnership).mockResolvedValue(null);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
});
@@ -239,6 +241,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete — F-1 H-6 rate limit",
it("returns 429 when the per-user cap is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -97,6 +97,7 @@ beforeEach(() => {
vi.mocked(auditLog).mockResolvedValue(undefined as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
@@ -407,6 +408,7 @@ describe("POST /api/medications/[id]/intake/import — future bound and limiter"
it("refuses with 429 once the import limiter trips", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -30,6 +30,7 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
@@ -87,6 +88,7 @@ beforeEach(() => {
);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
});
@@ -253,6 +255,7 @@ describe("POST /api/medications/[id]/inventory", () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -31,6 +31,7 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
@@ -84,6 +85,7 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
});
@@ -298,6 +300,7 @@ describe("POST /api/medications/[id]/side-effects", () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -30,6 +30,7 @@ vi.mock("@/lib/cache/server-cache", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
@@ -106,6 +107,7 @@ beforeEach(() => {
);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
});
@@ -178,6 +180,7 @@ describe("GET /api/medications/compliance", () => {
it("returns 429 when the per-user rate limit is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -110,6 +110,7 @@ beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 60_000,
});
@@ -152,6 +153,7 @@ describe("POST /api/medications/extract — rate limit", () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -96,6 +96,7 @@ beforeEach(() => {
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now(),
});
@@ -101,6 +101,7 @@ beforeEach(() => {
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 59,
resetAt: Date.now() + 60_000,
});
@@ -137,6 +138,7 @@ describe("POST /api/nutrients/water", () => {
it("returns 429 when the rate limit is exceeded", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -36,6 +36,7 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
@@ -79,6 +80,7 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
});
@@ -243,6 +245,7 @@ describe("POST /api/onboarding/step — rate limit", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
@@ -24,6 +24,7 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3_600_000,
}),
@@ -89,6 +90,7 @@ beforeEach(() => {
// Default rate-limit pass-through; per-test overrides drop in below.
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3_600_000,
});
@@ -210,6 +212,7 @@ describe("POST /api/user/avatar", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30_000,
});
@@ -145,6 +145,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
@@ -218,6 +219,7 @@ describe("POST /api/workouts/batch — auth + size + rate-limit", () => {
it("returns 429 when the rate-limit gate blocks the call", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});