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

264 lines
10 KiB
JavaScript

/**
* @file server-mode-user-edit.test.js
* @description The Users page's Edit user form (`GET`/`PATCH /api/users/:id`)
* and Settings → Account (`/api/me/account`, `/api/me/password`,
* `/api/me/sign-out-others`): one PATCH validates every field before any is
* written, sets display name/email/role/password/workspaces/sign-out together
* with the admin's own password, never takes a workspace from a third user; a
* user edits their own profile, changes their own password only with the old
* one (keeping the login they changed it from), signs out other devices, and a
* pane token may not do any of it.
* @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-edit-"));
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, headers } = {}) {
const res = await fetch(BASE + p, {
method,
headers: {
"Content-Type": "application/json",
...(cookie ? { cookie } : {}),
...(headers || {}),
},
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;
}
const lane = (cwd, owner = null) =>
Number(
db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)").run(cwd, owner)
.lastInsertRowid
);
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.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
ids.leo = (await auth.createUser("leo", "member-pass-2", "developer")).id;
ids.free = lane("/w/free");
ids.miaLane = lane("/w/mia", ids.mia);
ids.leoLane = lane("/w/leo", ids.leo);
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("Edit user (PATCH /api/users/:id)", () => {
it("reads one account with its workspaces and logins, never a hash", async () => {
const boss = await login("boss", BOSS_PASS);
await login("mia", "member-pass-1");
const r = await call("GET", `/api/users/${ids.mia}`, { cookie: boss });
assert.equal(r.status, 200);
assert.equal(r.body.user.username, "mia");
assert.equal(r.body.user.logins, 1);
assert.ok(r.body.user.lastLoginAt);
assert.ok(r.body.user.createdAt);
assert.deepEqual(
r.body.workspaces.map((w) => w.id),
[ids.miaLane]
);
assert.ok(!JSON.stringify(r.body).includes("scrypt"));
});
it("applies profile, role, workspaces and password in one request", async () => {
const boss = await login("boss", BOSS_PASS);
const r = await call("PATCH", `/api/users/${ids.mia}`, {
cookie: boss,
body: {
currentPassword: BOSS_PASS,
displayName: " Mia Tran ",
email: "mia@example.com",
role: "analyst",
password: "mia-new-pass",
addWorkspaces: [ids.free],
removeWorkspaces: [ids.miaLane],
},
});
assert.equal(r.status, 200, JSON.stringify(r.body));
assert.equal(r.body.user.displayName, "Mia Tran");
assert.equal(r.body.user.email, "mia@example.com");
assert.equal(r.body.user.role, "analyst");
assert.equal(r.body.user.logins, 0, "a new password ends the user's logins");
const owners = db
.prepare("SELECT id, owner_id FROM lanes WHERE id IN (?, ?)")
.all(ids.free, ids.miaLane);
assert.deepEqual(Object.fromEntries(owners.map((o) => [o.id, o.owner_id])), {
[ids.free]: ids.mia,
[ids.miaLane]: null,
});
assert.ok(await login("mia", "mia-new-pass"));
const audit = db
.prepare("SELECT path FROM audit_log WHERE owner_id = ? ORDER BY id DESC LIMIT 1")
.get(ids.mia).path;
assert.match(audit, /display name, email, role analyst/);
assert.ok(!audit.includes("mia-new-pass"), "the audit never carries the password");
});
it("writes nothing when one field is bad", async () => {
const boss = await login("boss", BOSS_PASS);
const before = db.prepare("SELECT * FROM users WHERE id = ?").get(ids.leo);
for (const [body, code] of [
[{ displayName: "Leo", email: "not-an-email" }, "EBADEMAIL"],
[{ displayName: "Leo", password: "" }, "EBADPASSWORD"],
[{ displayName: "Leo", role: "root" }, "EBADROLE"],
[{ displayName: "Leo", addWorkspaces: [99999] }, "ENOLANE"],
[{ displayName: "Leo", addWorkspaces: ["1"] }, "EBADFIELD"],
[{ displayName: "Leo", signOut: "yes" }, "EBADFIELD"],
// Never takes a workspace from a third user.
[{ displayName: "Leo", removeWorkspaces: [ids.free] }, "ENOTOWNED"],
]) {
const r = await call("PATCH", `/api/users/${ids.leo}`, {
cookie: boss,
body: { ...body, currentPassword: BOSS_PASS },
});
assert.equal(r.body?.error?.code, code, JSON.stringify(body));
}
assert.deepEqual(db.prepare("SELECT * FROM users WHERE id = ?").get(ids.leo), before);
assert.equal(
db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(ids.free).owner_id,
ids.mia
);
});
it("needs the admin's own password, and keeps ESELF", async () => {
const boss = await login("boss", BOSS_PASS);
const noPass = await call("PATCH", `/api/users/${ids.leo}`, {
cookie: boss,
body: { displayName: "x" },
});
assert.equal(noPass.body.error.code, "EREAUTH");
for (const body of [{ role: "developer" }, { disabled: true }]) {
const r = await call("PATCH", `/api/users/${ids.boss}`, {
cookie: boss,
body: { ...body, currentPassword: BOSS_PASS },
});
assert.equal(r.body.error.code, "ESELF");
}
});
it("signs a user out everywhere, but an admin editing themselves keeps this login", async () => {
await login("leo", "member-pass-2");
const boss = await login("boss", BOSS_PASS);
const other = await login("boss", BOSS_PASS);
const r = await call("PATCH", `/api/users/${ids.leo}`, {
cookie: boss,
body: { signOut: true, currentPassword: BOSS_PASS },
});
assert.equal(r.body.user.logins, 0);
const self = await call("PATCH", `/api/users/${ids.boss}`, {
cookie: boss,
body: { signOut: true, currentPassword: BOSS_PASS },
});
assert.equal(self.status, 200);
assert.equal((await call("GET", "/api/me/account", { cookie: boss })).status, 200);
assert.equal((await call("GET", "/api/me/account", { cookie: other })).status, 401);
});
});
describe("display names reach the workspace list and the audit log", () => {
it("carries owner_display_name beside owner_name, and audit display names", async () => {
const boss = await login("boss", BOSS_PASS);
const lanes = (await call("GET", "/api/lanes", { cookie: boss })).body.lanes;
const free = lanes.find((l) => l.id === ids.free);
assert.equal(free.owner_name, "mia");
assert.equal(free.owner_display_name, "Mia Tran");
const audit = (await call("GET", "/api/users/audit", { cookie: boss })).body.entries;
const row = audit.find((a) => a.owner === "mia");
assert.equal(row.owner_display, "Mia Tran");
assert.equal(row.reader, "boss");
assert.equal(row.reader_display, null);
});
});
describe("Settings → Account (/api/me)", () => {
it("shows the account, and edits its own display name and email", async () => {
const leo = await login("leo", "member-pass-2");
const g = await call("GET", "/api/me/account", { cookie: leo });
assert.equal(g.body.mode, "server");
assert.equal(g.body.user.username, "leo");
assert.equal(g.body.workspaces, 1);
assert.equal(g.body.logins, 1);
const p = await call("PATCH", "/api/me/account", {
cookie: leo,
body: { displayName: "Leo N", email: "" },
});
assert.equal(p.status, 200);
assert.equal(p.body.user.displayName, "Leo N");
assert.equal(p.body.user.email, null);
const bad = await call("PATCH", "/api/me/account", { cookie: leo, body: { email: "x@" } });
assert.equal(bad.body.error.code, "EBADEMAIL");
});
it("changes its own password only with the old one, keeping this login", async () => {
const leo = await login("leo", "member-pass-2");
const other = await login("leo", "member-pass-2");
const wrong = await call("POST", "/api/me/password", {
cookie: leo,
body: { currentPassword: "nope", newPassword: "leo-new-pass" },
});
assert.equal(wrong.body.error.code, "EREAUTH");
const ok = await call("POST", "/api/me/password", {
cookie: leo,
body: { currentPassword: "member-pass-2", newPassword: "leo-new-pass" },
});
assert.equal(ok.status, 200);
assert.equal((await call("GET", "/api/me/account", { cookie: leo })).status, 200);
assert.equal((await call("GET", "/api/me/account", { cookie: other })).status, 401);
assert.ok(await login("leo", "leo-new-pass"));
});
it("signs out other devices", async () => {
const a = await login("leo", "leo-new-pass");
const b = await login("leo", "leo-new-pass");
const r = await call("POST", "/api/me/sign-out-others", { cookie: a });
assert.ok(r.body.ended >= 1);
assert.equal((await call("GET", "/api/me/account", { cookie: a })).status, 200);
assert.equal((await call("GET", "/api/me/account", { cookie: b })).status, 401);
});
it("refuses a pane token: an agent may not change its owner's login", async () => {
const pane = auth.newPaneToken();
db.prepare(
`INSERT INTO dashboard_runs (id, mode, cwd, status, lane_id, pane_token_hash)
VALUES (?, 'interactive', '/w/leo', 'running', ?, ?)`
).run(`agenthub-lane-${ids.leoLane}`, ids.leoLane, pane.hash);
const r = await call("POST", "/api/me/sign-out-others", {
headers: { [auth.PANE_HEADER]: pane.token },
});
assert.notEqual(r.status, 200);
});
});