mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
169 of 364 mutating handlers met no rate limit, every PATCH and DELETE on an [id] among them. The shared per-account write bucket (300 a minute, keyed on the acting account) was called by hand in a dozen creates; it is now charged by the identity resolvers for any POST, PUT, PATCH or DELETE, once per request, so a route is metered by resolving its caller. The bulk ingest routes the native client syncs through in bursts (measurements, workouts, intake, mood and cycle bulk, nutrients, the ECG sweep) declare writeBucket: "own" and keep their dedicated ceilings, so a large HealthKit backfill cannot lock the person out of logging a dose. A read-over-POST stays free. write-rate-limit-guard fails on any mutating route that neither resolves its caller through a metering resolver nor calls a limiter, unless it is on a short reasoned exception list; an opt-out must be on its own reviewed list and call its own limiter.
164 lines
6.6 KiB
TypeScript
164 lines
6.6 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. Every authenticated write now shares one generous
|
|
* per-account bucket, charged where the caller is resolved
|
|
* (`src/lib/auth/write-meter.ts`), and the operations listed below 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 resolves its
|
|
* caller through a metering resolver and does not opt out. Its limit is that
|
|
* it reads the file rather than the running handler.
|
|
*/
|
|
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";
|
|
import { RECORD_WRITE_RATE_LIMITED_CODE } from "@/lib/auth/write-meter";
|
|
|
|
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/vaccinations/custom/route.ts", "/api/vaccinations/custom"],
|
|
["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 charged on the path every route that publishes it takes", () => {
|
|
// The bucket is charged by the identity resolvers (`meterAccountWrite`),
|
|
// not by each route, so a listed route meets it by resolving its caller
|
|
// through one of them and not opting out. `write-rate-limit-guard`
|
|
// holds every other mutating route to the same rule.
|
|
const unmetered = ON_THE_SHARED_BUCKET.filter(([route]) => {
|
|
const src = readFileSync(join(process.cwd(), route), "utf8");
|
|
return (
|
|
!/\brequire(?:Record)?Auth\s*\(/.test(src) ||
|
|
/\bwriteBucket\s*:\s*["']own["']/.test(src)
|
|
);
|
|
}).map(([route]) => route);
|
|
expect(unmetered).toEqual([]);
|
|
});
|
|
|
|
it("names the code it publishes", () => {
|
|
// The prose is not the discriminator: several of these routes consult a
|
|
// second bucket of their own, and a client that can only read `error`
|
|
// cannot tell which one refused it.
|
|
expect(RECORD_WRITE_RATE_LIMITED_CODE).toBe("record_write.rate_limited");
|
|
expect(published).toContain(RECORD_WRITE_RATE_LIMITED_CODE);
|
|
});
|
|
|
|
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([]);
|
|
});
|
|
});
|