fix(qa): normalize malformed JSON errors (#102830)

Co-authored-by: ZengWen-DT <ceng.wen@xydigit.com>
This commit is contained in:
Peter Steinberger 2026-07-09 15:40:27 +01:00 committed by GitHub
parent 3e492e0146
commit 743422217e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 59 additions and 2 deletions

View file

@ -172,6 +172,19 @@ describe("qa-bus server", () => {
}); });
}); });
it("returns a controlled error when a v1 POST body contains malformed JSON", async () => {
const state = createQaBusState();
const bus = await startQaBusServer({ state });
stops.push(bus["stop"]);
const response = await postQaBusRawJson(bus.baseUrl, "/v1/reset", "{");
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "Malformed JSON body",
});
});
it("keeps oversized numeric poll and search fields bounded", async () => { it("keeps oversized numeric poll and search fields bounded", async () => {
const state = createQaBusState(); const state = createQaBusState();
const bus = await startQaBusServer({ state }); const bus = await startQaBusServer({ state });

View file

@ -26,6 +26,18 @@ const QA_HTTP_JSON_BODY_TIMEOUT_MS = 5_000;
const QA_BUS_POLL_TIMEOUT_MAX_MS = 30_000; const QA_BUS_POLL_TIMEOUT_MAX_MS = 30_000;
const QA_BUS_POLL_LIMIT_MAX = 500; const QA_BUS_POLL_LIMIT_MAX = 500;
const QA_BUS_SEARCH_LIMIT_MAX = 100; const QA_BUS_SEARCH_LIMIT_MAX = 100;
const QA_MALFORMED_JSON_BODY_MESSAGE = "Malformed JSON body";
class QaMalformedJsonBodyError extends Error {
constructor() {
super(QA_MALFORMED_JSON_BODY_MESSAGE);
this.name = "QaMalformedJsonBodyError";
}
}
export function isQaMalformedJsonBodyError(error: unknown): error is Error {
return error instanceof QaMalformedJsonBodyError;
}
export async function readQaJsonBody(req: IncomingMessage): Promise<unknown> { export async function readQaJsonBody(req: IncomingMessage): Promise<unknown> {
const text = ( const text = (
@ -34,7 +46,14 @@ export async function readQaJsonBody(req: IncomingMessage): Promise<unknown> {
timeoutMs: QA_HTTP_JSON_BODY_TIMEOUT_MS, timeoutMs: QA_HTTP_JSON_BODY_TIMEOUT_MS,
}) })
).trim(); ).trim();
return text ? (JSON.parse(text) as unknown) : {}; if (!text) {
return {};
}
try {
return JSON.parse(text) as unknown;
} catch {
throw new QaMalformedJsonBodyError();
}
} }
export function writeJson(res: ServerResponse, statusCode: number, body: unknown) { export function writeJson(res: ServerResponse, statusCode: number, body: unknown) {

View file

@ -6,12 +6,12 @@ import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises"; import { setTimeout as sleep } from "node:timers/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readQaJsonBody } from "./bus-server.js"; import { readQaJsonBody } from "./bus-server.js";
import { resolveUiAssetVersion } from "./lab-server-ui.js";
import { import {
startQaLabServer, startQaLabServer,
writeQaLabServerError, writeQaLabServerError,
type QaLabServerStartParams, type QaLabServerStartParams,
} from "./lab-server.js"; } from "./lab-server.js";
import { resolveUiAssetVersion } from "./lab-server-ui.js";
const qaChannelMock = vi.hoisted(() => ({ const qaChannelMock = vi.hoisted(() => ({
resolveAccount: vi.fn(), resolveAccount: vi.fn(),
@ -639,6 +639,26 @@ describe("qa-lab server", () => {
expect(JSON.parse(res.body)).toEqual({ error: "Payload too large" }); expect(JSON.parse(res.body)).toEqual({ error: "Payload too large" });
}); });
it("returns controlled errors for malformed JSON body reads", async () => {
const lab = await startQaLabServerForTest();
cleanups.push(async () => {
await lab.stop();
});
const response = await fetch(`${lab.baseUrl}/api/inbound/message`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: "{",
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "Malformed JSON body",
});
});
it("anchors direct self-check runs under the explicit repo root by default", async () => { it("anchors direct self-check runs under the explicit repo root by default", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-lab-self-check-root-")); const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-lab-self-check-root-"));
cleanups.push(async () => { cleanups.push(async () => {

View file

@ -10,6 +10,7 @@ import {
import { import {
closeQaHttpServer, closeQaHttpServer,
handleQaBusRequest, handleQaBusRequest,
isQaMalformedJsonBodyError,
readQaJsonBody, readQaJsonBody,
writeError, writeError,
writeJson, writeJson,
@ -76,6 +77,10 @@ export function writeQaLabServerError(res: Parameters<typeof writeError>[0], err
if (writeQaRequestBodyLimitError(res, error)) { if (writeQaRequestBodyLimitError(res, error)) {
return; return;
} }
if (isQaMalformedJsonBodyError(error)) {
writeError(res, 400, error.message);
return;
}
if (error instanceof QaEvidenceGalleryError) { if (error instanceof QaEvidenceGalleryError) {
writeError(res, error.statusCode, error.message); writeError(res, error.statusCode, error.message);
return; return;