a9c71cd905
Usage panel (status bar): - every account shows its plan (Claude subscriptionType, Codex chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier) - new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's own file, sent only to its own host while valid, never refreshed - in-flight quota reads are shared between tabs AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added; Gemini CLI, Hermes and OpenClaw removed. Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat on an editor selection, Settings in three groups, JetBrains-style Changes panel (ticked files/hunks, ignore, blame, file history), terminal font. --no-verify: :3000 is taken by another dev server (lane-detect tests); both suites run by hand — server 2636/2639 (the 3 are :3000), client 1131/1131.
261 lines
9.5 KiB
JavaScript
261 lines
9.5 KiB
JavaScript
/**
|
|
* @file server-mode-user-admin.test.js
|
|
* @description Managing accounts from the dashboard (Users page): only an
|
|
* admin may create a user, change a role, disable/enable, reset a password or
|
|
* give a workspace, every change needs the admin's OWN password again
|
|
* (EREAUTH, throttled), an admin can never demote or disable themselves, no
|
|
* answer carries a password hash, each change lands in the audit log, and in
|
|
* local mode — no login at all — 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-user-admin-"));
|
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
|
|
const { createApp, startServer } = require("../index");
|
|
const { db } = require("../db");
|
|
const auth = require("../lib/auth");
|
|
|
|
let server;
|
|
let BASE;
|
|
const ids = {};
|
|
const BOSS_PASS = "admin-pass-1";
|
|
|
|
async function call(method, p, { cookie, body, origin } = {}) {
|
|
const res = await fetch(BASE + p, {
|
|
method,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(cookie ? { cookie } : {}),
|
|
...(origin ? { origin } : {}),
|
|
},
|
|
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}`;
|
|
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
|
|
ids.ana = (await auth.createUser("ana", "analyst-pass-1", "analyst")).id;
|
|
ids.mia = (await auth.createUser("mia", "member-pass-1", "member")).id;
|
|
ids.lane = db
|
|
.prepare("INSERT INTO lanes (cwd, tool) VALUES ('/w/ownerless', 'claude')")
|
|
.run().lastInsertRowid;
|
|
});
|
|
|
|
after(() => {
|
|
delete process.env.AGENTHUB_MODE;
|
|
server?.close();
|
|
fs.rmSync(TMP, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("managing accounts from the dashboard", () => {
|
|
it("lets an admin create a user, with their own password again", async () => {
|
|
const boss = await login("boss", BOSS_PASS);
|
|
const r = await call("POST", "/api/users", {
|
|
cookie: boss,
|
|
body: {
|
|
username: "neo",
|
|
password: "neo-pass-123",
|
|
role: "member",
|
|
currentPassword: BOSS_PASS,
|
|
},
|
|
});
|
|
assert.equal(r.status, 201);
|
|
assert.deepEqual(
|
|
{ username: r.body.user.username, role: r.body.user.role },
|
|
{ username: "neo", role: "member" }
|
|
);
|
|
assert.ok(!JSON.stringify(r.body).includes("scrypt"), "no password hash");
|
|
assert.ok(await login("neo", "neo-pass-123"), "the new user can log in");
|
|
|
|
const dup = await call("POST", "/api/users", {
|
|
cookie: boss,
|
|
body: { username: "neo", password: "neo-pass-123", currentPassword: BOSS_PASS },
|
|
});
|
|
assert.equal(dup.status, 409);
|
|
assert.equal(dup.body.error.code, "EEXISTS");
|
|
const short = await call("POST", "/api/users", {
|
|
cookie: boss,
|
|
body: { username: "zed", password: "short", currentPassword: BOSS_PASS },
|
|
});
|
|
assert.equal(short.status, 400);
|
|
assert.equal(short.body.error.code, "EBADPASSWORD");
|
|
});
|
|
|
|
it("refuses without the admin's password, or with a wrong one", async () => {
|
|
const boss = await login("boss", BOSS_PASS);
|
|
for (const currentPassword of [undefined, "not-the-password"]) {
|
|
const r = await call("POST", "/api/users", {
|
|
cookie: boss,
|
|
body: { username: "eve", password: "eve-pass-123", currentPassword },
|
|
});
|
|
assert.equal(r.status, 403);
|
|
assert.equal(r.body.error.code, "EREAUTH");
|
|
}
|
|
assert.equal(auth.findUser("eve"), undefined);
|
|
});
|
|
|
|
it("is an admin's only: an analyst is read-only, a member never reaches it", async () => {
|
|
const body = { username: "eve", password: "eve-pass-123", currentPassword: "analyst-pass-1" };
|
|
const ana = await call("POST", "/api/users", {
|
|
cookie: await login("ana", "analyst-pass-1"),
|
|
body,
|
|
});
|
|
assert.equal(ana.status, 403);
|
|
const mia = await call("POST", "/api/users", {
|
|
cookie: await login("mia", "member-pass-1"),
|
|
body: { ...body, currentPassword: "member-pass-1" },
|
|
});
|
|
assert.equal(mia.status, 403);
|
|
assert.equal(auth.findUser("eve"), undefined);
|
|
});
|
|
|
|
it("refuses a write from another origin", async () => {
|
|
const r = await call("POST", "/api/users", {
|
|
cookie: await login("boss", BOSS_PASS),
|
|
origin: "http://evil.example",
|
|
body: { username: "eve", password: "eve-pass-123", currentPassword: BOSS_PASS },
|
|
});
|
|
assert.equal(r.status, 403);
|
|
assert.equal(auth.findUser("eve"), undefined);
|
|
});
|
|
|
|
it("changes a role, disables and enables, and resets a password", async () => {
|
|
const boss = await login("boss", BOSS_PASS);
|
|
const neo = auth.findUser("neo");
|
|
const role = await call("PATCH", `/api/users/${neo.id}`, {
|
|
cookie: boss,
|
|
body: { role: "analyst", currentPassword: BOSS_PASS },
|
|
});
|
|
assert.equal(role.status, 200);
|
|
assert.equal(auth.findUser("neo").role, "analyst");
|
|
|
|
const neoLogin = await login("neo", "neo-pass-123");
|
|
const off = await call("PATCH", `/api/users/${neo.id}`, {
|
|
cookie: boss,
|
|
body: { disabled: true, currentPassword: BOSS_PASS },
|
|
});
|
|
assert.equal(off.status, 200);
|
|
assert.equal(off.body.user.disabled, true);
|
|
assert.equal((await call("GET", "/api/sessions", { cookie: neoLogin })).status, 401);
|
|
assert.equal(await login("neo", "neo-pass-123"), null, "a disabled user cannot log in");
|
|
await call("PATCH", `/api/users/${neo.id}`, {
|
|
cookie: boss,
|
|
body: { disabled: false, currentPassword: BOSS_PASS },
|
|
});
|
|
|
|
const pw = await call("POST", `/api/users/${neo.id}/password`, {
|
|
cookie: boss,
|
|
body: { password: "neo-new-pass-9", currentPassword: BOSS_PASS },
|
|
});
|
|
assert.equal(pw.status, 200);
|
|
assert.equal(await login("neo", "neo-pass-123"), null);
|
|
assert.ok(await login("neo", "neo-new-pass-9"));
|
|
|
|
const bad = await call("PATCH", `/api/users/${neo.id}`, {
|
|
cookie: boss,
|
|
body: { role: "root", currentPassword: BOSS_PASS },
|
|
});
|
|
assert.equal(bad.status, 400);
|
|
const none = await call("PATCH", "/api/users/9999", {
|
|
cookie: boss,
|
|
body: { role: "member", currentPassword: BOSS_PASS },
|
|
});
|
|
assert.equal(none.status, 404);
|
|
});
|
|
|
|
it("never lets an admin demote or disable themselves", async () => {
|
|
const boss = await login("boss", BOSS_PASS);
|
|
for (const body of [{ role: "member" }, { disabled: true }]) {
|
|
const r = await call("PATCH", `/api/users/${ids.boss}`, {
|
|
cookie: boss,
|
|
body: { ...body, currentPassword: BOSS_PASS },
|
|
});
|
|
assert.equal(r.status, 409);
|
|
assert.equal(r.body.error.code, "ESELF");
|
|
}
|
|
assert.equal(auth.findUser("boss").role, "admin");
|
|
});
|
|
|
|
it("gives a workspace to a user", async () => {
|
|
const boss = await login("boss", BOSS_PASS);
|
|
const r = await call("POST", `/api/users/${ids.mia}/workspaces`, {
|
|
cookie: boss,
|
|
body: { laneId: ids.lane, currentPassword: BOSS_PASS },
|
|
});
|
|
assert.equal(r.status, 200);
|
|
assert.equal(
|
|
db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(ids.lane).owner_id,
|
|
ids.mia
|
|
);
|
|
const none = await call("POST", `/api/users/${ids.mia}/workspaces`, {
|
|
cookie: boss,
|
|
body: { laneId: 9999, currentPassword: BOSS_PASS },
|
|
});
|
|
assert.equal(none.status, 404);
|
|
});
|
|
|
|
it("writes every change to the audit log", async () => {
|
|
const boss = await login("boss", BOSS_PASS);
|
|
const entries = (await call("GET", "/api/users/audit", { cookie: boss })).body.entries;
|
|
const mine = entries.filter((e) => e.reader === "boss" && e.method !== "GET");
|
|
const paths = mine.map((e) => e.path).join("\n");
|
|
assert.match(paths, /create neo/);
|
|
assert.match(paths, /role analyst/);
|
|
assert.match(paths, /disable/);
|
|
assert.match(paths, /password reset/);
|
|
assert.match(paths, new RegExp(`workspace ${ids.lane}`));
|
|
assert.ok(!paths.includes("neo-new-pass-9"), "never the password itself");
|
|
});
|
|
|
|
it("throttles wrong confirmation passwords", async () => {
|
|
const boss = await login("boss", BOSS_PASS);
|
|
let last;
|
|
for (let i = 0; i < 12; i++) {
|
|
last = await call("PATCH", `/api/users/${ids.mia}`, {
|
|
cookie: boss,
|
|
body: { role: "analyst", currentPassword: "guess-" + i },
|
|
});
|
|
}
|
|
assert.equal(last.status, 429);
|
|
assert.equal(last.body.error.code, "ETOOMANY");
|
|
assert.equal(auth.findUser("mia").role, "member");
|
|
});
|
|
});
|
|
|
|
describe("in local mode", () => {
|
|
it("refuses every account change — there is no login to check", async () => {
|
|
delete process.env.AGENTHUB_MODE;
|
|
try {
|
|
const r = await call("POST", "/api/users", {
|
|
body: { username: "eve", password: "eve-pass-123", currentPassword: "x" },
|
|
});
|
|
assert.equal(r.status, 403);
|
|
assert.equal(r.body.error.code, "ELOCALMODE");
|
|
assert.equal(auth.findUser("eve"), undefined);
|
|
} finally {
|
|
process.env.AGENTHUB_MODE = "server";
|
|
}
|
|
});
|
|
});
|