Files
AgentHub/server/__tests__/cc-profiles.test.js
T
nntrivi2001 11dcc04ac4 fix(cc-config): one account is one profile, and rename it in place
Four defects, all from one design mistake: a profile's display name and its
filename were the same string.

- Saving the logged-in account made a SECOND profile of an account another
  profile already held. Both then reported themselves active, so there was
  nothing to switch between. `POST /profiles` now refuses `EDUPLICATE` and
  names the profile that already holds the account. Re-saving into the
  account's own profile stays allowed - that is the token-refresh path.
- Worse, an accidental duplicate could not be cleaned up: two profiles of one
  account are both active, and the delete guard refused any active profile, so
  neither was ever deletable. The guard now blocks only the LAST saved copy of
  the live account, which is the actual credential-loss condition it exists
  for. The trash button had to follow, or the server would allow what the UI
  hid.
- A name could not contain spaces or diacritics, because it had to be
  filename-safe. A profile now carries `name` (internal identifier, still the
  filename, still what every route takes) and `label` (what the user reads and
  edits: 1-60 free-form characters, control characters rejected, stored
  trimmed, unique case-insensitively). A label never touches a path, which is
  what makes "Tài khoản chính" a legal name.
- Naming happened up front in a "save current account as" field. That field is
  gone. The server derives the identifier, the save button lives on the live
  account's card and appears only while it is unsaved, and the label is edited
  by clicking it (Enter saves, Escape cancels).

Renaming rewrites the label in place and deliberately does NOT move `name`, so
it cannot half-fail and leave a profile unreachable. Profiles written before
labels existed report `label` equal to their `name`, so nothing needs migrating.

Adds `PATCH /api/cc-config/profiles`; `POST` now takes an optional `name`.

Verified: server 1431/1431 (11 new), client 459/459, tsc clean. Also exercised
end to end against a COPY of the real profile store on this machine: the
duplicate deleted, the last copy still refused, a Vietnamese label accepted,
and the stored access/refresh tokens and 0600 mode intact after a rename.
2026-09-07 16:29:11 +07:00

334 lines
13 KiB
JavaScript

