Files
AgentHub/server/__tests__/server-mode-account-pool.test.js
T
nntrivi2001 776ad6466f feat(pool): an admin's switch moves a user's Claude login at once
PUT /api/accounts/pins (and agenthub accounts pin) now writes a pinned
Claude login into the user's home right away (claude-pool.switchHome):
every home's newer pair is copied back first, the member's own login is
never written over, and no pane is restarted — running sessions take the
new pair at their next credentials read. A live home stays on the login it
holds, so unpinning does not flip it back. The Users page calls it
"Switch a user's account" and says what happened.
2026-10-19 10:23:44 +07:00

221 lines
9.2 KiB
JavaScript

/**
* @file server-mode-account-pool.test.js
* @description The Claude/Codex account pool from the Users page: only an
* admin may add or remove an account, each change needs the admin's OWN
* password again (EREAUTH), the token only goes in — no answer (the add, the
* list) ever carries it — the audit log names the account, not its secret, and
* in local mode the routes refuse. An admin pins a user to one account
* (`PUT /api/accounts/pins`): that user's new panes skip the balancer.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = 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(), "server-mode-account-pool-"));
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 claudePool = require("../lib/claude-pool");
const codexPool = require("../lib/codex-pool");
// The quota read would go to the network; the pool's list must not.
require("../lib/cc-usage").fetchUsage = async () => ({});
let server;
let BASE;
const BOSS_PASS = "admin-pass-1";
const TOKEN = `sk-ant-oat01-${"a".repeat(40)}`;
const KEY = `sk-proj-${"b".repeat(40)}`;
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.status === 200 ? res.headers.get("set-cookie").split(";")[0] : null;
}
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");
await auth.createUser("ana", "analyst-pass-1", "analyst");
await auth.createUser("mia", "member-pass-1", "developer");
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("the account pool from the Users page", () => {
it("lets an admin add a Claude account, never answering with its token", async () => {
const boss = await login("boss", BOSS_PASS);
const r = await call("POST", "/api/accounts", {
cookie: boss,
body: { tool: "claude", name: "team-1", token: TOKEN, currentPassword: BOSS_PASS },
});
assert.equal(r.status, 201);
assert.equal(r.body.account.name, "team-1");
assert.ok(!JSON.stringify(r.body).includes(TOKEN));
assert.equal(claudePool.pickAccount().token, TOKEN, "new panes run on it");
const list = await call("GET", "/api/accounts", { cookie: boss });
assert.deepEqual(
list.body.accounts.map((a) => a.name),
["team-1"]
);
assert.ok(!JSON.stringify(list.body).includes(TOKEN));
const audit = db.prepare("SELECT path FROM audit_log ORDER BY id DESC LIMIT 1").get();
assert.match(audit.path, /add claude account team-1/);
assert.ok(!audit.path.includes(TOKEN));
});
it("adds a Codex API key, and removes accounts again", async () => {
const boss = await login("boss", BOSS_PASS);
const add = await call("POST", "/api/accounts", {
cookie: boss,
body: { tool: "codex", name: "cx-1", token: KEY, currentPassword: BOSS_PASS },
});
assert.equal(add.status, 201);
assert.equal(add.body.account.kind, "apikey");
assert.ok(!JSON.stringify(add.body).includes(KEY));
assert.equal(codexPool.__read("cx-1").key, KEY);
const del = await call("DELETE", "/api/accounts/cx-1", {
cookie: boss,
body: { tool: "codex", currentPassword: BOSS_PASS },
});
assert.equal(del.status, 200);
assert.equal(codexPool.__read("cx-1"), null);
const gone = await call("DELETE", "/api/accounts/cx-1", {
cookie: boss,
body: { tool: "codex", currentPassword: BOSS_PASS },
});
assert.equal(gone.status, 404);
});
it("refuses a wrong password, a bad token, a taken name and an unknown tool", async () => {
const boss = await login("boss", BOSS_PASS);
const add = (body) =>
call("POST", "/api/accounts", {
cookie: boss,
body: { tool: "claude", name: "team-2", token: TOKEN, currentPassword: BOSS_PASS, ...body },
});
assert.equal((await add({ currentPassword: "nope" })).body.error.code, "EREAUTH");
assert.equal((await add({ token: "not-a-token" })).body.error.code, "EBADTOKEN");
assert.equal((await add({ name: "team-1" })).body.error.code, "EEXISTS");
assert.equal((await add({ tool: "cursor" })).body.error.code, "EBADTOOL");
assert.equal((await add({ name: "../x" })).body.error.code, "EBADNAME");
});
it("is an admin's only: an analyst reads it, a developer neither reads nor writes", async () => {
const ana = await login("ana", "analyst-pass-1");
assert.equal((await call("GET", "/api/accounts", { cookie: ana })).status, 200);
const w = await call("POST", "/api/accounts", {
cookie: ana,
body: { tool: "claude", name: "x", token: TOKEN, currentPassword: "analyst-pass-1" },
});
assert.equal(w.status, 403);
const mia = await login("mia", "member-pass-1");
assert.equal((await call("GET", "/api/accounts", { cookie: mia })).status, 403);
const d = await call("DELETE", "/api/accounts/team-1", {
cookie: mia,
body: { tool: "claude", currentPassword: "member-pass-1" },
});
assert.equal(d.status, 403);
assert.ok(claudePool.pickAccount(), "team-1 is still there");
});
});
describe("pinning a user to one account", () => {
const pins = require("../lib/account-pins");
const pin = (cookie, body) =>
call("PUT", "/api/accounts/pins", {
cookie,
body: { tool: "claude", currentPassword: BOSS_PASS, ...body },
});
it("lets an admin pin and unpin, audited, and new panes follow the pin", async () => {
const boss = await login("boss", BOSS_PASS);
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
claudePool.addAccount("team-z", `sk-ant-oat01-${"z".repeat(40)}`);
assert.equal(claudePool.pickAccount(null, miaId).name, "team-1", "the balancer's pick");
const r = await pin(boss, { userId: miaId, account: "team-z" });
assert.equal(r.status, 200);
assert.deepEqual(r.body.pin, { userId: miaId, username: "mia", account: "team-z" });
assert.equal(r.body.switched, "next-session", "a setup-token goes in at the next spawn");
assert.equal(claudePool.pickAccount(null, miaId).name, "team-z");
assert.equal(claudePool.pickAccount(null, null).name, "team-1", "nobody else moves");
const audit = db.prepare("SELECT owner_id, path FROM audit_log ORDER BY id DESC LIMIT 1").get();
assert.equal(audit.owner_id, miaId);
assert.match(audit.path, /pin mia to claude account team-z/);
const list = await call("GET", "/api/accounts", { cookie: boss });
assert.deepEqual(
list.body.pins.claude.map((p) => [p.username, p.account]),
[["mia", "team-z"]]
);
assert.deepEqual(list.body.pins.codex, []);
const off = await pin(boss, { userId: miaId, account: null });
assert.equal(off.body.pin.account, null);
assert.equal(claudePool.pickAccount(null, miaId).name, "team-1");
claudePool.removeAccount("team-z");
});
it("refuses an unknown account or user, a wrong password, and a non-admin", async () => {
const boss = await login("boss", BOSS_PASS);
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
assert.equal(
(await pin(boss, { userId: miaId, account: "nope" })).body.error.code,
"ENOACCOUNT"
);
assert.equal((await pin(boss, { userId: 9999, account: "team-1" })).body.error.code, "ENOUSER");
assert.equal(
(await pin(boss, { userId: miaId, account: "team-1", currentPassword: "x" })).body.error.code,
"EREAUTH"
);
const ana = await login("ana", "analyst-pass-1");
const w = await call("PUT", "/api/accounts/pins", {
cookie: ana,
body: { tool: "claude", userId: miaId, account: "team-1", currentPassword: "analyst-pass-1" },
});
assert.equal(w.status, 403);
assert.equal(pins.pinFor(miaId, "claude"), null);
});
it("is ignored in local mode and dropped with its user", async () => {
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
pins.setPin(miaId, "claude", "team-1");
delete process.env.AGENTHUB_MODE;
try {
assert.equal(pins.pinFor(miaId, "claude"), null);
} finally {
process.env.AGENTHUB_MODE = "server";
}
assert.equal(pins.pinFor(miaId, "claude"), "team-1");
db.prepare("DELETE FROM users WHERE id = ?").run(miaId);
assert.equal(
db.prepare("SELECT COUNT(*) AS n FROM account_pins WHERE user_id = ?").get(miaId).n,
0
);
});
});