chore: v2.8.15 — Oh My Pi login sharing, omp --allow-home, Markdown Preview default, first-login password
- 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.
This commit is contained in:
@@ -119,6 +119,34 @@ describe("pages", () => {
|
||||
assert.equal(artifacts.captureFromHook("s1", { tool_input: { action: "read" } }), null);
|
||||
assert.equal(artifacts.captureFromHook("s1", { tool_response: "no link" }), null);
|
||||
});
|
||||
|
||||
it("gives a page from a session no workspace owns to the member who sent the hook", () => {
|
||||
const uid = db
|
||||
.prepare("INSERT INTO users (username, password_hash) VALUES ('ann', 'x')")
|
||||
.run().lastInsertRowid;
|
||||
const hook = (url) => ({
|
||||
tool_input: { action: "publish" },
|
||||
tool_response: `Published: ${url}`,
|
||||
});
|
||||
const own = artifacts.captureFromHook("loose-1", hook("https://claude.ai/artifact/m1"), {
|
||||
kind: "member",
|
||||
name: "ann",
|
||||
});
|
||||
assert.equal(own.owner_id, Number(uid));
|
||||
assert.equal(own.lane_id, null);
|
||||
// The host account names nobody; a workspace's owner still wins over the peer.
|
||||
const host = artifacts.captureFromHook("loose-2", hook("https://claude.ai/artifact/m2"), {
|
||||
kind: "host",
|
||||
});
|
||||
assert.equal(host.owner_id, null);
|
||||
const ws = artifacts.captureFromHook("s1", hook("https://claude.ai/artifact/m3"), {
|
||||
kind: "member",
|
||||
name: "ann",
|
||||
});
|
||||
const laneOwner = db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(lane.id).owner_id;
|
||||
assert.equal(ws.owner_id, laneOwner);
|
||||
assert.equal(ws.lane_id, lane.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("files", () => {
|
||||
@@ -145,6 +173,12 @@ describe("files", () => {
|
||||
assert.match(raw.text, /<h1>hi<\/h1>/);
|
||||
assert.match(raw.headers.get("content-security-policy"), /sandbox/);
|
||||
assert.equal(raw.headers.get("x-content-type-options"), "nosniff");
|
||||
// Deleting the source on disk takes nothing away: only a delete in AgentHub does.
|
||||
const live = path.join(ws, "out", "report.html");
|
||||
fs.rmSync(live);
|
||||
assert.equal((await request("GET", `/api/artifacts/${id}`)).status, 200);
|
||||
assert.match((await request("GET", `/api/artifacts/${id}/raw`)).text, /<h1>hi<\/h1>/);
|
||||
fs.writeFileSync(live, "changed");
|
||||
const other = await request("GET", `/api/artifacts/${id}/raw?path=../../t.db`);
|
||||
assert.equal(other.status, 404);
|
||||
|
||||
@@ -160,6 +194,32 @@ describe("files", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("my library", () => {
|
||||
it("adds the shared skills of others the caller installed, and only those", () => {
|
||||
const ins = db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, owner_id, source, shared) VALUES ('skill', ?, ?, ?, ?)"
|
||||
);
|
||||
const theirs = Number(ins.run("theirs", 77, "lib-theirs", 1).lastInsertRowid);
|
||||
const notInstalled = Number(ins.run("other", 77, "lib-other", 1).lastInsertRowid);
|
||||
const unshared = Number(ins.run("private", 77, "lib-private", 0).lastInsertRowid);
|
||||
const own = Number(ins.run("own", 5, "lib-own", 0).lastInsertRowid);
|
||||
for (const name of ["lib-theirs", "lib-private"])
|
||||
fs.mkdirSync(path.join(process.env.CLAUDE_HOME, "skills", name), { recursive: true });
|
||||
const req = { user: { id: 5, role: "admin" } };
|
||||
const ids = artifacts.list(req, { scope: "mine", kind: "skill" }).map((a) => [a.id, a.mine]);
|
||||
assert.deepEqual(
|
||||
ids.filter(([id]) => [theirs, notInstalled, unshared, own].includes(id)).sort(),
|
||||
[
|
||||
[theirs, false],
|
||||
[own, true],
|
||||
].sort()
|
||||
);
|
||||
db.prepare(
|
||||
`DELETE FROM artifacts WHERE id IN (${[theirs, notInstalled, unshared, own].join(",")})`
|
||||
).run();
|
||||
});
|
||||
});
|
||||
|
||||
describe("skills", () => {
|
||||
it("snapshots a workspace skill and installs it into ~/.claude/skills and ~/.agents/skills", async () => {
|
||||
const list = await request("GET", `/api/artifacts/skills?laneId=${lane.id}`);
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* served with `nosniff` and a `sandbox` CSP; `avatarAt` reaches `/api/auth/me`;
|
||||
* another account never sees it through `/api/me`; staff read it at
|
||||
* `/api/users/:id/avatar` (a developer is refused) and a workspace carries
|
||||
* `owner_avatar_at` for the sidebar's owner groups.
|
||||
* `owner_avatar_at` for the sidebar's owner groups. A picture from a link
|
||||
* (`POST /api/me/avatar/url`, server/lib/avatar-url.js) takes https only and
|
||||
* never reaches a loopback/private address.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -108,6 +110,51 @@ it("refuses an SVG, an empty body, a too-large image and no login", async () =>
|
||||
assert.equal((await put("", PNG)).status, 401);
|
||||
});
|
||||
|
||||
it("a picture from a link: https only, never a private or local address", async () => {
|
||||
const ava = await login("ava", "ava-pass-1");
|
||||
const post = (url) =>
|
||||
fetch(BASE + "/api/me/avatar/url", {
|
||||
method: "POST",
|
||||
headers: { cookie: ava, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
for (const [url, code] of [
|
||||
["not a url", "EBADURL"],
|
||||
["http://example.com/a.png", "EBADURL"],
|
||||
["https://user:pw@example.com/a.png", "EBADURL"],
|
||||
["https://127.0.0.1/a.png", "EPRIVATEURL"],
|
||||
["https://[::1]/a.png", "EPRIVATEURL"],
|
||||
["https://localhost/a.png", "EPRIVATEURL"],
|
||||
["https://169.254.169.254/latest", "EPRIVATEURL"],
|
||||
]) {
|
||||
const res = await post(url);
|
||||
assert.equal(res.status, 400, url);
|
||||
assert.equal((await res.json()).error.code, code, url);
|
||||
}
|
||||
assert.equal((await post("")).status, 400);
|
||||
|
||||
const { isPublicAddress } = require("../lib/avatar-url");
|
||||
for (const a of [
|
||||
"10.1.2.3",
|
||||
"172.20.0.1",
|
||||
"192.168.1.1",
|
||||
"100.64.0.1",
|
||||
"0.0.0.0",
|
||||
"::",
|
||||
"fe80::1",
|
||||
"fd00::1",
|
||||
"::ffff:127.0.0.1",
|
||||
"::ffff:7f00:1",
|
||||
"2002:7f00::1",
|
||||
"ff02::1",
|
||||
]) {
|
||||
assert.equal(isPublicAddress(a), false, a);
|
||||
}
|
||||
for (const a of ["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111", "::ffff:8.8.8.8"]) {
|
||||
assert.equal(isPublicAddress(a), true, a);
|
||||
}
|
||||
});
|
||||
|
||||
it("staff read another account's avatar; workspaces carry owner_avatar_at", async () => {
|
||||
const ava = await login("ava", "ava-pass-1");
|
||||
const ann = await login("ann", "ann-pass-1");
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @file omp-share.test.js
|
||||
* @description Oh My Pi login sharing (server/lib/omp-share.js): a member's
|
||||
* `models.yml` gets one `agenthub` provider pointing at the loopback gateway
|
||||
* with the pane token's env var NAME as its key (the rest of the file kept, a
|
||||
* non-mapping refused); the gateway's clients file holds only LIVE omp panes'
|
||||
* token hashes; a spawn is untouched while sharing is off, for a host pane, or
|
||||
* without S3; and the route refuses outside S3. No omp daemon is started.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, afterEach } = 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(), "omp-share-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
process.env.AGENTHUB_OMP_GATEWAY_PORT = "4999";
|
||||
|
||||
const yaml = require("js-yaml");
|
||||
const { createApp, startServer } = require("../index");
|
||||
const share = require("../lib/omp-share");
|
||||
const linux = require("../lib/linux-accounts");
|
||||
const runAs = require("../lib/run-as");
|
||||
const tmux = require("../lib/tmux");
|
||||
const { db } = require("../db");
|
||||
|
||||
const realS3 = linux.s3Enabled;
|
||||
const realLive = tmux.liveSessions;
|
||||
const flag = path.join(TMP, "data", "omp-share", "enabled");
|
||||
const clientsFile = path.join(TMP, "data", "omp-share", "clients.json");
|
||||
|
||||
function turnOn() {
|
||||
fs.mkdirSync(path.dirname(flag), { recursive: true });
|
||||
fs.writeFileSync(flag, "");
|
||||
linux.s3Enabled = () => true;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
linux.s3Enabled = realS3;
|
||||
tmux.liveSessions = realLive;
|
||||
fs.rmSync(flag, { force: true });
|
||||
});
|
||||
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
describe("mergedModels", () => {
|
||||
it("adds the provider to an empty or missing file", () => {
|
||||
const doc = yaml.load(share.mergedModels(""));
|
||||
assert.deepEqual(doc, {
|
||||
providers: {
|
||||
agenthub: {
|
||||
baseUrl: "http://127.0.0.1:4999",
|
||||
apiKey: "AGENTHUB_PANE_TOKEN",
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
discovery: { type: "proxy" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the member's own providers and settings", () => {
|
||||
const text =
|
||||
"equivalence:\n x: y\nproviders:\n mine:\n baseUrl: http://h/v1\n apiKey: K\n";
|
||||
const doc = yaml.load(share.mergedModels(text));
|
||||
assert.equal(doc.providers.mine.baseUrl, "http://h/v1");
|
||||
assert.deepEqual(doc.equivalence, { x: "y" });
|
||||
assert.equal(doc.providers.agenthub.apiKey, "AGENTHUB_PANE_TOKEN");
|
||||
});
|
||||
|
||||
it("writes nothing when the provider is already there as is", () => {
|
||||
const once = share.mergedModels("");
|
||||
assert.equal(share.mergedModels(once), null);
|
||||
});
|
||||
|
||||
it("replaces a stale provider of ours", () => {
|
||||
const stale = "providers:\n agenthub:\n baseUrl: http://127.0.0.1:1\n";
|
||||
assert.equal(
|
||||
yaml.load(share.mergedModels(stale)).providers.agenthub.baseUrl,
|
||||
"http://127.0.0.1:4999"
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a file that is not a mapping", () => {
|
||||
assert.throws(() => share.mergedModels("- a\n- b\n"), /mapping/);
|
||||
assert.throws(() => share.mergedModels("providers: [1]\n"), /mapping/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shareModel", () => {
|
||||
const host = "anthropic/claude-sonnet-5-5";
|
||||
it("runs on the host's default when none was asked for", () => {
|
||||
assert.equal(share.shareModel(null, host), "agenthub/anthropic/claude-sonnet-5-5");
|
||||
});
|
||||
it("maps a requested model onto the gateway's provider", () => {
|
||||
assert.equal(share.shareModel("claude-opus-5-5", host), "agenthub/anthropic/claude-opus-5-5");
|
||||
assert.equal(share.shareModel("openai-codex/gpt-5.5", host), "agenthub/openai-codex/gpt-5.5");
|
||||
assert.equal(share.shareModel("agenthub/anthropic/x", host), "agenthub/anthropic/x");
|
||||
});
|
||||
it("leaves the choice to omp when there is nothing to go on", () => {
|
||||
assert.equal(share.shareModel(null, null), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("forSpawn", () => {
|
||||
const member = { login: "ah-mia", home: path.join(TMP, "home-mia") };
|
||||
|
||||
it("is null while sharing is off, for a host pane, or without S3", () => {
|
||||
assert.equal(share.forSpawn(member, "agenthub-lane-1", "h"), null); // off
|
||||
fs.mkdirSync(path.dirname(flag), { recursive: true });
|
||||
fs.writeFileSync(flag, "");
|
||||
assert.equal(share.forSpawn(member, "agenthub-lane-1", "h"), null); // no S3
|
||||
linux.s3Enabled = () => true;
|
||||
assert.equal(share.forSpawn(null, "agenthub-lane-1", "h"), null); // the host's
|
||||
assert.equal(share.forSpawn(member, "agenthub-lane-1", null), null); // no token
|
||||
});
|
||||
|
||||
it("writes the member's models.yml AS them and lists the pane's token", () => {
|
||||
turnOn();
|
||||
const writes = [];
|
||||
const real = { read: runAs.readFileSafe, mkdir: runAs.mkdirAs, write: runAs.writeAs };
|
||||
runAs.readFileSafe = () => {
|
||||
const e = new Error("missing");
|
||||
e.code = "ENOENT";
|
||||
throw e;
|
||||
};
|
||||
runAs.mkdirAs = (acct, dir) => writes.push(["mkdir", acct.login, dir]);
|
||||
runAs.writeAs = (acct, file, mode, content) => writes.push([mode, acct.login, file, content]);
|
||||
tmux.liveSessions = () => new Set();
|
||||
try {
|
||||
const s = share.forSpawn(member, "agenthub-lane-7", "a".repeat(64));
|
||||
assert.deepEqual(s.env, { OMP_SKIP_SETUP: "1" });
|
||||
s.place();
|
||||
const agentDir = path.join(member.home, ".omp", "agent");
|
||||
assert.deepEqual(writes[0], ["mkdir", "ah-mia", agentDir]);
|
||||
assert.equal(writes[1][0], "replace");
|
||||
assert.equal(writes[1][2], path.join(agentDir, "models.yml"));
|
||||
assert.equal(yaml.load(writes[1][3]).providers.agenthub.apiKey, "AGENTHUB_PANE_TOKEN");
|
||||
const clients = JSON.parse(fs.readFileSync(clientsFile, "utf8"));
|
||||
assert.deepEqual(clients["agenthub-lane-7"], { tokenSha256: "a".repeat(64) });
|
||||
assert.equal((fs.statSync(clientsFile).mode & 0o777).toString(8), "600");
|
||||
} finally {
|
||||
runAs.readFileSafe = real.read;
|
||||
runAs.mkdirAs = real.mkdir;
|
||||
runAs.writeAs = real.write;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveClients", () => {
|
||||
before(() => {
|
||||
const ins = db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, cwd, status, tool, pane_token_hash) VALUES (?, '/x', ?, ?, ?)`
|
||||
);
|
||||
ins.run("agenthub-lane-21", "running", "omp", "b".repeat(64));
|
||||
ins.run("agenthub-lane-22", "running", "omp", "c".repeat(64)); // pane gone
|
||||
ins.run("agenthub-lane-23", "ended", "omp", "d".repeat(64));
|
||||
ins.run("agenthub-lane-24", "running", "claude", "e".repeat(64));
|
||||
});
|
||||
|
||||
it("lists only live omp panes", () => {
|
||||
tmux.liveSessions = () => new Set(["agenthub-lane-21", "agenthub-lane-23", "agenthub-lane-24"]);
|
||||
const c = share.liveClients();
|
||||
assert.deepEqual(c["agenthub-lane-21"], { tokenSha256: "b".repeat(64) });
|
||||
for (const id of ["agenthub-lane-22", "agenthub-lane-23", "agenthub-lane-24"]) {
|
||||
assert.equal(c[id], undefined, id);
|
||||
}
|
||||
});
|
||||
|
||||
it("says nothing when tmux could not be asked", () => {
|
||||
tmux.liveSessions = () => null;
|
||||
assert.equal(share.liveClients(), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/ai-tools/omp-sharing", () => {
|
||||
let server;
|
||||
let BASE;
|
||||
before(async () => {
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
after(() => server.close());
|
||||
|
||||
it("reports it unavailable and refuses the switch outside S3", async () => {
|
||||
const got = await (await fetch(`${BASE}/api/ai-tools/omp-sharing`)).json();
|
||||
assert.equal(got.available, false);
|
||||
assert.equal(got.enabled, false);
|
||||
const put = await fetch(`${BASE}/api/ai-tools/omp-sharing`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json", origin: BASE },
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
});
|
||||
assert.equal(put.status, 409);
|
||||
assert.equal((await put.json()).error.code, "ENOS3");
|
||||
assert.equal(fs.existsSync(flag), false);
|
||||
});
|
||||
|
||||
it("wants a boolean", async () => {
|
||||
const put = await fetch(`${BASE}/api/ai-tools/omp-sharing`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json", origin: BASE },
|
||||
body: JSON.stringify({ enabled: "yes" }),
|
||||
});
|
||||
assert.equal(put.status, 400);
|
||||
});
|
||||
});
|
||||
@@ -815,6 +815,8 @@ describe("pty-run", () => {
|
||||
);
|
||||
assert.deepEqual(paneArgv(call), [
|
||||
at("omp"),
|
||||
// A workspace at the account's home stays there (omp moves to a temp dir otherwise).
|
||||
"--allow-home",
|
||||
"--model",
|
||||
"opus",
|
||||
"--resume",
|
||||
@@ -823,7 +825,12 @@ describe("pty-run", () => {
|
||||
]);
|
||||
assert.throws(
|
||||
() =>
|
||||
pty.spawnRun({ laneId: 18, cwd: "/tmp/repo", tool: "omp", resumeSessionId: "--abcdefgh" }),
|
||||
pty.spawnRun({
|
||||
laneId: 18,
|
||||
cwd: "/tmp/repo",
|
||||
tool: "omp",
|
||||
resumeSessionId: "--abcdefgh",
|
||||
}),
|
||||
{ code: "EBADSESSION" }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @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"));
|
||||
});
|
||||
});
|
||||
@@ -230,19 +230,26 @@ describe("Settings → Account (/api/me)", () => {
|
||||
body: { currentPassword: "nope", newPassword: "leo-new-pass" },
|
||||
});
|
||||
assert.equal(wrong.body.error.code, "EREAUTH");
|
||||
const ok = await call("POST", "/api/me/password", {
|
||||
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", "leo-new-pass"));
|
||||
assert.ok(await login("leo", "New-Pass-2026"));
|
||||
});
|
||||
|
||||
it("signs out other devices", async () => {
|
||||
const a = await login("leo", "leo-new-pass");
|
||||
const b = await login("leo", "leo-new-pass");
|
||||
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);
|
||||
|
||||
@@ -116,6 +116,10 @@ before(async () => {
|
||||
});
|
||||
cookies[n] = login.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
// A second admin, so demoting "boss" never leaves the install without one (ELASTADMIN).
|
||||
db.prepare(
|
||||
"INSERT INTO users (username, role, password_hash) VALUES ('boss2', 'admin', 'x')"
|
||||
).run();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
|
||||
Reference in New Issue
Block a user