/** * @file Tests for the AI Proxy settings page — listing profiles, creating one, * and the two rules that keep a key safe: the form never shows one, and an * edit that leaves the field blank must not send an empty key that would wipe * the stored one. In server mode: a developer or analyst edits only their * personal profiles and adds personal ones, an admin picks shared or personal. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { AiProxyConfig, nineRouterUrl } from "../AiProxyConfig"; import { api } from "../../lib/api"; import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm"; import type { AuthState } from "../../lib/api"; let authState: AuthState = { mode: "local" }; vi.mock("../../components/AuthGate", () => ({ useAuth: () => ({ state: authState, logout: async () => {} }), })); vi.mock("../../lib/api", () => ({ api: { aiProxies: { list: vi.fn(), create: vi.fn(), update: vi.fn(), remove: vi.fn(), test: vi.fn(), nineRouter: vi.fn(), }, }, })); vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string, opts?: Record) => opts && typeof opts.label === "string" ? `${key}:${opts.label}` : key, }), })); const native = { name: "native", label: "Native (Claude / Codex)", builtin: true, baseUrl: "", hasKey: false, model: "", models: {}, }; const nineRouter = { ...native, name: "9router", label: "9Router", builtin: false, baseUrl: "http://localhost:20128/v1", hasKey: true, model: "Highest", models: { opus: "Highest", sonnet: "Highest", haiku: "Highest", smallFast: "Highest", subagent: "Highest", }, }; beforeEach(() => { authState = { mode: "local" }; vi.mocked(api.aiProxies.list) .mockReset() .mockResolvedValue({ items: [native] }); vi.mocked(api.aiProxies.create).mockReset().mockResolvedValue(nineRouter); vi.mocked(api.aiProxies.update).mockReset().mockResolvedValue(nineRouter); vi.mocked(api.aiProxies.remove).mockReset().mockResolvedValue({ removed: "9router" }); vi.mocked(api.aiProxies.nineRouter).mockReset().mockResolvedValue({ installed: false }); vi.mocked(api.aiProxies.test) .mockReset() .mockResolvedValue({ claude: { ok: true, status: 200, message: "Highest" }, codex: { ok: true, status: 200, message: "Highest" }, }); }); describe("AiProxyConfig", () => { it("shows Config 9router beside Add profile only when 9router is installed", async () => { vi.mocked(api.aiProxies.nineRouter).mockResolvedValue({ installed: true }); render(); const link = await screen.findByRole("link", { name: /aiProxy\.config9router/ }); // The dashboard's own host (jsdom's is localhost), not a fixed localhost. expect(link).toHaveAttribute("href", `http://${window.location.hostname}:20128`); expect(link).toHaveAttribute("target", "_blank"); }); it("points Config 9router at the host the dashboard was opened on", () => { expect(nineRouterUrl({ hostname: "10.1.13.7" })).toBe("http://10.1.13.7:20128"); expect(nineRouterUrl({ hostname: "::1" })).toBe("http://[::1]:20128"); }); it("hides Config 9router when 9router is not installed", async () => { render(); await screen.findByText("Native (Claude / Codex)"); await waitFor(() => expect(api.aiProxies.nineRouter).toHaveBeenCalled()); expect(screen.queryByRole("link", { name: /aiProxy\.config9router/ })).toBeNull(); }); it("lists the built-in native profile with no edit or delete control", async () => { render(); expect(await screen.findByText("Native (Claude / Codex)")).toBeInTheDocument(); expect(screen.queryByLabelText(/aiProxy\.edit Native/)).toBeNull(); expect(screen.queryByLabelText(/aiProxy\.remove Native/)).toBeNull(); }); it("saves a new profile with the typed endpoint, key and model", async () => { render(); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ })); await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "9Router"); await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1"); await user.type(screen.getByLabelText("aiProxy.fieldApiKey"), "sk-typed"); await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest"); await user.click(screen.getByRole("button", { name: "aiProxy.save" })); await waitFor(() => expect(api.aiProxies.create).toHaveBeenCalledWith({ label: "9Router", baseUrl: "http://localhost:20128/v1", model: "Highest", // Every tier equals the headline model, so none is sent: the server // fills them, which is what makes one field enough. models: {}, behavesAs: "", apiKey: "sk-typed", }) ); }); it("sends the typed behavesAs, which maps the proxy's model names in Claude Code (#56)", async () => { render(); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ })); await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "9Router"); await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1"); await user.type(screen.getByLabelText("aiProxy.fieldModel"), "agent"); await user.type(screen.getByLabelText("aiProxy.fieldBehavesAs"), "claude-opus-5-5"); await user.click(screen.getByRole("button", { name: "aiProxy.save" })); await waitFor(() => expect(api.aiProxies.create).toHaveBeenCalledWith( expect.objectContaining({ model: "agent", behavesAs: "claude-opus-5-5" }) ) ); }); it("sends only the tiers that differ from the headline model", async () => { render(); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ })); await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "9Router"); await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1"); await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest"); await user.type(screen.getByLabelText("aiProxy.tier.opus"), "PremiumCombo"); await user.click(screen.getByRole("button", { name: "aiProxy.save" })); await waitFor(() => expect(api.aiProxies.create).toHaveBeenCalledWith( expect.objectContaining({ models: { opus: "PremiumCombo" } }) ) ); }); it("never puts a stored key in the edit form, and omits it when left blank", async () => { // The API cannot return a key, so an edit that sends `apiKey: ""` would // silently erase the one on disk. vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, nineRouter] }); render(); const user = userEvent.setup(); await user.click(await screen.findByLabelText("aiProxy.edit 9Router")); expect(screen.getByLabelText("aiProxy.fieldApiKey")).toHaveValue(""); await user.clear(screen.getByLabelText("aiProxy.fieldLabel")); await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "9Router nhà"); await user.click(screen.getByRole("button", { name: "aiProxy.save" })); await waitFor(() => expect(api.aiProxies.update).toHaveBeenCalled()); const call = vi.mocked(api.aiProxies.update).mock.calls[0]; expect(call?.[1]).not.toHaveProperty("apiKey"); }); it("surfaces a server error instead of closing the form", async () => { vi.mocked(api.aiProxies.create).mockRejectedValue( new Error("baseUrl must be http:// or https://") ); render(); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ })); await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "Bad"); await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "file:///etc/passwd"); await user.type(screen.getByLabelText("aiProxy.fieldModel"), "x"); await user.click(screen.getByRole("button", { name: "aiProxy.save" })); // Inside the dialog, not behind its scrim. const dialog = screen.getByRole("dialog"); expect(await within(dialog).findByText(/must be http/)).toBeInTheDocument(); }); it("closes the form on Escape", async () => { render(); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ })); expect(screen.getByRole("dialog")).toBeInTheDocument(); await user.keyboard("{Escape}"); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); }); }); describe("AiProxyConfig — testing an endpoint before saving it", () => { it("sends the typed endpoint, key and model, and reports success", async () => { render(); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ })); await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1"); await user.type(screen.getByLabelText("aiProxy.fieldApiKey"), "sk-typed"); await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest"); await user.click(screen.getByTestId("proxy-test")); await waitFor(() => expect(api.aiProxies.test).toHaveBeenCalledWith({ baseUrl: "http://localhost:20128/v1", model: "Highest", apiKey: "sk-typed", }) ); const lines = await screen.findAllByTestId("proxy-test-result"); expect(lines[0]).toHaveTextContent("Claude Code: aiProxy.testOk"); expect(lines[1]).toHaveTextContent("Codex: aiProxy.testOk"); }); it("names the profile being edited so the server can reuse its stored key", async () => { vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, nineRouter] }); render(); const user = userEvent.setup(); await user.click(await screen.findByLabelText(/aiProxy\.edit 9Router/)); await user.click(screen.getByTestId("proxy-test")); await waitFor(() => expect(api.aiProxies.test).toHaveBeenCalledWith({ baseUrl: "http://localhost:20128/v1", model: "Highest", name: "9router", }) ); }); it("shows the proxy's own refusal per CLI rather than claiming it works", async () => { vi.mocked(api.aiProxies.test).mockResolvedValue({ claude: { ok: false, status: 401, message: "invalid api key" }, codex: { ok: true, status: 200, message: "Highest" }, }); render(); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ })); await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1"); await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest"); await user.click(screen.getByTestId("proxy-test")); const [claude, codex] = await screen.findAllByTestId("proxy-test-result"); expect(claude).toHaveTextContent("Claude Code: aiProxy.testFailed"); expect(codex).toHaveTextContent("Codex: aiProxy.testOk"); }); it("drops a stale verdict as soon as a field changes", async () => { render(); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ })); await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1"); await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest"); await user.click(screen.getByTestId("proxy-test")); expect(await screen.findAllByTestId("proxy-test-result")).toHaveLength(2); await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "2"); await waitFor(() => expect(screen.queryAllByTestId("proxy-test-result")).toHaveLength(0)); }); it("opens a profile's Edit and Remove from a right-click; the built-in row has no menu", async () => { vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, nineRouter] }); let pending: PendingConfirm | null = null; const off = subscribeConfirm((p) => (pending = p)); const user = userEvent.setup(); render(); const row = (await screen.findByText("9Router")).closest("tr")!; await user.pointer({ keys: "[MouseRight]", target: screen.getByText(native.label) }); expect(screen.queryByRole("menu")).toBeNull(); await user.pointer({ keys: "[MouseRight]", target: row }); await user.click(screen.getByRole("menuitem", { name: "aiProxy.remove" })); await waitFor(() => expect(pending?.title).toBe("aiProxy.removeConfirm:9Router")); settleConfirm(false); await waitFor(() => expect(pending).toBeNull()); expect(api.aiProxies.remove).not.toHaveBeenCalled(); await user.pointer({ keys: "[MouseRight]", target: row }); await user.click(screen.getByRole("menuitem", { name: "aiProxy.edit" })); expect(screen.getByRole("dialog")).toBeInTheDocument(); off(); }); }); describe("AiProxyConfig in server mode", () => { const shared = { ...nineRouter, scope: "shared" as const }; const mine = { ...nineRouter, name: "u7_mine", label: "Mine", scope: "personal" as const, }; const signIn = (role: "admin" | "developer" | "analyst") => { authState = { mode: "server", user: { id: 7, username: "mia", role } }; }; it("lets a developer edit only their personal profile, badged Personal", async () => { signIn("developer"); vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, shared, mine] }); render(); await screen.findByText("Mine"); expect(screen.getByText("aiProxy.personal")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /aiProxy\.edit 9Router/ })).toBeNull(); expect(screen.getByRole("button", { name: /aiProxy\.edit Mine/ })).toBeInTheDocument(); }); it("adds a developer's profile as personal, with no scope choice", async () => { signIn("developer"); const user = userEvent.setup(); render(); await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ })); const dialog = screen.getByRole("dialog"); expect(within(dialog).queryByRole("combobox")).toBeNull(); expect(within(dialog).getByText("aiProxy.scopePersonal")).toBeInTheDocument(); await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "Mine"); await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://x:1"); await user.type(screen.getByLabelText("aiProxy.fieldModel"), "m"); await user.click(screen.getByRole("button", { name: "aiProxy.save" })); await waitFor(() => expect(api.aiProxies.create).toHaveBeenCalled()); expect(vi.mocked(api.aiProxies.create).mock.calls[0]?.[0].scope).toBeUndefined(); }); it("lets an admin choose a personal profile", async () => { signIn("admin"); vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, shared] }); const user = userEvent.setup(); render(); expect( await screen.findByRole("button", { name: /aiProxy\.edit 9Router/ }) ).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: /aiProxy\.add/ })); await user.selectOptions(screen.getByLabelText("aiProxy.fieldScope"), "personal"); await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "Boss"); await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://x:1"); await user.type(screen.getByLabelText("aiProxy.fieldModel"), "m"); await user.click(screen.getByRole("button", { name: "aiProxy.save" })); await waitFor(() => expect(api.aiProxies.create).toHaveBeenCalled()); expect(vi.mocked(api.aiProxies.create).mock.calls[0]?.[0].scope).toBe("personal"); }); it("gives an analyst Add and Edit on personal profiles only", async () => { signIn("analyst"); vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, shared, mine] }); render(); await screen.findByText("Mine"); expect(screen.getByRole("button", { name: /aiProxy\.add/ })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /aiProxy\.edit 9Router/ })).toBeNull(); expect(screen.getByRole("button", { name: /aiProxy\.edit Mine/ })).toBeInTheDocument(); }); });