63eaa739d4
A member kept renaming themselves to a colleague's display name, plus "(you)", so the CPU/RAM card and every owner list showed two of that person. A display name is now refused (409 ENAMETAKEN) when, with marks, case, spaces, punctuation, bracketed parts and look-alike Cyrillic/Greek letters ignored, it equals another account's display name or username — in Settings → Account and on the Users page alike. Keeping the name one already has stays allowed, so an older clash does not block an admin's other edits.
291 lines
12 KiB
JavaScript
291 lines
12 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("refuses a display name that reads as another account's (ENAMETAKEN)", async () => {
|
||
assert.equal(auth.nameKey("Nguyễn Ngọc Trí Vĩ (you)"), auth.nameKey("nguyen.ngoc-tri VI"));
|
||
assert.equal(auth.nameKey("Đào"), "dao");
|
||
// Cyrillic о and а in place of Latin letters.
|
||
assert.equal(auth.nameKey("Mіа Trоn"), auth.nameKey("Mia Tron"));
|
||
const leo = await login("leo", "member-pass-2");
|
||
// mia's display name is "Mia Tran"; boss has none, only a username.
|
||
for (const name of ["MIA TRẦN (you)", "Mia\u200b Tran", "mia", "Bóss"]) {
|
||
const r = await call("PATCH", "/api/me/account", { cookie: leo, body: { displayName: name } });
|
||
assert.equal(r.status, 409, name);
|
||
assert.equal(r.body.error.code, "ENAMETAKEN");
|
||
}
|
||
assert.equal(auth.findUser("leo").display_name, "Leo N");
|
||
// Keeping one's own name, or a name of one's own, still works.
|
||
for (const name of ["Leo N", "Leo Nguyen"]) {
|
||
const r = await call("PATCH", "/api/me/account", { cookie: leo, body: { displayName: name } });
|
||
assert.equal(r.status, 200, name);
|
||
}
|
||
});
|
||
|
||
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 weak = await call("POST", "/api/me/password", {
|
||
cookie: leo,
|
||
body: { currentPassword: "member-pass-2", newPassword: "leo-new-pass" },
|
||
});
|
||
assert.equal(weak.status, 400);
|
||
assert.equal(weak.body.error.code, "EWEAKPASSWORD");
|
||
assert.deepEqual(weak.body.error.rules, ["upper", "digit", "username"]);
|
||
const ok = await call("POST", "/api/me/password", {
|
||
cookie: leo,
|
||
body: { currentPassword: "member-pass-2", newPassword: "New-Pass-2026" },
|
||
});
|
||
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", "New-Pass-2026"));
|
||
});
|
||
|
||
it("signs out other devices", async () => {
|
||
const a = await login("leo", "New-Pass-2026");
|
||
const b = await login("leo", "New-Pass-2026");
|
||
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);
|
||
});
|
||
});
|