fix(bar): resolve pre-dev review findings (security gate, honesty, correctness)

Gate /api/bar/* behind the localhost-when-auth-disabled guard (single DRY
choke point) so native quota/tier/cost can't leak on a non-loopback bind with
auth disabled; add a guard test. Delete the dishonest maxRedirections test that
asserted the opposite of the production redirect hardening. Key per-account
today-cost on the local day (matching analytics) instead of UTC. Stop the
inner 429 retry in the Claude usage fetch so the outer cache + circuit breaker
honor Retry-After. Narrow the usage-transformer map type and fix stale
doc-comments; clarify the one-alert-per-reset-window quota rule; gitignore the
local demo scaffolding.
This commit is contained in:
Tam Nhu Tran
2026-06-09 18:04:31 -04:00
parent ce98a69830
commit 7d3a11a452
13 changed files with 385 additions and 81 deletions
+4
View File
@@ -62,3 +62,7 @@ tests/mocks/fixtures/*.js
tests/mocks/fixtures/*.d.ts
tests/mocks/fixtures/*.js.map
tests/mocks/fixtures/*.d.ts.map
# Bar demo scaffolding (local dev only)
_serve*.ts
_serve*.log
@@ -64,7 +64,7 @@ final class BarNotifier: NotificationDelivering {
return
}
// Already requested: post only when authorized; a denied state is a no-op.
// Already requested: post when authorized or still-unknown; a denied state is a no-op.
if authState == .authorized || authState == .unknown {
post(notification, on: center)
}
@@ -164,7 +164,7 @@ struct BarSubscriptionCard: View {
/// Terse window label for the bar list, at most 4-5 chars:
/// five_hour "5h"
/// seven_day "wk"
/// seven_day_opus "Son" (sic this is the Opus sub-budget inside the week)
/// seven_day_opus "Opus"
/// seven_day_sonnet "Son"
///
/// Fall back to the backend-supplied label truncated to 5 chars so unknown
@@ -147,6 +147,11 @@ public enum BarAlertEngine {
let name = row.displayName ?? row.provider
// (1) quotaRemainingBelow fire the SINGLE most-severe crossed level.
// One alert per reset window (anti-spam): the fired-key embeds the reset
// bucket (row.nextReset ?? "noreset" below), so once an account crosses a
// level the alert is suppressed for the rest of that window even if quota
// recovers and then drops again. It re-arms automatically when nextReset
// rolls to a new window.
if prefs.quotaEnabled, row.quotaStatus == "ok", let pct = row.quotaPercentage {
let remaining = Int(pct.rounded())
// levels sorted desc; the most-severe crossed level is the smallest L
@@ -622,6 +622,34 @@ describe('Claude Quota Fetcher', () => {
expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(60);
});
it('does NOT inner-retry on 429; returns retryable single-attempt result honoring Retry-After', async () => {
// Safety intent: 429 must NOT trigger an immediate, delay-free inner retry.
// The outer 10-min cache + circuit breaker honor Retry-After and bound total
// volume, so a single attempt is made and the retryable signal is surfaced.
createClaudeAccount('claude-429@example.com', {
access_token: 'rate-limited-token',
expired: '2099-01-01T00:00:00.000Z',
type: 'claude',
});
let attempt = 0;
global.fetch = mock(() => {
attempt += 1;
return Promise.resolve(
new Response('', { status: 429, headers: { 'Retry-After': '120' } })
);
}) as typeof fetch;
const result = await fetchClaudeQuota('claude-429@example.com');
expect(result.success).toBe(false);
// Single attempt — no inner retry burned on the 429.
expect(attempt).toBe(1);
expect(result.httpStatus).toBe(429);
expect(result.retryable).toBe(true);
expect(result.errorDetail).toBe('retry-after:120');
});
it('clears the request timeout before retrying a retryable HTTP error', async () => {
createClaudeAccount('claude-retry-timeout@example.com', {
access_token: 'retry-timeout-token',
+4 -4
View File
@@ -280,10 +280,10 @@ async function runClaudeUsageFetch(
// Surface the upstream status + Retry-After so an outer caller (the
// native collector's circuit-breaker) can honor backoff guidance.
const retryAfter = response.headers.get('retry-after');
if (
attempt < CLAUDE_QUOTA_MAX_ATTEMPTS &&
(response.status === 429 || response.status >= 500)
) {
// Do not inner-retry 429 with no delay; the outer cache + circuit breaker
// honor Retry-After and bound total volume. Inner retry stays for
// transient 5xx only.
if (attempt < CLAUDE_QUOTA_MAX_ATTEMPTS && response.status >= 500) {
clearTimeout(timeoutId);
continue;
}
+18
View File
@@ -44,6 +44,13 @@ export const apiRoutes = Router();
const REMOTE_WRITE_ACCESS_ERROR =
'Remote dashboard writes require localhost access when dashboard auth is disabled.';
// CCS Bar endpoints (/api/bar/*) expose the user's native quota, tier, and cost
// snapshot. Unlike the rest of the read API these are sensitive even on GET, so
// they are gated for ALL methods (not just mutations) by the same
// localhost-when-auth-disabled choke point.
const BAR_LOCAL_ACCESS_ERROR =
'CCS Bar endpoints require localhost access when dashboard auth is disabled.';
function isMutationMethod(method: string): boolean {
const normalized = method.toUpperCase();
return (
@@ -55,6 +62,17 @@ function isMutationMethod(method: string): boolean {
}
apiRoutes.use((req, res, next) => {
// /api/bar/* leaks native quota/tier/cost data; gate it for every method.
// This middleware runs before the '/bar' mount below, so req.path still
// carries the '/bar' prefix here.
// Exact segment match so a future sibling like '/barbaz' isn't accidentally gated.
if (req.path === '/bar' || req.path.startsWith('/bar/')) {
if (requireLocalAccessWhenAuthDisabled(req, res, BAR_LOCAL_ACCESS_ERROR)) {
next();
}
return;
}
if (!isMutationMethod(req.method)) {
next();
return;
+1 -1
View File
@@ -93,7 +93,7 @@ const SPARKLINE_DAYS = 30;
const TOP_MODELS_LIMIT = 5;
/** Local-time YYYY-MM-DD key for a Date (matches the user's calendar day). */
function localDayKey(d: Date): string {
export function localDayKey(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
@@ -62,7 +62,7 @@ function createHistoryDetail(
provider: string,
model: string,
detail: CliproxyRequestDetail,
accountMap?: Map<number | string, string>
accountMap?: Map<string, string>
): CliproxyUsageHistoryDetail {
const pricingProvider = normalizeUsageProvider(provider) ?? provider.trim().toLowerCase();
const inputTokens = detail.tokens?.input_tokens ?? 0;
@@ -204,12 +204,13 @@ function hasTrackedUsage(detail: CliproxyRequestDetail): boolean {
* when they still report tracked token usage that analytics can account for.
*
* @param accountMap Optional auth_index → account email/id map. When provided,
* each detail's `accountId` is resolved from auth_index; falls back to
* `detail.source` when the index is not in the map.
* each detail's `accountId` is resolved from String(auth_index). When the index
* is absent from the map, `accountId` is left undefined so getTodayCostByAccount
* buckets the cost under 'unknown' rather than mis-attributing it.
*/
export function extractCliproxyUsageHistoryDetails(
response: CliproxyUsageApiResponse,
accountMap?: Map<number | string, string>
accountMap?: Map<string, string>
): CliproxyUsageHistoryDetail[] {
const apis = response?.usage?.apis;
if (!apis) return [];
+4 -1
View File
@@ -484,6 +484,7 @@ export function aggregateSessionUsage(
// ============================================================================
import type { CliproxyUsageHistoryDetail } from './cliproxy-usage-transformer';
import { localDayKey } from './bar-analytics';
/**
* Compute per-account cost totals for a given calendar day.
@@ -498,7 +499,9 @@ export function getTodayCostByAccount(
details: CliproxyUsageHistoryDetail[],
today?: string
): Record<string, number> {
const dateKey = today ?? new Date().toISOString().slice(0, 10);
// Key on the LOCAL calendar day so a near-midnight record buckets into the
// same day the analytics panel shows (bar-analytics also keys on localDayKey).
const dateKey = today ?? localDayKey(new Date());
const result: Record<string, number> = {};
for (const detail of details) {
+35 -34
View File
@@ -45,9 +45,7 @@ function restoreConsole(): void {
let moduleSeq = 0;
async function loadHandleBarCommand() {
moduleSeq++;
const mod = await import(
`../../../src/commands/bar/index?test=${Date.now()}-${moduleSeq}`
);
const mod = await import(`../../../src/commands/bar/index?test=${Date.now()}-${moduleSeq}`);
return mod.handleBarCommand as (args: string[]) => Promise<void>;
}
@@ -242,7 +240,9 @@ describe('bar.json contract (launch subcommand)', () => {
// Mock dependencies injected into handleBarLaunch
const mockEnsureDashboard = async () => ({ port: 4242, baseUrl: 'http://127.0.0.1:4242' });
const mockOpenApp = async (_appPath: string) => { calls.push(`open:${_appPath}`); };
const mockOpenApp = async (_appPath: string) => {
calls.push(`open:${_appPath}`);
};
const mockGetCcsDir = () => ccsDir;
const { handleBarLaunch } = await loadLaunchSubcommand();
@@ -273,14 +273,16 @@ describe('bar.json contract (launch subcommand)', () => {
await handleBarLaunch([], {
ensureDashboard: async () => ({ port: 9000, baseUrl: 'http://127.0.0.1:9000' }),
openApp: async () => { /* noop */ },
openApp: async () => {
/* noop */
},
getCcsDir: () => ccsDir,
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
});
const barJson = JSON.parse(
fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')
) as { authMode: string };
const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as {
authMode: string;
};
expect(barJson.authMode).toBe('loopback');
});
@@ -295,7 +297,9 @@ describe('bar.json contract (launch subcommand)', () => {
await handleBarLaunch([], {
ensureDashboard: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }),
openApp: async () => { throw new Error('App not found'); },
openApp: async () => {
throw new Error('App not found');
},
getCcsDir: () => ccsDir,
appInstallPath: nonExistentApp,
});
@@ -313,7 +317,9 @@ describe('bar.json contract (launch subcommand)', () => {
await handleBarLaunch([], {
ensureDashboard: async () => ({ port: 3001, baseUrl: 'http://127.0.0.1:3001' }),
openApp: async () => { throw new Error('open failed'); },
openApp: async () => {
throw new Error('open failed');
},
getCcsDir: () => ccsDir,
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
});
@@ -330,8 +336,12 @@ describe('bar.json contract (launch subcommand)', () => {
const { handleBarLaunch } = await loadLaunchSubcommand();
await handleBarLaunch([], {
ensureDashboard: async () => { throw new Error('port busy'); },
openApp: async () => { /* noop */ },
ensureDashboard: async () => {
throw new Error('port busy');
},
openApp: async () => {
/* noop */
},
getCcsDir: () => ccsDir,
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
});
@@ -469,8 +479,12 @@ describe('bar install subcommand', () => {
await expect(
handleBarInstall([], {
fetchReleaseAsset: async () => { throw new Error('network error'); },
downloadAndExtract: async () => { /* noop */ },
fetchReleaseAsset: async () => {
throw new Error('network error');
},
downloadAndExtract: async () => {
/* noop */
},
verifyCompat: async () => ({ version: FAKE_VERSION, compatible: true }),
getCcsDir: () => path.join(tempHome, '.ccs'),
getAppsDir: () => appsDir,
@@ -527,17 +541,6 @@ describe('bar install: redirect-following download (#8)', () => {
expect(allOutput).not.toMatch(/\[X\]/);
expect(allOutput).toMatch(/\[OK\]/);
});
it('production defaultDownloadAndExtract passes maxRedirections:5 to undici (structural test)', async () => {
// This test verifies the production code path uses maxRedirections.
// We test validateDownloadUrl directly (exported) and confirm the URL shape expected
// by defaultDownloadAndExtract is accepted for github.com and githubusercontent.com.
const { validateDownloadUrl } = await loadInstallSubcommand();
// Both the initial github.com URL and the 302 target must pass host validation.
expect(() => validateDownloadUrl(REDIRECT_URL)).not.toThrow();
expect(() => validateDownloadUrl(FINAL_URL)).not.toThrow();
});
});
// ---------------------------------------------------------------------------
@@ -602,9 +605,7 @@ describe('bar install: host allowlist validation (#9)', () => {
it('validateDownloadUrl accepts github.com URLs', async () => {
const { validateDownloadUrl } = await loadInstallSubcommand();
expect(() =>
validateDownloadUrl(
'https://github.com/kaitranntt/ccs/releases/download/tag/CCS-Bar.app.zip'
)
validateDownloadUrl('https://github.com/kaitranntt/ccs/releases/download/tag/CCS-Bar.app.zip')
).not.toThrow();
});
@@ -631,17 +632,17 @@ describe('bar install: host allowlist validation (#9)', () => {
it('validateDownloadUrl rejects untrusted hostnames', async () => {
const { validateDownloadUrl } = await loadInstallSubcommand();
expect(() =>
validateDownloadUrl('https://evil.example.com/CCS-Bar.app.zip')
).toThrow(/allowlist|trusted/i);
expect(() => validateDownloadUrl('https://evil.example.com/CCS-Bar.app.zip')).toThrow(
/allowlist|trusted/i
);
});
it('validateDownloadUrl rejects a URL that looks like github but is not', async () => {
const { validateDownloadUrl } = await loadInstallSubcommand();
// A domain that ends in github.com.attacker.com must be rejected
expect(() =>
validateDownloadUrl('https://github.com.attacker.com/download/file.zip')
).toThrow(/allowlist|trusted/i);
expect(() => validateDownloadUrl('https://github.com.attacker.com/download/file.zip')).toThrow(
/allowlist|trusted/i
);
});
it('handleBarInstall surfaces a clear error for non-github download URLs', async () => {
@@ -0,0 +1,187 @@
/**
* Security gate for /api/bar/* — these endpoints expose the user's native
* quota, tier, and cost snapshot, so unlike the rest of the read API they must
* be refused for non-loopback callers when dashboard auth is disabled.
*
* The gate lives in the top-level apiRoutes middleware (one choke point), so we
* exercise it by mounting the real apiRoutes and toggling auth via env, mirroring
* api-routes-remote-write-guard.test.ts.
*/
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import bcrypt from 'bcrypt';
import express from 'express';
import type { Server } from 'http';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { apiRoutes } from '../../../src/web-server/routes';
import {
authMiddleware,
createSessionMiddleware,
} from '../../../src/web-server/middleware/auth-middleware';
const BAR_LOCAL_ACCESS_ERROR =
'CCS Bar endpoints require localhost access when dashboard auth is disabled.';
describe('api-routes /api/bar/* local-access guard', () => {
let server: Server;
let baseUrl = '';
let forcedRemoteAddress = '127.0.0.1';
let tempHome = '';
let originalDashboardAuthEnabled: string | undefined;
let originalCcsHome: string | undefined;
let originalCodexHome: string | undefined;
beforeAll(async () => {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
Object.defineProperty(req.socket, 'remoteAddress', {
value: forcedRemoteAddress,
configurable: true,
});
next();
});
app.use('/api', apiRoutes);
await new Promise<void>((resolve, reject) => {
server = app.listen(0, '127.0.0.1');
server.once('error', reject);
server.once('listening', () => resolve());
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
originalCcsHome = process.env.CCS_HOME;
originalCodexHome = process.env.CODEX_HOME;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-api-routes-bar-guard-'));
process.env.CCS_HOME = tempHome;
process.env.CODEX_HOME = path.join(tempHome, '.codex');
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false';
forcedRemoteAddress = '192.168.2.50';
});
afterEach(() => {
if (originalDashboardAuthEnabled !== undefined) {
process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled;
} else {
delete process.env.CCS_DASHBOARD_AUTH_ENABLED;
}
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (originalCodexHome !== undefined) {
process.env.CODEX_HOME = originalCodexHome;
} else {
delete process.env.CODEX_HOME;
}
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
tempHome = '';
}
});
it('rejects a non-loopback GET /api/bar/summary when dashboard auth is disabled', async () => {
const response = await fetch(`${baseUrl}/api/bar/summary`);
// 403 from the gate means the bar handler never ran (no quota/cost data
// loaded) — the body is the gate error, not a summary array.
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ error: BAR_LOCAL_ACCESS_ERROR });
});
it('rejects a non-loopback GET /api/bar/analytics when dashboard auth is disabled', async () => {
const response = await fetch(`${baseUrl}/api/bar/analytics`);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ error: BAR_LOCAL_ACCESS_ERROR });
});
it('allows a loopback GET /api/bar/summary when dashboard auth is disabled', async () => {
forcedRemoteAddress = '127.0.0.1';
const response = await fetch(`${baseUrl}/api/bar/summary`, {
headers: { Host: '127.0.0.1' },
});
// Loopback passes the gate; the real handler degrades gracefully against an
// empty temp CCS_HOME and returns a 200 array.
expect(response.status).toBe(200);
expect(Array.isArray(await response.json())).toBe(true);
});
it('allows a non-loopback GET /api/bar/summary when dashboard auth is ENABLED', async () => {
// With auth enabled the helper returns true regardless of peer address, so
// an authenticated remote dashboard keeps working. We log in to get a session
// cookie, then a remote (non-loopback) GET must pass.
const password = 'testpassword123';
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true';
process.env.CCS_DASHBOARD_USERNAME = 'admin';
process.env.CCS_DASHBOARD_PASSWORD_HASH = await bcrypt.hash(password, 4);
const authApp = express();
authApp.use(express.json());
authApp.use((req, _res, next) => {
Object.defineProperty(req.socket, 'remoteAddress', {
value: '203.0.113.7',
configurable: true,
});
next();
});
authApp.use(createSessionMiddleware());
authApp.use(authMiddleware);
authApp.use('/api', apiRoutes);
const authServer = await new Promise<Server>((resolve, reject) => {
const instance = authApp.listen(0, '127.0.0.1');
instance.once('error', reject);
instance.once('listening', () => resolve(instance));
});
try {
const address = authServer.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve auth-enabled test server port');
}
const authBaseUrl = `http://127.0.0.1:${address.port}`;
const loginResponse = await fetch(`${authBaseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password }),
});
const cookie = loginResponse.headers.get('set-cookie');
expect(loginResponse.status).toBe(200);
expect(cookie).toBeTruthy();
const response = await fetch(`${authBaseUrl}/api/bar/summary`, {
headers: { Cookie: cookie as string },
});
// Not the gate's 403 — auth-enabled bypasses the localhost requirement.
expect(response.status).toBe(200);
expect(Array.isArray(await response.json())).toBe(true);
} finally {
await new Promise<void>((resolve) => authServer.close(() => resolve()));
delete process.env.CCS_DASHBOARD_USERNAME;
delete process.env.CCS_DASHBOARD_PASSWORD_HASH;
}
}, 15000);
});
@@ -10,24 +10,38 @@
*/
import { describe, expect, it, beforeEach, afterEach } from 'bun:test';
import type { CliproxyUsageApiResponse, CliproxyManagementAuthFile } from '../../../../src/cliproxy/services/stats-fetcher';
import type {
CliproxyUsageApiResponse,
CliproxyManagementAuthFile,
} from '../../../../src/cliproxy/services/stats-fetcher';
// ============================================================================
// HELPERS & FIXTURES
// ============================================================================
const TODAY = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
// Local calendar day (matches production getTodayCostByAccount, which keys on
// localDayKey — not a UTC ISO slice). Fixture timestamps below use the SAME
// local day with no trailing Z so they bucket consistently with production.
function localDay(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
const TODAY = localDay(new Date()); // YYYY-MM-DD, local
function makeResponse(entries: Array<{
provider: string;
model: string;
auth_index: number;
source: string;
timestamp: string;
input: number;
output: number;
failed?: boolean;
}>): CliproxyUsageApiResponse {
function makeResponse(
entries: Array<{
provider: string;
model: string;
auth_index: number;
source: string;
timestamp: string;
input: number;
output: number;
failed?: boolean;
}>
): CliproxyUsageApiResponse {
const apis: CliproxyUsageApiResponse['usage'] = { apis: {} };
for (const e of entries) {
if (!apis.apis![e.provider]) {
@@ -100,7 +114,9 @@ const authFileMap: Map<number | string, string> = new Map([
describe('extractCliproxyUsageHistoryDetails with accountMap', () => {
it('populates accountId from accountMap when auth_index is present', async () => {
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const { extractCliproxyUsageHistoryDetails } = await import(
'../../../../src/web-server/usage/cliproxy-usage-transformer'
);
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
@@ -108,14 +124,16 @@ describe('extractCliproxyUsageHistoryDetails with accountMap', () => {
const bobDetails = details.filter((d) => d.accountId === 'bob@example.com');
expect(aliceDetails).toHaveLength(2); // auth_index 0 appears twice
expect(bobDetails).toHaveLength(1); // auth_index 1 appears once
expect(bobDetails).toHaveLength(1); // auth_index 1 appears once
});
it('leaves accountId undefined when auth_index is not in accountMap (no source fallback)', async () => {
// Fix #7/#13/#15: detail.source is a CLIProxy source label, not an email.
// Using it as a cost key caused mis-attribution. When auth_index is absent from the
// map, accountId must be undefined so getTodayCostByAccount buckets under 'unknown'.
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const { extractCliproxyUsageHistoryDetails } = await import(
'../../../../src/web-server/usage/cliproxy-usage-transformer'
);
// Use string key matching buildAuthIndexToAccountMap's String(auth_index) output
const partialMap: Map<number | string, string> = new Map([['0', 'alice@example.com']]);
@@ -133,7 +151,9 @@ describe('extractCliproxyUsageHistoryDetails with accountMap', () => {
});
it('does not include accountId when no accountMap is provided (backward compat)', async () => {
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const { extractCliproxyUsageHistoryDetails } = await import(
'../../../../src/web-server/usage/cliproxy-usage-transformer'
);
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse);
@@ -143,7 +163,9 @@ describe('extractCliproxyUsageHistoryDetails with accountMap', () => {
});
it('does not expose source or auth_index on returned history details', async () => {
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const { extractCliproxyUsageHistoryDetails } = await import(
'../../../../src/web-server/usage/cliproxy-usage-transformer'
);
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
@@ -160,7 +182,9 @@ describe('extractCliproxyUsageHistoryDetails with accountMap', () => {
describe('CliproxyUsageHistoryDetail type', () => {
it('allows accountId as optional string field', async () => {
const { normalizeCliproxyUsageHistoryDetail } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const { normalizeCliproxyUsageHistoryDetail } = await import(
'../../../../src/web-server/usage/cliproxy-usage-transformer'
);
const withAccount = normalizeCliproxyUsageHistoryDetail({
model: 'claude-sonnet-4-5',
@@ -179,7 +203,9 @@ describe('CliproxyUsageHistoryDetail type', () => {
});
it('normalizes detail without accountId (remains undefined)', async () => {
const { normalizeCliproxyUsageHistoryDetail } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const { normalizeCliproxyUsageHistoryDetail } = await import(
'../../../../src/web-server/usage/cliproxy-usage-transformer'
);
const noAccount = normalizeCliproxyUsageHistoryDetail({
model: 'claude-sonnet-4-5',
@@ -203,12 +229,16 @@ describe('CliproxyUsageHistoryDetail type', () => {
describe('getTodayCostByAccount', () => {
it('returns per-account cost totals for today', async () => {
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
const { getTodayCostByAccount } = await import(
'../../../../src/web-server/usage/data-aggregator'
);
const details = [];
// Simulate alice's two requests today
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const { extractCliproxyUsageHistoryDetails } = await import(
'../../../../src/web-server/usage/cliproxy-usage-transformer'
);
const todayDetails = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
const result = getTodayCostByAccount(todayDetails, TODAY);
@@ -225,7 +255,9 @@ describe('getTodayCostByAccount', () => {
});
it('returns empty object when no details exist for today', async () => {
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
const { getTodayCostByAccount } = await import(
'../../../../src/web-server/usage/data-aggregator'
);
const result = getTodayCostByAccount([], TODAY);
@@ -233,8 +265,12 @@ describe('getTodayCostByAccount', () => {
});
it('filters out details from days other than today', async () => {
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const { getTodayCostByAccount } = await import(
'../../../../src/web-server/usage/data-aggregator'
);
const { extractCliproxyUsageHistoryDetails } = await import(
'../../../../src/web-server/usage/cliproxy-usage-transformer'
);
const yesterdayResponse = makeResponse([
{
@@ -254,8 +290,12 @@ describe('getTodayCostByAccount', () => {
});
it('accumulates costs across multiple details for the same account', async () => {
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const { getTodayCostByAccount } = await import(
'../../../../src/web-server/usage/data-aggregator'
);
const { extractCliproxyUsageHistoryDetails } = await import(
'../../../../src/web-server/usage/cliproxy-usage-transformer'
);
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
@@ -271,8 +311,12 @@ describe('getTodayCostByAccount', () => {
it('details without accountId are grouped under the "unknown" key', async () => {
// Fix #7/#13/#15: when no accountMap is provided, accountId is undefined on all details.
// getTodayCostByAccount buckets these under 'unknown' — not under detail.source.
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const { getTodayCostByAccount } = await import(
'../../../../src/web-server/usage/data-aggregator'
);
const { extractCliproxyUsageHistoryDetails } = await import(
'../../../../src/web-server/usage/cliproxy-usage-transformer'
);
// no accountMap — accountId will be undefined on all details
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse);
@@ -293,7 +337,9 @@ describe('getTodayCostByAccount', () => {
describe('backward compatibility: profile-based aggregation unaffected', () => {
it('transformCliproxyToDailyUsage works without accountMap', async () => {
const { transformCliproxyToDailyUsage } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const { transformCliproxyToDailyUsage } = await import(
'../../../../src/web-server/usage/cliproxy-usage-transformer'
);
const daily = transformCliproxyToDailyUsage(twoAccountResponse);
@@ -303,7 +349,8 @@ describe('backward compatibility: profile-based aggregation unaffected', () => {
});
it('buildCliproxyUsageHistoryAggregates preserves existing shape', async () => {
const { buildCliproxyUsageHistoryAggregates, extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const { buildCliproxyUsageHistoryAggregates, extractCliproxyUsageHistoryDetails } =
await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse);
const { daily, hourly, monthly } = buildCliproxyUsageHistoryAggregates(details);
@@ -338,7 +385,9 @@ describe('backward compatibility: profile-based aggregation unaffected', () => {
describe('buildAuthIndexToAccountMap', () => {
it('builds map from auth files with auth_index and email', async () => {
const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher');
const { buildAuthIndexToAccountMap } = await import(
'../../../../src/cliproxy/services/stats-fetcher'
);
const authFiles: CliproxyManagementAuthFile[] = [
{ auth_index: 0, provider: 'anthropic', email: 'alice@example.com' },
@@ -354,7 +403,9 @@ describe('buildAuthIndexToAccountMap', () => {
});
it('skips entries missing auth_index', async () => {
const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher');
const { buildAuthIndexToAccountMap } = await import(
'../../../../src/cliproxy/services/stats-fetcher'
);
const authFiles: CliproxyManagementAuthFile[] = [
{ provider: 'anthropic', email: 'nobody@example.com' }, // no auth_index
@@ -368,7 +419,9 @@ describe('buildAuthIndexToAccountMap', () => {
});
it('skips entries missing email', async () => {
const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher');
const { buildAuthIndexToAccountMap } = await import(
'../../../../src/cliproxy/services/stats-fetcher'
);
const authFiles: CliproxyManagementAuthFile[] = [
{ auth_index: 4, provider: 'anthropic' }, // no email
@@ -382,7 +435,9 @@ describe('buildAuthIndexToAccountMap', () => {
});
it('returns empty map for empty auth files array', async () => {
const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher');
const { buildAuthIndexToAccountMap } = await import(
'../../../../src/cliproxy/services/stats-fetcher'
);
const map = buildAuthIndexToAccountMap([]);
@@ -390,7 +445,9 @@ describe('buildAuthIndexToAccountMap', () => {
});
it('handles numeric and string auth_index keys consistently', async () => {
const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher');
const { buildAuthIndexToAccountMap } = await import(
'../../../../src/cliproxy/services/stats-fetcher'
);
const authFiles: CliproxyManagementAuthFile[] = [
{ auth_index: 7, provider: 'anthropic', email: 'alice@example.com' },