/** * @file server-mode-password-change.test.js * @description A password an admin GIVES (Users page create or reset) may be * anything non-empty, and the account must then pick its own before anything * else: its login reaches only the `/me` reads and `POST /api/me/password` * (EPWCHANGE), opens no WebSocket, and its own password must pass every rule * (EWEAKPASSWORD with the failed `rules`, never the current one). Also: the * last login survives a logout (`users.last_login_at`), the last enabled * admin can never be demoted or disabled (ELASTADMIN, the CLI's path * included) and old audit entries are pruned (`AGENTHUB_AUDIT_DAYS`). * @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-password-change-")); 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 BOSS_PASS = "Admin-pass-2026"; 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 }), }); if (res.status !== 200) return null; return { cookie: res.headers.get("set-cookie").split(";")[0], user: (await res.json()).user }; } before(async () => { process.env.AGENTHUB_MODE = "server"; server = await startServer(createApp(), 0); BASE = `http://127.0.0.1:${server.address().port}`; await auth.createUser("boss", BOSS_PASS, "admin"); }); after(() => { delete process.env.AGENTHUB_MODE; delete process.env.AGENTHUB_AUDIT_DAYS; server?.close(); fs.rmSync(TMP, { recursive: true, force: true }); }); describe("a password an admin gave", () => { it("is accepted weak, then the user must pick a strong one of their own", async () => { const boss = (await login("boss", BOSS_PASS)).cookie; const made = await call("POST", "/api/users", { cookie: boss, body: { username: "neo", password: "123", currentPassword: BOSS_PASS }, }); assert.equal(made.status, 201); assert.equal(made.body.user.mustChangePassword, true); const neo = await login("neo", "123"); assert.equal(neo.user.mustChangePassword, true); // Only its own account and the password change, until it has one. assert.equal( (await call("GET", "/api/lanes", { cookie: neo.cookie })).body.error.code, "EPWCHANGE" ); const patch = await call("PATCH", "/api/me/account", { cookie: neo.cookie, body: { displayName: "Neo" }, }); assert.equal(patch.body.error.code, "EPWCHANGE"); assert.equal((await call("GET", "/api/me/account", { cookie: neo.cookie })).status, 200); const me = await call("GET", "/api/auth/me", { cookie: neo.cookie }); assert.equal(me.body.user.mustChangePassword, true); const weak = await call("POST", "/api/me/password", { cookie: neo.cookie, body: { currentPassword: "123", newPassword: "neo12345" }, }); assert.equal(weak.status, 400); assert.equal(weak.body.error.code, "EWEAKPASSWORD"); assert.deepEqual(weak.body.error.rules, ["upper", "symbol", "username"]); const ok = await call("POST", "/api/me/password", { cookie: neo.cookie, body: { currentPassword: "123", newPassword: "Green-Tea-42" }, }); assert.equal(ok.status, 200); assert.equal((await call("GET", "/api/lanes", { cookie: neo.cookie })).status, 200); assert.equal( (await call("GET", "/api/auth/me", { cookie: neo.cookie })).body.user.mustChangePassword, false ); }); it("an admin's reset flags the account again; their own new password must pass every rule", async () => { const boss = (await login("boss", BOSS_PASS)).cookie; const neoId = auth.findUser("neo").id; const reset = await call("PATCH", `/api/users/${neoId}`, { cookie: boss, body: { password: "abc", currentPassword: BOSS_PASS }, }); assert.equal(reset.status, 200); assert.equal(reset.body.user.mustChangePassword, true); assert.equal((await login("neo", "abc")).user.mustChangePassword, true); const bossId = auth.findUser("boss").id; const own = (password) => call("PATCH", `/api/users/${bossId}`, { cookie: boss, body: { password, currentPassword: BOSS_PASS }, }); assert.equal((await own("123")).body.error.code, "EWEAKPASSWORD"); const same = await own(BOSS_PASS); assert.equal(same.body.error.code, "EWEAKPASSWORD"); assert.deepEqual(same.body.error.rules, ["reused"]); const done = await own("Blue-Sky-77!"); assert.equal(done.status, 200); assert.equal(done.body.user.mustChangePassword, false); }); }); describe("account bookkeeping", () => { it("keeps the last login after the user signs out", async () => { const pass = "Blue-Sky-77!"; const s = await login("boss", pass); await call("POST", "/api/auth/logout", { cookie: s.cookie }); const id = auth.findUser("boss").id; db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").run(id); const stats = auth.loginStats(id); assert.equal(stats.logins, 0); assert.ok(stats.lastLoginAt, "the last login is still known"); assert.equal(auth.allLoginStats().get(id).lastLoginAt, stats.lastLoginAt); }); it("never demotes, disables or deletes the last enabled admin", () => { for (const fn of [ () => auth.setRole("boss", "developer"), () => auth.setDisabled("boss", true), () => auth.deleteUser("boss"), ]) { assert.throws(fn, { code: "ELASTADMIN" }); } assert.equal(auth.findUser("boss").role, "admin"); assert.equal(auth.findUser("boss").disabled, 0); }); it("prunes audit entries older than AGENTHUB_AUDIT_DAYS", () => { const { pruneAudit } = require("../lib/audit"); const insert = db.prepare( "INSERT INTO audit_log (user_id, owner_id, method, path, at) VALUES (1, 1, 'GET', ?, ?)" ); insert.run("/old", new Date(Date.now() - 40 * 864e5).toISOString()); insert.run("/new", new Date().toISOString()); process.env.AGENTHUB_AUDIT_DAYS = "0"; assert.equal(pruneAudit({ force: true }), 0, "0 keeps everything"); process.env.AGENTHUB_AUDIT_DAYS = "30"; assert.ok(pruneAudit({ force: true }) >= 1); const paths = db .prepare("SELECT path FROM audit_log") .all() .map((r) => r.path); assert.ok(!paths.includes("/old")); assert.ok(paths.includes("/new")); }); });