Files
ccs/tests/unit/web-server/claude-extension-routes.test.ts
T

585 lines
20 KiB
TypeScript

import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
mock,
spyOn,
} 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';
describe('web-server claude-extension-routes', () => {
let server: Server;
let baseUrl = '';
let tempHome = '';
let originalCcsHome: string | undefined;
let originalClaudeConfigDir: string | undefined;
let setGlobalConfigDir: (dir: string | undefined) => void;
let claudeExtensionRoutes: ReturnType<typeof express.Router>;
let SharedManager: typeof import('../../../src/management/shared-manager').default;
let createEmptyUnifiedConfig: typeof import('../../../src/config/unified-config-types').createEmptyUnifiedConfig;
let saveUnifiedConfig: typeof import('../../../src/config/unified-config-loader').saveUnifiedConfig;
beforeAll(async () => {
originalCcsHome = process.env.CCS_HOME;
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
({ setGlobalConfigDir } = await import('../../../src/utils/config-manager'));
({ default: SharedManager } = await import('../../../src/management/shared-manager'));
({ createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types'));
({ saveUnifiedConfig } = await import('../../../src/config/unified-config-loader'));
({ default: claudeExtensionRoutes } = await import(
'../../../src/web-server/routes/claude-extension-routes'
));
const app = express();
app.use(express.json());
app.use('/api/claude-extension', claudeExtensionRoutes);
await new Promise<void>((resolve, reject) => {
server = app.listen(0, '127.0.0.1');
const handleError = (error: Error) => reject(error);
server.once('error', handleError);
server.once('listening', () => {
server.off('error', handleError);
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 () => {
if (server) {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
setGlobalConfigDir(undefined);
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
else delete process.env.CLAUDE_CONFIG_DIR;
});
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-claude-extension-routes-'));
process.env.CCS_HOME = tempHome;
process.env.CLAUDE_CONFIG_DIR = path.join(tempHome, '.claude');
setGlobalConfigDir(path.join(tempHome, '.ccs'));
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'glm.settings.json'),
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://api.example.test',
ANTHROPIC_API_KEY: 'sk-ant-test-123456',
ANTHROPIC_MODEL: 'claude-sonnet-4-5',
},
},
null,
2
) + '\n'
);
fs.writeFileSync(
path.join(ccsDir, 'rich.settings.json'),
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://rich.example.test',
ANTHROPIC_API_KEY: 'sk-ant-rich-123456',
ANTHROPIC_MODEL: 'claude-opus-4-1',
ANTHROPIC_MAX_TOKENS: '65536',
API_TIMEOUT_MS: '120000',
EXPERIMENTAL_ROUTER_HEADER: 'tenant-alpha',
},
},
null,
2
) + '\n'
);
const config = createEmptyUnifiedConfig();
config.profiles.glm = {
type: 'api',
settings: path.join(ccsDir, 'glm.settings.json'),
};
config.profiles.rich = {
type: 'api',
settings: path.join(ccsDir, 'rich.settings.json'),
};
config.accounts.work = {
created: '2026-03-15T00:00:00.000Z',
last_used: null,
context_mode: 'isolated',
};
config.default = 'work';
saveUnifiedConfig(config);
});
afterEach(() => {
mock.restore();
setGlobalConfigDir(undefined);
delete process.env.CCS_HOME;
delete process.env.CLAUDE_CONFIG_DIR;
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('lists profile options and IDE host targets', async () => {
const response = await fetch(`${baseUrl}/api/claude-extension/profiles`);
expect(response.status).toBe(200);
const payload = (await response.json()) as {
profiles: Array<{ name: string }>;
hosts: Array<{ id: string; defaultSettingsPath: string }>;
};
expect(payload.profiles.some((profile) => profile.name === 'default')).toBe(true);
expect(payload.profiles.some((profile) => profile.name === 'glm')).toBe(true);
expect(payload.profiles.some((profile) => profile.name === 'work')).toBe(true);
expect(payload.profiles.some((profile) => profile.name === 'gemini')).toBe(true);
expect(payload.hosts.map((host) => host.id)).toEqual(['vscode', 'cursor', 'windsurf']);
expect(payload.hosts.every((host) => host.defaultSettingsPath.endsWith('settings.json'))).toBe(
true
);
});
it('renders VS Code setup for API profiles with disableLoginPrompt', async () => {
const response = await fetch(`${baseUrl}/api/claude-extension/setup?profile=glm&host=vscode`);
expect(response.status).toBe(200);
const payload = (await response.json()) as {
host: { settingsKey: string; disableLoginPromptKey?: string };
ideSettings: { json: string };
sharedSettings: { command: string; json: string };
};
expect(payload.host.settingsKey).toBe('claudeCode.environmentVariables');
expect(payload.host.disableLoginPromptKey).toBe('claudeCode.disableLoginPrompt');
expect(payload.ideSettings.json).toContain('"claudeCode.disableLoginPrompt": true');
expect(payload.ideSettings.json).toContain('"ANTHROPIC_API_KEY"');
expect(payload.sharedSettings.command).toBe('ccs persist glm');
expect(payload.sharedSettings.json).toContain('"env"');
});
it('normalizes the effective profile CLAUDE_CONFIG_DIR for extension setup', async () => {
const explicitConfigDir = path.join(tempHome, '.claude-profiles', 'glm');
const glmSettingsPath = path.join(tempHome, '.ccs', 'glm.settings.json');
const normalizeSpy = spyOn(
SharedManager.prototype,
'normalizeSharedPluginMetadataPaths'
).mockImplementation(() => {});
fs.writeFileSync(
glmSettingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://api.example.test',
ANTHROPIC_API_KEY: 'sk-ant-test-123456',
ANTHROPIC_MODEL: 'claude-sonnet-4-5',
CLAUDE_CONFIG_DIR: explicitConfigDir,
},
},
null,
2
) + '\n'
);
const config = createEmptyUnifiedConfig();
config.profiles.glm = {
type: 'api',
settings: glmSettingsPath,
};
config.accounts.work = {
created: '2026-03-15T00:00:00.000Z',
last_used: null,
context_mode: 'isolated',
};
config.default = 'work';
config.continuity = {
inherit_from_account: {
glm: 'work',
},
};
saveUnifiedConfig(config);
const response = await fetch(`${baseUrl}/api/claude-extension/setup?profile=glm&host=vscode`);
expect(response.status).toBe(200);
const payload = (await response.json()) as {
ideSettings: { json: string };
};
expect(payload.ideSettings.json).toContain(explicitConfigDir);
expect(normalizeSpy.mock.calls.some(([configDir]) => configDir === explicitConfigDir)).toBe(
true
);
});
it('renders Windsurf setup for default account resolution via CLAUDE_CONFIG_DIR', async () => {
const response = await fetch(
`${baseUrl}/api/claude-extension/setup?profile=default&host=windsurf`
);
expect(response.status).toBe(200);
const payload = (await response.json()) as {
profile: { profileType: string; resolvedProfileName: string };
host: { settingsKey: string };
ideSettings: { json: string };
sharedSettings: { command: string };
};
expect(payload.profile.profileType).toBe('account');
expect(payload.profile.resolvedProfileName).toBe('work');
expect(payload.host.settingsKey).toBe('claude-code.environmentVariables');
expect(payload.ideSettings.json).toContain('"claude-code.environmentVariables"');
expect(payload.ideSettings.json).toContain('"CLAUDE_CONFIG_DIR"');
expect(payload.sharedSettings.command).toBe('ccs persist default');
});
it('creates a binding and applies managed settings to shared + IDE targets', async () => {
const ideSettingsPath = path.join(tempHome, 'ide', 'vscode', 'settings.json');
const createResponse = await fetch(`${baseUrl}/api/claude-extension/bindings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'GLM in VS Code',
profile: 'glm',
host: 'vscode',
ideSettingsPath,
}),
});
expect(createResponse.status).toBe(201);
const created = (await createResponse.json()) as {
binding: {
id: string;
effectiveIdeSettingsPath: string;
usesDefaultIdeSettingsPath: boolean;
};
};
expect(created.binding.effectiveIdeSettingsPath).toBe(ideSettingsPath);
expect(created.binding.usesDefaultIdeSettingsPath).toBe(false);
const applyResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/apply`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'all' }),
}
);
expect(applyResponse.status).toBe(200);
const applied = (await applyResponse.json()) as {
sharedSettings: { state: string };
ideSettings: { state: string };
};
expect(applied.sharedSettings.state).toBe('applied');
expect(applied.ideSettings.state).toBe('applied');
const sharedSettingsPath = path.join(tempHome, '.claude', 'settings.json');
const sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf8')) as {
env?: Record<string, string>;
};
const ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record<
string,
unknown
>;
expect(sharedSettings.env?.ANTHROPIC_API_KEY).toBe('sk-ant-test-123456');
expect(sharedSettings.env?.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5');
expect(
Array.isArray(ideSettings['claudeCode.environmentVariables']) &&
(ideSettings['claudeCode.environmentVariables'] as Array<{ name: string }>).some(
(entry) => entry.name === 'ANTHROPIC_API_KEY'
)
).toBe(true);
expect(ideSettings['claudeCode.disableLoginPrompt']).toBe(true);
});
it('resets only managed keys and preserves unrelated shared + IDE settings', async () => {
const ideSettingsPath = path.join(tempHome, 'ide', 'cursor', 'settings.json');
fs.mkdirSync(path.dirname(ideSettingsPath), { recursive: true });
fs.mkdirSync(path.join(tempHome, '.claude'), { recursive: true });
fs.writeFileSync(
path.join(tempHome, '.claude', 'settings.json'),
JSON.stringify(
{
theme: 'dark',
env: {
KEEP_ME: '1',
ANTHROPIC_API_KEY: 'stale',
},
},
null,
2
) + '\n'
);
fs.writeFileSync(
ideSettingsPath,
JSON.stringify(
{
'editor.fontSize': 14,
'claudeCode.environmentVariables': [{ name: 'ANTHROPIC_API_KEY', value: 'stale' }],
'claudeCode.disableLoginPrompt': true,
},
null,
2
) + '\n'
);
const createResponse = await fetch(`${baseUrl}/api/claude-extension/bindings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Cursor reset',
profile: 'glm',
host: 'cursor',
ideSettingsPath,
}),
});
const created = (await createResponse.json()) as { binding: { id: string } };
const resetResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/reset`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'all' }),
}
);
expect(resetResponse.status).toBe(200);
const resetPayload = (await resetResponse.json()) as {
sharedSettings: { state: string };
ideSettings: { state: string };
};
expect(resetPayload.sharedSettings.state).toBe('unconfigured');
expect(resetPayload.ideSettings.state).toBe('unconfigured');
const sharedSettings = JSON.parse(
fs.readFileSync(path.join(tempHome, '.claude', 'settings.json'), 'utf8')
) as {
theme?: string;
env?: Record<string, string>;
};
const ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record<
string,
unknown
>;
expect(sharedSettings.theme).toBe('dark');
expect(sharedSettings.env?.KEEP_ME).toBe('1');
expect(sharedSettings.env?.ANTHROPIC_API_KEY).toBeUndefined();
expect(ideSettings['editor.fontSize']).toBe(14);
expect(ideSettings['claudeCode.environmentVariables']).toBeUndefined();
expect(ideSettings['claudeCode.disableLoginPrompt']).toBeUndefined();
});
it('removes optional shared env keys even after the profile payload shrinks', async () => {
const ideSettingsPath = path.join(tempHome, 'ide', 'vscode', 'settings.json');
const createResponse = await fetch(`${baseUrl}/api/claude-extension/bindings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Rich env binding',
profile: 'rich',
host: 'vscode',
ideSettingsPath,
}),
});
const created = (await createResponse.json()) as { binding: { id: string } };
const applyResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/apply`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'shared' }),
}
);
expect(applyResponse.status).toBe(200);
const sharedSettingsPath = path.join(tempHome, '.claude', 'settings.json');
let sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf8')) as {
env?: Record<string, string>;
};
expect(sharedSettings.env?.ANTHROPIC_API_KEY).toBe('sk-ant-rich-123456');
expect(sharedSettings.env?.ANTHROPIC_MAX_TOKENS).toBe('65536');
expect(sharedSettings.env?.API_TIMEOUT_MS).toBe('120000');
expect(sharedSettings.env?.EXPERIMENTAL_ROUTER_HEADER).toBe('tenant-alpha');
fs.writeFileSync(
path.join(tempHome, '.ccs', 'rich.settings.json'),
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://rich.example.test',
ANTHROPIC_API_KEY: 'sk-ant-rich-123456',
ANTHROPIC_MODEL: 'claude-opus-4-1',
},
},
null,
2
) + '\n'
);
const resetResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/reset`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'shared' }),
}
);
expect(resetResponse.status).toBe(200);
const verifyResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/verify`
);
expect(verifyResponse.status).toBe(200);
const verified = (await verifyResponse.json()) as {
sharedSettings: { state: string };
};
expect(verified.sharedSettings.state).toBe('unconfigured');
sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf8')) as {
env?: Record<string, string>;
};
expect(sharedSettings.env?.ANTHROPIC_API_KEY).toBeUndefined();
expect(sharedSettings.env?.ANTHROPIC_MAX_TOKENS).toBeUndefined();
expect(sharedSettings.env?.API_TIMEOUT_MS).toBeUndefined();
expect(sharedSettings.env?.EXPERIMENTAL_ROUTER_HEADER).toBeUndefined();
});
it('preserves unrelated IDE env entries while applying and resetting managed values', async () => {
const ideSettingsPath = path.join(tempHome, 'ide', 'vscode', 'settings.json');
fs.mkdirSync(path.dirname(ideSettingsPath), { recursive: true });
fs.writeFileSync(
ideSettingsPath,
`{
// VS Code stores JSONC here, not strict JSON
"editor.tabSize": 2,
"claudeCode.environmentVariables": [
{ "name": "KEEP_ME", "value": "1" },
{ "name": "ANTHROPIC_API_KEY", "value": "stale" },
],
"claudeCode.disableLoginPrompt": false,
}
`
);
const createResponse = await fetch(`${baseUrl}/api/claude-extension/bindings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'VS Code preserved env',
profile: 'glm',
host: 'vscode',
ideSettingsPath,
}),
});
const created = (await createResponse.json()) as { binding: { id: string } };
const applyResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/apply`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'ide' }),
}
);
expect(applyResponse.status).toBe(200);
let ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record<
string,
unknown
>;
const appliedEnv = ideSettings['claudeCode.environmentVariables'] as Array<{
name: string;
value: string;
}>;
expect(appliedEnv.some((entry) => entry.name === 'KEEP_ME' && entry.value === '1')).toBe(true);
expect(
appliedEnv.some(
(entry) => entry.name === 'ANTHROPIC_API_KEY' && entry.value === 'sk-ant-test-123456'
)
).toBe(true);
const verifyAppliedResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/verify`
);
expect(verifyAppliedResponse.status).toBe(200);
const verifiedApplied = (await verifyAppliedResponse.json()) as {
ideSettings: { state: string };
};
expect(verifiedApplied.ideSettings.state).toBe('applied');
const resetResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/reset`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'ide' }),
}
);
expect(resetResponse.status).toBe(200);
ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record<string, unknown>;
expect(ideSettings['editor.tabSize']).toBe(2);
expect(ideSettings['claudeCode.disableLoginPrompt']).toBeUndefined();
expect(ideSettings['claudeCode.environmentVariables']).toEqual([
{ name: 'KEEP_ME', value: '1' },
]);
const verifyResetResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/verify`
);
expect(verifyResetResponse.status).toBe(200);
const verifiedReset = (await verifyResetResponse.json()) as {
ideSettings: { state: string };
};
expect(verifiedReset.ideSettings.state).toBe('unconfigured');
});
it('rejects bindings for profiles that do not exist', async () => {
const response = await fetch(`${baseUrl}/api/claude-extension/bindings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Broken profile binding',
profile: 'does-not-exist',
host: 'vscode',
}),
});
expect(response.status).toBe(400);
const payload = (await response.json()) as { error: string };
expect(payload.error).toContain('Unknown profile');
});
});