mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 04:17:54 +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/*.d.ts
|
||||||
tests/mocks/fixtures/*.js.map
|
tests/mocks/fixtures/*.js.map
|
||||||
tests/mocks/fixtures/*.d.ts.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
|
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 {
|
if authState == .authorized || authState == .unknown {
|
||||||
post(notification, on: center)
|
post(notification, on: center)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ struct BarSubscriptionCard: View {
|
|||||||
/// Terse window label for the bar list, at most 4-5 chars:
|
/// Terse window label for the bar list, at most 4-5 chars:
|
||||||
/// five_hour → "5h"
|
/// five_hour → "5h"
|
||||||
/// seven_day → "wk"
|
/// 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"
|
/// seven_day_sonnet → "Son"
|
||||||
///
|
///
|
||||||
/// Fall back to the backend-supplied label truncated to 5 chars so unknown
|
/// 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
|
let name = row.displayName ?? row.provider
|
||||||
|
|
||||||
// (1) quotaRemainingBelow — fire the SINGLE most-severe crossed level.
|
// (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 {
|
if prefs.quotaEnabled, row.quotaStatus == "ok", let pct = row.quotaPercentage {
|
||||||
let remaining = Int(pct.rounded())
|
let remaining = Int(pct.rounded())
|
||||||
// levels sorted desc; the most-severe crossed level is the smallest L
|
// 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);
|
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 () => {
|
it('clears the request timeout before retrying a retryable HTTP error', async () => {
|
||||||
createClaudeAccount('claude-retry-timeout@example.com', {
|
createClaudeAccount('claude-retry-timeout@example.com', {
|
||||||
access_token: 'retry-timeout-token',
|
access_token: 'retry-timeout-token',
|
||||||
|
|||||||
@@ -280,10 +280,10 @@ async function runClaudeUsageFetch(
|
|||||||
// Surface the upstream status + Retry-After so an outer caller (the
|
// Surface the upstream status + Retry-After so an outer caller (the
|
||||||
// native collector's circuit-breaker) can honor backoff guidance.
|
// native collector's circuit-breaker) can honor backoff guidance.
|
||||||
const retryAfter = response.headers.get('retry-after');
|
const retryAfter = response.headers.get('retry-after');
|
||||||
if (
|
// Do not inner-retry 429 with no delay; the outer cache + circuit breaker
|
||||||
attempt < CLAUDE_QUOTA_MAX_ATTEMPTS &&
|
// honor Retry-After and bound total volume. Inner retry stays for
|
||||||
(response.status === 429 || response.status >= 500)
|
// transient 5xx only.
|
||||||
) {
|
if (attempt < CLAUDE_QUOTA_MAX_ATTEMPTS && response.status >= 500) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ export const apiRoutes = Router();
|
|||||||
const REMOTE_WRITE_ACCESS_ERROR =
|
const REMOTE_WRITE_ACCESS_ERROR =
|
||||||
'Remote dashboard writes require localhost access when dashboard auth is disabled.';
|
'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 {
|
function isMutationMethod(method: string): boolean {
|
||||||
const normalized = method.toUpperCase();
|
const normalized = method.toUpperCase();
|
||||||
return (
|
return (
|
||||||
@@ -55,6 +62,17 @@ function isMutationMethod(method: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apiRoutes.use((req, res, next) => {
|
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)) {
|
if (!isMutationMethod(req.method)) {
|
||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ const SPARKLINE_DAYS = 30;
|
|||||||
const TOP_MODELS_LIMIT = 5;
|
const TOP_MODELS_LIMIT = 5;
|
||||||
|
|
||||||
/** Local-time YYYY-MM-DD key for a Date (matches the user's calendar day). */
|
/** 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 y = d.getFullYear();
|
||||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
const day = String(d.getDate()).padStart(2, '0');
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function createHistoryDetail(
|
|||||||
provider: string,
|
provider: string,
|
||||||
model: string,
|
model: string,
|
||||||
detail: CliproxyRequestDetail,
|
detail: CliproxyRequestDetail,
|
||||||
accountMap?: Map<number | string, string>
|
accountMap?: Map<string, string>
|
||||||
): CliproxyUsageHistoryDetail {
|
): CliproxyUsageHistoryDetail {
|
||||||
const pricingProvider = normalizeUsageProvider(provider) ?? provider.trim().toLowerCase();
|
const pricingProvider = normalizeUsageProvider(provider) ?? provider.trim().toLowerCase();
|
||||||
const inputTokens = detail.tokens?.input_tokens ?? 0;
|
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.
|
* when they still report tracked token usage that analytics can account for.
|
||||||
*
|
*
|
||||||
* @param accountMap Optional auth_index → account email/id map. When provided,
|
* @param accountMap Optional auth_index → account email/id map. When provided,
|
||||||
* each detail's `accountId` is resolved from auth_index; falls back to
|
* each detail's `accountId` is resolved from String(auth_index). When the index
|
||||||
* `detail.source` when the index is not in the map.
|
* is absent from the map, `accountId` is left undefined so getTodayCostByAccount
|
||||||
|
* buckets the cost under 'unknown' rather than mis-attributing it.
|
||||||
*/
|
*/
|
||||||
export function extractCliproxyUsageHistoryDetails(
|
export function extractCliproxyUsageHistoryDetails(
|
||||||
response: CliproxyUsageApiResponse,
|
response: CliproxyUsageApiResponse,
|
||||||
accountMap?: Map<number | string, string>
|
accountMap?: Map<string, string>
|
||||||
): CliproxyUsageHistoryDetail[] {
|
): CliproxyUsageHistoryDetail[] {
|
||||||
const apis = response?.usage?.apis;
|
const apis = response?.usage?.apis;
|
||||||
if (!apis) return [];
|
if (!apis) return [];
|
||||||
|
|||||||
@@ -484,6 +484,7 @@ export function aggregateSessionUsage(
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import type { CliproxyUsageHistoryDetail } from './cliproxy-usage-transformer';
|
import type { CliproxyUsageHistoryDetail } from './cliproxy-usage-transformer';
|
||||||
|
import { localDayKey } from './bar-analytics';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute per-account cost totals for a given calendar day.
|
* Compute per-account cost totals for a given calendar day.
|
||||||
@@ -498,7 +499,9 @@ export function getTodayCostByAccount(
|
|||||||
details: CliproxyUsageHistoryDetail[],
|
details: CliproxyUsageHistoryDetail[],
|
||||||
today?: string
|
today?: string
|
||||||
): Record<string, number> {
|
): 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> = {};
|
const result: Record<string, number> = {};
|
||||||
|
|
||||||
for (const detail of details) {
|
for (const detail of details) {
|
||||||
|
|||||||
@@ -45,9 +45,7 @@ function restoreConsole(): void {
|
|||||||
let moduleSeq = 0;
|
let moduleSeq = 0;
|
||||||
async function loadHandleBarCommand() {
|
async function loadHandleBarCommand() {
|
||||||
moduleSeq++;
|
moduleSeq++;
|
||||||
const mod = await import(
|
const mod = await import(`../../../src/commands/bar/index?test=${Date.now()}-${moduleSeq}`);
|
||||||
`../../../src/commands/bar/index?test=${Date.now()}-${moduleSeq}`
|
|
||||||
);
|
|
||||||
return mod.handleBarCommand as (args: string[]) => Promise<void>;
|
return mod.handleBarCommand as (args: string[]) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +240,9 @@ describe('bar.json contract (launch subcommand)', () => {
|
|||||||
|
|
||||||
// Mock dependencies injected into handleBarLaunch
|
// Mock dependencies injected into handleBarLaunch
|
||||||
const mockEnsureDashboard = async () => ({ port: 4242, baseUrl: 'http://127.0.0.1:4242' });
|
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 mockGetCcsDir = () => ccsDir;
|
||||||
|
|
||||||
const { handleBarLaunch } = await loadLaunchSubcommand();
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
||||||
@@ -273,14 +273,16 @@ describe('bar.json contract (launch subcommand)', () => {
|
|||||||
|
|
||||||
await handleBarLaunch([], {
|
await handleBarLaunch([], {
|
||||||
ensureDashboard: async () => ({ port: 9000, baseUrl: 'http://127.0.0.1:9000' }),
|
ensureDashboard: async () => ({ port: 9000, baseUrl: 'http://127.0.0.1:9000' }),
|
||||||
openApp: async () => { /* noop */ },
|
openApp: async () => {
|
||||||
|
/* noop */
|
||||||
|
},
|
||||||
getCcsDir: () => ccsDir,
|
getCcsDir: () => ccsDir,
|
||||||
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const barJson = JSON.parse(
|
const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as {
|
||||||
fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')
|
authMode: string;
|
||||||
) as { authMode: string };
|
};
|
||||||
expect(barJson.authMode).toBe('loopback');
|
expect(barJson.authMode).toBe('loopback');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -295,7 +297,9 @@ describe('bar.json contract (launch subcommand)', () => {
|
|||||||
|
|
||||||
await handleBarLaunch([], {
|
await handleBarLaunch([], {
|
||||||
ensureDashboard: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }),
|
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,
|
getCcsDir: () => ccsDir,
|
||||||
appInstallPath: nonExistentApp,
|
appInstallPath: nonExistentApp,
|
||||||
});
|
});
|
||||||
@@ -313,7 +317,9 @@ describe('bar.json contract (launch subcommand)', () => {
|
|||||||
|
|
||||||
await handleBarLaunch([], {
|
await handleBarLaunch([], {
|
||||||
ensureDashboard: async () => ({ port: 3001, baseUrl: 'http://127.0.0.1:3001' }),
|
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,
|
getCcsDir: () => ccsDir,
|
||||||
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
||||||
});
|
});
|
||||||
@@ -330,8 +336,12 @@ describe('bar.json contract (launch subcommand)', () => {
|
|||||||
const { handleBarLaunch } = await loadLaunchSubcommand();
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
||||||
|
|
||||||
await handleBarLaunch([], {
|
await handleBarLaunch([], {
|
||||||
ensureDashboard: async () => { throw new Error('port busy'); },
|
ensureDashboard: async () => {
|
||||||
openApp: async () => { /* noop */ },
|
throw new Error('port busy');
|
||||||
|
},
|
||||||
|
openApp: async () => {
|
||||||
|
/* noop */
|
||||||
|
},
|
||||||
getCcsDir: () => ccsDir,
|
getCcsDir: () => ccsDir,
|
||||||
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
||||||
});
|
});
|
||||||
@@ -469,8 +479,12 @@ describe('bar install subcommand', () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
handleBarInstall([], {
|
handleBarInstall([], {
|
||||||
fetchReleaseAsset: async () => { throw new Error('network error'); },
|
fetchReleaseAsset: async () => {
|
||||||
downloadAndExtract: async () => { /* noop */ },
|
throw new Error('network error');
|
||||||
|
},
|
||||||
|
downloadAndExtract: async () => {
|
||||||
|
/* noop */
|
||||||
|
},
|
||||||
verifyCompat: async () => ({ version: FAKE_VERSION, compatible: true }),
|
verifyCompat: async () => ({ version: FAKE_VERSION, compatible: true }),
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
@@ -527,17 +541,6 @@ describe('bar install: redirect-following download (#8)', () => {
|
|||||||
expect(allOutput).not.toMatch(/\[X\]/);
|
expect(allOutput).not.toMatch(/\[X\]/);
|
||||||
expect(allOutput).toMatch(/\[OK\]/);
|
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 () => {
|
it('validateDownloadUrl accepts github.com URLs', async () => {
|
||||||
const { validateDownloadUrl } = await loadInstallSubcommand();
|
const { validateDownloadUrl } = await loadInstallSubcommand();
|
||||||
expect(() =>
|
expect(() =>
|
||||||
validateDownloadUrl(
|
validateDownloadUrl('https://github.com/kaitranntt/ccs/releases/download/tag/CCS-Bar.app.zip')
|
||||||
'https://github.com/kaitranntt/ccs/releases/download/tag/CCS-Bar.app.zip'
|
|
||||||
)
|
|
||||||
).not.toThrow();
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -631,17 +632,17 @@ describe('bar install: host allowlist validation (#9)', () => {
|
|||||||
|
|
||||||
it('validateDownloadUrl rejects untrusted hostnames', async () => {
|
it('validateDownloadUrl rejects untrusted hostnames', async () => {
|
||||||
const { validateDownloadUrl } = await loadInstallSubcommand();
|
const { validateDownloadUrl } = await loadInstallSubcommand();
|
||||||
expect(() =>
|
expect(() => validateDownloadUrl('https://evil.example.com/CCS-Bar.app.zip')).toThrow(
|
||||||
validateDownloadUrl('https://evil.example.com/CCS-Bar.app.zip')
|
/allowlist|trusted/i
|
||||||
).toThrow(/allowlist|trusted/i);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('validateDownloadUrl rejects a URL that looks like github but is not', async () => {
|
it('validateDownloadUrl rejects a URL that looks like github but is not', async () => {
|
||||||
const { validateDownloadUrl } = await loadInstallSubcommand();
|
const { validateDownloadUrl } = await loadInstallSubcommand();
|
||||||
// A domain that ends in github.com.attacker.com must be rejected
|
// A domain that ends in github.com.attacker.com must be rejected
|
||||||
expect(() =>
|
expect(() => validateDownloadUrl('https://github.com.attacker.com/download/file.zip')).toThrow(
|
||||||
validateDownloadUrl('https://github.com.attacker.com/download/file.zip')
|
/allowlist|trusted/i
|
||||||
).toThrow(/allowlist|trusted/i);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handleBarInstall surfaces a clear error for non-github download URLs', async () => {
|
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 { 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
|
// 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<{
|
function makeResponse(
|
||||||
provider: string;
|
entries: Array<{
|
||||||
model: string;
|
provider: string;
|
||||||
auth_index: number;
|
model: string;
|
||||||
source: string;
|
auth_index: number;
|
||||||
timestamp: string;
|
source: string;
|
||||||
input: number;
|
timestamp: string;
|
||||||
output: number;
|
input: number;
|
||||||
failed?: boolean;
|
output: number;
|
||||||
}>): CliproxyUsageApiResponse {
|
failed?: boolean;
|
||||||
|
}>
|
||||||
|
): CliproxyUsageApiResponse {
|
||||||
const apis: CliproxyUsageApiResponse['usage'] = { apis: {} };
|
const apis: CliproxyUsageApiResponse['usage'] = { apis: {} };
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
if (!apis.apis![e.provider]) {
|
if (!apis.apis![e.provider]) {
|
||||||
@@ -100,7 +114,9 @@ const authFileMap: Map<number | string, string> = new Map([
|
|||||||
|
|
||||||
describe('extractCliproxyUsageHistoryDetails with accountMap', () => {
|
describe('extractCliproxyUsageHistoryDetails with accountMap', () => {
|
||||||
it('populates accountId from accountMap when auth_index is present', async () => {
|
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);
|
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
|
||||||
|
|
||||||
@@ -108,14 +124,16 @@ describe('extractCliproxyUsageHistoryDetails with accountMap', () => {
|
|||||||
const bobDetails = details.filter((d) => d.accountId === 'bob@example.com');
|
const bobDetails = details.filter((d) => d.accountId === 'bob@example.com');
|
||||||
|
|
||||||
expect(aliceDetails).toHaveLength(2); // auth_index 0 appears twice
|
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 () => {
|
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.
|
// 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
|
// 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'.
|
// 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
|
// Use string key matching buildAuthIndexToAccountMap's String(auth_index) output
|
||||||
const partialMap: Map<number | string, string> = new Map([['0', 'alice@example.com']]);
|
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 () => {
|
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);
|
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 () => {
|
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);
|
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
|
||||||
|
|
||||||
@@ -160,7 +182,9 @@ describe('extractCliproxyUsageHistoryDetails with accountMap', () => {
|
|||||||
|
|
||||||
describe('CliproxyUsageHistoryDetail type', () => {
|
describe('CliproxyUsageHistoryDetail type', () => {
|
||||||
it('allows accountId as optional string field', async () => {
|
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({
|
const withAccount = normalizeCliproxyUsageHistoryDetail({
|
||||||
model: 'claude-sonnet-4-5',
|
model: 'claude-sonnet-4-5',
|
||||||
@@ -179,7 +203,9 @@ describe('CliproxyUsageHistoryDetail type', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes detail without accountId (remains undefined)', async () => {
|
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({
|
const noAccount = normalizeCliproxyUsageHistoryDetail({
|
||||||
model: 'claude-sonnet-4-5',
|
model: 'claude-sonnet-4-5',
|
||||||
@@ -203,12 +229,16 @@ describe('CliproxyUsageHistoryDetail type', () => {
|
|||||||
|
|
||||||
describe('getTodayCostByAccount', () => {
|
describe('getTodayCostByAccount', () => {
|
||||||
it('returns per-account cost totals for today', async () => {
|
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 = [];
|
const details = [];
|
||||||
|
|
||||||
// Simulate alice's two requests today
|
// 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 todayDetails = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
|
||||||
|
|
||||||
const result = getTodayCostByAccount(todayDetails, TODAY);
|
const result = getTodayCostByAccount(todayDetails, TODAY);
|
||||||
@@ -225,7 +255,9 @@ describe('getTodayCostByAccount', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty object when no details exist for today', async () => {
|
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);
|
const result = getTodayCostByAccount([], TODAY);
|
||||||
|
|
||||||
@@ -233,8 +265,12 @@ describe('getTodayCostByAccount', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('filters out details from days other than today', async () => {
|
it('filters out details from days other than today', async () => {
|
||||||
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
|
const { getTodayCostByAccount } = await import(
|
||||||
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
|
'../../../../src/web-server/usage/data-aggregator'
|
||||||
|
);
|
||||||
|
const { extractCliproxyUsageHistoryDetails } = await import(
|
||||||
|
'../../../../src/web-server/usage/cliproxy-usage-transformer'
|
||||||
|
);
|
||||||
|
|
||||||
const yesterdayResponse = makeResponse([
|
const yesterdayResponse = makeResponse([
|
||||||
{
|
{
|
||||||
@@ -254,8 +290,12 @@ describe('getTodayCostByAccount', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('accumulates costs across multiple details for the same account', async () => {
|
it('accumulates costs across multiple details for the same account', async () => {
|
||||||
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
|
const { getTodayCostByAccount } = await import(
|
||||||
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
|
'../../../../src/web-server/usage/data-aggregator'
|
||||||
|
);
|
||||||
|
const { extractCliproxyUsageHistoryDetails } = await import(
|
||||||
|
'../../../../src/web-server/usage/cliproxy-usage-transformer'
|
||||||
|
);
|
||||||
|
|
||||||
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
|
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
|
||||||
|
|
||||||
@@ -271,8 +311,12 @@ describe('getTodayCostByAccount', () => {
|
|||||||
it('details without accountId are grouped under the "unknown" key', async () => {
|
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.
|
// Fix #7/#13/#15: when no accountMap is provided, accountId is undefined on all details.
|
||||||
// getTodayCostByAccount buckets these under 'unknown' — not under detail.source.
|
// getTodayCostByAccount buckets these under 'unknown' — not under detail.source.
|
||||||
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
|
const { getTodayCostByAccount } = await import(
|
||||||
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
|
'../../../../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
|
// no accountMap — accountId will be undefined on all details
|
||||||
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse);
|
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse);
|
||||||
@@ -293,7 +337,9 @@ describe('getTodayCostByAccount', () => {
|
|||||||
|
|
||||||
describe('backward compatibility: profile-based aggregation unaffected', () => {
|
describe('backward compatibility: profile-based aggregation unaffected', () => {
|
||||||
it('transformCliproxyToDailyUsage works without accountMap', async () => {
|
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);
|
const daily = transformCliproxyToDailyUsage(twoAccountResponse);
|
||||||
|
|
||||||
@@ -303,7 +349,8 @@ describe('backward compatibility: profile-based aggregation unaffected', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('buildCliproxyUsageHistoryAggregates preserves existing shape', async () => {
|
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 details = extractCliproxyUsageHistoryDetails(twoAccountResponse);
|
||||||
const { daily, hourly, monthly } = buildCliproxyUsageHistoryAggregates(details);
|
const { daily, hourly, monthly } = buildCliproxyUsageHistoryAggregates(details);
|
||||||
@@ -338,7 +385,9 @@ describe('backward compatibility: profile-based aggregation unaffected', () => {
|
|||||||
|
|
||||||
describe('buildAuthIndexToAccountMap', () => {
|
describe('buildAuthIndexToAccountMap', () => {
|
||||||
it('builds map from auth files with auth_index and email', async () => {
|
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[] = [
|
const authFiles: CliproxyManagementAuthFile[] = [
|
||||||
{ auth_index: 0, provider: 'anthropic', email: 'alice@example.com' },
|
{ auth_index: 0, provider: 'anthropic', email: 'alice@example.com' },
|
||||||
@@ -354,7 +403,9 @@ describe('buildAuthIndexToAccountMap', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('skips entries missing auth_index', async () => {
|
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[] = [
|
const authFiles: CliproxyManagementAuthFile[] = [
|
||||||
{ provider: 'anthropic', email: 'nobody@example.com' }, // no auth_index
|
{ provider: 'anthropic', email: 'nobody@example.com' }, // no auth_index
|
||||||
@@ -368,7 +419,9 @@ describe('buildAuthIndexToAccountMap', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('skips entries missing email', async () => {
|
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[] = [
|
const authFiles: CliproxyManagementAuthFile[] = [
|
||||||
{ auth_index: 4, provider: 'anthropic' }, // no email
|
{ auth_index: 4, provider: 'anthropic' }, // no email
|
||||||
@@ -382,7 +435,9 @@ describe('buildAuthIndexToAccountMap', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty map for empty auth files array', async () => {
|
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([]);
|
const map = buildAuthIndexToAccountMap([]);
|
||||||
|
|
||||||
@@ -390,7 +445,9 @@ describe('buildAuthIndexToAccountMap', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles numeric and string auth_index keys consistently', async () => {
|
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[] = [
|
const authFiles: CliproxyManagementAuthFile[] = [
|
||||||
{ auth_index: 7, provider: 'anthropic', email: 'alice@example.com' },
|
{ auth_index: 7, provider: 'anthropic', email: 'alice@example.com' },
|
||||||
|
|||||||
Reference in New Issue
Block a user