Files
OmniRoute/tests/unit/authz/client-api-policy-fallback.test.ts
Diego Rodrigues de Sa e Souza 8169b97d84 Release v3.8.18 (#3482)
* chore(release): open v3.8.18 development cycle

* fix(catalog): stop Codex CLI model-catalog refresh from erroring (#3481)

Codex's model-catalog refresh (codex_models_manager) does
GET /v1/models?client_version=<v> and decodes a JSON object with a
TOP-LEVEL `models` array. OmniRoute answers in the OpenAI-standard
`{object,data}` shape, so codex fails with "missing field `models`"
and logs "failed to refresh available models" on every startup.

Detect codex clients via the `originator` / `user-agent` = `codex_*`
headers they send and add an EMPTY top-level `models: []` so the decode
succeeds. Non-codex OpenAI clients keep the byte-identical `{object,data}`
response.

The array is intentionally empty: codex replaces its built-in per-model
agent prompt (`base_instructions`, ~21k chars) with whatever a populated
entry carries for the selected model, so emitting our catalog would drop
the agent prompt to nothing and break codex's agent behaviour (verified
empirically against codex 0.137). An empty list keeps codex on its
built-in model info — same inference as before, minus the error.

Validated end-to-end with the real handler against codex 0.137:
"failed to refresh available models" → 0 occurrences, instructions
preserved (built-in Codex agent prompt, not empty).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: ignore quality reports and local prompt artifacts

Add generated quality gate reports, metrics files, and local setup prompt
artifacts to .gitignore to prevent committing environment-specific or
temporary files.

* fix(provider): detect Responses API format when body has `input` but … (#3490)

Integrated into release/v3.8.18

* fix(sse): normalize numeric provider ids to strings (#3451)

Integrated into release/v3.8.18

* feat(browserPool): resolve Playwright proxy from proxy_registry DB (#3492)

Integrated into release/v3.8.18

* fix(theoldllm): generate X-Request-Token server-side, drop Playwright (#3491)

Integrated into release/v3.8.18

* feat(plugins): add lifecycle hooks and theme-manager plugin (#3473)

Integrated into release/v3.8.18

* fix(combo): parallel pre-screen + circuit-breaker fast-exit for priority combos (#3169)

Integrated into release/v3.8.18

* feat(ui): unifi active and finished requests into single view #1422 (#3401)

Integrated into release/v3.8.18

* docs(changelog): record #3401, #3473, #3492, #3490, #3451, #3491, #3169 under v3.8.18

* feat(docs): add doc accuracy gate + refresh AGENTS.md counts (#3510)

Integrated into release/v3.8.18

* fix(sse): drop empty-choices chunks without usage instead of injecting retry text (#3513)

PR #3422 ('allow OpenAI usage-only empty choices chunks') reintroduced the
assistant-content injection '[OmniRoute] Upstream returned an empty response.
Please retry.' for empty `choices: []` chunks that carry no valid usage. Clients
(Goose/opencode) feed that text back as a turn and spin in a retry loop -- the
exact regression #3400 had fixed by dropping the chunk.

Restore the drop behavior for the no-usage case while preserving #3422's
standards-compliant forwarding of usage-only `include_usage` final chunks.
Realign the mislabeled stream-utils test (it asserted the injection) and add a
dedicated regression guard.

Reported-by: @mochizzan
Refs: #3502, #3388, #3400, #3422

* fix(authz): fall back to URL token when Authorization isn't a usable Bearer (#3504)

Integrated into release/v3.8.18

* fix(playground): authenticate via session, test key policy by id (#3503)

Integrated into release/v3.8.18

* docs(changelog): record #3510, #3504, #3503 under v3.8.18

* fix: llama base url normalization (#3519)

* docs(changelog): reconcile v3.8.18 — add #3519, #3513, #3435-repair, gitignore chore (full commit↔changelog coverage)

* fix(opencode-plugin): bound regex quantifiers in normaliseFreeLabel (polynomial-ReDoS)

CodeQL js/polynomial-redos: unbounded \s* before an anchored \s*$ allowed
O(n²) backtracking on attacker-influenced display names. Bounded to {0,8}/{1,8}
(ample for any real label spacing). Plugin builds + 254 tests green.

* fix(types): restore clean typecheck:core for v3.8.18 release gate

- getPendingRequests() typed to real shape (was widened to object) → fixes
  unknown 'count' in the unified-requests view (#3401)
- streamChunks log payload cast to its declared type (callLogs.ts)
- preScreenTargets aligned to canonical IsModelAvailable signature (#3169),
  Promise.resolve-normalized so .catch never hits a bare boolean

All 5 gates green: lint(0 err) + typecheck:core + cycles + docs-all + unit + vitest(146).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Andrey Borodulin <borodulin@gmail.com>
Co-authored-by: Dmitrii Safronov <zimniy@cyberbrain.cc>
Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com>
Co-authored-by: PizzaV <103120356+pizzav-xyz@users.noreply.github.com>
Co-authored-by: Markus Hartung <mail@hartmark.se>
Co-authored-by: Felipe Almeman <4226997+zhiru@users.noreply.github.com>
2026-06-09 15:56:24 -03:00

271 lines
10 KiB
TypeScript

/**
* Issue #2257 — clientApi policy behavior when an invalid Bearer is sent and
* REQUIRE_API_KEY=false.
*
* The existing `client-api-policy.test.ts` shares a DB-backed setup via
* `resetStorage()` and `apiKeysDb` that has SQLite migration races on this
* branch. This standalone file mocks `validateApiKey` to test the policy's
* fallback branch in isolation — no DB, no migration runner.
*/
import test from "node:test";
import assert from "node:assert/strict";
import Module from "node:module";
// ─── Mock validateApiKey via require interception (so the dynamic import in
// the policy module returns our stub instead of hitting the real DB module) ─
type ValidateFn = (key: string) => boolean | Promise<boolean>;
let mockValidateApiKey: ValidateFn = () => false;
const originalResolve = (Module as unknown as { _resolveFilename: typeof Module._resolveFilename })
._resolveFilename;
// Intercept require() / import() resolution for the apiKeys DB module and
// substitute it for our stub. This runs only for the exact path the policy
// imports — production code paths are unaffected.
const POLICY_IMPORT_TARGET = "src/lib/db/apiKeys";
(Module as unknown as { _resolveFilename: typeof Module._resolveFilename })._resolveFilename =
function patched(this: unknown, request: string, ...rest: unknown[]) {
if (request.includes(POLICY_IMPORT_TARGET)) {
// Resolve to a stub file we create below
const stubPath = new URL("./__stub_apiKeys.mjs", import.meta.url).pathname;
// @ts-expect-error - rest spread to original
return originalResolve.call(this, stubPath, ...rest);
}
// @ts-expect-error - rest spread to original
return originalResolve.call(this, request, ...rest);
};
// Write the stub file ad-hoc (Node's loader needs a real file)
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-clientapi-policy-fallback-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const STUB_PATH = path.join(__dirname, "__stub_apiKeys.mjs");
fs.writeFileSync(
STUB_PATH,
`export const validateApiKey = (key) => globalThis.__mockValidateApiKey(key);\n`
);
// Wire the stub to our local variable
(globalThis as unknown as { __mockValidateApiKey: ValidateFn }).__mockValidateApiKey = (key) =>
mockValidateApiKey(key);
test.after(() => {
try {
fs.unlinkSync(STUB_PATH);
} catch {
/* ignore */
}
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
});
// ─── Load policy fresh (after the interceptor is in place) ────────────────
async function loadPolicy() {
const mod = await import(`../../../src/server/authz/policies/clientApi.ts?ts=${Date.now()}`);
return mod.clientApiPolicy;
}
function ctx(headers: Headers, normalizedPath = "/api/v1/chat/completions") {
return {
request: { method: "POST", headers, url: `http://localhost${normalizedPath}` },
classification: {
routeClass: "CLIENT_API" as const,
reason: "client_api_v1" as const,
normalizedPath,
},
requestId: "req_test",
};
}
// ─── Tests ────────────────────────────────────────────────────────────────
test.beforeEach(() => {
// Default to "every key fails" — individual tests override as needed.
mockValidateApiKey = () => false;
delete process.env.REQUIRE_API_KEY;
});
test("#2257 — invalid bearer + REQUIRE_API_KEY=true → 401", async () => {
process.env.REQUIRE_API_KEY = "true";
const policy = await loadPolicy();
const headers = new Headers({ authorization: "Bearer sk-stub-bogus" });
const out = await policy.evaluate(ctx(headers));
assert.equal(out.allow, false);
if (!out.allow) {
assert.equal(out.status, 401);
assert.equal(out.code, "AUTH_002");
}
});
test("#2257 — invalid bearer + REQUIRE_API_KEY=false → anonymous (with warning log)", async () => {
const originalWarn = console.warn;
const warnings: string[] = [];
console.warn = (msg: string) => warnings.push(String(msg));
try {
const policy = await loadPolicy();
const headers = new Headers({ authorization: "Bearer sk-stub-bogus" });
const out = await policy.evaluate(ctx(headers));
assert.equal(out.allow, true);
if (out.allow) {
assert.equal(out.subject.kind, "anonymous");
assert.equal(out.subject.id, "local");
}
assert.ok(
warnings.some((w) => w.includes("[clientApiPolicy]") && w.includes("REQUIRE_API_KEY=false")),
"expected a warning about the fallback"
);
} finally {
console.warn = originalWarn;
}
});
test("#2257 — invalid x-api-key + REQUIRE_API_KEY=false → anonymous (with warning log)", async () => {
const originalWarn = console.warn;
const warnings: string[] = [];
console.warn = (msg: string) => warnings.push(String(msg));
try {
const policy = await loadPolicy();
const headers = new Headers({ "x-api-key": "sk-stub-bogus" });
const out = await policy.evaluate(ctx(headers));
assert.equal(out.allow, true);
if (out.allow) {
assert.equal(out.subject.kind, "anonymous");
assert.equal(out.subject.id, "local");
}
assert.ok(
warnings.some((w) => w.includes("[clientApiPolicy]") && w.includes("REQUIRE_API_KEY=false")),
"expected a warning about the fallback"
);
} finally {
console.warn = originalWarn;
}
});
test("#2257 — fallback warning masks the x-api-key (only last-4 in log)", async () => {
const originalWarn = console.warn;
const warnings: string[] = [];
console.warn = (msg: string) => warnings.push(String(msg));
try {
const policy = await loadPolicy();
const headers = new Headers({ "x-api-key": "sk-secretprefix-secretmiddle-XYZW" });
const out = await policy.evaluate(ctx(headers));
assert.equal(out.allow, true);
assert.ok(
warnings.every((w) => !w.includes("secretprefix") && !w.includes("secretmiddle")),
"warning leaked the full bearer; only masked key id should be logged"
);
assert.ok(
warnings.some((w) => w.includes("key_XYZW")),
"expected masked key id (last-4) in the warning"
);
} finally {
console.warn = originalWarn;
}
});
test("#2257 — fallback warning masks the bearer (only last-4 in log)", async () => {
const originalWarn = console.warn;
const warnings: string[] = [];
console.warn = (msg: string) => warnings.push(String(msg));
try {
const policy = await loadPolicy();
const headers = new Headers({ authorization: "Bearer sk-secretprefix-secretmiddle-XYZW" });
const out = await policy.evaluate(ctx(headers));
assert.equal(out.allow, true);
assert.ok(
warnings.every((w) => !w.includes("secretprefix") && !w.includes("secretmiddle")),
"warning leaked the full bearer; only masked key id should be logged"
);
assert.ok(
warnings.some((w) => w.includes("key_XYZW")),
"expected masked key id (last-4) in the warning"
);
} finally {
console.warn = originalWarn;
}
});
test("#2257 — no bearer + REQUIRE_API_KEY=false → anonymous (unchanged, no fallback warning)", async () => {
const originalWarn = console.warn;
const warnings: string[] = [];
console.warn = (msg: string) => warnings.push(String(msg));
try {
const policy = await loadPolicy();
const out = await policy.evaluate(ctx(new Headers()));
assert.equal(out.allow, true);
if (out.allow) {
assert.equal(out.subject.kind, "anonymous");
}
// No warning should fire when no bearer is sent in the first place —
// the warning is specifically for the "invalid-bearer-fell-through" case.
assert.ok(
warnings.every((w) => !w.includes("[clientApiPolicy]")),
"no fallback warning expected when no bearer was sent"
);
} finally {
console.warn = originalWarn;
}
});
// ─── #3504 — non-usable Authorization must NOT short-circuit the URL path token ─
// VS Code Copilot sends its own (empty / non-OmniRoute) Authorization header even
// when the OmniRoute key lives in the URL path of a /vscode tokenized endpoint.
// A non-"Bearer <token>" Authorization must fall through to the URL token instead
// of returning null and 401'ing under REQUIRE_API_KEY=true.
// validateApiKey is the real (no-DB → always-false) implementation here, so we
// distinguish "URL token was extracted" from "no token found" by the rejection
// MESSAGE: an extracted-but-unknown token → "Invalid API key"; nothing extracted
// → "Authentication required". On the pre-fix code a non-Bearer Authorization
// returned null, so these would all 401 with "Authentication required".
test("#3504 — empty 'Bearer ' Authorization falls through to the URL path token", async () => {
process.env.REQUIRE_API_KEY = "true";
const policy = await loadPolicy();
const headers = new Headers({ authorization: "Bearer " });
const out = await policy.evaluate(
ctx(headers, "/api/v1/vscode/sk-url-token/chat/completions")
);
assert.equal(out.allow, false);
if (!out.allow) {
assert.equal(out.status, 401);
assert.equal(
out.message,
"Invalid API key",
"URL token must be extracted (→ 'Invalid API key'), not skipped (→ 'Authentication required')"
);
}
});
test("#3504 — a non-Bearer scheme (Basic) also falls through to the URL token", async () => {
process.env.REQUIRE_API_KEY = "true";
const policy = await loadPolicy();
const headers = new Headers({ authorization: "Basic Zm9vOmJhcg==" });
const out = await policy.evaluate(
ctx(headers, "/api/v1/vscode/sk-url-token/chat/completions")
);
assert.equal(out.allow, false);
if (!out.allow) assert.equal(out.message, "Invalid API key");
});
test("#3504 — non-Bearer Authorization with NO URL token still rejects as unauthenticated", async () => {
process.env.REQUIRE_API_KEY = "true";
const policy = await loadPolicy();
const headers = new Headers({ authorization: "Bearer " });
const out = await policy.evaluate(ctx(headers, "/api/v1/chat/completions"));
assert.equal(out.allow, false);
if (!out.allow) assert.equal(out.message, "Authentication required");
});