Files
AgentHub/server/__tests__/server-mode-account-pool.test.js
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

147 lines
5.9 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.
* @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");
});
});