mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
239 lines
7.7 KiB
TypeScript
239 lines
7.7 KiB
TypeScript
/**
|
|
* The shared write ceiling, against Postgres, on routes that never call it.
|
|
*
|
|
* The bucket is charged where the caller is resolved, so the routes below
|
|
* carry no limiter of their own; a PATCH and a DELETE on an `[id]` met none
|
|
* before. What has to hold: a write is counted, a read is not, a spent bucket
|
|
* refuses with the published code and the headers that describe it and
|
|
* writes nothing, and a bulk route on its own bucket keeps working while the
|
|
* shared one is spent. A POST that reads (`readOverPost`) is not counted on
|
|
* either resolver, and a bucket that cannot be read lets the write through
|
|
* while the limiter itself still fails for its other callers.
|
|
*/
|
|
import { NextRequest } from "next/server";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import {
|
|
RECORD_WRITE_BUCKET_PREFIX,
|
|
RECORD_WRITE_LIMIT,
|
|
} from "@/lib/rate-limit";
|
|
|
|
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);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
const USER_ID = "user-write-meter";
|
|
const BUCKET = `${RECORD_WRITE_BUCKET_PREFIX}:${USER_ID}`;
|
|
|
|
async function bucketCount(): Promise<number> {
|
|
const row = await getPrismaClient().rateLimit.findUnique({
|
|
where: { key: BUCKET },
|
|
});
|
|
return row?.count ?? 0;
|
|
}
|
|
|
|
async function spendBucket(): Promise<void> {
|
|
await getPrismaClient().rateLimit.upsert({
|
|
where: { key: BUCKET },
|
|
create: {
|
|
key: BUCKET,
|
|
count: RECORD_WRITE_LIMIT,
|
|
resetAt: new Date(Date.now() + 60_000),
|
|
},
|
|
update: {
|
|
count: RECORD_WRITE_LIMIT,
|
|
resetAt: new Date(Date.now() + 60_000),
|
|
},
|
|
});
|
|
}
|
|
|
|
let allergyId: string;
|
|
|
|
beforeEach(async () => {
|
|
const prisma = getPrismaClient();
|
|
await truncateAllTables(prisma);
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
await prisma.user.create({
|
|
data: {
|
|
id: USER_ID,
|
|
username: "write-meter",
|
|
email: "write-meter@example.test",
|
|
timezone: "UTC",
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
const allergy = await prisma.allergy.create({
|
|
data: {
|
|
userId: USER_ID,
|
|
substance: "Pollen",
|
|
category: "ENVIRONMENT",
|
|
type: "ALLERGY",
|
|
status: "ACTIVE",
|
|
},
|
|
});
|
|
allergyId = allergy.id;
|
|
});
|
|
|
|
function patchAllergy(body: unknown) {
|
|
return import("@/app/api/allergies/[id]/route").then(({ PATCH }) =>
|
|
PATCH(
|
|
new NextRequest(`http://localhost/api/allergies/${allergyId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(body),
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
{ params: Promise.resolve({ id: allergyId }) },
|
|
),
|
|
);
|
|
}
|
|
|
|
describe("the shared write bucket on routes without a limiter of their own", () => {
|
|
it("counts a PATCH and a DELETE, and not a GET", async () => {
|
|
const { GET, DELETE } = await import("@/app/api/allergies/[id]/route");
|
|
|
|
const read = await GET(
|
|
new NextRequest(`http://localhost/api/allergies/${allergyId}`),
|
|
{ params: Promise.resolve({ id: allergyId }) },
|
|
);
|
|
expect(read.status).toBe(200);
|
|
expect(await bucketCount()).toBe(0);
|
|
|
|
expect((await patchAllergy({ substance: "Birch pollen" })).status).toBe(
|
|
200,
|
|
);
|
|
expect(await bucketCount()).toBe(1);
|
|
|
|
const gone = await DELETE(
|
|
new NextRequest(`http://localhost/api/allergies/${allergyId}`, {
|
|
method: "DELETE",
|
|
}),
|
|
{ params: Promise.resolve({ id: allergyId }) },
|
|
);
|
|
expect(gone.status).toBe(200);
|
|
expect(await bucketCount()).toBe(2);
|
|
});
|
|
|
|
it("refuses a write once the bucket is spent, says why, and writes nothing", async () => {
|
|
await spendBucket();
|
|
|
|
const res = await patchAllergy({ substance: "Birch pollen" });
|
|
expect(res.status).toBe(429);
|
|
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
|
expect(body.meta?.errorCode).toBe("record_write.rate_limited");
|
|
expect(Number(res.headers.get("Retry-After"))).toBeGreaterThan(0);
|
|
expect(res.headers.get("X-RateLimit-Limit")).toBe(
|
|
String(RECORD_WRITE_LIMIT),
|
|
);
|
|
|
|
const row = await getPrismaClient().allergy.findUniqueOrThrow({
|
|
where: { id: allergyId },
|
|
});
|
|
expect(row.substance).toBe("Pollen");
|
|
});
|
|
|
|
it("keeps a bulk route on its own bucket working while the shared one is spent", async () => {
|
|
await spendBucket();
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
const res = await POST(
|
|
new NextRequest("http://localhost/api/measurements/batch", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
entries: [
|
|
{
|
|
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
|
value: 72.4,
|
|
unit: "kg",
|
|
startDate: new Date(Date.now() - 3_600_000).toISOString(),
|
|
endDate: new Date(Date.now() - 3_600_000).toISOString(),
|
|
externalId: "uuid-write-meter-weight-1",
|
|
},
|
|
],
|
|
}),
|
|
}),
|
|
);
|
|
expect(res.status, await res.clone().text()).toBeLessThan(300);
|
|
expect(
|
|
await getPrismaClient().measurement.count({ where: { userId: USER_ID } }),
|
|
).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("what the shared bucket does not count", () => {
|
|
async function postThrough(options?: { readOverPost: true }) {
|
|
const { apiHandler, requireAuth } = await import("@/lib/api-handler");
|
|
const { apiSuccess } = await import("@/lib/api-response");
|
|
const POST = apiHandler(async (_request: NextRequest) => {
|
|
const { user } = await requireAuth(undefined, options);
|
|
return apiSuccess({ id: user.id });
|
|
});
|
|
return POST(
|
|
new NextRequest("http://localhost/api/probe", {
|
|
method: "POST",
|
|
body: "{}",
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
}
|
|
|
|
it("leaves a read-over-POST through requireAuth uncounted", async () => {
|
|
expect((await postThrough({ readOverPost: true })).status).toBe(200);
|
|
expect(await bucketCount()).toBe(0);
|
|
});
|
|
|
|
it("counter-check: the same POST without the declaration is counted", async () => {
|
|
expect((await postThrough()).status).toBe(200);
|
|
expect(await bucketCount()).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("a bucket that cannot be read", () => {
|
|
it("lets the write through instead of answering 500, and the limiter still fails for its own callers", async () => {
|
|
const prisma = getPrismaClient();
|
|
const { checkRateLimit } = await import("@/lib/rate-limit");
|
|
await prisma.$executeRawUnsafe(
|
|
`ALTER TABLE "rate_limits" RENAME TO "rate_limits_away"`,
|
|
);
|
|
try {
|
|
const res = await patchAllergy({ substance: "Birch pollen" });
|
|
expect(res.status, await res.clone().text()).toBe(200);
|
|
const row = await prisma.allergy.findUniqueOrThrow({
|
|
where: { id: allergyId },
|
|
});
|
|
expect(row.substance).toBe("Birch pollen");
|
|
|
|
// A login or sign-up limit calls the limiter directly: no fail-open.
|
|
await expect(
|
|
checkRateLimit("auth:login:probe", 5, 60_000),
|
|
).rejects.toThrow();
|
|
} finally {
|
|
await prisma.$executeRawUnsafe(
|
|
`ALTER TABLE "rate_limits_away" RENAME TO "rate_limits"`,
|
|
);
|
|
}
|
|
});
|
|
});
|