Merge remote-tracking branch 'origin/master' into gitea/new_feature

# Conflicts:
#	open-sse/handlers/chatCore.js
#	open-sse/services/combo.js
#	src/app/(dashboard)/dashboard/profile/page.js
#	src/app/api/v1/models/route.js
#	src/lib/db/repos/settingsRepo.js
This commit is contained in:
2026-08-17 00:21:51 +07:00
103 changed files with 11250 additions and 3514 deletions

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import REGISTRY from "../../open-sse/providers/registry/index.js";
import { PROVIDERS, PROVIDER_MODELS } from "../../open-sse/providers/index.js";
describe("Alibaba Token Plan provider", () => {
const entry = REGISTRY.find((e) => e.id === "alitp-intl");
it("is registered as an OpenAI-compatible apikey provider", () => {
expect(entry).toBeDefined();
expect(entry.category).toBe("apikey");
expect(PROVIDERS["alitp-intl"]).toBeDefined();
expect(PROVIDERS["alitp-intl"].format).toBe("openai");
});
it("targets the Singapore Token Plan host in compatible mode", () => {
// eu-central-1 answers IllegalEndpoint; the plan is Singapore-only.
expect(PROVIDERS["alitp-intl"].baseUrl).toBe(
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
);
});
it("does not collide with the other three Alibaba key types", () => {
const hosts = ["alicode", "alicode-intl", "alims-intl", "alitp-intl"]
.map((id) => new URL(PROVIDERS[id].baseUrl).host);
expect(new Set(hosts).size).toBe(hosts.length);
});
it("exposes the models the plan actually serves", () => {
const ids = (PROVIDER_MODELS["alitp-intl"] || []).map((m) => m.id);
expect(ids).toEqual(expect.arrayContaining([
"qwen3.8-max-preview",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.6-flash",
"glm-5.2",
"deepseek-v4-pro",
]));
});
it("keeps every registry id unique after adding the provider", () => {
const ids = REGISTRY.map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
});
});

View File

@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/usageDb.js", () => ({
appendRequestLog: vi.fn(async () => {}),
saveRequestDetail: vi.fn(async () => {}),
saveRequestUsage: vi.fn(async () => {})
}));
const { extractUsageFromResponse } = await import("../../open-sse/handlers/chatCore/requestDetail.js");
const USAGE_METADATA = {
promptTokenCount: 1234,
candidatesTokenCount: 56,
cachedContentTokenCount: 78,
thoughtsTokenCount: 90,
};
const EXPECTED = {
prompt_tokens: 1234,
completion_tokens: 56,
cached_tokens: 78,
reasoning_tokens: 90,
};
describe("#3260 non-streaming usage extraction for enveloped Gemini responses", () => {
it("reads usageMetadata out of the antigravity { response } envelope", () => {
expect(extractUsageFromResponse({ response: { usageMetadata: USAGE_METADATA } })).toEqual(EXPECTED);
});
it("still reads a top-level usageMetadata", () => {
expect(extractUsageFromResponse({ usageMetadata: USAGE_METADATA })).toEqual(EXPECTED);
});
it("prefers the top-level metadata when both are present", () => {
const enveloped = { ...USAGE_METADATA, promptTokenCount: 1 };
const out = extractUsageFromResponse({
usageMetadata: USAGE_METADATA,
response: { usageMetadata: enveloped },
});
expect(out.prompt_tokens).toBe(1234);
});
it("leaves the OpenAI and Claude shapes alone", () => {
expect(extractUsageFromResponse({ usage: { prompt_tokens: 10, completion_tokens: 2 } }))
.toMatchObject({ prompt_tokens: 10, completion_tokens: 2 });
expect(extractUsageFromResponse({ usage: { input_tokens: 10, output_tokens: 2 } }))
.toMatchObject({ prompt_tokens: 10, completion_tokens: 2 });
});
it("returns null when there is no usage anywhere", () => {
expect(extractUsageFromResponse({ response: { candidates: [] } })).toBeNull();
expect(extractUsageFromResponse(null)).toBeNull();
});
});

View File

@@ -0,0 +1,61 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const proxyAwareFetch = vi.fn(async (url) => ({
ok: true,
status: 200,
json: async () => url.includes(":loadCodeAssist")
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" } }
: {
models: {
"gemini-3.7-flash-high": {
displayName: "Gemini 3.7 Flash (High)",
quotaInfo: { remainingFraction: 0.85, resetTime: "2026-08-25T12:00:00Z" },
},
"gemini-3.7-flash-medium": {
displayName: "Gemini 3.7 Flash (Medium)",
quotaInfo: { remainingFraction: 0.6, resetTime: "2026-08-25T12:00:00Z" },
},
"gemini-3.7-flash-low": {
displayName: "Gemini 3.7 Flash (Low)",
quotaInfo: { remainingFraction: 0.35, resetTime: "2026-08-25T12:00:00Z" },
},
"internal-model": {
displayName: "Internal",
isInternal: true,
quotaInfo: { remainingFraction: 0.5 },
},
},
},
text: async () => "{}",
}));
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
proxyAwareFetch,
}));
describe("Antigravity quota tracker: Gemini 3.7 Flash usage bars", () => {
beforeEach(() => proxyAwareFetch.mockClear());
it("returns Gemini 3.7 Flash tier quotas so the dashboard can render usage bars", async () => {
const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js");
const usage = await getAntigravityUsage("access-token", {});
expect(usage.quotas["gemini-3.7-flash-high"]).toMatchObject({
used: 150,
total: 1000,
remainingPercentage: 85,
displayName: "Gemini 3.7 Flash (High)",
});
expect(usage.quotas["gemini-3.7-flash-medium"]).toMatchObject({
used: 400,
total: 1000,
remainingPercentage: 60,
});
expect(usage.quotas["gemini-3.7-flash-low"]).toMatchObject({
used: 650,
total: 1000,
remainingPercentage: 35,
});
});
});

View File

