mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 12:16:59 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user