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 { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; import imageAnalysisRoutes from '../../../src/web-server/routes/image-analysis-routes'; describe('image-analysis routes', () => { let server: Server; let baseUrl = ''; let tempHome: string; let originalCcsHome: string | undefined; let originalDashboardAuthEnabled: string | undefined; let forcedRemoteAddress = '127.0.0.1'; beforeAll(async () => { const app = express(); app.use(express.json()); app.use((req, _res, next) => { Object.defineProperty(req.socket, 'remoteAddress', { value: forcedRemoteAddress, configurable: true, }); next(); }); app.use('/api/image-analysis', imageAnalysisRoutes); await new Promise((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((resolve) => server.close(() => resolve())); }); beforeEach(() => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-analysis-routes-test-')); originalCcsHome = process.env.CCS_HOME; originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; process.env.CCS_HOME = tempHome; process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; forcedRemoteAddress = '127.0.0.1'; const glmSettingsPath = path.join(tempHome, 'glm.settings.json'); const codexSettingsPath = path.join(tempHome, 'codex-profile.settings.json'); fs.writeFileSync( glmSettingsPath, JSON.stringify( { env: { ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/gemini', ANTHROPIC_AUTH_TOKEN: 'glm-token', }, }, null, 2 ) ); fs.writeFileSync( codexSettingsPath, JSON.stringify( { env: { ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/ghcp', ANTHROPIC_AUTH_TOKEN: 'codex-token', }, }, null, 2 ) ); mutateUnifiedConfig((config) => { config.profiles.glm = { settings: glmSettingsPath, target: 'claude', }; config.profiles.codexProfile = { settings: codexSettingsPath, target: 'droid', }; config.image_analysis = { enabled: true, timeout: 60, provider_models: { gemini: 'gemini-3-flash-preview', ghcp: 'claude-haiku-4.5', }, fallback_backend: 'gemini', profile_backends: { codexProfile: 'ghcp', }, }; }); }); afterEach(() => { if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; } else { delete process.env.CCS_HOME; } if (originalDashboardAuthEnabled !== undefined) { process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled; } else { delete process.env.CCS_DASHBOARD_AUTH_ENABLED; } if (tempHome && fs.existsSync(tempHome)) { fs.rmSync(tempHome, { recursive: true, force: true }); } }); it('blocks remote access when dashboard auth is disabled', async () => { forcedRemoteAddress = '10.10.0.24'; const response = await fetch(`${baseUrl}/api/image-analysis`); expect(response.status).toBe(403); expect(await response.json()).toEqual({ error: 'Image Analysis endpoints require localhost access when dashboard auth is disabled.', }); }); it('returns global settings, backend readiness, and profile coverage', async () => { const response = await fetch(`${baseUrl}/api/image-analysis`); expect(response.status).toBe(200); const payload = await response.json(); expect(payload.config).toMatchObject({ enabled: true, timeout: 60, fallbackBackend: 'gemini', profileBackends: { codexProfile: 'ghcp', }, }); expect(payload.catalog.knownBackends).toContain('gemini'); expect(payload.backends).toEqual( expect.arrayContaining([ expect.objectContaining({ backendId: 'gemini', model: 'gemini-3-flash-preview', }), expect.objectContaining({ backendId: 'ghcp', profilesUsing: 1, }), ]) ); expect(payload.profiles).toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'glm', target: 'claude', currentTargetMode: 'setup', }), expect.objectContaining({ name: 'codexProfile', target: 'droid', backendId: 'ghcp', currentTargetMode: 'bypassed', }), ]) ); expect(payload.summary).toMatchObject({ backendCount: 2, bypassedProfileCount: 1, }); expect(payload.runtime).toMatchObject({ managedToolReady: false, sharedHookInstalled: false, }); }); it('updates the saved config through the dashboard route and provisions the local runtime', async () => { const staleSettingsPath = path.join(tempHome, '.ccs', 'legacy.settings.json'); fs.writeFileSync( staleSettingsPath, JSON.stringify( { env: { ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/gemini', ANTHROPIC_AUTH_TOKEN: 'legacy-token', }, hooks: { PreToolUse: [ { matcher: 'Read', hooks: [ { type: 'command', command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"', timeout: 65000, }, ], }, ], }, }, null, 2 ) ); const instanceDir = path.join(tempHome, '.ccs', 'instances', 'demo'); fs.mkdirSync(instanceDir, { recursive: true }); fs.writeFileSync( path.join(instanceDir, '.claude.json'), JSON.stringify( { mcpServers: { custom: { type: 'stdio', command: 'node', args: ['custom-server.cjs'], env: {}, }, }, }, null, 2 ) ); const response = await fetch(`${baseUrl}/api/image-analysis`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, timeout: 120, providerModels: { gemini: 'gemini-2.5-pro', ghcp: 'claude-haiku-4.5', }, fallbackBackend: 'ghcp', profileBackends: { glm: 'gemini', codexProfile: 'ghcp', }, }), }); expect(response.status).toBe(200); const payload = await response.json(); expect(payload.config).toMatchObject({ timeout: 120, fallbackBackend: 'ghcp', profileBackends: { glm: 'gemini', codexProfile: 'ghcp', }, }); expect(payload.config.providerModels).toMatchObject({ gemini: 'gemini-2.5-pro', }); expect(payload.runtime).toMatchObject({ managedToolReady: true, sharedHookInstalled: true, }); const ccsDir = path.join(tempHome, '.ccs'); expect(fs.existsSync(path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs'))).toBe(true); expect(fs.existsSync(path.join(ccsDir, 'mcp', 'image-analysis-runtime.cjs'))).toBe(true); expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe(true); expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs'))).toBe(true); const claudeConfig = JSON.parse( fs.readFileSync(path.join(tempHome, '.claude.json'), 'utf8') ) as { mcpServers?: Record; }; expect(claudeConfig.mcpServers?.['ccs-image-analysis']).toEqual({ type: 'stdio', command: 'node', args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')], env: {}, }); const repairedSettings = JSON.parse(fs.readFileSync(staleSettingsPath, 'utf8')) as { hooks?: { PreToolUse?: Array<{ matcher?: string }> }; }; expect(repairedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read') ?? false).toBe( false ); const instanceConfig = JSON.parse( fs.readFileSync(path.join(instanceDir, '.claude.json'), 'utf8') ) as { mcpServers?: Record; }; expect(instanceConfig.mcpServers?.custom).toEqual({ type: 'stdio', command: 'node', args: ['custom-server.cjs'], env: {}, }); expect(instanceConfig.mcpServers?.['ccs-image-analysis']).toEqual({ type: 'stdio', command: 'node', args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')], env: {}, }); }); it('rejects profile mappings that point to a missing backend with a client error', async () => { const response = await fetch(`${baseUrl}/api/image-analysis`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ providerModels: { gemini: 'gemini-3-flash-preview', }, fallbackBackend: 'gemini', profileBackends: { codexProfile: 'ghcp', }, }), }); expect(response.status).toBe(400); expect(await response.json()).toEqual({ error: 'Profile mapping for "codexProfile" references an unknown backend.', }); }); });