diff --git a/tests/integration/memory-pipeline.test.ts b/tests/integration/memory-pipeline.test.ts index 06be356277..f288bb3473 100644 --- a/tests/integration/memory-pipeline.test.ts +++ b/tests/integration/memory-pipeline.test.ts @@ -252,7 +252,13 @@ test("MCP memory tools fall back to caller principal id when apiKeyId is omitted } }); +// GHSA-cpv3-xr7r-xf8q / #11040: the resolved caller principal ALWAYS wins over a +// caller-supplied `apiKeyId`, so a spoofed id in the tool arguments cannot write +// into (or read from) another principal's store. Before #11040 the explicit +// argument won and this test asserted the old behavior. test("MCP memory tools reject explicit apiKeyId that does not match caller principal", async () => { + await enableMemory(400, "hybrid"); + const prevEnvKey = process.env.OMNIROUTE_API_KEY; process.env.OMNIROUTE_API_KEY = "sk-other-principal"; try { @@ -265,14 +271,29 @@ test("MCP memory tools reject explicit apiKeyId that does not match caller princ metadata: {}, }); assert.equal(added.success, true); - assert.equal(added.data.memory.apiKeyId, "principal-b"); + // The spoofed `principal-b` is discarded; the write lands on the caller + // principal resolved from OMNIROUTE_API_KEY (the synthesized "env-key" record). + assert.equal(added.data.memory.apiKeyId, "env-key"); + // Nothing reached the spoofed principal's store. + const spoofedRows = await listMemories({ + apiKeyId: "principal-b", + sessionId: "mcp-mismatch", + }); + const spoofedList = Array.isArray(spoofedRows) ? spoofedRows : (spoofedRows.data ?? []); + assert.equal(spoofedList.length, 0); + + // It is readable by the caller itself, so the entry was redirected, not dropped — + // this also proves the search path is live (a disabled store would make the + // assertion above vacuous). const searched = await memoryTools.omniroute_memory_search.handler({ query: "cross-tenant", limit: 5, }); assert.equal(searched.success, true); - assert.equal(searched.data.count, 0); + assert.equal(searched.data.count, 1); + assert.equal(searched.data.memories[0].apiKeyId, "env-key"); + assert.equal(searched.data.memories[0].key, "pref:cross-tenant"); } finally { if (prevEnvKey === undefined) { delete process.env.OMNIROUTE_API_KEY; diff --git a/tests/integration/opencode-config-startup.test.ts b/tests/integration/opencode-config-startup.test.ts index 318bba4a3a..09161d8186 100644 --- a/tests/integration/opencode-config-startup.test.ts +++ b/tests/integration/opencode-config-startup.test.ts @@ -6,8 +6,13 @@ import path from "node:path"; import { createRequire } from "node:module"; import { after, it } from "node:test"; -const OPENCODE_VERSION = "1.18.8"; const require = createRequire(import.meta.url); +// Read the pin from the installed package instead of hard-coding it: the constant +// was frozen at 1.18.8 and silently went stale when Dependabot bumped opencode-ai +// to 1.18.18 (#10626), turning this into a base-red. Sourcing it from the resolved +// package.json keeps the assertion just as strict (the binary must report exactly +// the version this repo pins) while surviving future bumps. +const OPENCODE_VERSION: string = require("opencode-ai/package.json").version; const testHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-opencode-8849-")); const originalHome = process.env.HOME; const originalFetch = globalThis.fetch; @@ -93,10 +98,14 @@ it("#8849 generated config is accepted by pinned OpenCode schema and startup", a resolvedConfig.provider.issue8849.models["context-input-output"].limit.output, 32768 ); - assert.strictEqual( - resolvedConfig.provider.issue8849.models["no-limit-metadata"].limit, - undefined - ); + // #11035/#11032 (PR #11054) made the generator ALWAYS emit both limit keys: a model + // the catalog knows nothing about now gets the 128K context / 8K output fallbacks, + // because OpenCode's v1 provider schema rejects the whole config on a missing + // `limit.context` / `limit.output`. Before that fix the entry carried no `limit` at all. + assert.deepStrictEqual(resolvedConfig.provider.issue8849.models["no-limit-metadata"].limit, { + context: 128_000, + output: 8_192, + }); const startup = runOpencode(opencodeBinary, ["debug", "startup", "--pure"]); assert.strictEqual(startup.status, 0, startup.stderr); diff --git a/tests/integration/v1-contracts-behavior.test.ts b/tests/integration/v1-contracts-behavior.test.ts index 210452dc7f..fc1ea1cd1e 100644 --- a/tests/integration/v1-contracts-behavior.test.ts +++ b/tests/integration/v1-contracts-behavior.test.ts @@ -3,6 +3,31 @@ import assert from "node:assert/strict"; const BASE_URL = "http://localhost:20128"; +// #9320 (`fix(security): require auth for /v1/models when management auth is +// configured`) inverted the default: the `/v1` catalog reads are now gated +// whenever `isAuthRequired()` is true instead of only when +// `settings.requireAuthForModels === true`. The integration CI job sets +// `INITIAL_PASSWORD`, which flips `isAuthRequired()` on, so unauthenticated +// catalog reads answer 401 there while they answer 200 on a bare dev box. +// These are SHAPE contracts, so authenticate them with the deployment env key +// (`isConfiguredEnvApiKey` → `validateApiKey` returns true) and let +// tests/unit/v1-models-auth-leak-9320.test.ts own the auth-gate contract. +const TEST_API_KEY = "sk-v1-contracts-behavior-test-key"; +const previousEnvApiKey = process.env.OMNIROUTE_API_KEY; +process.env.OMNIROUTE_API_KEY = TEST_API_KEY; + +test.after(() => { + if (previousEnvApiKey === undefined) delete process.env.OMNIROUTE_API_KEY; + else process.env.OMNIROUTE_API_KEY = previousEnvApiKey; +}); + +function authedRequest(path: string): Request { + return new Request(`${BASE_URL}${path}`, { + method: "GET", + headers: { Authorization: `Bearer ${TEST_API_KEY}` }, + }); +} + test("contract: /api/v1 OPTIONS exposes CORS and allowed methods", async () => { const { OPTIONS } = await import("../../src/app/api/v1/route.ts"); const response = await OPTIONS(); @@ -29,8 +54,8 @@ test("contract: /api/v1 and /api/v1/models return consistent model IDs", async ( ]); const [v1Response, v1ModelsResponse] = await Promise.all([ - getV1(new Request(`${BASE_URL}/api/v1`, { method: "GET" })), - getV1Models(new Request(`${BASE_URL}/api/v1/models`, { method: "GET" })), + getV1(authedRequest("/api/v1")), + getV1Models(authedRequest("/api/v1/models")), ]); assert.equal(v1Response.status, 200); @@ -52,7 +77,7 @@ test("contract: /api/v1 and /api/v1/models return consistent model IDs", async ( test("contract: /api/v1/models returns OpenAI-compatible model shape", async () => { const { GET: getV1Models } = await import("../../src/app/api/v1/models/route.ts"); - const response = await getV1Models(new Request(`${BASE_URL}/api/v1/models`, { method: "GET" })); + const response = await getV1Models(authedRequest("/api/v1/models")); assert.equal(response.status, 200); const body = (await response.json()) as any; @@ -72,7 +97,7 @@ test("contract: /api/v1/models returns OpenAI-compatible model shape", async () test("contract: /api/v1/embeddings GET returns embedding model listing shape", async () => { const { GET: getEmbeddings } = await import("../../src/app/api/v1/embeddings/route.ts"); - const response = await getEmbeddings(); + const response = await getEmbeddings(authedRequest("/api/v1/embeddings")); assert.equal(response.status, 200); const body = (await response.json()) as any; @@ -91,7 +116,7 @@ test("contract: /api/v1/embeddings GET returns embedding model listing shape", a test("contract: /api/v1/images/generations GET returns image model listing shape", async () => { const { GET: getImageModels } = await import("../../src/app/api/v1/images/generations/route.ts"); - const response = await getImageModels(); + const response = await getImageModels(authedRequest("/api/v1/images/generations")); assert.equal(response.status, 200); const body = (await response.json()) as any;