/** * @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ĩ */ 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" }); }); });