diff --git a/apps/app/src/app/api/pick/input-regression.test.ts b/apps/app/src/app/api/pick/input-regression.test.ts new file mode 100644 index 0000000..1edd387 --- /dev/null +++ b/apps/app/src/app/api/pick/input-regression.test.ts @@ -0,0 +1,22 @@ +// @vitest-environment node +import { describe, it, expect, vi } from "vitest"; +import { NextRequest } from "next/server"; +vi.mock("./store", () => ({ getSession: vi.fn(), setSession: vi.fn() })); +import { PUT, POST } from "./route"; +import { setSession } from "./store"; +describe("QR pick input", () => { + it.each(["{", "null", "[]", JSON.stringify({sessionId:{}}), JSON.stringify({sessionId:" "}), JSON.stringify({sessionId:"x".repeat(129)})])("returns 400 for invalid scan body %s", async body => { + vi.clearAllMocks(); + const r = await PUT(new NextRequest("http://localhost/api/pick", {method:"PUT",body})); + expect(r.status).toBe(400); expect(setSession).not.toHaveBeenCalled(); + }); + it.each([{}, {sessionId:"valid",prompt:{}}, {sessionId:"valid",prompt:" "}, {sessionId:"valid",prompt:"x".repeat(20001)}])("rejects invalid prompts", async body => { + vi.clearAllMocks(); + const r = await POST(new NextRequest("http://localhost/api/pick", {method:"POST",body:JSON.stringify(body)})); + expect(r.status).toBe(400); expect(setSession).not.toHaveBeenCalled(); + }); + it("keeps a valid chosen prompt intact", async () => { + const r = await POST(new NextRequest("http://localhost/api/pick", {method:"POST",body:JSON.stringify({sessionId:"valid",prompt:"Compare batteries"})})); + expect(r.status).toBe(200); expect(setSession).toHaveBeenCalledWith("valid",{status:"picked",prompt:"Compare batteries"}); + }); +}); diff --git a/apps/app/src/app/api/pick/route.ts b/apps/app/src/app/api/pick/route.ts index 8b9e543..c486b70 100644 --- a/apps/app/src/app/api/pick/route.ts +++ b/apps/app/src/app/api/pick/route.ts @@ -19,11 +19,25 @@ export async function GET(req: NextRequest) { return NextResponse.json(session); } +async function readPickInput(req: NextRequest, needsPrompt = false) { + try { + const body: unknown = await req.json(); + if (!body || typeof body !== "object" || Array.isArray(body)) return undefined; + const { sessionId, prompt } = body as Record; + if (typeof sessionId !== "string" || !sessionId.trim() || sessionId.length > 128) return undefined; + if (needsPrompt && (typeof prompt !== "string" || !prompt.trim() || prompt.length > 20_000)) return undefined; + return { sessionId, prompt: typeof prompt === "string" ? prompt : undefined }; + } catch { + return undefined; + } +} + /** PUT — mark session as scanned (mobile calls this on page load) */ export async function PUT(req: NextRequest) { - const { sessionId } = await req.json(); - if (!sessionId) return NextResponse.json({ error: "missing sessionId" }, { status: 400 }); + const input = await readPickInput(req); + if (!input) return NextResponse.json({ error: "missing sessionId" }, { status: 400 }); + const { sessionId } = input; const existing = getSession(sessionId); if (!existing || existing.status === "waiting") { setSession(sessionId, { status: "scanned" }); @@ -34,11 +48,11 @@ export async function PUT(req: NextRequest) { /** POST — submit picked prompt (mobile calls this when user picks an option) */ export async function POST(req: NextRequest) { - const { sessionId, prompt } = await req.json(); - if (!sessionId || !prompt) { + const input = await readPickInput(req, true); + if (!input) { return NextResponse.json({ error: "missing sessionId or prompt" }, { status: 400 }); } - setSession(sessionId, { status: "picked", prompt }); + setSession(input.sessionId, { status: "picked", prompt: input.prompt }); return NextResponse.json({ ok: true }); }