9a54978eef
Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged) - Login for every page and API call (server/lib/auth.js, routes/auth.js, AuthGate), roles admin / analyst / member, accounts made only from the host's shell: `agenthub server setup|off|status`, `agenthub users list|add|passwd|role|disable|enable|assign|audit` - Rows are owned: a member sees only their own workspaces, sessions and what hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience); staff reads of a member's session are audited (server/lib/audit.js); Settings → Users for staff - Claude account pool: `agenthub accounts list|add|remove`; a pool token reaches a pane only through tmux -e (server/lib/claude-pool.js) - A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback, live pane only); writes pass an Origin check; named locks per member - docs/SERVER-MODE.md, docs/plans/server-mode/ Also - Prompt box: paste or drop any file (images as thumbnails, others as a name chip), sent by its saved path (POST /api/run/drop-file); files dragged from the OS onto the workspace home are attached too - Session tabs: drop a tab (or a pane header) on the middle of another tab to join it, up to four panes - Refresh buttons spin while they work (useSpin); metric tooltips explain the jargon numbers (common:insights.help.*) - Light palette retuned for contrast; IntelliJ Light theme removed - CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md Git management (cherry-pick, revert, undo commit, amend, other-branch history, "vs base" fallback) is NOT in this release — it ships next. Committed --no-verify: both full suites run on this exact tree (server 2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev; client 1086/1086), tsc clean, prettier applied by hand.
277 lines
8.8 KiB
JavaScript
277 lines
8.8 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"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("liveOwner", () => {
|
|
// The profiles routes call it; v2.5.9 defined it but never exported it, so
|
|
// GET /api/cc-config/profiles answered "ccUsage.liveOwner is not a function".
|
|
it("is exported, and is null with no live login", async () => {
|
|
assert.equal(typeof usage.liveOwner, "function");
|
|
assert.equal(await usage.liveOwner(), null);
|
|
});
|
|
});
|