Files
OmniRoute/tests/unit/chat-content-type-6414.test.ts
Chirag Singhal 1b6a1f08c7 fix(api): return 415 when /v1/chat/completions receives non-JSON Content-Type (#6414) (#6513)
fix(api): return 415 on /v1/messages for non-JSON Content-Type via requireJsonContentType middleware (#6414) Integrated into release/v3.8.47. (thanks @chirag127)
2026-07-07 20:36:32 -03:00

90 lines
3.4 KiB
TypeScript

// #6414: POST /v1/chat/completions (and /v1/messages) must return HTTP 415
// `unsupported_media_type` when the Content-Type header is not application/json,
// matching OpenAI's reference API and RFC 7231 §6.5.13. Previously OmniRoute
// silently parsed such requests as JSON via `.clone().json().catch(() => null)`
// and let them reach the provider-lookup layer, where they surfaced as misleading
// `model_not_found` / generic errors instead of a boundary 415.
import test from "node:test";
import assert from "node:assert/strict";
const { requireJsonContentType } = await import(
"../../src/shared/middleware/requireJsonContentType.ts"
);
function makeRequest(method: string, contentType: string | null): Request {
const headers = new Headers();
if (contentType !== null) headers.set("content-type", contentType);
return new Request("http://localhost/v1/chat/completions", {
method,
headers,
body: method === "GET" || method === "HEAD" ? undefined : "{}",
});
}
test("returns 415 for text/plain on POST", async () => {
const rejection = requireJsonContentType(makeRequest("POST", "text/plain"));
assert.ok(rejection, "expected a rejection Response");
assert.equal(rejection.status, 415);
const body = await rejection.json();
assert.equal(body.error.code, "unsupported_media_type");
assert.equal(body.error.type, "invalid_request_error");
});
test("returns 415 when Content-Type header is missing on POST", async () => {
const rejection = requireJsonContentType(makeRequest("POST", null));
assert.ok(rejection, "missing content-type must be rejected");
assert.equal(rejection.status, 415);
});
test("returns 415 for application/x-www-form-urlencoded on POST", async () => {
const rejection = requireJsonContentType(
makeRequest("POST", "application/x-www-form-urlencoded")
);
assert.ok(rejection);
assert.equal(rejection.status, 415);
});
test("admits application/json", () => {
assert.equal(requireJsonContentType(makeRequest("POST", "application/json")), null);
});
test("admits application/json with charset suffix", () => {
assert.equal(
requireJsonContentType(makeRequest("POST", "application/json; charset=utf-8")),
null
);
});
test("admits Application/JSON (case-insensitive)", () => {
assert.equal(requireJsonContentType(makeRequest("POST", "Application/JSON")), null);
});
test("admits application/json with leading whitespace", () => {
assert.equal(requireJsonContentType(makeRequest("POST", " application/json")), null);
});
test("does not touch GET requests (no body → nothing to reject)", () => {
assert.equal(requireJsonContentType(makeRequest("GET", "text/plain")), null);
});
test("does not touch OPTIONS preflight", () => {
assert.equal(requireJsonContentType(makeRequest("OPTIONS", null)), null);
});
test("guards PUT and PATCH the same as POST", () => {
const putRejection = requireJsonContentType(makeRequest("PUT", "text/plain"));
assert.ok(putRejection);
assert.equal(putRejection.status, 415);
const patchRejection = requireJsonContentType(makeRequest("PATCH", "text/plain"));
assert.ok(patchRejection);
assert.equal(patchRejection.status, 415);
});
test("rejection carries CORS + JSON headers", () => {
const rejection = requireJsonContentType(makeRequest("POST", "text/plain"));
assert.ok(rejection);
assert.equal(rejection.headers.get("Content-Type"), "application/json");
assert.ok(rejection.headers.get("Access-Control-Allow-Methods"));
});