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.
268 lines
8.5 KiB
JavaScript
268 lines
8.5 KiB
JavaScript
/**
|
|
* @file cc-usage.test.js
|
|
* @description Tests for per-profile quota lookup.
|
|
*
|
|
* Two properties carry the whole feature. A profile's refreshed OAuth tokens
|
|
* must be written back — the exchange retires the refresh token it was given,
|
|
* so dropping the result leaves an account the user has to log into again. And
|
|
* a still-valid token must NOT be refreshed, because every refresh rewrites
|
|
* credentials on disk and the point of a read is to read.
|
|
*
|
|
* `fetch` is stubbed throughout: nothing here talks to Anthropic.
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, beforeEach, afterEach, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const os = require("node:os");
|
|
|
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-usage-"));
|
|
process.env.CLAUDE_HOME = TMP;
|
|
|
|
const profiles = require("../lib/cc-profiles");
|
|
const usage = require("../lib/cc-usage");
|
|
|
|
const realFetch = global.fetch;
|
|
let calls = [];
|
|
|
|
after(() => {
|
|
global.fetch = realFetch;
|
|
try {
|
|
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5 });
|
|
} catch {
|
|
/* best-effort temp cleanup */
|
|
}
|
|
});
|
|
|
|
function writeProfileFile(name, credentials) {
|
|
const dir = profiles.profilesDir();
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, `${name}.json`),
|
|
JSON.stringify({
|
|
name,
|
|
label: "Label kept",
|
|
savedAt: 1,
|
|
account: { emailAddress: "a@b.c" },
|
|
credentials,
|
|
})
|
|
);
|
|
}
|
|
|
|
/** Answer the token endpoint and the usage endpoint, recording both. */
|
|
function stubFetch({ usageStatus = 200, usageBody } = {}) {
|
|
global.fetch = async (url, init) => {
|
|
calls.push({ url: String(url), init });
|
|
if (String(url).includes("/v1/oauth/token")) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
text: async () =>
|
|
JSON.stringify({
|
|
access_token: "fresh-access",
|
|
refresh_token: "rotated-refresh",
|
|
expires_in: 28800,
|
|
}),
|
|
};
|
|
}
|
|
return {
|
|
ok: usageStatus === 200,
|
|
status: usageStatus,
|
|
json: async () =>
|
|
usageBody || {
|
|
five_hour: { utilization: 48.0, resets_at: "2026-09-17T13:00:00Z" },
|
|
seven_day: { utilization: 66.0, resets_at: "2026-09-21T08:00:00Z" },
|
|
seven_day_opus: null,
|
|
},
|
|
};
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
calls = [];
|
|
fs.rmSync(profiles.profilesDir(), { recursive: true, force: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
global.fetch = realFetch;
|
|
});
|
|
|
|
describe("profileUsage", () => {
|
|
it("reports the two windows, and only those", async () => {
|
|
writeProfileFile("live", {
|
|
accessToken: "still-good",
|
|
refreshToken: "r",
|
|
expiresAt: Date.now() + 3_600_000,
|
|
});
|
|
stubFetch();
|
|
|
|
const result = await usage.profileUsage("live");
|
|
|
|
assert.deepEqual(result.fiveHour, { utilization: 48, resetsAt: "2026-09-17T13:00:00Z" });
|
|
assert.deepEqual(result.sevenDay, { utilization: 66, resetsAt: "2026-09-21T08:00:00Z" });
|
|
// Nothing token-shaped may be in what a route is about to serialise.
|
|
assert.equal(JSON.stringify(result).includes("still-good"), false);
|
|
});
|
|
|
|
it("does not refresh a token that is still valid", async () => {
|
|
writeProfileFile("live", {
|
|
accessToken: "still-good",
|
|
refreshToken: "r",
|
|
expiresAt: Date.now() + 3_600_000,
|
|
});
|
|
stubFetch();
|
|
|
|
const result = await usage.profileUsage("live");
|
|
|
|
assert.equal(result.refreshed, false);
|
|
assert.equal(
|
|
calls.some((c) => c.url.includes("/v1/oauth/token")),
|
|
false,
|
|
"a read must not rewrite credentials on disk"
|
|
);
|
|
assert.match(calls[0].init.headers.Authorization, /still-good/);
|
|
});
|
|
|
|
it("refreshes an expired token and WRITES THE ROTATED PAIR BACK", async () => {
|
|
// The exchange retires the refresh token it was handed. A refresh whose
|
|
// result is not persisted leaves the profile holding a dead credential —
|
|
// the account could never be activated again without a fresh login.
|
|
writeProfileFile("stale", {
|
|
accessToken: "expired",
|
|
refreshToken: "original-refresh",
|
|
expiresAt: Date.now() - 1000,
|
|
subscriptionType: "team",
|
|
});
|
|
stubFetch();
|
|
|
|
const result = await usage.profileUsage("stale");
|
|
|
|
assert.equal(result.refreshed, true);
|
|
const stored = profiles.readProfile("stale");
|
|
assert.equal(stored.credentials.accessToken, "fresh-access");
|
|
assert.equal(stored.credentials.refreshToken, "rotated-refresh");
|
|
assert.ok(stored.credentials.expiresAt > Date.now());
|
|
// Everything else about the profile survives the rewrite.
|
|
assert.equal(stored.label, "Label kept");
|
|
assert.equal(stored.credentials.subscriptionType, "team");
|
|
assert.equal(stored.account.emailAddress, "a@b.c");
|
|
});
|
|
|
|
it("retries once through a refresh when a token that looked valid is refused", async () => {
|
|
writeProfileFile("revoked", {
|
|
accessToken: "revoked-but-unexpired",
|
|
refreshToken: "original-refresh",
|
|
expiresAt: Date.now() + 3_600_000,
|
|
});
|
|
let first = true;
|
|
global.fetch = async (url) => {
|
|
calls.push({ url: String(url) });
|
|
if (String(url).includes("/v1/oauth/token")) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
text: async () =>
|
|
JSON.stringify({ access_token: "fresh", refresh_token: "rot", expires_in: 100 }),
|
|
};
|
|
}
|
|
if (first) {
|
|
first = false;
|
|
return { ok: false, status: 401, json: async () => ({}) };
|
|
}
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ five_hour: { utilization: 1, resets_at: null }, seven_day: null }),
|
|
};
|
|
};
|
|
|
|
const result = await usage.profileUsage("revoked");
|
|
|
|
assert.equal(result.refreshed, true);
|
|
assert.deepEqual(result.fiveHour, { utilization: 1, resetsAt: null });
|
|
});
|
|
|
|
it("refuses a profile with no refresh token instead of half-answering", async () => {
|
|
writeProfileFile("tokenless", { accessToken: "expired", expiresAt: Date.now() - 1000 });
|
|
stubFetch();
|
|
|
|
await assert.rejects(
|
|
() => usage.profileUsage("tokenless"),
|
|
(e) => e.code === "ENOREFRESH"
|
|
);
|
|
});
|
|
|
|
it("uses the CLI's own token for the account that is logged in, not the saved copy", async () => {
|
|
// The saved copy of the LIVE account is the one set of credentials certain
|
|
// to fail: the CLI has refreshed since, and that retired the refresh token
|
|
// sitting in the profile file. Refreshing it here would also rotate the
|
|
// token the user's terminal is using.
|
|
const account = { accountUuid: "uuid-live", emailAddress: "a@b.c" };
|
|
fs.mkdirSync(profiles.profilesDir(), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(profiles.profilesDir(), "mine.json"),
|
|
JSON.stringify({
|
|
name: "mine",
|
|
account,
|
|
credentials: { accessToken: "retired", refreshToken: "retired-r", expiresAt: 1 },
|
|
})
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(TMP, ".credentials.json"),
|
|
JSON.stringify({
|
|
claudeAiOauth: {
|
|
accessToken: "live-token",
|
|
refreshToken: "live-r",
|
|
expiresAt: Date.now() + 3_600_000,
|
|
},
|
|
})
|
|
);
|
|
fs.writeFileSync(path.join(TMP, ".claude.json"), JSON.stringify({ oauthAccount: account }));
|
|
stubFetch();
|
|
|
|
const result = await usage.profileUsage("mine");
|
|
|
|
assert.equal(result.refreshed, false);
|
|
assert.match(calls[0].init.headers.Authorization, /live-token/);
|
|
assert.equal(
|
|
calls.some((c) => c.url.includes("/v1/oauth/token")),
|
|
false,
|
|
"refreshing the live account would log the user's own terminal out"
|
|
);
|
|
fs.rmSync(path.join(TMP, ".credentials.json"));
|
|
fs.rmSync(path.join(TMP, ".claude.json"));
|
|
});
|
|
|
|
it("names a spent refresh token instead of echoing the upstream 400", async () => {
|
|
writeProfileFile("superseded", {
|
|
accessToken: "expired",
|
|
refreshToken: "already-rotated",
|
|
expiresAt: Date.now() - 1000,
|
|
});
|
|
global.fetch = async (url) => {
|
|
calls.push({ url: String(url) });
|
|
return {
|
|
ok: false,
|
|
status: 400,
|
|
text: async () => '{"error": "invalid_grant", "error_description": "not found"}',
|
|
};
|
|
};
|
|
|
|
await assert.rejects(
|
|
() => usage.profileUsage("superseded"),
|
|
(e) => e.code === "ESTALE" && /save the profile again/.test(e.message)
|
|
);
|
|
});
|
|
|
|
it("reports an unknown profile as ENOTFOUND", async () => {
|
|
stubFetch();
|
|
await assert.rejects(
|
|
() => usage.profileUsage("nope"),
|
|
(e) => e.code === "ENOTFOUND"
|
|
);
|
|
});
|
|
});
|