diff --git a/scripts/lib/orchestrator-gateway.mjs b/scripts/lib/orchestrator-gateway.mjs index 2204fef41..4b8ced9de 100644 --- a/scripts/lib/orchestrator-gateway.mjs +++ b/scripts/lib/orchestrator-gateway.mjs @@ -274,9 +274,10 @@ export function requireOrchestratorApiKey(rawKey) { /** * Fetch `/healthz` without a bearer token and require the orchestrator identity. * - * The response body is always bounded by byte count. When the caller supplies - * `timeoutMs`, that explicit deadline also covers request and body reads. Noema - * does not invent a default availability deadline for contextual-orchestrator. + * The successful response must identify itself as `application/json`; its body + * is always bounded by byte count. When the caller supplies `timeoutMs`, that + * explicit deadline also covers request and body reads. Noema does not invent + * a default availability deadline for contextual-orchestrator. * * @param {string} healthzUrl Absolute health URL derived from the `/v1` base. * @param {{ fetchImpl?: typeof fetch, timeoutMs?: number }} [options] @@ -334,6 +335,20 @@ export async function verifyOrchestratorHealthz(healthzUrl, options = {}) { `contextual-orchestrator health response status is ${response.status}`, ); } + const mediaType = String(response.headers?.get?.("content-type") ?? "") + .split(";", 1)[0] + .trim() + .toLowerCase(); + if (mediaType !== "application/json") { + try { + void response.body?.cancel?.().catch(() => undefined); + } catch { + // Cancellation is cleanup only after the media-type decision is final. + } + throw new Error( + "contextual-orchestrator health response content-type is not application/json", + ); + } const advertisedLength = response.headers?.get?.("content-length"); if ( typeof advertisedLength === "string" && diff --git a/test/orchestrator-gateway-body-timeout.test.ts b/test/orchestrator-gateway-body-timeout.test.ts index ad699be6c..da6ad7dd6 100644 --- a/test/orchestrator-gateway-body-timeout.test.ts +++ b/test/orchestrator-gateway-body-timeout.test.ts @@ -6,6 +6,14 @@ afterEach(() => { vi.useRealTimers(); }); +function jsonHeaders() { + return { + get(name: string) { + return name.toLowerCase() === "content-type" ? "application/json" : null; + }, + }; +} + describe("contextual-orchestrator health body timeout", () => { it("keeps an explicit caller timeout active while reading a stalled response body", async () => { let cancelled = false; @@ -24,7 +32,7 @@ describe("contextual-orchestrator health body timeout", () => { const response = { ok: true, status: 200, - headers: { get: () => null }, + headers: jsonHeaders(), body: { getReader: () => reader }, } as unknown as Response; @@ -66,7 +74,7 @@ describe("contextual-orchestrator health body timeout", () => { resolveFetch({ ok: true, status: 200, - headers: { get: () => null }, + headers: jsonHeaders(), body: null, arrayBuffer: async () => encoded.buffer, } as unknown as Response); diff --git a/test/orchestrator-gateway-bounded-healthz.test.ts b/test/orchestrator-gateway-bounded-healthz.test.ts index 519e41dcc..3f021c62e 100644 --- a/test/orchestrator-gateway-bounded-healthz.test.ts +++ b/test/orchestrator-gateway-bounded-healthz.test.ts @@ -2,17 +2,23 @@ import { describe, expect, it } from "vitest"; import { verifyOrchestratorHealthz } from "../scripts/lib/orchestrator-gateway.mjs"; +function jsonHeaders(contentLength: string | null = null) { + return { + get(name: string) { + if (name.toLowerCase() === "content-type") return "application/json"; + if (name.toLowerCase() === "content-length") return contentLength; + return null; + }, + }; +} + describe("contextual-orchestrator bounded health response", () => { it("rejects an advertised oversized body before materializing it", async () => { let materialized = false; const response = { ok: true, status: 200, - headers: { - get(name: string) { - return name.toLowerCase() === "content-length" ? "65537" : null; - }, - }, + headers: jsonHeaders("65537"), async arrayBuffer() { materialized = true; return new Uint8Array(65_537).buffer; @@ -32,11 +38,7 @@ describe("contextual-orchestrator bounded health response", () => { const response = { ok: true, status: 200, - headers: { - get() { - return null; - }, - }, + headers: jsonHeaders(), async arrayBuffer() { materialized += 1; return new Uint8Array(65_537).buffer; diff --git a/test/orchestrator-gateway-contract.test.ts b/test/orchestrator-gateway-contract.test.ts index ead582091..582f1b3dc 100644 --- a/test/orchestrator-gateway-contract.test.ts +++ b/test/orchestrator-gateway-contract.test.ts @@ -52,6 +52,12 @@ function publicRepositoryEventFile(): string { return path; } +function jsonResponse(body: BodyInit | null, init: ResponseInit = {}): Response { + const headers = new Headers(init.headers); + headers.set("content-type", "application/json"); + return new Response(body, { ...init, headers }); +} + describe("contextual-orchestrator gateway contract", () => { it("accepts an HTTPS /v1 URL and derives /healthz", () => { const parsed = parseOrchestratorGatewayUrl( @@ -162,7 +168,7 @@ describe("contextual-orchestrator gateway contract", () => { NOEMA_LLM_API_URL: "https://orchestrator.example/v1", NOEMA_LLM_MODEL: "orchestrator/free", }, - fetchImpl: async () => new Response( + fetchImpl: async () => jsonResponse( JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), { status: 200 }, ), @@ -173,7 +179,7 @@ describe("contextual-orchestrator gateway contract", () => { env: { NOEMA_LLM_API_URL: "https://orchestrator.example/v1", }, - fetchImpl: async () => new Response( + fetchImpl: async () => jsonResponse( JSON.stringify({ status: "ok", service: "openai" }), { status: 200 }, ), @@ -193,7 +199,7 @@ describe("contextual-orchestrator gateway contract", () => { env: { NOEMA_LLM_API_URL: "https://orchestrator.example/v1/", }, - fetchImpl: async () => new Response( + fetchImpl: async () => jsonResponse( JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), { status: 200 }, ), @@ -211,10 +217,10 @@ describe("contextual-orchestrator gateway contract", () => { fetchImpl: async () => new Response("nope", { status: 503 }), })).rejects.toThrow(/status is 503/); await expect(verifyOrchestratorHealthz("https://orchestrator.example/healthz", { - fetchImpl: async () => new Response("{", { status: 200 }), + fetchImpl: async () => jsonResponse("{", { status: 200 }), })).rejects.toThrow(/is not JSON/); await expect(verifyOrchestratorHealthz("https://orchestrator.example/healthz", { - fetchImpl: async () => new Response("x".repeat(65_537), { status: 200 }), + fetchImpl: async () => jsonResponse("x".repeat(65_537), { status: 200 }), })).rejects.toThrow(/too large/); await expect(verifyOrchestratorHealthz("https://orchestrator.example/healthz", { fetchImpl: "not-a-function" as unknown as typeof fetch, @@ -229,7 +235,7 @@ describe("contextual-orchestrator gateway contract", () => { })).rejects.toThrow(/health request failed/); const previousFetch = globalThis.fetch; - globalThis.fetch = async () => new Response( + globalThis.fetch = async () => jsonResponse( JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), { status: 200 }, ); @@ -243,7 +249,7 @@ describe("contextual-orchestrator gateway contract", () => { } await expect(verifyOrchestratorGatewayContract({ - fetchImpl: async () => new Response( + fetchImpl: async () => jsonResponse( JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), { status: 200 }, ), @@ -366,7 +372,7 @@ describe("contextual-orchestrator gateway contract", () => { NOEMA_LLM_API_URL: "https://orchestrator.example/v1", NOEMA_LLM_MODEL: "orchestrator/free", }, - fetchImpl: async () => new Response( + fetchImpl: async () => jsonResponse( JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), { status: 200 }, ), @@ -398,7 +404,7 @@ describe("contextual-orchestrator gateway contract", () => { env: { NOEMA_LLM_API_URL: "https://orchestrator.example/v1", }, - fetchImpl: async () => new Response( + fetchImpl: async () => jsonResponse( JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), { status: 200 }, ), @@ -449,7 +455,7 @@ describe("contextual-orchestrator gateway contract", () => { env: { NOEMA_LLM_API_URL: "https://orchestrator.example/v1", }, - fetchImpl: async () => new Response( + fetchImpl: async () => jsonResponse( JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), { status: 200 }, ), @@ -467,4 +473,4 @@ describe("contextual-orchestrator gateway contract", () => { expect(emptyProcessStderr.join("")).toMatch(/absolute HTTPS URL/); process.exitCode = previousProcessExit; }); -}); +}); \ No newline at end of file diff --git a/test/orchestrator-gateway-json-integrity.test.ts b/test/orchestrator-gateway-json-integrity.test.ts index 090c7c278..b9d77dec9 100644 --- a/test/orchestrator-gateway-json-integrity.test.ts +++ b/test/orchestrator-gateway-json-integrity.test.ts @@ -34,4 +34,89 @@ describe("contextual-orchestrator health JSON integrity", () => { }), ).rejects.toThrow(/duplicate decoded JSON keys/); }); + + it("rejects a valid identity document served under a non-JSON media type", async () => { + const response = new Response( + JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), + { + status: 200, + headers: { "content-type": "text/plain; charset=utf-8" }, + }, + ); + + await expect( + verifyOrchestratorHealthz("https://orchestrator.example/healthz", { + fetchImpl: (async () => response) as typeof fetch, + }), + ).rejects.toThrow( + "contextual-orchestrator health response content-type is not application/json", + ); + }); + + it("keeps the media-type rejection when response cancellation rejects asynchronously", async () => { + let cancelCalled = false; + const response = { + ok: true, + status: 200, + headers: { + get(name: string) { + return name.toLowerCase() === "content-type" ? "text/plain" : null; + }, + }, + body: { + cancel() { + cancelCalled = true; + return Promise.reject(new Error("cleanup transport failed")); + }, + }, + } as unknown as Response; + + await expect( + verifyOrchestratorHealthz("https://orchestrator.example/healthz", { + fetchImpl: (async () => response) as typeof fetch, + }), + ).rejects.toThrow( + "contextual-orchestrator health response content-type is not application/json", + ); + await Promise.resolve(); + expect(cancelCalled).toBe(true); + }); + + it("rejects a health identity when the media type is missing", async () => { + const body = JSON.stringify({ + status: "ok", + service: "contextual-orchestrator", + }); + const response = { + ok: true, + status: 200, + headers: { get: () => null }, + body: null, + arrayBuffer: async () => new TextEncoder().encode(body).buffer, + } as unknown as Response; + + await expect( + verifyOrchestratorHealthz("https://orchestrator.example/healthz", { + fetchImpl: (async () => response) as typeof fetch, + }), + ).rejects.toThrow( + "contextual-orchestrator health response content-type is not application/json", + ); + }); + + it("accepts application/json with media-type parameters", async () => { + const response = new Response( + JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), + { + status: 200, + headers: { "content-type": "application/json; charset=utf-8" }, + }, + ); + + await expect( + verifyOrchestratorHealthz("https://orchestrator.example/healthz", { + fetchImpl: (async () => response) as typeof fetch, + }), + ).resolves.toEqual({ status: "ok", service: "contextual-orchestrator" }); + }); }); diff --git a/test/orchestrator-gateway-residual-coverage.test.ts b/test/orchestrator-gateway-residual-coverage.test.ts index cf53602d9..0ada45f3a 100644 --- a/test/orchestrator-gateway-residual-coverage.test.ts +++ b/test/orchestrator-gateway-residual-coverage.test.ts @@ -18,7 +18,11 @@ function responseLike(input: { ok: true, status: 200, headers: { - get: () => input.contentLength ?? null, + get(name: string) { + if (name.toLowerCase() === "content-type") return "application/json"; + if (name.toLowerCase() === "content-length") return input.contentLength ?? null; + return null; + }, }, body: input.body ?? null, arrayBuffer: input.arrayBuffer ?? (async () => new TextEncoder().encode(healthyPayload).buffer), @@ -34,6 +38,7 @@ describe("contextual-orchestrator residual health coverage", () => { const response = new Response(healthyPayload, { status: 200, headers: { + "content-type": "application/json", "content-length": "not-a-decimal-length", }, }); diff --git a/test/orchestrator-gateway-secret-source.test.ts b/test/orchestrator-gateway-secret-source.test.ts index 2e1ffb23f..524dfa097 100644 --- a/test/orchestrator-gateway-secret-source.test.ts +++ b/test/orchestrator-gateway-secret-source.test.ts @@ -13,7 +13,7 @@ import { function healthyResponse(): Response { return new Response( JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), - { status: 200 }, + { status: 200, headers: { "content-type": "application/json" } }, ); } diff --git a/test/orchestrator-gateway-stream-bound.test.ts b/test/orchestrator-gateway-stream-bound.test.ts index ec968a7fb..902c8faff 100644 --- a/test/orchestrator-gateway-stream-bound.test.ts +++ b/test/orchestrator-gateway-stream-bound.test.ts @@ -18,6 +18,16 @@ async function settleWithin(promise: Promise, timeoutMs = 100): Promise { it("stops a chunked response at the byte ceiling without arrayBuffer materialization", async () => { let readCount = 0; @@ -45,7 +55,7 @@ describe("contextual-orchestrator streamed health response", () => { const response = { ok: true, status: 200, - headers: { get: () => null }, + headers: jsonHeaders(), body: { getReader: () => reader }, async arrayBuffer() { arrayBufferCalled = true; @@ -83,7 +93,7 @@ describe("contextual-orchestrator streamed health response", () => { const response = { ok: true, status: 200, - headers: { get: () => null }, + headers: jsonHeaders(), body: { getReader: () => reader }, } as unknown as Response; @@ -119,7 +129,7 @@ describe("contextual-orchestrator streamed health response", () => { const response = { ok: true, status: 200, - headers: { get: () => null }, + headers: jsonHeaders(), body: { getReader: () => reader }, } as unknown as Response; @@ -156,7 +166,7 @@ describe("contextual-orchestrator streamed health response", () => { const response = { ok: true, status: 200, - headers: { get: () => "65537" }, + headers: jsonHeaders("65537"), body: { cancel() { cancellationStarted = true; @@ -184,7 +194,7 @@ describe("contextual-orchestrator streamed health response", () => { const response = { ok: true, status: 200, - headers: { get: () => "65537" }, + headers: jsonHeaders("65537"), body: { cancel() { throw new Error("cleanup transport failed"); @@ -220,7 +230,7 @@ describe("contextual-orchestrator streamed health response", () => { const response = { ok: true, status: 200, - headers: { get: () => null }, + headers: jsonHeaders(), body: { getReader: () => reader }, async arrayBuffer() { throw new Error("streaming response must not fall back to arrayBuffer");