Files
AgentHub/client/src/pages/__tests__/AiProxyConfig.test.tsx
T
nntrivi2001 0c2f49876d feat: v2.7.6 — edit users in one form, Settings → Account, display names, copy over plain HTTP
- Users page: one Edit user form (display name, email, role, active, a new or
  generated password, workspaces given or taken back, sign out everywhere),
  validated as a whole before anything is written (PATCH /api/users/:id,
  GET /api/users/:id); users.display_name/email added migration-safe.
- Settings → Account for every signed-in user: profile, own password (the
  current one required, this login kept), sign out other devices
  (/api/me/account, /api/me/password, /api/me/sign-out-others; login cookie
  only, never a pane token).
- Display names wherever a person is named (sidebar, workspace owner groups,
  Users page and audit log); owner_display_name / reader_display additive.
- Users page: admins add and remove Claude/Codex pool accounts.
- Folder browser: an admin with their own Linux account opens in their home;
  the "Server folder" tick opens the host's (never remembered).
- AI Proxy: "Config 9router" beside Add profile when 9router is installed,
  opened on the dashboard's own host (GET /api/ai-proxies/9router).
- Terminal: a tmux selection reaches the clipboard over plain HTTP too
  (writeClipboard's textarea fallback, focus handed back).
- Chat: a file the session touched outside the workspace previews
  (GET /api/sessions/:id/file).
- install-system: `!requiretty` only when visudo accepts it (sudo-rs on
  Ubuntu 25.10+ rejects the setting).
- openapi.yaml regenerated; docs (API, SERVER-MODE, LANES) updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:10:29 +07:00

387 lines
16 KiB
TypeScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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<string, unknown>) =>
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(<AiProxyConfig />);
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(<AiProxyConfig />);
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(<AiProxyConfig />);
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(<AiProxyConfig />);
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(<AiProxyConfig />);
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(<AiProxyConfig />);
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(<AiProxyConfig />);
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(<AiProxyConfig />);
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(<AiProxyConfig />);
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(<AiProxyConfig />);
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(<AiProxyConfig />);
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(<AiProxyConfig />);
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(<AiProxyConfig />);
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(<AiProxyConfig />);
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(<AiProxyConfig />);
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(<AiProxyConfig />);
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(<AiProxyConfig />);
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(<AiProxyConfig />);
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();
});
});