From cd1730e50156140c08ff5046d52ac72b977b5e23 Mon Sep 17 00:00:00 2001 From: tdlxgpp <294463603+tdlxgpp@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:22:37 +0800 Subject: [PATCH] fix(api): reject empty results arrays in /api/runs An empty results array currently creates a run with total=0 and shouldFail=false, so a broken CI producer can still pass the gate. Extract ingest payload validation into a small pure helper and reject empty results with HTTP 400 before any database write. Add node:test regression coverage for the empty-array case, and document the non-empty requirement in the informational GET response. --- app/api/runs/route.ts | 11 +++++----- lib/validate-ingest.test.ts | 40 +++++++++++++++++++++++++++++++++++++ lib/validate-ingest.ts | 33 ++++++++++++++++++++++++++++++ package.json | 1 + tsconfig.json | 3 ++- 5 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 lib/validate-ingest.test.ts create mode 100644 lib/validate-ingest.ts diff --git a/app/api/runs/route.ts b/app/api/runs/route.ts index e838cee..c59f120 100644 --- a/app/api/runs/route.ts +++ b/app/api/runs/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { db, checkIngestToken } from "@/lib/supabase"; +import { validateIngestPayload } from "@/lib/validate-ingest"; import type { IngestPayload } from "@/lib/types"; export const runtime = "nodejs"; @@ -23,7 +24,7 @@ export async function GET() { output: "string (optional)", expected: "string (optional)", }, - ], + ], // required; must contain at least one result }, returns: { ok: true, runId: "uuid", regressed: 0, flagged: 0, shouldFail: false }, }); @@ -45,11 +46,9 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "invalid json" }, { status: 400 }); } - if (!body.suite || !body.label || !Array.isArray(body.results)) { - return NextResponse.json( - { error: "suite, label and results[] are required" }, - { status: 400 }, - ); + const validation = validateIngestPayload(body); + if (!validation.ok) { + return NextResponse.json({ error: validation.error }, { status: 400 }); } const supabase = db(); diff --git a/lib/validate-ingest.test.ts b/lib/validate-ingest.test.ts new file mode 100644 index 0000000..6d5da1b --- /dev/null +++ b/lib/validate-ingest.test.ts @@ -0,0 +1,40 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { validateIngestPayload } from "./validate-ingest.ts"; + +test("rejects an empty results array", () => { + const result = validateIngestPayload({ + suite: "smoke", + label: "run", + results: [], + }); + + assert.equal(result.ok, false); + assert.equal( + (result as { ok: false; error: string }).error, + "results[] must contain at least one result", + ); +}); + +test("rejects missing results array", () => { + const result = validateIngestPayload({ + suite: "smoke", + label: "run", + }); + + assert.equal(result.ok, false); + assert.equal( + (result as { ok: false; error: string }).error, + "results[] is required", + ); +}); + +test("accepts a non-empty results array", () => { + const result = validateIngestPayload({ + suite: "smoke", + label: "run", + results: [{ caseName: "case-1", passed: true }], + }); + + assert.equal(result.ok, true); +}); diff --git a/lib/validate-ingest.ts b/lib/validate-ingest.ts new file mode 100644 index 0000000..c9551a8 --- /dev/null +++ b/lib/validate-ingest.ts @@ -0,0 +1,33 @@ +export type ValidateIngestResult = + | { ok: true } + | { ok: false; error: string }; + +export function validateIngestPayload(body: unknown): ValidateIngestResult { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return { ok: false, error: "invalid body" }; + } + + const b = body as { + suite?: unknown; + label?: unknown; + results?: unknown; + }; + + if (typeof b.suite !== "string" || b.suite === "") { + return { ok: false, error: "suite is required" }; + } + + if (typeof b.label !== "string" || b.label === "") { + return { ok: false, error: "label is required" }; + } + + if (!Array.isArray(b.results)) { + return { ok: false, error: "results[] is required" }; + } + + if (b.results.length === 0) { + return { ok: false, error: "results[] must contain at least one result" }; + } + + return { ok: true }; +} diff --git a/package.json b/package.json index 934bb7a..88dc67e 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "next build", "start": "next start", "lint": "next lint", + "test": "node --test --no-warnings lib/validate-ingest.test.ts", "cf:build": "NODE_OPTIONS=--no-deprecation opennextjs-cloudflare build", "cf:preview": "NODE_OPTIONS=--no-deprecation opennextjs-cloudflare build && NODE_OPTIONS=--no-deprecation opennextjs-cloudflare preview", "deploy": "NODE_OPTIONS=--no-deprecation opennextjs-cloudflare build && NODE_OPTIONS=--no-deprecation opennextjs-cloudflare deploy" diff --git a/tsconfig.json b/tsconfig.json index 423b0d0..440451f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,6 +36,7 @@ ".next/dev/types/**/*.ts" ], "exclude": [ - "node_modules" + "node_modules", + "**/*.test.ts" ] }