Files
AgentHub/server/__tests__/server-mode-user-delete.test.js
T
nntrivi2001 22de5009ff feat: v2.7.8 — Artifacts hub, clone related repos as extra folders, quieter resume prompt
- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the
  Artifact tool's hook), pinned workspace files, skills and plugins, each
  kept as a snapshot; shared ones appear in a Hub and a skill or plugin
  installs into the viewer's own account (server/lib/artifacts.js,
  /api/artifacts, docs/plans/artifacts/).
- Workspace extra folders: clone one or more related repos beside the
  workspace and add them in one step (POST /api/lanes/:id/sources/clone).
- A pane that ended by itself while tmux kept running is no longer offered
  for resume (tmux.serverStartedAt in reconcileOrphans).
- Changes panel: a folder that is not a git repo opens on Files, and its
  files open from there.
- Plus the parallel sessions' work in this tree: File Explorer button in the
  panel header, user delete, RSA SSH keys, chat/terminal and pet fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:06:54 +07:00

171 lines
6.5 KiB
JavaScript

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