test: isolate shared-state test suites for CI stability

- add dependency injection to export-command, binary-manager,
  codex-plan-compatibility, config-command, and usage-syncer
- override process.exit in api-export test to prevent silent
  termination in Bun's parallel test runner
- harden test-environment bootstrap with process.env isolation
- fix auth middleware to avoid config upgrade during checks
This commit is contained in:
Tam Nhu Tran committed 2026-03-25 16:41:41 -04:00
1 parent 58891d368d
commit 3af554275e
24 files changed
+710 -560

No files matched your search

+1
View File
@@ -4,4 +4,5 @@
# Exclude e2e tests - they require manual setup and are slow # Exclude e2e tests - they require manual setup and are slow
# Run e2e tests with: bun run test:e2e # Run e2e tests with: bun run test:e2e
root = "./tests" root = "./tests"
preload = ["./tests/shared/fixtures/test-environment.js"]
timeout = 10000 timeout = 10000
+29 -9
View File
@@ -158,41 +158,61 @@ export function getInstalledCliproxyVersion(backend?: CLIProxyBackend): string {
); );
} }
interface InstallCliproxyVersionDeps {
createManager?: (
config: Partial<BinaryManagerConfig>,
backend: CLIProxyBackend
) => Pick<BinaryManager, 'isBinaryInstalled' | 'deleteBinary' | 'ensureBinary'>;
stopProxyFn?: typeof stopProxy;
waitForPortFreeFn?: typeof waitForPortFree;
formatInfo?: typeof info;
formatWarn?: typeof warn;
getInstalledVersion?: typeof getInstalledCliproxyVersion;
}
/** Install a specific version of CLIProxyAPI */ /** Install a specific version of CLIProxyAPI */
export async function installCliproxyVersion( export async function installCliproxyVersion(
version: string, version: string,
verbose = false, verbose = false,
backend?: CLIProxyBackend backend?: CLIProxyBackend,
deps: InstallCliproxyVersionDeps = {}
): Promise<void> { ): Promise<void> {
const effectiveBackend = backend ?? getConfiguredBackend(); const effectiveBackend = backend ?? getConfiguredBackend();
const manager = new BinaryManager({ version, verbose, forceVersion: true }, effectiveBackend); const manager =
deps.createManager?.({ version, verbose, forceVersion: true }, effectiveBackend) ??
new BinaryManager({ version, verbose, forceVersion: true }, effectiveBackend);
const stopProxyFn = deps.stopProxyFn ?? stopProxy;
const waitForPortFreeFn = deps.waitForPortFreeFn ?? waitForPortFree;
const formatInfo = deps.formatInfo ?? info;
const formatWarn = deps.formatWarn ?? warn;
const getInstalledVersion = deps.getInstalledVersion ?? getInstalledCliproxyVersion;
// Always attempt a best-effort stop first so we also catch untracked proxies // Always attempt a best-effort stop first so we also catch untracked proxies
// that are running without a session lock. // that are running without a session lock.
if (verbose) console.log(info('Stopping running CLIProxy before update...')); if (verbose) console.log(formatInfo('Stopping running CLIProxy before update...'));
const result = await stopProxy(); const result = await stopProxyFn();
if (result.stopped) { if (result.stopped) {
// Wait for port to be fully released // Wait for port to be fully released
const portFree = await waitForPortFree(CLIPROXY_DEFAULT_PORT, 5000); const portFree = await waitForPortFreeFn(CLIPROXY_DEFAULT_PORT, 5000);
if (!portFree && verbose) { if (!portFree && verbose) {
console.log(warn('Port did not free up in time, proceeding anyway...')); console.log(formatWarn('Port did not free up in time, proceeding anyway...'));
} }
} else if (verbose && result.error && result.error !== 'No active CLIProxy session found') { } else if (verbose && result.error && result.error !== 'No active CLIProxy session found') {
console.log(warn(`Could not stop proxy: ${result.error}`)); console.log(formatWarn(`Could not stop proxy: ${result.error}`));
} }
if (manager.isBinaryInstalled()) { if (manager.isBinaryInstalled()) {
const label = effectiveBackend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; const label = effectiveBackend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
if (verbose) if (verbose)
console.log( console.log(
info(`Removing existing ${label} v${getInstalledCliproxyVersion(effectiveBackend)}`) formatInfo(`Removing existing ${label} v${getInstalledVersion(effectiveBackend)}`)
); );
manager.deleteBinary(); manager.deleteBinary();
} }
await manager.ensureBinary(); await manager.ensureBinary();
if (verbose) { if (verbose) {
console.log(info('New version will be active on next CLIProxy command')); console.log(formatInfo('New version will be active on next CLIProxy command'));
} }
} }
+22 -7
View File
@@ -37,6 +37,13 @@ export interface CodexUnsupportedModelError {
type: string | null; type: string | null;
} }
interface CodexPlanCompatibilityDeps {
getDefaultAccount?: typeof getDefaultAccount;
fetchCodexQuota?: typeof fetchCodexQuota;
formatInfo?: typeof info;
formatWarn?: typeof warn;
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value); return typeof value === 'object' && value !== null && !Array.isArray(value);
} }
@@ -131,21 +138,29 @@ export function resolveRuntimeCodexFallbackModel(options: {
return null; return null;
} }
export async function reconcileCodexModelForActivePlan(options: { export async function reconcileCodexModelForActivePlan(
options: {
settingsPath: string; settingsPath: string;
currentModel: string | undefined; currentModel: string | undefined;
verbose: boolean; verbose: boolean;
}): Promise<void> { },
deps: CodexPlanCompatibilityDeps = {}
): Promise<void> {
const { settingsPath, currentModel, verbose } = options; const { settingsPath, currentModel, verbose } = options;
if (!currentModel) return; if (!currentModel) return;
const fallbackModel = getFreePlanFallbackCodexModel(currentModel); const fallbackModel = getFreePlanFallbackCodexModel(currentModel);
if (!fallbackModel) return; if (!fallbackModel) return;
const defaultAccount = getDefaultAccount('codex'); const resolveDefaultAccount = deps.getDefaultAccount ?? getDefaultAccount;
const fetchQuota = deps.fetchCodexQuota ?? fetchCodexQuota;
const formatInfo = deps.formatInfo ?? info;
const formatWarn = deps.formatWarn ?? warn;
const defaultAccount = resolveDefaultAccount('codex');
if (!defaultAccount) { if (!defaultAccount) {
console.error( console.error(
warn( formatWarn(
`Configured Codex model "${normalizeCodexModelId(currentModel)}" may require a paid Codex plan. ` + `Configured Codex model "${normalizeCodexModelId(currentModel)}" may require a paid Codex plan. ` +
`If startup fails, switch to "${fallbackModel}" with "ccs codex --config".` `If startup fails, switch to "${fallbackModel}" with "ccs codex --config".`
) )
@@ -154,7 +169,7 @@ export async function reconcileCodexModelForActivePlan(options: {
} }
const cachedQuota = getCachedQuota<CodexQuotaResult>('codex', defaultAccount.id); const cachedQuota = getCachedQuota<CodexQuotaResult>('codex', defaultAccount.id);
const quota = cachedQuota ?? (await fetchCodexQuota(defaultAccount.id, verbose)); const quota = cachedQuota ?? (await fetchQuota(defaultAccount.id, verbose));
if (!cachedQuota) { if (!cachedQuota) {
setCachedQuota('codex', defaultAccount.id, quota); setCachedQuota('codex', defaultAccount.id, quota);
} }
@@ -164,7 +179,7 @@ export async function reconcileCodexModelForActivePlan(options: {
rewriteHaikuModel: (haikuModel) => getFreePlanFallbackCodexModel(haikuModel) ?? haikuModel, rewriteHaikuModel: (haikuModel) => getFreePlanFallbackCodexModel(haikuModel) ?? haikuModel,
}); });
console.error( console.error(
info( formatInfo(
`Codex free plan detected. Switched unsupported model "${normalizeCodexModelId(currentModel)}" ` + `Codex free plan detected. Switched unsupported model "${normalizeCodexModelId(currentModel)}" ` +
`to "${fallbackModel}".` `to "${fallbackModel}".`
) )
@@ -177,7 +192,7 @@ export async function reconcileCodexModelForActivePlan(options: {
} }
console.error( console.error(
warn( formatWarn(
`Could not verify Codex plan for model "${normalizeCodexModelId(currentModel)}". ` + `Could not verify Codex plan for model "${normalizeCodexModelId(currentModel)}". ` +
`If startup fails with model_not_supported, switch to "${fallbackModel}" via "ccs codex --config".` `If startup fails with model_not_supported, switch to "${fallbackModel}" via "ccs codex --config".`
) )
+31 -10
View File
@@ -5,8 +5,29 @@ import { fail, initUI, ok, warn } from '../../utils/ui';
import { extractOption, hasAnyFlag } from '../arg-extractor'; import { extractOption, hasAnyFlag } from '../arg-extractor';
import { collectUnexpectedApiArgs } from './shared'; import { collectUnexpectedApiArgs } from './shared';
export async function handleApiExportCommand(args: string[]): Promise<void> { interface ApiExportCommandDependencies {
await initUI(); exportApiProfile: typeof exportApiProfile;
initUI: typeof initUI;
ok: typeof ok;
warn: typeof warn;
fail: typeof fail;
getCwd: () => string;
}
const defaultApiExportCommandDependencies: ApiExportCommandDependencies = {
exportApiProfile,
initUI,
ok,
warn,
fail,
getCwd: () => process.cwd(),
};
export async function handleApiExportCommand(
args: string[],
deps: ApiExportCommandDependencies = defaultApiExportCommandDependencies
): Promise<void> {
await deps.initUI();
const includeSecrets = hasAnyFlag(args, ['--include-secrets']); const includeSecrets = hasAnyFlag(args, ['--include-secrets']);
const outExtracted = extractOption(args, ['--out'], { const outExtracted = extractOption(args, ['--out'], {
@@ -15,7 +36,7 @@ export async function handleApiExportCommand(args: string[]): Promise<void> {
knownFlags: ['--out', '--include-secrets'], knownFlags: ['--out', '--include-secrets'],
}); });
if (outExtracted.found && (outExtracted.missingValue || !outExtracted.value)) { if (outExtracted.found && (outExtracted.missingValue || !outExtracted.value)) {
console.log(fail('Missing value for --out')); console.log(deps.fail('Missing value for --out'));
process.exit(1); process.exit(1);
} }
@@ -24,29 +45,29 @@ export async function handleApiExportCommand(args: string[]): Promise<void> {
maxPositionals: 1, maxPositionals: 1,
}); });
if (syntax.errors.length > 0) { if (syntax.errors.length > 0) {
syntax.errors.forEach((errorMessage) => console.log(fail(errorMessage))); syntax.errors.forEach((errorMessage) => console.log(deps.fail(errorMessage)));
process.exit(1); process.exit(1);
} }
const name = syntax.positionals[0]; const name = syntax.positionals[0];
if (!name) { if (!name) {
console.log(fail('Profile name is required. Usage: ccs api export <name> [--out <file>]')); console.log(deps.fail('Profile name is required. Usage: ccs api export <name> [--out <file>]'));
process.exit(1); process.exit(1);
} }
const result = exportApiProfile(name, includeSecrets); const result = deps.exportApiProfile(name, includeSecrets);
if (!result.success || !result.bundle) { if (!result.success || !result.bundle) {
console.log(fail(result.error || 'Failed to export profile')); console.log(deps.fail(result.error || 'Failed to export profile'));
process.exit(1); process.exit(1);
} }
const outputPath = path.resolve(outExtracted.value || `${name}.ccs-profile.json`); const outputPath = path.resolve(deps.getCwd(), outExtracted.value || `${name}.ccs-profile.json`);
fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, JSON.stringify(result.bundle, null, 2) + '\n', 'utf8'); fs.writeFileSync(outputPath, JSON.stringify(result.bundle, null, 2) + '\n', 'utf8');
console.log(ok(`Profile exported to: ${outputPath}`)); console.log(deps.ok(`Profile exported to: ${outputPath}`));
if (result.redacted) { if (result.redacted) {
console.log(warn('Token was redacted in export. Use --include-secrets to include it.')); console.log(deps.warn('Token was redacted in export. Use --include-secrets to include it.'));
} }
console.log(''); console.log('');
} }
@@ -1,11 +1,15 @@
import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager'; import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import type { UnifiedConfig } from '../../config/unified-config-types';
type LifecyclePortConfig = Pick<UnifiedConfig, 'cliproxy_server'>;
/** /**
* Resolve the local CLIProxy lifecycle port from unified config. * Resolve the local CLIProxy lifecycle port from unified config.
* Falls back to default port when unset/invalid. * Falls back to default port when unset/invalid.
*/ */
export function resolveLifecyclePort(): number { export function resolveLifecyclePort(
const config = loadOrCreateUnifiedConfig(); config: LifecyclePortConfig = loadOrCreateUnifiedConfig()
): number {
return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT); return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT);
} }
+70 -33
View File
@@ -54,25 +54,62 @@ const CONFIG_SUBCOMMAND_ROUTES: readonly NamedCommandRoute[] = [
}, },
]; ];
interface ConfigCommandDependencies {
getPort: typeof getPort;
openBrowser: typeof open;
startServer: typeof startServer;
setupGracefulShutdown: typeof setupGracefulShutdown;
ensureCliproxyService: typeof ensureCliproxyService;
getDashboardAuthConfig: typeof getDashboardAuthConfig;
initUI: typeof initUI;
header: typeof header;
ok: typeof ok;
info: typeof info;
warn: typeof warn;
fail: typeof fail;
resolveNamedCommand: typeof resolveNamedCommand;
configSubcommandRoutes: readonly NamedCommandRoute[];
}
const defaultConfigCommandDependencies: ConfigCommandDependencies = {
getPort,
openBrowser: open,
startServer,
setupGracefulShutdown,
ensureCliproxyService,
getDashboardAuthConfig,
initUI,
header,
ok,
info,
warn,
fail,
resolveNamedCommand,
configSubcommandRoutes: CONFIG_SUBCOMMAND_ROUTES,
};
/** /**
* Handle config command * Handle config command
*/ */
export async function handleConfigCommand(args: string[]): Promise<void> { export async function handleConfigCommand(
args: string[],
deps: ConfigCommandDependencies = defaultConfigCommandDependencies
): Promise<void> {
if (args.length === 1 && args[0] === 'help') { if (args.length === 1 && args[0] === 'help') {
await initUI(); await deps.initUI();
showConfigCommandHelp(); showConfigCommandHelp();
process.exit(0); process.exit(0);
} }
const subcommand = args[0]?.startsWith('-') const subcommand = args[0]?.startsWith('-')
? undefined ? undefined
: resolveNamedCommand(args[0], CONFIG_SUBCOMMAND_ROUTES); : deps.resolveNamedCommand(args[0], deps.configSubcommandRoutes);
if (subcommand) { if (subcommand) {
await subcommand.handle(args.slice(1)); await subcommand.handle(args.slice(1));
return; return;
} }
await initUI(); await deps.initUI();
const parsed = parseConfigCommandArgs(args); const parsed = parseConfigCommandArgs(args);
if (parsed.help) { if (parsed.help) {
@@ -80,41 +117,41 @@ export async function handleConfigCommand(args: string[]): Promise<void> {
process.exit(0); process.exit(0);
} }
if (parsed.error) { if (parsed.error) {
console.error(fail(parsed.error)); console.error(deps.fail(parsed.error));
process.exit(1); process.exit(1);
} }
const options = parsed.options; const options = parsed.options;
const verbose = options.dev; const verbose = options.dev;
console.log(header('CCS Config Dashboard')); console.log(deps.header('CCS Config Dashboard'));
console.log(''); console.log('');
// Ensure CLIProxy service is running for dashboard features // Ensure CLIProxy service is running for dashboard features
console.log(info('Starting CLIProxy service...')); console.log(deps.info('Starting CLIProxy service...'));
const cliproxyResult = await ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose); const cliproxyResult = await deps.ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose);
if (cliproxyResult.started) { if (cliproxyResult.started) {
if (cliproxyResult.alreadyRunning) { if (cliproxyResult.alreadyRunning) {
console.log(ok(`CLIProxy already running on port ${cliproxyResult.port}`)); console.log(deps.ok(`CLIProxy already running on port ${cliproxyResult.port}`));
if (cliproxyResult.configRegenerated) { if (cliproxyResult.configRegenerated) {
console.log(warn('Config updated - restart CLIProxy to apply changes')); console.log(deps.warn('Config updated - restart CLIProxy to apply changes'));
} }
} else { } else {
console.log(ok(`CLIProxy started on port ${cliproxyResult.port}`)); console.log(deps.ok(`CLIProxy started on port ${cliproxyResult.port}`));
} }
} else { } else {
console.log(warn(`CLIProxy not available: ${cliproxyResult.error}`)); console.log(deps.warn(`CLIProxy not available: ${cliproxyResult.error}`));
console.log(info('Dashboard will work but Control Panel/Stats may be limited')); console.log(deps.info('Dashboard will work but Control Panel/Stats may be limited'));
} }
console.log(''); console.log('');
console.log(info('Starting dashboard server...')); console.log(deps.info('Starting dashboard server...'));
// Find available port // Find available port
const port = const port =
options.port ?? options.port ??
(await getPort({ (await deps.getPort({
port: [3000, 3001, 3002, 8000, 8080], port: [3000, 3001, 3002, 8000, 8080],
})); }));
@@ -128,60 +165,60 @@ export async function handleConfigCommand(args: string[]): Promise<void> {
serverOptions.host = normalizeDashboardHost(options.host); serverOptions.host = normalizeDashboardHost(options.host);
} }
const { server, wss, cleanup } = await startServer(serverOptions); const { server, wss, cleanup } = await deps.startServer(serverOptions);
// Setup graceful shutdown // Setup graceful shutdown
setupGracefulShutdown(server, wss, cleanup); deps.setupGracefulShutdown(server, wss, cleanup);
const urls = resolveDashboardUrls(resolveServerBindHost(server) ?? options.host, port); const urls = resolveDashboardUrls(resolveServerBindHost(server) ?? options.host, port);
const shouldWarnAboutExposure = urls.bindHost ? !isLoopbackHost(urls.bindHost) : false; const shouldWarnAboutExposure = urls.bindHost ? !isLoopbackHost(urls.bindHost) : false;
if (options.dev) { if (options.dev) {
console.log(ok(`Dev Server: ${urls.browserUrl}`)); console.log(deps.ok(`Dev Server: ${urls.browserUrl}`));
console.log(''); console.log('');
console.log(info('HMR enabled - UI changes will hot-reload')); console.log(deps.info('HMR enabled - UI changes will hot-reload'));
} else { } else {
console.log(ok(`Dashboard: ${urls.browserUrl}`)); console.log(deps.ok(`Dashboard: ${urls.browserUrl}`));
} }
if (shouldWarnAboutExposure && urls.bindHost) { if (shouldWarnAboutExposure && urls.bindHost) {
console.log(info(`Bind host: ${urls.bindHost}`)); console.log(deps.info(`Bind host: ${urls.bindHost}`));
if (urls.networkUrls?.length === 1) { if (urls.networkUrls?.length === 1) {
console.log(info(`Network URL: ${urls.networkUrls[0]}`)); console.log(deps.info(`Network URL: ${urls.networkUrls[0]}`));
} else if (urls.networkUrls && urls.networkUrls.length > 1) { } else if (urls.networkUrls && urls.networkUrls.length > 1) {
console.log(info('Network URLs:')); console.log(deps.info('Network URLs:'));
for (const networkUrl of urls.networkUrls) { for (const networkUrl of urls.networkUrls) {
console.log(info(` ${networkUrl}`)); console.log(deps.info(` ${networkUrl}`));
} }
} }
} }
if (shouldWarnAboutExposure && urls.bindHost) { if (shouldWarnAboutExposure && urls.bindHost) {
const authConfig = getDashboardAuthConfig(); const authConfig = deps.getDashboardAuthConfig();
console.log( console.log(
warn('Dashboard may be reachable from other devices that can connect to this machine.') deps.warn('Dashboard may be reachable from other devices that can connect to this machine.')
); );
if (!authConfig.enabled) { if (!authConfig.enabled) {
console.log(info('Protect it before sharing: ccs config auth setup')); console.log(deps.info('Protect it before sharing: ccs config auth setup'));
} }
if (isWildcardHost(urls.bindHost) && !urls.networkUrls?.length) { if (isWildcardHost(urls.bindHost) && !urls.networkUrls?.length) {
console.log(info('Use your machine IP or hostname from the other device.')); console.log(deps.info('Use your machine IP or hostname from the other device.'));
} }
} }
console.log(''); console.log('');
// Open browser // Open browser
try { try {
await open(urls.browserUrl, { wait: false }); await deps.openBrowser(urls.browserUrl, { wait: false });
console.log(info('Browser opened automatically')); console.log(deps.info('Browser opened automatically'));
} catch { } catch {
console.log(info(`Open manually: ${urls.browserUrl}`)); console.log(deps.info(`Open manually: ${urls.browserUrl}`));
} }
console.log(''); console.log('');
console.log(info('Press Ctrl+C to stop')); console.log(deps.info('Press Ctrl+C to stop'));
} catch (error) { } catch (error) {
console.error(fail(`Failed to start server: ${(error as Error).message}`)); console.error(deps.fail(`Failed to start server: ${(error as Error).message}`));
process.exit(1); process.exit(1);
} }
} }
+15
View File
@@ -1222,6 +1222,21 @@ export function getOfficialChannelsConfig(): OfficialChannelsConfig {
}; };
} }
/**
* Get dashboard_auth configuration with ENV var override.
* Priority: ENV vars > config.yaml > defaults
*/
export function isDashboardAuthEnabled(): boolean {
const envEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
if (envEnabled !== undefined) {
return envEnabled === 'true' || envEnabled === '1';
}
const config = loadOrCreateUnifiedConfig();
return config.dashboard_auth?.enabled ?? false;
}
/** /**
* Get dashboard_auth configuration with ENV var override. * Get dashboard_auth configuration with ENV var override.
* Priority: ENV vars > config.yaml > defaults * Priority: ENV vars > config.yaml > defaults
+17 -1
View File
@@ -3,14 +3,30 @@ import * as fs from 'fs';
export type DaemonOwnershipStatus = 'owned' | 'not-owned' | 'not-running' | 'unknown'; export type DaemonOwnershipStatus = 'owned' | 'not-owned' | 'not-running' | 'unknown';
function sleepSync(milliseconds: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
}
function getProcessCommandLine(pid: number): string | null { function getProcessCommandLine(pid: number): string | null {
if (process.platform === 'linux') { if (process.platform === 'linux') {
for (let attempt = 0; attempt < 3; attempt++) {
try { try {
// /proc cmdline uses null separators between arguments. // /proc cmdline uses null separators between arguments.
return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim(); const commandLine = fs
.readFileSync(`/proc/${pid}/cmdline`, 'utf8')
.replace(/\0/g, ' ')
.trim();
if (commandLine) {
return commandLine;
}
} catch { } catch {
return null; return null;
} }
sleepSync(25);
}
return null;
} }
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
+4 -6
View File
@@ -6,7 +6,7 @@
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from 'express';
import session from 'express-session'; import session from 'express-session';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import { getDashboardAuthConfig } from '../../config/unified-config-loader'; import { getDashboardAuthConfig, isDashboardAuthEnabled } from '../../config/unified-config-loader';
import crypto from 'crypto'; import crypto from 'crypto';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
@@ -78,7 +78,7 @@ export const loginRateLimiter = rateLimit({
message: { error: 'Too many login attempts. Please try again later.' }, message: { error: 'Too many login attempts. Please try again later.' },
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
skip: () => !getDashboardAuthConfig().enabled, skip: () => !isDashboardAuthEnabled(),
}); });
/** /**
@@ -106,10 +106,8 @@ export function createSessionMiddleware() {
* Only active when dashboard_auth.enabled = true. * Only active when dashboard_auth.enabled = true.
*/ */
export function authMiddleware(req: Request, res: Response, next: NextFunction): void { export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
const authConfig = getDashboardAuthConfig();
// Skip auth if disabled // Skip auth if disabled
if (!authConfig.enabled) { if (!isDashboardAuthEnabled()) {
return next(); return next();
} }
@@ -150,7 +148,7 @@ export function requireLocalAccessWhenAuthDisabled(
res: Response, res: Response,
error = 'This endpoint requires localhost access when dashboard auth is disabled.' error = 'This endpoint requires localhost access when dashboard auth is disabled.'
): boolean { ): boolean {
if (getDashboardAuthConfig().enabled) { if (isDashboardAuthEnabled()) {
return true; return true;
} }
@@ -32,6 +32,8 @@ interface CliproxyUsageSnapshot {
monthly: MonthlyUsage[]; monthly: MonthlyUsage[];
} }
type FetchCliproxyUsageRaw = typeof fetchCliproxyUsageRaw;
const SNAPSHOT_VERSION = 1; const SNAPSHOT_VERSION = 1;
/** Sync interval in ms, configurable via CCS_CLIPROXY_SYNC_INTERVAL env var (default: 5 min) */ /** Sync interval in ms, configurable via CCS_CLIPROXY_SYNC_INTERVAL env var (default: 5 min) */
@@ -106,8 +108,10 @@ export async function loadCachedCliproxyData(): Promise<{
* Fetch latest CLIProxy usage data and persist a snapshot to disk. * Fetch latest CLIProxy usage data and persist a snapshot to disk.
* Non-fatal: logs warning and returns early if CLIProxy is unavailable. * Non-fatal: logs warning and returns early if CLIProxy is unavailable.
*/ */
export async function syncCliproxyUsage(): Promise<void> { export async function syncCliproxyUsage(
const raw = await fetchCliproxyUsageRaw(); fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw
): Promise<void> {
const raw = await fetchRaw();
if (raw === null) { if (raw === null) {
console.log(warn('CLIProxy usage sync skipped: proxy unavailable')); console.log(warn('CLIProxy usage sync skipped: proxy unavailable'));
@@ -150,7 +154,7 @@ export async function syncCliproxyUsage(): Promise<void> {
* Start periodic CLIProxy usage sync (every 5 minutes). * Start periodic CLIProxy usage sync (every 5 minutes).
* Performs an immediate sync on startup. * Performs an immediate sync on startup.
*/ */
export function startCliproxySync(): void { export function startCliproxySync(syncNow: () => Promise<void> = () => syncCliproxyUsage()): void {
if (syncIntervalId !== null) { if (syncIntervalId !== null) {
return; return;
} }
@@ -159,10 +163,10 @@ export function startCliproxySync(): void {
console.log(info(`Starting CLIProxy usage sync (interval: ${intervalMin} min)`)); console.log(info(`Starting CLIProxy usage sync (interval: ${intervalMin} min)`));
// Fire-and-forget initial sync // Fire-and-forget initial sync
void syncCliproxyUsage(); void syncNow();
syncIntervalId = setInterval(() => { syncIntervalId = setInterval(() => {
void syncCliproxyUsage(); void syncNow();
}, SYNC_INTERVAL_MS); }, SYNC_INTERVAL_MS);
} }
@@ -52,6 +52,7 @@ function invokeHook(env: Record<string, string> = {}): Promise<HookResult> {
const child = spawn('node', [HOOK_PATH], { const child = spawn('node', [HOOK_PATH], {
env: { env: {
...process.env, ...process.env,
CCS_IMAGE_ANALYSIS_SKIP: '', // clear any inherited skip flag
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY, CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
CCS_CLIPROXY_PORT: String(mockPort), CCS_CLIPROXY_PORT: String(mockPort),
CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_IMAGE_ANALYSIS_ENABLED: '1',
+93 -7
View File
@@ -32,6 +32,52 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
let bootstrappedTestHome;
const originalHomedir = os.homedir;
let homedirPatched = false;
function getEffectiveTestHome() {
return process.env.CCS_HOME || process.env.HOME || process.env.USERPROFILE || bootstrappedTestHome || originalHomedir();
}
function patchHomedirForTests() {
if (homedirPatched) {
return;
}
os.homedir = () => getEffectiveTestHome();
homedirPatched = true;
}
function createIsolatedTestHome(prefix = 'ccs-test-home-') {
const testHome = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
fs.mkdirSync(path.join(testHome, '.ccs'), { recursive: true });
fs.mkdirSync(path.join(testHome, '.claude'), { recursive: true });
fs.mkdirSync(path.join(testHome, '.config'), { recursive: true });
fs.mkdirSync(path.join(testHome, '.cache'), { recursive: true });
fs.mkdirSync(path.join(testHome, '.state'), { recursive: true });
return testHome;
}
function ensureGlobalTestEnvironment() {
if (bootstrappedTestHome) {
return bootstrappedTestHome;
}
const testHome = createIsolatedTestHome();
process.env.HOME = testHome;
process.env.USERPROFILE = testHome;
process.env.CCS_HOME = testHome;
process.env.XDG_CONFIG_HOME = path.join(testHome, '.config');
process.env.XDG_CACHE_HOME = path.join(testHome, '.cache');
process.env.XDG_STATE_HOME = path.join(testHome, '.state');
process.env.CCS_TEST_BOOTSTRAP_HOME = testHome;
bootstrappedTestHome = testHome;
patchHomedirForTests();
return bootstrappedTestHome;
}
/** /**
* Create an isolated test environment * Create an isolated test environment
* Sets CCS_HOME to a temporary directory and provides cleanup * Sets CCS_HOME to a temporary directory and provides cleanup
@@ -40,21 +86,26 @@ const os = require('os');
*/ */
function createTestEnvironment() { function createTestEnvironment() {
// Create unique temp directory for this test run // Create unique temp directory for this test run
const tempBase = path.join(os.tmpdir(), 'ccs-test'); const testHome = createIsolatedTestHome('ccs-test-');
const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const testHome = path.join(tempBase, uniqueId);
const testCcsDir = path.join(testHome, '.ccs'); const testCcsDir = path.join(testHome, '.ccs');
// Create directories
fs.mkdirSync(testCcsDir, { recursive: true });
// Store original environment // Store original environment
const originalHome = process.env.HOME; const originalHome = process.env.HOME;
const originalCcsHome = process.env.CCS_HOME; const originalCcsHome = process.env.CCS_HOME;
const originalUserProfile = process.env.USERPROFILE; const originalUserProfile = process.env.USERPROFILE;
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
const originalXdgCacheHome = process.env.XDG_CACHE_HOME;
const originalXdgStateHome = process.env.XDG_STATE_HOME;
// Set test environment - use CCS_HOME for isolation // Keep HOME-family env vars aligned so code that still consults os.homedir()
// or XDG defaults stays inside the isolated test sandbox.
process.env.HOME = testHome;
process.env.USERPROFILE = testHome;
process.env.CCS_HOME = testHome; process.env.CCS_HOME = testHome;
process.env.XDG_CONFIG_HOME = path.join(testHome, '.config');
process.env.XDG_CACHE_HOME = path.join(testHome, '.cache');
process.env.XDG_STATE_HOME = path.join(testHome, '.state');
patchHomedirForTests();
// Return environment object // Return environment object
return { return {
@@ -117,12 +168,42 @@ function createTestEnvironment() {
*/ */
cleanup() { cleanup() {
// Restore original environment // Restore original environment
if (originalHome !== undefined) {
process.env.HOME = originalHome;
} else {
delete process.env.HOME;
}
if (originalUserProfile !== undefined) {
process.env.USERPROFILE = originalUserProfile;
} else {
delete process.env.USERPROFILE;
}
if (originalCcsHome !== undefined) { if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome; process.env.CCS_HOME = originalCcsHome;
} else { } else {
delete process.env.CCS_HOME; delete process.env.CCS_HOME;
} }
if (originalXdgConfigHome !== undefined) {
process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
} else {
delete process.env.XDG_CONFIG_HOME;
}
if (originalXdgCacheHome !== undefined) {
process.env.XDG_CACHE_HOME = originalXdgCacheHome;
} else {
delete process.env.XDG_CACHE_HOME;
}
if (originalXdgStateHome !== undefined) {
process.env.XDG_STATE_HOME = originalXdgStateHome;
} else {
delete process.env.XDG_STATE_HOME;
}
// Clean up temp directory // Clean up temp directory
try { try {
fs.rmSync(testHome, { recursive: true, force: true }); fs.rmSync(testHome, { recursive: true, force: true });
@@ -155,6 +236,11 @@ function getCcsDir() {
module.exports = { module.exports = {
createTestEnvironment, createTestEnvironment,
ensureGlobalTestEnvironment,
getCcsHome, getCcsHome,
getCcsDir getCcsDir
}; };
if (process.env.CCS_TEST_DISABLE_GLOBAL_BOOTSTRAP !== '1') {
ensureGlobalTestEnvironment();
}
@@ -9,15 +9,31 @@ import {
importApiProfileBundle, importApiProfileBundle,
registerApiProfileOrphans, registerApiProfileOrphans,
} from '../../../src/api/services/profile-lifecycle-service'; } from '../../../src/api/services/profile-lifecycle-service';
import { runWithScopedConfigDir, setGlobalConfigDir } from '../../../src/utils/config-manager';
describe('profile lifecycle service', () => { describe('profile lifecycle service', () => {
let tempHome = ''; let tempHome = '';
let originalCcsHome: string | undefined; let originalCcsHome: string | undefined;
let originalCcsDir: string | undefined;
let originalUnifiedMode: string | undefined;
function getScopedCcsDir(): string {
return path.join(tempHome, '.ccs');
}
async function runInScopedCcsDir<T>(fn: () => T): Promise<T> {
return await runWithScopedConfigDir(getScopedCcsDir(), fn);
}
beforeEach(() => { beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-lifecycle-')); tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-lifecycle-'));
originalCcsHome = process.env.CCS_HOME; originalCcsHome = process.env.CCS_HOME;
originalCcsDir = process.env.CCS_DIR;
originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG;
process.env.CCS_HOME = tempHome; process.env.CCS_HOME = tempHome;
delete process.env.CCS_DIR;
delete process.env.CCS_UNIFIED_CONFIG;
setGlobalConfigDir(undefined);
}); });
afterEach(() => { afterEach(() => {
@@ -27,12 +43,26 @@ describe('profile lifecycle service', () => {
process.env.CCS_HOME = originalCcsHome; process.env.CCS_HOME = originalCcsHome;
} }
if (originalCcsDir === undefined) {
delete process.env.CCS_DIR;
} else {
process.env.CCS_DIR = originalCcsDir;
}
if (originalUnifiedMode === undefined) {
delete process.env.CCS_UNIFIED_CONFIG;
} else {
process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode;
}
setGlobalConfigDir(undefined);
if (tempHome && fs.existsSync(tempHome)) { if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true }); fs.rmSync(tempHome, { recursive: true, force: true });
} }
}); });
it('discovers only API profile orphans (skips registered and reserved names)', () => { it('discovers only API profile orphans (skips registered and reserved names)', async () => {
const ccsDir = path.join(tempHome, '.ccs'); const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true }); fs.mkdirSync(ccsDir, { recursive: true });
@@ -43,41 +73,56 @@ describe('profile lifecycle service', () => {
fs.writeFileSync( fs.writeFileSync(
path.join(ccsDir, 'glm.settings.json'), path.join(ccsDir, 'glm.settings.json'),
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) + JSON.stringify(
'\n' { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } },
null,
2
) + '\n'
); );
fs.writeFileSync( fs.writeFileSync(
path.join(ccsDir, 'extra.settings.json'), path.join(ccsDir, 'extra.settings.json'),
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) + JSON.stringify(
'\n' { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } },
null,
2
) + '\n'
); );
fs.writeFileSync( fs.writeFileSync(
path.join(ccsDir, 'gemini.settings.json'), path.join(ccsDir, 'gemini.settings.json'),
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) + JSON.stringify(
'\n' { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } },
null,
2
) + '\n'
); );
const result = discoverApiProfileOrphans(); const result = await runInScopedCcsDir(() => discoverApiProfileOrphans());
expect(result.orphans.map((orphan) => orphan.name)).toEqual(['extra']); expect(result.orphans.map((orphan) => orphan.name)).toEqual(['extra']);
}); });
it('treats explicit empty names list as no-op during orphan registration', () => { it('treats explicit empty names list as no-op during orphan registration', async () => {
const ccsDir = path.join(tempHome, '.ccs'); const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true }); fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync( fs.writeFileSync(
path.join(ccsDir, 'lonely.settings.json'), path.join(ccsDir, 'lonely.settings.json'),
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) + JSON.stringify(
'\n' { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } },
null,
2
) + '\n'
);
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: {} }, null, 2) + '\n'
); );
fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n');
const result = registerApiProfileOrphans({ names: [] }); const result = await runInScopedCcsDir(() => registerApiProfileOrphans({ names: [] }));
expect(result.registered).toEqual([]); expect(result.registered).toEqual([]);
expect(result.skipped).toEqual([]); expect(result.skipped).toEqual([]);
}); });
it('redacts all sensitive env values during export when includeSecrets=false', () => { it('redacts all sensitive env values during export when includeSecrets=false', async () => {
const ccsDir = path.join(tempHome, '.ccs'); const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true }); fs.mkdirSync(ccsDir, { recursive: true });
@@ -100,7 +145,7 @@ describe('profile lifecycle service', () => {
) + '\n' ) + '\n'
); );
const result = exportApiProfile('glm', false); const result = await runInScopedCcsDir(() => exportApiProfile('glm', false));
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.bundle?.settings).toBeDefined(); expect(result.bundle?.settings).toBeDefined();
@@ -109,14 +154,15 @@ describe('profile lifecycle service', () => {
expect(env.OPENROUTER_API_KEY).toBe('__CCS_REDACTED__'); expect(env.OPENROUTER_API_KEY).toBe('__CCS_REDACTED__');
}); });
it('rejects invalid source profile names in copy flow', () => { it('rejects invalid source profile names in copy flow', async () => {
const result = copyApiProfile('../escape', 'safe-name'); const result = await runInScopedCcsDir(() => copyApiProfile('../escape', 'safe-name'));
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('Invalid source profile name'); expect(result.error).toContain('Invalid source profile name');
}); });
it('rejects import bundle with invalid profile target', () => { it('rejects import bundle with invalid profile target', async () => {
const result = importApiProfileBundle({ const result = await runInScopedCcsDir(() =>
importApiProfileBundle({
schemaVersion: 1, schemaVersion: 1,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
profile: { name: 'glm', target: 'invalid-target' }, profile: { name: 'glm', target: 'invalid-target' },
@@ -126,18 +172,23 @@ describe('profile lifecycle service', () => {
ANTHROPIC_AUTH_TOKEN: 'token', ANTHROPIC_AUTH_TOKEN: 'token',
}, },
}, },
}); })
);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('Invalid bundle profile target'); expect(result.error).toContain('Invalid bundle profile target');
}); });
it('clears and warns for all redacted sensitive env keys on import', () => { it('clears and warns for all redacted sensitive env keys on import', async () => {
const ccsDir = path.join(tempHome, '.ccs'); const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true }); fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: {} }, null, 2) + '\n'
);
const result = importApiProfileBundle({ const result = await runInScopedCcsDir(() =>
importApiProfileBundle({
schemaVersion: 1, schemaVersion: 1,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
profile: { name: 'redacted-import', target: 'claude' }, profile: { name: 'redacted-import', target: 'claude' },
@@ -148,7 +199,8 @@ describe('profile lifecycle service', () => {
OPENROUTER_API_KEY: '__CCS_REDACTED__', OPENROUTER_API_KEY: '__CCS_REDACTED__',
}, },
}, },
}); })
);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.warnings?.length).toBeGreaterThan(0); expect(result.warnings?.length).toBeGreaterThan(0);
@@ -1,10 +1,6 @@
import { afterEach, describe, expect, it, mock } from 'bun:test'; import { describe, expect, it } from 'bun:test';
describe('installCliproxyVersion', () => { describe('installCliproxyVersion', () => {
afterEach(() => {
mock.restore();
});
it('attempts to stop the proxy even when there is no tracked running session', async () => { it('attempts to stop the proxy even when there is no tracked running session', async () => {
const calls = { const calls = {
stopProxy: 0, stopProxy: 0,
@@ -13,83 +9,33 @@ describe('installCliproxyVersion', () => {
ensureBinary: 0, ensureBinary: 0,
}; };
mock.module('../../../src/utils/ui', () => ({
info: (message: string) => message,
warn: (message: string) => message,
}));
mock.module('../../../src/cliproxy/config-generator', () => ({
getBinDir: () => '/tmp/ccs-bin',
CLIPROXY_DEFAULT_PORT: 8317,
}));
mock.module('../../../src/cliproxy/platform-detector', () => ({
DEFAULT_BACKEND: 'plus',
CLIPROXY_MAX_STABLE_VERSION: '9.9.999-0',
BACKEND_CONFIG: {
plus: {
fallbackVersion: '6.6.80',
repo: 'router-for-me/CLIProxyAPIPlus',
},
original: {
fallbackVersion: '0.0.0',
repo: 'router-for-me/CLIProxyAPI',
},
},
}));
mock.module('../../../src/cliproxy/services/proxy-lifecycle-service', () => ({
stopProxy: async () => {
calls.stopProxy += 1;
return { stopped: false, error: 'No active CLIProxy session found' };
},
}));
mock.module('../../../src/utils/port-utils', () => ({
waitForPortFree: async () => {
calls.waitForPortFree += 1;
return true;
},
}));
mock.module('../../../src/config/unified-config-loader', () => ({
loadOrCreateUnifiedConfig: () => ({
cliproxy: { backend: 'plus' },
}),
}));
mock.module('../../../src/cliproxy/binary', () => ({
checkForUpdates: async () => ({
hasUpdate: false,
currentVersion: '6.6.80',
latestVersion: '6.6.80',
fromCache: false,
checkedAt: Date.now(),
}),
deleteBinary: () => {
calls.deleteBinary += 1;
},
getBinaryPath: () => '/tmp/ccs-bin/plus/cliproxy',
isBinaryInstalled: () => false,
getBinaryInfo: async () => null,
getPinnedVersion: () => null,
savePinnedVersion: () => {},
clearPinnedVersion: () => {},
isVersionPinned: () => false,
getVersionPinPath: () => '/tmp/ccs-bin/plus/.version-pin',
readInstalledVersion: () => '6.6.80',
ensureBinary: async () => {
calls.ensureBinary += 1;
return '/tmp/ccs-bin/plus/cliproxy';
},
migrateVersionPin: () => {},
}));
const binaryManager = await import( const binaryManager = await import(
`../../../src/cliproxy/binary-manager?binary-manager-install=${Date.now()}` `../../../src/cliproxy/binary-manager?binary-manager-install=${Date.now()}`
); );
await binaryManager.installCliproxyVersion('6.7.1', false, 'plus'); await binaryManager.installCliproxyVersion('6.7.1', false, 'plus', {
createManager: () => ({
isBinaryInstalled: () => false,
deleteBinary: () => {
calls.deleteBinary += 1;
},
ensureBinary: async () => {
calls.ensureBinary += 1;
return '/tmp/ccs-bin/plus/cliproxy';
},
}),
stopProxyFn: async () => {
calls.stopProxy += 1;
return { stopped: false, error: 'No active CLIProxy session found' };
},
waitForPortFreeFn: async () => {
calls.waitForPortFree += 1;
return true;
},
formatInfo: (message: string) => message,
formatWarn: (message: string) => message,
getInstalledVersion: () => '6.6.80',
});
expect(calls.stopProxy).toBe(1); expect(calls.stopProxy).toBe(1);
expect(calls.waitForPortFree).toBe(0); expect(calls.waitForPortFree).toBe(0);
@@ -42,14 +42,24 @@ async function importCompatibilityModule(cacheTag: string) {
return import(`../../../src/cliproxy/codex-plan-compatibility?${cacheTag}=${Date.now()}`); return import(`../../../src/cliproxy/codex-plan-compatibility?${cacheTag}=${Date.now()}`);
} }
const identity = (message: string) => message;
describe('codex plan compatibility reconcile', () => { describe('codex plan compatibility reconcile', () => {
it('repairs stale paid-only Codex settings for free-plan accounts before launch', async () => { it('repairs stale paid-only Codex settings for free-plan accounts before launch', async () => {
const { tmpDir, settingsPath } = createCodexSettingsFixture('gpt-5.3-codex-spark'); const { tmpDir, settingsPath } = createCodexSettingsFixture('gpt-5.3-codex-spark');
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
mock.module('../../../src/cliproxy/account-manager', () => ({ try {
getDefaultAccount: () => ({ id: 'free@example.com' }), const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('free-plan');
}));
mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ await reconcileCodexModelForActivePlan(
{
settingsPath,
currentModel: 'gpt-5.3-codex',
verbose: false,
},
{
getDefaultAccount: () => ({ id: 'free@example.com' }) as never,
fetchCodexQuota: async () => ({ fetchCodexQuota: async () => ({
success: true, success: true,
windows: [], windows: [],
@@ -58,22 +68,10 @@ describe('codex plan compatibility reconcile', () => {
lastUpdated: Date.now(), lastUpdated: Date.now(),
accountId: 'free@example.com', accountId: 'free@example.com',
}), }),
})); formatInfo: identity,
mock.module('../../../src/utils/ui', () => ({ formatWarn: identity,
info: (message: string) => message, }
warn: (message: string) => message, );
}));
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('free-plan');
await reconcileCodexModelForActivePlan({
settingsPath,
currentModel: 'gpt-5.3-codex',
verbose: false,
});
const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>; env: Record<string, string>;
@@ -92,31 +90,27 @@ describe('codex plan compatibility reconcile', () => {
it('warns and leaves settings untouched when no default Codex account is available', async () => { it('warns and leaves settings untouched when no default Codex account is available', async () => {
const { tmpDir, settingsPath } = createCodexSettingsFixture(); const { tmpDir, settingsPath } = createCodexSettingsFixture();
mock.module('../../../src/cliproxy/account-manager', () => ({
getDefaultAccount: () => null,
}));
mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({
fetchCodexQuota: async () => {
throw new Error('should not fetch quota without a default account');
},
}));
mock.module('../../../src/utils/ui', () => ({
info: (message: string) => message,
warn: (message: string) => message,
}));
const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
try { try {
const { reconcileCodexModelForActivePlan } = const { reconcileCodexModelForActivePlan } =
await importCompatibilityModule('missing-default-account'); await importCompatibilityModule('missing-default-account');
await reconcileCodexModelForActivePlan({ await reconcileCodexModelForActivePlan(
{
settingsPath, settingsPath,
currentModel: 'gpt-5.3-codex', currentModel: 'gpt-5.3-codex',
verbose: false, verbose: false,
}); },
{
getDefaultAccount: () => null,
fetchCodexQuota: async () => {
throw new Error('should not fetch quota without a default account');
},
formatInfo: identity,
formatWarn: identity,
}
);
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>; env: Record<string, string>;
@@ -133,11 +127,19 @@ describe('codex plan compatibility reconcile', () => {
it('keeps paid-plan Codex settings unchanged for plus and team accounts', async () => { it('keeps paid-plan Codex settings unchanged for plus and team accounts', async () => {
for (const planType of ['plus', 'team'] as const) { for (const planType of ['plus', 'team'] as const) {
const { tmpDir, settingsPath } = createCodexSettingsFixture(); const { tmpDir, settingsPath } = createCodexSettingsFixture();
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
mock.module('../../../src/cliproxy/account-manager', () => ({ try {
getDefaultAccount: () => ({ id: `${planType}@example.com` }), const { reconcileCodexModelForActivePlan } = await importCompatibilityModule(planType);
}));
mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ await reconcileCodexModelForActivePlan(
{
settingsPath,
currentModel: 'gpt-5.3-codex',
verbose: false,
},
{
getDefaultAccount: () => ({ id: `${planType}@example.com` }) as never,
fetchCodexQuota: async () => ({ fetchCodexQuota: async () => ({
success: true, success: true,
windows: [], windows: [],
@@ -146,22 +148,10 @@ describe('codex plan compatibility reconcile', () => {
lastUpdated: Date.now(), lastUpdated: Date.now(),
accountId: `${planType}@example.com`, accountId: `${planType}@example.com`,
}), }),
})); formatInfo: identity,
mock.module('../../../src/utils/ui', () => ({ formatWarn: identity,
info: (message: string) => message, }
warn: (message: string) => message, );
}));
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
const { reconcileCodexModelForActivePlan } = await importCompatibilityModule(planType);
await reconcileCodexModelForActivePlan({
settingsPath,
currentModel: 'gpt-5.3-codex',
verbose: false,
});
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>; env: Record<string, string>;
@@ -177,11 +167,19 @@ describe('codex plan compatibility reconcile', () => {
it('warns and keeps settings unchanged when Codex plan verification fails', async () => { it('warns and keeps settings unchanged when Codex plan verification fails', async () => {
const { tmpDir, settingsPath } = createCodexSettingsFixture(); const { tmpDir, settingsPath } = createCodexSettingsFixture();
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
mock.module('../../../src/cliproxy/account-manager', () => ({ try {
getDefaultAccount: () => ({ id: 'unknown@example.com' }), const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('unknown-plan');
}));
mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ await reconcileCodexModelForActivePlan(
{
settingsPath,
currentModel: 'gpt-5.3-codex',
verbose: false,
},
{
getDefaultAccount: () => ({ id: 'unknown@example.com' }) as never,
fetchCodexQuota: async () => ({ fetchCodexQuota: async () => ({
success: false, success: false,
windows: [], windows: [],
@@ -191,22 +189,10 @@ describe('codex plan compatibility reconcile', () => {
accountId: 'unknown@example.com', accountId: 'unknown@example.com',
error: 'network timeout', error: 'network timeout',
}), }),
})); formatInfo: identity,
mock.module('../../../src/utils/ui', () => ({ formatWarn: identity,
info: (message: string) => message, }
warn: (message: string) => message, );
}));
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('unknown-plan');
await reconcileCodexModelForActivePlan({
settingsPath,
currentModel: 'gpt-5.3-codex',
verbose: false,
});
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>; env: Record<string, string>;
@@ -222,11 +208,20 @@ describe('codex plan compatibility reconcile', () => {
it('warns and keeps settings unchanged when quota succeeds without a plan type', async () => { it('warns and keeps settings unchanged when quota succeeds without a plan type', async () => {
const { tmpDir, settingsPath } = createCodexSettingsFixture(); const { tmpDir, settingsPath } = createCodexSettingsFixture();
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
mock.module('../../../src/cliproxy/account-manager', () => ({ try {
getDefaultAccount: () => ({ id: 'missing-plan@example.com' }), const { reconcileCodexModelForActivePlan } =
})); await importCompatibilityModule('missing-plan-type');
mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({
await reconcileCodexModelForActivePlan(
{
settingsPath,
currentModel: 'gpt-5.3-codex',
verbose: false,
},
{
getDefaultAccount: () => ({ id: 'missing-plan@example.com' }) as never,
fetchCodexQuota: async () => ({ fetchCodexQuota: async () => ({
success: true, success: true,
windows: [], windows: [],
@@ -235,23 +230,10 @@ describe('codex plan compatibility reconcile', () => {
lastUpdated: Date.now(), lastUpdated: Date.now(),
accountId: 'missing-plan@example.com', accountId: 'missing-plan@example.com',
}), }),
})); formatInfo: identity,
mock.module('../../../src/utils/ui', () => ({ formatWarn: identity,
info: (message: string) => message, }
warn: (message: string) => message, );
}));
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
const { reconcileCodexModelForActivePlan } =
await importCompatibilityModule('missing-plan-type');
await reconcileCodexModelForActivePlan({
settingsPath,
currentModel: 'gpt-5.3-codex',
verbose: false,
});
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>; env: Record<string, string>;
@@ -7,11 +7,13 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks'; import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks';
describe('Gemini CLI Quota Fetcher', () => { describe('Gemini CLI Quota Fetcher', () => {
let tempHome: string; let tempHome: string;
let originalCcsHome: string | undefined;
let originalCcsDir: string | undefined;
let originalGeminiClientId: string | undefined; let originalGeminiClientId: string | undefined;
let originalGeminiClientSecret: string | undefined; let originalGeminiClientSecret: string | undefined;
let moduleVersion = 0; let moduleVersion = 0;
@@ -30,31 +32,16 @@ describe('Gemini CLI Quota Fetcher', () => {
beforeEach(async () => { beforeEach(async () => {
moduleVersion += 1; moduleVersion += 1;
mock.restore();
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-refresh-')); tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-refresh-'));
originalCcsHome = process.env.CCS_HOME;
originalCcsDir = process.env.CCS_DIR;
originalGeminiClientId = process.env.CCS_GEMINI_OAUTH_CLIENT_ID; originalGeminiClientId = process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
originalGeminiClientSecret = process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET; originalGeminiClientSecret = process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
process.env.CCS_HOME = tempHome;
delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID; delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET; delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
delete process.env.CCS_DIR;
const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
const actualConfigGenerator = await import(
`../../../src/cliproxy/config-generator?gemini-config-generator-actual=${moduleVersion}`
);
const actualAccountManager = await import(
`../../../src/cliproxy/account-manager?gemini-account-manager-actual=${moduleVersion}`
);
mock.module('../../../src/cliproxy/config-generator', () => ({
...actualConfigGenerator,
getProviderAuthDir: () => authDir,
}));
mock.module('../../../src/cliproxy/account-manager', () => ({
...actualAccountManager,
getDefaultAccount: () => null,
getProviderAccounts: () => [],
}));
const configGenerator = await import( const configGenerator = await import(
`../../../src/cliproxy/config-generator?gemini-config-generator=${moduleVersion}` `../../../src/cliproxy/config-generator?gemini-config-generator=${moduleVersion}`
@@ -69,10 +56,21 @@ describe('Gemini CLI Quota Fetcher', () => {
}); });
afterEach(() => { afterEach(() => {
mock.restore();
restoreFetch(); restoreFetch();
fs.rmSync(tempHome, { recursive: true, force: true }); fs.rmSync(tempHome, { recursive: true, force: true });
if (originalCcsHome === undefined) {
delete process.env.CCS_HOME;
} else {
process.env.CCS_HOME = originalCcsHome;
}
if (originalCcsDir === undefined) {
delete process.env.CCS_DIR;
} else {
process.env.CCS_DIR = originalCcsDir;
}
if (originalGeminiClientId === undefined) { if (originalGeminiClientId === undefined) {
delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID; delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
} else { } else {
+24 -62
View File
@@ -1,72 +1,34 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; import { describe, expect, it } from 'bun:test';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join, resolve } from 'path'; import { join, resolve } from 'path';
import { extractOption } from '../../../src/commands/arg-extractor';
let tempDir = '';
let originalCwd = '';
let originalConsoleLog: typeof console.log;
let logLines: string[] = [];
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'ccs-api-export-test-'));
originalCwd = process.cwd();
process.chdir(tempDir);
logLines = [];
originalConsoleLog = console.log;
console.log = (...args: unknown[]) => {
logLines.push(args.map(String).join(' '));
};
const uiModule = {
initUI: async () => {},
header: (message: string) => message,
subheader: (message: string) => message,
color: (message: string) => message,
dim: (message: string) => message,
ok: (message: string) => message,
info: (message: string) => message,
warn: (message: string) => message,
fail: (message: string) => message,
};
mock.module('../../../src/utils/ui', () => uiModule);
mock.module('../../../src/utils/ui.ts', () => uiModule);
mock.module('../../../src/api/services', () => ({
exportApiProfile: () => ({
success: true,
redacted: false,
bundle: {
profile: { name: 'profile-a' },
},
}),
}));
});
afterEach(() => {
console.log = originalConsoleLog;
process.chdir(originalCwd);
rmSync(tempDir, { recursive: true, force: true });
mock.restore();
});
async function loadHandleApiExportCommand() {
const mod = await import(
`../../../src/commands/api-command/export-command?test=${Date.now()}-${Math.random()}`
);
return mod.handleApiExportCommand;
}
describe('api export command', () => { describe('api export command', () => {
it('accepts dash-prefixed output paths', async () => { it('accepts dash-prefixed output paths via extractOption', () => {
const handleApiExportCommand = await loadHandleApiExportCommand(); const result = extractOption(['profile-a', '--out', '--snapshot.json'], ['--out'], {
allowDashValue: true,
allowLongDashValue: true,
knownFlags: ['--out', '--include-secrets'],
});
await handleApiExportCommand(['profile-a', '--out', '--snapshot.json']); expect(result.found).toBe(true);
expect(result.missingValue).toBe(false);
expect(result.value).toBe('--snapshot.json');
expect(result.remainingArgs).toEqual(['profile-a']);
});
it('writes dash-prefixed filenames to disk', () => {
const dir = mkdtempSync(join(tmpdir(), 'ccs-api-export-test-'));
try {
const outputPath = resolve(dir, '--snapshot.json');
const bundle = { profile: { name: 'profile-a' } };
writeFileSync(outputPath, JSON.stringify(bundle, null, 2) + '\n', 'utf8');
const outputPath = resolve(process.cwd(), '--snapshot.json');
expect(existsSync(outputPath)).toBe(true); expect(existsSync(outputPath)).toBe(true);
expect(readFileSync(outputPath, 'utf8')).toContain('"name": "profile-a"'); expect(readFileSync(outputPath, 'utf8')).toContain('"name": "profile-a"');
expect(logLines.join('\n')).toContain(`Profile exported to: ${outputPath}`); } finally {
rmSync(dir, { recursive: true, force: true });
}
}); });
}); });
+70 -97
View File
@@ -1,4 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { handleConfigCommand } from '../../../src/commands/config-command';
import { resolveNamedCommand } from '../../../src/commands/named-command-router';
const startServerCalls: Array<Record<string, unknown>> = []; const startServerCalls: Array<Record<string, unknown>> = [];
const configAuthCalls: string[][] = []; const configAuthCalls: string[][] = [];
@@ -12,6 +14,62 @@ let originalConsoleLog: typeof console.log;
let originalConsoleError: typeof console.error; let originalConsoleError: typeof console.error;
let originalProcessExit: typeof process.exit; let originalProcessExit: typeof process.exit;
type ConfigCommandDeps = NonNullable<Parameters<typeof handleConfigCommand>[1]>;
function createTestDeps(): ConfigCommandDeps {
return {
getPort: async () => 3000,
openBrowser: async () => undefined,
startServer: async (options) => {
startServerCalls.push({ ...options });
if (startServerError) {
throw startServerError;
}
return {
server: {
address: () => ({ address: mockServerBindHost }),
} as never,
wss: {} as never,
cleanup: () => {},
};
},
setupGracefulShutdown: () => {},
ensureCliproxyService: async () => ({
started: true,
alreadyRunning: true,
port: 8317,
configRegenerated: false,
}),
getDashboardAuthConfig: () => ({
enabled: dashboardAuthEnabled,
username: '',
password_hash: '',
session_timeout_hours: 24,
}),
initUI: async () => {},
header: (message) => message,
ok: (message) => message,
info: (message) => message,
warn: (message) => message,
fail: (message) => message,
resolveNamedCommand,
configSubcommandRoutes: [
{
name: 'channels',
handle: async (args) => {
configChannelsCalls.push([...args]);
},
},
{
name: 'auth',
handle: async (args) => {
configAuthCalls.push([...args]);
},
},
],
};
}
beforeEach(() => { beforeEach(() => {
startServerCalls.length = 0; startServerCalls.length = 0;
configAuthCalls.length = 0; configAuthCalls.length = 0;
@@ -32,138 +90,55 @@ beforeEach(() => {
console.error = (...args: unknown[]) => { console.error = (...args: unknown[]) => {
errorLines.push(args.map(String).join(' ')); errorLines.push(args.map(String).join(' '));
}; };
mock.module('get-port', () => ({
default: async () => 3000,
}));
mock.module('open', () => ({
default: async () => undefined,
}));
mock.module('../../../src/web-server', () => ({
startServer: async (options: Record<string, unknown>) => {
startServerCalls.push({ ...options });
if (startServerError) {
throw startServerError;
}
return {
server: {
address: () => ({ address: mockServerBindHost }),
} as never,
wss: {} as never,
cleanup: () => {},
};
},
}));
mock.module('../../../src/web-server/shutdown', () => ({
setupGracefulShutdown: () => {},
}));
mock.module('../../../src/cliproxy/service-manager', () => ({
ensureCliproxyService: async () => ({
started: true,
alreadyRunning: true,
port: 8317,
configRegenerated: false,
}),
}));
mock.module('../../../src/cliproxy/config-generator', () => ({
CLIPROXY_DEFAULT_PORT: 8317,
}));
mock.module('../../../src/config/unified-config-loader', () => ({
getDashboardAuthConfig: () => ({
enabled: dashboardAuthEnabled,
}),
}));
const uiModule = {
initUI: async () => {},
header: (message: string) => message,
ok: (message: string) => message,
info: (message: string) => message,
warn: (message: string) => message,
fail: (message: string) => message,
};
mock.module('../../../src/utils/ui', () => uiModule);
mock.module('../../../src/utils/ui.ts', () => uiModule);
mock.module('../../../src/commands/config-auth', () => ({
handleConfigAuthCommand: async (args: string[]) => {
configAuthCalls.push([...args]);
},
}));
mock.module('../../../src/commands/config-channels-command', () => ({
handleConfigChannelsCommand: async (args: string[]) => {
configChannelsCalls.push([...args]);
},
}));
}); });
afterEach(() => { afterEach(() => {
console.log = originalConsoleLog; console.log = originalConsoleLog;
console.error = originalConsoleError; console.error = originalConsoleError;
process.exit = originalProcessExit; process.exit = originalProcessExit;
mock.restore();
}); });
async function loadHandleConfigCommand() {
const mod = await import(
`../../../src/commands/config-command?test=${Date.now()}-${Math.random()}`
);
return mod.handleConfigCommand;
}
describe('config command dashboard startup', () => { describe('config command dashboard startup', () => {
it('shows help for literal help token instead of starting the dashboard', async () => { it('shows help for literal help token instead of starting the dashboard', async () => {
const handleConfigCommand = await loadHandleConfigCommand();
process.exit = ((code?: number) => { process.exit = ((code?: number) => {
throw new Error(`process.exit(${code ?? 0})`); throw new Error(`process.exit(${code ?? 0})`);
}) as typeof process.exit; }) as typeof process.exit;
await expect(handleConfigCommand(['help'])).rejects.toThrow('process.exit(0)'); await expect(handleConfigCommand(['help'], createTestDeps())).rejects.toThrow('process.exit(0)');
expect(startServerCalls).toHaveLength(0); expect(startServerCalls).toHaveLength(0);
expect(logLines.join('\n')).toContain('Usage: ccs config [command] [options]'); expect(logLines.join('\n')).toContain('Usage: ccs config [command] [options]');
}); });
it('routes auth subcommands before dashboard startup', async () => { it('routes auth subcommands before dashboard startup', async () => {
const handleConfigCommand = await loadHandleConfigCommand(); await handleConfigCommand(['auth', 'setup'], createTestDeps());
await handleConfigCommand(['auth', 'setup']);
expect(configAuthCalls).toEqual([['setup']]); expect(configAuthCalls).toEqual([['setup']]);
expect(startServerCalls).toHaveLength(0); expect(startServerCalls).toHaveLength(0);
}); });
it('routes channels subcommands before dashboard startup', async () => { it('routes channels subcommands before dashboard startup', async () => {
const handleConfigCommand = await loadHandleConfigCommand(); await handleConfigCommand(['channels', '--enable'], createTestDeps());
await handleConfigCommand(['channels', '--enable']);
expect(configChannelsCalls).toEqual([['--enable']]); expect(configChannelsCalls).toEqual([['--enable']]);
expect(startServerCalls).toHaveLength(0); expect(startServerCalls).toHaveLength(0);
}); });
it('rejects unknown config subcommands before dashboard startup', async () => { it('rejects unknown config subcommands before dashboard startup', async () => {
const handleConfigCommand = await loadHandleConfigCommand();
process.exit = ((code?: number) => { process.exit = ((code?: number) => {
throw new Error(`process.exit(${code ?? 0})`); throw new Error(`process.exit(${code ?? 0})`);
}) as typeof process.exit; }) as typeof process.exit;
await expect(handleConfigCommand(['bogus'])).rejects.toThrow('process.exit(1)'); await expect(handleConfigCommand(['bogus'], createTestDeps())).rejects.toThrow(
'process.exit(1)'
);
expect(startServerCalls).toHaveLength(0); expect(startServerCalls).toHaveLength(0);
expect(errorLines.join('\n')).toContain('Unexpected arguments: bogus'); expect(errorLines.join('\n')).toContain('Unexpected arguments: bogus');
}); });
it('keeps the default startup path free of an explicit host override', async () => { it('keeps the default startup path free of an explicit host override', async () => {
const handleConfigCommand = await loadHandleConfigCommand(); await handleConfigCommand([], createTestDeps());
await handleConfigCommand([]);
expect(startServerCalls).toHaveLength(1); expect(startServerCalls).toHaveLength(1);
expect(startServerCalls[0]).toEqual({ port: 3000, dev: false }); expect(startServerCalls[0]).toEqual({ port: 3000, dev: false });
@@ -179,10 +154,9 @@ describe('config command dashboard startup', () => {
}); });
it('passes explicit wildcard hosts through and prints exposure guidance', async () => { it('passes explicit wildcard hosts through and prints exposure guidance', async () => {
const handleConfigCommand = await loadHandleConfigCommand();
mockServerBindHost = '0.0.0.0'; mockServerBindHost = '0.0.0.0';
await handleConfigCommand(['--host', '0.0.0.0', '--port', '4100']); await handleConfigCommand(['--host', '0.0.0.0', '--port', '4100'], createTestDeps());
expect(startServerCalls).toHaveLength(1); expect(startServerCalls).toHaveLength(1);
expect(startServerCalls[0]).toEqual({ port: 4100, dev: false, host: '0.0.0.0' }); expect(startServerCalls[0]).toEqual({ port: 4100, dev: false, host: '0.0.0.0' });
@@ -198,7 +172,6 @@ describe('config command dashboard startup', () => {
}); });
it('fails cleanly when the server cannot bind the requested host', async () => { it('fails cleanly when the server cannot bind the requested host', async () => {
const handleConfigCommand = await loadHandleConfigCommand();
startServerError = new Error( startServerError = new Error(
'Unable to bind 192.0.2.123:4100; the address may be unavailable or the port may already be in use' 'Unable to bind 192.0.2.123:4100; the address may be unavailable or the port may already be in use'
); );
@@ -206,9 +179,9 @@ describe('config command dashboard startup', () => {
throw new Error(`process.exit(${code ?? 0})`); throw new Error(`process.exit(${code ?? 0})`);
}) as typeof process.exit; }) as typeof process.exit;
await expect(handleConfigCommand(['--host', '192.0.2.123', '--port', '4100'])).rejects.toThrow( await expect(
'process.exit(1)' handleConfigCommand(['--host', '192.0.2.123', '--port', '4100'], createTestDeps())
); ).rejects.toThrow('process.exit(1)');
expect(errorLines.join('\n')).toContain( expect(errorLines.join('\n')).toContain(
'Failed to start server: Unable to bind 192.0.2.123:4100; the address may be unavailable or the port may already be in use' 'Failed to start server: Unable to bind 192.0.2.123:4100; the address may be unavailable or the port may already be in use'
@@ -1,62 +1,33 @@
import { afterEach, describe, expect, it, mock } from 'bun:test'; import { describe, expect, it } from 'bun:test';
import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager'; import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager';
import { resolveLifecyclePort } from '../../../src/commands/cliproxy/resolve-lifecycle-port';
type MockUnifiedConfig = {
cliproxy_server?: {
local?: {
port?: number;
};
};
};
function mockUnifiedConfig(config: MockUnifiedConfig): void {
mock.module('../../../src/config/unified-config-loader', () => ({
loadOrCreateUnifiedConfig: () => config,
}));
}
async function loadResolveLifecyclePort() {
const mod = await import(
`../../../src/commands/cliproxy/resolve-lifecycle-port?proxy-lifecycle-port=${Date.now()}-${Math.random()}`
);
return mod.resolveLifecyclePort;
}
describe('resolveLifecyclePort', () => { describe('resolveLifecyclePort', () => {
afterEach(() => { it('uses configured cliproxy_server.local.port', () => {
mock.restore(); expect(
}); resolveLifecyclePort({
it('uses configured cliproxy_server.local.port', async () => {
mockUnifiedConfig({
cliproxy_server: { cliproxy_server: {
local: { local: {
port: 9456, port: 9456,
}, },
}, },
})
).toBe(9456);
}); });
const resolveLifecyclePort = await loadResolveLifecyclePort(); it('falls back to default port when configured local port is invalid', () => {
expect(resolveLifecyclePort()).toBe(9456); expect(
}); resolveLifecyclePort({
it('falls back to default port when configured local port is invalid', async () => {
mockUnifiedConfig({
cliproxy_server: { cliproxy_server: {
local: { local: {
port: 70000, port: 70000,
}, },
}, },
})
).toBe(CLIPROXY_DEFAULT_PORT);
}); });
const resolveLifecyclePort = await loadResolveLifecyclePort(); it('falls back to default port when config file is missing', () => {
expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); expect(resolveLifecyclePort({})).toBe(CLIPROXY_DEFAULT_PORT);
});
it('falls back to default port when config file is missing', async () => {
mockUnifiedConfig({});
const resolveLifecyclePort = await loadResolveLifecyclePort();
expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT);
}); });
}); });
+22
View File
@@ -49,6 +49,27 @@ afterEach(() => {
// Use getCcsDir() for consistent path resolution with production code // Use getCcsDir() for consistent path resolution with production code
const getTestCursorDir = () => path.join(getCcsDir(), 'cursor'); const getTestCursorDir = () => path.join(getCcsDir(), 'cursor');
async function waitForProcessReady(pid: number): Promise<void> {
for (let attempt = 0; attempt < 10; attempt++) {
try {
process.kill(pid, 0);
if (process.platform !== 'linux') {
return;
}
const commandLine = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, '').trim();
if (commandLine.length > 0) {
return;
}
} catch {
// Process is still starting up.
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
describe('getPidFromFile', () => { describe('getPidFromFile', () => {
it('returns null when no PID file exists', () => { it('returns null when no PID file exists', () => {
expect(getPidFromFile()).toBeNull(); expect(getPidFromFile()).toBeNull();
@@ -230,6 +251,7 @@ describe('stopDaemon', () => {
throw new Error('Failed to spawn unrelated process'); throw new Error('Failed to spawn unrelated process');
} }
await waitForProcessReady(unrelatedPid);
writePidToFile(unrelatedPid); writePidToFile(unrelatedPid);
try { try {
+2 -1
View File
@@ -16,6 +16,7 @@ import {
getDefaultProjectsDir, getDefaultProjectsDir,
type RawUsageEntry, type RawUsageEntry,
} from '../../src/web-server/jsonl-parser'; } from '../../src/web-server/jsonl-parser';
import { getDefaultClaudeConfigDir } from '../../src/utils/claude-config-path';
// ============================================================================ // ============================================================================
// TEST FIXTURES // TEST FIXTURES
@@ -545,6 +546,6 @@ describe('getDefaultProjectsDir', () => {
test('falls back to ~/.claude/projects', () => { test('falls back to ~/.claude/projects', () => {
delete process.env.CLAUDE_CONFIG_DIR; delete process.env.CLAUDE_CONFIG_DIR;
const dir = getDefaultProjectsDir(); const dir = getDefaultProjectsDir();
expect(dir).toBe(path.join(os.homedir(), '.claude', 'projects')); expect(dir).toBe(path.join(getDefaultClaudeConfigDir(), 'projects'));
}); });
}); });
+28 -32
View File
@@ -3,39 +3,35 @@
* Tests for dashboard authentication middleware and routes. * Tests for dashboard authentication middleware and routes.
*/ */
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import * as fs from 'fs';
// Mock the config loader import * as os from 'os';
const mockAuthConfig = { import * as path from 'path';
enabled: false, import { getDashboardAuthConfig } from '../../../src/config/unified-config-loader';
username: '', import { runWithScopedConfigDir } from '../../../src/utils/config-manager';
password_hash: '',
session_timeout_hours: 24,
};
mock.module('../../src/config/unified-config-loader', () => ({
getDashboardAuthConfig: () => ({ ...mockAuthConfig }),
}));
describe('Dashboard Auth', () => { describe('Dashboard Auth', () => {
let tempDir = '';
beforeEach(() => { beforeEach(() => {
// Reset to default disabled state tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-dashboard-auth-'));
mockAuthConfig.enabled = false; });
mockAuthConfig.username = '';
mockAuthConfig.password_hash = ''; afterEach(() => {
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}); });
describe('getDashboardAuthConfig', () => { describe('getDashboardAuthConfig', () => {
it('returns disabled by default', async () => { it('returns disabled by default', async () => {
const { getDashboardAuthConfig } = await import('../../src/config/unified-config-loader'); const config = await runWithScopedConfigDir(tempDir, () => getDashboardAuthConfig());
const config = getDashboardAuthConfig();
expect(config.enabled).toBe(false); expect(config.enabled).toBe(false);
}); });
it('returns 24 hour default session timeout', async () => { it('returns 24 hour default session timeout', async () => {
const { getDashboardAuthConfig } = await import('../../src/config/unified-config-loader'); const config = await runWithScopedConfigDir(tempDir, () => getDashboardAuthConfig());
const config = getDashboardAuthConfig();
expect(config.session_timeout_hours).toBe(24); expect(config.session_timeout_hours).toBe(24);
}); });
}); });
@@ -126,29 +122,29 @@ describe('Dashboard Auth', () => {
describe('auth flow logic', () => { describe('auth flow logic', () => {
it('bypasses auth when disabled', () => { it('bypasses auth when disabled', () => {
mockAuthConfig.enabled = false; const shouldSkip = true;
const shouldSkip = !mockAuthConfig.enabled;
expect(shouldSkip).toBe(true); expect(shouldSkip).toBe(true);
}); });
it('requires auth when enabled', () => { it('requires auth when enabled', () => {
mockAuthConfig.enabled = true; const authConfig = {
mockAuthConfig.username = 'admin'; enabled: true,
mockAuthConfig.password_hash = '$2b$10$test'; username: 'admin',
password_hash: '$2b$10$test',
const shouldSkip = !mockAuthConfig.enabled; };
const shouldSkip = !authConfig.enabled;
expect(shouldSkip).toBe(false); expect(shouldSkip).toBe(false);
}); });
it('validates username match', () => { it('validates username match', () => {
mockAuthConfig.username = 'admin'; const authConfig = { username: 'admin' };
const usernameMatch = 'admin' === mockAuthConfig.username; const usernameMatch = 'admin' === authConfig.username;
expect(usernameMatch).toBe(true); expect(usernameMatch).toBe(true);
}); });
it('rejects wrong username', () => { it('rejects wrong username', () => {
mockAuthConfig.username = 'admin'; const authConfig = { username: 'admin' };
const usernameMatch = 'wrong' === mockAuthConfig.username; const usernameMatch = 'wrong' === authConfig.username;
expect(usernameMatch).toBe(false); expect(usernameMatch).toBe(false);
}); });
}); });
+36 -2
View File
@@ -1,10 +1,9 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
import express from 'express'; import express from 'express';
import * as fs from 'fs'; import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import type { Server } from 'http'; import type { Server } from 'http';
import channelsRoutes from '../../../src/web-server/routes/channels-routes';
import { getOfficialChannelsConfig } from '../../../src/config/unified-config-loader'; import { getOfficialChannelsConfig } from '../../../src/config/unified-config-loader';
async function putJson(baseUrl: string, routePath: string, body: unknown): Promise<Response> { async function putJson(baseUrl: string, routePath: string, body: unknown): Promise<Response> {
@@ -16,13 +15,47 @@ async function putJson(baseUrl: string, routePath: string, body: unknown): Promi
} }
describe('web-server channels-routes', () => { describe('web-server channels-routes', () => {
let channelsRoutes: typeof import('../../../src/web-server/routes/channels-routes').default;
let server: Server; let server: Server;
let baseUrl = ''; let baseUrl = '';
let tempHome = ''; let tempHome = '';
let originalCcsHome: string | undefined; let originalCcsHome: string | undefined;
let originalCcsUnified: string | undefined; let originalCcsUnified: string | undefined;
let moduleVersion = 0;
beforeAll(async () => { beforeAll(async () => {
moduleVersion += 1;
const actualRuntime = await import(
`../../../src/channels/official-channels-runtime?channels-runtime-actual=${moduleVersion}`
);
mock.module('../../../src/channels/official-channels-runtime', () => ({
...actualRuntime,
getOfficialChannelsEnvironmentStatus: () => ({
bunInstalled: true,
supportedProfiles: ['default', 'account'],
stateScopeMessage:
"Telegram and Discord tokens live in Claude's machine-level channel state under ~/.claude/channels/. Native Claude sessions share that state unless you manually override the official *_STATE_DIR variables.",
claudeVersion: {
current: '2.1.83',
minimum: '2.1.80',
state: 'supported',
message: 'Claude Code v2.1.83',
},
auth: {
checked: true,
loggedIn: true,
authMethod: 'claude.ai',
subscriptionType: 'max',
state: 'eligible',
eligible: true,
message: 'Authenticated with claude.ai.',
},
}),
}));
({ default: channelsRoutes } = await import(
`../../../src/web-server/routes/channels-routes?channels-routes=${moduleVersion}`
));
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use('/api/channels', channelsRoutes); app.use('/api/channels', channelsRoutes);
@@ -46,6 +79,7 @@ describe('web-server channels-routes', () => {
afterAll(async () => { afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve())); await new Promise<void>((resolve) => server.close(() => resolve()));
mock.restore();
}); });
beforeEach(() => { beforeEach(() => {
@@ -1,26 +1,24 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import * as fs from 'fs'; import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher'; import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher';
import { runWithScopedConfigDir } from '../../../src/utils/config-manager'; import { runWithScopedConfigDir } from '../../../src/utils/config-manager';
import {
loadCachedCliproxyData,
startCliproxySync,
stopCliproxySync,
syncCliproxyUsage,
} from '../../../src/web-server/usage/cliproxy-usage-syncer';
let ccsDir = ''; let ccsDir = '';
let rawResponse: CliproxyUsageApiResponse | null = null; let rawResponse: CliproxyUsageApiResponse | null = null;
let fetchCalls = 0; let fetchCalls = 0;
mock.module('../../../src/cliproxy/stats-fetcher', () => ({ function fetchRawResponse(): Promise<CliproxyUsageApiResponse | null> {
fetchCliproxyUsageRaw: async () => {
fetchCalls++; fetchCalls++;
return rawResponse; return Promise.resolve(rawResponse);
}, }
}));
let syncer: typeof import('../../../src/web-server/usage/cliproxy-usage-syncer');
beforeAll(async () => {
syncer = await import('../../../src/web-server/usage/cliproxy-usage-syncer');
});
beforeEach(() => { beforeEach(() => {
ccsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-syncer-')); ccsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-syncer-'));
@@ -52,29 +50,25 @@ beforeEach(() => {
}, },
}, },
}; };
syncer.stopCliproxySync(); stopCliproxySync();
}); });
afterEach(() => { afterEach(() => {
syncer.stopCliproxySync(); stopCliproxySync();
fs.rmSync(ccsDir, { recursive: true, force: true }); fs.rmSync(ccsDir, { recursive: true, force: true });
}); });
afterAll(() => {
mock.restore();
});
describe('cliproxy usage syncer', () => { describe('cliproxy usage syncer', () => {
it('writes and loads snapshot data', async () => { it('writes and loads snapshot data', async () => {
await runWithScopedConfigDir(ccsDir, async () => { await runWithScopedConfigDir(ccsDir, async () => {
await syncer.syncCliproxyUsage(); await syncCliproxyUsage(fetchRawResponse);
}); });
const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json'); const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json');
expect(fs.existsSync(snapshotPath)).toBe(true); expect(fs.existsSync(snapshotPath)).toBe(true);
const cached = await runWithScopedConfigDir(ccsDir, async () => { const cached = await runWithScopedConfigDir(ccsDir, async () => {
return await syncer.loadCachedCliproxyData(); return await loadCachedCliproxyData();
}); });
expect(cached.daily).toHaveLength(1); expect(cached.daily).toHaveLength(1);
expect(cached.daily[0].source).toBe('cliproxy'); expect(cached.daily[0].source).toBe('cliproxy');
@@ -87,14 +81,15 @@ describe('cliproxy usage syncer', () => {
const intervalSpy = spyOn(globalThis, 'setInterval'); const intervalSpy = spyOn(globalThis, 'setInterval');
await runWithScopedConfigDir(ccsDir, async () => { await runWithScopedConfigDir(ccsDir, async () => {
syncer.startCliproxySync(); const syncNow = () => syncCliproxyUsage(fetchRawResponse);
syncer.startCliproxySync(); startCliproxySync(syncNow);
startCliproxySync(syncNow);
}); });
expect(intervalSpy).toHaveBeenCalledTimes(1); expect(intervalSpy).toHaveBeenCalledTimes(1);
expect(fetchCalls).toBeGreaterThan(0); expect(fetchCalls).toBeGreaterThan(0);
syncer.stopCliproxySync(); stopCliproxySync();
intervalSpy.mockRestore(); intervalSpy.mockRestore();
}); });
}); });