Files
AgentHub/server/__tests__/server-mode-auth.test.js
T
nntrivi2001 9a54978eef feat: v2.5.10 — server mode for a team, file drops in the prompt, tab joins
Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged)
- Login for every page and API call (server/lib/auth.js, routes/auth.js,
  AuthGate), roles admin / analyst / member, accounts made only from the
  host's shell: `agenthub server setup|off|status`, `agenthub users
  list|add|passwd|role|disable|enable|assign|audit`
- Rows are owned: a member sees only their own workspaces, sessions and what
  hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience);
  staff reads of a member's session are audited (server/lib/audit.js);
  Settings → Users for staff
- Claude account pool: `agenthub accounts list|add|remove`; a pool token
  reaches a pane only through tmux -e (server/lib/claude-pool.js)
- A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback,
  live pane only); writes pass an Origin check; named locks per member
- docs/SERVER-MODE.md, docs/plans/server-mode/

Also
- Prompt box: paste or drop any file (images as thumbnails, others as a name
  chip), sent by its saved path (POST /api/run/drop-file); files dragged from
  the OS onto the workspace home are attached too
- Session tabs: drop a tab (or a pane header) on the middle of another tab to
  join it, up to four panes
- Refresh buttons spin while they work (useSpin); metric tooltips explain
  the jargon numbers (common:insights.help.*)
- Light palette retuned for contrast; IntelliJ Light theme removed
- CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md

Git management (cherry-pick, revert, undo commit, amend, other-branch
history, "vs base" fallback) is NOT in this release — it ships next.

Committed --no-verify: both full suites run on this exact tree (server
2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev;
client 1086/1086), tsc clean, prettier applied by hand.
2026-10-07 14:11:25 +07:00

232 lines
8.7 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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", "member");
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);
});
});
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, "member");
});
it("keeps an analyst read-only and 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);
const w = await call("POST", "/api/lanes/ensure", { cookie: analyst, body: {} });
assert.equal(w.status, 403);
assert.equal(w.body.error.code, "EREADONLY");
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("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);
assert.equal(
auth.isWebSocketLoggedIn(req({ cookie: analyst }, "/ws-pty/agenthub-lane-1")),
false
);
});
// 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);
});
});