mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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"));
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user