Files
HealthLog/tests/integration/rate-limit-429-headers.test.ts
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

210 lines
7.8 KiB
TypeScript

/**
* Which 429s carry the rate-limit headers, against a real limiter.
*
* `POST /api/measurements/batch` is the case that matters most: a first-run
* Apple Health import reliably trips its 60-per-minute ceiling, and until now
* the client got a prose sentence and nothing to back off against. The route
* builds its refusal with a bare `apiError(...)`, so this proves the headers
* come from `apiHandler` and the limiter's request-scoped verdict rather than
* from the handler body — which is what makes the other 128 header-less routes
* covered too.
*
* Two more shapes ride along, because the dressing is only honest when the
* numbers describe the ceiling that actually refused:
*
* - `POST /api/withings/sync` consults two buckets — a 5/minute baseline and
* a 1/hour bucket on the expensive full walk. A request the baseline lets
* through and the full bucket refuses must report the full bucket.
* - `POST /api/medications/extract` clears its limiter and can then be
* refused by the daily AI token budget. That 429 belongs to no bucket, so
* it must go out with no rate-limit headers at all rather than one naming
* a bucket that still has room.
*
* Run against a real Postgres because the limiter's window and remainder come
* out of the atomic upsert, and the budget ledger is a real row too; a mocked
* verdict would prove the plumbing and not the numbers.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-rate-limit-headers";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "rate-limit-headers",
email: "rate-limit-headers@example.test",
},
});
const session = await getPrismaClient().session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
});
function batchRequest(): NextRequest {
return new NextRequest("http://localhost/api/measurements/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 81.4,
unit: "kg",
startDate: "2026-07-20T06:00:00.000Z",
endDate: "2026-07-20T06:00:00.000Z",
externalId: `rl-${Math.random().toString(36).slice(2)}`,
},
],
}),
});
}
describe("429 rate-limit headers (real Postgres)", () => {
it("carries Retry-After and the X-RateLimit triple on a route that attaches none", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
// Burn the bucket directly rather than posting sixty batches: the route
// and the pre-charge share one key, so the next call through the handler
// is the one that is refused.
const { checkRateLimit } = await import("@/lib/rate-limit");
const key = `measurements:batch:${TEST_USER_ID}`;
for (let i = 0; i < 60; i += 1) {
await checkRateLimit(key, 60, 60_000);
}
const before = Date.now();
const res = await POST(batchRequest() as never);
expect(res.status).toBe(429);
const retryAfter = res.headers.get("Retry-After");
expect(retryAfter).toMatch(/^\d+$/);
const seconds = Number(retryAfter);
expect(seconds).toBeGreaterThan(0);
expect(seconds).toBeLessThanOrEqual(60);
expect(res.headers.get("X-RateLimit-Limit")).toBe("60");
expect(res.headers.get("X-RateLimit-Remaining")).toBe("0");
const reset = res.headers.get("X-RateLimit-Reset");
expect(reset).toBeTruthy();
const resetAt = Date.parse(reset as string);
expect(Number.isNaN(resetAt)).toBe(false);
expect(resetAt).toBeGreaterThan(before);
// The named delay must actually outlast the window it names.
expect(before + seconds * 1000).toBeGreaterThanOrEqual(resetAt - 1_000);
// The body is unchanged — the headers are additive.
const body = (await res.json()) as { data: null; error: string };
expect(body.data).toBeNull();
expect(typeof body.error).toBe("string");
});
it("leaves a successful response without them", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
const res = await POST(batchRequest() as never);
expect(res.status).toBe(200);
expect(res.headers.get("Retry-After")).toBeNull();
});
it("reports the bucket that refused when a route consults two", async () => {
const { POST } = await import("@/app/api/withings/sync/route");
// Burn only the full-sync bucket. The route's baseline 5/60s bucket is
// untouched, so it passes on the way in and the refusal comes from the
// 1/hour one — the numbers on the wire have to be that one's.
const { checkRateLimit } = await import("@/lib/rate-limit");
await checkRateLimit(`withings-sync-full:${TEST_USER_ID}`, 1, 60 * 60_000);
const res = await POST(
new NextRequest("http://localhost/api/withings/sync", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fullSync: true }),
}) as never,
);
expect(res.status).toBe(429);
expect(res.headers.get("X-RateLimit-Limit")).toBe("1");
expect(res.headers.get("X-RateLimit-Remaining")).toBe("0");
// The baseline bucket's window is 60 seconds; the hour-long one cannot be
// mistaken for it.
expect(Number(res.headers.get("Retry-After"))).toBeGreaterThan(60);
});
it("leaves a 429 the limiter did not produce undressed", async () => {
// The daily AI budget, not a bucket. The route's own 10-per-5-minutes
// limiter passes on the way in; dressing this refusal with that verdict
// would tell the client nine calls are still free and to retry in five
// minutes, when the budget does not move until UTC midnight.
await getPrismaClient().user.update({
where: { id: TEST_USER_ID },
// A self-hosted local endpoint resolves as the chain's primary, which
// keeps the request on the user-plan ceiling and off the consent gate.
data: { aiBaseUrl: "http://127.0.0.1:11434/v1" },
});
const { buildDateKey, USER_PLAN_CAP } =
await import("@/lib/ai/coach/budget");
await getPrismaClient().coachUsage.create({
data: {
userId: TEST_USER_ID,
dateKey: await buildDateKey(TEST_USER_ID),
totalTokens: USER_PLAN_CAP,
messageCount: 1,
},
});
const { POST } = await import("@/app/api/medications/extract/route");
const res = await POST(
new NextRequest("http://localhost/api/medications/extract", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: "Mounjaro 5mg weekly Wednesday" }),
}) as never,
);
expect(res.status).toBe(429);
for (const name of [
"Retry-After",
"X-RateLimit-Limit",
"X-RateLimit-Remaining",
"X-RateLimit-Reset",
]) {
expect(res.headers.get(name)).toBeNull();
}
});
});