/** * @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ĩ */ 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); }); });