1aee080fca
The quota check failed with `invalid_grant` for the account the user was logged into - the one profile they would try first. An OAuth refresh token is single use, so every refresh Claude Code performs on its own account retires the copy sitting in that account's saved profile: the stored credentials of the LIVE account are the one set guaranteed to be rejected, however recently the profile was saved. Confirmed on a real install - the saved profile held a refresh token three rotations behind `.credentials.json`. `cc-profiles.liveCredentialsFor` now matches a profile's accountUuid against the live credentials and, on a hit, the usage call uses what the CLI is holding. That token is never refreshed and that file is never written: rotating it would log the user out of their own terminal. An expired live token answers ELIVEEXPIRED - using Claude Code once makes the CLI refresh it - and a genuinely spent refresh token is now reported as ESTALE with what to do about it, instead of echoing Anthropic's raw 400. Opening the Profiles modal also asks for every saved account at once, rather than one button per row: comparing accounts is the only reason the panel exists. The guard against rewriting credentials over and over is no longer "one row at a time" but the rules above - the live account costs a plain read, and any other profile refreshes only once its token has actually expired, which then leaves it valid for hours of further opens. Each row resolves on its own, so a superseded account still lets its neighbours report.
147 lines
4.6 KiB
TypeScript
147 lines
4.6 KiB
TypeScript
/**
|
|
* @file Covers the quota lines in the Claude Code auth-profile modal: opening
|
|
* it asks for EVERY saved account's quota, not one row at a time.
|
|
*
|
|
* The one-at-a-time version made the user click through their own accounts to
|
|
* answer the only question the panel exists for — which account still has room.
|
|
* The other half is that each row fails on its own: an account whose saved
|
|
* login has been superseded must not stop its neighbours from reporting.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
import { render, screen, act } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
|
|
const ccConfigMock = vi.hoisted(() => {
|
|
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
|
const window = (utilization: number) => ({
|
|
utilization,
|
|
resetsAt: "2026-09-18T08:00:00Z",
|
|
});
|
|
return {
|
|
window,
|
|
overview: r({
|
|
roots: {
|
|
claudeHome: "/home/.claude",
|
|
projectClaudeDir: "/repo/.claude",
|
|
projectRoot: "/repo",
|
|
},
|
|
counts: {
|
|
skills: { user: 0, project: 0 },
|
|
agents: { user: 0, project: 0 },
|
|
commands: { user: 0, project: 0 },
|
|
outputStyles: { user: 0, project: 0 },
|
|
mcpServers: { user: 0, project: 0 },
|
|
hooks: {},
|
|
plugins: 0,
|
|
marketplaces: 0,
|
|
keybindings: 0,
|
|
settingsFiles: 0,
|
|
memory: 0,
|
|
},
|
|
}),
|
|
skills: r({ items: [] }),
|
|
agents: r({ items: [] }),
|
|
commands: r({ items: [] }),
|
|
outputStyles: r({ items: [] }),
|
|
memory: r({ items: [] }),
|
|
plugins: r({ manifestPath: "", manifestExists: false, plugins: [] }),
|
|
marketplaces: r({ marketplaces: [] }),
|
|
mcp: r({ mcp: [] }),
|
|
hooks: r({ hooks: [] }),
|
|
settings: r({ sources: [] }),
|
|
keybindings: r({ groups: [] }),
|
|
statusline: r({}),
|
|
hookScripts: r({ items: [] }),
|
|
backups: r({ items: [] }),
|
|
file: r({ ok: true, file: "", size: 0, mtime: 0, truncated: false, text: "" }),
|
|
pluginsAvailable: r({ installed: [], available: [] }),
|
|
updateAllStatus: r({ batch: null }),
|
|
// The modal's own list: two saved accounts, one of them live.
|
|
profiles: r({
|
|
items: [
|
|
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
|
|
{ name: "spare", label: "Spare", active: false, accountUuid: "u2", email: "d@e.f" },
|
|
],
|
|
active: { accountUuid: "u1", email: "a@b.c" },
|
|
}),
|
|
profileUsage: vi.fn().mockImplementation(async (name: string) => {
|
|
if (name === "spare") {
|
|
throw new Error("this profile's saved login has been superseded");
|
|
}
|
|
return {
|
|
name,
|
|
refreshed: false,
|
|
fiveHour: window(8),
|
|
sevenDay: window(70),
|
|
checkedAt: "2026-09-17T16:00:00Z",
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
|
|
vi.mock("../../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>();
|
|
return {
|
|
...actual,
|
|
api: { ...(actual.api as Record<string, unknown>), ccConfig: ccConfigMock },
|
|
};
|
|
});
|
|
|
|
vi.mock("../../lib/eventBus", () => ({
|
|
eventBus: {
|
|
subscribe: () => () => {},
|
|
publish: () => {},
|
|
onConnection: () => () => {},
|
|
connected: true,
|
|
setConnected: () => {},
|
|
},
|
|
}));
|
|
|
|
import { CcConfig } from "../CcConfig";
|
|
|
|
async function settle() {
|
|
await act(async () => {
|
|
await new Promise((res) => setTimeout(res, 0));
|
|
await new Promise((res) => setTimeout(res, 0));
|
|
});
|
|
}
|
|
|
|
async function openProfiles() {
|
|
render(<CcConfig />);
|
|
await settle();
|
|
await userEvent.click(screen.getByRole("button", { name: /profile/i }));
|
|
await settle();
|
|
await settle();
|
|
}
|
|
|
|
afterEach(() => vi.clearAllMocks());
|
|
|
|
describe("CcConfig — auth profile quotas", () => {
|
|
it("asks for every saved account's quota when the modal opens", async () => {
|
|
await openProfiles();
|
|
|
|
expect(ccConfigMock.profileUsage).toHaveBeenCalledWith("work");
|
|
expect(ccConfigMock.profileUsage).toHaveBeenCalledWith("spare");
|
|
expect(await screen.findByText("8%")).toBeInTheDocument();
|
|
expect(await screen.findByText("70%")).toBeInTheDocument();
|
|
});
|
|
|
|
it("reports the account that failed without costing the others their numbers", async () => {
|
|
await openProfiles();
|
|
|
|
expect(await screen.findByText(/saved login has been superseded/i)).toBeInTheDocument();
|
|
expect(screen.getByText("8%")).toBeInTheDocument();
|
|
});
|
|
|
|
it("asks once per profile, however many times the panel re-renders", async () => {
|
|
await openProfiles();
|
|
await settle();
|
|
await settle();
|
|
|
|
const asked = ccConfigMock.profileUsage.mock.calls.filter((c) => c[0] === "work");
|
|
expect(asked).toHaveLength(1);
|
|
});
|
|
});
|