/** * @file Tests for deleting a user (`DELETE /api/users/:id`, auth.deleteUser): * the admin's own password again, never yourself (ESELF) or the last admin * (ELASTADMIN); the user's logins end, their workspaces' records, prefs and * folder go, the audit log keeps the deed — and under S3 the root helper is * asked to `delete` the Linux account and home FIRST, so its refusal leaves * every row in place. * @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-delete-")); const USERS = path.join(TMP, "users"); 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"); const linux = require("../lib/linux-accounts"); let server; let BASE; const ids = {}; const BOSS_PASS = "admin-pass-1"; 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; } const lane = (cwd, owner) => Number( db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)").run(cwd, owner) .lastInsertRowid ); before(async () => { process.env.AGENTHUB_MODE = "server"; process.env.AGENTHUB_USERS_ROOT = USERS; fs.mkdirSync(path.join(USERS, "mia", "proj"), { recursive: true }); fs.writeFileSync(path.join(USERS, "mia", "proj", "a.txt"), "x"); 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.miaLane = lane(path.join(USERS, "mia", "proj"), ids.mia); db.prepare("INSERT INTO user_prefs (user_key, prefs) VALUES (?, '{}')").run(`u${ids.mia}`); }); after(() => { delete process.env.AGENTHUB_MODE; delete process.env.AGENTHUB_USERS_ROOT; delete process.env.AGENTHUB_SERVER_CONF; linux.__setRunImpl(null); linux.__resetConfCache(); server?.close(); fs.rmSync(TMP, { recursive: true, force: true }); }); describe("Delete user (DELETE /api/users/:id)", () => { it("needs the admin's own password, and never deletes yourself", async () => { const boss = await login("boss", BOSS_PASS); const wrong = await call("DELETE", `/api/users/${ids.mia}`, { cookie: boss, body: { currentPassword: "nope" }, }); assert.equal(wrong.status, 403); assert.equal(wrong.body.error.code, "EREAUTH"); const self = await call("DELETE", `/api/users/${ids.boss}`, { cookie: boss, body: { currentPassword: BOSS_PASS }, }); assert.equal(self.status, 409); assert.equal(self.body.error.code, "ESELF"); assert.ok(db.prepare("SELECT 1 FROM users WHERE id = ?").get(ids.mia)); }); it("refuses a developer", async () => { const mia = await login("mia", "member-pass-1"); const r = await call("DELETE", `/api/users/${ids.boss}`, { cookie: mia, body: { currentPassword: "member-pass-1" }, }); assert.equal(r.status, 403); }); it("deletes the user, their logins, workspaces' records, prefs and folder — audited", async () => { const miaCookie = await login("mia", "member-pass-1"); const boss = await login("boss", BOSS_PASS); const r = await call("DELETE", `/api/users/${ids.mia}`, { cookie: boss, body: { currentPassword: BOSS_PASS }, }); assert.equal(r.status, 200, JSON.stringify(r.body)); assert.equal(db.prepare("SELECT 1 FROM users WHERE id = ?").get(ids.mia), undefined); assert.equal(db.prepare("SELECT 1 FROM lanes WHERE id = ?").get(ids.miaLane), undefined); assert.equal( db.prepare("SELECT 1 FROM user_prefs WHERE user_key = ?").get(`u${ids.mia}`), undefined ); assert.ok(!fs.existsSync(path.join(USERS, "mia")), "her folder is gone"); // Her old login no longer works. assert.equal((await call("GET", "/api/me/account", { cookie: miaCookie })).status, 401); const audit = db .prepare("SELECT path FROM audit_log WHERE owner_id = ? ORDER BY id DESC") .get(ids.mia); assert.match(audit.path, /^delete mia/); // The same name can be made again, as a new account. const again = await auth.createUser("mia", "member-pass-3", "developer"); assert.notEqual(again.id, ids.mia); }); it("never deletes the last admin", () => { assert.throws(() => auth.deleteUser("boss"), { code: "ELASTADMIN" }); }); }); describe("Delete user under S3", () => { const CONF = path.join(TMP, "server.conf"); let calls; before(() => { fs.writeFileSync(CONF, `HOST_USER=h\nHOST_UID=1000\nUSERS_ROOT=${USERS}\n`); process.env.AGENTHUB_SERVER_CONF = CONF; linux.__resetConfCache(); }); it("asks the root helper to delete the account and home first", async () => { calls = []; linux.__setRunImpl((argv) => { calls.push(argv); return "ok"; }); const u = await auth.createUser("leo", "member-pass-2", "developer"); calls = []; auth.deleteUser("leo"); assert.deepEqual(calls, [["/opt/agenthub/sbin/agenthub-provision", "delete", "leo"]]); assert.equal(db.prepare("SELECT 1 FROM users WHERE id = ?").get(u.id), undefined); }); it("keeps every row when the helper refuses", async () => { linux.__setRunImpl(() => "ok"); const u = await auth.createUser("zoe", "member-pass-4", "developer"); const zl = lane(path.join(USERS, "zoe", "p"), u.id); linux.__setRunImpl(() => { throw Object.assign(new Error("x"), { stderr: "ah-zoe is not an AgentHub member" }); }); assert.throws(() => auth.deleteUser("zoe"), { code: "EPROVISION" }); assert.ok(db.prepare("SELECT 1 FROM users WHERE id = ?").get(u.id)); assert.ok(db.prepare("SELECT 1 FROM lanes WHERE id = ?").get(zl)); }); });