mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
feat: add the feature for user save combo
This commit is contained in:
@@ -268,6 +268,52 @@ exports[`GOLDEN buildHeaders (default executor providers) > cline → headers (a
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildHeaders (default executor providers) > clinepass → headers (apiKey / oauth) 1`] = `
|
||||
{
|
||||
"apiKey": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://cline.bot",
|
||||
"User-Agent": "9Router/0.5.30",
|
||||
"X-CLIENT-TYPE": "9router",
|
||||
"X-CLIENT-VERSION": "0.5.30",
|
||||
"X-CORE-VERSION": "0.5.30",
|
||||
"X-IS-MULTIROOT": "false",
|
||||
"X-PLATFORM": "linux",
|
||||
"X-PLATFORM-VERSION": "v24.15.0",
|
||||
"X-Title": "Cline",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://cline.bot",
|
||||
"User-Agent": "9Router/0.5.30",
|
||||
"X-CLIENT-TYPE": "9router",
|
||||
"X-CLIENT-VERSION": "0.5.30",
|
||||
"X-CORE-VERSION": "0.5.30",
|
||||
"X-IS-MULTIROOT": "false",
|
||||
"X-PLATFORM": "linux",
|
||||
"X-PLATFORM-VERSION": "v24.15.0",
|
||||
"X-Title": "Cline",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://cline.bot",
|
||||
"User-Agent": "9Router/0.5.30",
|
||||
"X-CLIENT-TYPE": "9router",
|
||||
"X-CLIENT-VERSION": "0.5.30",
|
||||
"X-CORE-VERSION": "0.5.30",
|
||||
"X-IS-MULTIROOT": "false",
|
||||
"X-PLATFORM": "linux",
|
||||
"X-PLATFORM-VERSION": "v24.15.0",
|
||||
"X-Title": "Cline",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildHeaders (default executor providers) > cloudflare-ai → headers (apiKey / oauth) 1`] = `
|
||||
{
|
||||
"apiKey": {
|
||||
@@ -381,6 +427,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > deepseek → headers
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildHeaders (default executor providers) > featherless → headers (apiKey / oauth) 1`] = `
|
||||
{
|
||||
"apiKey": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildHeaders (default executor providers) > fireworks → headers (apiKey / oauth) 1`] = `
|
||||
{
|
||||
"apiKey": {
|
||||
@@ -482,6 +547,40 @@ exports[`GOLDEN buildHeaders (default executor providers) > glm-cn → headers (
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildHeaders (default executor providers) > grok-cli → headers (apiKey / oauth) 1`] = `
|
||||
{
|
||||
"apiKey": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
|
||||
"x-authenticateresponse": "authenticate-response",
|
||||
"x-grok-client-identifier": "grok-pager",
|
||||
"x-grok-client-version": "0.2.93",
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
|
||||
"x-authenticateresponse": "authenticate-response",
|
||||
"x-grok-client-identifier": "grok-pager",
|
||||
"x-grok-client-version": "0.2.93",
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
|
||||
"x-authenticateresponse": "authenticate-response",
|
||||
"x-grok-client-identifier": "grok-pager",
|
||||
"x-grok-client-version": "0.2.93",
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildHeaders (default executor providers) > groq → headers (apiKey / oauth) 1`] = `
|
||||
{
|
||||
"apiKey": {
|
||||
@@ -539,6 +638,28 @@ exports[`GOLDEN buildHeaders (default executor providers) > kilocode → headers
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildHeaders (default executor providers) > kimchi → headers (apiKey / oauth) 1`] = `
|
||||
{
|
||||
"apiKey": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "kimchi/0.1.50",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "kimchi/0.1.50",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "kimchi/0.1.50",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildHeaders (default executor providers) > kimi → headers (apiKey / oauth) 1`] = `
|
||||
{
|
||||
"apiKey": {
|
||||
@@ -828,6 +949,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > perplexity → heade
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildHeaders (default executor providers) > perplexity-agent → headers (apiKey / oauth) 1`] = `
|
||||
{
|
||||
"apiKey": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → headers (apiKey / oauth) 1`] = `
|
||||
{
|
||||
"apiKey": {
|
||||
@@ -866,6 +1006,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > together → headers
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildHeaders (default executor providers) > venice → headers (apiKey / oauth) 1`] = `
|
||||
{
|
||||
"apiKey": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildHeaders (default executor providers) > vercel-ai-gateway → headers (apiKey / oauth) 1`] = `
|
||||
{
|
||||
"apiKey": {
|
||||
@@ -1012,6 +1171,13 @@ exports[`GOLDEN buildUrl (default executor providers) > cline → url (stream +
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildUrl (default executor providers) > clinepass → url (stream + non-stream) 1`] = `
|
||||
{
|
||||
"nonStream": "https://api.cline.bot/api/v1/chat/completions",
|
||||
"stream": "https://api.cline.bot/api/v1/chat/completions",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildUrl (default executor providers) > cloudflare-ai → url (stream + non-stream) 1`] = `
|
||||
{
|
||||
"nonStream": "https://api.cloudflare.com/client/v4/accounts/ACC123/ai/v1/chat/completions",
|
||||
@@ -1047,6 +1213,13 @@ exports[`GOLDEN buildUrl (default executor providers) > deepseek → url (stream
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildUrl (default executor providers) > featherless → url (stream + non-stream) 1`] = `
|
||||
{
|
||||
"nonStream": "https://api.featherless.ai/v1/chat/completions",
|
||||
"stream": "https://api.featherless.ai/v1/chat/completions",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildUrl (default executor providers) > fireworks → url (stream + non-stream) 1`] = `
|
||||
{
|
||||
"nonStream": "https://api.fireworks.ai/inference/v1/chat/completions",
|
||||
@@ -1082,6 +1255,13 @@ exports[`GOLDEN buildUrl (default executor providers) > glm-cn → url (stream +
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildUrl (default executor providers) > grok-cli → url (stream + non-stream) 1`] = `
|
||||
{
|
||||
"nonStream": "https://cli-chat-proxy.grok.com/v1/responses",
|
||||
"stream": "https://cli-chat-proxy.grok.com/v1/responses",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildUrl (default executor providers) > groq → url (stream + non-stream) 1`] = `
|
||||
{
|
||||
"nonStream": "https://api.groq.com/openai/v1/chat/completions",
|
||||
@@ -1103,6 +1283,13 @@ exports[`GOLDEN buildUrl (default executor providers) > kilocode → url (stream
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildUrl (default executor providers) > kimchi → url (stream + non-stream) 1`] = `
|
||||
{
|
||||
"nonStream": "https://llm.kimchi.dev/openai/v1/chat/completions",
|
||||
"stream": "https://llm.kimchi.dev/openai/v1/chat/completions",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildUrl (default executor providers) > kimi → url (stream + non-stream) 1`] = `
|
||||
{
|
||||
"nonStream": "https://api.kimi.com/coding/v1/messages?beta=true",
|
||||
@@ -1194,6 +1381,13 @@ exports[`GOLDEN buildUrl (default executor providers) > perplexity → url (stre
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildUrl (default executor providers) > perplexity-agent → url (stream + non-stream) 1`] = `
|
||||
{
|
||||
"nonStream": "https://api.perplexity.ai/v1/responses",
|
||||
"stream": "https://api.perplexity.ai/v1/responses",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (stream + non-stream) 1`] = `
|
||||
{
|
||||
"nonStream": "https://api.siliconflow.com/v1/chat/completions",
|
||||
@@ -1208,6 +1402,13 @@ exports[`GOLDEN buildUrl (default executor providers) > together → url (stream
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildUrl (default executor providers) > venice → url (stream + non-stream) 1`] = `
|
||||
{
|
||||
"nonStream": "https://api.venice.ai/api/v1/chat/completions",
|
||||
"stream": "https://api.venice.ai/api/v1/chat/completions",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildUrl (default executor providers) > vercel-ai-gateway → url (stream + non-stream) 1`] = `
|
||||
{
|
||||
"nonStream": "https://ai-gateway.vercel.sh/v1/chat/completions",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CliToolConfigValidationError,
|
||||
isPersistableCliTool,
|
||||
normalizeCliToolConfig,
|
||||
} from "@/shared/constants/cliToolConfig.js";
|
||||
|
||||
describe("CLI tool configuration contract", () => {
|
||||
it("allows every non-MITM CLI tool and rejects MITM tool IDs", () => {
|
||||
for (const toolId of ["claude", "codex", "opencode", "cowork", "cursor", "copilot"]) {
|
||||
expect(isPersistableCliTool(toolId)).toBe(true);
|
||||
}
|
||||
for (const toolId of ["antigravity", "kiro", "unknown", ""]) {
|
||||
expect(isPersistableCliTool(toolId)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes Claude config and strips unknown fields", () => {
|
||||
expect(normalizeCliToolConfig("claude", {
|
||||
baseUrl: "https://router.example/v1/",
|
||||
apiKeyMode: "managed",
|
||||
apiKeyId: "key-1",
|
||||
claudeModels: { sonnet: " cc/sonnet ", opus: "cc/opus", haiku: "" },
|
||||
claudeThinking: { sonnet: "high", opus: "", ignored: "max" },
|
||||
transientModalOpen: true,
|
||||
})).toEqual({
|
||||
baseUrl: "https://router.example/v1",
|
||||
apiKeyMode: "managed",
|
||||
apiKeyId: "key-1",
|
||||
claudeModels: { sonnet: "cc/sonnet", opus: "cc/opus", haiku: "" },
|
||||
claudeThinking: { sonnet: "high" },
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes tool-specific model selections", () => {
|
||||
expect(normalizeCliToolConfig("codex", {
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
apiKeyMode: "custom",
|
||||
apiKeyId: "must-be-removed",
|
||||
codexModel: "cx/gpt",
|
||||
codexThinking: "xhigh",
|
||||
})).toMatchObject({ apiKeyMode: "custom", apiKeyId: null, codexModel: "cx/gpt", codexThinking: "xhigh" });
|
||||
|
||||
expect(normalizeCliToolConfig("opencode", {
|
||||
baseUrl: "https://router.example",
|
||||
apiKeyMode: "managed",
|
||||
apiKeyId: null,
|
||||
opencodeModels: ["cc/a", "cc/a", "cx/b"],
|
||||
opencodeDefaultModel: "missing/model",
|
||||
})).toMatchObject({ opencodeModels: ["cc/a", "cx/b"], opencodeDefaultModel: "cc/a" });
|
||||
|
||||
expect(normalizeCliToolConfig("cowork", {
|
||||
baseUrl: "https://router.example",
|
||||
apiKeyMode: "managed",
|
||||
selectedModels: ["cc/a"],
|
||||
coworkThinking: { "cc/a": "high", "stale/model": "low" },
|
||||
})).toMatchObject({ selectedModels: ["cc/a"], coworkThinking: { "cc/a": "high" } });
|
||||
|
||||
expect(normalizeCliToolConfig("cursor", {
|
||||
apiKeyMode: "managed",
|
||||
apiKeyId: "key-1",
|
||||
selectedModels: ["cc/a", "cx/b"],
|
||||
})).toEqual({ apiKeyMode: "managed", apiKeyId: "key-1", selectedModels: ["cc/a", "cx/b"] });
|
||||
|
||||
expect(normalizeCliToolConfig("copilot", {
|
||||
baseUrl: "https://router.example/v1",
|
||||
selectedModels: ["cc/a"],
|
||||
copilotThinking: { "cc/a": "high", "stale/model": "low" },
|
||||
copilotTokens: {
|
||||
"cc/a": { maxInputTokens: 100000, maxOutputTokens: 32000 },
|
||||
"stale/model": { maxInputTokens: 1 },
|
||||
},
|
||||
})).toEqual({
|
||||
baseUrl: "https://router.example/v1",
|
||||
selectedModels: ["cc/a"],
|
||||
copilotThinking: { "cc/a": "high" },
|
||||
copilotTokens: { "cc/a": { maxInputTokens: 100000, maxOutputTokens: 32000 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects plaintext secrets, invalid URLs, and invalid token limits", () => {
|
||||
expect(() => normalizeCliToolConfig("claude", {
|
||||
baseUrl: "https://router.example",
|
||||
apiKey: "secret",
|
||||
})).toThrowError(CliToolConfigValidationError);
|
||||
|
||||
expect(() => normalizeCliToolConfig("codex", {
|
||||
baseUrl: "file:///tmp/socket",
|
||||
apiKeyMode: "managed",
|
||||
})).toThrow("Endpoint must use HTTP or HTTPS");
|
||||
|
||||
expect(() => normalizeCliToolConfig("copilot", {
|
||||
baseUrl: "https://router.example",
|
||||
selectedModels: ["cc/a"],
|
||||
copilotTokens: { "cc/a": { maxInputTokens: -1 } },
|
||||
})).toThrow("maxInputTokens must be a positive integer");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
let tempDir;
|
||||
let db;
|
||||
let ownerOne;
|
||||
let ownerTwo;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-cli-tool-config-"));
|
||||
process.env.DATA_DIR = tempDir;
|
||||
try { global._dbAdapter?.instance?.close?.(); } catch {}
|
||||
delete global._dbAdapter;
|
||||
vi.resetModules();
|
||||
db = await import("@/lib/db/index.js");
|
||||
await db.initDb();
|
||||
ownerOne = await db.createUser({ username: "cli-owner-one", password: "password", role: "admin" });
|
||||
ownerTwo = await db.createUser({ username: "cli-owner-two", password: "password", role: "user" });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try { global._dbAdapter?.instance?.close?.(); } catch {}
|
||||
delete global._dbAdapter;
|
||||
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
});
|
||||
|
||||
const claudeConfig = (model) => ({
|
||||
baseUrl: "https://router.example/v1",
|
||||
apiKeyMode: "managed",
|
||||
apiKeyId: null,
|
||||
claudeModels: { sonnet: model, opus: "", haiku: "" },
|
||||
claudeThinking: { sonnet: "high" },
|
||||
});
|
||||
|
||||
describe("CLI tool configuration persistence", () => {
|
||||
it("round-trips and atomically overwrites one owner/tool row", async () => {
|
||||
const created = await db.upsertCliToolConfig(ownerOne.id, "claude", claudeConfig("cc/sonnet-a"));
|
||||
expect(created).toMatchObject({ ownerId: ownerOne.id, toolId: "claude", config: claudeConfig("cc/sonnet-a") });
|
||||
|
||||
const updated = await db.upsertCliToolConfig(ownerOne.id, "claude", claudeConfig("cc/sonnet-b"));
|
||||
expect(updated.config.claudeModels.sonnet).toBe("cc/sonnet-b");
|
||||
expect((await db.getCliToolConfigsByOwnerId(ownerOne.id)).filter((row) => row.toolId === "claude")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("isolates users and permits concurrent saves to different tools", async () => {
|
||||
await Promise.all([
|
||||
db.upsertCliToolConfig(ownerOne.id, "cursor", {
|
||||
apiKeyMode: "custom",
|
||||
selectedModels: ["cc/a", "cx/b"],
|
||||
}),
|
||||
db.upsertCliToolConfig(ownerTwo.id, "cursor", {
|
||||
apiKeyMode: "managed",
|
||||
apiKeyId: null,
|
||||
selectedModels: ["gg/c"],
|
||||
}),
|
||||
db.upsertCliToolConfig(ownerOne.id, "codex", {
|
||||
baseUrl: "https://router.example/v1",
|
||||
apiKeyMode: "managed",
|
||||
apiKeyId: null,
|
||||
codexModel: "cx/gpt",
|
||||
codexThinking: "high",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect((await db.getCliToolConfig(ownerOne.id, "cursor")).config.selectedModels).toEqual(["cc/a", "cx/b"]);
|
||||
expect((await db.getCliToolConfig(ownerTwo.id, "cursor")).config.selectedModels).toEqual(["gg/c"]);
|
||||
expect((await db.getCliToolConfig(ownerOne.id, "codex")).config.codexModel).toBe("cx/gpt");
|
||||
});
|
||||
|
||||
it("includes valid configurations in export/import and skips malformed rows", async () => {
|
||||
const exported = await db.exportDb();
|
||||
expect(exported.cliToolConfigs).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ ownerId: ownerOne.id, toolId: "claude" }),
|
||||
expect.objectContaining({ ownerId: ownerTwo.id, toolId: "cursor" }),
|
||||
]));
|
||||
|
||||
exported.cliToolConfigs.push({
|
||||
ownerId: ownerOne.id,
|
||||
toolId: "kiro",
|
||||
config: { apiKey: "must-not-import" },
|
||||
});
|
||||
await db.importDb(exported);
|
||||
|
||||
expect(await db.getCliToolConfig(ownerOne.id, "claude")).not.toBeNull();
|
||||
expect(await db.getCliToolConfig(ownerOne.id, "kiro")).toBeNull();
|
||||
});
|
||||
|
||||
it("removes configurations when their owner is deleted", async () => {
|
||||
const disposable = await db.createUser({ username: "cli-disposable", password: "password", role: "user" });
|
||||
await db.upsertCliToolConfig(disposable.id, "cursor", {
|
||||
apiKeyMode: "managed",
|
||||
apiKeyId: null,
|
||||
selectedModels: ["cc/a"],
|
||||
});
|
||||
|
||||
expect(await db.deleteUser(disposable.id)).toBe(true);
|
||||
expect(await db.getCliToolConfig(disposable.id, "cursor")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const requireCurrentDashboardUser = vi.fn();
|
||||
const getApiKeyByIdAndOwnerId = vi.fn();
|
||||
const getCliToolConfig = vi.fn();
|
||||
const upsertCliToolConfig = vi.fn();
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
json(body, init = {}) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: init.status || 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/currentUser", () => ({ requireCurrentDashboardUser }));
|
||||
vi.mock("@/lib/db/index.js", () => ({
|
||||
getApiKeyByIdAndOwnerId,
|
||||
getCliToolConfig,
|
||||
upsertCliToolConfig,
|
||||
}));
|
||||
|
||||
const { GET, PUT } = await import("@/app/api/cli-tools/config/[toolId]/route.js");
|
||||
const context = (toolId) => ({ params: Promise.resolve({ toolId }) });
|
||||
const putRequest = (body) => new Request("https://9router.local/api/cli-tools/config/claude", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
describe("/api/cli-tools/config/[toolId]", () => {
|
||||
beforeEach(() => {
|
||||
requireCurrentDashboardUser.mockReset();
|
||||
getApiKeyByIdAndOwnerId.mockReset();
|
||||
getCliToolConfig.mockReset();
|
||||
upsertCliToolConfig.mockReset();
|
||||
requireCurrentDashboardUser.mockResolvedValue({ id: "user-1", role: "user" });
|
||||
});
|
||||
|
||||
it("returns only the authenticated user's saved config without caching", async () => {
|
||||
getCliToolConfig.mockResolvedValue({
|
||||
config: { apiKeyMode: "custom", apiKeyId: null, selectedModels: ["cc/a"] },
|
||||
updatedAt: "2026-07-16T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const response = await GET(new Request("https://9router.local"), context("cursor"));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||
expect(getCliToolConfig).toHaveBeenCalledWith("user-1", "cursor");
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
config: { apiKeyMode: "custom", apiKeyId: null, selectedModels: ["cc/a"] },
|
||||
updatedAt: "2026-07-16T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unauthenticated and MITM tool requests", async () => {
|
||||
requireCurrentDashboardUser.mockRejectedValueOnce(new Error("Unauthorized"));
|
||||
expect((await GET(new Request("https://9router.local"), context("cursor"))).status).toBe(401);
|
||||
expect((await GET(new Request("https://9router.local"), context("kiro"))).status).toBe(404);
|
||||
expect(getCliToolConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates managed key ownership before saving", async () => {
|
||||
getApiKeyByIdAndOwnerId.mockResolvedValue(null);
|
||||
const response = await PUT(putRequest({
|
||||
baseUrl: "https://router.example/v1",
|
||||
apiKeyMode: "managed",
|
||||
apiKeyId: "foreign-key",
|
||||
claudeModels: {},
|
||||
claudeThinking: {},
|
||||
}), context("claude"));
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(getApiKeyByIdAndOwnerId).toHaveBeenCalledWith("foreign-key", "user-1");
|
||||
expect(upsertCliToolConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes a custom-key config without persisting plaintext", async () => {
|
||||
upsertCliToolConfig.mockImplementation(async (ownerId, toolId, config) => ({
|
||||
ownerId,
|
||||
toolId,
|
||||
config,
|
||||
updatedAt: "2026-07-16T00:00:00.000Z",
|
||||
}));
|
||||
const response = await PUT(putRequest({
|
||||
baseUrl: "https://router.example/v1/",
|
||||
apiKeyMode: "custom",
|
||||
apiKeyId: "ignored-key-id",
|
||||
claudeModels: { sonnet: "cc/sonnet" },
|
||||
claudeThinking: { sonnet: "high" },
|
||||
}), context("claude"));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.config).toMatchObject({ apiKeyMode: "custom", apiKeyId: null });
|
||||
expect(JSON.stringify(body)).not.toContain("ignored-key-id");
|
||||
expect(getApiKeyByIdAndOwnerId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects plaintext API keys and malformed JSON", async () => {
|
||||
const secretResponse = await PUT(putRequest({
|
||||
baseUrl: "https://router.example/v1",
|
||||
apiKeyMode: "custom",
|
||||
apiKey: "secret-value",
|
||||
claudeModels: {},
|
||||
}), context("claude"));
|
||||
expect(secretResponse.status).toBe(400);
|
||||
expect(JSON.stringify(await secretResponse.json())).not.toContain("secret-value");
|
||||
|
||||
const malformedResponse = await PUT(new Request("https://9router.local", {
|
||||
method: "PUT",
|
||||
body: "{",
|
||||
}), context("claude"));
|
||||
expect(malformedResponse.status).toBe(400);
|
||||
expect(upsertCliToolConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ensureCliToolV1Endpoint,
|
||||
isLocalCliToolUrl,
|
||||
resolveCliToolBaseUrl,
|
||||
resolveInitialCliToolBaseUrl,
|
||||
} from "@/shared/utils/cliToolEndpoint.js";
|
||||
|
||||
describe("CLI tool endpoint resolution", () => {
|
||||
it("keeps local dashboard origins local, including custom ports", () => {
|
||||
expect(resolveCliToolBaseUrl({ appUrl: "http://localhost:30100" })).toBe("http://localhost:30100");
|
||||
expect(resolveCliToolBaseUrl({ appUrl: "http://127.0.0.1:20128/" })).toBe("http://127.0.0.1:20128");
|
||||
expect(isLocalCliToolUrl("http://app.localhost:20128")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the browser deployment origin instead of localhost or a generic cloud URL", () => {
|
||||
expect(resolveCliToolBaseUrl({
|
||||
appUrl: "https://router.customer.example",
|
||||
cloudEnabled: true,
|
||||
cloudUrl: "https://9router.com",
|
||||
configuredBaseUrl: "http://localhost:20128",
|
||||
})).toBe("https://router.customer.example");
|
||||
});
|
||||
|
||||
it("uses a public endpoint for externally hosted tools when the dashboard is local", () => {
|
||||
expect(resolveCliToolBaseUrl({
|
||||
appUrl: "http://localhost:20128",
|
||||
requiresExternalUrl: true,
|
||||
tunnelEnabled: true,
|
||||
tunnelPublicUrl: "https://tunnel.example/",
|
||||
cloudEnabled: true,
|
||||
cloudUrl: "https://cloud.example",
|
||||
})).toBe("https://tunnel.example");
|
||||
|
||||
expect(resolveCliToolBaseUrl({
|
||||
appUrl: "http://localhost:20128",
|
||||
requiresExternalUrl: true,
|
||||
cloudEnabled: true,
|
||||
cloudUrl: "https://cloud.example/",
|
||||
})).toBe("https://cloud.example");
|
||||
});
|
||||
|
||||
it("adds /v1 exactly once to generated endpoints", () => {
|
||||
expect(ensureCliToolV1Endpoint("https://router.example/")).toBe("https://router.example/v1");
|
||||
expect(ensureCliToolV1Endpoint("https://router.example/v1/")).toBe("https://router.example/v1");
|
||||
});
|
||||
|
||||
it("migrates the old hardcoded localhost default when opened on a deployment", () => {
|
||||
expect(resolveInitialCliToolBaseUrl(
|
||||
"http://127.0.0.1:20128/v1",
|
||||
"https://router.customer.example",
|
||||
)).toBe("https://router.customer.example/v1");
|
||||
});
|
||||
|
||||
it("preserves an explicit remote saved endpoint and local saved endpoint on local runtime", () => {
|
||||
expect(resolveInitialCliToolBaseUrl(
|
||||
"https://custom-gateway.example/v1",
|
||||
"https://router.customer.example",
|
||||
)).toBe("https://custom-gateway.example/v1");
|
||||
expect(resolveInitialCliToolBaseUrl(
|
||||
"http://localhost:30100/v1",
|
||||
"http://localhost:20128",
|
||||
)).toBe("http://localhost:30100/v1");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user