Files
AgentHub/server/__tests__/pool-groups.test.js
T
nntrivi2001 685eb748e8 feat: account pool per tool with groups and a Cursor pool; update runs in the background
- 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.
2026-10-22 17:02:08 +07:00

204 lines
7.9 KiB
JavaScript

/**
* @file pool-groups.test.js
* @description Account pool groups (server/lib/pool-groups.js and the
* `/api/accounts/groups` routes), fake accounts only: with no group every
* account is shared as before; a group's member gets only the group's
* accounts of each tool (none → no pool account), a user in no group only
* the accounts no group lists, a pin wins over the group; one group per user;
* a removed account or deleted group leaves no rows behind; and the routes
* need an admin's own password again, are audited and refuse in local mode.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pool-groups-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
const groups = require("../lib/pool-groups");
const cursorPool = require("../lib/cursor-pool");
const claudePool = require("../lib/claude-pool");
const pins = require("../lib/account-pins");
require("../lib/cc-usage").fetchUsage = async () => ({});
const BOSS_PASS = "admin-pass-1";
const KEY = (c) => `key_${c.repeat(40)}`;
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
let server;
let BASE;
let MIA;
let LEO;
let ZOE;
async function call(method, p, { cookie, body } = {}) {
const res = await fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return res.headers.get("set-cookie").split(";")[0];
}
before(async () => {
process.env.AGENTHUB_MODE = "server";
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
await auth.createUser("boss", BOSS_PASS, "admin");
MIA = (await auth.createUser("mia", "member-pass-1", "developer")).id;
LEO = (await auth.createUser("leo", "member-pass-2", "developer")).id;
ZOE = (await auth.createUser("zoe", "member-pass-3", "developer")).id;
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
beforeEach(() => {
db.prepare("DELETE FROM pool_groups").run();
db.prepare("DELETE FROM account_pins").run();
for (const dir of [cursorPool.poolDir(), claudePool.poolDir()]) {
fs.rmSync(dir, { recursive: true, force: true });
}
for (const c of ["a", "b", "c"]) cursorPool.addAccount(c, { apiKey: KEY(c) });
});
describe("pool groups: who may take which account", () => {
it("shares every account while no group exists", () => {
assert.equal(cursorPool.pickAccount(MIA).name, "a");
assert.ok(["a", "b", "c"].every(groups.allowed(MIA, "cursor")));
});
it("gives a member only the group's accounts, the rest only the shared ones", () => {
const g = groups.createGroup("Team A");
groups.setGroupUsers(g.id, [MIA]);
groups.setGroupAccounts(g.id, "cursor", ["b"]);
assert.equal(cursorPool.pickAccount(MIA).name, "b");
assert.equal(cursorPool.pickAccount(LEO).name, "a", "b is no longer shared");
// An account may serve several groups; a group with no account of a tool
// leaves its members on their own sign-in.
const h = groups.createGroup("Team B");
groups.setGroupUsers(h.id, [ZOE]);
assert.equal(cursorPool.pickAccount(ZOE), null);
groups.setGroupAccounts(h.id, "cursor", ["b", "c"]);
assert.ok(["b", "c"].includes(cursorPool.pickAccount(ZOE).name));
assert.equal(cursorPool.pickAccount(LEO).name, "a");
// Claude follows the same groups.
claudePool.addAccount("shared", TOKEN("s"));
claudePool.addAccount("team", TOKEN("t"));
groups.setGroupAccounts(g.id, "claude", ["team"]);
assert.equal(claudePool.pickAccount(null, MIA).name, "team");
assert.equal(claudePool.pickAccount(null, LEO).name, "shared");
});
it("puts a user in one group only, and honours a pin outside it", () => {
const g = groups.createGroup("A");
const h = groups.createGroup("B");
groups.setGroupUsers(g.id, [MIA, LEO]);
groups.setGroupUsers(h.id, [MIA]);
const list = groups.listGroups();
assert.deepEqual(
list.find((x) => x.name === "A").users.map((u) => u.username),
["leo"]
);
assert.deepEqual(
list.find((x) => x.name === "B").users.map((u) => u.username),
["mia"]
);
groups.setGroupAccounts(h.id, "cursor", ["b"]);
pins.setPin(MIA, "cursor", "c");
assert.equal(cursorPool.pickAccount(MIA).name, "c");
});
it("drops a removed account from its groups, and a group's rows with it", () => {
const g = groups.createGroup("A");
groups.setGroupUsers(g.id, [MIA]);
groups.setGroupAccounts(g.id, "cursor", ["b"]);
cursorPool.removeAccount("b");
assert.deepEqual(groups.listGroups()[0].accounts.cursor, []);
groups.deleteGroup(g.id);
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM pool_group_users").get().n, 0);
assert.throws(() => groups.setGroupAccounts(g.id, "cursor", ["a"]), { code: "ENOGROUP" });
assert.throws(() => groups.createGroup(""), { code: "EBADNAME" });
});
});
describe("pool groups: routes", () => {
it("creates, edits and deletes a group with the admin's own password, audited", async () => {
const boss = await login("boss", BOSS_PASS);
const bad = await call("POST", "/api/accounts/groups", {
cookie: boss,
body: { name: "Team A", currentPassword: "nope" },
});
assert.equal(bad.body.error.code, "EREAUTH");
const made = await call("POST", "/api/accounts/groups", {
cookie: boss,
body: { name: "Team A", currentPassword: BOSS_PASS },
});
assert.equal(made.status, 201);
const id = made.body.group.id;
const dup = await call("POST", "/api/accounts/groups", {
cookie: boss,
body: { name: "team a", currentPassword: BOSS_PASS },
});
assert.equal(dup.body.error.code, "EEXISTS");
const edit = await call("PATCH", `/api/accounts/groups/${id}`, {
cookie: boss,
body: {
name: "Team Alpha",
userIds: [MIA],
accounts: { cursor: ["a"] },
currentPassword: BOSS_PASS,
},
});
assert.equal(edit.status, 200);
assert.equal(edit.body.group.name, "Team Alpha");
assert.deepEqual(edit.body.group.accounts.cursor, ["a"]);
const missing = await call("PATCH", `/api/accounts/groups/${id}`, {
cookie: boss,
body: { accounts: { cursor: ["nope"] }, currentPassword: BOSS_PASS },
});
assert.equal(missing.body.error.code, "ENOACCOUNT");
const list = await call("GET", "/api/accounts", { cookie: boss });
assert.equal(list.body.groups[0].users[0].username, "mia");
const audit = db.prepare("SELECT path FROM audit_log ORDER BY id DESC LIMIT 1").get();
assert.match(audit.path, /group Team Alpha cursor accounts/);
const dev = await login("leo", "member-pass-2");
const refused = await call("DELETE", `/api/accounts/groups/${id}`, {
cookie: dev,
body: { currentPassword: "member-pass-2" },
});
assert.equal(refused.status, 403);
const del = await call("DELETE", `/api/accounts/groups/${id}`, {
cookie: boss,
body: { currentPassword: BOSS_PASS },
});
assert.equal(del.status, 200);
const gone = await call("DELETE", `/api/accounts/groups/${id}`, {
cookie: boss,
body: { currentPassword: BOSS_PASS },
});
assert.equal(gone.status, 404);
});
});