Files
HealthLog/src/__tests__/record-write-rate-limit-contract.test.ts
T
Marc-André Bombeck 3ffdfe15a1 fix(api): give the shared write ceiling a code a client can branch on
The eleven single-record writes answered their shared 429 with prose and
nothing else, while the envelope's own description tells a client to
branch on `meta.errorCode`. On `POST /api/medications/[id]/intake` and
`POST /api/cycle/day-logs` a second, narrower bucket can answer 429 as
well, so the presence of the rate-limit headers was the only thing
telling the two refusals apart.

All eleven now carry `record_write.rate_limited`, the catalogue lists
it, the published 429 names it, and the contract test fails if a site
stops emitting it or the sentence stops publishing it.

The same description now states what a client could not predict from
"300 requests per 60 seconds": refused requests count too, because the
check runs before the body is read.
2026-09-10 08:39:09 +02:00

157 lines
6.2 KiB
TypeScript

/**
* The single-record write ceiling is the one the contract describes.
*
* The batch endpoints have been capped at 60 calls a minute since they were
* written; the per-record creates a naive or older client loops over were not
* capped at all, which is backwards — the batch endpoint is the one a
* well-behaved client uses. Eleven of them share one generous per-account
* bucket now, and the operations publish its name and its cap so a client that
* meets the ceiling can read which one it met.
*
* A published number is only worth reading if it cannot drift from the code, so
* this pulls the bucket key and the limit out of `prisma.$queryRaw` — the same
* technique `rate-limit-auth-surface.test.ts` uses — and holds the emitted
* OpenAPI description to what came back. Reading both from the same constant
* would prove nothing: the sentence is written by hand, deliberately, and this
* is what keeps the hand-written half honest.
*
* The route half is structural: every route in the list below calls the shared
* helper. Its limit is that it reads the file rather than the running handler,
* so a call placed after an early return would still count as present.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
RECORD_WRITE_BUCKET_PREFIX,
RECORD_WRITE_LIMIT,
RECORD_WRITE_WINDOW_MS,
checkRecordWriteRateLimit,
} from "@/lib/rate-limit";
import { buildOpenApiDocument } from "@/lib/openapi/registry";
import { recordWriteRateLimitResponse } from "@/lib/openapi/routes/shared";
vi.mock("@/lib/db", () => ({ prisma: { $queryRaw: vi.fn() } }));
import { prisma } from "@/lib/db";
/**
* The single-record writes on the shared bucket, paired with the operation
* that publishes it. Hand-listed on purpose: the point of the list is that
* adding a create without the limiter is a decision somebody has to make, and
* a list derived from the routes that already call it could not notice one.
*/
const ON_THE_SHARED_BUCKET: Array<[route: string, openApiPath: string]> = [
["src/app/api/measurements/route.ts", "/api/measurements"],
["src/app/api/mood-entries/route.ts", "/api/mood-entries"],
["src/app/api/medications/intake/route.ts", "/api/medications/intake"],
[
"src/app/api/medications/[id]/intake/route.ts",
"/api/medications/{id}/intake",
],
["src/app/api/labs/route.ts", "/api/labs"],
[
"src/app/api/custom-metrics/[id]/entries/route.ts",
"/api/custom-metrics/{id}/entries",
],
["src/app/api/cycle/day-logs/route.ts", "/api/cycle/day-logs"],
["src/app/api/vaccinations/route.ts", "/api/vaccinations"],
["src/app/api/allergies/route.ts", "/api/allergies"],
["src/app/api/biomarkers/route.ts", "/api/biomarkers"],
["src/app/api/encounters/route.ts", "/api/encounters"],
];
/** The published sentence, read from the object the route modules spread. */
const published = recordWriteRateLimitResponse["429"].description;
beforeEach(() => {
vi.mocked(prisma.$queryRaw).mockReset();
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ count: 1, reset_at: new Date(Date.now() + 60_000) },
] as never);
});
describe("the shared single-record write bucket", () => {
it("keys on the account under the name the contract publishes", async () => {
await checkRecordWriteRateLimit("account-7");
const call = vi.mocked(prisma.$queryRaw).mock.calls.at(-1)!;
// `$queryRaw\`INSERT … VALUES (${key}, …)\`` lands the key at index 1 and
// the window interval at index 2.
expect(call[1]).toBe("record-write:account-7");
expect(call[2]).toBe(`${RECORD_WRITE_WINDOW_MS} milliseconds`);
expect(published).toContain("`record-write:<accountId>`");
expect(`${RECORD_WRITE_BUCKET_PREFIX}:account-7`).toBe(call[1]);
});
it("refuses at the cap the contract publishes", async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
{ count: RECORD_WRITE_LIMIT, reset_at: new Date(Date.now() + 60_000) },
] as never);
await expect(checkRecordWriteRateLimit("account-7")).resolves.toMatchObject(
{ allowed: true, limit: RECORD_WRITE_LIMIT, remaining: 0 },
);
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
{
count: RECORD_WRITE_LIMIT + 1,
reset_at: new Date(Date.now() + 60_000),
},
] as never);
await expect(checkRecordWriteRateLimit("account-7")).resolves.toMatchObject(
{ allowed: false },
);
const windowSeconds = RECORD_WRITE_WINDOW_MS / 1000;
expect(published).toContain(
`${RECORD_WRITE_LIMIT} requests per ${windowSeconds} seconds`,
);
});
it("says the headers describe that bucket, and declares them", () => {
expect(Object.keys(recordWriteRateLimitResponse["429"].headers)).toEqual([
"Retry-After",
"X-RateLimit-Limit",
"X-RateLimit-Remaining",
"X-RateLimit-Reset",
]);
expect(published).toContain("Retry-After");
});
it("is called by every route that publishes it", () => {
const missing = ON_THE_SHARED_BUCKET.filter(([route]) => {
const src = readFileSync(join(process.cwd(), route), "utf8");
return !src.includes("checkRecordWriteRateLimit(");
}).map(([route]) => route);
expect(missing).toEqual([]);
});
it("names the code it publishes at every site", () => {
// The prose is not the discriminator: two of these routes consult a second
// bucket of their own, and a client that can only read `error` cannot tell
// which one refused it.
const silent = ON_THE_SHARED_BUCKET.filter(([route]) => {
const src = readFileSync(join(process.cwd(), route), "utf8");
return !src.includes('errorCode: "record_write.rate_limited"');
}).map(([route]) => route);
expect(silent).toEqual([]);
expect(published).toContain("record_write.rate_limited");
});
it("is published on every operation that answers it", () => {
const doc = buildOpenApiDocument() as {
paths: Record<
string,
{ post?: { responses?: Record<string, { description?: string }> } }
>;
};
const unpublished = ON_THE_SHARED_BUCKET.filter(
([, path]) =>
doc.paths[path]?.post?.responses?.["429"]?.description !== published,
).map(([, path]) => path);
expect(unpublished).toEqual([]);
});
});