/** * @file server-mode-auth.test.js * @description Server mode's login gate (server/lib/auth.js, routes/auth.js): * local mode untouched, every API call behind a login, roles (analyst * read-only, admin-only config writes), hooks from this machine only, logout * and disable ending a login, the failed-login throttle, the WebSocket check, * and the same-origin guard accepting the dashboard's own allowed Host. * @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-test-${Date.now()}-${process.pid}.db`); process.env.DASHBOARD_DB_PATH = TEST_DB; process.env.DASHBOARD_LIVENESS_PROBE = "0"; const { createApp, startServer } = require("../index"); const auth = require("../lib/auth"); const { sameOriginGuard } = require("../lib/same-origin"); let server; let BASE; 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, }); const text = await res.text(); let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ } return { status: res.status, body: json, setCookie: res.headers.get("set-cookie") }; } async function loginAs(username, password) { const r = await call("POST", "/api/auth/login", { body: { username, password } }); assert.equal(r.status, 200, JSON.stringify(r.body)); return r.setCookie.split(";")[0]; } before(async () => { server = await startServer(createApp(), 0); BASE = `http://127.0.0.1:${server.address().port}`; await auth.createUser("boss", "admin-pass-1", "admin"); await auth.createUser("mia", "member-pass-1", "developer"); await auth.createUser("ana", "analyst-pass-1", "analyst"); }); 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 }); } }); describe("local mode (the default)", () => { it("needs no login and says it is local", async () => { delete process.env.AGENTHUB_MODE; assert.equal((await call("GET", "/api/sessions")).status, 200); assert.deepEqual((await call("GET", "/api/auth/me")).body, { mode: "local" }); assert.equal((await call("POST", "/api/auth/login", { body: {} })).status, 404); // The one local viewer keeps their prefs too. const chat = { prefs: { "agenthub.chatUi": "1" } }; assert.equal((await call("PUT", "/api/me/prefs", { body: chat })).status, 200); assert.equal((await call("GET", "/api/me/prefs")).body.prefs["agenthub.chatUi"], "1"); }); }); describe("server mode", () => { before(() => { process.env.AGENTHUB_MODE = "server"; }); it("refuses the API without a login, but answers health and /auth/me", async () => { const r = await call("GET", "/api/sessions"); assert.equal(r.status, 401); assert.equal(r.body.error.code, "EAUTH"); assert.equal((await call("GET", "/api/health")).status, 200); assert.deepEqual((await call("GET", "/api/auth/me")).body, { mode: "server", user: null }); }); it("rejects a wrong password and sets an httpOnly strict cookie on a right one", async () => { const bad = await call("POST", "/api/auth/login", { body: { username: "mia", password: "x" } }); assert.equal(bad.status, 401); assert.equal(bad.body.error.code, "EBADLOGIN"); const ok = await call("POST", "/api/auth/login", { body: { username: "MIA", password: "member-pass-1" }, }); assert.equal(ok.status, 200); assert.equal(ok.body.user.username, "mia"); assert.match(ok.setCookie, /agenthub_sid=[^;]+; Path=\/; HttpOnly; SameSite=Strict/); const cookie = ok.setCookie.split(";")[0]; assert.equal((await call("GET", "/api/sessions", { cookie })).status, 200); assert.equal((await call("GET", "/api/auth/me", { cookie })).body.user.role, "developer"); }); it("lets an analyst work but keeps the host's config writes an admin's", async () => { const analyst = await loginAs("ana", "analyst-pass-1"); assert.equal((await call("GET", "/api/sessions", { cookie: analyst })).status, 200); // Past the role gate: the route itself answers the empty body. const w = await call("POST", "/api/lanes/ensure", { cookie: analyst, body: {} }); assert.ok(![401, 403].includes(w.status), `got ${w.status}`); for (const [method, url] of [ ["PUT", "/api/settings/no-such-route"], ["POST", "/api/settings/quit"], ["POST", "/api/alerts/rules"], ["DELETE", "/api/alerts/rules/x"], ]) { const r = await call(method, url, { cookie: analyst, body: {} }); assert.equal(r.status, 403, `${method} ${url}`); assert.equal(r.body.error.code, "EADMIN"); } const member = await loginAs("mia", "member-pass-1"); // A path no router answers: the guard decides before any handler could // write the real settings file. const m = await call("PUT", "/api/settings/no-such-route", { cookie: member, body: {} }); assert.equal(m.status, 403); assert.equal(m.body.error.code, "EADMIN"); const admin = await loginAs("boss", "admin-pass-1"); const a = await call("PUT", "/api/settings/no-such-route", { cookie: admin, body: {} }); assert.equal(a.status, 404); }); it("takes hooks from this machine only", async () => { const remote = await call("POST", "/api/hooks/event", { body: {}, headers: { "x-forwarded-for": "203.0.113.9" }, }); assert.equal(remote.status, 403); assert.equal(remote.body.error.code, "ELOCALONLY"); const local = await call("POST", "/api/hooks/event", { body: {} }); assert.ok(![401, 403].includes(local.status), `got ${local.status}`); }); it("ends a login on logout and on disable", async () => { const one = await loginAs("mia", "member-pass-1"); await call("POST", "/api/auth/logout", { cookie: one }); assert.equal((await call("GET", "/api/sessions", { cookie: one })).status, 401); const two = await loginAs("mia", "member-pass-1"); auth.setDisabled("mia", true); assert.equal((await call("GET", "/api/sessions", { cookie: two })).status, 401); const again = await call("POST", "/api/auth/login", { body: { username: "mia", password: "member-pass-1" }, }); assert.equal(again.status, 401); auth.setDisabled("mia", false); }); it("keeps each user's UI prefs apart, and only for a logged-in caller", async () => { assert.equal((await call("GET", "/api/me/prefs")).status, 401); const boss = await loginAs("boss", "admin-pass-1"); const ana = await loginAs("ana", "analyst-pass-1"); const on = { prefs: { "agenthub.chatUi": "1" } }; const put = await call("PUT", "/api/me/prefs", { cookie: boss, body: on }); assert.deepEqual(put.body.prefs, { "agenthub.chatUi": "1" }); assert.equal( (await call("GET", "/api/me/prefs", { cookie: ana })).body.prefs["agenthub.chatUi"], undefined ); for (const prefs of [{ "agenthub.chatUi": true }, { userKey: "u1" }]) { const bad = await call("PUT", "/api/me/prefs", { cookie: ana, body: { prefs } }); assert.equal(bad.status, 400); assert.equal(bad.body.error.code, "EBADPREFS"); } // null removes the key: back to the default. await call("PUT", "/api/me/prefs", { cookie: boss, body: { prefs: { "agenthub.chatUi": null } }, }); assert.deepEqual((await call("GET", "/api/me/prefs", { cookie: boss })).body.prefs, {}); // The appearance (useTheme's storage keys) follows the account too. const look = { theme: "light", "agenthub.theme.light": "canvas", "agenthub.theme.dark": "darcula", }; const saved = await call("PUT", "/api/me/prefs", { cookie: ana, body: { prefs: look } }); assert.equal(saved.status, 200); assert.deepEqual(saved.body.prefs, look); }); it("checks a WebSocket upgrade's login and Origin", async () => { const cookie = await loginAs("boss", "admin-pass-1"); const req = (headers, url = "/ws") => ({ url, headers: { host: "127.0.0.1:1", ...headers } }); assert.equal(auth.isWebSocketLoggedIn(req({})), false); assert.equal(auth.isWebSocketLoggedIn(req({ cookie })), true); assert.equal(auth.isWebSocketLoggedIn(req({ cookie, origin: "https://evil.example" })), false); const analyst = await loginAs("ana", "analyst-pass-1"); assert.equal(auth.isWebSocketLoggedIn(req({ cookie: analyst })), true); // Which runs an analyst may type into is the run check's (canSeeRun), not this one's. assert.equal( auth.isWebSocketLoggedIn(req({ cookie: analyst }, "/ws-pty/agenthub-lane-1")), true ); }); // Last: it locks this test's address out of logging in. it("throttles repeated failed logins", async () => { let last; for (let i = 0; i < 11; i++) { last = await call("POST", "/api/auth/login", { body: { username: "boss", password: "no" } }); } assert.equal(last.status, 429); assert.equal(last.body.error.code, "ETOOMANY"); }); }); describe("sameOriginGuard", () => { const run = (headers) => { let status = 200; let passed = false; const res = { status(s) { status = s; return this; }, json() { return this; }, }; sameOriginGuard({ headers }, res, () => (passed = true)); return passed ? 200 : status; }; it("accepts the dashboard's own allowed Host as an Origin", () => { process.env.DASHBOARD_ALLOWED_HOSTS = "agenthub.example.com"; try { assert.equal( run({ origin: "https://agenthub.example.com", host: "agenthub.example.com" }), 200 ); assert.equal(run({ origin: "https://evil.example", host: "agenthub.example.com" }), 403); assert.equal(run({ origin: "https://evil.example", host: "evil.example" }), 403); assert.equal(run({ origin: "http://localhost:4820", host: "localhost:4820" }), 200); assert.equal(run({}), 200); } finally { delete process.env.DASHBOARD_ALLOWED_HOSTS; } }); }); describe("server mode prefix checks follow Express routing", () => { before(() => { process.env.AGENTHUB_MODE = "server"; }); it("refuses a member a staff-only router reached by case or encoding", async () => { const r = await call("POST", "/api/auth/login", { body: { username: "mia", password: "member-pass-1" }, headers: { "x-forwarded-for": "198.51.100.7" }, }); const cookie = r.setCookie.split(";")[0]; for (const p of ["/api/webhooks", "/api/Webhooks", "/api/%77ebhooks", "/api//webhooks"]) { const res = await call("GET", p, { cookie }); assert.ok([403, 404].includes(res.status), `${p} answered ${res.status}`); } const hook = await call("POST", "/api/Hooks/event", { body: {}, headers: { "x-forwarded-for": "203.0.113.9" }, }); assert.equal(hook.status, 403); }); });