@@ -0,0 +1,86 @@
// custom-server.js is the only thing that makes x-9r-real-ip trustworthy. Boot a real
// HTTP server through it and confirm a client cannot smuggle its own peer headers in.
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createRequire } from "node:module";
import http from "node:http";
import { __test__ as requestDetails } from "@/lib/db/repos/requestDetailsRepo.js";
const require = createRequire(import.meta.url);
let server;
let baseUrl;
let seenHeaders;
beforeAll(async () => {
require("../../custom-server.js");
server = http.createServer((req, res) => {
seenHeaders = req.headers;
res.end("ok");
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
baseUrl = `http://127.0.0.1:${server.address().port}`;
});
afterAll(async () => {
await new Promise((resolve) => server.close(resolve));
});
async function get(headers = {}) {
await fetch(baseUrl, { headers });
return seenHeaders;
}
describe("custom-server peer header sanitizing", () => {
it("generates a peer trust token at boot", () => {
expect(process.env.NINEROUTER_PEER_TOKEN).toMatch(/^[0-9a-f]{48}$/);
});
it("replaces a client-supplied x-9r-real-ip with the socket address", async () => {
const headers = await get({ "x-9r-real-ip": "203.0.113.55" });
expect(headers["x-9r-real-ip"]).toMatch(/^(::ffff:)?127\.0\.0\.1$/);
});
it("stamps the trust token so downstream can tell the wrapper ran", async () => {
const headers = await get();
expect(headers["x-9r-peer-token"]).toBe(process.env.NINEROUTER_PEER_TOKEN);
});
it("drops a client-supplied peer trust token", async () => {
const headers = await get({ "x-9r-peer-token": "forged-token" });
expect(headers["x-9r-peer-token"]).toBe(process.env.NINEROUTER_PEER_TOKEN);
expect(headers["x-9r-peer-token"]).not.toBe("forged-token");
});
it("drops a client-supplied x-9r-via-proxy marker", async () => {
const headers = await get({ "x-9r-via-proxy": "1" });
expect(headers["x-9r-via-proxy"]).toBeUndefined();
});
it("marks via-proxy and adopts the forwarded IP for a loopback proxy hop", async () => {
const headers = await get({ "x-forwarded-for": "203.0.113.9, 10.0.0.1" });
expect(headers["x-9r-via-proxy"]).toBe("1");
expect(headers["x-9r-real-ip"]).toBe("203.0.113.9");
expect(headers["x-forwarded-for"]).toBeUndefined();
});
// chat.js snapshots every client header into the request detail. Anything that grants
// access must not survive into a record the dashboard renders and cloud sync uploads.
it("keeps the peer token out of persisted request details", () => {
const sanitized = requestDetails.sanitizeHeaders({
"x-9r-peer-token": "secret",
"x-9r-cli-token": "secret",
"authorization": "Bearer sk-x",
"x-9r-real-ip": "127.0.0.1",
});
expect(sanitized["x-9r-peer-token"]).toBeUndefined();
expect(sanitized["x-9r-cli-token"]).toBeUndefined();
expect(sanitized["authorization"]).toBeUndefined();
expect(sanitized["x-9r-real-ip"]).toBe("127.0.0.1");
});
});

View File

@@ -35,6 +35,8 @@ vi.mock("@/lib/auth/dashboardSession", () => ({
const { proxy, __test__ } = await import("../../src/dashboardGuard.js");
const PEER_TOKEN = "peer-token-fixture";
function request(pathname, headers = {}) {
const normalizedHeaders = new Headers(headers);
return {
@@ -45,9 +47,16 @@ function request(pathname, headers = {}) {
};
}
// A request that actually came through custom-server.js: peer IP stamped from the TCP
// socket and proven by the per-process secret.
function localRequest(pathname, headers = {}) {
return request(pathname, { "x-9r-peer-token": PEER_TOKEN, "x-9r-real-ip": "127.0.0.1", ...headers });
}
describe("dashboard guard public LLM API access", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
mocks.getSettings.mockResolvedValue({ requireLogin: true });
mocks.validateApiKey.mockResolvedValue(false);
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
@@ -55,14 +64,14 @@ describe("dashboard guard public LLM API access", () => {
});
it("allows loopback public LLM API without API key", async () => {
const response = await proxy(request("/v1/chat/completions", { host: "localhost:20128" }));
const response = await proxy(localRequest("/v1/chat/completions", { host: "localhost:20128" }));
expect(response).toBe(mocks.nextResponse);
expect(mocks.validateApiKey).not.toHaveBeenCalled();
});
it("rejects remote Host-spoof when real peer IP is non-loopback", async () => {
const response = await proxy(request("/v1/chat/completions", {
const response = await proxy(localRequest("/v1/chat/completions", {
host: "localhost",
"x-9r-real-ip": "10.204.111.34",
}));
@@ -72,7 +81,7 @@ describe("dashboard guard public LLM API access", () => {
});
it("allows loopback peer IP regardless of Host", async () => {
const response = await proxy(request("/v1/chat/completions", {
const response = await proxy(localRequest("/v1/chat/completions", {
host: "localhost:20128",
"x-9r-real-ip": "127.0.0.1",
}));
@@ -89,7 +98,7 @@ describe("dashboard guard public LLM API access", () => {
});
it("allows loopback rewritten public LLM API without API key", async () => {
const response = await proxy(request("/api/v1/chat/completions", { host: "localhost:20128" }));
const response = await proxy(localRequest("/api/v1/chat/completions", { host: "localhost:20128" }));
expect(response).toBe(mocks.nextResponse);
expect(mocks.validateApiKey).not.toHaveBeenCalled();
@@ -191,6 +200,7 @@ describe("dashboard guard public LLM API access", () => {
describe("dashboard guard local-only access", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
mocks.getSettings.mockResolvedValue({ requireLogin: true });
mocks.validateApiKey.mockResolvedValue(false);
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
@@ -207,7 +217,7 @@ describe("dashboard guard local-only access", () => {
});
it("rejects local-only route on loopback when requireLogin=true and no JWT", async () => {
const response = await proxy(request("/api/mcp/filesystem/sse", {
const response = await proxy(localRequest("/api/mcp/filesystem/sse", {
host: "localhost:20128",
origin: "http://localhost:20128",
}));
@@ -219,7 +229,7 @@ describe("dashboard guard local-only access", () => {
it("allows local-only route on loopback when requireLogin=false", async () => {
mocks.getSettings.mockResolvedValue({ requireLogin: false });
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
const response = await proxy(localRequest("/api/cli-tools/antigravity-mitm", {
host: "localhost:20128",
origin: "http://localhost:20128",
}));
@@ -240,7 +250,7 @@ describe("dashboard guard local-only access", () => {
it("rejects local-only route when Origin is non-loopback (CSRF block)", async () => {
mocks.getSettings.mockResolvedValue({ requireLogin: false });
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
const response = await proxy(localRequest("/api/cli-tools/antigravity-mitm", {
host: "localhost:20128",
origin: "http://evil.example.com",
}));

View File

@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import REGISTRY from "../../open-sse/providers/registry/index.js";
import { PROVIDER_MEDIA } from "../../open-sse/providers/index.js";
import { FORMAT_HANDLERS } from "../../open-sse/handlers/ttsProviders/genericFormats.js";
import { AI_PROVIDERS } from "@/shared/constants/providers";
const AUDIO = new Uint8Array(256).fill(7);
function okResponse() {
return {
ok: true,
status: 200,
headers: new Headers({ "content-type": "audio/mpeg" }),
arrayBuffer: async () => AUDIO.buffer,
};
}
describe("Fish Audio TTS provider", () => {
const entry = REGISTRY.find((e) => e.id === "fish-audio");
it("is registered as a TTS-only apikey provider", () => {
expect(entry).toBeDefined();
expect(entry.category).toBe("apikey");
expect(entry.serviceKinds).toEqual(["tts"]);
expect(PROVIDER_MEDIA["fish-audio"]?.ttsConfig?.baseUrl).toBe("https://api.fish.audio/v1/tts");
});
it("is visible to the generic dispatcher, which reads AI_PROVIDERS", () => {
// synthesizeViaConfig() looks the provider up here, not in PROVIDER_MEDIA.
expect(AI_PROVIDERS["fish-audio"]?.ttsConfig?.format).toBe("fish-audio");
expect(typeof FORMAT_HANDLERS["fish-audio"]).toBe("function");
});
it("exposes the four documented models", () => {
const ids = (entry.ttsConfig.models || []).map((m) => m.id);
expect(ids).toEqual(["s2.1-pro-free", "s2.1-pro", "s2-pro", "s1"]);
});
it("keeps registry ids and aliases unique", () => {
const ids = REGISTRY.map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
const aliases = REGISTRY.map((e) => e.alias).filter(Boolean);
expect(new Set(aliases).size).toBe(aliases.length);
});
});
describe("Fish Audio TTS request shape", () => {
const handler = FORMAT_HANDLERS["fish-audio"];
let fetchMock;
beforeEach(() => {
fetchMock = vi.fn(async () => okResponse());
global.fetch = fetchMock;
});
const callArgs = () => {
const [url, init] = fetchMock.mock.calls.at(-1);
return { url, init, body: JSON.parse(init.body) };
};
it("sends the model as an HTTP header, not in the body", async () => {
await handler({
baseUrl: "https://api.fish.audio/v1/tts",
apiKey: "sk-test",
text: "xin chào",
modelId: "s1",
voiceId: "",
});
const { url, init, body } = callArgs();
expect(url).toBe("https://api.fish.audio/v1/tts");
expect(init.headers.model).toBe("s1");
expect(init.headers.Authorization).toBe("Bearer sk-test");
expect(body).toEqual({ text: "xin chào", format: "mp3" });
});
it("maps the voice onto reference_id, and omits it when unset", async () => {
await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "voice-abc" });
expect(callArgs().body.reference_id).toBe("voice-abc");
await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "" });
expect(callArgs().body).not.toHaveProperty("reference_id");
});
it("defaults to the free model when none is given", async () => {
await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "", voiceId: "" });
expect(callArgs().init.headers.model).toBe("s2.1-pro-free");
});
it("returns base64 audio with its format", async () => {
const out = await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "" });
expect(out.format).toBe("mp3");
expect(typeof out.base64).toBe("string");
expect(out.base64.length).toBeGreaterThan(0);
});
it("surfaces the upstream error message", async () => {
global.fetch = vi.fn(async () => ({
ok: false,
status: 402,
text: async () => JSON.stringify({ message: "Insufficient credit" }),
}));
await expect(
handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "" }),
).rejects.toThrow("Insufficient credit");
});
});

View File

@@ -0,0 +1,83 @@
// Issue #3024 — Fusion combo must strip `stream_options` from panel requests
// when running non-streaming, or DeepSeek rejects with
// "stream_options should be set along with stream = true".
import { describe, it, expect, vi } from "vitest";
import { handleFusionChat } from "../../open-sse/services/combo.js";
// Minimal logger stub (combo.js calls log.info/warn).
const log = { info: () => {}, warn: () => {}, error: () => {} };
function makeBody(extra = {}) {
return {
model: "combo/gemseek",
stream: true,
stream_options: { include_usage: true },
messages: [{ role: "user", content: "hi" }],
...extra,
};
}
describe("Fusion strips stream_options (#3024)", () => {
it("removes stream_options before fanning out to panel models", async () => {
let capturedPanelBody = null;
const handleSingleModel = vi.fn(async (panelBody, model, isPanel) => {
if (isPanel) capturedPanelBody = panelBody;
// Simulate a successful non-stream JSON answer for the panel.
if (isPanel) {
return new Response(JSON.stringify({ choices: [{ message: { content: `ans-${model}` } }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
// Judge leg: return a final answer.
return new Response(JSON.stringify({ choices: [{ message: { content: "final" } }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
const res = await handleFusionChat({
body: makeBody(),
models: ["ds/deepseek-v4-flash", "gemini/gemini-3.5-flash-lite"],
handleSingleModel,
log,
comboName: "GemSeek",
judgeModel: "gemini/gemini-3.5-flash-lite",
});
expect(res).toBeInstanceOf(Response);
expect(capturedPanelBody).not.toBeNull();
// Critical assertion: stream_options must NOT leak into panel requests.
expect(capturedPanelBody.stream_options).toBeUndefined();
expect(capturedPanelBody.stream).toBe(false);
// Ensure the original client body still had it (proves we stripped deliberately).
expect(makeBody().stream_options).toBeDefined();
});
it("does not throw for a 2-model fusion with stream_options present", async () => {
const handleSingleModel = vi.fn(async (panelBody, model, isPanel) => {
if (isPanel) {
return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ choices: [{ message: { content: "final" } }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
const res = await handleFusionChat({
body: makeBody({ stream_options: { include_usage: true } }),
models: ["ds/deepseek-v4-pro", "ds/deepseek-v4-flash"],
handleSingleModel,
log,
comboName: "GemSeek",
judgeModel: "ds/deepseek-v4-pro",
});
expect(res.status).toBe(200);
});
});

View File

@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
import antigravityRegistry from "../../open-sse/providers/registry/antigravity.js";
import geminiRegistry from "../../open-sse/providers/registry/gemini.js";
import { MODEL_PRICING } from "../../open-sse/providers/pricing.js";
describe("Gemini 3.7 Flash Support & Config (#3286, #3281)", () => {
it("registers gemini-3.7-flash tiered models in antigravity provider registry", () => {
const agIds = antigravityRegistry.models.map(m => m.id);
expect(agIds).toContain("gemini-3.7-flash-high");
expect(agIds).toContain("gemini-3.7-flash-medium");
expect(agIds).toContain("gemini-3.7-flash-low");
expect(agIds).not.toContain("gemini-3.7-flash");
});
it("registers gemini-3.7-flash in gemini provider registry", () => {
const geminiIds = geminiRegistry.models.map(m => m.id);
expect(geminiIds).toContain("gemini-3.7-flash");
});
it("resolves capabilities correctly for gemini-3.7 models with official limits", () => {
const caps = getCapabilitiesForModel("antigravity", "gemini-3.7-flash-high");
expect(caps.vision).toBe(true);
expect(caps.reasoning).toBe(true);
expect(caps.thinkingFormat).toBe("gemini-level");
expect(caps.contextWindow).toBe(1048576);
expect(caps.maxOutput).toBe(65536);
});
it("defines pricing matching gemini-3.6-flash baseline", () => {
expect(MODEL_PRICING["gemini-3.7-flash"]).toEqual(MODEL_PRICING["gemini-3.6-flash"]);
expect(MODEL_PRICING["gemini-3.7-flash-high"]).toEqual(MODEL_PRICING["gemini-3.6-flash-high"]);
expect(MODEL_PRICING["gemini-3.7-flash-medium"]).toEqual(MODEL_PRICING["gemini-3.6-flash-medium"]);
expect(MODEL_PRICING["gemini-3.7-flash-low"]).toEqual(MODEL_PRICING["gemini-3.6-flash-low"]);
});
});

View File

@@ -0,0 +1,115 @@
import { describe, it, expect } from "vitest";
import { detectRequiredCapabilities } from "../../open-sse/services/combo.js";
import { augmentModelsWithCapacityAdapter } from "../../open-sse/services/capacityAdapter.js";
import { stripUnsupportedModalities } from "../../open-sse/translator/concerns/modality.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
describe("Hermes Vision Image Detection", () => {
it("detects vision from Ollama / Hermes images array", () => {
const body = {
messages: [
{
role: "user",
content: "Please analyze this image from Hermes",
images: ["iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="],
},
],
};
const caps = detectRequiredCapabilities(body);
expect(caps.has("vision")).toBe(true);
});
it("detects vision from Vercel AI SDK / Hermes experimental_attachments", () => {
const body = {
messages: [
{
role: "user",
content: "Describe this attachment",
experimental_attachments: [
{
contentType: "image/png",
url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
},
],
},
],
};
const caps = detectRequiredCapabilities(body);
expect(caps.has("vision")).toBe(true);
});
it("detects vision from Hermes attachments array", () => {
const body = {
messages: [
{
role: "user",
content: "Look at this photo",
attachments: [
{
mediaType: "image/jpeg",
url: "https://example.com/photo.jpg",
},
],
},
],
};
const caps = detectRequiredCapabilities(body);
expect(caps.has("vision")).toBe(true);
});
it("detects vision from embedded data:image URI in string content", () => {
const body = {
messages: [
{
role: "user",
content: "Here is an inline image: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
},
],
};
const caps = detectRequiredCapabilities(body);
expect(caps.has("vision")).toBe(true);
});
it("auto-switches non-vision model (deepseek-v4-pro) to Vision Adapter model (Kimi-K3)", () => {
const body = {
messages: [
{
role: "user",
content: "Analyze image",
images: ["base64data..."],
},
],
};
const reqCaps = detectRequiredCapabilities(body);
const settings = {
capacityAdapter: {
vision: {
enabled: true,
models: ["cmc/moonshotai/Kimi-K3"],
},
},
};
const augmented = augmentModelsWithCapacityAdapter(["cmc/deepseek/deepseek-v4-pro"], reqCaps, settings);
expect(augmented).toEqual(["cmc/moonshotai/Kimi-K3", "cmc/deepseek/deepseek-v4-pro"]);
});
it("strips msg.images and attachments when model does not support vision", () => {
const body = {
messages: [
{
role: "user",
content: "Test text",
images: ["base64..."],
experimental_attachments: [{ contentType: "image/png", url: "data:image/png;base64,..." }],
},
],
};
const noVisionCaps = { vision: false, pdf: false, audioInput: false };
stripUnsupportedModalities(body, FORMATS.OPENAI, noVisionCaps);
expect(body.messages[0].images).toBeUndefined();
expect(body.messages[0].experimental_attachments).toHaveLength(0);
});
});

View File

@@ -14,6 +14,13 @@ describe("Kiro MITM model slots", () => {
expect(Array.isArray(kiro.defaultModels)).toBe(true);
});
it("offers a mappable slot for the agent default model id 'auto'", () => {
// اسلات auto برای vibe mode لازمه — وگرنه درخواست میره AWS
const auto = kiro.defaultModels.find((m) => m.id === "auto");
expect(auto).toBeTruthy();
expect(auto.alias).toBe("auto");
});
it("offers a mappable slot for Claude Sonnet 5", () => {
const sonnet5 = kiro.defaultModels.find((m) => m.id === "claude-sonnet-5");
expect(sonnet5).toBeTruthy();

View File

@@ -0,0 +1,369 @@
/**
* Five Kiro defects, all on the OAuth/social Kiro route (kr/claude-sonnet-4.5
* with a >100k context). Production shape: 402 of 2156 usageHistory rows for
* Kiro recorded completionTokens 0, and the 25 newest rows all sat pinned at
* exactly 1 output token against prompts of 80k-103k -- i.e. the router was not
* measuring the answer, it was measuring nothing and rounding up.
*
* A. OUT 0 / OUT 1. finish() estimates completion tokens as
* totalContentLength / 4, but tool-call bytes were never added to
* totalContentLength. A turn whose entire answer is a tool call therefore
* measured as an empty answer (Math.max(1, ...) is where the 1 comes from).
*
* B. Truncation threw away a complete-enough answer. stopDisposition() maps
* model_context_window_exceeded (and max_tokens alongside tool calls) to
* terminal_incomplete, which hard-fails the turn -- even when the model had
* already streamed text. A truncated turn is what finish_reason "length" is
* for. Both disposition gates needed the bypass: the declared-stop-reason
* gate runs first and returns, so patching only finish() would be dead code.
*
* C. One bad tool fragment killed every good one. Three separate latches:
* emitTools() validated per turn and threw out of the loop; the frame-loop
* catch cleared state.tools wholesale; and the toolUseEvent branch returned
* early forever once toolValidationError was set. Net effect for the client:
* a turn that answered nothing.
*
* D. Cache tokens dropped on the kiro:claude route. kiro-to-claude built usage
* from prompt_tokens/completion_tokens only, so Claude clients lost
* cache_read_input_tokens / cache_creation_input_tokens and could neither
* price the turn nor size their prompt cache.
*
* E. Defence-in-depth only: both request translators discarded canonical.valid.
* canonicalizeKiroConversation() self-heals every failure mode it can detect
* (see the test below), so the guard is unreachable by construction today --
* it exists so a future validator rule cannot ship an unusable body silently.
*
* Kiro also answers an unusable conversation with 400 {"message":"Improperly
* formed request.","reason":"REQUEST_BODY_INVALID"}, which cools down every
* account that reports it. That is a property of ERROR_RULES rather than of this
* executor -- the same body fails identically on any account -- so it belongs to
* the request-scoped `fallback: false` rule kind, not here.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const fetchMock = vi.fn();
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
proxyAwareFetch: (...args) => fetchMock(...args)
}));
const { KiroExecutor } = await import("../../open-sse/executors/kiro.js");
const { kiroToClaudeResponse, kiroToClaudeNonStreaming } = await import(
"../../open-sse/translator/response/kiro-to-claude.js"
);
const { validateKiroConversation, canonicalizeKiroConversation } = await import(
"../../open-sse/translator/concerns/kiroConversation.js"
);
const encoder = new TextEncoder();
const credentials = {
accessToken: "test-token",
providerSpecificData: { kiroToolCallRepair: true }
};
function crc32(bytes) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit++) {
crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function encodeHeader(name, value) {
const nameBytes = encoder.encode(name);
const valueBytes = encoder.encode(value);
const bytes = new Uint8Array(1 + nameBytes.length + 3 + valueBytes.length);
let offset = 0;
bytes[offset++] = nameBytes.length;
bytes.set(nameBytes, offset);
offset += nameBytes.length;
bytes[offset++] = 7;
new DataView(bytes.buffer).setUint16(offset, valueBytes.length, false);
offset += 2;
bytes.set(valueBytes, offset);
return bytes;
}
function concat(chunks) {
const output = new Uint8Array(chunks.reduce((size, chunk) => size + chunk.byteLength, 0));
let offset = 0;
for (const chunk of chunks) {
output.set(chunk, offset);
offset += chunk.byteLength;
}
return output;
}
function checksum(bytes) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
view.setUint32(8, crc32(bytes.subarray(0, 8)), false);
view.setUint32(bytes.byteLength - 4, crc32(bytes.subarray(0, bytes.byteLength - 4)), false);
return bytes;
}
function frameFromEntries(entries, payload) {
const headers = concat(entries.map(([name, value]) => encodeHeader(name, value)));
const payloadBytes = encoder.encode(JSON.stringify(payload));
const totalLength = 12 + headers.byteLength + payloadBytes.byteLength + 4;
const frame = new Uint8Array(totalLength);
const view = new DataView(frame.buffer);
view.setUint32(0, totalLength, false);
view.setUint32(4, headers.byteLength, false);
frame.set(headers, 12);
frame.set(payloadBytes, 12 + headers.byteLength);
return checksum(frame);
}
function frame(eventType, payload) {
return frameFromEntries([[":event-type", eventType]], payload);
}
function response(frames, status = 200) {
return new Response(new ReadableStream({
start(controller) {
for (const value of frames) controller.enqueue(value);
controller.close();
}
}), { status, statusText: status === 200 ? "OK" : "Upstream Error" });
}
async function execute(executor = new KiroExecutor(), overrides = {}) {
return executor.execute({
model: "kr/claude-opus-4.8",
body: { systemPrompt: "base", conversationState: {} },
stream: true,
credentials,
...overrides
});
}
// Output is held behind the ": kiro-validation" heartbeat until clean EOF, so
// every executor assertion has to drain the whole response.
async function run(frames) {
fetchMock.mockResolvedValueOnce(response(frames));
return await (await execute()).response.text();
}
// Same as run(), but with the bounded tool-call repair retry disabled, so a
// hard failure is surfaced from the first attempt instead of triggering a
// second upstream fetch.
async function runNoRepair(frames) {
fetchMock.mockResolvedValueOnce(response(frames));
const result = await execute(new KiroExecutor(), {
credentials: { accessToken: "test-token", providerSpecificData: { kiroToolCallRepair: false } }
});
return await result.response.text();
}
// The estimator only runs when metering and context usage both arrived and the
// upstream reported no token totals of its own -- the exact production shape.
const METERED = [
frame("meteringEvent", { usage: 2, unit: "credit" }),
frame("contextUsageEvent", { contextUsagePercentage: 10 })
];
function usageFrom(body) {
const usages = body
.split("\n")
.filter(line => line.startsWith("data: ") && line.includes('"usage"'))
.map(line => JSON.parse(line.slice(6)).usage)
.filter(Boolean);
return usages[usages.length - 1];
}
beforeEach(() => {
fetchMock.mockReset();
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("A: tool-call bytes count as output tokens", () => {
it("does not report a tool-only turn as OUT 1", async () => {
const body = await run([
frame("toolUseEvent", {
toolUseId: "call_a",
name: "tool_call",
input: { name: "mcp_search", arguments: { query: "why is the router reporting zero output" } }
}),
frame("metadataEvent", { stopReason: "tool_use" }),
...METERED
]);
const usage = usageFrom(body);
expect(usage).toBeDefined();
// Was 1: the Math.max floor over a totalContentLength of 0.
expect(usage.completion_tokens).toBeGreaterThan(10);
expect(usage.total_tokens).toBe(usage.prompt_tokens + usage.completion_tokens);
});
it("still counts plain text output", async () => {
const body = await run([
frame("assistantResponseEvent", { content: "x".repeat(400) }),
frame("metadataEvent", { stopReason: "end_turn" }),
...METERED
]);
expect(usageFrom(body).completion_tokens).toBe(100);
});
});
describe("C: one unusable tool fragment does not take the whole turn with it", () => {
it("ships the valid call and drops only the invalid one", async () => {
const body = await run([
frame("toolUseEvent", {
toolUseId: "good",
name: "tool_call",
input: { name: "mcp_search", arguments: { q: "router" } }
}),
// No nested MCP name -> unusable, cannot be forwarded to the client.
frame("toolUseEvent", { toolUseId: "bad", name: "tool_call", input: { arguments: { q: "router" } } }),
frame("metadataEvent", { stopReason: "tool_use" }),
...METERED
]);
expect(body).toContain('\\"name\\":\\"mcp_search\\"');
expect(body).not.toContain('"id":"bad"');
expect(body).toContain('"finish_reason":"tool_calls"');
});
it("keeps streamed text when the only tool call is unusable", async () => {
const body = await run([
frame("assistantResponseEvent", { content: "Here is what I found." }),
frame("toolUseEvent", { toolUseId: "bad", name: "tool_call", input: { arguments: {} } }),
frame("metadataEvent", { stopReason: "tool_use" }),
...METERED
]);
expect(body).toContain("Here is what I found.");
expect(body).not.toContain("invalid_kiro_tool_call");
});
it("still hard-fails when nothing usable was produced at all", async () => {
const body = await runNoRepair([
frame("toolUseEvent", { toolUseId: "bad", name: "tool_call", input: { arguments: {} } }),
frame("metadataEvent", { stopReason: "tool_use" })
]);
expect(body).toContain("invalid_kiro_tool_call");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe("B: truncation after output closes as length, not as a failure", () => {
it("keeps the text and reports finish_reason length", async () => {
const body = await run([
frame("assistantResponseEvent", { content: "Partial but usable answer." }),
frame("metadataEvent", { stopReason: "model_context_window_exceeded" }),
...METERED
]);
expect(body).toContain("Partial but usable answer.");
expect(body).toContain('"finish_reason":"length"');
expect(body).not.toContain("kiro_terminal_incomplete");
});
it("still fails a truncation that produced nothing", async () => {
const body = await run([
frame("metadataEvent", { stopReason: "model_context_window_exceeded" })
]);
expect(body).toContain("kiro_terminal_incomplete");
});
});
describe("D: cache tokens survive the kiro -> claude translation", () => {
const finishChunk = (usage) => ({
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage
});
function finalUsage(usage) {
const state = {};
// Usage rides an earlier chunk in the real stream; feed it the same way.
kiroToClaudeResponse({ choices: [{ index: 0, delta: { content: "hi" } }], usage }, state);
const events = kiroToClaudeResponse(finishChunk(usage), state) || [];
return events.find(e => e.type === "message_delta")?.usage;
}
it("forwards the flat Chat spelling the executor emits", () => {
expect(finalUsage({
prompt_tokens: 103000,
completion_tokens: 640,
cache_read_input_tokens: 98000,
cache_creation_input_tokens: 1912
})).toEqual({
input_tokens: 103000,
output_tokens: 640,
cache_read_input_tokens: 98000,
cache_creation_input_tokens: 1912
});
});
it("also accepts the nested details spelling used on passthrough", () => {
expect(finalUsage({
prompt_tokens: 500,
completion_tokens: 20,
prompt_tokens_details: { cached_tokens: 480, cache_creation_tokens: 20 }
})).toEqual({
input_tokens: 500,
output_tokens: 20,
cache_read_input_tokens: 480,
cache_creation_input_tokens: 20
});
});
it("omits the cache keys when the upstream reported none", () => {
expect(finalUsage({ prompt_tokens: 500, completion_tokens: 20 }))
.toEqual({ input_tokens: 500, output_tokens: 20 });
});
it("preserves cache on the non-streaming path too", () => {
const message = kiroToClaudeNonStreaming({
choices: [{ message: { content: "hi" } }],
usage: { prompt_tokens: 90, completion_tokens: 4, cache_read_input_tokens: 80 }
});
expect(message.usage).toMatchObject({
input_tokens: 90,
output_tokens: 4,
cache_read_input_tokens: 80
});
expect(message.usage).not.toHaveProperty("cache_creation_input_tokens");
});
});
describe("E: the translator valid-guard is defence-in-depth", () => {
const SPECS = [{ toolSpecification: { name: "read_file", inputSchema: { json: { type: "object" } } } }];
it("validateKiroConversation names the offending turn", () => {
// Hand-built, NOT normalized: assistant first, then a tool call with no
// matching result and a name no spec declares.
const result = validateKiroConversation(
[{ assistantResponseMessage: { content: "hi", toolUses: [{ toolUseId: "t1", name: "ghost" }] } }],
{ userInputMessage: { content: "go" } },
SPECS
);
expect(result.valid).toBe(false);
expect(result.errors).toContain("role:0");
expect(result.errors).toContain("pair:0");
expect(result.errors).toContain("spec:0");
});
it("canonicalizeKiroConversation heals that same conversation", () => {
// This is why the guard cannot fire today: normalizeTurns() forces
// user-first/user-last alternation and non-empty content, and the
// second-chance pass flattens every structured tool turn to text.
const out = canonicalizeKiroConversation({
history: [{ assistantResponseMessage: { content: "hi", toolUses: [{ toolUseId: "t1", name: "ghost" }] } }],
currentMessage: { userInputMessage: { content: "" } },
modelId: "claude-sonnet-4-5",
toolSpecs: SPECS
});
expect(out.valid).toBe(true);
expect(out.errors).toEqual([]);
expect(out.currentMessage.userInputMessage.content).toBe("continue");
expect(out.history[0].userInputMessage).toBeDefined();
});
});

View File

@@ -0,0 +1,211 @@
// GHSA-pjm4-8fpg-f9p6 (#3294): `next start` leaves custom-server.js out of the request
// path, so x-9r-real-ip arrives straight from the client and a remote caller can claim to
// be loopback. Host is spoofable the same way, so it cannot be the production fallback.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const mocks = vi.hoisted(() => ({
nextResponse: Symbol("next"),
jsonResponse: vi.fn((body, init) => ({ status: init?.status || 200, body })),
getSettings: vi.fn(),
validateApiKey: vi.fn(),
getConsistentMachineId: vi.fn(),
verifyDashboardAuthToken: vi.fn(),
}));
vi.mock("next/server", () => ({
NextResponse: {
next: vi.fn(() => mocks.nextResponse),
json: mocks.jsonResponse,
redirect: vi.fn((url) => ({ status: 307, url })),
},
}));
vi.mock("@/lib/localDb", () => ({
getSettings: mocks.getSettings,
validateApiKey: mocks.validateApiKey,
}));
vi.mock("@/shared/utils/machineId", () => ({
getConsistentMachineId: mocks.getConsistentMachineId,
}));
vi.mock("@/lib/auth/dashboardSession", () => ({
verifyDashboardAuthToken: mocks.verifyDashboardAuthToken,
}));
const { proxy } = await import("../../src/dashboardGuard.js");
const { getClientIp } = await import("../../src/lib/auth/loginLimiter.js");
const PEER_TOKEN = "peer-token-fixture";
function request(pathname, headers = {}) {
return {
nextUrl: { pathname, searchParams: new URL(`http://localhost${pathname}`).searchParams },
headers: new Headers(headers),
cookies: { get: vi.fn(() => undefined) },
url: `http://localhost${pathname}`,
};
}
const originalNodeEnv = process.env.NODE_ENV;
describe("peer header trust", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
process.env.NODE_ENV = "production";
mocks.getSettings.mockResolvedValue({ requireLogin: true });
mocks.validateApiKey.mockResolvedValue(false);
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
mocks.verifyDashboardAuthToken.mockResolvedValue(false);
});
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
delete process.env.NINEROUTER_PEER_TOKEN;
});
it("rejects a spoofed loopback peer IP that carries no trust proof", async () => {
const response = await proxy(request("/api/v1/models", {
host: "172.18.192.1:20140",
"x-9r-real-ip": "127.0.0.1",
}));
expect(response.status).toBe(401);
expect(response.body.error).toBe("API key required for remote API access");
});
it("rejects a spoofed loopback peer IP carrying a wrong trust token", async () => {
const response = await proxy(request("/api/v1/models", {
host: "172.18.192.1:20140",
"x-9r-real-ip": "127.0.0.1",
"x-9r-peer-token": "guessed-token",
}));
expect(response.status).toBe(401);
});
it("rejects a spoofed loopback Host in production", async () => {
const response = await proxy(request("/api/v1/models", { host: "localhost" }));
expect(response.status).toBe(401);
});
it("rejects a spoofed loopback peer IP when the wrapper never booted", async () => {
delete process.env.NINEROUTER_PEER_TOKEN;
const response = await proxy(request("/api/v1/models", {
host: "172.18.192.1:20140",
"x-9r-real-ip": "127.0.0.1",
"x-9r-peer-token": "any-token",
}));
expect(response.status).toBe(401);
});
it("keeps serving a genuinely local request stamped by the wrapper", async () => {
const response = await proxy(request("/api/v1/models", {
host: "localhost:20128",
"x-9r-real-ip": "127.0.0.1",
"x-9r-peer-token": PEER_TOKEN,
}));
expect(response).toBe(mocks.nextResponse);
expect(mocks.validateApiKey).not.toHaveBeenCalled();
});
// A dual-stack listener reports loopback as ::ffff:127.0.0.1, which the old
// split-on-first-colon check reduced to "".
it.each(["::ffff:127.0.0.1", "::1", "[::1]", "127.0.0.1", "::FFFF:127.0.0.1"])(
"treats %s as a loopback peer",
async (peerIp) => {
const response = await proxy(request("/api/v1/models", {
host: "localhost:20128",
"x-9r-real-ip": peerIp,
"x-9r-peer-token": PEER_TOKEN,
}));
expect(response).toBe(mocks.nextResponse);
}
);
it.each(["::ffff:10.204.111.34", "2001:db8::1", "[2001:db8::1]", "10.204.111.34"])(
"refuses %s as a peer",
async (peerIp) => {
const response = await proxy(request("/api/v1/models", {
host: "localhost:20128",
"x-9r-real-ip": peerIp,
"x-9r-peer-token": PEER_TOKEN,
}));
expect(response.status).toBe(401);
}
);
it("still refuses a stamped non-loopback peer IP", async () => {
const response = await proxy(request("/api/v1/models", {
host: "localhost:20128",
"x-9r-real-ip": "10.204.111.34",
"x-9r-peer-token": PEER_TOKEN,
}));
expect(response.status).toBe(401);
});
it("blocks spoofed local-only routes that would otherwise spawn processes", async () => {
mocks.getSettings.mockResolvedValue({ requireLogin: false });
const response = await proxy(request("/api/mcp/filesystem/sse", {
host: "172.18.192.1:20140",
"x-9r-real-ip": "127.0.0.1",
}));
expect(response.status).toBe(403);
expect(response.body.error).toBe("Local only: CLI token required");
});
it("accepts the legacy Host fallback only in development", async () => {
process.env.NODE_ENV = "development";
const response = await proxy(request("/api/v1/models", { host: "localhost:20127" }));
expect(response).toBe(mocks.nextResponse);
});
});
describe("login limiter client IP", () => {
beforeEach(() => {
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
delete process.env.TRUST_PROXY;
});
afterEach(() => {
delete process.env.NINEROUTER_PEER_TOKEN;
delete process.env.TRUST_PROXY;
});
it("buckets spoofed peer IPs together so lockout cannot be rotated away", () => {
const first = getClientIp(request("/api/auth/login", { "x-9r-real-ip": "1.1.1.1" }));
const second = getClientIp(request("/api/auth/login", { "x-9r-real-ip": "2.2.2.2" }));
expect(first).toBe("unknown");
expect(second).toBe("unknown");
});
it("keys on the stamped peer IP when the wrapper proved it", () => {
const ip = getClientIp(request("/api/auth/login", {
"x-9r-real-ip": "203.0.113.9",
"x-9r-peer-token": PEER_TOKEN,
}));
expect(ip).toBe("203.0.113.9");
});
it("still honours TRUST_PROXY for operators fronting 9router with a reverse proxy", () => {
process.env.TRUST_PROXY = "true";
const ip = getClientIp(request("/api/auth/login", { "x-forwarded-for": "198.51.100.7, 10.0.0.1" }));
expect(ip).toBe("198.51.100.7");
});
});

View File

@@ -0,0 +1,52 @@
/**
* Some providers (e.g. codebuddy / cbcn) attach `tool_calls: []` to every
* streaming chunk. An empty array is truthy in JS, so the guard
* `if (delta.tool_calls)` closed the message on the first content token,
* emitting `output_text.done` early and truncating the answer. This mirrors
* the real repro: `codex exec -m cbcn/kimi-k3` answered only "cod" instead
* of "codex-ok".
*/
import { describe, it, expect } from "vitest";
import { openaiToOpenAIResponsesResponse } from "../../open-sse/translator/response/openai-responses.js";
import { initState } from "../../open-sse/translator/index.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
describe("OpenAI Chat stream → Responses: empty tool_calls arrays", () => {
it("does not emit output_text.done early when every chunk carries tool_calls: []", () => {
const state = initState(FORMATS.OPENAI_RESPONSES);
const chunks = [
{ id: "cmb-test", choices: [{ index: 0, delta: { role: "assistant", content: "", reasoning_content: "", tool_calls: [] }, finish_reason: null }] },
{ id: "cmb-test", choices: [{ index: 0, delta: { content: "", reasoning_content: "thinking", tool_calls: [] }, finish_reason: null }] },
{ id: "cmb-test", choices: [{ index: 0, delta: { content: "cod", reasoning_content: "", tool_calls: [] }, finish_reason: null }] },
{ id: "cmb-test", choices: [{ index: 0, delta: { content: "ex", reasoning_content: "", tool_calls: [] }, finish_reason: null }] },
{ id: "cmb-test", choices: [{ index: 0, delta: { content: "-ok", reasoning_content: "", tool_calls: [] }, finish_reason: null }] },
{ id: "cmb-test", choices: [{ index: 0, delta: { content: "", reasoning_content: "", tool_calls: [] }, finish_reason: "stop" }] },
];
const events = chunks.flatMap((chunk) => openaiToOpenAIResponsesResponse(chunk, state));
const textDone = events.filter((e) => e.event === "response.output_text.done");
const textDeltas = events.filter((e) => e.event === "response.output_text.delta");
expect(textDone).toHaveLength(1);
expect(textDone[0].data.text).toBe("codex-ok");
expect(textDeltas.map((e) => e.data.delta).join("")).toBe("codex-ok");
// done must come after every delta
expect(events.indexOf(textDone[0])).toBe(events.indexOf(textDeltas[textDeltas.length - 1]) + 1);
});
it("still closes the message before a real tool call", () => {
const state = initState(FORMATS.OPENAI_RESPONSES);
const chunks = [
{ id: "cmb-test", choices: [{ index: 0, delta: { content: "Let me run that.", tool_calls: [] }, finish_reason: null }] },
{ id: "cmb-test", choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "exec", arguments: "" } }] }, finish_reason: null }] },
{ id: "cmb-test", choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] },
];
const events = chunks.flatMap((chunk) => openaiToOpenAIResponsesResponse(chunk, state));
const added = events.find((e) => e.event === "response.output_item.added" && e.data.item?.type === "function_call");
const textDone = events.find((e) => e.event === "response.output_text.done");
expect(added).toBeTruthy();
expect(textDone.data.text).toBe("Let me run that.");
});
});

View File

@@ -1,71 +1,97 @@
import { describe, expect, it } from "vitest";
import { PROVIDER_MODELS, getModelTargetFormat } from "../../open-sse/config/providerModels.js";
import { OpenCodeGoExecutor } from "../../open-sse/executors/opencode-go.js";
import { PROVIDER_MODELS, getModelSupportedFormats } from "../../open-sse/config/providerModels.js";
import { PROVIDERS } from "../../open-sse/config/providers.js";
import { resolveTransport } from "../../open-sse/services/provider.js";
const CHAT_MODELS = [
"glm-5.2",
"glm-5.1",
// OpenCode Go docs' endpoint table currently says kimi-k2.7, but its
// config example and the live API use kimi-k2.7-code.
"kimi-k2.7-code",
"kimi-k2.6",
"deepseek-v4-pro",
"deepseek-v4-flash",
"mimo-v2.5",
"mimo-v2.5-pro",
];
// Chat-only models (no /messages, no /responses support on opencode-go)
const CHAT_ONLY = ["glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "mimo-v2.5", "mimo-v2.5-pro"];
// Models that also expose the Anthropic /messages endpoint
const CLAUDE_CAPABLE = ["minimax-m3", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus"];
// Models that also expose the OpenAI /responses endpoint
const RESPONSES_CAPABLE = ["deepseek-v4-pro", "deepseek-v4-flash"];
const MESSAGES_MODELS = [
"minimax-m3",
"minimax-m2.7",
"minimax-m2.5",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.6-plus",
];
// Mirror of chatCore's per-model transport guard: use the sourceFormat-matched
// transport only when the model declares support for that sourceFormat.
function pickTransport(provider, sourceFormat, alias, model) {
const supported = getModelSupportedFormats(alias, model);
const rt = resolveTransport(provider, sourceFormat);
return supported?.includes(sourceFormat) ? rt : null;
}
describe("OpenCode Go official model catalog", () => {
it("matches the documented OpenCode Go model IDs", () => {
const ids = (PROVIDER_MODELS["opencode-go"] || []).map((model) => model.id);
expect(ids).toEqual([...CHAT_MODELS, ...MESSAGES_MODELS]);
describe("OpenCode Go model catalog", () => {
it("matches the documented model IDs", () => {
const ids = (PROVIDER_MODELS["opencode-go"] || []).map((m) => m.id);
expect(ids).toEqual([
"glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6",
"deepseek-v4-pro", "deepseek-v4-flash",
"mimo-v2.5", "mimo-v2.5-pro",
"minimax-m3", "minimax-m2.7", "minimax-m2.5",
"qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus",
]);
});
});
it("marks documented Qwen and MiniMax models as Anthropic messages format", () => {
for (const model of MESSAGES_MODELS) {
expect(getModelTargetFormat("opencode-go", model)).toBe("claude");
describe("OpenCode Go per-model supportedFormats", () => {
it("declares [openai, claude] for MiniMax + Qwen models", () => {
for (const m of CLAUDE_CAPABLE) {
expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai", "claude"]);
}
});
it("keeps GLM, Kimi, DeepSeek, and MiMo on OpenAI-compatible chat format", () => {
for (const model of CHAT_MODELS) {
expect(getModelTargetFormat("opencode-go", model)).toBeNull();
it("declares [openai, claude, openai-responses] for DeepSeek models", () => {
for (const m of RESPONSES_CAPABLE) {
expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai", "claude", "openai-responses"]);
}
});
it("declares [openai] only for chat-only models (GLM/Kimi/MiMo) → guards /messages routing", () => {
for (const m of CHAT_ONLY) {
expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai"]);
}
});
});
describe("OpenCode Go endpoint routing", () => {
it("routes Qwen and MiniMax models to the messages endpoint with x-api-key auth", () => {
const executor = new OpenCodeGoExecutor();
describe("OpenCode Go multi-endpoint transports", () => {
it("declares openai / claude / openai-responses transports", () => {
const formats = (PROVIDERS["opencode-go"].transports || []).map((t) => t.format);
expect(formats).toEqual(["openai", "claude", "openai-responses"]);
});
for (const model of MESSAGES_MODELS) {
expect(executor.buildUrl(model)).toBe("https://opencode.ai/zen/go/v1/messages");
const headers = executor.buildHeaders({ apiKey: "sk-test" }, false);
expect(headers["x-api-key"]).toBe("sk-test");
expect(headers["anthropic-version"]).toBeDefined();
expect(headers.Authorization).toBeUndefined();
it("resolveTransport picks the endpoint matching the client sourceFormat", () => {
expect(resolveTransport("opencode-go", "claude").baseUrl).toBe("https://opencode.ai/zen/go/v1/messages");
expect(resolveTransport("opencode-go", "openai-responses").baseUrl).toBe("https://opencode.ai/zen/go/v1/responses");
expect(resolveTransport("opencode-go", "openai").baseUrl).toBe("https://opencode.ai/zen/go/v1/chat/completions");
});
it("uses x-api-key + anthropicVersion on the claude transport", () => {
const t = resolveTransport("opencode-go", "claude");
expect(t.auth.header).toBe("x-api-key");
expect(t.auth.anthropicVersion).toBe(true);
});
});
describe("OpenCode Go per-model transport guard (chatCore logic)", () => {
it("routes MiniMax/Qwen + claude-format client to /messages", () => {
for (const m of CLAUDE_CAPABLE) {
expect(pickTransport("opencode-go", "claude", "opencode-go", m)?.baseUrl).toBe("https://opencode.ai/zen/go/v1/messages");
}
});
it("routes GLM, Kimi, DeepSeek, and MiMo models to chat/completions with bearer auth", () => {
const executor = new OpenCodeGoExecutor();
it("does NOT route chat-only models to /messages on a claude-format request", () => {
for (const m of CHAT_ONLY) {
expect(pickTransport("opencode-go", "claude", "opencode-go", m)).toBeNull();
}
});
for (const model of CHAT_MODELS) {
expect(executor.buildUrl(model)).toBe("https://opencode.ai/zen/go/v1/chat/completions");
const headers = executor.buildHeaders({ apiKey: "sk-test" }, false);
expect(headers.Authorization).toBe("Bearer sk-test");
expect(headers["x-api-key"]).toBeUndefined();
expect(headers["anthropic-version"]).toBeUndefined();
it("routes DeepSeek + responses-format client to /responses", () => {
for (const m of RESPONSES_CAPABLE) {
expect(pickTransport("opencode-go", "openai-responses", "opencode-go", m)?.baseUrl).toBe("https://opencode.ai/zen/go/v1/responses");
}
});
it("does NOT route MiniMax (no responses support) to /responses", () => {
for (const m of CLAUDE_CAPABLE) {
expect(pickTransport("opencode-go", "openai-responses", "opencode-go", m)).toBeNull();
}
});
});

View File

@@ -0,0 +1,77 @@
// Issue #3010 — Dashboard "Test" button fails for reasoning models because of a
// tiny max_tokens probe. pingModelByKind must use a sane budget (1024) and treat a
// reasoning-only (length-limited) response as a successful connection.
//
// The route module pulls in Next.js-only deps (@/lib/localDb, etc.) that don't
// resolve under raw vitest, so we mock them and exercise the exported function.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock the heavy Next.js-dependent imports BEFORE importing ping.js.
vi.mock("@/lib/localDb", () => ({ getApiKeys: vi.fn(async () => [{ key: "test-key", isActive: true }]) }));
vi.mock("@/shared/constants/config", () => ({ UPDATER_CONFIG: { appPort: 20127 } }));
vi.mock("@/shared/utils/machineId", () => ({ getConsistentMachineId: vi.fn(async () => "cli-token") }));
const { pingModelByKind } = await import("../../src/app/api/models/test/ping.js");
describe("pingModelByKind reasoning models (#3010)", () => {
let fetchMock;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function jsonResponse(obj) {
return {
ok: true,
status: 200,
text: async () => JSON.stringify(obj),
json: async () => obj,
};
}
it("uses a 1024-token budget for the chat completions probe", async () => {
fetchMock.mockResolvedValue(jsonResponse({ choices: [{ message: { content: "Hi there!" } }] }));
await pingModelByKind("cline-pass/kimi-k3", "llm", "http://127.0.0.1:20127");
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.max_tokens).toBe(1024);
});
it("treats a reasoning-only (length-limited) response as ok:true", async () => {
fetchMock.mockResolvedValue(
jsonResponse({
choices: [
{
finish_reason: "length",
message: { content: "", reasoning: "The user said hi — a simple greeting..." },
},
],
})
);
const result = await pingModelByKind("cline-pass/kimi-k3", "llm", "http://127.0.0.1:20127");
expect(result.ok).toBe(true);
expect(result.note).toMatch(/reasoning-only/);
});
it("still fails when there are no choices and no reasoning", async () => {
fetchMock.mockResolvedValue(jsonResponse({ choices: [] }));
const result = await pingModelByKind("some/model", "llm", "http://127.0.0.1:20127");
expect(result.ok).toBe(false);
expect(result.error).toMatch(/no completion choices/);
});
it("passes a normal answer with the larger budget", async () => {
fetchMock.mockResolvedValue(jsonResponse({ choices: [{ message: { content: "Hello!" } }] }));
const result = await pingModelByKind("openai/gpt-4o", "llm", "http://127.0.0.1:20127");
expect(result.ok).toBe(true);
});
});

View File

@@ -0,0 +1,147 @@
/**
* Unit tests for qoder billing error detection.
*
* Ensures that billing blocks (code 112, 10605, pricingUrl) are detected
* on the first SSE frame and returned as 403 responses so chatCore can
* mark the connection unavailable and trigger combo failover.
*/
import { describe, it, expect } from "vitest";
import { __test__ as qoderExecutorInternals } from "../../open-sse/executors/qoder.js";
describe("isBillingBlock", () => {
const { isBillingBlock } = qoderExecutorInternals;
it("detects code 112 (quota exhausted)", () => {
const msg = '{"code":"112","message":"Quota exhausted","pricingUrl":"..."}';
expect(isBillingBlock(msg)).toBe(true);
});
it("detects code 10605 (queue throttle)", () => {
const msg = '{"code":"10605","message":"Queue limit"}';
expect(isBillingBlock(msg)).toBe(true);
});
it("detects pricingUrl field", () => {
const msg = '{"message":"Upgrade required","pricingUrl":"https://..."}';
expect(isBillingBlock(msg)).toBe(true);
});
it("returns false for normal errors without billing markers", () => {
const msg = '{"code":"500","message":"Internal error"}';
expect(isBillingBlock(msg)).toBe(false);
});
it("returns false for empty or non-string input", () => {
expect(isBillingBlock("")).toBe(false);
expect(isBillingBlock(null)).toBe(false);
expect(isBillingBlock(undefined)).toBe(false);
});
});
describe("wrapQoderSSE billing detection", () => {
const { wrapQoderSSE } = qoderExecutorInternals;
function makeResponse(lines, { status = 200 } = {}) {
const body = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
for (const line of lines) controller.enqueue(encoder.encode(line));
controller.close();
},
});
return new Response(body, { status });
}
it("returns 403 response when first frame is billing block (code 112)", async () => {
const billingEnv = JSON.stringify({
statusCodeValue: 403,
body: '{"code":"112","message":"Quota exhausted","pricingUrl":"https://qoder.sh/pricing"}',
});
const upstream = `data: ${billingEnv}\n\n`;
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/ultimate");
expect(wrapped.status).toBe(403);
expect(wrapped.ok).toBe(false);
const json = await wrapped.json();
expect(json.error).toBeDefined();
expect(json.error.message).toContain("112");
});
it("returns 403 response when first frame is billing block (code 10605)", async () => {
const billingEnv = JSON.stringify({
statusCodeValue: 429,
body: '{"code":"10605","message":"Queue limit"}',
});
const upstream = `data: ${billingEnv}\n\n`;
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/ultimate");
expect(wrapped.status).toBe(403);
expect(wrapped.ok).toBe(false);
});
it("returns 403 response when first frame has pricingUrl", async () => {
const billingEnv = JSON.stringify({
statusCodeValue: 402,
body: '{"message":"Payment required","pricingUrl":"https://..."}',
});
const upstream = `data: ${billingEnv}\n\n`;
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/ultimate");
expect(wrapped.status).toBe(403);
});
it("passes through normal errors (non-billing) as wrapped SSE", async () => {
const errorEnv = JSON.stringify({
statusCodeValue: 500,
body: "Internal server error",
});
const upstream = `data: ${errorEnv}\n\n`;
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/ultimate");
// Normal error: still 200 response, error text in SSE body
expect(wrapped.status).toBe(200);
expect(wrapped.ok).toBe(true);
const reader = wrapped.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
}
buf += decoder.decode();
expect(buf).toContain("[qoder error 500");
expect(buf).toContain("data: [DONE]");
});
it("passes through successful responses unchanged", async () => {
const inner = JSON.stringify({ choices: [{ delta: { content: "hello" } }] });
const successEnv = JSON.stringify({ statusCodeValue: 200, body: inner });
const upstream = `data: ${successEnv}\n\n`;
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/ultimate");
expect(wrapped.status).toBe(200);
expect(wrapped.ok).toBe(true);
const reader = wrapped.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
}
buf += decoder.decode();
expect(buf).toContain(`data: ${inner}`);
expect(buf).toContain("data: [DONE]");
});
});

View File

@@ -401,7 +401,7 @@ describe("wrapQoderSSE", () => {
it("forwards an OpenAI envelope chunk and emits [DONE] in flush", async () => {
const inner = JSON.stringify({ choices: [{ delta: { content: "hi" } }] });
const upstream = `data: ${JSON.stringify({ statusCodeValue: 200, body: inner })}\n\n`;
const wrapped = wrapQoderSSE(makeResponse([upstream]), "qoder/auto");
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/auto");
const out = await drain(wrapped);
expect(out).toContain(`data: ${inner}\n\n`);
expect(out).toContain("data: [DONE]\n\n");
@@ -413,7 +413,7 @@ describe("wrapQoderSSE", () => {
const inner = JSON.stringify({ choices: [{ delta: { content: "tail" } }], finish_reason: "stop" });
// Note: NO trailing \n on the final line.
const upstream = `data: ${JSON.stringify({ statusCodeValue: 200, body: inner })}`;
const wrapped = wrapQoderSSE(makeResponse([upstream]), "qoder/auto");
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/auto");
const out = await drain(wrapped);
expect(out).toContain(`data: ${inner}\n\n`);
});
@@ -426,7 +426,7 @@ describe("wrapQoderSSE", () => {
const errorEnv = JSON.stringify({ statusCodeValue: 500, body: "boom" });
const validInner = JSON.stringify({ choices: [{ delta: { content: "leak" } }] });
const validEnv = JSON.stringify({ statusCodeValue: 200, body: validInner });
const wrapped = wrapQoderSSE(
const wrapped = await wrapQoderSSE(
makeResponse([`data: ${errorEnv}\n\ndata: ${validEnv}\n\n`]),
"qoder/auto",
);
@@ -443,7 +443,7 @@ describe("wrapQoderSSE", () => {
it("strips embedded newlines from inner body before forwarding", async () => {
const innerWithNewlines = '{"choices":[{"delta":{"content":"a\nb"}}]}';
const env = JSON.stringify({ statusCodeValue: 200, body: innerWithNewlines });
const wrapped = wrapQoderSSE(makeResponse([`data: ${env}\n\n`]), "qoder/auto");
const wrapped = await wrapQoderSSE(makeResponse([`data: ${env}\n\n`]), "qoder/auto");
const out = await drain(wrapped);
// The forwarded data: line should be a single event terminated by \n\n
// and contain no internal \n other than the trailing pair.
@@ -455,15 +455,15 @@ describe("wrapQoderSSE", () => {
it("upstream error envelope produces an error chunk + [DONE]", async () => {
const env = JSON.stringify({ statusCodeValue: 503, body: "service unavailable" });
const wrapped = wrapQoderSSE(makeResponse([`data: ${env}\n\n`]), "qoder/lite");
const wrapped = await wrapQoderSSE(makeResponse([`data: ${env}\n\n`]), "qoder/lite");
const out = await drain(wrapped);
expect(out).toContain("[qoder error 503");
expect(out).toContain("data: [DONE]\n\n");
});
it("non-ok responses are returned unchanged (no transform)", () => {
it("non-ok responses are returned unchanged (no transform)", async () => {
const r = new Response("not ok", { status: 500 });
const wrapped = wrapQoderSSE(r, "qoder/auto");
const wrapped = await wrapQoderSSE(r, "qoder/auto");
expect(wrapped).toBe(r);
});
});

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
// Mirror the redaction logic from src/app/api/usage/request-details/route.js
// so we can test it in isolation.
function redactDetails(details) {
return (details || []).map((d) => {
const redacted = { ...d };
for (const key of ["request", "providerRequest", "providerResponse", "response"]) {
if (redacted[key] !== undefined) {
redacted[key] = { redacted: true };
}
}
return redacted;
});
}
describe("request-details redaction", () => {
it("removes conversation payloads but keeps metadata", () => {
const details = [{
id: "abc",
provider: "opencode",
model: "deepseek-v4-flash-free",
timestamp: "2026-08-05T00:00:00Z",
status: "success",
tokens: { prompt_tokens: 10, completion_tokens: 5 },
request: { messages: [{ role: "user", content: "secret prompt" }] },
providerRequest: { messages: [{ role: "user", content: "secret prompt" }] },
providerResponse: { choices: [{ message: { content: "secret answer" } }] },
response: { content: "secret answer" },
}];
const out = redactDetails(details)[0];
expect(out.id).toBe("abc");
expect(out.provider).toBe("opencode");
expect(out.model).toBe("deepseek-v4-flash-free");
expect(out.tokens).toEqual({ prompt_tokens: 10, completion_tokens: 5 });
expect(out.request).toEqual({ redacted: true });
expect(out.providerRequest).toEqual({ redacted: true });
expect(out.providerResponse).toEqual({ redacted: true });
expect(out.response).toEqual({ redacted: true });
});
it("handles empty details", () => {
expect(redactDetails([])).toEqual([]);
expect(redactDetails(null)).toEqual([]);
});
it("keeps non-sensitive fields untouched", () => {
const details = [{ id: "x", status: "error", latency: { total: 100 } }];
const out = redactDetails(details)[0];
expect(out.id).toBe("x");
expect(out.status).toBe("error");
expect(out.latency).toEqual({ total: 100 });
});
});

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
const { openaiToOpenAIResponsesRequest, openaiResponsesToOpenAIRequest } =
await import("../../open-sse/translator/request/openai-responses.js");
const CHAT_BODY = (extra = {}) => ({
model: "example-model",
messages: [{ role: "user", content: "hello" }],
...extra,
});
describe("#3216 prompt_cache_key across the chat/responses translation", () => {
it("preserves an explicit key when converting chat → responses", () => {
const out = openaiToOpenAIResponsesRequest(
"example-model",
CHAT_BODY({ prompt_cache_key: "stable-cache-key" }),
true,
{},
);
expect(out.prompt_cache_key).toBe("stable-cache-key");
});
it("does not invent a key when the client sent none", () => {
const out = openaiToOpenAIResponsesRequest("example-model", CHAT_BODY(), true, {});
expect(out.prompt_cache_key).toBeUndefined();
});
it("still drops the key on the responses → chat direction", () => {
const out = openaiResponsesToOpenAIRequest(
"example-model",
{
model: "example-model",
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
prompt_cache_key: "stable-cache-key",
},
true,
{},
);
expect(out.prompt_cache_key).toBeUndefined();
});
});

144
tests/unit/saml.test.js Normal file
View File

@@ -0,0 +1,144 @@
import { describe, it, expect } from "vitest";
import {
formatX509Certificate,
isSamlConfigured,
generateSamlMetadata,
pickSamlEmail,
pickSamlDisplayName,
validateSamlResponse,
} from "../../src/lib/auth/saml.js";
import { mergeWithDefaults } from "../../src/lib/db/repos/settingsRepo.js";
describe("SAML 2.0 Auth Engine Utilities", () => {
describe("formatX509Certificate", () => {
it("formats raw Base64 string into standard 64-column PEM block", () => {
const rawBase64 = "MIIC1234567890123456789012345678901234567890123456789012345678901234567890";
const formatted = formatX509Certificate(rawBase64);
expect(formatted).toContain("-----BEGIN CERTIFICATE-----");
expect(formatted).toContain("-----END CERTIFICATE-----");
expect(formatted).toContain("MIIC123456789012345678901234567890123456789012345678901234567890");
expect(formatted).toContain("\n1234567890\n");
});
it("cleans existing PEM header/footer and extra whitespace", () => {
const rawPem = `
-----BEGIN CERTIFICATE-----
MIIC123456789012345678901234567890123456789012345678901234567890
1234567890
-----END CERTIFICATE-----
`;
const formatted = formatX509Certificate(rawPem);
expect(formatted).toContain("-----BEGIN CERTIFICATE-----");
expect(formatted.match(/BEGIN CERTIFICATE/g)?.length).toBe(1);
});
it("returns empty string for null, undefined, or invalid inputs", () => {
expect(formatX509Certificate(null)).toBe("");
expect(formatX509Certificate(undefined)).toBe("");
expect(formatX509Certificate(" ")).toBe("");
});
});
describe("isSamlConfigured", () => {
it("returns true when entryPoint and cert are non-empty", () => {
expect(
isSamlConfigured({
samlEntryPoint: "https://idp.example.com/sso",
samlCert: "dummy-cert",
})
).toBe(true);
});
it("returns false if entryPoint or cert is missing", () => {
expect(isSamlConfigured({ samlEntryPoint: "https://idp.example.com/sso" })).toBe(false);
expect(isSamlConfigured({ samlCert: "dummy-cert" })).toBe(false);
expect(isSamlConfigured({})).toBe(false);
});
});
describe("generateSamlMetadata", () => {
it("generates valid SP XML metadata with Entity ID and ACS binding", () => {
const settings = {
samlEntryPoint: "https://idp.example.com/sso",
samlIssuer: "urn:9router:sp",
samlCert: "MIIC123456789012345678901234567890123456789012345678901234567890",
};
const xml = generateSamlMetadata("https://localhost:20127", settings);
expect(xml).toContain('entityID="urn:9router:sp"');
expect(xml).toContain('Location="https://localhost:20127/api/auth/saml/acs"');
expect(xml).toContain('WantAssertionsSigned="true"');
});
});
describe("InResponseTo Replay Validation", () => {
it("throws error when expectedRequestId is supplied but InResponseTo is missing", async () => {
const settings = { samlCert: "dummy-cert" };
const rawXml = Buffer.from('<Response ID="123"></Response>').toString("base64");
await expect(
validateSamlResponse(null, { SAMLResponse: rawXml }, "req-123", settings)
).rejects.toThrow(/InResponseTo mismatch/);
});
it("throws error when expectedRequestId is supplied but InResponseTo does not match", async () => {
const settings = { samlCert: "dummy-cert" };
const rawXml = Buffer.from('<Response InResponseTo="wrong-id"></Response>').toString("base64");
await expect(
validateSamlResponse(null, { SAMLResponse: rawXml }, "req-123", settings)
).rejects.toThrow(/InResponseTo mismatch/);
});
it("throws error if samlCert is not configured", async () => {
const rawXml = Buffer.from('<Response ID="123"></Response>').toString("base64");
await expect(
validateSamlResponse(null, { SAMLResponse: rawXml }, "req-123", {})
).rejects.toThrow(/Certificate/);
});
});
describe("Claims Extraction", () => {
const mockProfile = {
email: "user@example.com",
displayName: "Jane Doe",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": ["custom@example.com"],
customEmail: "custom-email@example.com",
customName: "Custom User",
};
it("pickSamlEmail extracts custom attribute or common claims", () => {
expect(pickSamlEmail(mockProfile, {})).toBe("user@example.com");
expect(
pickSamlEmail(mockProfile, { samlAttributeEmail: "customEmail" })
).toBe("custom-email@example.com");
expect(
pickSamlEmail(
{ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": ["custom@example.com"] },
{}
)
).toBe("custom@example.com");
});
it("pickSamlDisplayName extracts custom attribute, common names, or falls back to email", () => {
expect(pickSamlDisplayName(mockProfile, {})).toBe("Jane Doe");
expect(
pickSamlDisplayName(mockProfile, { samlAttributeName: "customName" })
).toBe("Custom User");
expect(
pickSamlDisplayName({ email: "user@example.com" }, {})
).toBe("user@example.com");
expect(
pickSamlDisplayName({ givenName: "Alice", surname: "Smith" }, {})
).toBe("Alice Smith");
});
});
describe("Settings Repository Defaults", () => {
it("mergeWithDefaults safely populates SAML defaults for existing installations", () => {
const merged = mergeWithDefaults({ authMode: "password" });
expect(merged.ssoType).toBe("oidc");
expect(merged.samlIssuer).toBe("urn:9router:sp");
expect(merged.samlLoginLabel).toBe("Sign in with SAML SSO");
expect(merged.samlAttributeEmail).toBe("email");
expect(merged.samlAttributeName).toBe("name");
});
});
});

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { resolveBaseUrl } from "../../open-sse/handlers/search/callers.js";
const CONFIG = { id: "searxng", baseUrl: "https://searxng.example.com" };
describe("resolveBaseUrl SSRF guard", () => {
it("uses provider default when no override", () => {
expect(resolveBaseUrl(CONFIG, {})).toBe("https://searxng.example.com");
});
it("allows public https override", () => {
const params = { providerOptions: { baseUrl: "https://my-searxng.example.com" } };
expect(resolveBaseUrl(CONFIG, params)).toBe("https://my-searxng.example.com");
});
it("allows public http override", () => {
const params = { providerOptions: { baseUrl: "http://searxng.example.net" } };
expect(resolveBaseUrl(CONFIG, params)).toBe("http://searxng.example.net");
});
it("rejects loopback override", () => {
const params = { providerOptions: { baseUrl: "http://127.0.0.1:18999" } };
expect(() => resolveBaseUrl(CONFIG, params)).toThrow();
});
it("rejects private IP override", () => {
for (const ip of ["10.0.0.1", "192.168.1.1", "172.16.0.1"]) {
const params = { providerOptions: { baseUrl: `http://${ip}` } };
expect(() => resolveBaseUrl(CONFIG, params), `should reject ${ip}`).toThrow();
}
});
it("rejects localhost hostname override", () => {
const params = { providerOptions: { baseUrl: "http://localhost:8080" } };
expect(() => resolveBaseUrl(CONFIG, params)).toThrow();
});
it("rejects cloud metadata override", () => {
const params = { providerOptions: { baseUrl: "http://169.254.169.254/latest/meta-data" } };
expect(() => resolveBaseUrl(CONFIG, params)).toThrow();
});
it("rejects non-http protocols", () => {
for (const proto of ["file:///etc/passwd", "gopher://127.0.0.1:70", "ftp://10.0.0.1"]) {
const params = { providerOptions: { baseUrl: proto } };
expect(() => resolveBaseUrl(CONFIG, params), `should reject ${proto}`).toThrow();
}
});
});

View File

@@ -37,6 +37,17 @@ describe("standalone build assets", () => {
.toBe("static asset");
});
// Without the wrapper beside server.js nothing can prove a request is local.
it("copies the request-sanitizing server wrapper into the standalone output", () => {
const projectRoot = createBuildFixture(".next");
writeFileSync(join(projectRoot, "custom-server.js"), "wrapper");
copyStandaloneAssets({ projectRoot, distDir: ".next" });
expect(readFileSync(join(projectRoot, ".next", "standalone", "custom-server.js"), "utf8"))
.toBe("wrapper");
});
it("does not modify workspace-traced CLI builds", () => {
const projectRoot = createBuildFixture(".next-cli-build");
const previousMode = process.env.NEXT_TRACING_ROOT_MODE;