From d29808aea2ca746a018519fcdc62167c0b36ad7b Mon Sep 17 00:00:00 2001 From: Dong Liu Date: Fri, 12 Jun 2026 00:02:08 +0000 Subject: [PATCH] fix(commandboard-api): return 400 for malformed JSON bodies instead of 500 Malformed JSON on any POST endpoint previously bubbled a SyntaxError to the top-level handler, which mapped every throw to 500. Client input errors now respond 400 { "error": "Invalid JSON body" } via a typed InvalidJsonBodyError thrown from readJson(). Adds contract tests covering all four POST endpoints plus a control asserting schema-invalid (but well-formed) bodies still return 422. Fixes #11 --- apps/commandboard-api/src/contract.test.ts | 31 ++++++++++++++++++++++ apps/commandboard-api/src/index.ts | 17 +++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/commandboard-api/src/contract.test.ts b/apps/commandboard-api/src/contract.test.ts index 2b9629d..4de2ea1 100644 --- a/apps/commandboard-api/src/contract.test.ts +++ b/apps/commandboard-api/src/contract.test.ts @@ -182,3 +182,34 @@ describe("CommandBoard API contracts", () => { expect(Array.isArray(body.errors)).toBe(true); }); }); + +describe("invalid JSON request bodies", () => { + const postEndpoints = [ + "/api/tasks", + "/api/plugins/sh1pt/actions/publish", + "/api/plugins/c0mpute/jobs/dispatch", + "/api/plugins/c0mpute/quotes" + ]; + + it.each(postEndpoints)("returns 400 (not 500) for malformed JSON on %s", async (endpoint) => { + const response = await fetch(`${baseUrl}${endpoint}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{invalid json" + }); + const body = await response.json() as { error: string }; + + expect(response.status).toBe(400); + expect(body).toEqual({ error: "Invalid JSON body" }); + }); + + it("still returns 422 for well-formed JSON that fails schema validation", async () => { + const response = await fetch(`${baseUrl}/api/tasks`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "missing required fields" }) + }); + + expect(response.status).toBe(422); + }); +}); diff --git a/apps/commandboard-api/src/index.ts b/apps/commandboard-api/src/index.ts index 58b6166..04287ca 100644 --- a/apps/commandboard-api/src/index.ts +++ b/apps/commandboard-api/src/index.ts @@ -60,6 +60,10 @@ export function createCommandBoardServer() { try { await route(request, response); } catch (error) { + if (error instanceof InvalidJsonBodyError) { + json(response, 400, { error: error.message }); + return; + } json(response, 500, { error: error instanceof Error ? error.message : String(error) }); } }); @@ -281,13 +285,24 @@ function text(response: ServerResponse, status: number, contentType: string, bod response.end(body); } +class InvalidJsonBodyError extends Error { + constructor() { + super("Invalid JSON body"); + this.name = "InvalidJsonBodyError"; + } +} + async function readJson(request: IncomingMessage) { const chunks: Buffer[] = []; for await (const chunk of request) { chunks.push(Buffer.from(chunk)); } - return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; + try { + return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; + } catch { + throw new InvalidJsonBodyError(); + } } function isRecord(value: unknown): value is Record {