mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-19 08:14:52 +00:00
Merge remote-tracking branch 'origin/dev' into pr-689-anthropic-direct-api
# Conflicts: # ui/src/components/profiles/profile-dialog.tsx
This commit is contained in:
commit
5bcad30762
89 files changed
+6407
-726
No files matched your search
@@ -0,0 +1,203 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { spawn } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const HOOK_PATH = path.join(__dirname, '../../lib/hooks/image-analyzer-transformer.cjs');
|
||||
const TEST_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-hook-it-'));
|
||||
const TEST_PNG_PATH = path.join(TEST_DIR, 'thinking-block-test.png');
|
||||
const CLIPROXY_API_KEY = 'test-api-key-12345';
|
||||
|
||||
interface HookResult {
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
interface MockRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
interface MockResponse {
|
||||
statusCode: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
let mockServer: http.Server | null = null;
|
||||
let mockPort = 0;
|
||||
let requests: MockRequest[] = [];
|
||||
let queuedResponses: MockResponse[] = [];
|
||||
|
||||
function createTestPng(filepath: string): void {
|
||||
const png = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
|
||||
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
|
||||
0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8,
|
||||
0xcf, 0xc0, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, 0xb4, 0x00, 0x00, 0x00,
|
||||
0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
]);
|
||||
fs.writeFileSync(filepath, png);
|
||||
}
|
||||
|
||||
function enqueueResponses(...responses: MockResponse[]): void {
|
||||
queuedResponses = responses;
|
||||
}
|
||||
|
||||
function invokeHook(env: Record<string, string> = {}): Promise<HookResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('node', [HOOK_PATH], {
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
|
||||
CCS_CLIPROXY_PORT: String(mockPort),
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||
CCS_PROFILE_TYPE: 'cliproxy',
|
||||
CCS_CURRENT_PROVIDER: 'codex',
|
||||
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'codex:gpt-5.1-codex-mini,agy:gemini-2.5-flash',
|
||||
...env,
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error('Hook timed out'));
|
||||
}, 10000);
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code: code ?? -1, stdout, stderr });
|
||||
});
|
||||
|
||||
child.stdin.end(
|
||||
JSON.stringify({
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: TEST_PNG_PATH },
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
createTestPng(TEST_PNG_PATH);
|
||||
|
||||
mockServer = http.createServer((req, res) => {
|
||||
if (req.method === 'GET' && req.url === '/') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok' }));
|
||||
return;
|
||||
}
|
||||
|
||||
let body = '';
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on('end', () => {
|
||||
requests.push({
|
||||
method: req.method || 'GET',
|
||||
path: req.url || '/',
|
||||
body: body ? JSON.parse(body) : null,
|
||||
});
|
||||
|
||||
const nextResponse = queuedResponses.shift() || {
|
||||
statusCode: 200,
|
||||
body: { content: [{ type: 'text', text: 'default description' }] },
|
||||
};
|
||||
|
||||
res.writeHead(nextResponse.statusCode, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(nextResponse.body));
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
mockServer?.once('error', reject);
|
||||
mockServer?.listen(0, '127.0.0.1', () => {
|
||||
const address = mockServer?.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
reject(new Error('Failed to resolve mock server port'));
|
||||
return;
|
||||
}
|
||||
mockPort = address.port;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
requests = [];
|
||||
enqueueResponses({
|
||||
statusCode: 200,
|
||||
body: { content: [{ type: 'text', text: 'default description' }] },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => mockServer?.close(() => resolve()));
|
||||
fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('image analyzer hook regression coverage', () => {
|
||||
it('uses the first text block after thinking blocks', async () => {
|
||||
enqueueResponses({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'internal reasoning' },
|
||||
{ type: 'text', text: 'blue square with white border' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await invokeHook();
|
||||
|
||||
expect(result.code).toBe(2);
|
||||
expect(requests).toHaveLength(1);
|
||||
const output = JSON.parse(result.stdout);
|
||||
expect(output.hookSpecificOutput.permissionDecisionReason).toContain(
|
||||
'blue square with white border'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not retry onto a cross-provider default model when a provider-specific model is set', async () => {
|
||||
enqueueResponses({
|
||||
statusCode: 500,
|
||||
body: { error: { message: 'mock failure' } },
|
||||
});
|
||||
|
||||
const result = await invokeHook();
|
||||
|
||||
expect(result.code).toBe(2);
|
||||
expect(requests).toHaveLength(1);
|
||||
expect((requests[0].body as { model: string }).model).toBe('gpt-5.1-codex-mini');
|
||||
});
|
||||
|
||||
it('skips analysis before contacting CLIProxy when the current provider has no mapped vision model', async () => {
|
||||
const result = await invokeHook({
|
||||
CCS_CURRENT_PROVIDER: 'unknown-provider',
|
||||
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-flash',
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(requests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
copyApiProfile,
|
||||
discoverApiProfileOrphans,
|
||||
exportApiProfile,
|
||||
importApiProfileBundle,
|
||||
registerApiProfileOrphans,
|
||||
} from '../../../src/api/services/profile-lifecycle-service';
|
||||
|
||||
describe('profile lifecycle service', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-lifecycle-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('discovers only API profile orphans (skips registered and reserved names)', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.json'),
|
||||
JSON.stringify({ profiles: { glm: '~/.ccs/glm.settings.json' } }, null, 2) + '\n'
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'glm.settings.json'),
|
||||
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) +
|
||||
'\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'extra.settings.json'),
|
||||
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) +
|
||||
'\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'gemini.settings.json'),
|
||||
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) +
|
||||
'\n'
|
||||
);
|
||||
|
||||
const result = discoverApiProfileOrphans();
|
||||
expect(result.orphans.map((orphan) => orphan.name)).toEqual(['extra']);
|
||||
});
|
||||
|
||||
it('treats explicit empty names list as no-op during orphan registration', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'lonely.settings.json'),
|
||||
JSON.stringify({ 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');
|
||||
|
||||
const result = registerApiProfileOrphans({ names: [] });
|
||||
expect(result.registered).toEqual([]);
|
||||
expect(result.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it('redacts all sensitive env values during export when includeSecrets=false', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.json'),
|
||||
JSON.stringify({ profiles: { glm: '~/.ccs/glm.settings.json' } }, null, 2) + '\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'glm.settings.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.example.com',
|
||||
ANTHROPIC_AUTH_TOKEN: 'token-1',
|
||||
OPENROUTER_API_KEY: 'token-2',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
|
||||
const result = exportApiProfile('glm', false);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.bundle?.settings).toBeDefined();
|
||||
|
||||
const env = (result.bundle?.settings.env as Record<string, unknown>) || {};
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('__CCS_REDACTED__');
|
||||
expect(env.OPENROUTER_API_KEY).toBe('__CCS_REDACTED__');
|
||||
});
|
||||
|
||||
it('rejects invalid source profile names in copy flow', () => {
|
||||
const result = copyApiProfile('../escape', 'safe-name');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Invalid source profile name');
|
||||
});
|
||||
|
||||
it('rejects import bundle with invalid profile target', () => {
|
||||
const result = importApiProfileBundle({
|
||||
schemaVersion: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
profile: { name: 'glm', target: 'invalid-target' },
|
||||
settings: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.example.com',
|
||||
ANTHROPIC_AUTH_TOKEN: 'token',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Invalid bundle profile target');
|
||||
});
|
||||
|
||||
it('clears and warns for all redacted sensitive env keys on import', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n');
|
||||
|
||||
const result = importApiProfileBundle({
|
||||
schemaVersion: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
profile: { name: 'redacted-import', target: 'claude' },
|
||||
settings: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.example.com',
|
||||
ANTHROPIC_AUTH_TOKEN: '__CCS_REDACTED__',
|
||||
OPENROUTER_API_KEY: '__CCS_REDACTED__',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.warnings?.length).toBeGreaterThan(0);
|
||||
|
||||
const settingsPath = path.join(ccsDir, 'redacted-import.settings.json');
|
||||
const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
expect(parsed.env.ANTHROPIC_AUTH_TOKEN).toBe('');
|
||||
expect(parsed.env.OPENROUTER_API_KEY).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,22 @@ describe('auth command args parsing', () => {
|
||||
expect(parsed.deeperContinuity).toBe(true);
|
||||
});
|
||||
|
||||
it('parses bare flag for create command', () => {
|
||||
const parsed = parseArgs(['work', '--bare']);
|
||||
|
||||
expect(parsed.profileName).toBe('work');
|
||||
expect(parsed.bare).toBe(true);
|
||||
});
|
||||
|
||||
it('parses bare flag with shared context flags', () => {
|
||||
const parsed = parseArgs(['work', '--bare', '--share-context', '--context-group', 'sprint-a']);
|
||||
|
||||
expect(parsed.profileName).toBe('work');
|
||||
expect(parsed.bare).toBe(true);
|
||||
expect(parsed.shareContext).toBe(true);
|
||||
expect(parsed.contextGroup).toBe('sprint-a');
|
||||
});
|
||||
|
||||
it('tracks unknown flags and keeps positional profile intact', () => {
|
||||
const parsed = parseArgs(['--foo', 'bar', 'work']);
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('resolveProfileContinuityInheritance', () => {
|
||||
mode: 'shared',
|
||||
group: 'team-alpha',
|
||||
continuityMode: 'deeper',
|
||||
});
|
||||
}, { bare: false });
|
||||
});
|
||||
|
||||
it('supports legacy continuity_inherit_from_account fallback', async () => {
|
||||
@@ -255,7 +255,46 @@ describe('resolveProfileContinuityInheritance', () => {
|
||||
});
|
||||
expect(ensureInstanceSpy).toHaveBeenCalledWith('pro', {
|
||||
mode: 'isolated',
|
||||
}, { bare: false });
|
||||
});
|
||||
|
||||
it('propagates bare source-account mode when inheriting continuity', async () => {
|
||||
spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({
|
||||
version: 8,
|
||||
continuity: {
|
||||
inherit_from_account: {
|
||||
glm: 'pro',
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof configLoader.loadOrCreateUnifiedConfig>);
|
||||
|
||||
const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue(
|
||||
'/tmp/.ccs/instances/pro'
|
||||
);
|
||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
|
||||
pro: {
|
||||
type: 'account',
|
||||
created: '2026-03-01T00:00:00.000Z',
|
||||
last_used: null,
|
||||
bare: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resolveProfileContinuityInheritance({
|
||||
profileName: 'glm',
|
||||
profileType: 'settings',
|
||||
target: 'claude',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceAccount: 'pro',
|
||||
claudeConfigDir: '/tmp/.ccs/instances/pro',
|
||||
});
|
||||
expect(ensureInstanceSpy).toHaveBeenCalledWith(
|
||||
'pro',
|
||||
{ mode: 'isolated' },
|
||||
{ bare: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('does not apply km settings alias mapping to kimi cliproxy profile', async () => {
|
||||
|
||||
@@ -161,7 +161,7 @@ describe('ProfileDetector', () => {
|
||||
const mockUnifiedConfig = {
|
||||
version: 2,
|
||||
accounts: {
|
||||
work: { created: '2025-01-01', last_used: '2025-01-02' },
|
||||
work: { created: '2025-01-01', last_used: '2025-01-02', bare: true },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -176,6 +176,7 @@ describe('ProfileDetector', () => {
|
||||
expect(result.name).toBe('work');
|
||||
expect(result.profile).toBeDefined();
|
||||
expect((result.profile as any).type).toBe('account');
|
||||
expect((result.profile as any).bare).toBe(true);
|
||||
} finally {
|
||||
isUnifiedModeSpy.mockRestore();
|
||||
loadUnifiedConfigSpy.mockRestore();
|
||||
|
||||
@@ -91,4 +91,43 @@ describe('profile-registry context normalization', () => {
|
||||
expect(accounts.work.context_group).toBeUndefined();
|
||||
expect(accounts.work.continuity_mode).toBe('standard');
|
||||
});
|
||||
|
||||
it('persists bare flag for legacy profiles', () => {
|
||||
const registry = new ProfileRegistry();
|
||||
registry.createProfile('work', { type: 'account', bare: true });
|
||||
|
||||
const profile = registry.getProfile('work');
|
||||
expect(profile.bare).toBe(true);
|
||||
});
|
||||
|
||||
it('persists bare flag for unified accounts and merged projection', () => {
|
||||
process.env.CCS_UNIFIED_CONFIG = '1';
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-03-05T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
' bare: true',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
|
||||
const accounts = registry.getAllAccountsUnified();
|
||||
expect(accounts.work.bare).toBe(true);
|
||||
|
||||
const merged = registry.getAllProfilesMerged();
|
||||
expect(merged.work.bare).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@
|
||||
* - Email masking
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -18,18 +18,22 @@ import {
|
||||
writeQuotaWarning,
|
||||
maskEmail,
|
||||
} from '../../../src/cliproxy/account-safety';
|
||||
import { sanitizeEmail } from '../../../src/cliproxy/auth-utils';
|
||||
|
||||
// Setup test isolation
|
||||
let tmpDir: string;
|
||||
let origCcsHome: string | undefined;
|
||||
let originalFetch: typeof fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-exhaust-'));
|
||||
origCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpDir;
|
||||
originalFetch = global.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
if (origCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = origCcsHome;
|
||||
} else {
|
||||
@@ -61,6 +65,25 @@ function writeConfig(quotaConfig: unknown): void {
|
||||
);
|
||||
}
|
||||
|
||||
function writeClaudeAuth(accountId: string, accessToken: string): void {
|
||||
const authDir = path.join(tmpDir, '.ccs', 'cliproxy', 'auth');
|
||||
const tokenFile = `claude-${sanitizeEmail(accountId)}.json`;
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, tokenFile),
|
||||
JSON.stringify(
|
||||
{
|
||||
access_token: accessToken,
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'claude',
|
||||
email: accountId,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
describe('Quota Exhaustion Handlers', () => {
|
||||
describe('writeQuotaWarning', () => {
|
||||
it('should write to stderr with box format', async () => {
|
||||
@@ -230,6 +253,69 @@ describe('Quota Exhaustion Handlers', () => {
|
||||
expect(result.reason).toContain('no alternatives');
|
||||
});
|
||||
|
||||
it('should switch Claude accounts when fallback quota is unavailable but auth is valid', async () => {
|
||||
writeRegistry({
|
||||
claude: {
|
||||
default: 'exhausted@example.com',
|
||||
accounts: {
|
||||
'exhausted@example.com': {
|
||||
email: 'exhausted@example.com',
|
||||
tokenFile: `claude-${sanitizeEmail('exhausted@example.com')}.json`,
|
||||
},
|
||||
'fallback@example.com': {
|
||||
email: 'fallback@example.com',
|
||||
tokenFile: `claude-${sanitizeEmail('fallback@example.com')}.json`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
writeConfig({
|
||||
mode: 'auto',
|
||||
auto: {
|
||||
tier_priority: ['ultra', 'pro', 'free'],
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 10,
|
||||
preflight_check: true,
|
||||
},
|
||||
runtime_monitor: {
|
||||
enabled: true,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 10,
|
||||
},
|
||||
});
|
||||
|
||||
writeClaudeAuth('exhausted@example.com', 'exhausted-token');
|
||||
writeClaudeAuth('fallback@example.com', 'fallback-token');
|
||||
|
||||
global.fetch = mock((_url: string, options?: RequestInit) => {
|
||||
const authHeader = new Headers(options?.headers).get('Authorization') ?? '';
|
||||
if (authHeader === 'Bearer fallback-token') {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: 'OAuth authentication is currently not supported.',
|
||||
},
|
||||
}),
|
||||
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response('', { status: 500 }));
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await handleQuotaExhaustion('claude', 'exhausted@example.com', 10);
|
||||
const { getDefaultAccount } = await import('../../../src/cliproxy/account-manager');
|
||||
|
||||
expect(result.switchedTo).toBe('fallback@example.com');
|
||||
expect(getDefaultAccount('claude')?.id).toBe('fallback@example.com');
|
||||
});
|
||||
|
||||
it('should write warning to stderr', async () => {
|
||||
writeRegistry({
|
||||
agy: {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
getManagementAuthUrlPath,
|
||||
getPasteCallbackStartPath,
|
||||
getManagementOAuthCallbackPath,
|
||||
} from '../../../src/cliproxy/auth/auth-types';
|
||||
|
||||
describe('auth-types paste-callback start path', () => {
|
||||
it('maps providers to CLIProxyAPI management auth-url routes', () => {
|
||||
expect(getPasteCallbackStartPath('gemini')).toBe(
|
||||
'/v0/management/gemini-cli-auth-url?is_webui=true'
|
||||
);
|
||||
expect(getPasteCallbackStartPath('codex')).toBe('/v0/management/codex-auth-url?is_webui=true');
|
||||
expect(getPasteCallbackStartPath('agy')).toBe(
|
||||
'/v0/management/antigravity-auth-url?is_webui=true'
|
||||
);
|
||||
expect(getPasteCallbackStartPath('claude')).toBe(
|
||||
'/v0/management/anthropic-auth-url?is_webui=true'
|
||||
);
|
||||
expect(getPasteCallbackStartPath('ghcp')).toBe('/v0/management/github-auth-url?is_webui=true');
|
||||
});
|
||||
|
||||
it('keeps Kiro on the legacy start route for paste-callback mode', () => {
|
||||
expect(getPasteCallbackStartPath('kiro')).toBe('/oauth/kiro/start');
|
||||
});
|
||||
|
||||
it('still exposes the generic management auth-url helper', () => {
|
||||
expect(getManagementAuthUrlPath('kiro')).toBe('/v0/management/kiro-auth-url?is_webui=true');
|
||||
});
|
||||
|
||||
it('uses CLIProxyAPI management oauth-callback route', () => {
|
||||
expect(getManagementOAuthCallbackPath()).toBe('/v0/management/oauth-callback');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
describe('installCliproxyVersion', () => {
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('attempts to stop the proxy even when there is no tracked running session', async () => {
|
||||
const calls = {
|
||||
stopProxy: 0,
|
||||
waitForPortFree: 0,
|
||||
deleteBinary: 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(
|
||||
`../../../src/cliproxy/binary-manager?binary-manager-install=${Date.now()}`
|
||||
);
|
||||
|
||||
await binaryManager.installCliproxyVersion('6.7.1', false, 'plus');
|
||||
|
||||
expect(calls.stopProxy).toBe(1);
|
||||
expect(calls.waitForPortFree).toBe(0);
|
||||
expect(calls.deleteBinary).toBe(0);
|
||||
expect(calls.ensureBinary).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -106,6 +106,33 @@ describe('Model Catalog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Claude models', () => {
|
||||
it('contains Claude provider catalog', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert(MODEL_CATALOG.claude, 'Should have Claude provider');
|
||||
assert.strictEqual(MODEL_CATALOG.claude.provider, 'claude');
|
||||
assert.strictEqual(MODEL_CATALOG.claude.displayName, 'Claude (Anthropic)');
|
||||
});
|
||||
it('has correct default model', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert.strictEqual(MODEL_CATALOG.claude.defaultModel, 'claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('includes Claude Sonnet 4.6', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const sonnet = MODEL_CATALOG.claude.models.find((m) => m.id === 'claude-sonnet-4-6');
|
||||
assert(sonnet, 'Should include Claude Sonnet 4.6');
|
||||
assert.strictEqual(sonnet.name, 'Claude Sonnet 4.6');
|
||||
});
|
||||
|
||||
it('retains previous 4.5 snapshot models for explicit selection', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const ids = MODEL_CATALOG.claude.models.map((m) => m.id);
|
||||
assert(ids.includes('claude-opus-4-5-20251101'));
|
||||
assert(ids.includes('claude-sonnet-4-5-20250929'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gemini models', () => {
|
||||
it('has correct default model', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import type { ProxyTarget } from '../../../src/cliproxy/proxy-target-resolver';
|
||||
import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks';
|
||||
|
||||
const remoteTarget: ProxyTarget = {
|
||||
host: 'proxy.example.com',
|
||||
port: 8317,
|
||||
protocol: 'https',
|
||||
managementKey: 'test-mgmt-key',
|
||||
isRemote: true,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch();
|
||||
});
|
||||
|
||||
describe('requestPasteCallbackStart', () => {
|
||||
it('uses management auth-url route for non-kiro providers', async () => {
|
||||
mockFetch([
|
||||
{
|
||||
url: /\/v0\/management\/anthropic-auth-url\?is_webui=true$/,
|
||||
response: { auth_url: 'https://auth.example.com/claude' },
|
||||
},
|
||||
]);
|
||||
|
||||
const { requestPasteCallbackStart } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?request-claude-start=${Date.now()}`
|
||||
);
|
||||
const startData = await requestPasteCallbackStart('claude', remoteTarget);
|
||||
|
||||
expect(startData.auth_url).toBe('https://auth.example.com/claude');
|
||||
|
||||
const [request] = getCapturedFetchRequests();
|
||||
expect(request.url).toBe(
|
||||
'https://proxy.example.com:8317/v0/management/anthropic-auth-url?is_webui=true'
|
||||
);
|
||||
expect(request.method).toBe('GET');
|
||||
expect(request.headers['Authorization']).toBe('Bearer test-mgmt-key');
|
||||
expect(request.headers['Content-Type']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps kiro on the legacy start route with POST', async () => {
|
||||
mockFetch([
|
||||
{
|
||||
url: /\/oauth\/kiro\/start$/,
|
||||
method: 'POST',
|
||||
response: { auth_url: 'https://auth.example.com/kiro' },
|
||||
},
|
||||
]);
|
||||
|
||||
const { requestPasteCallbackStart } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?request-kiro-start=${Date.now()}`
|
||||
);
|
||||
const startData = await requestPasteCallbackStart('kiro', remoteTarget);
|
||||
|
||||
expect(startData.auth_url).toBe('https://auth.example.com/kiro');
|
||||
|
||||
const [request] = getCapturedFetchRequests();
|
||||
expect(request.url).toBe('https://proxy.example.com:8317/oauth/kiro/start');
|
||||
expect(request.method).toBe('POST');
|
||||
expect(request.headers['Authorization']).toBe('Bearer test-mgmt-key');
|
||||
expect(request.headers['Content-Type']).toBe('application/json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePasteCallbackAuthUrl', () => {
|
||||
it('returns the immediate auth URL without polling', async () => {
|
||||
const { resolvePasteCallbackAuthUrl } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?resolve-immediate-auth-url=${Date.now()}`
|
||||
);
|
||||
const authUrl = await resolvePasteCallbackAuthUrl(
|
||||
remoteTarget,
|
||||
{ auth_url: 'https://auth.example.com/direct' },
|
||||
50,
|
||||
0
|
||||
);
|
||||
|
||||
expect(authUrl).toBe('https://auth.example.com/direct');
|
||||
expect(getCapturedFetchRequests()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('polls management status when the start response only returns state', async () => {
|
||||
mockFetch([
|
||||
{
|
||||
url: /\/v0\/management\/get-auth-status\?state=state-123$/,
|
||||
response: { status: 'auth_url', auth_url: 'https://auth.example.com/polled' },
|
||||
},
|
||||
]);
|
||||
|
||||
const { resolvePasteCallbackAuthUrl } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?resolve-polled-auth-url=${Date.now()}`
|
||||
);
|
||||
const authUrl = await resolvePasteCallbackAuthUrl(remoteTarget, { state: 'state-123' }, 50, 0);
|
||||
|
||||
expect(authUrl).toBe('https://auth.example.com/polled');
|
||||
|
||||
const [request] = getCapturedFetchRequests();
|
||||
expect(request.url).toBe(
|
||||
'https://proxy.example.com:8317/v0/management/get-auth-status?state=state-123'
|
||||
);
|
||||
expect(request.method).toBe('GET');
|
||||
expect(request.headers['Authorization']).toBe('Bearer test-mgmt-key');
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getQuotaCacheStats,
|
||||
QUOTA_CACHE_TTL_MS,
|
||||
} from '../../../src/cliproxy/quota-response-cache';
|
||||
import { shouldCacheQuotaResult } from '../../../src/web-server/routes/cliproxy-stats-routes';
|
||||
import type { GeminiCliQuotaResult, CodexQuotaResult } from '../../../src/cliproxy/quota-types';
|
||||
|
||||
describe('Quota Caching Integration', () => {
|
||||
@@ -308,6 +309,60 @@ describe('Quota Caching Integration', () => {
|
||||
|
||||
expect(cached?.error).toBe('API error: 503');
|
||||
});
|
||||
|
||||
it('should cache stable auth and workspace failures', () => {
|
||||
expect(
|
||||
shouldCacheQuotaResult({
|
||||
success: false,
|
||||
needsReauth: true,
|
||||
error: 'Token expired',
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldCacheQuotaResult({
|
||||
success: false,
|
||||
httpStatus: 402,
|
||||
error: 'Workspace deactivated (HTTP 402)',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip transient failures marked retryable or temporary by status', () => {
|
||||
expect(
|
||||
shouldCacheQuotaResult({
|
||||
success: false,
|
||||
retryable: true,
|
||||
error: 'Rate limited - try again later',
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldCacheQuotaResult({
|
||||
success: false,
|
||||
httpStatus: 429,
|
||||
error: 'Rate limited - try again later',
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldCacheQuotaResult({
|
||||
success: false,
|
||||
httpStatus: 503,
|
||||
error: 'Codex quota service unavailable (HTTP 503)',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should respect explicit non-retryable failures even without message pattern matches', () => {
|
||||
expect(
|
||||
shouldCacheQuotaResult({
|
||||
success: false,
|
||||
retryable: false,
|
||||
error: 'Unknown upstream error',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('high-volume scenarios', () => {
|
||||
|
||||
@@ -360,6 +360,136 @@ describe('Claude Quota Fetcher', () => {
|
||||
expect(result.error).toContain('Authentication');
|
||||
});
|
||||
|
||||
it('treats OAuth-unsupported 401 as policy-limits unavailable', async () => {
|
||||
createClaudeAccount(
|
||||
'claude-oauth-unsupported@example.com',
|
||||
{
|
||||
access_token: 'oauth-token',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'claude',
|
||||
},
|
||||
'claude'
|
||||
);
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'authentication_error',
|
||||
message: 'OAuth authentication is currently not supported.',
|
||||
},
|
||||
}),
|
||||
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-oauth-unsupported@example.com');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.needsReauth).toBeUndefined();
|
||||
expect(result.windows).toHaveLength(0);
|
||||
expect(result.coreUsage?.fiveHour).toBeNull();
|
||||
expect(result.coreUsage?.weekly).toBeNull();
|
||||
});
|
||||
|
||||
it('treats root-level OAuth-unsupported 401 message as policy-limits unavailable', async () => {
|
||||
createClaudeAccount('claude-oauth-root-message@example.com', {
|
||||
access_token: 'oauth-token',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'claude',
|
||||
});
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
message: 'OAuth authentication is currently not supported.',
|
||||
}),
|
||||
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-oauth-root-message@example.com');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.needsReauth).toBeUndefined();
|
||||
expect(result.windows).toHaveLength(0);
|
||||
expect(result.coreUsage?.fiveHour).toBeNull();
|
||||
expect(result.coreUsage?.weekly).toBeNull();
|
||||
});
|
||||
|
||||
it('treats plain-text OAuth-unsupported 401 as policy-limits unavailable', async () => {
|
||||
createClaudeAccount('claude-oauth-plaintext@example.com', {
|
||||
access_token: 'oauth-token',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'claude',
|
||||
});
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response('OAuth authentication is currently not supported.', { status: 401 })
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-oauth-plaintext@example.com');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.needsReauth).toBeUndefined();
|
||||
expect(result.windows).toHaveLength(0);
|
||||
expect(result.coreUsage?.fiveHour).toBeNull();
|
||||
expect(result.coreUsage?.weekly).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps non-matching 401 payloads in the reauth path', async () => {
|
||||
createClaudeAccount('claude-auth-other-401@example.com', {
|
||||
access_token: 'oauth-token',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'claude',
|
||||
});
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
type: 'authentication_error',
|
||||
message: 'Token revoked.',
|
||||
},
|
||||
}),
|
||||
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-auth-other-401@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.needsReauth).toBe(true);
|
||||
expect(result.error).toContain('Authentication');
|
||||
});
|
||||
|
||||
it('treats 404 policy limits responses as unavailable but successful', async () => {
|
||||
createClaudeAccount('claude-policy-limits-404@example.com', {
|
||||
access_token: 'oauth-token',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'claude',
|
||||
});
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response('', { status: 404 }))) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-policy-limits-404@example.com');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.needsReauth).toBeUndefined();
|
||||
expect(result.windows).toHaveLength(0);
|
||||
expect(result.coreUsage?.fiveHour).toBeNull();
|
||||
expect(result.coreUsage?.weekly).toBeNull();
|
||||
});
|
||||
|
||||
it('fails fast when auth file has no token', async () => {
|
||||
createClaudeAccount('claude-missing@example.com', {
|
||||
access_token: ' ',
|
||||
|
||||
@@ -4,13 +4,48 @@
|
||||
* Tests for Codex quota window parsing and transformation logic
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
buildCodexQuotaWindows,
|
||||
buildCodexCoreUsageSummary,
|
||||
fetchCodexQuota,
|
||||
getUnknownCodexWindowLabels,
|
||||
} from '../../../src/cliproxy/quota-fetcher-codex';
|
||||
|
||||
let tmpDir: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalFetch: typeof fetch;
|
||||
|
||||
function createCodexAccount(
|
||||
accountId: string,
|
||||
tokenPayload: Record<string, unknown>,
|
||||
tokenFile = `codex-${accountId.replace(/[@.]/g, '_')}.json`
|
||||
): void {
|
||||
const authDir = path.join(tmpDir, '.ccs', 'cliproxy', 'auth');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(tokenPayload));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-quota-test-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpDir;
|
||||
originalFetch = global.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Codex Quota Fetcher', () => {
|
||||
describe('buildCodexQuotaWindows', () => {
|
||||
it('should parse snake_case API response', () => {
|
||||
@@ -307,4 +342,184 @@ describe('Codex Quota Fetcher', () => {
|
||||
expect(labels).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchCodexQuota failure mapping', () => {
|
||||
function createValidCodexAccount(email: string, accountId = `workspace-${email}`): void {
|
||||
createCodexAccount(email, {
|
||||
access_token: 'test-token',
|
||||
account_id: accountId,
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
email,
|
||||
type: 'codex',
|
||||
});
|
||||
}
|
||||
|
||||
it('maps deactivated workspace 402 responses to structured metadata', async () => {
|
||||
createValidCodexAccount('workspace@example.com', 'workspace-123');
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ detail: { code: 'deactivated_workspace' } }), {
|
||||
status: 402,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchCodexQuota('workspace@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.httpStatus).toBe(402);
|
||||
expect(result.errorCode).toBe('deactivated_workspace');
|
||||
expect(result.error).toContain('Workspace deactivated');
|
||||
expect(result.actionHint).toContain('active ChatGPT workspace');
|
||||
expect(result.retryable).toBe(false);
|
||||
});
|
||||
|
||||
it('maps 401 responses to reauth-required metadata', async () => {
|
||||
createValidCodexAccount('reauth@example.com', 'workspace-reauth');
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response('', { status: 401 }))) as typeof fetch;
|
||||
|
||||
const result = await fetchCodexQuota('reauth@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.httpStatus).toBe(401);
|
||||
expect(result.errorCode).toBe('reauth_required');
|
||||
expect(result.needsReauth).toBe(true);
|
||||
expect(result.actionHint).toContain('ccs cliproxy auth codex');
|
||||
});
|
||||
|
||||
it('maps 403 responses to forbidden metadata', async () => {
|
||||
createValidCodexAccount('forbidden@example.com', 'workspace-forbidden');
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ detail: { code: 'quota_api_forbidden' } }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchCodexQuota('forbidden@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.httpStatus).toBe(403);
|
||||
expect(result.errorCode).toBe('quota_api_forbidden');
|
||||
expect(result.isForbidden).toBe(true);
|
||||
expect(result.retryable).toBe(false);
|
||||
});
|
||||
|
||||
it('maps 429 responses to retryable rate-limit metadata', async () => {
|
||||
createValidCodexAccount('rate-limit@example.com', 'workspace-rate-limit');
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ detail: { code: 'rate_limited' } }), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchCodexQuota('rate-limit@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.httpStatus).toBe(429);
|
||||
expect(result.errorCode).toBe('rate_limited');
|
||||
expect(result.retryable).toBe(true);
|
||||
expect(result.actionHint).toContain('Retry');
|
||||
});
|
||||
|
||||
it('maps 5xx responses to retryable provider-unavailable metadata', async () => {
|
||||
createValidCodexAccount('outage@example.com', 'workspace-outage');
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ detail: { code: 'upstream_failure' } }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchCodexQuota('outage@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.httpStatus).toBe(503);
|
||||
expect(result.errorCode).toBe('upstream_failure');
|
||||
expect(result.retryable).toBe(true);
|
||||
expect(result.error).toContain('service unavailable');
|
||||
});
|
||||
|
||||
it('maps unknown upstream statuses to a non-retryable structured error', async () => {
|
||||
createValidCodexAccount('teapot@example.com', 'workspace-teapot');
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response('{"message":"Strange upstream response"}', {
|
||||
status: 418,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchCodexQuota('teapot@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.httpStatus).toBe(418);
|
||||
expect(result.errorCode).toBe('unknown_upstream_error');
|
||||
expect(result.retryable).toBe(false);
|
||||
expect(result.error).toBe('Strange upstream response');
|
||||
});
|
||||
|
||||
it('sanitizes and truncates raw upstream error detail before returning it', async () => {
|
||||
createValidCodexAccount('sanitized@example.com', 'workspace-sanitized');
|
||||
const leakedToken = 'secret-token-value-123';
|
||||
const oversizedMessage = 'x'.repeat(400);
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
message: 'Upstream failure',
|
||||
access_token: leakedToken,
|
||||
extra: oversizedMessage,
|
||||
}),
|
||||
{
|
||||
status: 418,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchCodexQuota('sanitized@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorDetail).toBeDefined();
|
||||
expect(result.errorDetail).not.toContain(leakedToken);
|
||||
expect(result.errorDetail).toContain('[redacted]');
|
||||
expect(result.errorDetail?.endsWith('...[truncated]')).toBe(true);
|
||||
});
|
||||
|
||||
it('omits raw HTML upstream bodies from the returned error detail', async () => {
|
||||
createValidCodexAccount('html@example.com', 'workspace-html');
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response('<html><body>bad gateway</body></html>', {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
})
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchCodexQuota('html@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorDetail).toBe('[HTML error response omitted]');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,75 @@
|
||||
* Tests for Gemini CLI bucket parsing and transformation logic
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
buildGeminiCliBuckets,
|
||||
resolveGeminiCliProjectId,
|
||||
} from '../../../src/cliproxy/quota-fetcher-gemini-cli';
|
||||
import { refreshGeminiToken } from '../../../src/cliproxy/auth/gemini-token-refresh';
|
||||
import { getProviderAuthDir } from '../../../src/cliproxy/config-generator';
|
||||
import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks';
|
||||
|
||||
describe('Gemini CLI Quota Fetcher', () => {
|
||||
let tempHome: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalGeminiClientId: string | undefined;
|
||||
let originalGeminiClientSecret: string | undefined;
|
||||
|
||||
function writeGeminiToken(token: Record<string, unknown>): string {
|
||||
const authDir = getProviderAuthDir('gemini');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
const tokenPath = path.join(authDir, 'gemini-test.json');
|
||||
fs.writeFileSync(tokenPath, JSON.stringify(token, null, 2));
|
||||
return tokenPath;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-refresh-'));
|
||||
originalHome = process.env.HOME;
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalGeminiClientId = process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
|
||||
originalGeminiClientSecret = process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
|
||||
|
||||
process.env.HOME = tempHome;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
|
||||
delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch();
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
|
||||
if (originalGeminiClientId === undefined) {
|
||||
delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
|
||||
} else {
|
||||
process.env.CCS_GEMINI_OAUTH_CLIENT_ID = originalGeminiClientId;
|
||||
}
|
||||
|
||||
if (originalGeminiClientSecret === undefined) {
|
||||
delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
|
||||
} else {
|
||||
process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET = originalGeminiClientSecret;
|
||||
}
|
||||
});
|
||||
|
||||
describe('resolveGeminiCliProjectId', () => {
|
||||
it('should extract project ID from account field', () => {
|
||||
const account = 'user@example.com (cloudaicompanion-abc-123)';
|
||||
@@ -191,4 +253,85 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
expect(flashBucket!.modelIds).toContain('gemini-2.5-flash');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshGeminiToken', () => {
|
||||
it('uses OAuth client metadata stored in the token file', async () => {
|
||||
writeGeminiToken({
|
||||
type: 'gemini',
|
||||
email: 'file@example.com',
|
||||
token: {
|
||||
access_token: 'old-token',
|
||||
refresh_token: 'refresh-from-file',
|
||||
expiry: Date.now() - 1000,
|
||||
client_id: 'file-client-id',
|
||||
client_secret: 'file-client-secret',
|
||||
token_uri: 'https://oauth2.googleapis.com/token',
|
||||
},
|
||||
});
|
||||
|
||||
mockFetch([
|
||||
{
|
||||
url: 'https://oauth2.googleapis.com/token',
|
||||
method: 'POST',
|
||||
response: { access_token: 'fresh-token', expires_in: 1800 },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await refreshGeminiToken();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const [request] = getCapturedFetchRequests();
|
||||
expect(request.body).toContain('client_id=file-client-id');
|
||||
expect(request.body).toContain('client_secret=file-client-secret');
|
||||
expect(request.body).toContain('refresh_token=refresh-from-file');
|
||||
});
|
||||
|
||||
it('falls back to CCS_GEMINI_OAUTH_CLIENT_* env vars when token metadata is missing', async () => {
|
||||
process.env.CCS_GEMINI_OAUTH_CLIENT_ID = 'env-client-id';
|
||||
process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET = 'env-client-secret';
|
||||
|
||||
writeGeminiToken({
|
||||
type: 'gemini',
|
||||
email: 'env@example.com',
|
||||
token: {
|
||||
access_token: 'old-token',
|
||||
refresh_token: 'refresh-from-file',
|
||||
expiry: Date.now() - 1000,
|
||||
},
|
||||
});
|
||||
|
||||
mockFetch([
|
||||
{
|
||||
url: 'https://oauth2.googleapis.com/token',
|
||||
method: 'POST',
|
||||
response: { access_token: 'fresh-token', expires_in: 1800 },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await refreshGeminiToken();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const [request] = getCapturedFetchRequests();
|
||||
expect(request.body).toContain('client_id=env-client-id');
|
||||
expect(request.body).toContain('client_secret=env-client-secret');
|
||||
});
|
||||
|
||||
it('returns a clear error when no refresh client credentials are available', async () => {
|
||||
writeGeminiToken({
|
||||
type: 'gemini',
|
||||
email: 'missing@example.com',
|
||||
token: {
|
||||
access_token: 'old-token',
|
||||
refresh_token: 'refresh-from-file',
|
||||
expiry: Date.now() - 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await refreshGeminiToken();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('CCS_GEMINI_OAUTH_CLIENT_ID');
|
||||
expect(result.error).toContain('CCS_GEMINI_OAUTH_CLIENT_SECRET');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -326,6 +326,227 @@ describe('ToolSanitizationProxy Integration', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('strips Gemini-unsupported top-level tool fields while keeping schema sanitization', async () => {
|
||||
const proxy = new ToolSanitizationProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
||||
});
|
||||
const port = await proxy.start();
|
||||
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/api/provider/gemini/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'gemini-2.5-pro',
|
||||
tools: [
|
||||
{
|
||||
name: 'tool__with__duplicate__duplicate',
|
||||
description: 'Test description',
|
||||
strict: true,
|
||||
input_examples: [{ command: 'ls -la' }],
|
||||
type: 'custom',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
defer_loading: true,
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: {
|
||||
type: 'string',
|
||||
examples: ['ls -la'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const sentTools = (lastRequest!.body as Record<string, unknown>).tools as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(sentTools[0].name).toBe('tool__with__duplicate');
|
||||
expect(sentTools[0].description).toBe('Test description');
|
||||
expect(sentTools[0].strict).toBeUndefined();
|
||||
expect(sentTools[0].input_examples).toBeUndefined();
|
||||
expect(sentTools[0].type).toBeUndefined();
|
||||
expect(sentTools[0].cache_control).toBeUndefined();
|
||||
expect(sentTools[0].defer_loading).toBeUndefined();
|
||||
expect(sentTools[0].input_schema).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
proxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('strips only Codex-unsupported top-level tool fields before forwarding', async () => {
|
||||
const proxy = new ToolSanitizationProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
||||
});
|
||||
const port = await proxy.start();
|
||||
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/api/provider/codex/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-5.3-codex',
|
||||
tools: [
|
||||
{
|
||||
name: 'codex_tool',
|
||||
description: 'Codex test',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
defer_loading: true,
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
prompt: {
|
||||
type: 'string',
|
||||
examples: ['fix the failing test'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const sentTools = (lastRequest!.body as Record<string, unknown>).tools as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(sentTools[0].name).toBe('codex_tool');
|
||||
expect(sentTools[0].description).toBe('Codex test');
|
||||
expect(sentTools[0].cache_control).toBeUndefined();
|
||||
expect(sentTools[0].defer_loading).toBe(true);
|
||||
expect(sentTools[0].input_schema).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
prompt: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
proxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
for (const model of [
|
||||
'gpt-5.3-codex-xhigh',
|
||||
'gpt-5.1-codex-mini',
|
||||
'gpt-5.1-codex',
|
||||
'gpt-5-codex',
|
||||
]) {
|
||||
it(`strips only Codex-unsupported top-level tool fields on root model-routed request (${model})`, async () => {
|
||||
const proxy = new ToolSanitizationProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
||||
});
|
||||
const port = await proxy.start();
|
||||
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
tools: [
|
||||
{
|
||||
name: 'root_codex_tool',
|
||||
description: 'Root-routed Codex test',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
defer_loading: true,
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
prompt: {
|
||||
type: 'string',
|
||||
examples: ['fix the failing test'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const sentTools = (lastRequest!.body as Record<string, unknown>).tools as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(sentTools[0].name).toBe('root_codex_tool');
|
||||
expect(sentTools[0].description).toBe('Root-routed Codex test');
|
||||
expect(sentTools[0].cache_control).toBeUndefined();
|
||||
expect(sentTools[0].defer_loading).toBe(true);
|
||||
expect(sentTools[0].input_schema).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
prompt: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
proxy.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('preserves top-level tool fields for non-target root routes', async () => {
|
||||
const proxy = new ToolSanitizationProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
||||
});
|
||||
const port = await proxy.start();
|
||||
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'claude-sonnet-4-6',
|
||||
tools: [
|
||||
{
|
||||
name: 'claude_tool__duplicate__duplicate',
|
||||
description: 'Anthropic passthrough test',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
defer_loading: true,
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
prompt: {
|
||||
type: 'string',
|
||||
examples: ['keep these top-level fields'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const sentTools = (lastRequest!.body as Record<string, unknown>).tools as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(sentTools[0].name).toBe('claude_tool__duplicate');
|
||||
expect(sentTools[0].description).toBe('Anthropic passthrough test');
|
||||
expect(sentTools[0].cache_control).toEqual({ type: 'ephemeral' });
|
||||
expect(sentTools[0].defer_loading).toBe(true);
|
||||
expect(sentTools[0].input_schema).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
prompt: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
proxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves other tool properties during sanitization', async () => {
|
||||
const proxy = new ToolSanitizationProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
||||
@@ -341,7 +562,10 @@ describe('ToolSanitizationProxy Integration', () => {
|
||||
{
|
||||
name: 'foo__bar__bar',
|
||||
description: 'Test description',
|
||||
input_schema: { type: 'object', properties: { x: { type: 'string' } } },
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: { x: { type: 'string', examples: ['demo'] } },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -112,7 +112,7 @@ cliproxy:
|
||||
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.1-codex-mini');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.1-codex-mini');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.1-codex-mini');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.1-codex-mini');
|
||||
expect(settings.env.CUSTOM_FLAG).toBe('keep-me');
|
||||
expect(settings.hooks.PreToolUse.length).toBe(1);
|
||||
|
||||
@@ -134,7 +134,7 @@ cliproxy:
|
||||
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.1-codex-mini');
|
||||
|
||||
const modelOnly = updateVariant('demo', { model: 'gpt-5.3-codex' });
|
||||
expect(modelOnly.success).toBe(true);
|
||||
@@ -145,6 +145,6 @@ cliproxy:
|
||||
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.1-codex-mini');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import { handleSyncCommand } from '../../../src/commands/sync-command';
|
||||
import { ClaudeDirInstaller } from '../../../src/utils/claude-dir-installer';
|
||||
import { ClaudeSymlinkManager } from '../../../src/utils/claude-symlink-manager';
|
||||
import SharedManager from '../../../src/management/shared-manager';
|
||||
import { InstanceManager } from '../../../src/management/instance-manager';
|
||||
import ProfileRegistry from '../../../src/auth/profile-registry';
|
||||
import type { ProfileMetadata } from '../../../src/types';
|
||||
|
||||
function profile(metadata: Partial<ProfileMetadata> = {}): ProfileMetadata {
|
||||
return {
|
||||
type: 'account',
|
||||
created: '2026-03-05T00:00:00.000Z',
|
||||
last_used: null,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
describe('sync command MCP sync behavior', () => {
|
||||
let originalProcessExit: typeof process.exit;
|
||||
|
||||
beforeEach(() => {
|
||||
originalProcessExit = process.exit;
|
||||
process.exit = ((code?: number) => {
|
||||
throw new Error(`process.exit(${code ?? 0})`);
|
||||
}) as typeof process.exit;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exit = originalProcessExit;
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('syncs MCP servers only to non-bare profiles', async () => {
|
||||
spyOn(ClaudeDirInstaller.prototype, 'install').mockReturnValue(true);
|
||||
spyOn(ClaudeDirInstaller.prototype, 'cleanupDeprecated').mockReturnValue({
|
||||
success: true,
|
||||
cleanedFiles: [],
|
||||
});
|
||||
spyOn(ClaudeSymlinkManager.prototype, 'install').mockImplementation(() => {});
|
||||
spyOn(SharedManager.prototype, 'ensureSharedDirectories').mockImplementation(() => {});
|
||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
|
||||
work: profile(),
|
||||
sandbox: profile({ bare: true }),
|
||||
personal: profile(),
|
||||
});
|
||||
spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true);
|
||||
|
||||
const getInstancePathSpy = spyOn(InstanceManager.prototype, 'getInstancePath').mockImplementation(
|
||||
(name: string) => `/tmp/${name}`
|
||||
);
|
||||
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
||||
() => true
|
||||
);
|
||||
|
||||
await expect(handleSyncCommand()).rejects.toThrow('process.exit(0)');
|
||||
|
||||
expect(getInstancePathSpy.mock.calls.map((call) => call[0])).toEqual(['work', 'personal']);
|
||||
expect(syncMcpSpy.mock.calls.map((call) => call[0])).toEqual(['/tmp/work', '/tmp/personal']);
|
||||
});
|
||||
|
||||
it('skips MCP sync when all profiles are bare', async () => {
|
||||
spyOn(ClaudeDirInstaller.prototype, 'install').mockReturnValue(true);
|
||||
spyOn(ClaudeDirInstaller.prototype, 'cleanupDeprecated').mockReturnValue({
|
||||
success: true,
|
||||
cleanedFiles: [],
|
||||
});
|
||||
spyOn(ClaudeSymlinkManager.prototype, 'install').mockImplementation(() => {});
|
||||
spyOn(SharedManager.prototype, 'ensureSharedDirectories').mockImplementation(() => {});
|
||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
|
||||
sandbox: profile({ bare: true }),
|
||||
experiment: profile({ bare: true }),
|
||||
});
|
||||
spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true);
|
||||
|
||||
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
||||
() => true
|
||||
);
|
||||
|
||||
await expect(handleSyncCommand()).rejects.toThrow('process.exit(0)');
|
||||
|
||||
expect(syncMcpSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import * as http from 'http';
|
||||
import { DEFAULT_COPILOT_MODELS, fetchModelsFromDaemon } from '../../../src/copilot/copilot-models';
|
||||
|
||||
describe('fetchModelsFromDaemon', () => {
|
||||
it('falls back to defaults when daemon is unreachable', async () => {
|
||||
const models = await fetchModelsFromDaemon(9999);
|
||||
expect(models).toEqual(DEFAULT_COPILOT_MODELS);
|
||||
});
|
||||
|
||||
it('falls back to defaults when daemon returns invalid JSON', async () => {
|
||||
const server = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end('{not-valid-json');
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve test server port');
|
||||
}
|
||||
|
||||
try {
|
||||
const models = await fetchModelsFromDaemon(address.port);
|
||||
expect(models).toEqual(DEFAULT_COPILOT_MODELS);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it('parses live limits from daemon metadata and preserves known model metadata', async () => {
|
||||
const server = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: 'claude-sonnet-4.5',
|
||||
name: 'Claude Sonnet 4.5',
|
||||
capabilities: {
|
||||
limits: {
|
||||
max_context_window_tokens: 128000,
|
||||
max_prompt_tokens: 128000,
|
||||
max_output_tokens: 64000,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4.6',
|
||||
name: 'Claude Sonnet 4.6',
|
||||
capabilities: {
|
||||
limits: {
|
||||
max_context_window_tokens: 128000,
|
||||
max_prompt_tokens: 128000,
|
||||
max_output_tokens: 64000,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
name: 'invalid-entry',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve test server port');
|
||||
}
|
||||
|
||||
try {
|
||||
const models = await fetchModelsFromDaemon(address.port);
|
||||
expect(models).toHaveLength(2);
|
||||
|
||||
const knownModel = models.find((model) => model.id === 'claude-sonnet-4.5');
|
||||
expect(knownModel?.provider).toBe('anthropic');
|
||||
expect(knownModel?.minPlan).toBe('pro');
|
||||
expect(knownModel?.multiplier).toBe(1);
|
||||
expect(knownModel?.limits).toEqual({
|
||||
maxContextWindowTokens: 128000,
|
||||
maxPromptTokens: 128000,
|
||||
maxOutputTokens: 64000,
|
||||
});
|
||||
|
||||
const liveOnlyModel = models.find((model) => model.id === 'claude-sonnet-4.6');
|
||||
expect(liveOnlyModel?.name).toBe('Claude Sonnet 4.6');
|
||||
expect(liveOnlyModel?.provider).toBe('anthropic');
|
||||
expect(liveOnlyModel?.limits?.maxPromptTokens).toBe(128000);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { InstanceManager } from '../../src/management/instance-manager';
|
||||
import SharedManager from '../../src/management/shared-manager';
|
||||
|
||||
describe('InstanceManager MCP sync', () => {
|
||||
let tempRoot = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCcsDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-mcp-test-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
|
||||
process.env.CCS_HOME = tempRoot;
|
||||
delete process.env.CCS_DIR;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir;
|
||||
else delete process.env.CCS_DIR;
|
||||
|
||||
if (tempRoot && fs.existsSync(tempRoot)) {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('merges global MCP servers and preserves instance-specific overrides', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tempRoot, '.claude.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
globalOnly: { command: 'global-cmd' },
|
||||
shared: { command: 'global-shared' },
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const manager = new InstanceManager();
|
||||
const instancePath = manager.getInstancePath('work');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(instancePath, '.claude.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
shared: { command: 'instance-shared' },
|
||||
instanceOnly: { command: 'instance-only' },
|
||||
},
|
||||
otherKey: 'keep-me',
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const synced = manager.syncMcpServers(instancePath);
|
||||
expect(synced).toBe(true);
|
||||
|
||||
const instanceContent = JSON.parse(fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8'));
|
||||
expect(instanceContent.otherKey).toBe('keep-me');
|
||||
expect(instanceContent.mcpServers).toEqual({
|
||||
globalOnly: { command: 'global-cmd' },
|
||||
shared: { command: 'instance-shared' },
|
||||
instanceOnly: { command: 'instance-only' },
|
||||
});
|
||||
});
|
||||
|
||||
it('logs warning when global MCP sync fails', () => {
|
||||
fs.writeFileSync(path.join(tempRoot, '.claude.json'), '{invalid-json', 'utf8');
|
||||
const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const manager = new InstanceManager();
|
||||
const instancePath = manager.getInstancePath('work');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
|
||||
const synced = manager.syncMcpServers(instancePath);
|
||||
|
||||
expect(synced).toBe(false);
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(warnSpy.mock.calls[0]?.[0] || '')).toContain('MCP sync skipped');
|
||||
});
|
||||
|
||||
it('skips shared symlinks and MCP sync for bare instance creation', async () => {
|
||||
const linkSharedSpy = spyOn(SharedManager.prototype, 'linkSharedDirectories').mockImplementation(
|
||||
() => {}
|
||||
);
|
||||
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
|
||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
||||
() => false
|
||||
);
|
||||
|
||||
const manager = new InstanceManager();
|
||||
await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true });
|
||||
|
||||
expect(linkSharedSpy).not.toHaveBeenCalled();
|
||||
expect(syncMcpSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
compareCliproxyVersions,
|
||||
isCliproxyVersionExperimental,
|
||||
isCliproxyVersionInRange,
|
||||
} from '../../../ui/src/lib/cliproxy-version-risk';
|
||||
|
||||
describe('cliproxy-version-risk helpers', () => {
|
||||
it('compares versions while ignoring release suffixes', () => {
|
||||
expect(compareCliproxyVersions('6.6.88', '6.6.81-0')).toBe(1);
|
||||
expect(compareCliproxyVersions('6.6.81-0', '6.6.81')).toBe(0);
|
||||
expect(compareCliproxyVersions('6.6.80', '6.6.81')).toBe(-1);
|
||||
});
|
||||
|
||||
it('detects experimental versions against max stable', () => {
|
||||
expect(isCliproxyVersionExperimental('10.0.0', '9.9.999-0')).toBe(true);
|
||||
expect(isCliproxyVersionExperimental('6.6.88', '9.9.999-0')).toBe(false);
|
||||
});
|
||||
|
||||
it('detects versions inside the faulty range', () => {
|
||||
expect(isCliproxyVersionInRange('6.6.81', '6.6.81-0', '6.6.88-0')).toBe(true);
|
||||
expect(isCliproxyVersionInRange('6.6.88', '6.6.81-0', '6.6.88-0')).toBe(true);
|
||||
expect(isCliproxyVersionInRange('6.6.89', '6.6.81-0', '6.6.88-0')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { ERROR_CODES, getErrorCategory, getErrorDocUrl } from '../../../src/utils/error-codes';
|
||||
|
||||
describe('error-codes', () => {
|
||||
it('builds live docs URLs with lowercase anchors', () => {
|
||||
expect(getErrorDocUrl(ERROR_CODES.PROFILE_NOT_FOUND)).toBe(
|
||||
'https://docs.ccs.kaitran.ca/reference/error-codes#e104'
|
||||
);
|
||||
expect(getErrorDocUrl(ERROR_CODES.INTERNAL_ERROR)).toBe(
|
||||
'https://docs.ccs.kaitran.ca/reference/error-codes#e900'
|
||||
);
|
||||
});
|
||||
|
||||
it('maps numeric ranges to human-readable categories', () => {
|
||||
expect(getErrorCategory(ERROR_CODES.CONFIG_MISSING)).toBe('Configuration');
|
||||
expect(getErrorCategory(ERROR_CODES.PROFILE_NOT_FOUND)).toBe('Profile Management');
|
||||
expect(getErrorCategory(ERROR_CODES.CLAUDE_NOT_FOUND)).toBe('Claude CLI Detection');
|
||||
expect(getErrorCategory(ERROR_CODES.API_AUTH_FAILED)).toBe('Network/API');
|
||||
expect(getErrorCategory(ERROR_CODES.FS_CANNOT_READ_FILE)).toBe('File System');
|
||||
expect(getErrorCategory(ERROR_CODES.INVALID_STATE)).toBe('Internal');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import type { CLIProxyBackend } from '../../../src/cliproxy/types';
|
||||
import {
|
||||
installDashboardCliproxyVersion,
|
||||
type DashboardCliproxyInstallResult,
|
||||
} from '../../../src/web-server/services/cliproxy-dashboard-install-service';
|
||||
|
||||
function createDeps(
|
||||
overrides: {
|
||||
sessionRunning?: boolean;
|
||||
remoteRunning?: boolean;
|
||||
startResult?: { started: boolean; alreadyRunning: boolean; port: number; error?: string };
|
||||
} = {}
|
||||
) {
|
||||
const calls = {
|
||||
isCliproxyRunning: 0,
|
||||
installCliproxyVersion: 0,
|
||||
ensureCliproxyService: 0,
|
||||
};
|
||||
|
||||
const deps = {
|
||||
getProxyStatus: () => ({ running: overrides.sessionRunning ?? false }),
|
||||
isCliproxyRunning: async () => {
|
||||
calls.isCliproxyRunning += 1;
|
||||
return overrides.remoteRunning ?? false;
|
||||
},
|
||||
installCliproxyVersion: async (
|
||||
_version: string,
|
||||
_verbose?: boolean,
|
||||
_backend?: CLIProxyBackend
|
||||
) => {
|
||||
calls.installCliproxyVersion += 1;
|
||||
},
|
||||
ensureCliproxyService: async () => {
|
||||
calls.ensureCliproxyService += 1;
|
||||
return (
|
||||
overrides.startResult ?? {
|
||||
started: true,
|
||||
alreadyRunning: false,
|
||||
port: 8317,
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return { deps, calls };
|
||||
}
|
||||
|
||||
describe('installDashboardCliproxyVersion', () => {
|
||||
it('restarts the proxy after install when it was already running', async () => {
|
||||
const { deps, calls } = createDeps({ sessionRunning: true });
|
||||
|
||||
const result = await installDashboardCliproxyVersion('6.7.1', 'plus', deps);
|
||||
|
||||
expect(result).toEqual<DashboardCliproxyInstallResult>({
|
||||
success: true,
|
||||
restarted: true,
|
||||
port: 8317,
|
||||
message: 'Successfully installed CLIProxy Plus v6.7.1 and restarted it on port 8317',
|
||||
});
|
||||
expect(calls.isCliproxyRunning).toBe(0);
|
||||
expect(calls.installCliproxyVersion).toBe(1);
|
||||
expect(calls.ensureCliproxyService).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the proxy stopped after install when it was not running beforehand', async () => {
|
||||
const { deps, calls } = createDeps({ sessionRunning: false, remoteRunning: false });
|
||||
|
||||
const result = await installDashboardCliproxyVersion('6.7.1', 'plus', deps);
|
||||
|
||||
expect(result).toEqual<DashboardCliproxyInstallResult>({
|
||||
success: true,
|
||||
restarted: false,
|
||||
message: 'Successfully installed CLIProxy Plus v6.7.1',
|
||||
});
|
||||
expect(calls.isCliproxyRunning).toBe(1);
|
||||
expect(calls.installCliproxyVersion).toBe(1);
|
||||
expect(calls.ensureCliproxyService).toBe(0);
|
||||
});
|
||||
|
||||
it('reports a restart failure after a successful install when the proxy had been running', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
sessionRunning: false,
|
||||
remoteRunning: true,
|
||||
startResult: {
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
port: 8317,
|
||||
error: 'Port 8317 is blocked by another process',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await installDashboardCliproxyVersion('6.7.1', 'original', deps);
|
||||
|
||||
expect(result).toEqual<DashboardCliproxyInstallResult>({
|
||||
success: false,
|
||||
restarted: false,
|
||||
error: 'Port 8317 is blocked by another process',
|
||||
message: 'Installed CLIProxy v6.7.1, but failed to restart it',
|
||||
});
|
||||
expect(calls.isCliproxyRunning).toBe(1);
|
||||
expect(calls.installCliproxyVersion).toBe(1);
|
||||
expect(calls.ensureCliproxyService).toBe(1);
|
||||
});
|
||||
|
||||
it('uses a fallback restart error when the start result omits one', async () => {
|
||||
const { deps } = createDeps({
|
||||
remoteRunning: true,
|
||||
startResult: {
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
port: 8317,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await installDashboardCliproxyVersion('6.7.1', 'plus', deps);
|
||||
|
||||
expect(result).toEqual<DashboardCliproxyInstallResult>({
|
||||
success: false,
|
||||
restarted: false,
|
||||
error: 'Installed CLIProxy Plus v6.7.1, but restart failed',
|
||||
message: 'Installed CLIProxy Plus v6.7.1, but failed to restart it',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import express from 'express';
|
||||
import type { Server } from 'http';
|
||||
|
||||
const installSpy = {
|
||||
calls: 0,
|
||||
};
|
||||
|
||||
mock.module('../../../src/config/unified-config-loader', () => ({
|
||||
loadOrCreateUnifiedConfig: () => ({
|
||||
cliproxy: { backend: 'plus' },
|
||||
}),
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/binary-manager', () => ({
|
||||
checkCliproxyUpdate: async () => ({
|
||||
hasUpdate: false,
|
||||
currentVersion: '6.6.80',
|
||||
latestVersion: '6.6.89',
|
||||
fromCache: false,
|
||||
checkedAt: Date.now(),
|
||||
backend: 'plus',
|
||||
backendLabel: 'CLIProxy Plus',
|
||||
isStable: true,
|
||||
maxStableVersion: '9.9.999-0',
|
||||
}),
|
||||
getInstalledCliproxyVersion: () => '6.6.80',
|
||||
installCliproxyVersion: async () => {},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/binary/version-checker', () => ({
|
||||
fetchAllVersions: async () => ({
|
||||
versions: ['6.6.89', '6.6.88', '6.6.81', '6.6.80'],
|
||||
latestStable: '6.6.89',
|
||||
latest: '6.6.89',
|
||||
fromCache: false,
|
||||
checkedAt: Date.now(),
|
||||
}),
|
||||
isNewerVersion: (version: string, maxStable: string) => {
|
||||
const normalize = (value: string) => value.replace(/-\d+$/, '').split('.').map(Number);
|
||||
const versionParts = normalize(version);
|
||||
const maxStableParts = normalize(maxStable);
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const versionPart = versionParts[index] || 0;
|
||||
const maxStablePart = maxStableParts[index] || 0;
|
||||
|
||||
if (versionPart > maxStablePart) return true;
|
||||
if (versionPart < maxStablePart) return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
isVersionFaulty: (version: string) =>
|
||||
['6.6.81', '6.6.82', '6.6.83', '6.6.84', '6.6.85', '6.6.86', '6.6.87', '6.6.88'].includes(
|
||||
version
|
||||
),
|
||||
}));
|
||||
|
||||
mock.module('../../../src/web-server/services/cliproxy-dashboard-install-service', () => ({
|
||||
installDashboardCliproxyVersion: async () => {
|
||||
installSpy.calls += 1;
|
||||
return {
|
||||
success: true,
|
||||
restarted: true,
|
||||
port: 8317,
|
||||
message: 'installed',
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
let cliproxyStatsRoutes: typeof import('../../../src/web-server/routes/cliproxy-stats-routes').default;
|
||||
let server: Server;
|
||||
let baseUrl = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
cliproxyStatsRoutes = (await import('../../../src/web-server/routes/cliproxy-stats-routes'))
|
||||
.default;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/cliproxy', cliproxyStatsRoutes);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = app.listen(0, '127.0.0.1');
|
||||
const onError = (error: Error) => reject(error);
|
||||
server.once('error', onError);
|
||||
server.once('listening', () => {
|
||||
server.off('error', onError);
|
||||
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}`;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
installSpy.calls = 0;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe('cliproxy-stats-routes install contract', () => {
|
||||
it('returns faultyRange in the versions response', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy/versions`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
faultyRange: { min: string; max: string };
|
||||
currentVersion: string;
|
||||
};
|
||||
expect(body.currentVersion).toBe('6.6.80');
|
||||
expect(body.faultyRange).toEqual({ min: '6.6.81-0', max: '6.6.88-0' });
|
||||
});
|
||||
|
||||
it('returns faulty confirmation metadata without calling the installer', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy/install`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ version: '6.6.81' }),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
success: boolean;
|
||||
requiresConfirmation: boolean;
|
||||
isFaulty: boolean;
|
||||
isExperimental: boolean;
|
||||
message: string;
|
||||
};
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.requiresConfirmation).toBe(true);
|
||||
expect(body.isFaulty).toBe(true);
|
||||
expect(body.isExperimental).toBe(false);
|
||||
expect(body.message).toContain('known bugs');
|
||||
expect(installSpy.calls).toBe(0);
|
||||
});
|
||||
|
||||
it('returns experimental confirmation metadata without calling the installer', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy/install`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ version: '10.0.0' }),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
success: boolean;
|
||||
requiresConfirmation: boolean;
|
||||
isFaulty: boolean;
|
||||
isExperimental: boolean;
|
||||
message: string;
|
||||
};
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.requiresConfirmation).toBe(true);
|
||||
expect(body.isFaulty).toBe(false);
|
||||
expect(body.isExperimental).toBe(true);
|
||||
expect(body.message).toContain('experimental');
|
||||
expect(installSpy.calls).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import express from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { Server } from 'http';
|
||||
import profileRoutes from '../../../src/web-server/routes/profile-routes';
|
||||
|
||||
describe('profile-routes lifecycle endpoints', () => {
|
||||
let server: Server;
|
||||
let baseUrl = '';
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/profiles', profileRoutes);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = app.listen(0, '127.0.0.1');
|
||||
const onError = (error: Error) => reject(error);
|
||||
server.once('error', onError);
|
||||
server.once('listening', () => {
|
||||
server.off('error', onError);
|
||||
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(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-routes-lifecycle-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unknown fields on profile create payload', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/profiles`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'demo',
|
||||
baseUrl: 'https://api.example.com',
|
||||
apiKey: 'token',
|
||||
unknownField: true,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as { error: string };
|
||||
expect(body.error).toContain('Unknown profile field(s)');
|
||||
});
|
||||
|
||||
it('does not register all orphans when names=[] is explicitly passed', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n');
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'lonely.settings.json'),
|
||||
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) +
|
||||
'\n'
|
||||
);
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/profiles/orphans/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ names: [] }),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = (await response.json()) as { registered: string[]; skipped: Array<unknown> };
|
||||
expect(body.registered).toEqual([]);
|
||||
expect(body.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects malformed names payload for orphan registration', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/profiles/orphans/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ names: 'lonely' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as { error: string };
|
||||
expect(body.error).toContain('names must be an array');
|
||||
});
|
||||
|
||||
it('rejects import bundle with invalid profile target', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/profiles/import`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
bundle: {
|
||||
schemaVersion: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
profile: { name: 'demo', target: 'invalid' },
|
||||
settings: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.example.com',
|
||||
ANTHROPIC_AUTH_TOKEN: 'token',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as { error: string };
|
||||
expect(body.error).toContain('Invalid bundle profile target');
|
||||
});
|
||||
|
||||
it('validates source profile name on export endpoint', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/profiles/1invalid/export`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as { error: string };
|
||||
expect(body.error).toContain('API name must start with letter');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user