22de5009ff
- 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>
171 lines
6.5 KiB
JavaScript
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));
|
|
});
|
|
});
|