/**
* @file Tests for named Claude Code auth profiles — snapshotting the logged-in
* account, switching between accounts, and the guards that keep a switch from
* destroying the only copy of a credential.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cc-profiles-"));
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const profiles = require("../lib/cc-profiles");
const HOME = process.env.CLAUDE_HOME;
const CREDS = path.join(HOME, ".credentials.json");
const CONFIG = `${HOME}.json`;
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
/** Put one account on disk the way the `claude` CLI would leave it. */
function login(uuid, email, { mcp = true, extras = {} } = {}) {
fs.mkdirSync(HOME, { recursive: true });
fs.writeFileSync(
CREDS,
JSON.stringify({
...(mcp ? { mcpOAuth: { "some:server": { accessToken: "mcp-token" } } } : {}),
claudeAiOauth: { accessToken: `at-${uuid}`, refreshToken: `rt-${uuid}`, expiresAt: 111 },
})
);
fs.writeFileSync(
CONFIG,
JSON.stringify({
numStartups: 7,
projects: { "/tmp/x": { allowedTools: [] } },
userID: `uid-${uuid}`,
oauthAccount: { accountUuid: uuid, emailAddress: email, organizationName: "Org" },
...extras,
})
);
}
beforeEach(() => {
fs.rmSync(HOME, { recursive: true, force: true });
fs.rmSync(CONFIG, { force: true });
});
describe("cc-profiles", () => {
it("reports the live account and no profiles before anything is saved", () => {
login("uuid-a", "a@example.com");
const { items, active } = profiles.listProfiles();
assert.deepEqual(items, []);
assert.equal(active.email, "a@example.com");
assert.equal(active.accountUuid, "uuid-a");
});
it("saves the current account and marks it active in the list", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
const { items } = profiles.listProfiles();
assert.equal(items.length, 1);
assert.equal(items[0].name, "work");
assert.equal(items[0].email, "a@example.com");
assert.equal(items[0].active, true);
});
it("never exposes token material in the list", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
const json = JSON.stringify(profiles.listProfiles());
assert.ok(!json.includes("at-uuid-a"), "access token leaked into the list response");
assert.ok(!json.includes("rt-uuid-a"), "refresh token leaked into the list response");
});
it("stores profiles 0600 in a 0700 dir", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
const dir = profiles.profilesDir();
assert.equal(fs.statSync(dir).mode & 0o777, 0o700);
assert.equal(fs.statSync(path.join(dir, "work.json")).mode & 0o777, 0o600);
});
it("rejects names that are not filename-safe", () => {
login("uuid-a", "a@example.com");
for (const bad of ["../escape", "has/slash", "", "a".repeat(41), ".dotfirst"]) {
assert.throws(() => profiles.saveProfile(bad), { code: "EBADNAME" }, `accepted ${bad}`);
}
});
it("refuses to save when nobody is logged in", () => {
fs.mkdirSync(HOME, { recursive: true });
assert.throws(() => profiles.saveProfile("work"), { code: "ENOAUTH" });
});
it("switches the account and keeps everything else in the config", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
login("uuid-b", "b@example.com");
profiles.saveProfile("personal");
const result = profiles.activateProfile("work");
assert.equal(result.savedOutgoing, "personal");
const creds = JSON.parse(fs.readFileSync(CREDS, "utf8"));
assert.equal(creds.claudeAiOauth.accessToken, "at-uuid-a");
assert.equal(creds.claudeAiOauth.refreshToken, "rt-uuid-a");
// MCP server tokens belong to the machine, not the account.
assert.equal(creds.mcpOAuth["some:server"].accessToken, "mcp-token");
const cfg = JSON.parse(fs.readFileSync(CONFIG, "utf8"));
assert.equal(cfg.oauthAccount.accountUuid, "uuid-a");
assert.equal(cfg.userID, "uid-uuid-a");
// Untouched: everything that is not the account identity.
assert.equal(cfg.numStartups, 7);
assert.deepEqual(cfg.projects, { "/tmp/x": { allowedTools: [] } });
const { items } = profiles.listProfiles();
assert.deepEqual(
items.map((p) => [p.name, p.active]),
[
["personal", false],
["work", true],
]
);
});
it("re-snapshots the outgoing account so refreshed tokens survive a round trip", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
login("uuid-b", "b@example.com");
profiles.saveProfile("personal");
profiles.activateProfile("work");
// Simulate Claude Code refreshing the now-active account's token.
const creds = JSON.parse(fs.readFileSync(CREDS, "utf8"));
creds.claudeAiOauth.accessToken = "at-refreshed";
fs.writeFileSync(CREDS, JSON.stringify(creds));
profiles.activateProfile("personal");
profiles.activateProfile("work");
assert.equal(
JSON.parse(fs.readFileSync(CREDS, "utf8")).claudeAiOauth.accessToken,
"at-refreshed"
);
});
it("refuses to switch away from an account that was never saved", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
login("uuid-b", "b@example.com"); // logged in, but not snapshotted
assert.throws(() => profiles.activateProfile("work"), { code: "EUNSAVED" });
// Nothing moved.
assert.equal(JSON.parse(fs.readFileSync(CREDS, "utf8")).claudeAiOauth.accessToken, "at-uuid-b");
});
it("rewrites a second stale .claude.json so it cannot contradict the switch", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
login("uuid-b", "b@example.com");
profiles.saveProfile("personal");
const inner = path.join(HOME, ".claude.json");
fs.writeFileSync(
inner,
JSON.stringify({ oauthAccount: { accountUuid: "uuid-stale" }, firstStartTime: "x" })
);
profiles.activateProfile("work");
const cfg = JSON.parse(fs.readFileSync(inner, "utf8"));
assert.equal(cfg.oauthAccount.accountUuid, "uuid-a");
assert.equal(cfg.firstStartTime, "x");
});
it("does not create a .claude.json that was not there", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
login("uuid-b", "b@example.com");
profiles.saveProfile("personal");
profiles.activateProfile("work");
assert.equal(fs.existsSync(path.join(HOME, ".claude.json")), false);
});
it("deletes an inactive profile and refuses the active one", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
login("uuid-b", "b@example.com");
profiles.saveProfile("personal");
assert.throws(() => profiles.deleteProfile("personal"), { code: "EACTIVE" });
assert.deepEqual(profiles.deleteProfile("work"), { ok: true, name: "work" });
assert.throws(() => profiles.deleteProfile("work"), { code: "ENOTFOUND" });
});
it("refuses to save an account that is already saved under another name", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
assert.throws(() => profiles.saveProfile("work-again"), { code: "EDUPLICATE" });
// The rejected name left no file behind.
assert.deepEqual(
profiles.listProfiles().items.map((p) => p.name),
["work"]
);
});
it("names the profile that already holds the account, so the error is actionable", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
assert.throws(() => profiles.saveProfile("dup"), /work/);
});
it("still re-snapshots an account into its own existing profile", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
assert.equal(profiles.saveProfile("work").name, "work");
});
it("derives a filename-safe slug and a readable label when no name is given", () => {
login("uuid-a", "trí.vĩ@example.com");
const saved = profiles.saveProfile();
assert.match(saved.name, /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/);
const item = profiles.listProfiles().items[0];
assert.equal(item.name, saved.name);
assert.ok(item.label, "a saved profile always has a label to show");
});
it("labels an existing profile by its name when the file predates labels", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
const file = path.join(profiles.profilesDir(), "work.json");
const stored = JSON.parse(fs.readFileSync(file, "utf8"));
delete stored.label;
fs.writeFileSync(file, JSON.stringify(stored));
assert.equal(profiles.listProfiles().items[0].label, "work");
});
it("renames a profile's label, keeping its credentials and its identifier", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
const result = profiles.renameProfile("work", "Tài khoản chính");
assert.equal(result.name, "work");
assert.equal(result.label, "Tài khoản chính");
const item = profiles.listProfiles().items[0];
assert.equal(item.name, "work", "the identifier must not change under a rename");
assert.equal(item.label, "Tài khoản chính");
assert.equal(item.email, "a@example.com");
// Switching still works, so the credentials survived the rewrite.
login("uuid-b", "b@example.com");
profiles.saveProfile("personal");
profiles.activateProfile("work");
assert.equal(JSON.parse(fs.readFileSync(CREDS, "utf8")).claudeAiOauth.accessToken, "at-uuid-a");
});
it("accepts spaces and diacritics in a label but not control characters", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
for (const good of ["Tài khoản chính", "Work / Personal", "công ty ⭐", "a b"]) {
assert.equal(profiles.renameProfile("work", good).label, good);
}
for (const bad of ["", " ", "a\nb", "a\tb", "x".repeat(61), 5, null]) {
assert.throws(
() => profiles.renameProfile("work", bad),
{ code: "EBADLABEL" },
`took ${bad}`
);
}
});
it("trims a label rather than storing the padding", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
assert.equal(profiles.renameProfile("work", " Chính ").label, "Chính");
});
it("refuses a label another profile already uses, case-insensitively", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
login("uuid-b", "b@example.com");
profiles.saveProfile("personal");
profiles.renameProfile("work", "Chính");
assert.throws(() => profiles.renameProfile("personal", "chính"), { code: "ELABELTAKEN" });
// Renaming a profile to the label it already has is not a conflict.
assert.equal(profiles.renameProfile("work", "Chính").label, "Chính");
});
it("refuses to rename a profile that does not exist", () => {
login("uuid-a", "a@example.com");
assert.throws(() => profiles.renameProfile("nope", "X"), { code: "ENOTFOUND" });
});
it("deletes a duplicate of the live account while another copy still holds it", () => {
// The state a pre-EDUPLICATE save could leave behind: two profiles, one
// account, both flagged active — and with a blanket active-guard, neither
// deletable. Cleanup has to be possible.
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
const dup = path.join(profiles.profilesDir(), "work-copy.json");
fs.copyFileSync(path.join(profiles.profilesDir(), "work.json"), dup);
assert.deepEqual(
profiles.listProfiles().items.map((p) => [p.name, p.active]),
[
["work", true],
["work-copy", true],
]
);
assert.deepEqual(profiles.deleteProfile("work-copy"), { ok: true, name: "work-copy" });
// The last copy of the live account is still protected.
assert.throws(() => profiles.deleteProfile("work"), { code: "EACTIVE" });
});
it("skips corrupt profile files instead of failing the whole list", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
fs.writeFileSync(path.join(profiles.profilesDir(), "broken.json"), "{not json");
const { items } = profiles.listProfiles();
assert.deepEqual(
items.map((p) => p.name),
["work"]
);
});
it("reports a corrupt live credentials file rather than guessing", () => {
fs.mkdirSync(HOME, { recursive: true });
fs.writeFileSync(CREDS, "{nope");
assert.throws(() => profiles.listProfiles(), { code: "EBADJSON" });
});
});