/** * @file server-mode-rows.test.js * @description Server mode's per-user row ownership on the list and aggregate * routes (phase S2): sessions (+ facets, POST), agents, events (+ facets), * snapshots, the alert feed and ack-all, and the settings info counts. A * member gets only their own workspaces' rows, another member theirs, an admin * and an analyst everything, and local mode is unchanged. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const path = require("path"); const fs = require("fs"); const os = require("os"); const TEST_DB = path.join(os.tmpdir(), `server-mode-rows-${Date.now()}-${process.pid}.db`); process.env.DASHBOARD_DB_PATH = TEST_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 cookies = {}; async function call(method, p, { as, body } = {}) { const res = await fetch(BASE + p, { method, headers: { "Content-Type": "application/json", ...(as ? { cookie: cookies[as] } : {}), }, body: body ? JSON.stringify(body) : undefined, }); return { status: res.status, body: await res.json().catch(() => null) }; } const get = async (p, as) => { const r = await call("GET", p, { as }); assert.equal(r.status, 200, `${p}: ${JSON.stringify(r.body)}`); return r.body; }; const ids = (rows, key = "id") => rows.map((r) => r[key]).sort(); const ALL_SESSIONS = ["sA", "sA2", "sB", "sHost"]; before(async () => { server = await startServer(createApp(), 0); BASE = `http://127.0.0.1:${server.address().port}`; const alice = await auth.createUser("alice", "alice-pass-1", "member"); const bob = await auth.createUser("bob", "bob-pass-1", "member"); await auth.createUser("boss", "admin-pass-1", "admin"); await auth.createUser("ana", "analyst-pass-1", "analyst"); const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)"); const laneA = lane.run("/w/alice", alice.id).lastInsertRowid; const laneB = lane.run("/w/bob", bob.id).lastInsertRowid; const session = db.prepare( "INSERT INTO sessions (id, name, status, cwd, lane_id, source) VALUES (?, ?, 'active', ?, ?, ?)" ); session.run("sA", "alice one", "/w/alice", laneA, "local"); session.run("sA2", "alice unstamped", "/w/alice", null, "local"); session.run("sB", "bob one", "/w/bob", laneB, "remote-b"); session.run("sHost", "host", "/w/host", null, "local"); // sA2 is alice's through a run of her workspace, not a stamp. db.prepare( "INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id) VALUES ('agenthub-lane-1-2', 'sA2', 'interactive', '/w/alice', 'running', ?)" ).run(laneA); const rule = db.prepare( "INSERT INTO alert_rules (id, name, rule_type, config) VALUES ('r1', 'rule', 'error_rate', '{}')" ); rule.run(); for (const sid of ALL_SESSIONS) { db.prepare( "INSERT INTO agents (id, session_id, name, type, status) VALUES (?, ?, 'Main', 'main', 'working')" ).run(`${sid}-main`, sid); db.prepare( "INSERT INTO events (session_id, agent_id, event_type, tool_name, summary) VALUES (?, ?, ?, ?, 'x')" ).run(sid, `${sid}-main`, `Type_${sid}`, `Tool_${sid}`); db.prepare("INSERT INTO token_usage (session_id, model, input_tokens) VALUES (?, 'm', 10)").run( sid ); db.prepare( "INSERT INTO alert_events (rule_id, rule_name, rule_type, session_id, message) VALUES ('r1', 'rule', 'error_rate', ?, 'boom')" ).run(sid); db.prepare( "INSERT INTO session_snapshots (token, session_id, payload) VALUES (?, ?, '{\"events\":[]}')" ).run(`tok-${sid}`.padEnd(32, "x"), sid); } // An alert with no session (a budget) is staff-only. db.prepare( "INSERT INTO alert_events (rule_id, rule_name, rule_type, session_id, message) VALUES ('r1', 'rule', 'budget', NULL, 'spend')" ).run(); process.env.AGENTHUB_MODE = "server"; for (const [name, pass] of [ ["alice", "alice-pass-1"], ["bob", "bob-pass-1"], ["boss", "admin-pass-1"], ["ana", "analyst-pass-1"], ]) { const r = await fetch(BASE + "/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: name, password: pass }), }); assert.equal(r.status, 200); cookies[name] = r.headers.get("set-cookie").split(";")[0]; } }); after(() => { delete process.env.AGENTHUB_MODE; server?.close(); for (const f of [TEST_DB, `${TEST_DB}-wal`, `${TEST_DB}-shm`, `${TEST_DB}-migrate.lock`]) { fs.rmSync(f, { force: true }); } }); /** What `as` sees on every scoped list/aggregate route. */ async function view(as) { const sessions = await get("/api/sessions", as); const priced = await get("/api/sessions?sort_by=price", as); const waiting = await get("/api/sessions?status=active&archived=0&q=alice", as); const sFacets = await get("/api/sessions/facets", as); const agents = await get("/api/agents", as); const working = await get("/api/agents?status=working", as); const events = await get("/api/events", as); const eFacets = await get("/api/events/facets", as); const snaps = await get("/api/snapshots", as); const alerts = await get("/api/alerts", as); const info = await get("/api/settings/info", as); return { sessions: ids(sessions.sessions), total: sessions.total, priced: ids(priced.sessions), waiting: ids(waiting.sessions), cwds: sFacets.cwds, sources: sFacets.sources, agents: ids(agents.agents, "session_id"), working: ids(working.agents, "session_id"), events: ids(events.events, "session_id"), eventTotal: events.total, tools: eFacets.tool_names, snapshots: ids(snaps.snapshots, "session_id"), alerts: alerts.alerts.map((a) => a.session_id).sort(), alertTotal: alerts.total, unacked: alerts.unacked, counts: info.db.counts, h1: info.db.load_stats.h1, }; } function assertSees(v, sids) { assert.deepEqual(v.sessions, sids); assert.equal(v.total, sids.length); assert.deepEqual(v.priced, sids); assert.deepEqual(v.agents, sids); assert.deepEqual(v.working, sids); assert.deepEqual(v.events, sids); assert.equal(v.eventTotal, sids.length); assert.deepEqual( v.tools, sids.map((s) => `Tool_${s}`) ); assert.deepEqual(v.snapshots, sids); assert.equal(v.counts.sessions, sids.length); assert.equal(v.counts.agents, sids.length); assert.equal(v.counts.events, sids.length); assert.equal(v.counts.token_usage, sids.length); assert.equal(v.h1, sids.length); } describe("server mode: list and aggregate routes", () => { it("shows a member only their own workspaces' rows, unstamped ones included", async () => { const v = await view("alice"); assertSees(v, ["sA", "sA2"]); assert.deepEqual(v.waiting, ["sA", "sA2"]); assert.deepEqual(v.cwds, ["/w/alice"]); assert.deepEqual(v.sources, ["local"]); assert.deepEqual(v.alerts, ["sA", "sA2"]); assert.equal(v.alertTotal, 2); assert.equal(v.unacked, 2); }); it("shows another member theirs", async () => { const v = await view("bob"); assertSees(v, ["sB"]); assert.deepEqual(v.waiting, []); assert.deepEqual(v.cwds, ["/w/bob"]); assert.deepEqual(v.sources, ["remote-b"]); assert.deepEqual(v.alerts, ["sB"]); assert.equal(v.unacked, 1); }); it("shows an admin and an analyst everything", async () => { for (const as of ["boss", "ana"]) { const v = await view(as); assertSees(v, ALL_SESSIONS); assert.deepEqual(v.waiting, ["sA", "sA2"]); assert.deepEqual(v.cwds, ["/w/alice", "/w/bob", "/w/host"]); assert.deepEqual(v.sources, ["local", "remote-b"]); assert.equal(v.alerts.length, 5); // the budget alert included assert.equal(v.unacked, 5); } }); it("keeps a member out of another's rows through filters and POST", async () => { // A filter naming another's session is refused, not answered empty. assert.equal((await call("GET", "/api/events?session_id=sB", { as: "alice" })).status, 404); assert.equal((await call("GET", "/api/agents?session_id=sB", { as: "alice" })).status, 404); // An agent_id filter cannot reach past the ownership predicate. const ev = await get("/api/events?agent_id=sB-main", "alice"); assert.equal(ev.total, 0); // A member cannot create (or probe) sessions through POST /api/sessions at // all — sessions come from hooks and the dashboard's own panes. const r = await call("POST", "/api/sessions", { as: "alice", body: { id: "sB" } }); assert.equal(r.status, 403); assert.ok(!JSON.stringify(r.body).includes("sB")); const own = await call("POST", "/api/sessions", { as: "alice", body: { id: "sA" } }); assert.equal(own.status, 403); // A snapshot of another's session cannot be captured. const snap = await call("POST", "/api/snapshots", { as: "alice", body: { session_id: "sB" } }); assert.equal(snap.status, 404); }); it("lets a member acknowledge only their own alerts", async () => { const r = await call("POST", "/api/alerts/ack-all", { as: "alice" }); assert.equal(r.status, 200); assert.equal(r.body.acknowledged, 2); assert.equal((await get("/api/alerts", "alice")).unacked, 0); assert.equal((await get("/api/alerts", "bob")).unacked, 1); assert.equal((await get("/api/alerts?unacked=true", "boss")).total, 3); }); }); describe("local mode", () => { before(() => delete process.env.AGENTHUB_MODE); after(() => (process.env.AGENTHUB_MODE = "server")); it("needs no login and lists every row", async () => { const v = await view(); assertSees(v, ALL_SESSIONS); assert.equal(v.alerts.length, 5); assert.equal(v.unacked, 3); const ack = await call("POST", "/api/alerts/ack-all"); assert.equal(ack.body.acknowledged, 3); }); });