Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions scripts/lib/orchestrator-gateway.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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" &&
Expand Down
12 changes: 10 additions & 2 deletions test/orchestrator-gateway-body-timeout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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);
Expand Down
22 changes: 12 additions & 10 deletions test/orchestrator-gateway-bounded-healthz.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
28 changes: 17 additions & 11 deletions test/orchestrator-gateway-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 },
),
Expand All @@ -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 },
),
Expand All @@ -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 },
),
Expand All @@ -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,
Expand All @@ -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 },
);
Expand All @@ -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 },
),
Expand Down Expand Up @@ -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 },
),
Expand Down Expand Up @@ -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 },
),
Expand Down Expand Up @@ -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 },
),
Expand All @@ -467,4 +473,4 @@ describe("contextual-orchestrator gateway contract", () => {
expect(emptyProcessStderr.join("")).toMatch(/absolute HTTPS URL/);
process.exitCode = previousProcessExit;
});
});
});
85 changes: 85 additions & 0 deletions test/orchestrator-gateway-json-integrity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
});
});
7 changes: 6 additions & 1 deletion test/orchestrator-gateway-residual-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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",
},
});
Expand Down
2 changes: 1 addition & 1 deletion test/orchestrator-gateway-secret-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } },
);
}

Expand Down
Loading
Loading