685eb748e8
- The update dialog can be closed while an update applies: it keeps running, the page still reloads itself when the new server is up, and a failure re-opens the dialog with its error. - Account pool card: one tab per AI tool (Claude Code, Codex, Cursor CLI, Oh My Pi, and every other installed tool, which says it has no pool), accounts as cards with 5-hour and weekly quota shown as what is LEFT plus their reset times, and the saved profiles of Settings → AI tools → Profiles as cards with "Share to pool". - Cursor account pool (server/lib/cursor-pool.js): Cursor API keys handed to a cursor-agent pane only through tmux -e CURSOR_API_KEY; `agenthub accounts add <name> --tool cursor`. - Pool groups (server/lib/pool-groups.js, /api/accounts/groups): a group's members run only on its accounts of each tool, a user in no group on the accounts no group lists, a pin still wins. account_pins loses its tool CHECK (rebuilt, pins kept); three new tables. - Oh My Pi login sharing can be switched from its pool tab too.
449 lines
17 KiB
TypeScript
449 lines
17 KiB
TypeScript
/**
|
|
* @file AccountPoolCard.test.tsx
|
|
* @description The Users page's account pool card: one tab per AI tool — the
|
|
* Claude, Codex and Cursor pools with their accounts' live panes and quota,
|
|
* Oh My Pi's login sharing, and a "no pool" note for any other installed
|
|
* tool; says what an empty pool means per tool, an
|
|
* admin adds an account only with their own password typed (the token goes
|
|
* in once and is not shown back) — a saved login moved in (the server's own
|
|
* included), or a setup-token — and removes one the same way, and an analyst
|
|
* sees no add or remove control. An account's Edit ticks the users who run on
|
|
* it, saved with the admin's own password; the users show on the account.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import type { AccountPool } from "../../../lib/api";
|
|
|
|
const accountPool = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
add: vi.fn(),
|
|
remove: vi.fn(),
|
|
profiles: vi.fn(),
|
|
setUsers: vi.fn(),
|
|
createGroup: vi.fn(),
|
|
updateGroup: vi.fn(),
|
|
deleteGroup: vi.fn(),
|
|
}));
|
|
const users = vi.hoisted(() => ({ list: vi.fn() }));
|
|
const aiTools = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
ompSharing: vi.fn(),
|
|
setOmpSharing: vi.fn(),
|
|
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
|
}));
|
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>();
|
|
return { ...actual, api: { accountPool, users, aiTools } };
|
|
});
|
|
|
|
import { AccountPoolCard } from "../AccountPoolCard";
|
|
|
|
/** The card reads `?poolAdd=` and links to Settings: it needs a router. */
|
|
const card = (isAdmin: boolean, url = "/admin") => (
|
|
<MemoryRouter initialEntries={[url]}>
|
|
<AccountPoolCard isAdmin={isAdmin} />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const POOL: AccountPool = {
|
|
accounts: [
|
|
{
|
|
name: "team-1",
|
|
kind: "login",
|
|
email: "team1@example.com",
|
|
refreshError: null,
|
|
addedAt: "2026-10-01T00:00:00Z",
|
|
expiresAt: null,
|
|
expired: false,
|
|
livePanes: 2,
|
|
spend5h: 1.5,
|
|
spend7d: 12,
|
|
tokens5h: 250_000,
|
|
tokens7d: 3_400_000,
|
|
quota: {
|
|
fiveHour: {
|
|
utilization: 42,
|
|
resetsAt: new Date(Date.now() + 2 * 3600e3 + 630e3).toISOString(),
|
|
},
|
|
sevenDay: {
|
|
utilization: 15,
|
|
resetsAt: new Date(Date.now() + 3 * 86400e3 + 3630e3).toISOString(),
|
|
},
|
|
checkedAt: "2026-10-01T00:00:00Z",
|
|
error: null,
|
|
},
|
|
quotaPercent: 42,
|
|
full: false,
|
|
},
|
|
{
|
|
name: "team-2",
|
|
kind: "token",
|
|
email: null,
|
|
refreshError: null,
|
|
addedAt: "2026-10-01T00:00:00Z",
|
|
expiresAt: null,
|
|
expired: false,
|
|
livePanes: 0,
|
|
spend5h: 0,
|
|
spend7d: 0,
|
|
tokens5h: 0,
|
|
tokens7d: 0,
|
|
// A 5-hour window that has reset since the reading is full again.
|
|
quota: {
|
|
fiveHour: { utilization: 100, resetsAt: "2020-01-01T00:00:00Z" },
|
|
sevenDay: null,
|
|
checkedAt: "2026-10-01T00:00:00Z",
|
|
error: null,
|
|
},
|
|
quotaPercent: null,
|
|
full: false,
|
|
},
|
|
],
|
|
codex: [
|
|
{
|
|
name: "cx-1",
|
|
kind: "apikey",
|
|
addedAt: "2026-10-02T00:00:00Z",
|
|
livePanes: 0,
|
|
spend5h: 0,
|
|
spend7d: 0,
|
|
tokens5h: 0,
|
|
tokens7d: 0,
|
|
quotaPercent: null,
|
|
full: false,
|
|
},
|
|
],
|
|
blockPercent: 90,
|
|
};
|
|
|
|
const tool = (id: string, label: string, installed = true) => ({ id, label, installed });
|
|
const OMP = {
|
|
available: true,
|
|
enabled: false,
|
|
installed: true,
|
|
broker: { running: false },
|
|
gateway: { running: false },
|
|
};
|
|
|
|
beforeEach(() => {
|
|
for (const fn of Object.values(accountPool)) fn.mockReset();
|
|
aiTools.list.mockResolvedValue({
|
|
tools: [
|
|
tool("claude", "Claude Code"),
|
|
tool("codex", "Codex"),
|
|
tool("cursor", "Cursor CLI"),
|
|
tool("omp", "Oh My Pi"),
|
|
tool("antigravity", "Antigravity"),
|
|
tool("goose", "Goose", false),
|
|
],
|
|
default: "claude",
|
|
});
|
|
aiTools.ompSharing.mockResolvedValue(OMP);
|
|
aiTools.setOmpSharing.mockResolvedValue({ ...OMP, enabled: true });
|
|
accountPool.list.mockResolvedValue(POOL);
|
|
users.list.mockResolvedValue({
|
|
users: [
|
|
{ id: 7, username: "mia", disabled: false },
|
|
{ id: 8, username: "leo", disabled: false },
|
|
],
|
|
});
|
|
accountPool.profiles.mockResolvedValue({
|
|
profiles: [
|
|
{
|
|
name: "work",
|
|
label: "Work",
|
|
email: "w@example.com",
|
|
subscriptionType: "max",
|
|
active: false,
|
|
},
|
|
{ name: "me", label: "Me", email: "me@example.com", subscriptionType: "pro", active: true },
|
|
],
|
|
});
|
|
});
|
|
|
|
describe("AccountPoolCard", () => {
|
|
it("shows each tool's pool on its own tab, counted, and explains an empty one", async () => {
|
|
const user = userEvent.setup();
|
|
const { unmount } = render(card(false));
|
|
expect(await screen.findByText("team-1")).toBeInTheDocument();
|
|
expect(screen.queryByText("cx-1")).toBeNull();
|
|
const [account, second] = screen.getAllByTestId("pool-account");
|
|
// Every account of the pool has its card; quota is shown as what is LEFT.
|
|
expect(second).toHaveTextContent("team-2");
|
|
const quota = screen.getAllByTestId("pool-quota")[0]!;
|
|
expect(quota).toHaveTextContent("5 hours");
|
|
expect(quota).toHaveTextContent("58%");
|
|
expect(quota).toHaveTextContent("resets in 2h 10m");
|
|
expect(quota).toHaveTextContent("Week");
|
|
expect(quota).toHaveTextContent("85%");
|
|
expect(quota).toHaveTextContent("resets in 3d 1h");
|
|
const reset = screen.getAllByTestId("pool-quota")[1]!;
|
|
expect(reset).toHaveTextContent("100%");
|
|
expect(reset).toHaveTextContent("—");
|
|
expect(account).toHaveTextContent("250.0K tokens");
|
|
expect(account).toHaveTextContent("$12.00 · 3.4M tokens in 7 days");
|
|
expect(account).toHaveTextContent("2 live");
|
|
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
|
|
// Without groups nothing is said about them on an account.
|
|
expect(screen.queryByText("Shared pool")).toBeNull();
|
|
expect(screen.queryByRole("button", { name: /^add/i })).toBeNull();
|
|
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
|
|
const codexTab = screen.getByRole("tab", { name: /Codex/ });
|
|
expect(codexTab).toHaveTextContent("1");
|
|
expect(screen.getByRole("tab", { name: /Claude Code/ })).toHaveTextContent("2");
|
|
await user.click(codexTab);
|
|
expect(screen.getByText("cx-1")).toBeInTheDocument();
|
|
expect(screen.queryByText("team-1")).toBeNull();
|
|
// A pool with no account still has its tab, with what that means.
|
|
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
|
|
expect(screen.getByText(/No Cursor CLI accounts yet/)).toBeInTheDocument();
|
|
// An installed tool without a pool says so; an uninstalled one gets no tab.
|
|
await user.click(screen.getByRole("tab", { name: /Antigravity/ }));
|
|
expect(screen.getByText("Antigravity has no account pool")).toBeInTheDocument();
|
|
expect(screen.getByText(/system keyring/)).toBeInTheDocument();
|
|
expect(screen.queryByRole("tab", { name: /Goose/ })).toBeNull();
|
|
unmount();
|
|
accountPool.list.mockResolvedValue({ accounts: [], codex: [], blockPercent: 90 });
|
|
render(card(true));
|
|
expect(await screen.findByText(/No Claude Code accounts yet/)).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Add Claude Code account" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("adds a Cursor API key from the Cursor tab", async () => {
|
|
accountPool.add.mockResolvedValue({ account: { name: "cu-1" } });
|
|
accountPool.list.mockResolvedValue({ ...POOL, cursor: [] });
|
|
const user = userEvent.setup();
|
|
render(card(true));
|
|
await screen.findByText("team-1");
|
|
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
|
|
await user.click(screen.getByRole("button", { name: "Add Cursor CLI account" }));
|
|
const dialog = await screen.findByRole("dialog");
|
|
expect(screen.queryByLabelText("From")).toBeNull();
|
|
await user.type(screen.getByLabelText("Name"), "cu-1");
|
|
await user.type(screen.getByLabelText("Cursor API key"), "key_abc");
|
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
|
await user.click(
|
|
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")!
|
|
);
|
|
await waitFor(() =>
|
|
expect(accountPool.add).toHaveBeenCalledWith(
|
|
{ tool: "cursor", name: "cu-1", token: "key_abc" },
|
|
"boss-pass"
|
|
)
|
|
);
|
|
expect(accountPool.profiles).not.toHaveBeenCalledWith("cursor");
|
|
});
|
|
|
|
it("shares a saved profile from its card", async () => {
|
|
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
|
const user = userEvent.setup();
|
|
render(card(true));
|
|
await screen.findByText("team-1");
|
|
await user.click(await screen.findByRole("button", { name: "Share Work to the pool" }));
|
|
const dialog = await screen.findByRole("dialog");
|
|
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("work"));
|
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
|
await user.click(
|
|
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")!
|
|
);
|
|
await waitFor(() =>
|
|
expect(accountPool.add).toHaveBeenCalledWith(
|
|
{ tool: "claude", name: "work", profile: "work" },
|
|
"boss-pass"
|
|
)
|
|
);
|
|
});
|
|
|
|
it("shows the groups as cards and saves a new one with members and accounts", async () => {
|
|
accountPool.list.mockResolvedValue({
|
|
...POOL,
|
|
groups: [
|
|
{
|
|
id: 1,
|
|
name: "Team A",
|
|
users: [{ userId: 8, username: "leo" }],
|
|
accounts: { claude: ["team-1"], codex: [], cursor: [] },
|
|
},
|
|
],
|
|
});
|
|
accountPool.createGroup.mockResolvedValue({ group: { id: 2, name: "Team B" } });
|
|
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
|
const user = userEvent.setup();
|
|
render(card(true));
|
|
const groupCard = await screen.findByTestId("pool-group");
|
|
expect(groupCard).toHaveTextContent("Team A");
|
|
expect(groupCard).toHaveTextContent("leo");
|
|
expect(screen.getAllByTestId("pool-account")[0]).toHaveTextContent("Team A");
|
|
await user.click(screen.getByRole("button", { name: "New group" }));
|
|
await screen.findByRole("dialog");
|
|
await user.type(screen.getByLabelText("Group name"), "Team B");
|
|
await user.click(await screen.findByRole("checkbox", { name: /leo/ }));
|
|
expect(screen.getByText("now in Team A")).toBeInTheDocument();
|
|
await user.click(screen.getByRole("checkbox", { name: "cx-1" }));
|
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
|
await waitFor(() =>
|
|
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
|
2,
|
|
{ userIds: [8], accounts: { claude: [], codex: ["cx-1"], cursor: [] } },
|
|
"boss-pass"
|
|
)
|
|
);
|
|
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B", "boss-pass");
|
|
});
|
|
|
|
it("turns Oh My Pi login sharing on from its tab", async () => {
|
|
const user = userEvent.setup();
|
|
render(card(true));
|
|
await screen.findByText("team-1");
|
|
await user.click(screen.getByRole("tab", { name: /Oh My Pi/ }));
|
|
expect(screen.getByTestId("omp-pool-status")).toHaveTextContent(/Sharing is off/);
|
|
await user.click(screen.getByRole("switch"));
|
|
await waitFor(() => expect(aiTools.setOmpSharing).toHaveBeenCalledWith(true));
|
|
expect(screen.getByRole("tab", { name: /Oh My Pi/ })).toHaveTextContent("shared");
|
|
});
|
|
|
|
it("moves a saved login in, the server's own included", async () => {
|
|
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
|
const user = userEvent.setup();
|
|
render(card(true));
|
|
await screen.findByText("team-1");
|
|
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
|
|
const dialog = await screen.findByRole("dialog");
|
|
const select = await screen.findByLabelText("Profile");
|
|
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
|
|
expect(screen.getByRole("option", { name: /Me/ })).toBeEnabled();
|
|
await user.selectOptions(select, "work");
|
|
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
|
(b) => b.textContent === "Add account"
|
|
)!;
|
|
expect(confirm).toBeDisabled();
|
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
|
await user.click(confirm);
|
|
await waitFor(() =>
|
|
expect(accountPool.add).toHaveBeenCalledWith(
|
|
{ tool: "claude", name: "work", profile: "work" },
|
|
"boss-pass"
|
|
)
|
|
);
|
|
});
|
|
|
|
it("adds a setup-token only with the admin's own password", async () => {
|
|
accountPool.add.mockResolvedValue({ account: { name: "team-2" } });
|
|
const user = userEvent.setup();
|
|
render(card(true));
|
|
await screen.findByText("team-1");
|
|
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
|
|
const dialog = await screen.findByRole("dialog");
|
|
await user.selectOptions(screen.getByLabelText("From"), "token");
|
|
await user.type(screen.getByLabelText("Name"), "team-2");
|
|
await user.type(screen.getByLabelText("Token"), "sk-ant-oat01-xyz");
|
|
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
|
(b) => b.textContent === "Add account"
|
|
)!;
|
|
expect(confirm).toBeDisabled();
|
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
|
expect(confirm).toBeEnabled();
|
|
await user.click(confirm);
|
|
await waitFor(() =>
|
|
expect(accountPool.add).toHaveBeenCalledWith(
|
|
{ tool: "claude", name: "team-2", token: "sk-ant-oat01-xyz" },
|
|
"boss-pass"
|
|
)
|
|
);
|
|
});
|
|
|
|
it("removes an account with the admin's own password", async () => {
|
|
accountPool.remove.mockResolvedValue({ ok: true });
|
|
const user = userEvent.setup();
|
|
render(card(true));
|
|
await screen.findByText("team-1");
|
|
await user.click(screen.getByRole("tab", { name: /Codex/ }));
|
|
await user.click(await screen.findByRole("button", { name: "Remove cx-1" }));
|
|
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
|
|
await user.click(screen.getByRole("button", { name: "Remove" }));
|
|
await waitFor(() =>
|
|
expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1", "boss-pass")
|
|
);
|
|
});
|
|
|
|
it("sets who runs on an account from its Edit, with the admin's own password", async () => {
|
|
accountPool.list.mockResolvedValue({
|
|
...POOL,
|
|
pins: {
|
|
claude: [
|
|
{ userId: 8, username: "leo", account: "team-1" },
|
|
{ userId: 7, username: "mia", account: "team-0" },
|
|
],
|
|
codex: [],
|
|
},
|
|
});
|
|
accountPool.setUsers.mockResolvedValue({
|
|
added: [{ userId: 7, username: "mia", switched: "switched" }],
|
|
removed: [{ userId: 8, username: "leo" }],
|
|
});
|
|
const user = userEvent.setup();
|
|
render(card(true));
|
|
expect(await screen.findByText("Used by: leo")).toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: /switch a user/i })).toBeNull();
|
|
await user.click(screen.getByRole("button", { name: "Edit team-1" }));
|
|
const dialog = await screen.findByRole("dialog");
|
|
const leo = await screen.findByRole("checkbox", { name: /leo/ });
|
|
const mia = screen.getByRole("checkbox", { name: /mia/ });
|
|
expect(leo).toHaveAttribute("aria-checked", "true");
|
|
expect(mia).toHaveAttribute("aria-checked", "false");
|
|
expect(screen.getByText("now on team-0")).toBeInTheDocument();
|
|
await user.click(mia);
|
|
await user.click(leo);
|
|
const save = Array.from(dialog.querySelectorAll("button")).find(
|
|
(b) => b.textContent === "Save"
|
|
)!;
|
|
expect(save).toBeDisabled();
|
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
|
await user.click(save);
|
|
await waitFor(() =>
|
|
expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7], "boss-pass")
|
|
);
|
|
});
|
|
|
|
it("moves a saved Codex profile in, and opens on one from its Move to pool link", async () => {
|
|
accountPool.add.mockResolvedValue({ account: { name: "cx" } });
|
|
accountPool.profiles.mockImplementation(async (tool: string) =>
|
|
tool === "codex"
|
|
? {
|
|
profiles: [
|
|
{ name: "cx", label: "Codex", email: "c@x", subscriptionType: "plus", active: false },
|
|
],
|
|
}
|
|
: { profiles: [] }
|
|
);
|
|
const user = userEvent.setup();
|
|
render(card(true, "/admin?poolAdd=codex&poolProfile=cx"));
|
|
const dialog = await screen.findByRole("dialog");
|
|
await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("codex"));
|
|
expect(screen.getByRole("tab", { name: /Codex/ })).toHaveAttribute("aria-selected", "true");
|
|
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("cx"));
|
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
|
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
|
(b) => b.textContent === "Add account"
|
|
)!;
|
|
await user.click(confirm);
|
|
await waitFor(() =>
|
|
expect(accountPool.add).toHaveBeenCalledWith(
|
|
{ tool: "codex", name: "cx", profile: "cx" },
|
|
"boss-pass"
|
|
)
|
|
);
|
|
});
|
|
|
|
it("links back to the profiles in Settings", async () => {
|
|
render(card(false));
|
|
const link = await screen.findByRole("link", { name: "Profiles in Settings" });
|
|
expect(link.getAttribute("href")).toBe("/settings/agents#tool-profiles");
|
|
});
|
|
});
|