0c2f49876d
- 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>
147 lines
5.9 KiB
JavaScript
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");
|
|
});
|
|
});
|