fix(import): refuse unknown query parameters on the CSV imports

The lab, medication and measurement CSV routes ignored parameters they
did not know, so a misspelt dryrun=1 fell back to a real import and
wrote the file the caller meant only to check. Their query schemas are
strict now: an unknown parameter answers 422 before anything is read
or a rate bucket is spent.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 23:56:16 +02:00
1 parent f8c17ee9bb
commit 896ba233da
6 files changed
+75 -30

No files matched your search

@@ -100,6 +100,16 @@ describe("record CSV import routes", () => {
expect(mocks.importLabsCsv).not.toHaveBeenCalled();
});
it("refuses an unknown parameter, so a misspelt dry run never writes", async () => {
for (const typo of ["dryrun=1", "dry_run=1", "dryRun=1&preview=1"]) {
expect((await post(labsPost, `${LABS}&${typo}`)).status).toBe(422);
expect((await post(medsPost, `${MEDS}&${typo}`)).status).toBe(422);
}
expect(mocks.importLabsCsv).not.toHaveBeenCalled();
expect(mocks.importMedicationsCsv).not.toHaveBeenCalled();
expect(mocks.checkRateLimit).not.toHaveBeenCalled();
});
it("spends the preview bucket on a dry run and the import bucket otherwise", async () => {
await post(labsPost, `${LABS}&dryRun=1`);
expect(mocks.checkRateLimit).toHaveBeenLastCalledWith(
@@ -164,6 +164,23 @@ const mMeasurement = () =>
updateMany: ReturnType<typeof vi.fn>;
};
describe("POST /api/import/csv — strict query", () => {
it("refuses a misspelt dry-run flag instead of importing", async () => {
for (const query of ["dryrun=1", "dry_run=1", "dryRun=yes"]) {
const res = await POST(
new NextRequest(`http://localhost/api/import/csv?${query}`, {
method: "POST",
body: [HEADER, "WEIGHT,80,kg,2026-05-01T08:00:00Z,,,"].join("\n"),
headers: { "content-type": "text/csv" },
}),
);
expect(res.status).toBe(422);
}
expect(mMeasurement().createManyAndReturn).not.toHaveBeenCalled();
expect(checkRateLimit).not.toHaveBeenCalled();
});
});
describe("POST /api/import/csv — fatal header error", () => {
it("returns 422 when a required column is missing", async () => {
const res = await POST(csvRequest("type,value,unit\nWEIGHT,80,kg"));
+9 -3
View File
@@ -7,7 +7,9 @@ import {
apiError,
getClientIp,
readBodyText,
returnAllZodIssues,
} from "@/lib/api-response";
import { csvImportQuerySchema } from "@/lib/validations/record-import";
import { encryptNote, readNote } from "@/lib/crypto/note-cipher";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
@@ -109,9 +111,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "import.csv.upload" } });
const dryRun =
new URL(request.url).searchParams.get("dryRun") === "1" ||
new URL(request.url).searchParams.get("dryRun") === "true";
// Strict: an unknown parameter is refused, so a misspelt `dryrun=1` can
// never fall through to a real import.
const queryParse = csvImportQuerySchema.safeParse(
Object.fromEntries(new URL(request.url).searchParams),
);
if (!queryParse.success) return returnAllZodIssues(queryParse.error, 422);
const dryRun = queryParse.data.dryRun;
// Same blast-radius reasoning as the JSON import — share the bucket so a
// CSV upload counts against the same 5/hour quota.