Files
OmniRoute/tests/unit/api-malformed-json-400.test.ts
Diego Rodrigues de Sa e Souza 3d4f3e4960 test(infra): retry recursive temp-dir removal instead of failing a shard on ENOTEMPTY (#11966) (#11968)
* test(infra): retry recursive temp-dir removal instead of failing a shard on ENOTEMPTY (#11966)

Two shards on release/v3.8.51 went red in one day with the same signature —
"ENOTEMPTY, Directory not empty: /tmp/omniroute-<test>-XXXXXX" — from
combo-same-provider-cascade (Unit Tests fast-path 4/4, on a PR that touches only
.github/) and auth-policy-embeddings-webfetch-7785 (the 20k-test TIA step). Both pass
alone and on re-run: the cleanup races something still writing into the directory
(SQLite WAL/-shm checkpoint, a worker, the backup) and under a loaded hosted runner
the window opens. 1154 test files do their own cleanup with
fs.rmSync(dir, { recursive: true, force: true }); 57 already asked for retries.

One-shot codemod (scripts/ad-hoc/codemod-rm-maxretries.mjs, kept for the record):
every rm / rmSync / rmdirSync option object with `recursive: true` and no
`maxRetries` gains `maxRetries: 5, retryDelay: 100` — Node itself then retries
ENOTEMPTY/EBUSY/EPERM for up to ~0.5 s before giving up. 2243 call sites in 1292
files under tests/, the shared tests/_setup/isolateDataDir.ts exit hook included.
Only the option object changes: no call site, assertion or import is touched.

Validation: prettier and ESLint (with the frozen suppressions) clean on all 1292
files; a random 20-file sample runs green (quota-redis-store hangs identically on
the untouched tree — it needs a Redis on localhost, an environment matter). The
four unit shards on this PR are the full run.

* fix(quality): let check-forgotten-sibling-tests read a 1,000-file diff

The gate shells out to `git diff` through execFileSync with Node's default 1 MB
maxBuffer; the 1,292-file codemod in this PR is the first diff large enough to
overflow it, and the gate died with `spawnSync git ENOBUFS` before comparing
anything. 64 MB is far above any real PR and costs nothing when unused.
2026-08-29 01:17:40 -03:00

174 lines
8.1 KiB
TypeScript

/**
* 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, maxRetries: 5, retryDelay: 100 });
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"
);
});