776ad6466f
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.
221 lines
9.2 KiB
JavaScript
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
|
|
);
|
|
});
|
|
});
|