From 561a0694cfb5db316ff3cbef64458f7c85adea88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 09:26:53 +0200 Subject: [PATCH] fix(admin): return 422 for a malformed query string The app-logs, audit-log and host-metrics admin endpoints parsed their query with a throwing parse(), so a bad parameter surfaced as a 500 and a reported incident. Switch each to safeParse and return the standard 422 validation envelope, matching how body parsing is handled. --- .../admin/app-logs/__tests__/route.test.ts | 19 ++++++++++++++----- src/app/api/admin/app-logs/route.ts | 6 ++++-- src/app/api/admin/audit-log/route.ts | 6 ++++-- src/app/api/admin/host-metrics/route.ts | 6 ++++-- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/app/api/admin/app-logs/__tests__/route.test.ts b/src/app/api/admin/app-logs/__tests__/route.test.ts index dabd2ac88..d12a12c5d 100644 --- a/src/app/api/admin/app-logs/__tests__/route.test.ts +++ b/src/app/api/admin/app-logs/__tests__/route.test.ts @@ -119,11 +119,20 @@ describe("GET /api/admin/app-logs", () => { expect(json.data.events).toHaveLength(10); }); - it("rejects an invalid level value", async () => { + it("rejects an invalid level value with a 422 (not a thrown 500)", async () => { appendLogEvent(makeEvent()); - // Invalid `level` — Zod parse should throw and the apiHandler test - // mock rethrows it (we mock only `requireAdmin`, not the wrapper - // error-translator). We simply assert the call rejects. - await expect(GET(req("level=critical"))).rejects.toThrow(); + // Invalid `level` — the query is parsed with `safeParse` and returns the + // standard 422 multi-issue envelope rather than throwing (which the + // apiHandler catch ladder would otherwise surface as a 500 + incident). + const res = await GET(req("level=critical")); + expect(res.status).toBe(422); + const json = (await res.json()) as { + data: null; + error: string; + details: { issues: unknown[] }; + }; + expect(json.data).toBeNull(); + expect(json.error).toBe("Validation failed"); + expect(json.details.issues.length).toBeGreaterThan(0); }); }); diff --git a/src/app/api/admin/app-logs/route.ts b/src/app/api/admin/app-logs/route.ts index eee82faf4..c5e3e3478 100644 --- a/src/app/api/admin/app-logs/route.ts +++ b/src/app/api/admin/app-logs/route.ts @@ -1,5 +1,5 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { readLogBuffer, LOG_BUFFER_MAX } from "@/lib/logging/in-memory-buffer"; import { redactSecrets } from "@/lib/logging/redact"; @@ -35,7 +35,7 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "admin.app-logs.list" } }); const { searchParams } = new URL(request.url); - const parsed = querySchema.parse({ + const result = querySchema.safeParse({ traceId: searchParams.get("traceId") ?? undefined, level: searchParams.get("level") ?? undefined, action: searchParams.get("action") ?? undefined, @@ -43,6 +43,8 @@ export const GET = apiHandler(async (request: NextRequest) => { until: searchParams.get("until") ?? undefined, limit: searchParams.get("limit") ?? undefined, }); + if (!result.success) return returnAllZodIssues(result.error); + const parsed = result.data; const events = readLogBuffer({ traceId: parsed.traceId, diff --git a/src/app/api/admin/audit-log/route.ts b/src/app/api/admin/audit-log/route.ts index 47c62061b..6ba1fb45a 100644 --- a/src/app/api/admin/audit-log/route.ts +++ b/src/app/api/admin/audit-log/route.ts @@ -1,6 +1,6 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { redactSecrets } from "@/lib/logging/redact"; import { NextRequest } from "next/server"; @@ -56,7 +56,7 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "admin.audit-log.list" } }); const { searchParams } = new URL(request.url); - const parsed = querySchema.parse({ + const result = querySchema.safeParse({ limit: searchParams.get("limit") ?? undefined, offset: searchParams.get("offset") ?? undefined, filter: searchParams.get("filter") ?? undefined, @@ -68,6 +68,8 @@ export const GET = apiHandler(async (request: NextRequest) => { since: searchParams.get("since") ?? undefined, until: searchParams.get("until") ?? undefined, }); + if (!result.success) return returnAllZodIssues(result.error); + const parsed = result.data; // Resolve perPage: clamp to allowed set, fall back to 50. const perPageResolved = diff --git a/src/app/api/admin/host-metrics/route.ts b/src/app/api/admin/host-metrics/route.ts index 28cf09803..8b84ec4e5 100644 --- a/src/app/api/admin/host-metrics/route.ts +++ b/src/app/api/admin/host-metrics/route.ts @@ -1,6 +1,6 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -59,9 +59,11 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "admin.host-metrics.list" } }); const { searchParams } = new URL(request.url); - const parsed = querySchema.parse({ + const result = querySchema.safeParse({ since: searchParams.get("since") ?? undefined, }); + if (!result.success) return returnAllZodIssues(result.error); + const parsed = result.data; const sinceMs = presetToMs(parsed.since); const cutoff = new Date(Date.now() - sinceMs);