Files
AgentHub/server/__tests__/server-s3-onboarded.test.js
T
nntrivi2001 0c2f49876d feat: v2.7.6 — edit users in one form, Settings → Account, display names, copy over plain HTTP
- Users page: one Edit user form (display name, email, role, active, a new or
  generated password, workspaces given or taken back, sign out everywhere),
  validated as a whole before anything is written (PATCH /api/users/:id,
  GET /api/users/:id); users.display_name/email added migration-safe.
- Settings → Account for every signed-in user: profile, own password (the
  current one required, this login kept), sign out other devices
  (/api/me/account, /api/me/password, /api/me/sign-out-others; login cookie
  only, never a pane token).
- Display names wherever a person is named (sidebar, workspace owner groups,
  Users page and audit log); owner_display_name / reader_display additive.
- Users page: admins add and remove Claude/Codex pool accounts.
- Folder browser: an admin with their own Linux account opens in their home;
  the "Server folder" tick opens the host's (never remembered).
- AI Proxy: "Config 9router" beside Add profile when 9router is installed,
  opened on the dashboard's own host (GET /api/ai-proxies/9router).
- Terminal: a tmux selection reaches the clipboard over plain HTTP too
  (writeClipboard's textarea fallback, focus handed back).
- Chat: a file the session touched outside the workspace previews
  (GET /api/sessions/:id/file).
- install-system: `!requiretty` only when visudo accepts it (sudo-rs on
  Ubuntu 25.10+ rejects the setting).
- openapi.yaml regenerated; docs (API, SERVER-MODE, LANES) updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:10:29 +07:00

106 lines
4.0 KiB
JavaScript

/**
* @file server-s3-onboarded.test.js
* @description S3: a member's Claude pane on a pool account (or an AI Proxy)
* must not stop at the first-run "Select login method" screen a fresh home
* shows even with CLAUDE_CODE_OAUTH_TOKEN set — `run-as.markClaudeOnboarded`
* adds `hasCompletedOnboarding: true` to the member's `~/.claude.json`, AS the
* member (write-as, played by fixtures/fake-sudo.js), keeping every other key
* and leaving a file it cannot trust alone.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, beforeEach, 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.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-onboarded-")));
const USERS = path.join(TMP, "users");
const HOME = path.join(USERS, "mia");
const FILE = path.join(HOME, ".claude.json");
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
const auth = require("../lib/auth");
const linux = require("../lib/linux-accounts");
const runAs = require("../lib/run-as");
const { fakeSudo } = require("./fixtures/fake-sudo");
const sudo = fakeSudo();
let acct;
before(async () => {
fs.mkdirSync(HOME, { recursive: true });
// The member's uid is this test's own, so its files pass the owner check.
const uid = process.getuid();
fs.writeFileSync(process.env.AGENTHUB_PASSWD, `ah-mia:x:${uid}:${uid}::/:/bin/sh\n`);
fs.writeFileSync(
process.env.AGENTHUB_SERVER_CONF,
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
);
linux.__resetConfCache();
runAs.__resetCache();
linux.__setRunImpl(() => "ok");
runAs.__setExecImpl(sudo.exec);
process.env.AGENTHUB_MODE = "server";
await auth.createUser("mia", "member-pass-1", "developer");
acct = runAs.accountForPath(HOME);
assert.ok(acct, "mia is a provisioned member");
});
after(() => {
delete process.env.AGENTHUB_MODE;
});
beforeEach(() => {
fs.rmSync(FILE, { force: true });
sudo.calls.length = 0;
});
describe("markClaudeOnboarded", () => {
it("creates ~/.claude.json, 0600, as the member", () => {
assert.equal(runAs.markClaudeOnboarded(acct), true);
assert.deepEqual(JSON.parse(fs.readFileSync(FILE, "utf8")), { hasCompletedOnboarding: true });
assert.equal(fs.statSync(FILE).mode & 0o777, 0o600);
assert.ok(sudo.calls.some((c) => c.includes(FILE) && c.includes("private")));
});
it("adds only the flag, keeping the member's own state", () => {
fs.writeFileSync(FILE, JSON.stringify({ theme: "light", projects: { "/x": {} } }));
assert.equal(runAs.markClaudeOnboarded(acct), true);
assert.deepEqual(JSON.parse(fs.readFileSync(FILE, "utf8")), {
theme: "light",
projects: { "/x": {} },
hasCompletedOnboarding: true,
});
});
it("writes nothing when the member is already onboarded", () => {
fs.writeFileSync(FILE, JSON.stringify({ hasCompletedOnboarding: true }));
assert.equal(runAs.markClaudeOnboarded(acct), false);
assert.equal(sudo.calls.length, 0);
});
it("leaves a file that is not JSON, or not a regular file, alone", () => {
fs.writeFileSync(FILE, "{ half-written");
assert.equal(runAs.markClaudeOnboarded(acct), false);
assert.equal(fs.readFileSync(FILE, "utf8"), "{ half-written");
fs.rmSync(FILE);
fs.mkdirSync(FILE);
assert.equal(runAs.markClaudeOnboarded(acct), false);
fs.rmdirSync(FILE);
assert.equal(sudo.calls.length, 0);
});
it("never writes through a link the member left at the name", () => {
const target = path.join(TMP, "elsewhere.json");
fs.writeFileSync(target, "{}");
fs.symlinkSync(target, FILE);
runAs.markClaudeOnboarded(acct);
assert.equal(fs.readFileSync(target, "utf8"), "{}");
});
});