750d74a2af
- omp-share.js: an admin's switch (S3) runs omp's own auth broker + gateway as the host on loopback; a member's omp pane reaches it as a scoped client keyed by its pane token (clients file = live omp panes only), runs on --model agenthub/<model>, never holds a credential. - omp catalog: --allow-home, so a workspace at the account's home keeps its sessions there (they were recorded under a temp dir and dropped). - Settings → Appearance → Open Markdown as: Preview (default) or Raw, for Files/Changes, the config viewer and a chat's file panel. - First-login password change and password rules, avatar URLs, Artifacts "Mine" includes installed shared skills/plugins, a member's own hook pages.
182 lines
7.0 KiB
JavaScript
182 lines
7.0 KiB
JavaScript
/**
|
|
* @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ĩ <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-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"));
|
|
});
|
|
});
|