mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Release v3.8.41 (#5327)
Release v3.8.41 — 52 commits since v3.8.40 (19 CHANGELOG bullets, 11 contributors). All gating CI green: Unit×8, Coverage×8, Vitest, Package Artifact, Quality Ratchet, CodeQL, Lint, Docs Sync (Strict), Node 24/26 compat, E2E×9, Integration, Electron smoke. Advisory checks overridden (main unprotected): PR Test Policy = test-masking heuristic on the cumulative 52-commit assert delta (legitimate dead-code-sweep removals + consolidations, reviewed per-PR); SonarCloud/SonarQube = new-code maintainability/coverage quality gate (CodeQL/Semgrep/Security/npm-audit/Dependabot all clean — not a security finding).
This commit is contained in:
committed by
GitHub
parent
7c23dab64d
commit
78f09c8d9f
@@ -1149,6 +1149,9 @@ test("isCreditsExhausted returns true for actual credits-exhausted signals", ()
|
||||
assert.equal(isCreditsExhausted("payment required"), true);
|
||||
assert.equal(isCreditsExhausted("free tier of the model has been exhausted"), true);
|
||||
assert.equal(isCreditsExhausted("exceeded your current usage quota"), true);
|
||||
// #5239: "Insufficient account balance" out-of-credit bodies
|
||||
assert.equal(isCreditsExhausted("Insufficient account balance"), true);
|
||||
assert.equal(isCreditsExhausted("insufficient_balance"), true);
|
||||
});
|
||||
|
||||
test("CREDITS_EXHAUSTED_SIGNALS no longer contains generic gRPC resource-exhausted patterns", () => {
|
||||
|
||||
@@ -234,6 +234,13 @@ import {
|
||||
getActiveStreams,
|
||||
archiveStream,
|
||||
} from "../../src/sse/services/streamState.ts";
|
||||
import * as streamStateModule from "../../src/sse/services/streamState.ts";
|
||||
|
||||
test("stream state public surface excludes removed completed-history accessor", () => {
|
||||
assert.equal(Object.hasOwn(streamStateModule, "getRecentCompletedStreams"), false);
|
||||
assert.equal(typeof streamStateModule.getActiveStreams, "function");
|
||||
assert.equal(typeof streamStateModule.archiveStream, "function");
|
||||
});
|
||||
|
||||
test("StreamTracker: starts in INITIALIZED state", () => {
|
||||
const tracker = new StreamTracker("req-1");
|
||||
|
||||
@@ -2,8 +2,16 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Dynamic imports to pick up ESM modules with tsx
|
||||
const { getCatalog, getSkillById, filterCatalog, computeCoverage, refreshCatalog, API_SKILL_IDS, CLI_SKILL_IDS } =
|
||||
await import("../../src/lib/agentSkills/catalog.ts");
|
||||
const {
|
||||
getCatalog,
|
||||
getSkillById,
|
||||
filterCatalog,
|
||||
computeCoverage,
|
||||
refreshCatalog,
|
||||
API_SKILL_IDS,
|
||||
CLI_SKILL_IDS,
|
||||
} = await import("../../src/lib/agentSkills/catalog.ts");
|
||||
const agentSkillsConstants = await import("../../src/shared/constants/agentSkills.ts");
|
||||
|
||||
// ─── Counts ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -36,11 +44,7 @@ test("getCatalog() contains exactly 20 cli skills", () => {
|
||||
test("all skill IDs match regex ^[a-z][a-z0-9-]*$", () => {
|
||||
const ID_REGEX = /^[a-z][a-z0-9-]*$/;
|
||||
for (const skill of getCatalog()) {
|
||||
assert.match(
|
||||
skill.id,
|
||||
ID_REGEX,
|
||||
`Skill ID "${skill.id}" does not match expected format`,
|
||||
);
|
||||
assert.match(skill.id, ID_REGEX, `Skill ID "${skill.id}" does not match expected format`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -50,7 +54,7 @@ test("all skill IDs are unique (no duplicates)", () => {
|
||||
assert.equal(
|
||||
uniqueIds.size,
|
||||
ids.length,
|
||||
`Duplicate IDs found: ${ids.filter((id, i) => ids.indexOf(id) !== i).join(", ")}`,
|
||||
`Duplicate IDs found: ${ids.filter((id, i) => ids.indexOf(id) !== i).join(", ")}`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -67,25 +71,35 @@ test("all skills have rawUrl and githubUrl as valid GitHub URLs", () => {
|
||||
for (const skill of getCatalog()) {
|
||||
assert.ok(
|
||||
skill.rawUrl.startsWith("https://raw.githubusercontent.com/"),
|
||||
`Skill ${skill.id}: rawUrl "${skill.rawUrl}" is not a GitHub raw URL`,
|
||||
`Skill ${skill.id}: rawUrl "${skill.rawUrl}" is not a GitHub raw URL`
|
||||
);
|
||||
assert.ok(
|
||||
skill.githubUrl.startsWith("https://github.com/"),
|
||||
`Skill ${skill.id}: githubUrl "${skill.githubUrl}" is not a GitHub blob URL`,
|
||||
`Skill ${skill.id}: githubUrl "${skill.githubUrl}" is not a GitHub blob URL`
|
||||
);
|
||||
assert.ok(
|
||||
skill.rawUrl.endsWith("/SKILL.md"),
|
||||
`Skill ${skill.id}: rawUrl does not end with /SKILL.md`,
|
||||
`Skill ${skill.id}: rawUrl does not end with /SKILL.md`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("agent skills constants expose URL builders without the unused repository URL", () => {
|
||||
assert.equal(typeof agentSkillsConstants.getAgentSkillRawUrl, "function");
|
||||
assert.equal(typeof agentSkillsConstants.getAgentSkillBlobUrl, "function");
|
||||
assert.equal("AGENT_SKILLS_REPO_URL" in agentSkillsConstants, false);
|
||||
});
|
||||
|
||||
test("api skills have area matching API_SKILL_IDS derived IDs", () => {
|
||||
const catalog = getCatalog();
|
||||
for (const id of API_SKILL_IDS) {
|
||||
const skill = catalog.find((s) => s.id === id);
|
||||
assert.ok(skill, `API skill ID "${id}" not found in catalog`);
|
||||
assert.equal(skill!.category, "api", `Skill "${id}" expected category api, got ${skill!.category}`);
|
||||
assert.equal(
|
||||
skill!.category,
|
||||
"api",
|
||||
`Skill "${id}" expected category api, got ${skill!.category}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -94,7 +108,11 @@ test("cli skills have area matching CLI_SKILL_IDS derived IDs", () => {
|
||||
for (const id of CLI_SKILL_IDS) {
|
||||
const skill = catalog.find((s) => s.id === id);
|
||||
assert.ok(skill, `CLI skill ID "${id}" not found in catalog`);
|
||||
assert.equal(skill!.category, "cli", `Skill "${id}" expected category cli, got ${skill!.category}`);
|
||||
assert.equal(
|
||||
skill!.category,
|
||||
"cli",
|
||||
`Skill "${id}" expected category cli, got ${skill!.category}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -203,7 +221,10 @@ test("computeCoverage() returns valid SkillCoverage shape", () => {
|
||||
assert.equal(cov.totalSkills, cov.api.have + cov.cli.have + (cov.config?.have ?? 0));
|
||||
|
||||
// generatedAt must be a valid ISO datetime string
|
||||
assert.ok(!isNaN(Date.parse(cov.generatedAt)), `generatedAt "${cov.generatedAt}" is not a valid ISO date`);
|
||||
assert.ok(
|
||||
!isNaN(Date.parse(cov.generatedAt)),
|
||||
`generatedAt "${cov.generatedAt}" is not a valid ISO date`
|
||||
);
|
||||
});
|
||||
|
||||
test("computeCoverage() api.have + cli.have = totalSkills", () => {
|
||||
|
||||
28
tests/unit/api-error-message-5340.test.ts
Normal file
28
tests/unit/api-error-message-5340.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { extractApiErrorMessage } from "@/shared/http/apiErrorMessage";
|
||||
|
||||
describe("extractApiErrorMessage (#5340)", () => {
|
||||
it("surfaces the message from a structured error envelope", () => {
|
||||
const body = {
|
||||
error: { code: "INVALID_ORIGIN", message: "Invalid request origin", correlation_id: "x" },
|
||||
};
|
||||
assert.equal(extractApiErrorMessage(body, "fallback"), "Invalid request origin");
|
||||
});
|
||||
|
||||
it("returns a plain string error as-is", () => {
|
||||
assert.equal(extractApiErrorMessage({ error: "boom" }, "fallback"), "boom");
|
||||
});
|
||||
|
||||
it("never renders a raw error object — falls back when message is missing", () => {
|
||||
assert.equal(extractApiErrorMessage({ error: { code: "X" } }, "fallback"), "fallback");
|
||||
});
|
||||
|
||||
it("falls back for empty, null, or malformed bodies", () => {
|
||||
assert.equal(extractApiErrorMessage({ error: " " }, "fallback"), "fallback");
|
||||
assert.equal(extractApiErrorMessage(null, "fallback"), "fallback");
|
||||
assert.equal(extractApiErrorMessage({}, "fallback"), "fallback");
|
||||
assert.equal(extractApiErrorMessage({ error: { message: 42 } }, "fallback"), "fallback");
|
||||
});
|
||||
});
|
||||
29
tests/unit/api-key-utils-public-surface.test.ts
Normal file
29
tests/unit/api-key-utils-public-surface.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
process.env.API_KEY_SECRET = "test-api-key-utils-secret";
|
||||
|
||||
const apiKeyUtils = await import("../../src/shared/utils/apiKey.ts");
|
||||
|
||||
test("api key utility public surface keeps generation and parsing only", () => {
|
||||
const machineId = "testmachine";
|
||||
const { key, keyId } = apiKeyUtils.generateApiKeyWithMachine(machineId);
|
||||
|
||||
assert.equal(typeof key, "string");
|
||||
assert.equal(typeof keyId, "string");
|
||||
assert.match(key, new RegExp(`^sk-${machineId}-${keyId}-[a-f0-9]{8}$`));
|
||||
assert.deepEqual(apiKeyUtils.parseApiKey(key), {
|
||||
machineId,
|
||||
keyId,
|
||||
isNewFormat: true,
|
||||
});
|
||||
assert.deepEqual(apiKeyUtils.parseApiKey("sk-legacykey"), {
|
||||
machineId: null,
|
||||
keyId: "legacykey",
|
||||
isNewFormat: false,
|
||||
});
|
||||
assert.equal(apiKeyUtils.parseApiKey("not-a-key"), null);
|
||||
|
||||
assert.equal("verifyApiKeyCrc" in apiKeyUtils, false);
|
||||
assert.equal("isNewFormatKey" in apiKeyUtils, false);
|
||||
});
|
||||
68
tests/unit/api/v1/relay-routing-backend.test.ts
Normal file
68
tests/unit/api/v1/relay-routing-backend.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
getBifrostRoutingConfig,
|
||||
resolveRelayRoutingBackend,
|
||||
shouldTryBifrost,
|
||||
} from "../../../../src/app/api/v1/relay/chat/completions/routingBackend.ts";
|
||||
|
||||
test("relay routing backend defaults to TypeScript without bifrost", () => {
|
||||
const env = {};
|
||||
|
||||
assert.equal(resolveRelayRoutingBackend(env), "ts");
|
||||
assert.equal(getBifrostRoutingConfig(env), null);
|
||||
});
|
||||
|
||||
test("relay routing backend auto-enables bifrost when base URL is configured", () => {
|
||||
const env = {
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080/",
|
||||
OMNIROUTE_BIFROST_KEY: "sidecar-key",
|
||||
BIFROST_TIMEOUT_MS: "250",
|
||||
};
|
||||
|
||||
const config = getBifrostRoutingConfig(env);
|
||||
|
||||
assert.equal(resolveRelayRoutingBackend(env), "auto");
|
||||
assert.equal(config?.baseUrl, "http://127.0.0.1:8080");
|
||||
assert.equal(config?.apiKey, "sidecar-key");
|
||||
assert.equal(config?.timeoutMs, 250);
|
||||
assert.equal(config?.streamingEnabled, true);
|
||||
assert.equal(config?.enabled, true);
|
||||
assert.equal(shouldTryBifrost("auto", config), true);
|
||||
});
|
||||
|
||||
test("relay routing backend honors explicit TS and strict bifrost modes", () => {
|
||||
const env = {
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
OMNIROUTE_RELAY_BACKEND: "ts",
|
||||
};
|
||||
const config = getBifrostRoutingConfig(env);
|
||||
|
||||
assert.equal(resolveRelayRoutingBackend(env), "ts");
|
||||
assert.equal(shouldTryBifrost("ts", config), false);
|
||||
|
||||
env.OMNIROUTE_RELAY_BACKEND = "bifrost";
|
||||
assert.equal(resolveRelayRoutingBackend(env), "bifrost");
|
||||
assert.equal(shouldTryBifrost("bifrost", config), true);
|
||||
});
|
||||
|
||||
test("relay routing backend respects bifrost killswitch", () => {
|
||||
const env = {
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
BIFROST_ENABLED: "0",
|
||||
};
|
||||
const config = getBifrostRoutingConfig(env);
|
||||
|
||||
assert.equal(resolveRelayRoutingBackend(env), "ts");
|
||||
assert.equal(config?.enabled, false);
|
||||
assert.equal(shouldTryBifrost("auto", config), false);
|
||||
});
|
||||
|
||||
test("relay routing backend falls back on invalid timeout values", () => {
|
||||
const env = {
|
||||
BIFROST_BASE_URL: "http://127.0.0.1:8080",
|
||||
BIFROST_TIMEOUT_MS: "not-a-number",
|
||||
};
|
||||
|
||||
assert.equal(getBifrostRoutingConfig(env)?.timeoutMs, 30000);
|
||||
});
|
||||
42
tests/unit/apply-executor-proxy-info-5217.test.ts
Normal file
42
tests/unit/apply-executor-proxy-info-5217.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { applyExecutorProxyToInfo } from "../../src/sse/handlers/chatHelpers.ts";
|
||||
|
||||
/**
|
||||
* #5217 (secondary) — the egress logger logged `proxy=direct` even when an
|
||||
* executor (OpencodeExecutor rotation) pinned a per-account proxy internally,
|
||||
* because the pre-resolved `proxyInfo` never learned about it.
|
||||
* `applyExecutorProxyToInfo` merges the executor-applied proxy back into proxyInfo
|
||||
* so the egress line reflects the real egress.
|
||||
*/
|
||||
|
||||
test("returns proxyInfo unchanged when the executor applied no proxy", () => {
|
||||
const proxyInfo = { proxy: null, level: "direct", levelId: null };
|
||||
assert.strictEqual(applyExecutorProxyToInfo(proxyInfo, null), proxyInfo);
|
||||
assert.strictEqual(applyExecutorProxyToInfo(proxyInfo, undefined), proxyInfo);
|
||||
});
|
||||
|
||||
test("injects the applied proxy and labels a previously-direct level as 'account'", () => {
|
||||
const applied = { type: "http", host: "127.0.0.1", port: 9999 };
|
||||
const merged = applyExecutorProxyToInfo({ proxy: null, level: "direct", levelId: null }, applied);
|
||||
assert.deepEqual(merged?.proxy, applied);
|
||||
assert.equal(merged?.level, "account");
|
||||
});
|
||||
|
||||
test("injects the applied proxy even when proxyInfo is null/undefined", () => {
|
||||
const applied = { type: "socks5", host: "10.0.0.1", port: 1080 };
|
||||
const merged = applyExecutorProxyToInfo(null, applied);
|
||||
assert.deepEqual(merged?.proxy, applied);
|
||||
assert.equal(merged?.level, "account");
|
||||
});
|
||||
|
||||
test("preserves an existing non-direct level (connection/key/global proxy)", () => {
|
||||
const applied = { type: "http", host: "127.0.0.1", port: 8888 };
|
||||
const merged = applyExecutorProxyToInfo(
|
||||
{ proxy: { type: "http", host: "1.2.3.4", port: 1 }, level: "connection", levelId: "conn-1" },
|
||||
applied
|
||||
);
|
||||
assert.deepEqual(merged?.proxy, applied, "proxy is overwritten by the actually-applied one");
|
||||
assert.equal(merged?.level, "connection", "a non-direct level must be preserved");
|
||||
assert.equal(merged?.levelId, "conn-1");
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import * as classifyPublicApi from "../../../src/server/authz/classify.ts";
|
||||
import { classifyRoute } from "../../../src/server/authz/classify.ts";
|
||||
import type { RouteClass } from "../../../src/server/authz/types.ts";
|
||||
|
||||
@@ -209,3 +210,10 @@ test("classifyRoute treats /api/v1 prefix exactly", () => {
|
||||
assert.equal(classifyRoute("/api/v1betamax").routeClass, "MANAGEMENT");
|
||||
assert.equal(classifyRoute("/api/v1beta/models").routeClass, "CLIENT_API");
|
||||
});
|
||||
|
||||
test("classify module public surface only exposes route classification", () => {
|
||||
assert.equal("classifyRoute" in classifyPublicApi, true);
|
||||
assert.equal("isClientApi" in classifyPublicApi, false);
|
||||
assert.equal("isManagement" in classifyPublicApi, false);
|
||||
assert.equal("isPublic" in classifyPublicApi, false);
|
||||
});
|
||||
|
||||
@@ -343,7 +343,67 @@ test("runAuthzPipeline rejects dashboard mutations from invalid browser origin",
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.equal(body.error.code, "INVALID_ORIGIN");
|
||||
assert.equal(body.error.message, "Invalid request origin");
|
||||
assert.match(body.error.message, /^Invalid request origin\./);
|
||||
assert.match(body.error.message, /OMNIROUTE_PUBLIC_BASE_URL/);
|
||||
});
|
||||
|
||||
test("runAuthzPipeline answers OPTIONS /v1/models preflight with Allow-Origin (#5242)", async () => {
|
||||
// Literal Wayland AI / Electron repro: browser preflight with an Origin and
|
||||
// no CORS_ALLOW_ALL must still receive Access-Control-Allow-Origin so the
|
||||
// renderer is allowed to read the catalog response.
|
||||
delete process.env.CORS_ALLOW_ALL;
|
||||
delete process.env.CORS_ALLOWED_ORIGINS;
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(
|
||||
request("http://localhost/v1/models", {
|
||||
method: "OPTIONS",
|
||||
headers: { origin: "http://localhost" },
|
||||
}),
|
||||
{ enforce: true }
|
||||
);
|
||||
|
||||
assert.equal(response.status, 204);
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "CLIENT_API");
|
||||
assert.equal(response.headers.get("Access-Control-Allow-Origin"), "http://localhost");
|
||||
assert.match(response.headers.get("Vary") || "", /Origin/);
|
||||
// Token-auth surface — must NOT advertise credentials with the echoed origin.
|
||||
assert.equal(response.headers.get("Access-Control-Allow-Credentials"), null);
|
||||
});
|
||||
|
||||
test("runAuthzPipeline serves GET /v1/models with Allow-Origin to dashboard session (#5242)", async () => {
|
||||
await forceAuthRequired();
|
||||
delete process.env.CORS_ALLOW_ALL;
|
||||
delete process.env.CORS_ALLOWED_ORIGINS;
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(
|
||||
request("http://localhost/v1/models", {
|
||||
headers: { cookie: await dashboardCookie(), origin: "http://localhost" },
|
||||
}),
|
||||
{ enforce: true }
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "CLIENT_API");
|
||||
assert.equal(response.headers.get("Access-Control-Allow-Origin"), "http://localhost");
|
||||
assert.equal(response.headers.get("Access-Control-Allow-Credentials"), null);
|
||||
});
|
||||
|
||||
test("runAuthzPipeline keeps MANAGEMENT OPTIONS fail-closed for arbitrary origin (#5242)", async () => {
|
||||
delete process.env.CORS_ALLOW_ALL;
|
||||
delete process.env.CORS_ALLOWED_ORIGINS;
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(
|
||||
request("http://localhost/api/keys", {
|
||||
method: "OPTIONS",
|
||||
headers: { origin: "http://localhost" },
|
||||
}),
|
||||
{ enforce: true }
|
||||
);
|
||||
|
||||
assert.equal(response.status, 204);
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT");
|
||||
// Management surface is cookie-authed → no permissive origin echo.
|
||||
assert.equal(response.headers.get("Access-Control-Allow-Origin"), null);
|
||||
});
|
||||
|
||||
test("runAuthzPipeline refreshes dashboard JWTs near expiry", async () => {
|
||||
|
||||
@@ -219,3 +219,102 @@ describe("public origin resolution", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("direct LAN/loopback host origin (#5340)", () => {
|
||||
it("accepts a direct LAN-IP host even when a localhost public base URL is configured", () => {
|
||||
process.env.NEXT_PUBLIC_BASE_URL = "http://localhost:20128";
|
||||
const request = new Request("http://omniroute:20128/api/keys", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...stampedPeer("192.168.0.50"),
|
||||
host: "192.168.0.15:20128",
|
||||
origin: "http://192.168.0.15:20128",
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
getPublicOriginCandidates(request).some(
|
||||
(candidate) => candidate.origin === "http://192.168.0.15:20128"
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.equal(validateBrowserMutationOrigin(request).ok, true);
|
||||
});
|
||||
|
||||
it("accepts a direct loopback-IP host with no configured public origin", () => {
|
||||
const request = new Request("http://omniroute:20128/api/keys", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...stampedPeer("127.0.0.1"),
|
||||
host: "127.0.0.1:20128",
|
||||
origin: "http://127.0.0.1:20128",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(validateBrowserMutationOrigin(request).ok, true);
|
||||
});
|
||||
|
||||
it("rejects a DNS-rebinding domain host even when the peer is loopback", () => {
|
||||
// evil.example rebinds to a loopback socket; the Host header carries the
|
||||
// attacker domain, which classifies as remote → no trusted candidate.
|
||||
const request = new Request("http://omniroute:20128/api/keys", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...stampedPeer("127.0.0.1"),
|
||||
host: "evil.example:20128",
|
||||
origin: "http://evil.example:20128",
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
getPublicOriginCandidates(request).some(
|
||||
(candidate) => candidate.origin === "http://evil.example:20128"
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(validateBrowserMutationOrigin(request).ok, false);
|
||||
});
|
||||
|
||||
it("does not widen the origin for a remote peer even when the Host is a LAN IP", () => {
|
||||
const request = new Request("http://omniroute:20128/api/keys", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...stampedPeer("203.0.113.7"),
|
||||
host: "192.168.0.15:20128",
|
||||
origin: "http://192.168.0.15:20128",
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(validateBrowserMutationOrigin(request).ok, false);
|
||||
});
|
||||
|
||||
it("does not trust the Host header when the peer stamp is absent", () => {
|
||||
const request = new Request("http://omniroute:20128/api/keys", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "192.168.0.15:20128",
|
||||
origin: "http://192.168.0.15:20128",
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(validateBrowserMutationOrigin(request).ok, false);
|
||||
});
|
||||
|
||||
it("pins the protocol to the connection — a mismatched https origin is rejected", () => {
|
||||
const request = new Request("http://omniroute:20128/api/keys", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...stampedPeer("192.168.0.50"),
|
||||
host: "192.168.0.15:20128",
|
||||
origin: "https://192.168.0.15:20128",
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(validateBrowserMutationOrigin(request).ok, false);
|
||||
});
|
||||
});
|
||||
|
||||
111
tests/unit/base-thinking-budget-config-5312.test.ts
Normal file
111
tests/unit/base-thinking-budget-config-5312.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* TDD regression for #5312 (FIX B / RC-B): base.ts force-injects adaptive thinking
|
||||
* for the native Claude OAuth wire-image path WITHOUT consulting the operator's
|
||||
* Thinking-Budget config, so the dashboard mode is ignored for Claude Code traffic.
|
||||
*
|
||||
* Expected behavior:
|
||||
* - mode=auto (strip): the default adaptive injection is suppressed.
|
||||
* - default/passthrough (no operator config): adaptive is still injected so the
|
||||
* native Claude Code behavior is UNCHANGED (#4633 must not regress).
|
||||
* - mode=custom/adaptive producing thinking.type="enabled": remapped to
|
||||
* type="adaptive" + output_config.effort, because Opus 4.7/4.8 reject "enabled".
|
||||
*
|
||||
* Harness mirrors tests/unit/claude-thinking-tool-choice-guard.test.ts.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { BaseExecutor } from "../../open-sse/executors/base.ts";
|
||||
import {
|
||||
setThinkingBudgetConfig,
|
||||
getThinkingBudgetConfig,
|
||||
DEFAULT_THINKING_CONFIG,
|
||||
} from "../../open-sse/services/thinkingBudget.ts";
|
||||
|
||||
class ClaudeLikeExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("claude", { baseUrls: ["https://api.anthropic.com/v1/messages"] });
|
||||
}
|
||||
needsRefresh() {
|
||||
return false;
|
||||
}
|
||||
async transformRequest(_model: string, body: Record<string, unknown>) {
|
||||
return { ...body };
|
||||
}
|
||||
}
|
||||
|
||||
async function captureUpstreamBody(
|
||||
body: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
const executor = new ClaudeLikeExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
let upstreamBody: Record<string, unknown> | null = null;
|
||||
globalThis.fetch = async (_url: string | URL | Request, init: RequestInit = {}) => {
|
||||
upstreamBody = JSON.parse(String(init.body));
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
try {
|
||||
await executor.execute({
|
||||
model: "claude-opus-4-8",
|
||||
body,
|
||||
stream: false,
|
||||
credentials: { accessToken: "sk-ant-oat-test-5312" },
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
assert.ok(upstreamBody, "fetch must have been called");
|
||||
return upstreamBody!;
|
||||
}
|
||||
|
||||
test.afterEach(() => {
|
||||
setThinkingBudgetConfig(DEFAULT_THINKING_CONFIG);
|
||||
});
|
||||
|
||||
test("#5312 RC-B: default/passthrough config still injects adaptive thinking (#4633 preserved)", async () => {
|
||||
setThinkingBudgetConfig(DEFAULT_THINKING_CONFIG);
|
||||
assert.equal(getThinkingBudgetConfig().mode, "passthrough");
|
||||
const upstream = await captureUpstreamBody({
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
assert.deepEqual(
|
||||
upstream.thinking,
|
||||
{ type: "adaptive" },
|
||||
"adaptive thinking must still be injected for native Claude Code by default"
|
||||
);
|
||||
assert.deepEqual(upstream.output_config, { effort: "high" });
|
||||
});
|
||||
|
||||
test("#5312 RC-B: mode=auto suppresses the forced adaptive injection (strip honored)", async () => {
|
||||
setThinkingBudgetConfig({ mode: "auto" });
|
||||
const upstream = await captureUpstreamBody({
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
assert.equal(
|
||||
upstream.thinking,
|
||||
undefined,
|
||||
"operator chose auto (strip) — thinking must NOT be force-injected"
|
||||
);
|
||||
assert.equal(upstream.output_config, undefined, "no effort hint should be injected in auto mode");
|
||||
});
|
||||
|
||||
test("#5312 RC-B: custom-budget enabled block is remapped to adaptive (Opus 4.8 rejects enabled)", async () => {
|
||||
setThinkingBudgetConfig({ mode: "custom", customBudget: 8192 });
|
||||
// Simulate what applyThinkingBudget produces upstream for a custom budget.
|
||||
const upstream = await captureUpstreamBody({
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
thinking: { type: "enabled", budget_tokens: 8192 },
|
||||
});
|
||||
assert.deepEqual(
|
||||
upstream.thinking,
|
||||
{ type: "adaptive" },
|
||||
"type=enabled must be remapped to adaptive for the Claude OAuth path"
|
||||
);
|
||||
assert.deepEqual(
|
||||
upstream.output_config,
|
||||
{ effort: "medium" },
|
||||
"8192 budget maps to medium effort"
|
||||
);
|
||||
});
|
||||
@@ -271,6 +271,14 @@ describe("a11yAudit", () => {
|
||||
assert.equal(report.total, 0);
|
||||
});
|
||||
|
||||
it("should not export the removed contrast compliance wrapper", async () => {
|
||||
const audit = await import("../../src/shared/utils/a11yAudit.ts");
|
||||
assert.equal("checkContrast" in audit, false);
|
||||
assert.equal(typeof audit.getContrastRatio, "function");
|
||||
assert.equal(typeof audit.auditHTML, "function");
|
||||
assert.equal(typeof audit.generateReport, "function");
|
||||
});
|
||||
|
||||
it("should export WCAG rules", () => {
|
||||
assert.ok(WCAG_RULES.ARIA_LABEL);
|
||||
assert.ok(WCAG_RULES.COLOR_CONTRAST);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import * as bodySizeGuard from "../../src/shared/middleware/bodySizeGuard.ts";
|
||||
import {
|
||||
MAX_BODY_BYTES_AUDIO,
|
||||
MAX_BODY_BYTES_FILE,
|
||||
@@ -8,6 +9,12 @@ import {
|
||||
} from "../../src/shared/middleware/bodySizeGuard.ts";
|
||||
import { requestBodyLimitMbToBytes } from "../../src/shared/constants/bodySize.ts";
|
||||
|
||||
test("body size guard public surface excludes the removed default MB helper", () => {
|
||||
assert.equal(Object.hasOwn(bodySizeGuard, "getDefaultRequestBodyLimitMb"), false);
|
||||
assert.equal(typeof bodySizeGuard.getBodySizeLimit, "function");
|
||||
assert.equal(typeof bodySizeGuard.checkBodySize, "function");
|
||||
});
|
||||
|
||||
test("body size guard uses maxBodySizeMb from settings for regular API routes", () => {
|
||||
assert.equal(
|
||||
getBodySizeLimit("/api/v1/responses", { maxBodySizeMb: 100 }),
|
||||
@@ -53,7 +60,10 @@ test("/api/v1/files route guard allows 500 MB file upload", () => {
|
||||
method: "POST",
|
||||
headers: { "content-length": String(thirtyMb) },
|
||||
});
|
||||
assert.equal(checkBodySize(request, getBodySizeLimit("/api/v1/files", { maxBodySizeMb: 10 })), null);
|
||||
assert.equal(
|
||||
checkBodySize(request, getBodySizeLimit("/api/v1/files", { maxBodySizeMb: 10 })),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test("/api/v1/files route guard rejects >512 MB file upload", async () => {
|
||||
|
||||
102
tests/unit/call-log-trim-sql-vars-5217.test.ts
Normal file
102
tests/unit/call-log-trim-sql-vars-5217.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* #5217 — `trimCallLogsToMaxRows()` deleted up to batchSize=5000 ids in a single
|
||||
* `DELETE … IN (?, ?, …)` via `deleteCallLogRowsByIds`. SQLite caps a statement at
|
||||
* ~999 bound parameters by default, so any trim that needed to delete >999 rows
|
||||
* threw "too many SQL variables", aborting the trim and blocking the Request-log
|
||||
* table from being persisted/pruned. The delete must chunk the ids so a large
|
||||
* trim succeeds instead of throwing.
|
||||
*/
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-calllogs-trim-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.CALL_LOG_RETENTION_DAYS = "3650";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const callLogs = await import("../../src/lib/usage/callLogs.ts");
|
||||
|
||||
function insertCallLog(id: string, timestamp: string) {
|
||||
const db = core.getDbInstance();
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO call_logs (
|
||||
id, timestamp, method, path, status, model, provider, detail_state
|
||||
)
|
||||
VALUES (@id, @timestamp, 'POST', '/v1/chat/completions', 200, 'openai/gpt-4.1', 'openai', 'none')
|
||||
`
|
||||
).run({ id, timestamp });
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("trimCallLogsToMaxRows deletes >999 rows in one pass without 'too many SQL variables'", () => {
|
||||
const db = core.getDbInstance();
|
||||
const total = 1500;
|
||||
const base = Date.parse("2026-01-01T00:00:00.000Z");
|
||||
const insertMany = db.transaction(() => {
|
||||
for (let i = 0; i < total; i++) {
|
||||
insertCallLog(`trim-${String(i).padStart(5, "0")}`, new Date(base + i * 1000).toISOString());
|
||||
}
|
||||
});
|
||||
insertMany();
|
||||
|
||||
assert.equal(
|
||||
(db.prepare("SELECT COUNT(*) AS cnt FROM call_logs").get() as { cnt: number }).cnt,
|
||||
total
|
||||
);
|
||||
|
||||
// Trim to 10 rows → 1490 ids must be deleted in a single trim batch (batchSize=5000),
|
||||
// which without chunking would exceed SQLite's ~999 bound-parameter limit and throw.
|
||||
let result: { deletedRows: number; deletedArtifacts: number } | undefined;
|
||||
assert.doesNotThrow(() => {
|
||||
result = callLogs.trimCallLogsToMaxRows(10);
|
||||
});
|
||||
|
||||
assert.equal(result!.deletedRows, total - 10, "all overflow rows must be deleted (chunked)");
|
||||
assert.equal(
|
||||
(db.prepare("SELECT COUNT(*) AS cnt FROM call_logs").get() as { cnt: number }).cnt,
|
||||
10,
|
||||
"exactly maxRows rows must remain"
|
||||
);
|
||||
});
|
||||
|
||||
test("deleteCallLogsBefore deletes a batch larger than SQLite's variable limit without throwing", () => {
|
||||
const db = core.getDbInstance();
|
||||
// Exceed SQLITE_MAX_VARIABLE_NUMBER (999 on many builds, 32766 on newer ones).
|
||||
// deleteCallLogsBefore passes EVERY matching id to one DELETE … IN (...) — the
|
||||
// un-chunked version threw "too many SQL variables"; chunking must avoid it on
|
||||
// any build. 35k > the 32766 cap, so this reproduces even on modern SQLite.
|
||||
const total = 35000;
|
||||
const base = Date.parse("2026-01-01T00:00:00.000Z");
|
||||
const insertMany = db.transaction(() => {
|
||||
for (let i = 0; i < total; i++) {
|
||||
insertCallLog(`old-${String(i).padStart(5, "0")}`, new Date(base + i * 1000).toISOString());
|
||||
}
|
||||
});
|
||||
insertMany();
|
||||
|
||||
let result: { deletedRows: number } | undefined;
|
||||
assert.doesNotThrow(() => {
|
||||
result = callLogs.deleteCallLogsBefore("2030-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
assert.equal(result!.deletedRows, total, "every row before the cutoff must be deleted");
|
||||
assert.equal(
|
||||
(db.prepare("SELECT COUNT(*) AS cnt FROM call_logs").get() as { cnt: number }).cnt,
|
||||
0
|
||||
);
|
||||
});
|
||||
268
tests/unit/chatgpt-web-tools-5240.test.ts
Normal file
268
tests/unit/chatgpt-web-tools-5240.test.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
// Tool-call emulation for the ChatGPT Web executor (#5240).
|
||||
//
|
||||
// chatgpt-web was omitted from the #3259 prompt-emulation rollout: body.tools
|
||||
// was never read and both response builders hardcoded finish_reason:"stop".
|
||||
// These tests live in a dedicated file (chatgpt-web.test.ts is a frozen
|
||||
// god-file at the file-size cap and cannot grow).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { ChatGptWebExecutor, __resetChatGptWebCachesForTesting } = await import(
|
||||
"../../open-sse/executors/chatgpt-web.ts"
|
||||
);
|
||||
const { __setTlsFetchOverrideForTesting } = await import(
|
||||
"../../open-sse/services/chatgptTlsClient.ts"
|
||||
);
|
||||
|
||||
// ─── Minimal TLS-fetch mock ──────────────────────────────────────────────────
|
||||
// Tailored to the tool-call flow (gpt-5.3-instant, non-thinking): root/DPL,
|
||||
// session→accessToken, sentinel→token (no PoW), conv→SSE. Warmup GETs fall
|
||||
// through to 404, which the executor tolerates.
|
||||
|
||||
function makeHeaders(map: Record<string, string> = {}) {
|
||||
const h = new Headers();
|
||||
for (const [k, v] of Object.entries(map)) h.set(k, String(v));
|
||||
return h;
|
||||
}
|
||||
|
||||
function sseText(events: unknown[]): string {
|
||||
return events.map((e) => `data: ${JSON.stringify(e)}\r\n\r\n`).join("") + "data: [DONE]\r\n\r\n";
|
||||
}
|
||||
|
||||
/** A single finished assistant turn whose text is `parts`. */
|
||||
function convWithAssistantText(parts: string) {
|
||||
return [
|
||||
{
|
||||
conversation_id: "tc-1",
|
||||
message: {
|
||||
id: "tm-1",
|
||||
author: { role: "assistant" },
|
||||
content: { content_type: "text", parts: [parts] },
|
||||
status: "in_progress",
|
||||
},
|
||||
},
|
||||
{
|
||||
conversation_id: "tc-1",
|
||||
message: {
|
||||
id: "tm-1",
|
||||
author: { role: "assistant" },
|
||||
content: { content_type: "text", parts: [parts] },
|
||||
status: "finished_successfully",
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function installMockFetch(convEvents: unknown[]) {
|
||||
const calls = { urls: [] as string[], bodies: [] as unknown[] };
|
||||
|
||||
__setTlsFetchOverrideForTesting(async (url: string, opts: any = {}) => {
|
||||
const u = String(url);
|
||||
calls.urls.push(u);
|
||||
calls.bodies.push(opts.body);
|
||||
const json = (body: unknown, status = 200) => ({
|
||||
status,
|
||||
headers: makeHeaders({ "Content-Type": "application/json" }),
|
||||
text: JSON.stringify(body),
|
||||
body: null,
|
||||
});
|
||||
|
||||
if ((u === "https://chatgpt.com/" || u === "https://chatgpt.com") && (opts.method || "GET") === "GET") {
|
||||
return {
|
||||
status: 200,
|
||||
headers: makeHeaders({ "Content-Type": "text/html" }),
|
||||
text: '<html data-build="prod-test123"><script src="https://cdn.oaistatic.com/_next/static/chunks/main-test.js"></script></html>',
|
||||
body: null,
|
||||
};
|
||||
}
|
||||
if (u.includes("/api/auth/session")) {
|
||||
return json({
|
||||
accessToken: "jwt-abc",
|
||||
expires: new Date(Date.now() + 3600_000).toISOString(),
|
||||
user: { id: "user-1" },
|
||||
});
|
||||
}
|
||||
if (u.includes("/sentinel/chat-requirements")) {
|
||||
return json({ token: "req-token", proofofwork: { required: false } });
|
||||
}
|
||||
if (
|
||||
u.endsWith("/backend-api/f/conversation") ||
|
||||
u.endsWith("/backend-api/conversation") ||
|
||||
/\/backend-api\/(f\/)?conversation\?/.test(u)
|
||||
) {
|
||||
return {
|
||||
status: 200,
|
||||
headers: makeHeaders({ "Content-Type": "text/event-stream" }),
|
||||
text: sseText(convEvents),
|
||||
body: null,
|
||||
};
|
||||
}
|
||||
// Warmup (/me, /conversations, /models) — tolerated.
|
||||
return { status: 404, headers: makeHeaders(), text: "not mocked", body: null };
|
||||
});
|
||||
|
||||
return {
|
||||
calls,
|
||||
restore() {
|
||||
__setTlsFetchOverrideForTesting(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const WEATHER_TOOL = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Get the current weather for a location",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { location: { type: "string" } },
|
||||
required: ["location"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const TOOL_CALL_TEXT = '<tool>{"name":"get_weather","arguments":{"location":"Tokyo"}}</tool>';
|
||||
|
||||
function baseOpts(extra: Record<string, unknown>) {
|
||||
return {
|
||||
model: "gpt-5.3-instant",
|
||||
credentials: { apiKey: "test" },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
log: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
test("Tools request-side: <tool> contract is serialized into the upstream system message (#5240)", async () => {
|
||||
__resetChatGptWebCachesForTesting();
|
||||
const m = installMockFetch(convWithAssistantText("ok"));
|
||||
try {
|
||||
const executor = new ChatGptWebExecutor();
|
||||
await executor.execute(
|
||||
baseOpts({
|
||||
body: {
|
||||
messages: [{ role: "user", content: "What is the weather in Tokyo?" }],
|
||||
tools: [WEATHER_TOOL],
|
||||
},
|
||||
stream: false,
|
||||
}) as any
|
||||
);
|
||||
|
||||
const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation"));
|
||||
assert.ok(convIdx >= 0, "conversation endpoint was hit");
|
||||
const convBody = JSON.parse(m.calls.bodies[convIdx] as string);
|
||||
const systemMsg = convBody.messages.find((mm: any) => mm.author.role === "system");
|
||||
assert.ok(systemMsg, "a system message carrying the tool contract was sent");
|
||||
const systemText = systemMsg.content.parts.join("");
|
||||
assert.match(systemText, /<tool>/, "system prompt instructs the model to emit <tool> blocks");
|
||||
assert.match(systemText, /get_weather/, "system prompt lists the requested tool");
|
||||
} finally {
|
||||
m.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("Tools non-stream: <tool>{...}</tool> text becomes OpenAI tool_calls + finish_reason (#5240)", async () => {
|
||||
__resetChatGptWebCachesForTesting();
|
||||
const m = installMockFetch(convWithAssistantText(TOOL_CALL_TEXT));
|
||||
try {
|
||||
const executor = new ChatGptWebExecutor();
|
||||
const result = await executor.execute(
|
||||
baseOpts({
|
||||
body: {
|
||||
messages: [{ role: "user", content: "What is the weather in Tokyo?" }],
|
||||
tools: [WEATHER_TOOL],
|
||||
},
|
||||
stream: false,
|
||||
}) as any
|
||||
);
|
||||
|
||||
assert.equal(result.response.status, 200);
|
||||
const json = await result.response.json();
|
||||
assert.equal(json.choices[0].finish_reason, "tool_calls");
|
||||
const tc = json.choices[0].message.tool_calls;
|
||||
assert.ok(Array.isArray(tc) && tc.length === 1, "exactly one tool_call");
|
||||
assert.equal(tc[0].type, "function");
|
||||
assert.equal(tc[0].function.name, "get_weather");
|
||||
assert.equal(typeof tc[0].function.arguments, "string", "arguments is a JSON string");
|
||||
assert.deepEqual(JSON.parse(tc[0].function.arguments), { location: "Tokyo" });
|
||||
assert.equal(json.choices[0].message.content, null);
|
||||
} finally {
|
||||
m.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("Tools stream: terminal chunk carries delta.tool_calls + finish_reason tool_calls (#5240)", async () => {
|
||||
__resetChatGptWebCachesForTesting();
|
||||
const m = installMockFetch(convWithAssistantText(TOOL_CALL_TEXT));
|
||||
try {
|
||||
const executor = new ChatGptWebExecutor();
|
||||
const result = await executor.execute(
|
||||
baseOpts({
|
||||
body: {
|
||||
messages: [{ role: "user", content: "What is the weather in Tokyo?" }],
|
||||
tools: [WEATHER_TOOL],
|
||||
stream: true,
|
||||
},
|
||||
stream: true,
|
||||
}) as any
|
||||
);
|
||||
|
||||
assert.equal(result.response.status, 200);
|
||||
assert.equal(result.response.headers.get("Content-Type"), "text/event-stream");
|
||||
|
||||
const text = await result.response.text();
|
||||
const chunks = text
|
||||
.split("\n")
|
||||
.filter((l) => l.startsWith("data: ") && !l.includes("[DONE]"))
|
||||
.map((l) => JSON.parse(l.slice(6)));
|
||||
|
||||
const toolChunk = chunks.find((c) => c.choices[0].delta && c.choices[0].delta.tool_calls);
|
||||
assert.ok(toolChunk, "a chunk carries delta.tool_calls");
|
||||
assert.equal(toolChunk.choices[0].finish_reason, "tool_calls");
|
||||
const tc = toolChunk.choices[0].delta.tool_calls;
|
||||
assert.equal(tc[0].function.name, "get_weather");
|
||||
assert.deepEqual(JSON.parse(tc[0].function.arguments), { location: "Tokyo" });
|
||||
|
||||
const lastLine = text.trim().split("\n").filter(Boolean).pop();
|
||||
assert.equal(lastLine, "data: [DONE]");
|
||||
} finally {
|
||||
m.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("Tools regression: no-tools request still streams plain content with finish_reason stop (#5240)", async () => {
|
||||
__resetChatGptWebCachesForTesting();
|
||||
const m = installMockFetch(convWithAssistantText("Just plain text, no tools."));
|
||||
try {
|
||||
const executor = new ChatGptWebExecutor();
|
||||
const result = await executor.execute(
|
||||
baseOpts({
|
||||
body: { messages: [{ role: "user", content: "hi" }], stream: true },
|
||||
stream: true,
|
||||
}) as any
|
||||
);
|
||||
|
||||
const text = await result.response.text();
|
||||
const chunks = text
|
||||
.split("\n")
|
||||
.filter((l) => l.startsWith("data: ") && !l.includes("[DONE]"))
|
||||
.map((l) => JSON.parse(l.slice(6)));
|
||||
|
||||
assert.ok(
|
||||
chunks.some(
|
||||
(c) => c.choices[0].delta && c.choices[0].delta.content === "Just plain text, no tools."
|
||||
),
|
||||
"plain content is streamed"
|
||||
);
|
||||
assert.ok(
|
||||
chunks.every((c) => !(c.choices[0].delta && c.choices[0].delta.tool_calls)),
|
||||
"no tool_calls emitted without a tools array"
|
||||
);
|
||||
const finishChunk = chunks.find((c) => c.choices[0].finish_reason);
|
||||
assert.equal(finishChunk.choices[0].finish_reason, "stop");
|
||||
} finally {
|
||||
m.restore();
|
||||
}
|
||||
});
|
||||
@@ -77,3 +77,8 @@ test("getCliTool returns correct tool by id", async () => {
|
||||
const missing = getCliTool("nonexistent");
|
||||
assert.equal(missing, undefined);
|
||||
});
|
||||
|
||||
test("CLI tools registry does not export provider model mapping helper", async () => {
|
||||
const registry = await import("../../src/shared/constants/cliTools.ts");
|
||||
assert.equal("getProviderModelsForMapping" in registry, false);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,12 @@ import assert from "node:assert/strict";
|
||||
|
||||
const builderDraft = await import("../../src/lib/combos/builderDraft.ts");
|
||||
|
||||
test("combo builder draft public surface excludes removed target accessor", () => {
|
||||
assert.equal(Object.hasOwn(builderDraft, "getComboDraftTarget"), false);
|
||||
assert.equal(typeof builderDraft.buildPrecisionComboModelStep, "function");
|
||||
assert.equal(typeof builderDraft.buildManualComboModelStep, "function");
|
||||
});
|
||||
|
||||
test("parseQualifiedModel keeps provider prefix and the full tail model id", () => {
|
||||
assert.deepEqual(builderDraft.parseQualifiedModel("openrouter/openai/gpt-5.4"), {
|
||||
providerId: "openrouter",
|
||||
|
||||
@@ -29,6 +29,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import * as comboSteps from "../../src/lib/combos/steps.ts";
|
||||
import { getComboModelString } from "../../src/lib/combos/steps.ts";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -38,6 +39,13 @@ const MODEL_SERVICE_SRC = path.resolve(__dirname, "../../src/sse/services/model.
|
||||
|
||||
const FAKE_UUID_NODE_ID = "openai-compatible-responses-d302c75f-f133-48d3-afa1-066e594e0d29";
|
||||
|
||||
test("combo step public surface excludes removed type-guard helpers", () => {
|
||||
assert.equal(Object.hasOwn(comboSteps, "isComboRefStep"), false);
|
||||
assert.equal(Object.hasOwn(comboSteps, "isComboModelStep"), false);
|
||||
assert.equal(typeof comboSteps.getComboModelString, "function");
|
||||
assert.equal(typeof comboSteps.getComboStepWeight, "function");
|
||||
});
|
||||
|
||||
test("#2778 getComboModelString with UUID-prefixed providerId assembles the UUID-prefixed model string", () => {
|
||||
// This is what a combo step looks like when the UI stores the internal node id as
|
||||
// providerId — the same scenario that causes the bug.
|
||||
@@ -151,7 +159,10 @@ test("#2778 matching logic: node with prefix=flymux and id=UUID-id still matches
|
||||
});
|
||||
|
||||
test("custom provider auth lookup search pool maps alias prefixes to internal provider ids", async () => {
|
||||
const authSrc = fs.readFileSync(path.resolve(__dirname, "../../src/sse/services/auth.ts"), "utf8");
|
||||
const authSrc = fs.readFileSync(
|
||||
path.resolve(__dirname, "../../src/sse/services/auth.ts"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert.match(
|
||||
authSrc,
|
||||
|
||||
@@ -11,8 +11,8 @@ process.env.OMNIROUTE_CONFIG_HOT_RELOAD_MS = "100";
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const { getDbInstance } = core;
|
||||
const { applyRuntimeSettings, resetRuntimeSettingsStateForTests } =
|
||||
await import("../../src/lib/config/runtimeSettings.ts");
|
||||
const runtimeSettings = await import("../../src/lib/config/runtimeSettings.ts");
|
||||
const { applyRuntimeSettings, resetRuntimeSettingsStateForTests } = runtimeSettings;
|
||||
const { startRuntimeConfigHotReload, stopRuntimeConfigHotReloadForTests } =
|
||||
await import("../../src/lib/config/hotReload.ts");
|
||||
const { getCliCompatProviders } = await import("../../open-sse/config/cliFingerprints.ts");
|
||||
@@ -66,6 +66,16 @@ test.after(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test("runtime settings public surface excludes removed snapshot inspection helper", () => {
|
||||
assert.equal(
|
||||
Object.hasOwn(runtimeSettings, "getLastAppliedRuntimeSettingsSnapshotForTests"),
|
||||
false
|
||||
);
|
||||
assert.equal(typeof runtimeSettings.applyRuntimeSettings, "function");
|
||||
assert.equal(typeof runtimeSettings.getAuthzBypassSnapshot, "function");
|
||||
assert.equal(typeof runtimeSettings.resetRuntimeSettingsStateForTests, "function");
|
||||
});
|
||||
|
||||
test("updateSettings applies runtime settings incrementally without restart", async () => {
|
||||
await applyRuntimeSettings(await settingsDb.getSettings(), {
|
||||
force: true,
|
||||
|
||||
@@ -128,6 +128,51 @@ describe("cors/origins.applyCorsHeaders", () => {
|
||||
assert.match(res.headers.get("Vary") || "", /Origin/);
|
||||
});
|
||||
|
||||
it("CLIENT_API: echoes arbitrary Origin (+Vary) when no allowlist matches (relaxForTokenAuth)", () => {
|
||||
// Token-authenticated /v1/* surface (issue #5242): no allowlist, arbitrary
|
||||
// origin → echo it back so browser/Electron renderers can read the body.
|
||||
const res = NextResponse.json({ ok: true });
|
||||
const req = new Request("https://server.example.com/api/v1/models", {
|
||||
headers: { Origin: "http://localhost" },
|
||||
});
|
||||
applyCorsHeaders(res, req, true);
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Origin"), "http://localhost");
|
||||
assert.match(res.headers.get("Vary") || "", /Origin/);
|
||||
// Never paired with Allow-Credentials on the token-auth surface.
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Credentials"), null);
|
||||
});
|
||||
|
||||
it("CLIENT_API: returns '*' when no Origin header is present (relaxForTokenAuth)", () => {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
const req = new Request("https://server.example.com/api/v1/models");
|
||||
applyCorsHeaders(res, req, true);
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Origin"), "*");
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Credentials"), null);
|
||||
});
|
||||
|
||||
it("MANAGEMENT: stays fail-closed for arbitrary Origin with no allowlist (relax off)", () => {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
const req = new Request("https://server.example.com/api/keys", {
|
||||
headers: { Origin: "http://localhost" },
|
||||
});
|
||||
// Default (relaxForTokenAuth = false) — same as MANAGEMENT routes.
|
||||
applyCorsHeaders(res, req);
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Origin"), null);
|
||||
applyCorsHeaders(res, req, false);
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Origin"), null);
|
||||
});
|
||||
|
||||
it("relaxForTokenAuth still honors an explicit allowlist match exactly (no wildcard)", () => {
|
||||
process.env.CORS_ALLOWED_ORIGINS = "https://app.example.com";
|
||||
const res = NextResponse.json({ ok: true });
|
||||
const req = new Request("https://server.example.com/api/v1/models", {
|
||||
headers: { Origin: "https://app.example.com" },
|
||||
});
|
||||
applyCorsHeaders(res, req, true);
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Origin"), "https://app.example.com");
|
||||
assert.match(res.headers.get("Vary") || "", /Origin/);
|
||||
});
|
||||
|
||||
it("reflects requested headers from Access-Control-Request-Headers preflight", () => {
|
||||
process.env.CORS_ALLOWED_ORIGINS = "https://app.example.com";
|
||||
const res = NextResponse.json({ ok: true });
|
||||
|
||||
66
tests/unit/db-command-code-auth.test.ts
Normal file
66
tests/unit/db-command-code-auth.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-command-code-auth-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const coreDb = await import("../../src/lib/db/core.ts");
|
||||
const commandCodeAuthDb = await import("../../src/lib/db/commandCodeAuth.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
coreDb.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
coreDb.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("status lookup expires stale pending command-code auth sessions", () => {
|
||||
const stateHash = commandCodeAuthDb.hashCommandCodeAuthState("expired-state");
|
||||
const session = commandCodeAuthDb.createPendingCommandCodeAuthSession({
|
||||
stateHash,
|
||||
expiresAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
assert.equal(session.status, "pending");
|
||||
|
||||
const status = commandCodeAuthDb.getCommandCodeAuthSessionSafeStatus(stateHash);
|
||||
|
||||
assert.equal(status?.status, "expired");
|
||||
assert.equal(status?.stateHash, stateHash);
|
||||
});
|
||||
|
||||
test("consume returns the received api key once and marks the session applied", () => {
|
||||
const stateHash = commandCodeAuthDb.hashCommandCodeAuthState("received-state");
|
||||
commandCodeAuthDb.createPendingCommandCodeAuthSession({
|
||||
stateHash,
|
||||
expiresAt: "2999-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const received = commandCodeAuthDb.markCommandCodeAuthSessionReceived({
|
||||
stateHash,
|
||||
apiKey: "sk-test-command-code",
|
||||
metadata: { userId: "user-1", userName: "Test User" },
|
||||
});
|
||||
|
||||
assert.equal(received?.status, "received");
|
||||
assert.equal(received?.metadata?.userId, "user-1");
|
||||
|
||||
const consumed = commandCodeAuthDb.consumeCommandCodeAuthSecret(stateHash);
|
||||
|
||||
assert.equal(consumed?.apiKey, "sk-test-command-code");
|
||||
assert.equal(consumed?.status, "applied");
|
||||
|
||||
assert.equal(commandCodeAuthDb.consumeCommandCodeAuthSecret(stateHash), null);
|
||||
assert.equal(commandCodeAuthDb.getCommandCodeAuthSessionSafeStatus(stateHash)?.status, "applied");
|
||||
});
|
||||
@@ -39,6 +39,7 @@ test("encryption stays in passthrough mode when no storage key is configured", a
|
||||
assert.equal(encryption.encrypt(""), "");
|
||||
assert.equal(encryption.decrypt(null), null);
|
||||
assert.equal(encryption.decrypt(undefined), undefined);
|
||||
assert.equal("validateEncryptionConfig" in encryption, false);
|
||||
});
|
||||
|
||||
test("encrypt/decrypt round-trip uses the expected serialized format", async () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ test("getEmbeddingDimension resolves known dimensions from the registry", () =>
|
||||
assert.equal(getEmbeddingDimension("openai/text-embedding-3-small"), 1536);
|
||||
assert.equal(getEmbeddingDimension("openai/text-embedding-3-large"), 3072);
|
||||
assert.equal(getEmbeddingDimension("nebius/Qwen/Qwen3-Embedding-8B"), 4096);
|
||||
assert.equal(getEmbeddingDimension("gemini/text-embedding-004"), 768);
|
||||
assert.equal(getEmbeddingDimension("gemini/gemini-embedding-001"), 768);
|
||||
// OpenRouter re-exports OpenAI ids under its own prefix at the same dimension.
|
||||
assert.equal(getEmbeddingDimension("openrouter/openai/text-embedding-3-small"), 1536);
|
||||
});
|
||||
|
||||
@@ -316,48 +316,16 @@ describe("encryption module", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateEncryptionConfig() with various key states", () => {
|
||||
it("should return valid when no key is set (passthrough mode)", async () => {
|
||||
describe("validateEncryptionConfig() removed as dead code (#5364)", () => {
|
||||
// The unused `validateEncryptionConfig` export was removed in #5364 (no
|
||||
// production caller). Guard that it stays gone, mirroring the sibling
|
||||
// node:test assertion in db-encryption.test.ts.
|
||||
it("no longer exports validateEncryptionConfig", async () => {
|
||||
vi.resetModules();
|
||||
|
||||
const { validateEncryptionConfig } = await import("@/lib/db/encryption");
|
||||
const mod = await import("@/lib/db/encryption");
|
||||
|
||||
const result = validateEncryptionConfig();
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return valid when a proper key is set", async () => {
|
||||
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345");
|
||||
vi.resetModules();
|
||||
|
||||
const { validateEncryptionConfig } = await import("@/lib/db/encryption");
|
||||
|
||||
const result = validateEncryptionConfig();
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return valid when key is empty string (treated as not set)", async () => {
|
||||
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "");
|
||||
vi.resetModules();
|
||||
|
||||
const { validateEncryptionConfig } = await import("@/lib/db/encryption");
|
||||
|
||||
const result = validateEncryptionConfig();
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return invalid when key is whitespace only", async () => {
|
||||
vi.stubEnv("STORAGE_ENCRYPTION_KEY", " ");
|
||||
vi.resetModules();
|
||||
|
||||
const { validateEncryptionConfig } = await import("@/lib/db/encryption");
|
||||
|
||||
const result = validateEncryptionConfig();
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("empty");
|
||||
expect("validateEncryptionConfig" in mod).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
61
tests/unit/event-bus.test.ts
Normal file
61
tests/unit/event-bus.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { beforeEach, describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
emit,
|
||||
getEventHistory,
|
||||
on,
|
||||
onAny,
|
||||
type HistoryEntry,
|
||||
} from "../../src/lib/events/eventBus.ts";
|
||||
|
||||
import * as eventBusPublicApi from "../../src/lib/events/eventBus.ts";
|
||||
|
||||
const requestStartedPayload = {
|
||||
id: "req-1",
|
||||
model: "gpt-test",
|
||||
provider: "openai",
|
||||
timestamp: 123,
|
||||
};
|
||||
|
||||
describe("eventBus", () => {
|
||||
beforeEach(() => {
|
||||
globalThis.__omnirouteEventBus = undefined;
|
||||
});
|
||||
|
||||
it("public surface excludes unused listener management helpers", () => {
|
||||
assert.equal("off" in eventBusPublicApi, false);
|
||||
assert.equal("removeAllListeners" in eventBusPublicApi, false);
|
||||
assert.equal("getBusStats" in eventBusPublicApi, false);
|
||||
});
|
||||
|
||||
it("emits to specific and wildcard listeners until unsubscribed", () => {
|
||||
const received: (typeof requestStartedPayload)[] = [];
|
||||
const wildcardEvents: string[] = [];
|
||||
|
||||
const unsubscribe = on("request.started", (payload) => {
|
||||
received.push(payload);
|
||||
});
|
||||
const unsubscribeAny = onAny((event) => {
|
||||
wildcardEvents.push(event);
|
||||
});
|
||||
|
||||
emit("request.started", requestStartedPayload);
|
||||
unsubscribe();
|
||||
unsubscribeAny();
|
||||
emit("request.started", { ...requestStartedPayload, id: "req-2" });
|
||||
|
||||
assert.deepEqual(received, [requestStartedPayload]);
|
||||
assert.deepEqual(wildcardEvents, ["request.started"]);
|
||||
});
|
||||
|
||||
it("keeps recent event history for late subscribers", () => {
|
||||
emit("request.started", requestStartedPayload);
|
||||
|
||||
const history: HistoryEntry[] = getEventHistory(undefined, 1);
|
||||
|
||||
assert.equal(history.length, 1);
|
||||
assert.equal(history[0]?.event, "request.started");
|
||||
assert.deepEqual(history[0]?.payload, requestStartedPayload);
|
||||
});
|
||||
});
|
||||
@@ -418,3 +418,12 @@ describe("featureFlagUpdateSchema validation", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings schema public surface", () => {
|
||||
it("uses databaseSettingsSchema as the canonical database settings export", async () => {
|
||||
const settingsSchemas = await import("../../src/shared/validation/settingsSchemas.ts");
|
||||
assert.equal("DatabaseSettingsSchema" in settingsSchemas, false);
|
||||
assert.equal("featureFlagUpdateSchema" in settingsSchemas, false);
|
||||
assert.equal(typeof settingsSchemas.databaseSettingsSchema.safeParse, "function");
|
||||
});
|
||||
});
|
||||
|
||||
22
tests/unit/formatting-public-surface.test.ts
Normal file
22
tests/unit/formatting-public-surface.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const formatting = await import("../../src/shared/utils/formatting.ts");
|
||||
const sseLogger = await import("../../src/sse/utils/logger.ts");
|
||||
|
||||
test("formatting utilities public surface excludes removed display helpers", () => {
|
||||
assert.equal(Object.hasOwn(formatting, "formatDateTime"), false);
|
||||
assert.equal(Object.hasOwn(formatting, "maskKey"), false);
|
||||
assert.equal(Object.hasOwn(formatting, "formatCostAbbreviated"), false);
|
||||
|
||||
assert.equal(formatting.formatTime("2026-06-29T12:34:56Z").length, 8);
|
||||
assert.equal(formatting.formatDuration(1250), "1.3s");
|
||||
assert.equal(formatting.maskSegment("abcdef", 2, 2), "ab***ef");
|
||||
assert.equal(formatting.formatCost(0.0123), "$0.0123");
|
||||
});
|
||||
|
||||
test("sse logger wrapper no longer re-exports formatting maskKey", () => {
|
||||
assert.equal(Object.hasOwn(sseLogger, "maskKey"), false);
|
||||
assert.equal(typeof sseLogger.debug, "function");
|
||||
assert.equal(typeof sseLogger.warn, "function");
|
||||
});
|
||||
100
tests/unit/key-health-402-disable-5239.test.ts
Normal file
100
tests/unit/key-health-402-disable-5239.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* TDD regression for #5239: an upstream HTTP 402 "Insufficient account balance"
|
||||
* must disable the depleted key on an API Key Round-Robin connection.
|
||||
*
|
||||
* Bug: `recordKeyHealthStatus()` only recorded a per-key failure for status 401.
|
||||
* Every other status (including 402) was ignored, so when multiple keys live on
|
||||
* ONE connection via `providerSpecificData.extraApiKeys`, a 402 on the selected
|
||||
* key never marked it invalid — the rotator kept returning the depleted key.
|
||||
*
|
||||
* Fix: a 402 branch marks the current key invalid immediately (terminal — the
|
||||
* balance won't recover mid-session), so `getValidApiKey()` skips it and the
|
||||
* rotator falls through to the remaining key. This test fails before the fix
|
||||
* (the 402'd key stays "active" and is still returned) and passes after.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-5239-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { recordKeyHealthStatus } = await import(
|
||||
"../../open-sse/handlers/chatCore/keyHealth.ts"
|
||||
);
|
||||
const { getValidApiKey, getAllKeyHealth, resetKeyStatus } = await import(
|
||||
"../../open-sse/services/apiKeyRotator.ts"
|
||||
);
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Two keys live on ONE connection as API Key Round-Robin (extraApiKeys[]).
|
||||
// No primary key, so the rotator only chooses among the two extras — making the
|
||||
// "skips invalid, returns the other" assertion deterministic.
|
||||
const K1 = "sk-depleted-402";
|
||||
const K2 = "sk-healthy-key";
|
||||
|
||||
function buildCreds(connId: string, selectedKeyId: string) {
|
||||
return {
|
||||
connectionId: connId,
|
||||
apiKey: selectedKeyId === "extra_0" ? K1 : K2,
|
||||
providerSpecificData: {
|
||||
extraApiKeys: [K1, K2],
|
||||
selectedKeyId,
|
||||
apiKeyHealth: {},
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
}
|
||||
|
||||
test("#5239 402 marks the selected round-robin key invalid and the rotator skips it", () => {
|
||||
const connId = "conn-5239-402";
|
||||
// Selected key is extra_0 (K1) — the one upstream rejected with 402.
|
||||
recordKeyHealthStatus(402, buildCreds(connId, "extra_0"));
|
||||
|
||||
// The 402'd key is now invalid in the in-memory rotator state.
|
||||
const allHealth = getAllKeyHealth();
|
||||
assert.equal(
|
||||
allHealth[`${connId}:extra_0`]?.status,
|
||||
"invalid",
|
||||
"402 must mark the selected key invalid in one shot"
|
||||
);
|
||||
|
||||
// The rotator must skip the depleted extra_0 (K1) and return extra_1 (K2).
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const next = getValidApiKey(connId, "", [K1, K2]);
|
||||
assert.ok(next, "a valid key should remain");
|
||||
assert.notEqual(next!.key, K1, "depleted 402 key must never be returned");
|
||||
assert.equal(next!.key, K2, "rotator should fall through to the healthy key");
|
||||
}
|
||||
|
||||
resetKeyStatus(connId, "extra_0");
|
||||
resetKeyStatus(connId, "extra_1");
|
||||
});
|
||||
|
||||
test("#5239 inverse: a 2xx keeps the key active (no false-positive disable)", () => {
|
||||
const connId = "conn-5239-2xx";
|
||||
recordKeyHealthStatus(200, buildCreds(connId, "extra_0"));
|
||||
|
||||
const allHealth = getAllKeyHealth();
|
||||
const entry = allHealth[`${connId}:extra_0`];
|
||||
// active (or absent — never invalidated) on success.
|
||||
assert.notEqual(entry?.status, "invalid", "a 2xx must not disable the key");
|
||||
|
||||
// Both keys remain selectable.
|
||||
const selected = new Set<string>();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const next = getValidApiKey(connId, "", [K1, K2]);
|
||||
if (next) selected.add(next.key);
|
||||
}
|
||||
assert.ok(selected.has(K1), "K1 must remain usable after a 2xx");
|
||||
assert.ok(selected.has(K2), "K2 must remain usable");
|
||||
|
||||
resetKeyStatus(connId, "extra_0");
|
||||
resetKeyStatus(connId, "extra_1");
|
||||
});
|
||||
@@ -1,16 +1,24 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
getCachedLoginShellPath,
|
||||
getLoginShellPath,
|
||||
mergeShellPath,
|
||||
parseShellPathOutput,
|
||||
} from "../../src/shared/services/loginShellPath.ts";
|
||||
import * as loginShellPath from "../../src/shared/services/loginShellPath.ts";
|
||||
|
||||
// Regression guards for #3321: macOS GUI/Electron apps don't inherit the login-shell PATH,
|
||||
// so Homebrew/nvm/volta CLIs were reported "not installed". We recover the real PATH from
|
||||
// the login shell and merge it into the lookup env. The pure helpers are tested here with
|
||||
// an injected shell runner (no macOS / no real shell needed).
|
||||
|
||||
test("login shell path public surface excludes removed cache reset helper", () => {
|
||||
assert.equal(Object.hasOwn(loginShellPath, "__resetLoginShellPathCacheForTesting"), false);
|
||||
assert.equal(typeof getCachedLoginShellPath, "function");
|
||||
assert.equal(typeof getLoginShellPath, "function");
|
||||
});
|
||||
|
||||
test("mergeShellPath unions and de-dupes, keeping base entries first", () => {
|
||||
assert.equal(
|
||||
mergeShellPath("/usr/bin:/bin", "/opt/homebrew/bin:/usr/bin", ":"),
|
||||
|
||||
@@ -17,9 +17,8 @@ const {
|
||||
handlePoolWarm,
|
||||
handleBrowserPoolStatus,
|
||||
} = await import("../../open-sse/mcp-server/tools/poolTools.ts");
|
||||
const { MCP_TOOL_SCOPES, MCP_SCOPE_LIST } = await import(
|
||||
"../../src/shared/constants/mcpScopes.ts"
|
||||
);
|
||||
const mcpScopesModule = await import("../../src/shared/constants/mcpScopes.ts");
|
||||
const { MCP_TOOL_SCOPES, MCP_SCOPE_LIST } = mcpScopesModule;
|
||||
|
||||
const POOL_TOOL_NAMES = [
|
||||
"omniroute_pool_status",
|
||||
@@ -107,6 +106,12 @@ test("read tools require read:health; lifecycle tools require write:resilience",
|
||||
assert.deepEqual(tools.omniroute_pool_warm.scopes, ["write:resilience"]);
|
||||
});
|
||||
|
||||
test("MCP scope constants public surface excludes unused helper bundles", () => {
|
||||
assert.equal("MCP_SCOPE_PRESETS" in mcpScopesModule, false);
|
||||
assert.equal("hasRequiredScopes" in mcpScopesModule, false);
|
||||
assert.equal("getMissingScopes" in mcpScopesModule, false);
|
||||
});
|
||||
|
||||
// ── Live handler behavior (against the in-memory PoolRegistry) ─────────────
|
||||
// No pools are created here, so the registry stays empty: status returns the
|
||||
// all-pools aggregate shape and per-provider tools return a clear error.
|
||||
@@ -155,9 +160,8 @@ test("handlePoolReset reports reset:false for an unknown provider", async () =>
|
||||
// ── #3368 PR7 — browser pool observability ────────────────────────────────
|
||||
|
||||
test("handleBrowserPoolStatus returns status + cumulative metrics shape", async () => {
|
||||
const { __resetBrowserPoolMetricsForTest } = await import(
|
||||
"../../open-sse/services/browserPool.ts"
|
||||
);
|
||||
const { __resetBrowserPoolMetricsForTest } =
|
||||
await import("../../open-sse/services/browserPool.ts");
|
||||
__resetBrowserPoolMetricsForTest();
|
||||
|
||||
const result = (await handleBrowserPoolStatus()) as {
|
||||
@@ -184,9 +188,8 @@ test("handleBrowserPoolStatus returns status + cumulative metrics shape", async
|
||||
});
|
||||
|
||||
test("shutdownPool increments the shutdowns counter and records the reason", async () => {
|
||||
const { shutdownPool, getBrowserPoolMetrics, __resetBrowserPoolMetricsForTest } = await import(
|
||||
"../../open-sse/services/browserPool.ts"
|
||||
);
|
||||
const { shutdownPool, getBrowserPoolMetrics, __resetBrowserPoolMetricsForTest } =
|
||||
await import("../../open-sse/services/browserPool.ts");
|
||||
__resetBrowserPoolMetricsForTest();
|
||||
|
||||
await shutdownPool("unit-test-reason");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveEmbeddingSource } from "../../src/lib/memory/embedding/index";
|
||||
const embeddingPublicApi = await import("../../src/lib/memory/embedding/index");
|
||||
import type { MemorySettingsExtended } from "../../src/shared/schemas/memory";
|
||||
|
||||
function makeSettings(overrides: Partial<MemorySettingsExtended> = {}): MemorySettingsExtended {
|
||||
@@ -17,6 +18,10 @@ function makeSettings(overrides: Partial<MemorySettingsExtended> = {}): MemorySe
|
||||
}
|
||||
|
||||
describe("resolveEmbeddingSource", () => {
|
||||
it("public surface excludes unused cache invalidation wrapper", () => {
|
||||
assert.equal("invalidateEmbeddingCache" in embeddingPublicApi, false);
|
||||
});
|
||||
|
||||
it("auto + no key + no static + no transformers => source null", () => {
|
||||
const res = resolveEmbeddingSource(makeSettings({ embeddingSource: "auto" }));
|
||||
assert.strictEqual(res.source, null);
|
||||
@@ -28,39 +33,47 @@ describe("resolveEmbeddingSource", () => {
|
||||
});
|
||||
|
||||
it("auto + embeddingProviderModel set to openai/... => source remote", () => {
|
||||
const res = resolveEmbeddingSource(makeSettings({
|
||||
embeddingSource: "auto",
|
||||
embeddingProviderModel: "openai/text-embedding-3-small",
|
||||
}));
|
||||
const res = resolveEmbeddingSource(
|
||||
makeSettings({
|
||||
embeddingSource: "auto",
|
||||
embeddingProviderModel: "openai/text-embedding-3-small",
|
||||
})
|
||||
);
|
||||
assert.strictEqual(res.source, "remote");
|
||||
assert.strictEqual(res.model, "openai/text-embedding-3-small");
|
||||
});
|
||||
|
||||
it("auto + no model + staticEnabled=true => source static", () => {
|
||||
const res = resolveEmbeddingSource(makeSettings({
|
||||
embeddingSource: "auto",
|
||||
embeddingProviderModel: null,
|
||||
staticEnabled: true,
|
||||
}));
|
||||
const res = resolveEmbeddingSource(
|
||||
makeSettings({
|
||||
embeddingSource: "auto",
|
||||
embeddingProviderModel: null,
|
||||
staticEnabled: true,
|
||||
})
|
||||
);
|
||||
assert.strictEqual(res.source, "static");
|
||||
assert.ok(res.model !== null);
|
||||
});
|
||||
|
||||
it("auto + no model + staticEnabled=false + transformersEnabled=true => source transformers", () => {
|
||||
const res = resolveEmbeddingSource(makeSettings({
|
||||
embeddingSource: "auto",
|
||||
embeddingProviderModel: null,
|
||||
staticEnabled: false,
|
||||
transformersEnabled: true,
|
||||
}));
|
||||
const res = resolveEmbeddingSource(
|
||||
makeSettings({
|
||||
embeddingSource: "auto",
|
||||
embeddingProviderModel: null,
|
||||
staticEnabled: false,
|
||||
transformersEnabled: true,
|
||||
})
|
||||
);
|
||||
assert.strictEqual(res.source, "transformers");
|
||||
});
|
||||
|
||||
it("explicit 'remote' + no model => source null with no_key reason", () => {
|
||||
const res = resolveEmbeddingSource(makeSettings({
|
||||
embeddingSource: "remote",
|
||||
embeddingProviderModel: null,
|
||||
}));
|
||||
const res = resolveEmbeddingSource(
|
||||
makeSettings({
|
||||
embeddingSource: "remote",
|
||||
embeddingProviderModel: null,
|
||||
})
|
||||
);
|
||||
assert.strictEqual(res.source, null);
|
||||
// The reason must reference the missing key, not just be non-empty.
|
||||
assert.ok(
|
||||
@@ -70,43 +83,53 @@ describe("resolveEmbeddingSource", () => {
|
||||
});
|
||||
|
||||
it("explicit 'remote' + model set => source remote (no fallback)", () => {
|
||||
const res = resolveEmbeddingSource(makeSettings({
|
||||
embeddingSource: "remote",
|
||||
embeddingProviderModel: "openai/text-embedding-3-small",
|
||||
}));
|
||||
const res = resolveEmbeddingSource(
|
||||
makeSettings({
|
||||
embeddingSource: "remote",
|
||||
embeddingProviderModel: "openai/text-embedding-3-small",
|
||||
})
|
||||
);
|
||||
assert.strictEqual(res.source, "remote");
|
||||
assert.strictEqual(res.model, "openai/text-embedding-3-small");
|
||||
});
|
||||
|
||||
it("explicit 'static' + staticEnabled=true => source static", () => {
|
||||
const res = resolveEmbeddingSource(makeSettings({
|
||||
embeddingSource: "static",
|
||||
staticEnabled: true,
|
||||
}));
|
||||
const res = resolveEmbeddingSource(
|
||||
makeSettings({
|
||||
embeddingSource: "static",
|
||||
staticEnabled: true,
|
||||
})
|
||||
);
|
||||
assert.strictEqual(res.source, "static");
|
||||
});
|
||||
|
||||
it("explicit 'static' + staticEnabled=false => source null", () => {
|
||||
const res = resolveEmbeddingSource(makeSettings({
|
||||
embeddingSource: "static",
|
||||
staticEnabled: false,
|
||||
}));
|
||||
const res = resolveEmbeddingSource(
|
||||
makeSettings({
|
||||
embeddingSource: "static",
|
||||
staticEnabled: false,
|
||||
})
|
||||
);
|
||||
assert.strictEqual(res.source, null);
|
||||
});
|
||||
|
||||
it("explicit 'transformers' + transformersEnabled=true => source transformers", () => {
|
||||
const res = resolveEmbeddingSource(makeSettings({
|
||||
embeddingSource: "transformers",
|
||||
transformersEnabled: true,
|
||||
}));
|
||||
const res = resolveEmbeddingSource(
|
||||
makeSettings({
|
||||
embeddingSource: "transformers",
|
||||
transformersEnabled: true,
|
||||
})
|
||||
);
|
||||
assert.strictEqual(res.source, "transformers");
|
||||
});
|
||||
|
||||
it("explicit 'transformers' + transformersEnabled=false => source null", () => {
|
||||
const res = resolveEmbeddingSource(makeSettings({
|
||||
embeddingSource: "transformers",
|
||||
transformersEnabled: false,
|
||||
}));
|
||||
const res = resolveEmbeddingSource(
|
||||
makeSettings({
|
||||
embeddingSource: "transformers",
|
||||
transformersEnabled: false,
|
||||
})
|
||||
);
|
||||
assert.strictEqual(res.source, null);
|
||||
});
|
||||
|
||||
@@ -121,11 +144,16 @@ describe("resolveEmbeddingSource", () => {
|
||||
});
|
||||
|
||||
it("signature contains source:model:dim components", () => {
|
||||
const res = resolveEmbeddingSource(makeSettings({
|
||||
embeddingSource: "static",
|
||||
staticEnabled: true,
|
||||
}));
|
||||
assert.ok(res.signature.includes("static"), `signature should contain 'static': ${res.signature}`);
|
||||
const res = resolveEmbeddingSource(
|
||||
makeSettings({
|
||||
embeddingSource: "static",
|
||||
staticEnabled: true,
|
||||
})
|
||||
);
|
||||
assert.ok(
|
||||
res.signature.includes("static"),
|
||||
`signature should contain 'static': ${res.signature}`
|
||||
);
|
||||
assert.ok(res.signature.includes(":"), "signature should contain colons");
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,13 @@ test("resolveModelAlias: resolves deprecated Gemini model", () => {
|
||||
assert.equal(resolveModelAlias("gemini-pro"), "gemini-2.5-pro");
|
||||
assert.equal(resolveModelAlias("gemini-1.5-pro"), "gemini-2.5-pro");
|
||||
assert.equal(resolveModelAlias("gemini-1.5-flash"), "gemini-2.5-flash");
|
||||
// Retired 2.0 Flash-Lite (Google shutdown 2026-06-01) + renamed flash-lite preview
|
||||
// both forward to the live GA gemini-3.1-flash-lite.
|
||||
assert.equal(resolveModelAlias("gemini-2.0-flash-lite"), "gemini-3.1-flash-lite");
|
||||
assert.equal(resolveModelAlias("gemini-3.1-flash-lite-preview"), "gemini-3.1-flash-lite");
|
||||
// Retired free Gemma (was in the gemini-free pool) forwards to the current
|
||||
// gemini-free model instead of erroring with model-not-found.
|
||||
assert.equal(resolveModelAlias("gemma-4"), "gemini-3.1-flash-lite");
|
||||
});
|
||||
|
||||
test("resolveModelAlias: resolves deprecated Claude model", () => {
|
||||
|
||||
@@ -274,8 +274,7 @@ test("modelsDev capabilities helpers create the table, persist rows, filter by p
|
||||
assert.equal(allCaps.anthropic["claude-sonnet-4-20250514"].attachment, true);
|
||||
assert.deepEqual(Object.keys(openaiOnly), ["openai"]);
|
||||
assert.equal(openaiOnly.openai["gpt-4o"].limit_context, 128000);
|
||||
assert.equal(modelsDev.getModelContextLimit("openai", "gpt-4o"), 128000);
|
||||
assert.equal(modelsDev.getModelContextLimit("openai", "missing"), null);
|
||||
assert.equal("getModelContextLimit" in modelsDev, false);
|
||||
|
||||
modelsDev.clearModelsDevCapabilities();
|
||||
assert.deepEqual(modelsDev.getSyncedCapabilities(), {});
|
||||
|
||||
@@ -33,3 +33,10 @@ test("modular schemas: loginSchema validates correctly", () => {
|
||||
});
|
||||
assert.equal(invalid.success, false);
|
||||
});
|
||||
|
||||
test("validation helpers only export request-body helper APIs", async () => {
|
||||
const helpers = await import("../../src/shared/validation/helpers.ts");
|
||||
assert.equal("loginSchema" in helpers, false);
|
||||
assert.equal(typeof helpers.validateBody, "function");
|
||||
assert.equal(typeof helpers.isValidationFailure, "function");
|
||||
});
|
||||
|
||||
93
tests/unit/noauth-proxy-resolution.test.ts
Normal file
93
tests/unit/noauth-proxy-resolution.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
resolveAccountProxies,
|
||||
type ProxyByIdLookup,
|
||||
} from "../../src/sse/services/noAuthProxyResolution.ts";
|
||||
|
||||
// #5217 (Gap 1): per-account Proxy Pool reference resolution.
|
||||
// The OpenCode Free per-account proxy modal now stores a proxy REFERENCE (proxyId)
|
||||
// instead of forcing manual host/port re-entry. The server resolves that id to the
|
||||
// live pool record so the executor still receives an inline {type,host,port,...}.
|
||||
|
||||
const POOL: Record<string, { type: string; host: string; port: number; username?: string; password?: string }> = {
|
||||
"pool-1": { type: "http", host: "1.2.3.4", port: 8080, username: "u", password: "p" },
|
||||
"pool-2": { type: "socks5", host: "9.9.9.9", port: 1080 },
|
||||
};
|
||||
|
||||
const lookup: ProxyByIdLookup = async (id) => POOL[id] ?? null;
|
||||
|
||||
test("by-id reference resolves to the pool record (type/host/port/credentials)", async () => {
|
||||
const out = await resolveAccountProxies([{ fingerprint: "acc-a", proxyId: "pool-1" }], lookup);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].fingerprint, "acc-a");
|
||||
assert.deepEqual(out[0].proxy, {
|
||||
type: "http",
|
||||
host: "1.2.3.4",
|
||||
port: 8080,
|
||||
username: "u",
|
||||
password: "p",
|
||||
});
|
||||
});
|
||||
|
||||
test("by-id reference without credentials omits username/password", async () => {
|
||||
const out = await resolveAccountProxies([{ fingerprint: "acc-b", proxyId: "pool-2" }], lookup);
|
||||
assert.deepEqual(out[0].proxy, { type: "socks5", host: "9.9.9.9", port: 1080 });
|
||||
});
|
||||
|
||||
test("inline custom proxy passes through unchanged (escape hatch / legacy)", async () => {
|
||||
const inline = { type: "https", host: "5.6.7.8", port: 3128, username: "x", password: "y" };
|
||||
const out = await resolveAccountProxies([{ fingerprint: "acc-c", proxy: inline }], lookup);
|
||||
assert.deepEqual(out[0].proxy, inline);
|
||||
});
|
||||
|
||||
test("unknown / deleted proxyId degrades safely to direct (null), no crash", async () => {
|
||||
const out = await resolveAccountProxies([{ fingerprint: "acc-d", proxyId: "gone" }], lookup);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].proxy, null);
|
||||
});
|
||||
|
||||
test("a throwing lookup degrades to direct (null) rather than rejecting", async () => {
|
||||
const throwing: ProxyByIdLookup = async () => {
|
||||
throw new Error("db down");
|
||||
};
|
||||
const out = await resolveAccountProxies([{ fingerprint: "acc-e", proxyId: "pool-1" }], throwing);
|
||||
assert.equal(out[0].proxy, null);
|
||||
});
|
||||
|
||||
test("proxyId takes precedence over an inline proxy on the same entry", async () => {
|
||||
const out = await resolveAccountProxies(
|
||||
[{ fingerprint: "acc-f", proxyId: "pool-2", proxy: { type: "http", host: "0.0.0.0", port: 1 } }],
|
||||
lookup
|
||||
);
|
||||
assert.equal(out[0].proxy?.host, "9.9.9.9");
|
||||
});
|
||||
|
||||
test("entry with neither proxyId nor proxy.host yields direct (null)", async () => {
|
||||
const out = await resolveAccountProxies(
|
||||
[{ fingerprint: "acc-g" }, { fingerprint: "acc-h", proxy: null }],
|
||||
lookup
|
||||
);
|
||||
assert.equal(out[0].proxy, null);
|
||||
assert.equal(out[1].proxy, null);
|
||||
});
|
||||
|
||||
test("non-array / malformed input is ignored without throwing", async () => {
|
||||
assert.deepEqual(await resolveAccountProxies(undefined, lookup), []);
|
||||
assert.deepEqual(await resolveAccountProxies(null, lookup), []);
|
||||
const out = await resolveAccountProxies(
|
||||
[null, 42, "x", { proxyId: "pool-1" }, { fingerprint: "ok", proxyId: "pool-2" }],
|
||||
lookup
|
||||
);
|
||||
// Only the well-formed entry (with a string fingerprint) survives.
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].fingerprint, "ok");
|
||||
});
|
||||
|
||||
test("inline proxy missing host is treated as direct (null)", async () => {
|
||||
const out = await resolveAccountProxies(
|
||||
[{ fingerprint: "acc-i", proxy: { type: "http", port: 8080 } as never }],
|
||||
lookup
|
||||
);
|
||||
assert.equal(out[0].proxy, null);
|
||||
});
|
||||
43
tests/unit/oauth-connection-tokenexpiresat-5326.test.ts
Normal file
43
tests/unit/oauth-connection-tokenexpiresat-5326.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildOAuthConnectionCreatePayload } from "../../src/lib/oauth/connectionPersistence.ts";
|
||||
|
||||
// Regression for #5326: a freshly created OAuth connection (e.g. antigravity) used
|
||||
// to persist only `expiresAt`, leaving `tokenExpiresAt` null. The dashboard token
|
||||
// badge prefers `tokenExpiresAt` and falls back to the original grant clock when it
|
||||
// is null, flashing a false "Token Expired" until the first background refresh.
|
||||
// The create payload must mirror the computed expiry into BOTH fields.
|
||||
test("buildOAuthConnectionCreatePayload mirrors expiresAt into tokenExpiresAt (#5326)", () => {
|
||||
const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString();
|
||||
const tokenData = {
|
||||
accessToken: "at-123",
|
||||
refreshToken: "rt-123",
|
||||
email: "user@example.com",
|
||||
expiresIn: 3600,
|
||||
};
|
||||
|
||||
const payload = buildOAuthConnectionCreatePayload("antigravity", tokenData, expiresAt);
|
||||
|
||||
assert.equal(payload.provider, "antigravity");
|
||||
assert.equal(payload.authType, "oauth");
|
||||
assert.equal(payload.testStatus, "active");
|
||||
assert.equal(payload.expiresAt, expiresAt);
|
||||
// The fix: tokenExpiresAt is set (was null/undefined before) and equals expiresAt.
|
||||
assert.equal(payload.tokenExpiresAt, expiresAt);
|
||||
assert.equal(payload.tokenExpiresAt, payload.expiresAt);
|
||||
// tokenData fields are still carried through.
|
||||
assert.equal(payload.accessToken, "at-123");
|
||||
assert.equal(payload.refreshToken, "rt-123");
|
||||
});
|
||||
|
||||
test("buildOAuthConnectionCreatePayload keeps tokenExpiresAt null when expiry is unknown", () => {
|
||||
const payload = buildOAuthConnectionCreatePayload(
|
||||
"antigravity",
|
||||
{ accessToken: "at-456" },
|
||||
null
|
||||
);
|
||||
|
||||
assert.equal(payload.expiresAt, null);
|
||||
assert.equal(payload.tokenExpiresAt, null);
|
||||
});
|
||||
@@ -13,6 +13,12 @@ const { getProvider } = oauthHandlers;
|
||||
// Registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("oauth provider helpers public surface excludes removed provider-name list helper", () => {
|
||||
assert.equal(Object.hasOwn(oauthHandlers, "getProviderNames"), false);
|
||||
assert.equal(typeof oauthHandlers.getProvider, "function");
|
||||
assert.equal(typeof oauthHandlers.generateAuthData, "function");
|
||||
});
|
||||
|
||||
test("PROVIDERS map includes a 'trae' entry", () => {
|
||||
assert.ok("trae" in PROVIDERS, "PROVIDERS must contain trae");
|
||||
});
|
||||
|
||||
@@ -136,8 +136,15 @@ test("CircuitBreaker: calls onStateChange callback", async () => {
|
||||
|
||||
// ─── Request Timeout Tests ───────────────────────────
|
||||
|
||||
import * as requestTimeout from "../../src/shared/utils/requestTimeout.ts";
|
||||
import { withTimeout, getProviderTimeout } from "../../src/shared/utils/requestTimeout.ts";
|
||||
|
||||
test("requestTimeout: public surface excludes removed fetch wrapper", () => {
|
||||
assert.equal(Object.hasOwn(requestTimeout, "fetchWithTimeout"), false);
|
||||
assert.equal(typeof requestTimeout.withTimeout, "function");
|
||||
assert.equal(typeof requestTimeout.getProviderTimeout, "function");
|
||||
});
|
||||
|
||||
test("requestTimeout: withTimeout resolves before timeout", async () => {
|
||||
const result = await withTimeout(async () => "fast", 1000, "test");
|
||||
assert.equal(result, "fast");
|
||||
@@ -164,7 +171,15 @@ test("requestTimeout: getProviderTimeout returns provider-specific value", () =>
|
||||
|
||||
// ─── Correlation ID Tests ────────────────────────────
|
||||
|
||||
import { getCorrelationId, runWithCorrelation } from "../../src/shared/middleware/correlationId.ts";
|
||||
import * as correlationId from "../../src/shared/middleware/correlationId.ts";
|
||||
const { getCorrelationId, runWithCorrelation } = correlationId;
|
||||
|
||||
test("correlationId: public surface excludes unused wrapper helpers", () => {
|
||||
assert.equal(Object.hasOwn(correlationId, "correlationMiddleware"), false);
|
||||
assert.equal(Object.hasOwn(correlationId, "createCorrelatedLogger"), false);
|
||||
assert.equal(typeof correlationId.getCorrelationId, "function");
|
||||
assert.equal(typeof correlationId.runWithCorrelation, "function");
|
||||
});
|
||||
|
||||
test("correlationId: getCorrelationId returns undefined outside context", () => {
|
||||
assert.equal(getCorrelationId(), undefined);
|
||||
|
||||
64
tests/unit/openai-to-claude-empty-messages-5245.test.ts
Normal file
64
tests/unit/openai-to-claude-empty-messages-5245.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { openaiToClaudeRequest } =
|
||||
await import("../../open-sse/translator/request/openai-to-claude.ts");
|
||||
|
||||
// Regression: an all-system OpenAI request (e.g. an all-system compaction or
|
||||
// title-generation turn from a client like OpenCode) hoists every
|
||||
// system/developer message into Claude's top-level `system` field, leaving the
|
||||
// `messages` array empty. Claude's Messages API then rejects the request with
|
||||
// `400 messages: at least one message is required`. The converter must
|
||||
// synthesize a minimal user turn so the request stays valid. (#5245)
|
||||
|
||||
test("openaiToClaudeRequest: all-system input never yields an empty messages array", () => {
|
||||
const result = openaiToClaudeRequest(
|
||||
"claude-sonnet-4-6",
|
||||
{
|
||||
messages: [
|
||||
{ role: "system", content: "You summarize." },
|
||||
{ role: "system", content: "Summarize the conversation so far." },
|
||||
],
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
// system content is hoisted to the top-level `system` field …
|
||||
assert.ok(result.system, "system content should be hoisted to result.system");
|
||||
// … but `messages` must not be empty (would 400 upstream).
|
||||
assert.ok(Array.isArray(result.messages));
|
||||
assert.equal(result.messages.length, 1);
|
||||
assert.equal(result.messages[0].role, "user");
|
||||
// content block must be non-empty text (Anthropic rejects empty text blocks).
|
||||
const block = result.messages[0].content[0];
|
||||
assert.equal(block.type, "text");
|
||||
assert.ok(typeof block.text === "string" && block.text.length > 0);
|
||||
});
|
||||
|
||||
test("openaiToClaudeRequest: developer-only input also gets a synthesized user turn", () => {
|
||||
const result = openaiToClaudeRequest(
|
||||
"claude-sonnet-4-6",
|
||||
{ messages: [{ role: "developer", content: "Follow these rules." }] },
|
||||
false
|
||||
);
|
||||
assert.equal(result.messages.length, 1);
|
||||
assert.equal(result.messages[0].role, "user");
|
||||
});
|
||||
|
||||
test("openaiToClaudeRequest: normal system+user request is unaffected by the guard", () => {
|
||||
const result = openaiToClaudeRequest(
|
||||
"claude-sonnet-4-6",
|
||||
{
|
||||
messages: [
|
||||
{ role: "system", content: "You summarize." },
|
||||
{ role: "user", content: "Summarize: hello world" },
|
||||
],
|
||||
},
|
||||
false
|
||||
);
|
||||
// Exactly the real user turn — no synthesized placeholder appended.
|
||||
assert.equal(result.messages.length, 1);
|
||||
assert.equal(result.messages[0].role, "user");
|
||||
const text = JSON.stringify(result.messages[0].content);
|
||||
assert.ok(text.includes("hello world"));
|
||||
});
|
||||
87
tests/unit/openai-to-claude-redacted-replay-5312.test.ts
Normal file
87
tests/unit/openai-to-claude-redacted-replay-5312.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* TDD regression for #5312 (FIX D / RC-D): openai-to-claude reconstructed a Claude
|
||||
* `thinking` block from signature-less `reasoning_content` and stamped it with the
|
||||
* fabricated DEFAULT_THINKING_CLAUDE_SIGNATURE. Anthropic validates signatures and
|
||||
* rejects the fake one with 400 "Invalid signature in thinking block" — and
|
||||
* claudeHelper's latest-assistant guard preserves the block verbatim, so the fake
|
||||
* signature leaks upstream.
|
||||
*
|
||||
* Fix: emit a signature-less `redacted_thinking` placeholder (matching what
|
||||
* prepareClaudeRequest produces downstream). A REAL part.signature must always be
|
||||
* preserved verbatim — never overwritten with the default.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { openaiToClaudeRequest } = await import(
|
||||
"../../open-sse/translator/request/openai-to-claude.ts"
|
||||
);
|
||||
const { DEFAULT_THINKING_CLAUDE_SIGNATURE } = await import(
|
||||
"../../open-sse/config/defaultThinkingSignature.ts"
|
||||
);
|
||||
|
||||
test("#5312 RC-D: signature-less reasoning_content yields no fabricated-signature thinking block", () => {
|
||||
const result = openaiToClaudeRequest(
|
||||
"claude-opus-4-8",
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "assistant", reasoning_content: "thinking about it", content: "hi there" },
|
||||
],
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const assistant = result.messages.find((m) => m.role === "assistant");
|
||||
assert.ok(assistant, "expected assistant message");
|
||||
|
||||
// No block may carry the fabricated default signature.
|
||||
const fake = assistant.content.find(
|
||||
(b) => b && b.signature === DEFAULT_THINKING_CLAUDE_SIGNATURE
|
||||
);
|
||||
assert.equal(fake, undefined, "must NOT emit a thinking block with the fabricated signature");
|
||||
|
||||
// No `thinking`-typed block at all from signature-less reasoning_content.
|
||||
assert.equal(
|
||||
assistant.content.find((b) => b && b.type === "thinking"),
|
||||
undefined,
|
||||
"signature-less reasoning_content must not produce a `thinking` block"
|
||||
);
|
||||
|
||||
// It becomes a redacted_thinking placeholder (Anthropic accepts without sig check).
|
||||
const redacted = assistant.content.find((b) => b && b.type === "redacted_thinking");
|
||||
assert.ok(redacted, "expected a redacted_thinking placeholder");
|
||||
assert.equal(redacted.data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
|
||||
assert.equal(redacted.signature, undefined, "redacted_thinking must not carry a signature");
|
||||
});
|
||||
|
||||
test("#5312 RC-D: a REAL thinking signature is preserved verbatim", () => {
|
||||
const REAL_SIG = "ErUBCkYI... real-anthropic-signature ...xyz==";
|
||||
const result = openaiToClaudeRequest(
|
||||
"claude-opus-4-8",
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "real reasoning", signature: REAL_SIG },
|
||||
{ type: "text", text: "answer" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const assistant = result.messages.find((m) => m.role === "assistant");
|
||||
assert.ok(assistant, "expected assistant message");
|
||||
const thinking = assistant.content.find((b) => b && b.type === "thinking");
|
||||
assert.ok(thinking, "expected the real thinking block to survive");
|
||||
assert.equal(thinking.signature, REAL_SIG, "real signature must be preserved verbatim");
|
||||
assert.notEqual(
|
||||
thinking.signature,
|
||||
DEFAULT_THINKING_CLAUDE_SIGNATURE,
|
||||
"real signature must never be overwritten with the default"
|
||||
);
|
||||
});
|
||||
@@ -2,7 +2,10 @@ import { describe, it, beforeEach, afterEach, before, after } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import net from "node:net";
|
||||
import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts";
|
||||
import { resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts";
|
||||
import {
|
||||
resolveProxyForRequest,
|
||||
runWithAppliedProxyCapture,
|
||||
} from "../../open-sse/utils/proxyFetch.ts";
|
||||
|
||||
/**
|
||||
* #4954 — "OpenCode Free" exposes per-account proxy + multi-account rotation in
|
||||
@@ -165,4 +168,78 @@ describe("OpencodeExecutor per-account proxy + rotation (#4954)", () => {
|
||||
assert.strictEqual(p.source, "context", "every dispatch must egress through a proxy context");
|
||||
}
|
||||
});
|
||||
|
||||
// #5217 (Gap 2): the per-request account/proxy selection log was log.debug, which
|
||||
// is hidden at the default APP_LOG_LEVEL=info — operators could not see which
|
||||
// account/proxy a request rotated to. It must be emitted at info level.
|
||||
it("logs the account/proxy rotation selection at info level (#5217)", async () => {
|
||||
const exec = new OpencodeExecutor("opencode-zen");
|
||||
installFetchStub([200]);
|
||||
|
||||
const infoCalls: Array<{ tag: unknown; msg: string }> = [];
|
||||
const debugCalls: Array<{ tag: unknown; msg: string }> = [];
|
||||
const spyLog = {
|
||||
debug: (tag: unknown, msg: string) => debugCalls.push({ tag, msg }),
|
||||
info: (tag: unknown, msg: string) => infoCalls.push({ tag, msg }),
|
||||
warn() {},
|
||||
error() {},
|
||||
};
|
||||
|
||||
await exec.execute({
|
||||
model: "grok-code",
|
||||
body: { messages: [{ role: "user", content: "hi" }], stream: false },
|
||||
stream: false,
|
||||
signal: null,
|
||||
credentials: credentialsWithProxies(),
|
||||
log: spyLog as any,
|
||||
});
|
||||
|
||||
const dispatchInfo = infoCalls.find(
|
||||
(c) => c.tag === "OPENCODE" && /dispatch via account/.test(c.msg)
|
||||
);
|
||||
assert.ok(
|
||||
dispatchInfo,
|
||||
`expected an info-level "dispatch via account …" log; info calls=${JSON.stringify(infoCalls)}`
|
||||
);
|
||||
// The selection line must carry the masked account id + rotation index, and
|
||||
// must NOT be emitted at debug (where it would be invisible at default level).
|
||||
assert.match(dispatchInfo!.msg, /account aaaaaaaa…|account bbbbbbbb…/);
|
||||
assert.match(dispatchInfo!.msg, /idx \d+\/2/);
|
||||
assert.ok(
|
||||
!debugCalls.some((c) => /dispatch via account/.test(c.msg)),
|
||||
"the selection log must not also/only be at debug level"
|
||||
);
|
||||
// Masking guard: never log the full 32-char account id.
|
||||
assert.ok(
|
||||
!/(a{32}|b{32})/.test(dispatchInfo!.msg),
|
||||
"rotation log must keep the account id masked"
|
||||
);
|
||||
});
|
||||
|
||||
// #5217 (secondary): the per-account proxy the executor pins internally must be
|
||||
// captured into an AppliedProxySink so the post-execution egress logger reflects
|
||||
// the real egress (was "direct") rather than the pre-resolved connection proxy.
|
||||
it("records the executor-applied account proxy into the AppliedProxySink (#5217)", async () => {
|
||||
const exec = new OpencodeExecutor("opencode-zen");
|
||||
installFetchStub([200]);
|
||||
|
||||
const sink: { proxy: any } = { proxy: null };
|
||||
await runWithAppliedProxyCapture(sink, () =>
|
||||
exec.execute({
|
||||
model: "grok-code",
|
||||
body: { messages: [{ role: "user", content: "hi" }], stream: false },
|
||||
stream: false,
|
||||
signal: null,
|
||||
credentials: credentialsWithProxies(),
|
||||
log,
|
||||
})
|
||||
);
|
||||
|
||||
assert.ok(sink.proxy, "sink must capture the proxy the executor actually applied");
|
||||
assert.equal(sink.proxy.host, "127.0.0.1", "captured proxy host must match the account proxy");
|
||||
assert.ok(
|
||||
sink.proxy.port === portA || sink.proxy.port === portB,
|
||||
`captured proxy port must be one of the configured account proxies, got ${sink.proxy.port}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
// Characterization of the pricing.ts split (god-file decomposition): the host became a barrel that
|
||||
// re-exports DEFAULT_PRICING (now merged from 4 semantic family files that import shared tier consts)
|
||||
// and keeps the 3 helper functions. Pure-data move → behavior identical. Locks: public surface, the
|
||||
// and keeps the helper functions. Pure-data move → behavior identical. Locks: public surface, the
|
||||
// spread-merge integrity, and that lookups/cost math resolve unchanged.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const P = await import("../../src/shared/constants/pricing.ts");
|
||||
|
||||
test("barrel still exports DEFAULT_PRICING + the 3 helpers", () => {
|
||||
for (const name of [
|
||||
"DEFAULT_PRICING",
|
||||
"getPricingForModel",
|
||||
"getDefaultPricing",
|
||||
"calculateCostFromTokens",
|
||||
]) {
|
||||
test("barrel still exports DEFAULT_PRICING + supported helpers", () => {
|
||||
for (const name of ["DEFAULT_PRICING", "getPricingForModel", "getDefaultPricing"]) {
|
||||
assert.ok(name in P, `missing export: ${name}`);
|
||||
}
|
||||
assert.equal(Object.hasOwn(P, "calculateCostFromTokens"), false);
|
||||
});
|
||||
|
||||
test("DEFAULT_PRICING merges the 4 family files; families partition all entries", async () => {
|
||||
@@ -49,8 +45,7 @@ test("shared tier consts feed the parts (a known model resolves to a shared rate
|
||||
assert.equal(typeof (pricing as { input?: number }).input, "number");
|
||||
});
|
||||
|
||||
test("calculateCostFromTokens stays callable and numeric", () => {
|
||||
const fn = (P as Record<string, (...a: unknown[]) => unknown>).calculateCostFromTokens;
|
||||
const out = fn("openai", "gpt-4o", { prompt_tokens: 1000, completion_tokens: 1000 });
|
||||
assert.equal(typeof out, "number");
|
||||
test("formatCost remains re-exported from the pricing barrel", () => {
|
||||
const fn = (P as Record<string, (value: number) => string>).formatCost;
|
||||
assert.equal(fn(0.0123), "$0.0123");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-provider-limits-sync-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
|
||||
const schedulerModule = await import("../../src/shared/services/providerLimitsSyncScheduler.ts");
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("provider limits sync scheduler public surface excludes unused stop helper", () => {
|
||||
assert.equal("startProviderLimitsSyncScheduler" in schedulerModule, true);
|
||||
assert.equal("stopProviderLimitsSyncScheduler" in schedulerModule, false);
|
||||
});
|
||||
@@ -15,6 +15,7 @@ const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-v1-provid
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const serviceModelsDb = await import("../../src/lib/db/serviceModels.ts");
|
||||
const routeModule = await import(
|
||||
"../../src/app/api/v1/providers/[provider]/models/route.ts"
|
||||
);
|
||||
@@ -71,6 +72,28 @@ test("GET /v1/providers/:provider/models accepts anthropic-compatible connection
|
||||
}
|
||||
});
|
||||
|
||||
test("GET /v1/providers/:provider/models returns synced embedded service models", async () => {
|
||||
serviceModelsDb.saveServiceModels("cliproxyapi", [
|
||||
{ id: "cli/gpt-5", name: "GPT-5 via CLIProxyAPI" },
|
||||
{ id: "old-model" },
|
||||
]);
|
||||
serviceModelsDb.saveServiceModels("cliproxyapi", [
|
||||
{ id: "cli/gpt-5", name: "GPT-5 via CLIProxyAPI" },
|
||||
]);
|
||||
|
||||
const res = await callGET("cliproxyapi");
|
||||
const body = await res.json();
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(body.object, "list");
|
||||
assert.deepEqual(
|
||||
body.data.map((model: any) => model.id),
|
||||
["cli/gpt-5"]
|
||||
);
|
||||
assert.equal(body.data[0].owned_by, "cliproxyapi");
|
||||
assert.equal(body.data[0].parent, null);
|
||||
});
|
||||
|
||||
test("GET /v1/providers/:provider/models rejects non-matching connection-like strings", async () => {
|
||||
// Looks like a connection ID but with wrong prefix
|
||||
const res = await callGET("custom-compatible-chat-a1b2c3d4-e5f6-7890-abcd-ef1234567890");
|
||||
|
||||
@@ -28,8 +28,8 @@ const OPENAI_ADDED_IDS = [
|
||||
] as const;
|
||||
|
||||
const GEMINI_ADDED_IDS = [
|
||||
"gemini-3-flash-lite-preview",
|
||||
"gemini-2.0-flash-lite",
|
||||
"gemini-3.1-flash-lite",
|
||||
"gemini-2.5-flash-lite",
|
||||
] as const;
|
||||
|
||||
test("openai registry exposes gpt-4.1 mini/nano and o3-mini/o4-mini reasoning variants", () => {
|
||||
@@ -69,7 +69,7 @@ test("port did not regress previously curated openai/gemini ids", () => {
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-1.5-pro",
|
||||
"gemini-3.5-flash",
|
||||
] as const) {
|
||||
assert.ok(geminiIds.has(id), `existing gemini model ${id} must remain`);
|
||||
}
|
||||
|
||||
@@ -447,7 +447,10 @@ test("grok-web validator: full DevTools cookie blob is parsed for the sso value"
|
||||
const result = await validateProviderApiKey({ provider: "grok-web", apiKey: blob });
|
||||
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(capturedCookie, "sso=eyJTARGET.abc.def");
|
||||
// #5350 — the outbound cookie now forwards the Cloudflare cookies too.
|
||||
assert.match(capturedCookie, /(?:^|;\s*)sso=eyJTARGET\.abc\.def(?:;|$)/);
|
||||
assert.match(capturedCookie, /(?:^|;\s*)cf_clearance=baz(?:;|$)/);
|
||||
assert.match(capturedCookie, /(?:^|;\s*)__cf_bm=bar(?:;|$)/);
|
||||
});
|
||||
|
||||
test("grok-web validator: empty/missing sso in input returns 'Missing sso cookie'", async () => {
|
||||
@@ -594,6 +597,45 @@ test("grok-web validator: 403 with credential-rejection body is treated as auth-
|
||||
assert.match(result.error || "", /Invalid SSO cookie/i);
|
||||
});
|
||||
|
||||
// #5350 — when the user DID supply a cf_clearance, an auth-shaped 401 / invalid-credentials 403
|
||||
// is almost always an IP-reputation block (cf_clearance is IP+TLS+UA-pinned and cannot be
|
||||
// replayed from a different machine), NOT a bad cookie. Surface the IP guidance instead of the
|
||||
// misleading "Invalid SSO cookie" verdict.
|
||||
test("grok-web validator: 401 WITH a cf_clearance maps to IP-reputation guidance, not 'invalid cookie' (#5350)", async () => {
|
||||
__setGrokTlsFetchOverride(async () => {
|
||||
return { status: 401, headers: new Headers(), text: "Unauthorized", body: null };
|
||||
});
|
||||
|
||||
const blob = "sso=eyJTARGET.abc.def; sso-rw=RW; cf_clearance=CF; __cf_bm=BM";
|
||||
const result = await validateProviderApiKey({ provider: "grok-web", apiKey: blob });
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(result.error || "", /residential IP|proxy/i);
|
||||
assert.doesNotMatch(result.error || "", /invalid SSO cookie/i);
|
||||
});
|
||||
|
||||
test("grok-web validator: invalid-credentials 403 WITH a cf_clearance maps to IP-reputation guidance (#5350)", async () => {
|
||||
__setGrokTlsFetchOverride(async () => {
|
||||
return {
|
||||
status: 403,
|
||||
headers: new Headers(),
|
||||
text: JSON.stringify({
|
||||
error: {
|
||||
code: 16,
|
||||
message: "Failed to look up session ID. [WKE=unauthenticated:invalid-credentials]",
|
||||
details: [],
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
};
|
||||
});
|
||||
|
||||
const blob = "sso=eyJTARGET.abc.def; cf_clearance=CF";
|
||||
const result = await validateProviderApiKey({ provider: "grok-web", apiKey: blob });
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(result.error || "", /residential IP|proxy/i);
|
||||
assert.doesNotMatch(result.error || "", /invalid SSO cookie/i);
|
||||
});
|
||||
|
||||
test("grok-web validator: TLS client unavailable surfaces actionable error", async () => {
|
||||
__setGrokTlsFetchOverride(async () => {
|
||||
const { TlsClientUnavailableError } = await import("../../open-sse/services/grokTlsClient.ts");
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ProviderCatalogMetadata,
|
||||
type StaticProviderCatalogCategory,
|
||||
} from "@/lib/providers/catalog";
|
||||
import * as providerCatalog from "@/lib/providers/catalog";
|
||||
|
||||
const CATALOG_CATEGORIES = [
|
||||
"no-auth",
|
||||
@@ -89,6 +90,12 @@ function countByCategory(entries: ProviderFilterEntry[]) {
|
||||
};
|
||||
}
|
||||
|
||||
test("provider catalog public surface excludes removed categories helper", () => {
|
||||
assert.equal(Object.hasOwn(providerCatalog, "getStaticProviderCategories"), false);
|
||||
assert.equal(typeof providerCatalog.getStaticProviderCatalogGroup, "function");
|
||||
assert.equal(typeof providerCatalog.resolveStaticProviderCatalogEntry, "function");
|
||||
});
|
||||
|
||||
test('filterByCategory("free") returns every hasFree provider across native categories', () => {
|
||||
const entries = buildCatalogEntries();
|
||||
const expectedIds = dedupeByProviderId(entries.filter(providerHasFree)).map(
|
||||
|
||||
@@ -23,6 +23,13 @@ test.after(() => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("proxy logger public surface excludes removed stats helper", () => {
|
||||
assert.equal(Object.hasOwn(proxyLogger, "getProxyLogStats"), false);
|
||||
assert.equal(typeof proxyLogger.getProxyLogs, "function");
|
||||
assert.equal(typeof proxyLogger.clearProxyLogs, "function");
|
||||
assert.equal(typeof proxyLogger.logProxyEvent, "function");
|
||||
});
|
||||
|
||||
test("GET /api/usage/proxy-logs returns filtered proxy logs", async () => {
|
||||
proxyLogger.logProxyEvent({
|
||||
status: "success",
|
||||
|
||||
@@ -21,6 +21,7 @@ process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const poolsDb = await import("../../src/lib/db/quotaPools.ts");
|
||||
const sqliteQuotaStoreModule = await import("../../src/lib/quota/sqliteQuotaStore.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
@@ -53,6 +54,12 @@ test.after(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("sqliteQuotaStore public surface excludes removed singleton reset helper", () => {
|
||||
assert.equal(Object.hasOwn(sqliteQuotaStoreModule, "resetSqliteQuotaStore"), false);
|
||||
assert.equal(typeof sqliteQuotaStoreModule.getSqliteQuotaStore, "function");
|
||||
assert.equal(typeof sqliteQuotaStoreModule.SqliteQuotaStore, "function");
|
||||
});
|
||||
|
||||
// Helper: make a dimension key
|
||||
function makeDim(poolId = "pool-test", unit = "tokens" as const, window = "hourly" as const) {
|
||||
return { poolId, unit, window };
|
||||
@@ -153,9 +160,7 @@ test("sqliteQuotaStore: 50 concurrent consumes → exact sum (mutex guards)", as
|
||||
const COST = 10;
|
||||
|
||||
// Fire 50 concurrent consumes
|
||||
await Promise.all(
|
||||
Array.from({ length: N }, () => store.consume("key-concurrent", dim, COST))
|
||||
);
|
||||
await Promise.all(Array.from({ length: N }, () => store.consume("key-concurrent", dim, COST)));
|
||||
|
||||
const effective = await store.peek("key-concurrent", dim);
|
||||
|
||||
@@ -202,7 +207,10 @@ test("sqliteQuotaStore: poolUsageWithDimensions returns correct shape", async ()
|
||||
assert.equal(dimSnap.window, "hourly");
|
||||
assert.equal(dimSnap.limit, 1000);
|
||||
// consumedTotal should be close to 300 + 200 = 500
|
||||
assert.ok(dimSnap.consumedTotal > 490, `consumedTotal should be close to 500, got ${dimSnap.consumedTotal}`);
|
||||
assert.ok(
|
||||
dimSnap.consumedTotal > 490,
|
||||
`consumedTotal should be close to 500, got ${dimSnap.consumedTotal}`
|
||||
);
|
||||
assert.equal(dimSnap.perKey.length, 2);
|
||||
|
||||
// Validate perKey shapes
|
||||
|
||||
71
tests/unit/rerank-providers-5332.test.ts
Normal file
71
tests/unit/rerank-providers-5332.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { parseRerankModel, getAllRerankModels, getRerankProvider } = await import(
|
||||
"../../open-sse/config/rerankRegistry.ts"
|
||||
);
|
||||
const { transformResponseFromProvider, transformRequestForProvider } = await import(
|
||||
"../../open-sse/handlers/rerank.ts"
|
||||
);
|
||||
|
||||
test("#5332 parseRerankModel resolves siliconflow multi-slash model id", () => {
|
||||
assert.deepEqual(parseRerankModel("siliconflow/Qwen/Qwen3-Reranker-8B"), {
|
||||
provider: "siliconflow",
|
||||
model: "Qwen/Qwen3-Reranker-8B",
|
||||
});
|
||||
});
|
||||
|
||||
test("#5332 parseRerankModel resolves deepinfra multi-slash model id", () => {
|
||||
assert.deepEqual(parseRerankModel("deepinfra/Qwen/Qwen3-Reranker-0.6B"), {
|
||||
provider: "deepinfra",
|
||||
model: "Qwen/Qwen3-Reranker-0.6B",
|
||||
});
|
||||
});
|
||||
|
||||
test("#5332 getAllRerankModels lists siliconflow + deepinfra reranker models", () => {
|
||||
const ids = getAllRerankModels().map((m) => m.id);
|
||||
assert.ok(ids.includes("siliconflow/Qwen/Qwen3-Reranker-8B"));
|
||||
assert.ok(ids.includes("deepinfra/Qwen/Qwen3-Reranker-8B"));
|
||||
});
|
||||
|
||||
test("#5332 siliconflow is Cohere-compatible (passthrough body, no special format)", () => {
|
||||
const cfg = getRerankProvider("siliconflow");
|
||||
assert.equal(cfg.baseUrl, "https://api.siliconflow.com/v1/rerank");
|
||||
const body = { model: "Qwen/Qwen3-Reranker-8B", query: "q", documents: ["a", "b"], top_n: 2 };
|
||||
assert.deepEqual(transformRequestForProvider(cfg, body), body);
|
||||
});
|
||||
|
||||
test("#5332 deepinfra request adapter → {queries,documents} (string + {text})", () => {
|
||||
const cfg = getRerankProvider("deepinfra");
|
||||
const out = transformRequestForProvider(cfg, {
|
||||
model: "Qwen/Qwen3-Reranker-8B",
|
||||
query: "capital of USA?",
|
||||
documents: ["Washington DC", { text: "Paris" }],
|
||||
});
|
||||
assert.deepEqual(out, { queries: ["capital of USA?"], documents: ["Washington DC", "Paris"] });
|
||||
});
|
||||
|
||||
test("#5332 deepinfra response adapter → Cohere results sorted desc, honors top_n + documents", () => {
|
||||
const cfg = getRerankProvider("deepinfra");
|
||||
const out = transformResponseFromProvider(
|
||||
cfg,
|
||||
{ scores: [0.1, 0.9, 0.5] },
|
||||
{ documents: ["a", "b", "c"], top_n: 2, return_documents: true }
|
||||
);
|
||||
assert.equal(out.results.length, 2);
|
||||
assert.equal(out.results[0].index, 1); // 0.9 highest
|
||||
assert.equal(out.results[0].relevance_score, 0.9);
|
||||
assert.equal(out.results[0].document.text, "b");
|
||||
assert.equal(out.results[1].index, 2); // 0.5 next
|
||||
});
|
||||
|
||||
test("#5332 deepinfra response omits document text when return_documents=false", () => {
|
||||
const cfg = getRerankProvider("deepinfra");
|
||||
const out = transformResponseFromProvider(
|
||||
cfg,
|
||||
{ scores: [0.3, 0.7] },
|
||||
{ documents: ["a", "b"], return_documents: false }
|
||||
);
|
||||
assert.equal(out.results[0].document, undefined);
|
||||
assert.equal(out.results[0].index, 1);
|
||||
});
|
||||
@@ -23,6 +23,18 @@ test("isPrivateLanHost: accepts RFC1918 IPv4 (incl. :port and ::ffff: mapped)",
|
||||
}
|
||||
});
|
||||
|
||||
test("isPrivateLanHost: accepts Tailscale CGNAT IPv4 range", () => {
|
||||
for (const h of [
|
||||
"100.64.0.1",
|
||||
"100.96.135.160",
|
||||
"100.127.255.254",
|
||||
"100.96.135.160:20128",
|
||||
"::ffff:100.96.135.160",
|
||||
]) {
|
||||
assert.equal(isPrivateLanHost(h), true, `expected Tailscale LAN: ${h}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("isPrivateLanHost: accepts IPv6 ULA / link-local", () => {
|
||||
assert.equal(isPrivateLanHost("fd12:3456::1"), true);
|
||||
assert.equal(isPrivateLanHost("fe80::1"), true);
|
||||
@@ -32,6 +44,8 @@ test("isPrivateLanHost: rejects public IPs, loopback and junk", () => {
|
||||
for (const h of [
|
||||
"8.8.8.8",
|
||||
"69.164.221.35", // public VPS
|
||||
"100.63.255.255", // just outside Tailscale 100.64/10
|
||||
"100.128.0.1", // just outside Tailscale 100.64/10
|
||||
"172.32.0.1", // just outside 172.16/12
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ZodError } from "zod";
|
||||
|
||||
const { SearchProviderCatalogItemSchema, SearchProviderCatalogResponseSchema, ScrapeResultSchema } =
|
||||
await import("../../src/shared/schemas/searchTools.ts");
|
||||
const searchToolsModule = await import("../../src/shared/schemas/searchTools.ts");
|
||||
|
||||
// ── SearchProviderCatalogItemSchema ───────────────────────────────────────────
|
||||
|
||||
@@ -160,6 +161,11 @@ test("SearchProviderCatalogResponseSchema: invalid — providers not array", ()
|
||||
assert.ok(!result.success, "non-array providers should fail");
|
||||
});
|
||||
|
||||
test("SearchProviderCatalogResponseSchemaFull alias is not exported", () => {
|
||||
assert.equal("SearchProviderCatalogResponseSchemaFull" in searchToolsModule, false);
|
||||
assert.equal(typeof searchToolsModule.SearchProviderCatalogResponseSchema.safeParse, "function");
|
||||
});
|
||||
|
||||
// ── ScrapeResultSchema ────────────────────────────────────────────────────────
|
||||
|
||||
test("ScrapeResultSchema: valid scrape result parses", () => {
|
||||
|
||||
@@ -13,6 +13,8 @@ test("featureDisabledError carries the featureName", async () => {
|
||||
assert.equal(err.featureName, "my-feature");
|
||||
assert.match(err.message, /my-feature/);
|
||||
assert.match(err.message, /minimal/);
|
||||
assert.equal("OMNIROUTE_BUILD_PROFILE" in mod, false);
|
||||
assert.equal("IS_MINIMAL_BUILD" in mod, false);
|
||||
});
|
||||
|
||||
test("install.stub.ts: installCert / uninstallCert throw FeatureDisabledError", async () => {
|
||||
|
||||
@@ -6,7 +6,16 @@ import {
|
||||
isCacheableForWrite,
|
||||
} from "../../src/lib/semanticCache.ts";
|
||||
|
||||
const semanticCachePublicApi = await import("../../src/lib/semanticCache.ts");
|
||||
|
||||
describe("Semantic Cache", () => {
|
||||
it("public surface excludes unused maintenance timer helpers", () => {
|
||||
assert.equal("startAutoCleanup" in semanticCachePublicApi, false);
|
||||
assert.equal("stopAutoCleanup" in semanticCachePublicApi, false);
|
||||
assert.equal("cleanExpiredEntries" in semanticCachePublicApi, false);
|
||||
assert.equal("cleanOldMetrics" in semanticCachePublicApi, false);
|
||||
});
|
||||
|
||||
describe("generateSignature", () => {
|
||||
it("generates consistent signatures for same inputs", () => {
|
||||
const messages = [{ role: "user", content: "hello" }];
|
||||
@@ -77,7 +86,11 @@ describe("Semantic Cache", () => {
|
||||
const messages = [{ role: "user", content: "what is 2+2?" }];
|
||||
const sigKeyA = generateSignature("gpt-4o", messages, 0, 1, "key-id-alice");
|
||||
const sigKeyB = generateSignature("gpt-4o", messages, 0, 1, "key-id-bob");
|
||||
assert.notEqual(sigKeyA, sigKeyB, "different API keys must produce different cache signatures");
|
||||
assert.notEqual(
|
||||
sigKeyA,
|
||||
sigKeyB,
|
||||
"different API keys must produce different cache signatures"
|
||||
);
|
||||
});
|
||||
|
||||
it("generates consistent signatures for same API key ID (#3740)", () => {
|
||||
|
||||
71
tests/unit/serve-node-options-preserve-5238.test.ts
Normal file
71
tests/unit/serve-node-options-preserve-5238.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Issue #5238 (Defect C) — `omniroute serve` silently DISCARDED a user-set
|
||||
* `NODE_OPTIONS=--max-old-space-size=…`. The serve command spread `process.env`
|
||||
* then UNCONDITIONALLY overwrote NODE_OPTIONS with the calibrated default, so a
|
||||
* user who exported `NODE_OPTIONS=--max-old-space-size=8192` still ran at the
|
||||
* calibrated/old default and OOM'd (reporter: set 8192, crashed at ~505 MB).
|
||||
*
|
||||
* The fix mirrors the Electron (electron/main.js) and standalone
|
||||
* (scripts/dev/run-standalone.mjs) launchers: preserve a user-set heap flag,
|
||||
* otherwise APPEND the calibrated value (keeping unrelated flags intact). It
|
||||
* also gates the explicit `node --max-old-space-size` CLI arg so it never
|
||||
* shadows the user's NODE_OPTIONS value.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { buildServerNodeOptions, buildNodeHeapArgs, envHasExplicitHeapFlag } = await import(
|
||||
"../../scripts/build/runtime-env.mjs"
|
||||
);
|
||||
|
||||
const HEAP_RE = /--max-old-space-size=(\d+)/g;
|
||||
function heapValues(nodeOptions: string): string[] {
|
||||
return [...nodeOptions.matchAll(HEAP_RE)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
test("#5238 case 1: user-set NODE_OPTIONS heap wins, no second/calibrated flag injected", () => {
|
||||
const env = { NODE_OPTIONS: "--max-old-space-size=8192" };
|
||||
// memoryLimit here would be the calibrated default the OLD code forced in.
|
||||
const result = buildServerNodeOptions(env, 512);
|
||||
|
||||
const values = heapValues(result);
|
||||
assert.deepEqual(values, ["8192"], "exactly one heap flag, the user's 8192");
|
||||
assert.ok(!values.includes("512"), "calibrated 512 must NOT be present");
|
||||
// CLI arg path must also not re-inject a conflicting/shadowing flag.
|
||||
assert.deepEqual(
|
||||
buildNodeHeapArgs(env, 512),
|
||||
[],
|
||||
"no explicit CLI --max-old-space-size when user pinned NODE_OPTIONS"
|
||||
);
|
||||
});
|
||||
|
||||
test("#5238 case 2: no NODE_OPTIONS → calibrated value applied", () => {
|
||||
const result = buildServerNodeOptions({}, 2048);
|
||||
assert.equal(result, "--max-old-space-size=2048");
|
||||
assert.deepEqual(heapValues(result), ["2048"]);
|
||||
assert.deepEqual(buildNodeHeapArgs({}, 2048), ["--max-old-space-size=2048"]);
|
||||
});
|
||||
|
||||
test("#5238 case 3: unrelated pre-existing flag preserved when heap is appended", () => {
|
||||
const env = { NODE_OPTIONS: "--enable-source-maps" };
|
||||
const result = buildServerNodeOptions(env, 2048);
|
||||
assert.ok(result.includes("--enable-source-maps"), "unrelated flag preserved");
|
||||
assert.deepEqual(heapValues(result), ["2048"], "calibrated heap appended once");
|
||||
assert.equal(result, "--enable-source-maps --max-old-space-size=2048");
|
||||
});
|
||||
|
||||
test("#5238 case 4: user heap flag + unrelated flag both preserved, no override", () => {
|
||||
const env = { NODE_OPTIONS: "--enable-source-maps --max-old-space-size=8192" };
|
||||
const result = buildServerNodeOptions(env, 512);
|
||||
assert.ok(result.includes("--enable-source-maps"), "unrelated flag preserved");
|
||||
assert.deepEqual(heapValues(result), ["8192"], "user heap preserved, calibrated NOT added");
|
||||
assert.equal(result, env.NODE_OPTIONS, "returned as-is");
|
||||
assert.deepEqual(buildNodeHeapArgs(env, 512), [], "no shadowing CLI arg");
|
||||
});
|
||||
|
||||
test("#5238 envHasExplicitHeapFlag detects a user-pinned heap", () => {
|
||||
assert.equal(envHasExplicitHeapFlag({ NODE_OPTIONS: "--max-old-space-size=4096" }), true);
|
||||
assert.equal(envHasExplicitHeapFlag({ NODE_OPTIONS: "--enable-source-maps" }), false);
|
||||
assert.equal(envHasExplicitHeapFlag({}), false);
|
||||
assert.equal(envHasExplicitHeapFlag(undefined), false);
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
unregisterSupervisor,
|
||||
getSupervisor,
|
||||
} from "../../../src/lib/services/registry.ts";
|
||||
const registryPublicApi = await import("../../../src/lib/services/registry.ts");
|
||||
import type { ServiceSupervisor } from "../../../src/lib/services/ServiceSupervisor.ts";
|
||||
import {
|
||||
activeConnections,
|
||||
@@ -76,6 +77,10 @@ function joined(received: Buffer[]): string {
|
||||
// ─── tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("embedWsProxy", () => {
|
||||
it("registry public surface excludes unused supervisor listing helper", () => {
|
||||
assert.equal("listSupervisors" in registryPublicApi, false);
|
||||
});
|
||||
|
||||
it("idempotent — initEmbedWsProxy does not bind twice", async () => {
|
||||
// Reset the global flag so we can test it from scratch
|
||||
const prev = globalThis.__omnirouteEmbedWsStarted;
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { SETTINGS_FALLBACK_STRATEGY_VALUES } from "@/shared/constants/routingStrategies";
|
||||
import { updateSettingsSchema as settingsRouteSchema } from "@/shared/validation/settingsSchemas";
|
||||
import * as sharedSchemaModule from "@/shared/validation/schemas";
|
||||
import { updateSettingsSchema as sharedSettingsSchema } from "@/shared/validation/schemas";
|
||||
|
||||
for (const strategy of SETTINGS_FALLBACK_STRATEGY_VALUES) {
|
||||
@@ -23,6 +24,10 @@ test("settings schemas reject combo-only strategies as account fallback strategi
|
||||
}
|
||||
});
|
||||
|
||||
test("shared settings schema module omits the unused fallback strategy sub-schema export", () => {
|
||||
assert.equal("settingsFallbackStrategySchema" in sharedSchemaModule, false);
|
||||
});
|
||||
|
||||
test("settings schemas accept cooldown-aware retry knobs", () => {
|
||||
const payload = {
|
||||
requestRetry: 3,
|
||||
|
||||
27
tests/unit/static-constants-public-surface.test.ts
Normal file
27
tests/unit/static-constants-public-surface.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const config = await import("../../src/shared/constants/config.ts");
|
||||
const colors = await import("../../src/shared/constants/colors.ts");
|
||||
const pricing = await import("../../src/shared/constants/pricing.ts");
|
||||
|
||||
test("config constants public surface excludes removed app endpoint placeholders", () => {
|
||||
assert.equal(Object.hasOwn(config, "SUBSCRIPTION_CONFIG"), false);
|
||||
assert.equal(Object.hasOwn(config, "API_ENDPOINTS"), false);
|
||||
assert.ok(config.PROVIDER_ENDPOINTS.openai);
|
||||
assert.ok(config.APP_CONFIG.name);
|
||||
});
|
||||
|
||||
test("colors public surface excludes removed provider-color wrapper", () => {
|
||||
assert.equal(Object.hasOwn(colors, "getProviderColor"), false);
|
||||
assert.ok(colors.PROVIDER_COLORS.codex);
|
||||
assert.equal(colors.getProtocolColor("openai-chat", "openai").label, "OpenAI-Chat");
|
||||
assert.equal(colors.getProxyStatusStyle("success").bg, "#059669");
|
||||
});
|
||||
|
||||
test("pricing public surface excludes removed token-cost helper", () => {
|
||||
assert.equal(Object.hasOwn(pricing, "calculateCostFromTokens"), false);
|
||||
assert.equal(typeof pricing.getPricingForModel, "function");
|
||||
assert.equal(typeof pricing.getDefaultPricing, "function");
|
||||
assert.equal(typeof pricing.formatCost, "function");
|
||||
});
|
||||
@@ -12,11 +12,11 @@ test("T28: gemini AI Studio catalog includes current preview models", () => {
|
||||
const geminiIds = REGISTRY.gemini.models.map((m) => m.id);
|
||||
assert.ok(geminiIds.includes("gemini-3.1-pro-preview"));
|
||||
assert.ok(geminiIds.includes("gemini-3-flash-preview"));
|
||||
assert.ok(geminiIds.includes("gemini-3.1-flash-lite-preview"));
|
||||
assert.ok(geminiIds.includes("gemini-3.1-flash-lite"));
|
||||
assert.ok(geminiIds.includes("gemini-3.5-flash"));
|
||||
assert.ok(geminiIds.includes("gemini-2.5-flash"));
|
||||
assert.ok(geminiIds.includes("gemini-2.5-pro"));
|
||||
assert.equal(geminiIds[0], "gemini-2.0-flash", "preserve the existing Gemini default");
|
||||
assert.equal(geminiIds[0], "gemini-3.1-pro-preview", "preserve the existing Gemini default");
|
||||
});
|
||||
|
||||
test("T28: antigravity static catalog exposes client-visible Gemini tier IDs", () => {
|
||||
|
||||
181
tests/unit/think-close-marker-suppress-5245.test.ts
Normal file
181
tests/unit/think-close-marker-suppress-5245.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { claudeToOpenAIResponse } =
|
||||
await import("../../open-sse/translator/response/claude-to-openai.ts");
|
||||
const {
|
||||
shouldSuppressThinkCloseMarker,
|
||||
thinkingMarkerHeaderSignal,
|
||||
resolveSuppressThinkClose,
|
||||
THINKING_MARKER_HEADER,
|
||||
} = await import("../../open-sse/utils/thinkCloseMarker.ts");
|
||||
|
||||
// #5245: when translating a Claude-native stream to OpenAI shape,
|
||||
// claude-to-openai.ts emits a textual `</think>` close marker (by design, for
|
||||
// Claude Code / Cursor — #4633). Clients that render it verbatim (OpenCode)
|
||||
// want it suppressed. `state.suppressThinkClose` gates the emission; default
|
||||
// (unset/false) preserves the #4633 behaviour.
|
||||
|
||||
function newState(extra: Record<string, unknown> = {}) {
|
||||
return { toolCalls: new Map(), toolNameMap: new Map(), ...extra } as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Drive a thinking-then-text stream and collect every emitted chunk.
|
||||
function runThinkThenText(state: Record<string, unknown>) {
|
||||
const out: unknown[] = [];
|
||||
const push = (r: unknown) => {
|
||||
if (Array.isArray(r)) out.push(...r);
|
||||
else if (r) out.push(r);
|
||||
};
|
||||
push(claudeToOpenAIResponse({ type: "message_start", message: { id: "m1" } }, state));
|
||||
push(
|
||||
claudeToOpenAIResponse(
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "thinking" } },
|
||||
state
|
||||
)
|
||||
);
|
||||
push(
|
||||
claudeToOpenAIResponse(
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "thinking_delta", thinking: "plan" },
|
||||
},
|
||||
state
|
||||
)
|
||||
);
|
||||
push(claudeToOpenAIResponse({ type: "content_block_stop", index: 0 }, state));
|
||||
push(
|
||||
claudeToOpenAIResponse(
|
||||
{ type: "content_block_start", index: 1, content_block: { type: "text" } },
|
||||
state
|
||||
)
|
||||
);
|
||||
push(
|
||||
claudeToOpenAIResponse(
|
||||
{ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "169" } },
|
||||
state
|
||||
)
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Drive a thinking-only stream that flushes the deferred </think> at the
|
||||
// message_delta finish path (no text_delta ever arrives; toolCalls stays empty).
|
||||
// Exercises the L197-207 finish-flush branch in claude-to-openai.ts.
|
||||
function runThinkOnlyThenFinish(state: Record<string, unknown>) {
|
||||
const out: unknown[] = [];
|
||||
const push = (r: unknown) => {
|
||||
if (Array.isArray(r)) out.push(...r);
|
||||
else if (r) out.push(r);
|
||||
};
|
||||
push(claudeToOpenAIResponse({ type: "message_start", message: { id: "m1" } }, state));
|
||||
push(
|
||||
claudeToOpenAIResponse(
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "thinking" } },
|
||||
state
|
||||
)
|
||||
);
|
||||
push(
|
||||
claudeToOpenAIResponse(
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "thinking_delta", thinking: "plan" },
|
||||
},
|
||||
state
|
||||
)
|
||||
);
|
||||
push(claudeToOpenAIResponse({ type: "content_block_stop", index: 0 }, state));
|
||||
push(
|
||||
claudeToOpenAIResponse(
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 5 } },
|
||||
state
|
||||
)
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
function contentChunks(chunks: unknown[]): string[] {
|
||||
return chunks
|
||||
.map(
|
||||
(c) =>
|
||||
(c as { choices?: Array<{ delta?: { content?: unknown } }> })?.choices?.[0]?.delta?.content
|
||||
)
|
||||
.filter((v): v is string => typeof v === "string");
|
||||
}
|
||||
|
||||
// ── shouldSuppressThinkCloseMarker ───────────────────────────────────────────
|
||||
|
||||
test("shouldSuppressThinkCloseMarker: suppresses for OpenCode, preserves CC/Cursor/unknown", () => {
|
||||
assert.equal(shouldSuppressThinkCloseMarker("opencode/1.17.11"), true);
|
||||
assert.equal(shouldSuppressThinkCloseMarker("OpenCode/2.0"), true);
|
||||
assert.equal(shouldSuppressThinkCloseMarker("claude-code/1.0"), false);
|
||||
assert.equal(shouldSuppressThinkCloseMarker("cursor-agent/0.5"), false);
|
||||
assert.equal(shouldSuppressThinkCloseMarker("some-other-client/1.0"), false);
|
||||
assert.equal(shouldSuppressThinkCloseMarker(""), false);
|
||||
assert.equal(shouldSuppressThinkCloseMarker(null), false);
|
||||
assert.equal(shouldSuppressThinkCloseMarker(undefined), false);
|
||||
});
|
||||
|
||||
// ── translator gating ────────────────────────────────────────────────────────
|
||||
|
||||
test("claude-to-openai: default emits the </think> marker before the first text (#4633 preserved)", () => {
|
||||
const contents = contentChunks(runThinkThenText(newState()));
|
||||
assert.ok(contents.includes("</think>"), "marker must be emitted by default");
|
||||
assert.ok(contents.includes("169"), "real text still emitted");
|
||||
// marker comes before the real text
|
||||
assert.ok(contents.indexOf("</think>") < contents.indexOf("169"));
|
||||
});
|
||||
|
||||
test("claude-to-openai: suppressThinkClose drops the </think> marker but keeps the text (#5245)", () => {
|
||||
const contents = contentChunks(runThinkThenText(newState({ suppressThinkClose: true })));
|
||||
assert.ok(!contents.includes("</think>"), "marker must be suppressed");
|
||||
assert.ok(contents.includes("169"), "real text still emitted");
|
||||
});
|
||||
|
||||
// ── finish-flush path (L197-207, toolCalls.size === 0) ───────────────────────
|
||||
|
||||
test("claude-to-openai: finish-flush emits </think> by default for thinking-only response (#4633)", () => {
|
||||
const contents = contentChunks(runThinkOnlyThenFinish(newState()));
|
||||
assert.ok(contents.includes("</think>"), "marker must be emitted at finish by default");
|
||||
});
|
||||
|
||||
test("claude-to-openai: finish-flush suppressed under suppressThinkClose (#5312)", () => {
|
||||
const contents = contentChunks(runThinkOnlyThenFinish(newState({ suppressThinkClose: true })));
|
||||
assert.ok(!contents.includes("</think>"), "marker must be suppressed at finish");
|
||||
});
|
||||
|
||||
// ── header signal resolution (x-omniroute-thinking-marker — #5312) ───────────
|
||||
|
||||
test("thinkingMarkerHeaderSignal: off → suppress, on → keep, absent/unknown → null", () => {
|
||||
assert.equal(thinkingMarkerHeaderSignal("off"), true);
|
||||
assert.equal(thinkingMarkerHeaderSignal("OFF"), true);
|
||||
assert.equal(thinkingMarkerHeaderSignal(" off "), true);
|
||||
assert.equal(thinkingMarkerHeaderSignal("on"), false);
|
||||
assert.equal(thinkingMarkerHeaderSignal("keep"), false);
|
||||
assert.equal(thinkingMarkerHeaderSignal(""), null);
|
||||
assert.equal(thinkingMarkerHeaderSignal("weird"), null);
|
||||
assert.equal(thinkingMarkerHeaderSignal(null), null);
|
||||
assert.equal(thinkingMarkerHeaderSignal(undefined), null);
|
||||
});
|
||||
|
||||
test("resolveSuppressThinkClose: header opts in (Cursor) and overrides the UA allowlist", () => {
|
||||
// header constant is the documented wire name
|
||||
assert.equal(THINKING_MARKER_HEADER, "x-omniroute-thinking-marker");
|
||||
// Cursor's UA is NOT in the allowlist → marker kept by default (orphan </think>, #5312)…
|
||||
assert.equal(resolveSuppressThinkClose({ userAgent: "cursor-agent/0.5" }), false);
|
||||
// …but `off` opts in to suppression regardless of UA.
|
||||
assert.equal(
|
||||
resolveSuppressThinkClose({ userAgent: "cursor-agent/0.5", thinkingMarkerHeader: "off" }),
|
||||
true
|
||||
);
|
||||
// `on` force-keeps even for an allowlisted (OpenCode) UA.
|
||||
assert.equal(
|
||||
resolveSuppressThinkClose({ userAgent: "opencode/1.0", thinkingMarkerHeader: "on" }),
|
||||
false
|
||||
);
|
||||
// No header → defers to UA policy (OpenCode suppressed, unknown kept).
|
||||
assert.equal(resolveSuppressThinkClose({ userAgent: "opencode/1.0" }), true);
|
||||
assert.equal(resolveSuppressThinkClose({ userAgent: "claude-code/1.0" }), false);
|
||||
});
|
||||
74
tests/unit/thinking-budget-hydration-5312.test.ts
Normal file
74
tests/unit/thinking-budget-hydration-5312.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* TDD regression for #5312 (FIX A / RC-A): the dashboard Thinking-Budget setting
|
||||
* is dropped on restart because nothing reads `settings.thinkingBudget` back at
|
||||
* boot — `_config` resets to DEFAULT (passthrough) on every process start.
|
||||
*
|
||||
* Fix: `hydrateThinkingBudgetConfig(settings)` (open-sse/services/thinkingBudget.ts),
|
||||
* called once during server bootstrap (src/server-init.ts), restores the persisted
|
||||
* mode. This test seeds the setting through the real settings DB round-trip, runs
|
||||
* the hydrator, and asserts the in-memory config reflects the operator's choice.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-5312a-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { getSettings, updateSettings } = await import("../../src/lib/db/settings.ts");
|
||||
const {
|
||||
hydrateThinkingBudgetConfig,
|
||||
getThinkingBudgetConfig,
|
||||
setThinkingBudgetConfig,
|
||||
DEFAULT_THINKING_CONFIG,
|
||||
} = await import("../../open-sse/services/thinkingBudget.ts");
|
||||
|
||||
test.afterEach(() => {
|
||||
// Reset the module-global config so tests do not leak state into each other.
|
||||
setThinkingBudgetConfig(DEFAULT_THINKING_CONFIG);
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("#5312 RC-A: persisted thinkingBudget mode is restored at boot", async () => {
|
||||
// Simulate the operator saving mode=auto via the dashboard PUT handler.
|
||||
await updateSettings({ thinkingBudget: { mode: "auto" } });
|
||||
|
||||
// Simulate a fresh boot: config is at its DEFAULT until the hydrator runs.
|
||||
setThinkingBudgetConfig(DEFAULT_THINKING_CONFIG);
|
||||
assert.equal(getThinkingBudgetConfig().mode, "passthrough", "pre-hydration baseline");
|
||||
|
||||
const settings = await getSettings();
|
||||
const applied = hydrateThinkingBudgetConfig(settings);
|
||||
|
||||
assert.equal(applied, true, "hydrator must report it applied a config");
|
||||
assert.equal(getThinkingBudgetConfig().mode, "auto", "operator mode must survive restart");
|
||||
});
|
||||
|
||||
test("#5312 RC-A: custom budget fields are restored verbatim", async () => {
|
||||
await updateSettings({
|
||||
thinkingBudget: { mode: "custom", customBudget: 4096, effortLevel: "low" },
|
||||
});
|
||||
setThinkingBudgetConfig(DEFAULT_THINKING_CONFIG);
|
||||
|
||||
const settings = await getSettings();
|
||||
assert.equal(hydrateThinkingBudgetConfig(settings), true);
|
||||
|
||||
const cfg = getThinkingBudgetConfig();
|
||||
assert.equal(cfg.mode, "custom");
|
||||
assert.equal(cfg.customBudget, 4096);
|
||||
assert.equal(cfg.effortLevel, "low");
|
||||
});
|
||||
|
||||
test("#5312 RC-A: no behavior change when the setting is unset", async () => {
|
||||
setThinkingBudgetConfig(DEFAULT_THINKING_CONFIG);
|
||||
const applied = hydrateThinkingBudgetConfig({});
|
||||
assert.equal(applied, false, "hydrator must be a no-op when thinkingBudget is absent");
|
||||
assert.equal(getThinkingBudgetConfig().mode, "passthrough");
|
||||
});
|
||||
160
tests/unit/tls-options.test.ts
Normal file
160
tests/unit/tls-options.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* #5242 (Bug 1C) — opt-in native HTTPS/TLS serving for `omniroute serve`.
|
||||
*
|
||||
* `resolveTlsOptions` + `createServerListener` (scripts/dev/tls-options.mjs) are
|
||||
* the pure decision helpers the CLI (serve.mjs) and the standalone server
|
||||
* wrapper (standalone-server-ws.mjs) share. TLS is strictly opt-in:
|
||||
* - both cert+key present & readable → TLS options → https.Server
|
||||
* - neither present → null → http.Server (byte-identical to today)
|
||||
* - only one of the pair → null + warning (never half-enable TLS)
|
||||
* - unreadable path → null + warning (never crash over a TLS misconfig)
|
||||
*
|
||||
* Filesystem reads and the http/https factories are injected so the suite is
|
||||
* deterministic and needs no real certs.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
|
||||
const { resolveTlsOptions, createServerListener } = await import(
|
||||
"../../scripts/dev/tls-options.mjs"
|
||||
);
|
||||
|
||||
function makeReader(map: Record<string, string>) {
|
||||
return (p: string) => {
|
||||
if (p in map) return Buffer.from(map[p]);
|
||||
const err: NodeJS.ErrnoException = new Error(`ENOENT: ${p}`);
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
};
|
||||
}
|
||||
|
||||
test("both cert+key provided and readable → returns TLS options", () => {
|
||||
const warnings: string[] = [];
|
||||
const opts = resolveTlsOptions(
|
||||
{ OMNIROUTE_TLS_CERT: "/c/server.crt", OMNIROUTE_TLS_KEY: "/c/server.key" },
|
||||
{ readFileSync: makeReader({ "/c/server.crt": "CERT", "/c/server.key": "KEY" }), warn: (m) => warnings.push(m) }
|
||||
);
|
||||
assert.ok(opts, "expected non-null TLS options");
|
||||
assert.equal(opts.cert.toString(), "CERT");
|
||||
assert.equal(opts.key.toString(), "KEY");
|
||||
assert.equal(opts.certPath, "/c/server.crt");
|
||||
assert.deepEqual(warnings, [], "no warning when correctly configured");
|
||||
});
|
||||
|
||||
test("neither cert nor key → null, no warning (default HTTP path)", () => {
|
||||
const warnings: string[] = [];
|
||||
const opts = resolveTlsOptions({}, { warn: (m) => warnings.push(m) });
|
||||
assert.equal(opts, null);
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
test("only cert provided → null + warning (never half-enable TLS)", () => {
|
||||
const warnings: string[] = [];
|
||||
const opts = resolveTlsOptions(
|
||||
{ OMNIROUTE_TLS_CERT: "/c/server.crt" },
|
||||
{ warn: (m) => warnings.push(m) }
|
||||
);
|
||||
assert.equal(opts, null);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /both OMNIROUTE_TLS_CERT and OMNIROUTE_TLS_KEY/);
|
||||
});
|
||||
|
||||
test("only key provided → null + warning", () => {
|
||||
const warnings: string[] = [];
|
||||
const opts = resolveTlsOptions(
|
||||
{ OMNIROUTE_TLS_KEY: "/c/server.key" },
|
||||
{ warn: (m) => warnings.push(m) }
|
||||
);
|
||||
assert.equal(opts, null);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /both OMNIROUTE_TLS_CERT and OMNIROUTE_TLS_KEY/);
|
||||
});
|
||||
|
||||
test("unreadable path → null + warning, falls back to HTTP (never crash)", () => {
|
||||
const warnings: string[] = [];
|
||||
const opts = resolveTlsOptions(
|
||||
{ OMNIROUTE_TLS_CERT: "/missing.crt", OMNIROUTE_TLS_KEY: "/missing.key" },
|
||||
{ readFileSync: makeReader({}), warn: (m) => warnings.push(m) }
|
||||
);
|
||||
assert.equal(opts, null);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /could not read TLS cert\/key/);
|
||||
});
|
||||
|
||||
test("whitespace-only env values are treated as absent", () => {
|
||||
const opts = resolveTlsOptions(
|
||||
{ OMNIROUTE_TLS_CERT: " ", OMNIROUTE_TLS_KEY: " " },
|
||||
{ warn: () => {} }
|
||||
);
|
||||
assert.equal(opts, null);
|
||||
});
|
||||
|
||||
test("createServerListener: null tlsOptions → http server (unchanged)", () => {
|
||||
let httpCalled = false;
|
||||
let httpsCalled = false;
|
||||
const listener = () => {};
|
||||
const result = createServerListener([listener], null, {
|
||||
createHttp: (...a: unknown[]) => {
|
||||
httpCalled = true;
|
||||
assert.equal(a[a.length - 1], listener);
|
||||
return "HTTP_SERVER";
|
||||
},
|
||||
createHttps: () => {
|
||||
httpsCalled = true;
|
||||
return "HTTPS_SERVER";
|
||||
},
|
||||
});
|
||||
assert.equal(result, "HTTP_SERVER");
|
||||
assert.ok(httpCalled && !httpsCalled);
|
||||
});
|
||||
|
||||
test("createServerListener: tlsOptions → https server with merged cert/key + listener", () => {
|
||||
let httpCalled = false;
|
||||
const listener = () => {};
|
||||
const result = createServerListener([listener], { cert: "CERT", key: "KEY" }, {
|
||||
createHttp: () => {
|
||||
httpCalled = true;
|
||||
return "HTTP_SERVER";
|
||||
},
|
||||
createHttps: (opts: { cert: string; key: string }, fn: unknown) => {
|
||||
assert.equal(opts.cert, "CERT");
|
||||
assert.equal(opts.key, "KEY");
|
||||
assert.equal(fn, listener);
|
||||
return "HTTPS_SERVER";
|
||||
},
|
||||
});
|
||||
assert.equal(result, "HTTPS_SERVER");
|
||||
assert.ok(!httpCalled);
|
||||
});
|
||||
|
||||
test("createServerListener: merges a leading options object with cert/key", () => {
|
||||
const listener = () => {};
|
||||
createServerListener([{ keepAlive: true }, listener], { cert: "C", key: "K" }, {
|
||||
createHttps: (opts: Record<string, unknown>, fn: unknown) => {
|
||||
assert.equal(opts.keepAlive, true);
|
||||
assert.equal(opts.cert, "C");
|
||||
assert.equal(opts.key, "K");
|
||||
assert.equal(fn, listener);
|
||||
return "HTTPS_SERVER";
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("createServerListener: real default (no TLS) returns an http.Server", () => {
|
||||
const server = createServerListener([], null);
|
||||
assert.ok(server instanceof http.Server);
|
||||
server.close();
|
||||
});
|
||||
|
||||
test("createServerListener: with a real cert pair returns a real https.Server", async () => {
|
||||
const { default: selfsigned } = await import("selfsigned");
|
||||
const pems = selfsigned.generate([{ name: "commonName", value: "localhost" }], {
|
||||
keySize: 2048,
|
||||
algorithm: "sha256",
|
||||
});
|
||||
const server = createServerListener([() => {}], { cert: pems.cert, key: pems.private });
|
||||
assert.ok(server instanceof https.Server, "expected a real https.Server instance");
|
||||
server.close();
|
||||
});
|
||||
114
tests/unit/token-health-no-refresh-token-expired-5326.test.ts
Normal file
114
tests/unit/token-health-no-refresh-token-expired-5326.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
process.env.NODE_ENV = "test";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-hc-no-refresh-5326-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const tokenHealthCheck = await import("../../src/lib/tokenHealthCheck.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
break;
|
||||
} catch (error: any) {
|
||||
if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Regression for #5326: a refresh-CAPABLE provider (antigravity) with NO refresh
|
||||
// token used to be silently skipped by the sweep (`if (!conn.refreshToken) return`),
|
||||
// leaving the row at testStatus="active" while the dashboard badge showed a
|
||||
// confusing cosmetic "Token Expired". The sweep must surface reality as a terminal
|
||||
// "expired" status so the row reflects that it genuinely needs re-auth.
|
||||
test("checkConnection marks refresh-capable provider with no refresh token as expired (#5326)", async () => {
|
||||
await resetStorage();
|
||||
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "antigravity",
|
||||
authType: "oauth",
|
||||
name: "Antigravity No-Refresh Account",
|
||||
email: "antigravity-no-refresh@example.com",
|
||||
accessToken: "access-token-only",
|
||||
refreshToken: null,
|
||||
testStatus: "active",
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
await tokenHealthCheck.checkConnection(connection);
|
||||
|
||||
const updated = await providersDb.getProviderConnectionById((connection as any).id);
|
||||
assert.equal(updated?.testStatus, "expired");
|
||||
assert.equal(updated?.errorCode, "no_refresh_token");
|
||||
assert.ok(updated?.lastHealthCheckAt);
|
||||
});
|
||||
|
||||
// A connection WITH a refresh token must NOT be force-expired by this branch. Use a
|
||||
// far-future known expiry so the sweep returns before attempting any network refresh,
|
||||
// isolating the behavior of the no-refresh-token branch.
|
||||
test("checkConnection leaves a connection WITH a refresh token untouched (#5326)", async () => {
|
||||
await resetStorage();
|
||||
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "antigravity",
|
||||
authType: "oauth",
|
||||
name: "Antigravity Healthy Account",
|
||||
email: "antigravity-healthy@example.com",
|
||||
accessToken: "access-token",
|
||||
refreshToken: "refresh-token-present",
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
tokenExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
testStatus: "active",
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
await tokenHealthCheck.checkConnection(connection);
|
||||
|
||||
const updated = await providersDb.getProviderConnectionById((connection as any).id);
|
||||
assert.equal(updated?.testStatus, "active");
|
||||
assert.notEqual(updated?.errorCode, "no_refresh_token");
|
||||
});
|
||||
|
||||
// A provider that does NOT support token refresh must be left untouched even with no
|
||||
// refresh token (it never had one to lose, and is not "expired" — just non-refreshing).
|
||||
test("checkConnection leaves a non-refresh provider with no refresh token untouched (#5326)", async () => {
|
||||
await resetStorage();
|
||||
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "custom-no-refresh-support-5326", // not in supportsTokenRefresh + no tokenUrl/refreshUrl
|
||||
authType: "oauth",
|
||||
name: "Non-refresh Provider Account",
|
||||
email: "non-refresh@example.com",
|
||||
accessToken: "access-token-only",
|
||||
refreshToken: null,
|
||||
testStatus: "active",
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
await tokenHealthCheck.checkConnection(connection);
|
||||
|
||||
const updated = await providersDb.getProviderConnectionById((connection as any).id);
|
||||
assert.equal(updated?.testStatus, "active");
|
||||
assert.notEqual(updated?.errorCode, "no_refresh_token");
|
||||
});
|
||||
@@ -196,9 +196,12 @@ test("OpenAI -> Claude converts multimodal content, tool declarations, tool call
|
||||
|
||||
const assistantMessage = result.messages.find((message) => message.role === "assistant");
|
||||
assert.ok(assistantMessage, "expected an assistant message");
|
||||
assert.equal(assistantMessage.content[0].type, "thinking");
|
||||
assert.equal(assistantMessage.content[0].thinking, "Need a tool");
|
||||
assert.equal(assistantMessage.content[0].signature, DEFAULT_THINKING_CLAUDE_SIGNATURE);
|
||||
// #5312 RC-D: signature-less reasoning_content becomes a redacted_thinking
|
||||
// placeholder (no fabricated signature), not a thinking block.
|
||||
assert.equal(assistantMessage.content[0].type, "redacted_thinking");
|
||||
assert.equal(assistantMessage.content[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
|
||||
assert.equal(assistantMessage.content[0].signature, undefined);
|
||||
assert.equal(assistantMessage.content[0].thinking, undefined);
|
||||
assert.equal(assistantMessage.content[1].text, "Calling tool");
|
||||
assert.equal(assistantMessage.content[2].type, "tool_use");
|
||||
assert.equal(assistantMessage.content[2].name, `${CLAUDE_OAUTH_TOOL_PREFIX}weather.get`);
|
||||
@@ -551,13 +554,21 @@ test("OpenAI -> Claude preserves reasoning_content on assistant tool call messag
|
||||
assert.equal(assistantMsgs.length, 1, "expected exactly one assistant message");
|
||||
|
||||
const assistantMsg = assistantMsgs[0];
|
||||
const thinkingBlock = assistantMsg.content.find((b) => b.type === "thinking");
|
||||
// #5312 RC-D: reasoning_content becomes a signature-less redacted_thinking
|
||||
// placeholder; the real text is re-hydrated downstream (reasoningCache) for
|
||||
// non-Anthropic upstreams, while Anthropic accepts it without signature validation.
|
||||
const thinkingBlock = assistantMsg.content.find((b) => b.type === "redacted_thinking");
|
||||
const textBlock = assistantMsg.content.find((b) => b.type === "text");
|
||||
const toolUseBlock = assistantMsg.content.find((b) => b.type === "tool_use");
|
||||
|
||||
assert.ok(thinkingBlock, "expected thinking block from reasoning_content");
|
||||
assert.equal(thinkingBlock.thinking, "I need to check the weather");
|
||||
assert.equal(thinkingBlock.signature, DEFAULT_THINKING_CLAUDE_SIGNATURE);
|
||||
assert.ok(thinkingBlock, "expected redacted_thinking placeholder from reasoning_content");
|
||||
assert.equal(thinkingBlock.data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
|
||||
assert.equal(thinkingBlock.signature, undefined);
|
||||
assert.equal(
|
||||
assistantMsg.content.find((b) => b.type === "thinking"),
|
||||
undefined,
|
||||
"must NOT emit a thinking block with a fabricated signature (#5312)"
|
||||
);
|
||||
|
||||
assert.ok(textBlock, "expected text block");
|
||||
assert.equal(textBlock.text, "Let me check that for you.");
|
||||
|
||||
@@ -139,7 +139,110 @@ describe("NoAuthAccountCard compact grid", () => {
|
||||
});
|
||||
await waitForCondition(() => el.textContent?.includes("Proxy for Account 1") ?? false);
|
||||
expect(el.textContent).toContain("Proxy for Account 1");
|
||||
// The editor exposes a host input (the proxy configuration surface).
|
||||
// No saved proxies in this fixture → editor falls back to the custom inputs.
|
||||
expect(el.querySelector("input[placeholder='Host']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── #5217 (Gap 1): Proxy Pool dropdown (by-id reference) ──────────────────────
|
||||
|
||||
const SAVED_PROXIES = [
|
||||
{ id: "pool-1", name: "US East", type: "socks5", host: "1.2.3.4", port: 1080, status: "active" },
|
||||
{ id: "pool-2", name: "EU West", type: "http", host: "9.9.9.9", port: 8080, status: "active" },
|
||||
];
|
||||
|
||||
function setupFetchWithProxies(fingerprints: string[], accountProxies: unknown[] = []) {
|
||||
const connections = [
|
||||
{
|
||||
id: "conn-1",
|
||||
provider: PROVIDER_ID,
|
||||
providerSpecificData: { fingerprints, accountProxies },
|
||||
},
|
||||
];
|
||||
const putBodies: unknown[] = [];
|
||||
const mockFetch = vi.fn((url: string, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/api/settings/proxies")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ items: SAVED_PROXIES }),
|
||||
} as Response);
|
||||
}
|
||||
if (u.includes("/api/providers")) {
|
||||
if (init?.method === "PUT" && typeof init.body === "string") {
|
||||
putBodies.push(JSON.parse(init.body));
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ connections }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response);
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
return { mockFetch, putBodies };
|
||||
}
|
||||
|
||||
describe("NoAuthAccountCard proxy pool dropdown (#5217 Gap 1)", () => {
|
||||
it("defaults the editor to the Saved Proxy Pool dropdown when pool proxies exist", async () => {
|
||||
setupFetchWithProxies(makeFingerprints(2));
|
||||
const el = renderCard();
|
||||
await waitForCondition(() => grid(el)?.querySelectorAll("[data-account-id]").length === 2);
|
||||
const proxyBtn = grid(el)!
|
||||
.querySelector<HTMLElement>("[data-account-id]")!
|
||||
.querySelector<HTMLButtonElement>("button[title]")!;
|
||||
act(() => proxyBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await waitForCondition(() => el.textContent?.includes("Proxy for Account 1") ?? false);
|
||||
// The saved dropdown lists every pool proxy by name; the manual Host input is hidden.
|
||||
const select = el.querySelector<HTMLSelectElement>("select")!;
|
||||
expect(select).toBeTruthy();
|
||||
expect(el.textContent).toContain("US East");
|
||||
expect(el.textContent).toContain("EU West");
|
||||
expect(el.querySelector("input[placeholder='Host']")).toBeNull();
|
||||
});
|
||||
|
||||
it("persists a by-id reference {fingerprint, proxyId} when a pool proxy is selected", async () => {
|
||||
const fps = makeFingerprints(2);
|
||||
const { putBodies } = setupFetchWithProxies(fps);
|
||||
const el = renderCard();
|
||||
await waitForCondition(() => grid(el)?.querySelectorAll("[data-account-id]").length === 2);
|
||||
const proxyBtn = grid(el)!
|
||||
.querySelector<HTMLElement>("[data-account-id]")!
|
||||
.querySelector<HTMLButtonElement>("button[title]")!;
|
||||
act(() => proxyBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await waitForCondition(() => el.querySelector("select") !== null);
|
||||
|
||||
const select = el.querySelector<HTMLSelectElement>("select")!;
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLSelectElement.prototype,
|
||||
"value"
|
||||
)!.set!;
|
||||
act(() => {
|
||||
setter.call(select, "pool-2");
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
const saveBtn = Array.from(el.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Save"
|
||||
)!;
|
||||
act(() => saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
|
||||
await waitForCondition(() => putBodies.length > 0);
|
||||
const body = putBodies.at(-1) as { providerSpecificData?: { accountProxies?: unknown[] } };
|
||||
const stored = body.providerSpecificData?.accountProxies ?? [];
|
||||
expect(stored).toEqual([{ fingerprint: fps[0], proxyId: "pool-2" }]);
|
||||
});
|
||||
|
||||
it("lights the shield for an account stored as a by-id reference", async () => {
|
||||
const fps = makeFingerprints(2);
|
||||
setupFetchWithProxies(fps, [{ fingerprint: fps[0], proxyId: "pool-1" }]);
|
||||
const el = renderCard();
|
||||
await waitForCondition(() => grid(el)?.querySelectorAll("[data-account-id]").length === 2);
|
||||
const firstShield = grid(el)!
|
||||
.querySelector<HTMLElement>("[data-account-id]")!
|
||||
.querySelector<HTMLButtonElement>("button[title]")!;
|
||||
// Tooltip is resolved from the referenced pool record, not an inline proxy.
|
||||
expect(firstShield.getAttribute("title")).toContain("1.2.3.4");
|
||||
expect(firstShield.className).toContain("text-blue-400");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,10 +12,13 @@ const HOST = await import("../../src/lib/providers/validation.ts");
|
||||
|
||||
test("searchProviders exposes the search validators + the per-provider config map", () => {
|
||||
assert.equal(typeof (search as Record<string, unknown>).validateSearchProvider, "function");
|
||||
assert.equal(typeof (search as Record<string, unknown>).validateGenericProvider, "function");
|
||||
assert.equal("validateGenericProvider" in search, false);
|
||||
const cfg = (search as Record<string, Record<string, unknown>>).SEARCH_VALIDATOR_CONFIGS;
|
||||
assert.ok(cfg && typeof cfg === "object");
|
||||
assert.ok(Object.keys(cfg).length > 0, "SEARCH_VALIDATOR_CONFIGS must carry the provider configs");
|
||||
assert.ok(
|
||||
Object.keys(cfg).length > 0,
|
||||
"SEARCH_VALIDATOR_CONFIGS must carry the provider configs"
|
||||
);
|
||||
});
|
||||
|
||||
test("embeddingProviders exposes clarifai + embedding + rerank validators", () => {
|
||||
|
||||
@@ -643,11 +643,11 @@ test("vscode tokenized models route prefixes the provider without duplicating br
|
||||
new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/models`)
|
||||
);
|
||||
const body = (await response.json()) as any;
|
||||
const model = (body.data || []).find((entry: any) => entry.id === "gemini/gemini-1.5-pro");
|
||||
const model = (body.data || []).find((entry: any) => entry.id === "gemini/gemini-2.5-pro");
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(model, "missing gemini/gemini-1.5-pro in tokenized VS Code models route");
|
||||
assert.equal(model.name, "Gemini 1.5 Pro");
|
||||
assert.ok(model, "missing gemini/gemini-2.5-pro in tokenized VS Code models route");
|
||||
assert.equal(model.name, "Gemini 2.5 Pro");
|
||||
});
|
||||
|
||||
test("vscode tokenized tags route mirrors the Ollama tags payload", async () => {
|
||||
|
||||
@@ -71,9 +71,30 @@ test("buildGrokCookieHeader: single sso= pair emits only sso", () => {
|
||||
assert.equal(buildGrokCookieHeader("sso=eyJ0eXAi.abc"), "sso=eyJ0eXAi.abc");
|
||||
});
|
||||
|
||||
test("buildGrokCookieHeader: full cookie blob forwards both sso and sso-rw", () => {
|
||||
test("buildGrokCookieHeader: full cookie blob forwards sso, sso-rw and cf_clearance", () => {
|
||||
const blob = "cf_clearance=zzz; sso=AAA.bbb; sso-rw=CCC.ddd; other=1";
|
||||
assert.equal(buildGrokCookieHeader(blob), "sso=AAA.bbb; sso-rw=CCC.ddd");
|
||||
assert.equal(buildGrokCookieHeader(blob), "sso=AAA.bbb; sso-rw=CCC.ddd; cf_clearance=zzz");
|
||||
});
|
||||
|
||||
// #5350 — forward the Cloudflare cookies (cf_clearance + __cf_bm) when the pasted
|
||||
// blob carries them, matching the real browser request (parity with AIClient2API).
|
||||
test("buildGrokCookieHeader: forwards sso, sso-rw, cf_clearance and __cf_bm (order-independent)", () => {
|
||||
const blob = "i18nextLng=en; __cf_bm=BM; sso=SSO; sso-rw=RW; cf_clearance=CF; x-userid=U";
|
||||
const header = buildGrokCookieHeader(blob);
|
||||
assert.match(header, /(?:^|;\s*)sso=SSO(?:;|$)/);
|
||||
assert.match(header, /(?:^|;\s*)sso-rw=RW(?:;|$)/);
|
||||
assert.match(header, /(?:^|;\s*)cf_clearance=CF(?:;|$)/);
|
||||
assert.match(header, /(?:^|;\s*)__cf_bm=BM(?:;|$)/);
|
||||
});
|
||||
|
||||
test("buildGrokCookieHeader: bare sso emits no phantom cf_clearance/__cf_bm keys", () => {
|
||||
assert.equal(buildGrokCookieHeader("sso=SSO"), "sso=SSO");
|
||||
assert.doesNotMatch(buildGrokCookieHeader("sso=SSO"), /cf_clearance|__cf_bm/);
|
||||
});
|
||||
|
||||
test("buildGrokCookieHeader: forwards __cf_bm even when cf_clearance is absent", () => {
|
||||
const blob = "foo=1; __cf_bm=BM; sso=AAA.bbb";
|
||||
assert.equal(buildGrokCookieHeader(blob), "sso=AAA.bbb; __cf_bm=BM");
|
||||
});
|
||||
|
||||
test("buildGrokCookieHeader: order-independent — sso-rw before sso in the blob", () => {
|
||||
|
||||
13
tests/unit/webhook-event-descriptions.test.ts
Normal file
13
tests/unit/webhook-event-descriptions.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const webhookEvents = await import("../../src/lib/webhooks/eventDescriptions.ts");
|
||||
|
||||
test("webhook event descriptions expose event metadata without the unused payload builder", () => {
|
||||
const descriptions = webhookEvents.EVENT_DESCRIPTIONS;
|
||||
|
||||
assert.ok(descriptions["request.completed"]);
|
||||
assert.equal(descriptions["request.completed"].label, "Request Completed");
|
||||
assert.equal(descriptions["test.ping"].exampleData.webhookId, "preview");
|
||||
assert.equal("buildExamplePayload" in webhookEvents, false);
|
||||
});
|
||||
Reference in New Issue
Block a user