From 14c182ff37196c52838969cd742d00ff4a4ec64a Mon Sep 17 00:00:00 2001 From: MikeTuev Date: Sun, 12 Jul 2026 05:36:25 +0500 Subject: [PATCH 1/4] fix(dashboard): logs detail modal no longer reopens on first close (#6830) LogsPage recomputed initialId from window.location on every render, but the App Router syncs window.location only after the navigation commits. Closing the detail modal re-rendered the page while the URL still carried ?id=X, so initialSelectedId flipped null -> X and the child's one-shot deep-link effect (guard still unarmed after the open-click render, where location was stale in the other direction) reopened the modal. Only the second close worked. Read the id once via lazy useState so the prop stays stable for the page's lifetime; deep links still open the modal on mount. Regression test reproduces the App Router ordering with a router.replace mock that re-renders the page before committing the URL. --- src/app/(dashboard)/dashboard/logs/page.tsx | 10 +- ...page-detail-modal-reopen-on-close.test.tsx | 206 ++++++++++++++++++ 2 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 tests/unit/ui/logs-page-detail-modal-reopen-on-close.test.tsx diff --git a/src/app/(dashboard)/dashboard/logs/page.tsx b/src/app/(dashboard)/dashboard/logs/page.tsx index 4b88afde7e..d7051f8612 100644 --- a/src/app/(dashboard)/dashboard/logs/page.tsx +++ b/src/app/(dashboard)/dashboard/logs/page.tsx @@ -46,9 +46,13 @@ export default function LogsPage() { return () => document.removeEventListener("mousedown", handleClickOutside); }, []); - // initial id from URL (synchronously on client) so child can open on mount - const initialId = - typeof window !== "undefined" ? new URL(window.location.href).searchParams.get("id") : null; + // initial id from URL (synchronously on client) so child can open on mount. + // Read once via lazy state: window.location lags router.replace() by one + // render, so re-reading it on every render flips this prop mid-session and + // re-triggers the child's deep-link effect right when the modal closes. + const [initialId] = useState(() => + typeof window !== "undefined" ? new URL(window.location.href).searchParams.get("id") : null + ); async function handleExport(hours: number) { setExporting(true); diff --git a/tests/unit/ui/logs-page-detail-modal-reopen-on-close.test.tsx b/tests/unit/ui/logs-page-detail-modal-reopen-on-close.test.tsx new file mode 100644 index 0000000000..27d418ee46 --- /dev/null +++ b/tests/unit/ui/logs-page-detail-modal-reopen-on-close.test.tsx @@ -0,0 +1,206 @@ +// @vitest-environment jsdom +/** + * TDD regression: dashboard/logs — closing the request-detail modal after the + * FIRST row click immediately reopens it; only the second close works. + * + * Root cause: LogsPage computes `initialId` from `window.location` on EVERY + * render, but Next.js App Router syncs `window.location` only after the + * navigation commits — i.e. after the re-render triggered by + * `router.replace()`. So: + * + * 1. Row click → openDetail → router.replace("?id=X"): page re-renders while + * location is still the old URL → initialId stays null, the child's + * one-shot `initialOpenedRef` guard is never consumed. + * 2. First close → closeDetail → router.replace(no id): page re-renders while + * location STILL carries "?id=X" → initialId flips null → "X" → the child's + * deep-link effect fires (guard still unarmed) → openDetail reopens the + * modal. + * 3. Second close works because the guard is now armed. + * + * The router mock below reproduces that ordering: replace() re-renders the + * page synchronously (like the App Router segment re-render) and the URL is + * committed to window.location separately, after the render — via + * commitPendingUrl(). + */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const routerControl = vi.hoisted(() => ({ + pendingUrl: null as string | null, + bumpPageRender: () => {}, +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + replace: (url: string) => { + // Mirror App Router ordering: the segment re-renders first, the URL is + // synced to window.location only after the commit (commitPendingUrl()). + routerControl.pendingUrl = url; + routerControl.bumpPageRender(); + }, + push: vi.fn(), + prefetch: vi.fn(), + refresh: vi.fn(), + }), + usePathname: () => "/dashboard/logs", + useSearchParams: () => new URLSearchParams(globalThis.location.search), +})); + +vi.mock("@/store/emailPrivacyStore", () => ({ + default: () => ({ emailsVisible: true }), +})); + +// LogsPage imports { ConfirmModal, RequestLoggerV2 } from the barrel; keep the +// real logger (the component under test) and stub the unrelated ConfirmModal +// so the test doesn't drag the whole barrel into jsdom. +vi.mock("@/shared/components", async () => { + const { default: RequestLoggerV2 } = await import( + "../../../src/shared/components/RequestLoggerV2.tsx" + ); + const ConfirmModal = ({ isOpen }: { isOpen: boolean }) => + isOpen ?
: null; + return { RequestLoggerV2, ConfirmModal }; +}); + +const { default: LogsPage } = await import( + "../../../src/app/(dashboard)/dashboard/logs/page.tsx" +); + +// Stands in for the App Router segment root: router.replace() re-renders the +// whole page tree, which is exactly what re-evaluates LogsPage's initialId. +function Harness() { + const [, setVersion] = React.useState(0); + React.useEffect(() => { + routerControl.bumpPageRender = () => setVersion((v) => v + 1); + return () => { + routerControl.bumpPageRender = () => {}; + }; + }, []); + return ; +} + +function commitPendingUrl() { + if (routerControl.pendingUrl != null) { + window.history.replaceState(null, "", routerControl.pendingUrl); + routerControl.pendingUrl = null; + } +} + +class FakeIntersectionObserver { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } +} + +const LOG_ROW = { + id: "log-1", + status: 200, + timestamp: new Date().toISOString(), + model: "gpt-4o", + provider: "openai", + account: "user@example.com", + tokens: { in: 10, out: 20 }, + duration: 1234, +}; + +let container: HTMLElement; +let root: Root; + +async function settle() { + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); +} + +beforeEach(() => { + localStorage.clear(); + routerControl.pendingUrl = null; + routerControl.bumpPageRender = () => {}; + vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith("/api/usage/call-logs")) { + return Response.json([LOG_ROW]); + } + if (url.startsWith("/api/logs/detail")) { + return Response.json({ enabled: false }); + } + if (url.startsWith(`/api/logs/${LOG_ROW.id}`)) { + return Response.json({ ...LOG_ROW, active: false }); + } + if (url.startsWith("/api/provider-nodes")) { + return Response.json({ nodes: [] }); + } + return Response.json({}); + }) + ); + vi.useFakeTimers(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + window.history.replaceState(null, "", "/dashboard/logs"); +}); + +describe("LogsPage detail modal — first-close reopen regression", () => { + it("closing the modal after the first row click keeps it closed", async () => { + window.history.replaceState(null, "", "/dashboard/logs"); + + await act(async () => { + root.render(); + }); + await settle(); + + // First click on the log row opens the detail modal. + const row = container.querySelector("tbody tr") as HTMLTableRowElement; + expect(row).not.toBeNull(); + await act(async () => { + row.click(); + }); + await settle(); + // Navigation commits after the render: URL now carries ?id=log-1. + commitPendingUrl(); + expect(window.location.search).toContain("id=log-1"); + expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + + // First close: closeDetail() re-renders the page while window.location + // still has ?id=log-1 (the close navigation has not committed yet). + const dialog = container.querySelector('[role="dialog"]') as HTMLElement; + await act(async () => { + dialog.click(); // backdrop click → onClose + }); + await settle(); + + // The modal must stay closed — the stale ?id in location must not reopen it. + expect(container.querySelector('[role="dialog"]')).toBeNull(); + }); + + it("deep link ?id= still opens the modal on mount", async () => { + window.history.replaceState(null, "", `/dashboard/logs?id=${LOG_ROW.id}`); + + await act(async () => { + root.render(); + }); + await settle(); + + expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + }); +}); From a7227f4ef38a513900c5a930e398857b1a5eb6fc Mon Sep 17 00:00:00 2001 From: Chirag Singhal <76880977+chirag127@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:06:29 +0530 Subject: [PATCH 2/4] fix(fusion): select judge from a surviving panel member when no explicit judge (#6869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When no explicit judgeModel is configured, the judge defaulted to panel[0] before fan-out and was never reassigned. If panel[0] failed fan-out (timeout / rate-limit / dropped straggler → it lands in `failures`, not `answers`), the multi-answer synthesis path still dispatched the judge to that dead panel[0], erroring the whole fusion request even though a quorum of other panel members succeeded — exactly the failure fusion exists to tolerate. Resolve the effective synthesis judge from a survivor when no explicit judge is set: prefer panel[0] only when it survived, otherwise the first surviving answer. An explicitly configured judge is still honored unchanged (operator intent), and the answers.length===0 (503) and single-survivor branches keep their existing semantics. Co-authored-by: Chirag Singhal --- open-sse/services/fusion.ts | 23 +++- tests/unit/fusion-judge-survivor.test.ts | 141 +++++++++++++++++++++++ 2 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 tests/unit/fusion-judge-survivor.test.ts diff --git a/open-sse/services/fusion.ts b/open-sse/services/fusion.ts index d089186823..000f5a21c5 100644 --- a/open-sse/services/fusion.ts +++ b/open-sse/services/fusion.ts @@ -354,14 +354,31 @@ export async function handleFusionChat({ // surviving panel answer, rather than silently substituting the panel // member for the configured judge (issue #6455). The judge still adds // value reviewing/polishing a lone source per its documented contract. + } + + // Resolve the judge that ACTUALLY runs synthesis. An explicit judgeModel is + // honored as configured (operator intent — kept even if it was down during + // fan-out; that's the operator's choice). With NO explicit judge the judge + // defaulted to panel[0] — but panel[0] may have FAILED fan-out (timeout / + // rate-limit / dropped straggler → it lands in `failures`, not `answers`). + // Handing synthesis to a dead panel[0] sinks the whole request despite a + // healthy quorum — exactly the case fusion exists to tolerate. So pick a + // SURVIVOR: prefer panel[0] when it survived, otherwise the first survivor. + const effectiveJudge = hasExplicitJudge + ? judge + : answers.some((a) => a.model === panel[0]) + ? panel[0] + : answers[0].model; + + if (answers.length === 1) { log.info( "FUSION", - `Only ${answers[0].model} succeeded — judging single answer with ${judge}` + `Only ${answers[0].model} succeeded — judging single answer with ${effectiveJudge}` ); } // 4. Judge analyzes + writes one final answer (streams to client if requested). const judgeBody = appendUserTurn(body, buildJudgePrompt(answers)); - log.info("FUSION", `Judging ${answers.length} answers with ${judge}`); - return handleSingleModel(judgeBody, judge); + log.info("FUSION", `Judging ${answers.length} answers with ${effectiveJudge}`); + return handleSingleModel(judgeBody, effectiveJudge); } diff --git a/tests/unit/fusion-judge-survivor.test.ts b/tests/unit/fusion-judge-survivor.test.ts new file mode 100644 index 0000000000..22eb2e5bda --- /dev/null +++ b/tests/unit/fusion-judge-survivor.test.ts @@ -0,0 +1,141 @@ +/** + * Regression test: with NO explicit judgeModel, the synthesis judge must be a + * SURVIVING panel member — never a panel[0] that failed fan-out. + * + * Bug: `handleFusionChat` fixed the default judge to `panel[0]` BEFORE fan-out + * and never reassigned it. When panel[0] timed out / was rate-limited / dropped + * as a straggler it landed in `failures`, not `answers` — yet the multi-answer + * synthesis path still dispatched `handleSingleModel(judgeBody, panel[0])`, + * handing synthesis to a dead model. The whole fusion request then errored even + * though a quorum of OTHER panel members succeeded — exactly the failure mode + * fusion exists to tolerate. + * + * Fix: when no explicit judge is configured, resolve the effective judge from a + * survivor (prefer panel[0] only when it survived, else the first survivor). An + * explicitly configured judge is still honored unchanged. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { handleFusionChat } from "../../open-sse/services/fusion.ts"; + +const noop = () => {}; +const log = { info: noop, warn: noop, debug: noop, error: noop }; + +type Body = Record; + +function okResponse(content: string): Promise { + const body = JSON.stringify({ + choices: [{ message: { role: "assistant", content } }], + }); + return Promise.resolve( + new Response(body, { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); +} + +function errResponse(status: number): Promise { + const body = JSON.stringify({ error: { message: "boom" } }); + return Promise.resolve( + new Response(body, { + status, + headers: { "Content-Type": "application/json" }, + }) + ); +} + +const PANEL = ["prov/model-a", "prov/model-b", "prov/model-c"]; + +test("fusion judge-survivor: no explicit judge + panel[0] fails fan-out → synthesis uses a surviving member, not the dead panel[0]", async () => { + const seen: string[] = []; + const handleSingleModel = (_b: Body, m: string) => { + seen.push(m); + // panel[0] (model-a) fails fan-out; B & C succeed. + if (m === "prov/model-a") return errResponse(429); + return okResponse(`ans-${m}`); + }; + + const res = await handleFusionChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + models: PANEL, + handleSingleModel, + log, + // NO explicit judge — this is the default-judge path that was broken. + tuning: { minPanel: 1, stragglerGraceMs: 4000, panelHardTimeoutMs: 60000 }, + }); + + assert.notEqual( + res.status, + 503, + "a healthy quorum (B, C) must not error just because panel[0] died" + ); + const body = (await res.clone().json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + assert.ok( + (body.choices?.[0]?.message?.content ?? "").length > 0, + "must carry a real synthesized answer" + ); + + // The synthesis dispatch is the LAST handleSingleModel call. + const synthesisJudge = seen[seen.length - 1]; + assert.notEqual(synthesisJudge, "prov/model-a", "judge must NOT be the failed panel[0]"); + assert.ok( + synthesisJudge === "prov/model-b" || synthesisJudge === "prov/model-c", + `judge must be a survivor (B or C), got ${synthesisJudge}` + ); +}); + +test("fusion judge-survivor: no explicit judge + panel[0] survives → panel[0] is still chosen (existing-good case unchanged)", async () => { + const seen: string[] = []; + const handleSingleModel = (_b: Body, m: string) => { + seen.push(m); + // panel[0] (model-a) survives; model-b fails. + if (m === "prov/model-b") return errResponse(429); + return okResponse(`ans-${m}`); + }; + + const res = await handleFusionChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + models: PANEL, + handleSingleModel, + log, + tuning: { minPanel: 1, stragglerGraceMs: 4000, panelHardTimeoutMs: 60000 }, + }); + + assert.notEqual(res.status, 503); + assert.equal( + seen[seen.length - 1], + "prov/model-a", + "when panel[0] survives it remains the default judge" + ); +}); + +test("fusion judge-survivor: explicit judge is honored unchanged even if it failed fan-out", async () => { + const seen: string[] = []; + const handleSingleModel = (_b: Body, m: string) => { + seen.push(m); + // The configured judge (model-a) fails fan-out; B & C succeed. + if (m === "prov/model-a") return errResponse(429); + return okResponse(`ans-${m}`); + }; + + const res = await handleFusionChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + models: PANEL, + handleSingleModel, + log, + judgeModel: "prov/model-a", // explicit — operator intent is honored as-is. + tuning: { minPanel: 1, stragglerGraceMs: 4000, panelHardTimeoutMs: 60000 }, + }); + + assert.notEqual(res.status, 503); + assert.equal( + seen[seen.length - 1], + "prov/model-a", + "an explicitly configured judge is dispatched unchanged (operator's choice)" + ); +}); From 6973e2bd3416ff5749dcbb8c80f804e570c1a01a Mon Sep 17 00:00:00 2001 From: Chirag Singhal <76880977+chirag127@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:06:33 +0530 Subject: [PATCH 3/4] fix(api): return 400 (not 500) on malformed JSON body (#6871) Co-authored-by: Chirag Singhal --- src/app/api/model-combo-mappings/route.ts | 15 +- src/app/api/plugins/[name]/config/route.ts | 19 ++- tests/unit/api-malformed-json-400.test.ts | 173 +++++++++++++++++++++ 3 files changed, 194 insertions(+), 13 deletions(-) create mode 100644 tests/unit/api-malformed-json-400.test.ts diff --git a/src/app/api/model-combo-mappings/route.ts b/src/app/api/model-combo-mappings/route.ts index eb8b7d23f4..955530e4f5 100644 --- a/src/app/api/model-combo-mappings/route.ts +++ b/src/app/api/model-combo-mappings/route.ts @@ -4,11 +4,11 @@ * POST — Create a new mapping */ -import { z } from "zod"; import { NextResponse } from "next/server"; +import { z } from "zod"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -import { getModelComboMappings, createModelComboMapping } from "@/lib/localDb"; -import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { createModelComboMapping, getModelComboMappings } from "@/lib/localDb"; +import { validatedJsonBody } from "@/shared/validation/helpers"; const createMappingSchema = z.object({ pattern: z.string().min(1, "Pattern is required").max(500), @@ -36,13 +36,12 @@ export async function POST(request: Request) { if (authError) return authError; try { - const rawBody = await request.json(); - const validation = validateBody(createMappingSchema, rawBody); - if (isValidationFailure(validation)) { - return NextResponse.json({ error: validation.error }, { status: 400 }); + const parsed = await validatedJsonBody(request, createMappingSchema); + if (!parsed.success) { + return parsed.response; } - const { data } = validation; + const { data } = parsed; const mapping = await createModelComboMapping({ pattern: data.pattern.trim(), comboId: data.comboId, diff --git a/src/app/api/plugins/[name]/config/route.ts b/src/app/api/plugins/[name]/config/route.ts index dbbae2ae32..e6b94375b8 100644 --- a/src/app/api/plugins/[name]/config/route.ts +++ b/src/app/api/plugins/[name]/config/route.ts @@ -1,9 +1,9 @@ -import { NextRequest, NextResponse } from "next/server"; -import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { buildErrorBody } from "@omniroute/open-sse/utils/error"; -import { getPluginByName, updatePluginConfig } from "@/lib/db/plugins"; -import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { type NextRequest, NextResponse } from "next/server"; import { z } from "zod"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { getPluginByName, updatePluginConfig } from "@/lib/db/plugins"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; export async function OPTIONS() { return handleCorsOptions(); @@ -41,7 +41,16 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const authError = await requireManagementAuth(request); if (authError) return authError; const { name } = await params; - const body = await request.json(); + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json(buildErrorBody(400, "Invalid JSON body"), { + status: 400, + headers: CORS_HEADERS, + }); + } const schema = z.object({ config: z.record(z.string(), z.unknown()), diff --git a/tests/unit/api-malformed-json-400.test.ts b/tests/unit/api-malformed-json-400.test.ts new file mode 100644 index 0000000000..1b0cce21c1 --- /dev/null +++ b/tests/unit/api-malformed-json-400.test.ts @@ -0,0 +1,173 @@ +/** + * Regression tests: mutating API routes must return 400 (not 500) on a malformed + * JSON request body. + * + * Before the fix, both handlers called `await request.json()` on a raw body: + * - PUT /api/plugins/[name]/config had no try/catch → unhandled 500. + * - POST /api/model-combo-mappings parsed inside the outer try whose catch + * returns a generic 500. + * A malformed body must instead surface as a clean 400 with the standard error + * envelope, while a well-formed body keeps its existing behavior. + * + * DB/auth setup mirrors tests/unit/agentSkills-routes.test.ts: a temp DATA_DIR + * with no configured password means requireManagementAuth() is a no-op (auth is + * not required), so the handlers run unauthenticated. DB handles are released in + * test.after (resetDbInstance) per CLAUDE.md — unreleased SQLite handles hang the + * Node test runner. + */ + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +// ── DB / auth setup ───────────────────────────────────────────────────────── + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-malformed-json-400-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_API_KEY_SECRET = process.env.API_KEY_SECRET; +const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD; + +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "malformed-json-400-test-secret"; +delete process.env.INITIAL_PASSWORD; // ensure auth is NOT required + +// Import DB first (order matters — sets DATA_DIR before localDb loads) +const core = await import("../../src/lib/db/core.ts"); +const combosDb = await import("../../src/lib/db/combos.ts"); + +// Import routes AFTER env vars are set +const pluginConfigRoute = await import("../../src/app/api/plugins/[name]/config/route.ts"); +const modelComboRoute = await import("../../src/app/api/model-combo-mappings/route.ts"); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Build a POST/PUT Request that CLAIMS to be JSON but carries a non-JSON body. */ +function malformedJsonRequest(url: string, method = "POST"): Request { + return new Request(url, { + method, + headers: { "content-type": "application/json" }, + body: "not-json", + }); +} + +/** Build a POST/PUT Request with a well-formed JSON body. */ +function jsonRequest(url: string, body: unknown, method = "POST"): Request { + return new Request(url, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + + if (ORIGINAL_API_KEY_SECRET === undefined) delete process.env.API_KEY_SECRET; + else process.env.API_KEY_SECRET = ORIGINAL_API_KEY_SECRET; + + if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD; + else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD; +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// PUT /api/plugins/[name]/config +// ═════════════════════════════════════════════════════════════════════════════ + +test("PUT /api/plugins/[name]/config — malformed JSON body returns 400 (was 500)", async () => { + const req = malformedJsonRequest("http://localhost/api/plugins/demo/config", "PUT"); + const res = await pluginConfigRoute.PUT(req, { + params: Promise.resolve({ name: "demo" }), + }); + + assert.equal(res.status, 400, "malformed JSON must yield 400, not an unhandled 500"); + const body = (await res.json()) as { error?: { message?: string } }; + assert.ok(body.error, "response must carry a structured error envelope"); + assert.equal(typeof body.error.message, "string"); + // Hard rule #12: no stack trace leaked in the error message. + assert.ok(!body.error.message?.includes("at /"), "error message must not leak a stack trace"); +}); + +test("PUT /api/plugins/[name]/config — valid body does NOT 400 on parse (404 for unknown plugin)", async () => { + // Well-formed body against a plugin that does not exist: the handler must get + // PAST body parsing/validation and reach the not-found branch (404) — proving + // the happy path is unregressed. + const req = jsonRequest( + "http://localhost/api/plugins/does-not-exist/config", + { config: { foo: "bar" } }, + "PUT" + ); + const res = await pluginConfigRoute.PUT(req, { + params: Promise.resolve({ name: "does-not-exist" }), + }); + + assert.notEqual(res.status, 400, "a well-formed body must not be rejected as invalid JSON"); + assert.equal(res.status, 404, "unknown plugin should surface as 404, past the parse guard"); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// POST /api/model-combo-mappings +// ═════════════════════════════════════════════════════════════════════════════ + +test("POST /api/model-combo-mappings — malformed JSON body returns 400 (was 500)", async () => { + const req = malformedJsonRequest("http://localhost/api/model-combo-mappings"); + const res = await modelComboRoute.POST(req); + + assert.equal(res.status, 400, "malformed JSON must yield 400, not a generic 500"); + const body = (await res.json()) as { + error?: { + message?: string; + details?: Array<{ field: string; message: string }>; + }; + }; + // Uses the canonical validatedJsonBody envelope. + assert.equal(body.error?.message, "Invalid request"); + assert.deepEqual(body.error?.details, [{ field: "body", message: "Invalid JSON body" }]); +}); + +test("POST /api/model-combo-mappings — well-formed body succeeds (201), no parse regression", async () => { + // Seed a real combo so the mapping's FK (combo_id → combos.id) is satisfied. + await combosDb.createCombo({ + id: "combo-under-test", + name: "combo-under-test", + }); + + const req = jsonRequest("http://localhost/api/model-combo-mappings", { + pattern: "gpt-4*", + comboId: "combo-under-test", + }); + const res = await modelComboRoute.POST(req); + + assert.equal(res.status, 201, "a valid body must still create the mapping (201)"); + const body = (await res.json()) as { + mapping?: { pattern?: string; comboId?: string }; + }; + assert.equal(body.mapping?.pattern, "gpt-4*"); + assert.equal(body.mapping?.comboId, "combo-under-test"); +}); + +test("POST /api/model-combo-mappings — well-formed but invalid body still returns 400", async () => { + // Empty pattern fails the Zod schema — same 400 envelope as the malformed path. + const req = jsonRequest("http://localhost/api/model-combo-mappings", { + pattern: "", + comboId: "combo-x", + }); + const res = await modelComboRoute.POST(req); + + assert.equal(res.status, 400); + const body = (await res.json()) as { + error?: { message?: string; details?: Array<{ field: string }> }; + }; + assert.equal(body.error?.message, "Invalid request"); + assert.ok( + body.error?.details?.some((d) => d.field === "pattern"), + "validation failure should name the offending field" + ); +}); From 1b7a9150e5a77c0ed43848253b28fc47fbeca58c Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 11 Jul 2026 22:42:52 -0300 Subject: [PATCH 4/4] chore(ci): fix shared base-reds blocking PR queue (stryker registration + codex 0.144 test) - register tests/unit/cliproxyapi-model-mapping-dispatch.test.ts in stryker.conf.json tap.testFiles (gap from #6903) - update provider-models-route-codex.test.ts client_version 0.142.0 -> 0.144.0 (stale test from #6780 prod bump) --- changelog.d/maintenance/release-v3.8.47-basereds.md | 1 + stryker.conf.json | 1 + tests/unit/provider-models-route-codex.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 changelog.d/maintenance/release-v3.8.47-basereds.md diff --git a/changelog.d/maintenance/release-v3.8.47-basereds.md b/changelog.d/maintenance/release-v3.8.47-basereds.md new file mode 100644 index 0000000000..5305f83ac7 --- /dev/null +++ b/changelog.d/maintenance/release-v3.8.47-basereds.md @@ -0,0 +1 @@ +- **chore(ci):** fix two shared base-reds on the release tip that blocked the PR queue — register `cliproxyapi-model-mapping-dispatch.test.ts` in `stryker.conf.json` `tap.testFiles` (mutation-coverage gap left by #6903) and update `provider-models-route-codex.test.ts` to expect Codex client version `0.144.0` (stale assertion left by #6780's production bump). diff --git a/stryker.conf.json b/stryker.conf.json index d443617d97..0c03de6500 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -108,6 +108,7 @@ "tests/unit/cli-simulate.test.ts", "tests/unit/cline-response-envelope.test.ts", "tests/unit/clinepass-provider.test.ts", + "tests/unit/cliproxyapi-model-mapping-dispatch.test.ts", "tests/unit/codex-failover.test.ts", "tests/unit/codex-quota-selection-hydration.test.ts", "tests/unit/codex-session-affinity-reset-aware-5903.test.ts", diff --git a/tests/unit/provider-models-route-codex.test.ts b/tests/unit/provider-models-route-codex.test.ts index 42f14e78bb..c6b6ba9291 100644 --- a/tests/unit/provider-models-route-codex.test.ts +++ b/tests/unit/provider-models-route-codex.test.ts @@ -145,11 +145,11 @@ test("provider models route discovers live Codex models and preserves static ali assert.equal(body.source, "api"); assert.deepEqual(seenRequests, [ { - url: "https://chatgpt.com/backend-api/codex/models?client_version=0.142.0", + url: "https://chatgpt.com/backend-api/codex/models?client_version=0.144.0", authorization: "Bearer codex-access-token", workspaceId: "account-123", originator: "codex_cli_rs", - userAgent: "codex-cli/0.142.0 (Windows 10.0.26200; x64)", + userAgent: "codex-cli/0.144.0 (Windows 10.0.26200; x64)", }, { url: "https://raw.githubusercontent.com/openai/codex/refs/heads/main/codex-rs/models-manager/models.json",