Files
OmniRoute/tests/unit/chat-messages-entry-objects-12643.test.ts
Diego Rodrigues de Sa e Souza 872376bdc1 fix(chat): reject null/non-object entries in messages[] (#12643) (#13755)
* fix(chat): reject null/non-object entries in messages[] (#12643)

A messages array containing null (or any non-object entry, e.g. [null] or
[42]) passed every existing entry guard in chat.ts (#5110/#6402/#6407/#6412)
and reached downstream translators/session helpers that read `.role` /
`.content` directly off each entry (openai-to-claude.ts, sessionManager.ts,
contextManager.ts's fixToolPairs), crashing with a raw TypeError and
surfacing as an HTTP 500 instead of a clean 400. The route's Zod schema is
intentionally wide (z.array(z.unknown())), so this shape check belongs in
the handler's guard chain. Adds one more entry-shape guard clause to the
same chokepoint, rejecting the request with a clear 400 before any routing
or upstream call.

Regression test: tests/unit/chat-messages-entry-objects-12643.test.ts

PR #12644 (@soroush5) proposed this exact fix but was closed without
merging on 2026-09-12; this re-implements it fresh against the current tip
using the same guard shape and error message.

Originally-proposed-by: @soroush5 in #12644
Co-authored-by: soroush5 <mrsoroushahmadi@gmail.com>

* chore(quality): refix the chat.ts ceiling for the merged tree

This branch rebaselined src/sse/handlers/chat.ts against an older tip. After
merging the current release tip the combined file is 2520 lines, so the 2500
ceiling no longer covers it.

The tip alone is already at 2509 — above the 2500 this PR had frozen — so most
of the gap is inherited, not introduced here. This PR's own contribution is the
+10 of the messages-entry guard itself. Ceiling refixed at the value the gate
reports for the merged tree.

---------

Co-authored-by: soroush5 <mrsoroushahmadi@gmail.com>
2026-09-17 13:16:02 -03:00

70 lines
2.5 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts";
// Regression tests for #12643 — a `messages` array containing `null` (or any
// non-object entry) passed every entry guard and crashed translators with a
// raw TypeError (`msg.role` off null), surfacing as HTTP 500.
//
// The guard at src/sse/handlers/chat.ts now rejects non-object entries with a
// clear OmniRoute-level 400 before any routing or upstream call, extending the
// #5110/#6402/#6407/#6412 guard family.
const harness = await createChatPipelineHarness("chat-messages-entry-objects-12643");
const { handleChat, buildRequest, resetStorage, seedConnection } = harness;
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
await harness.cleanup();
});
async function postMessages(messages: unknown) {
await seedConnection("anthropic", { apiKey: "sk-ant" });
let upstreamCalled = false;
globalThis.fetch = async () => {
upstreamCalled = true;
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
};
const response = await handleChat(
buildRequest({
body: {
model: "anthropic/claude-haiku-4-5",
messages,
},
})
);
const body = (await response.json()) as { error?: { message?: string } };
return { response, body, upstreamCalled };
}
test("#12643: messages: [null] is rejected with a clear 400", async () => {
const { response, body, upstreamCalled } = await postMessages([null]);
assert.equal(response.status, 400, "null entry must be a 400, not a 500 crash");
assert.match(body.error?.message ?? "", /Expected array of objects/i);
assert.equal(upstreamCalled, false, "must not forward upstream");
});
test("#12643: messages with string/number entries are rejected with a clear 400", async () => {
for (const messages of [[{ role: "user", content: "hi" }, "oops"], [42]]) {
const { response, body, upstreamCalled } = await postMessages(messages);
assert.equal(response.status, 400, `must be a 400: ${JSON.stringify(messages)}`);
assert.match(body.error?.message ?? "", /Expected array of objects/i);
assert.equal(upstreamCalled, false, "must not forward upstream");
}
});
test("#12643: well-formed messages pass the entry guard", async () => {
const { response, body } = await postMessages([{ role: "user", content: "hi" }]);
const msg = body.error?.message ?? "";
assert.ok(!(response.status === 400 && /Expected array of objects/i.test(msg)));
});