mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
676 lines
23 KiB
TypeScript
676 lines
23 KiB
TypeScript
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 configRoutes from '../../../src/web-server/routes/config-routes';
|
|
import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types';
|
|
import { loadUnifiedConfig, saveUnifiedConfig } from '../../../src/config/unified-config-loader';
|
|
|
|
async function putJson(baseUrl: string, routePath: string, body: unknown): Promise<Response> {
|
|
return fetch(`${baseUrl}${routePath}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
async function postJson(baseUrl: string, routePath: string, body?: unknown): Promise<Response> {
|
|
return fetch(`${baseUrl}${routePath}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
describe('web-server config-routes account context validation', () => {
|
|
let server: Server;
|
|
let baseUrl = '';
|
|
let forcedRemoteAddress = '127.0.0.1';
|
|
let tempHome = '';
|
|
let originalCcsHome: string | undefined;
|
|
let originalDashboardAuthEnabled: string | undefined;
|
|
|
|
beforeAll(async () => {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
Object.defineProperty(req.socket, 'remoteAddress', {
|
|
value: forcedRemoteAddress,
|
|
configurable: true,
|
|
});
|
|
next();
|
|
});
|
|
app.use('/api/config', configRoutes);
|
|
|
|
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 () => {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
});
|
|
|
|
beforeEach(() => {
|
|
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-config-routes-context-'));
|
|
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';
|
|
fs.mkdirSync(path.join(tempHome, '.ccs'), { recursive: true });
|
|
});
|
|
|
|
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/config/format`);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({
|
|
error:
|
|
'Local configuration endpoints require localhost access when dashboard auth is disabled.',
|
|
});
|
|
});
|
|
|
|
it('allows remote access when dashboard auth is enabled', async () => {
|
|
forcedRemoteAddress = '10.10.0.24';
|
|
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true';
|
|
|
|
const response = await fetch(`${baseUrl}/api/config/format`);
|
|
|
|
expect(response.status).toBe(200);
|
|
});
|
|
|
|
it('rejects invalid account context_mode values', async () => {
|
|
const response = await putJson(baseUrl, '/api/config', {
|
|
version: 8,
|
|
accounts: {
|
|
work: {
|
|
created: '2026-01-01T00:00:00.000Z',
|
|
last_used: null,
|
|
context_mode: 'weird',
|
|
},
|
|
},
|
|
profiles: {},
|
|
cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
const payload = (await response.json()) as { error: string };
|
|
expect(payload.error).toContain('context_mode');
|
|
});
|
|
|
|
it('rejects context_group when mode is not shared', async () => {
|
|
const response = await putJson(baseUrl, '/api/config', {
|
|
version: 8,
|
|
accounts: {
|
|
work: {
|
|
created: '2026-01-01T00:00:00.000Z',
|
|
last_used: null,
|
|
context_mode: 'isolated',
|
|
context_group: 'sprint-a',
|
|
},
|
|
},
|
|
profiles: {},
|
|
cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
const payload = (await response.json()) as { error: string };
|
|
expect(payload.error).toContain('context_group requires context_mode=shared');
|
|
});
|
|
|
|
it('rejects continuity_mode when mode is not shared', async () => {
|
|
const response = await putJson(baseUrl, '/api/config', {
|
|
version: 8,
|
|
accounts: {
|
|
work: {
|
|
created: '2026-01-01T00:00:00.000Z',
|
|
last_used: null,
|
|
context_mode: 'isolated',
|
|
continuity_mode: 'deeper',
|
|
},
|
|
},
|
|
profiles: {},
|
|
cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
const payload = (await response.json()) as { error: string };
|
|
expect(payload.error).toContain('continuity_mode requires context_mode=shared');
|
|
});
|
|
|
|
it('rejects invalid shared continuity_mode values', async () => {
|
|
const response = await putJson(baseUrl, '/api/config', {
|
|
version: 8,
|
|
accounts: {
|
|
work: {
|
|
created: '2026-01-01T00:00:00.000Z',
|
|
last_used: null,
|
|
context_mode: 'shared',
|
|
context_group: 'team-alpha',
|
|
continuity_mode: 'extreme',
|
|
},
|
|
},
|
|
profiles: {},
|
|
cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
const payload = (await response.json()) as { error: string };
|
|
expect(payload.error).toContain('continuity_mode');
|
|
});
|
|
|
|
it('rejects invalid shared context_group names', async () => {
|
|
const response = await putJson(baseUrl, '/api/config', {
|
|
version: 8,
|
|
accounts: {
|
|
work: {
|
|
created: '2026-01-01T00:00:00.000Z',
|
|
last_used: null,
|
|
context_mode: 'shared',
|
|
context_group: '###',
|
|
},
|
|
},
|
|
profiles: {},
|
|
cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
const payload = (await response.json()) as { error: string };
|
|
expect(payload.error).toContain('context_group');
|
|
});
|
|
|
|
it('rejects whitespace-only shared context_group values', async () => {
|
|
const response = await putJson(baseUrl, '/api/config', {
|
|
version: 8,
|
|
accounts: {
|
|
work: {
|
|
created: '2026-01-01T00:00:00.000Z',
|
|
last_used: null,
|
|
context_mode: 'shared',
|
|
context_group: ' ',
|
|
},
|
|
},
|
|
profiles: {},
|
|
cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
const payload = (await response.json()) as { error: string };
|
|
expect(payload.error).toContain('requires a non-empty value');
|
|
});
|
|
|
|
it('accepts valid shared context metadata', async () => {
|
|
const config = createEmptyUnifiedConfig();
|
|
config.accounts.work = {
|
|
created: '2026-01-01T00:00:00.000Z',
|
|
last_used: null,
|
|
context_mode: 'shared',
|
|
context_group: 'Sprint-A',
|
|
continuity_mode: 'deeper',
|
|
};
|
|
const response = await putJson(baseUrl, '/api/config', config);
|
|
|
|
expect(response.status).toBe(200);
|
|
const payload = (await response.json()) as { success: boolean };
|
|
expect(payload.success).toBe(true);
|
|
|
|
const savedConfig = loadUnifiedConfig();
|
|
expect(savedConfig?.accounts.work.context_group).toBe('sprint-a');
|
|
expect(savedConfig?.accounts.work.continuity_mode).toBe('deeper');
|
|
});
|
|
|
|
it('redacts secrets from config and raw YAML responses', async () => {
|
|
const config = createEmptyUnifiedConfig();
|
|
config.global_env = {
|
|
enabled: true,
|
|
env: {
|
|
DEBUG: '1',
|
|
GH_TOKEN: 'gh-token-secret',
|
|
GITHUB_TOKEN: 'github-token-secret',
|
|
OPENAI_API_KEY: 'sk-test-123456',
|
|
},
|
|
};
|
|
config.cliproxy.auth = {
|
|
api_key: 'cliproxy-global-api-key',
|
|
management_secret: 'cliproxy-global-management-secret',
|
|
};
|
|
config.cliproxy.variants.gemini = {
|
|
provider: 'gemini',
|
|
auth: {
|
|
api_key: 'variant-api-key',
|
|
management_secret: 'variant-management-secret',
|
|
},
|
|
};
|
|
config.cliproxy_server = {
|
|
remote: {
|
|
enabled: true,
|
|
host: 'proxy.example.com',
|
|
protocol: 'https',
|
|
auth_token: 'remote-auth-token',
|
|
management_key: 'management-secret',
|
|
},
|
|
fallback: {
|
|
enabled: true,
|
|
auto_start: false,
|
|
},
|
|
local: {
|
|
port: 8317,
|
|
auto_start: true,
|
|
},
|
|
};
|
|
config.dashboard_auth = {
|
|
enabled: true,
|
|
username: 'admin',
|
|
password_hash: '$2b$10$123456789012345678901u4cPFsKnzGWxZmfq6OnpZnN0UiM6Qf7e',
|
|
session_timeout_hours: 24,
|
|
};
|
|
saveUnifiedConfig(config);
|
|
const yamlPath = path.join(tempHome, '.ccs', 'config.yaml');
|
|
const rawYaml = fs.readFileSync(yamlPath, 'utf8');
|
|
fs.writeFileSync(yamlPath, `# custom comment\n${rawYaml}`);
|
|
|
|
const response = await fetch(`${baseUrl}/api/config`);
|
|
expect(response.status).toBe(200);
|
|
const payload = (await response.json()) as typeof config;
|
|
|
|
expect(payload.global_env?.env.DEBUG).toBe('1');
|
|
expect(payload.global_env?.env.GH_TOKEN).toBe('[redacted]');
|
|
expect(payload.global_env?.env.GITHUB_TOKEN).toBe('[redacted]');
|
|
expect(payload.global_env?.env.OPENAI_API_KEY).toBe('[redacted]');
|
|
expect(payload.cliproxy.auth?.api_key).toBe('[redacted]');
|
|
expect(payload.cliproxy.auth?.management_secret).toBe('[redacted]');
|
|
expect(payload.cliproxy.variants.gemini?.auth?.api_key).toBe('[redacted]');
|
|
expect(payload.cliproxy.variants.gemini?.auth?.management_secret).toBe('[redacted]');
|
|
expect(payload.cliproxy_server?.remote.auth_token).toBe('[redacted]');
|
|
expect(payload.cliproxy_server?.remote.management_key).toBe('[redacted]');
|
|
expect(payload.dashboard_auth?.password_hash).toBe('[redacted]');
|
|
|
|
const rawResponse = await fetch(`${baseUrl}/api/config/raw`);
|
|
expect(rawResponse.status).toBe(200);
|
|
const rawConfig = await rawResponse.text();
|
|
|
|
expect(rawConfig).toContain('# custom comment');
|
|
expect(rawConfig).toContain('DEBUG: "1"');
|
|
expect(rawConfig).toContain('GH_TOKEN: [redacted]');
|
|
expect(rawConfig).toContain('GITHUB_TOKEN: [redacted]');
|
|
expect(rawConfig).toContain('OPENAI_API_KEY: [redacted]');
|
|
expect(rawConfig).toContain('api_key: [redacted]');
|
|
expect(rawConfig).toContain('management_secret: [redacted]');
|
|
expect(rawConfig).not.toContain('sk-test-123456');
|
|
expect(rawConfig).not.toContain('gh-token-secret');
|
|
expect(rawConfig).not.toContain('github-token-secret');
|
|
expect(rawConfig).not.toContain('cliproxy-global-api-key');
|
|
expect(rawConfig).not.toContain('cliproxy-global-management-secret');
|
|
expect(rawConfig).not.toContain('variant-api-key');
|
|
expect(rawConfig).not.toContain('variant-management-secret');
|
|
expect(rawConfig).not.toContain('remote-auth-token');
|
|
expect(rawConfig).not.toContain('management-secret');
|
|
});
|
|
|
|
it('preserves redacted secrets when saving a sanitized config payload', async () => {
|
|
const config = createEmptyUnifiedConfig();
|
|
config.global_env = {
|
|
enabled: true,
|
|
env: {
|
|
DEBUG: '1',
|
|
OPENAI_API_KEY: 'sk-test-123456',
|
|
},
|
|
};
|
|
config.cliproxy.auth = {
|
|
api_key: 'cliproxy-global-api-key',
|
|
management_secret: 'cliproxy-global-management-secret',
|
|
};
|
|
config.cliproxy.variants.gemini = {
|
|
provider: 'gemini',
|
|
auth: {
|
|
api_key: 'variant-api-key',
|
|
management_secret: 'variant-management-secret',
|
|
},
|
|
};
|
|
config.cliproxy_server = {
|
|
remote: {
|
|
enabled: true,
|
|
host: 'proxy.example.com',
|
|
protocol: 'https',
|
|
auth_token: 'remote-auth-token',
|
|
management_key: 'management-secret',
|
|
},
|
|
fallback: {
|
|
enabled: true,
|
|
auto_start: false,
|
|
},
|
|
local: {
|
|
port: 8317,
|
|
auto_start: true,
|
|
},
|
|
};
|
|
config.dashboard_auth = {
|
|
enabled: true,
|
|
username: 'admin',
|
|
password_hash: '$2b$10$123456789012345678901u4cPFsKnzGWxZmfq6OnpZnN0UiM6Qf7e',
|
|
session_timeout_hours: 24,
|
|
};
|
|
saveUnifiedConfig(config);
|
|
|
|
const getResponse = await fetch(`${baseUrl}/api/config`);
|
|
const sanitized = (await getResponse.json()) as typeof config;
|
|
sanitized.cliproxy.kiro_no_incognito = true;
|
|
|
|
const putResponse = await putJson(baseUrl, '/api/config', sanitized);
|
|
expect(putResponse.status).toBe(200);
|
|
|
|
const savedConfig = loadUnifiedConfig();
|
|
expect(savedConfig?.cliproxy.kiro_no_incognito).toBe(true);
|
|
expect(savedConfig?.global_env?.env.OPENAI_API_KEY).toBe('sk-test-123456');
|
|
expect(savedConfig?.cliproxy.auth?.api_key).toBe('cliproxy-global-api-key');
|
|
expect(savedConfig?.cliproxy.auth?.management_secret).toBe(
|
|
'cliproxy-global-management-secret'
|
|
);
|
|
expect(savedConfig?.cliproxy.variants.gemini?.auth?.api_key).toBe('variant-api-key');
|
|
expect(savedConfig?.cliproxy.variants.gemini?.auth?.management_secret).toBe(
|
|
'variant-management-secret'
|
|
);
|
|
expect(savedConfig?.cliproxy_server?.remote.auth_token).toBe('remote-auth-token');
|
|
expect(savedConfig?.cliproxy_server?.remote.management_key).toBe('management-secret');
|
|
expect(savedConfig?.dashboard_auth?.password_hash).toBe(
|
|
'$2b$10$123456789012345678901u4cPFsKnzGWxZmfq6OnpZnN0UiM6Qf7e'
|
|
);
|
|
});
|
|
|
|
it('preserves cliproxy auth secrets when hidden auth subtrees are omitted on save', async () => {
|
|
const config = createEmptyUnifiedConfig();
|
|
config.cliproxy.auth = {
|
|
api_key: 'cliproxy-global-api-key',
|
|
management_secret: 'cliproxy-global-management-secret',
|
|
};
|
|
config.cliproxy.variants.gemini = {
|
|
provider: 'gemini',
|
|
auth: {
|
|
api_key: 'variant-api-key',
|
|
management_secret: 'variant-management-secret',
|
|
},
|
|
};
|
|
saveUnifiedConfig(config);
|
|
|
|
const getResponse = await fetch(`${baseUrl}/api/config`);
|
|
const sanitized = (await getResponse.json()) as typeof config;
|
|
delete sanitized.cliproxy.auth;
|
|
if (sanitized.cliproxy.variants.gemini) {
|
|
delete sanitized.cliproxy.variants.gemini.auth;
|
|
}
|
|
sanitized.cliproxy.kiro_no_incognito = true;
|
|
|
|
const putResponse = await putJson(baseUrl, '/api/config', sanitized);
|
|
expect(putResponse.status).toBe(200);
|
|
|
|
const savedConfig = loadUnifiedConfig();
|
|
expect(savedConfig?.cliproxy.kiro_no_incognito).toBe(true);
|
|
expect(savedConfig?.cliproxy.auth?.api_key).toBe('cliproxy-global-api-key');
|
|
expect(savedConfig?.cliproxy.auth?.management_secret).toBe(
|
|
'cliproxy-global-management-secret'
|
|
);
|
|
expect(savedConfig?.cliproxy.variants.gemini?.auth?.api_key).toBe('variant-api-key');
|
|
expect(savedConfig?.cliproxy.variants.gemini?.auth?.management_secret).toBe(
|
|
'variant-management-secret'
|
|
);
|
|
});
|
|
|
|
it('clears cliproxy auth secrets when empty auth objects are saved explicitly', async () => {
|
|
const config = createEmptyUnifiedConfig();
|
|
config.cliproxy.auth = {
|
|
api_key: 'cliproxy-global-api-key',
|
|
management_secret: 'cliproxy-global-management-secret',
|
|
};
|
|
config.cliproxy.variants.gemini = {
|
|
provider: 'gemini',
|
|
auth: {
|
|
api_key: 'variant-api-key',
|
|
management_secret: 'variant-management-secret',
|
|
},
|
|
};
|
|
saveUnifiedConfig(config);
|
|
|
|
const getResponse = await fetch(`${baseUrl}/api/config`);
|
|
const sanitized = (await getResponse.json()) as typeof config;
|
|
sanitized.cliproxy.auth = {};
|
|
if (sanitized.cliproxy.variants.gemini) {
|
|
sanitized.cliproxy.variants.gemini.auth = {};
|
|
}
|
|
|
|
const putResponse = await putJson(baseUrl, '/api/config', sanitized);
|
|
expect(putResponse.status).toBe(200);
|
|
|
|
const savedConfig = loadUnifiedConfig();
|
|
expect(savedConfig?.cliproxy.auth).toBeUndefined();
|
|
expect(savedConfig?.cliproxy.variants.gemini?.auth).toBeUndefined();
|
|
});
|
|
|
|
it('redacts block-scalar secrets from raw YAML responses', async () => {
|
|
const yamlPath = path.join(tempHome, '.ccs', 'config.yaml');
|
|
fs.writeFileSync(
|
|
yamlPath,
|
|
[
|
|
'# block scalar test',
|
|
'version: 10',
|
|
'accounts: {}',
|
|
'profiles: {}',
|
|
'cliproxy:',
|
|
' oauth_accounts: {}',
|
|
' providers: []',
|
|
' variants:',
|
|
' gemini:',
|
|
' provider: gemini',
|
|
' auth:',
|
|
' api_key: |-',
|
|
' variant-api-secret-line-1',
|
|
' variant-api-secret-line-2',
|
|
' auth:',
|
|
' api_key: >+',
|
|
' cliproxy-global-api-secret',
|
|
' management_secret: |+',
|
|
' cliproxy-global-management-secret',
|
|
'global_env:',
|
|
' enabled: true',
|
|
' env:',
|
|
' GITHUB_TOKEN: |-',
|
|
' github-token-secret-line-1',
|
|
' github-token-secret-line-2',
|
|
'cliproxy_server:',
|
|
' remote:',
|
|
' enabled: true',
|
|
' host: proxy.example.com',
|
|
' protocol: https',
|
|
' auth_token: >-',
|
|
' remote-auth-secret',
|
|
'dashboard_auth:',
|
|
' enabled: true',
|
|
' username: admin',
|
|
' password_hash: >+',
|
|
' dashboard-password-secret',
|
|
'',
|
|
].join('\n')
|
|
);
|
|
|
|
const rawResponse = await fetch(`${baseUrl}/api/config/raw`);
|
|
expect(rawResponse.status).toBe(200);
|
|
const rawConfig = await rawResponse.text();
|
|
|
|
expect(rawConfig).toContain('# block scalar test');
|
|
expect(rawConfig).toContain('GITHUB_TOKEN: [redacted]');
|
|
expect(rawConfig).toContain('api_key: [redacted]');
|
|
expect(rawConfig).toContain('management_secret: [redacted]');
|
|
expect(rawConfig).toContain('auth_token: [redacted]');
|
|
expect(rawConfig).toContain('password_hash: [redacted]');
|
|
expect(rawConfig).not.toContain('github-token-secret-line-1');
|
|
expect(rawConfig).not.toContain('cliproxy-global-api-secret');
|
|
expect(rawConfig).not.toContain('cliproxy-global-management-secret');
|
|
expect(rawConfig).not.toContain('variant-api-secret-line-1');
|
|
expect(rawConfig).not.toContain('remote-auth-secret');
|
|
expect(rawConfig).not.toContain('dashboard-password-secret');
|
|
});
|
|
|
|
it('allows deleting visible global env keys while preserving redacted secrets', async () => {
|
|
const config = createEmptyUnifiedConfig();
|
|
config.global_env = {
|
|
enabled: true,
|
|
env: {
|
|
DEBUG: '1',
|
|
OPENAI_API_KEY: 'sk-test-123456',
|
|
},
|
|
};
|
|
saveUnifiedConfig(config);
|
|
|
|
const getResponse = await fetch(`${baseUrl}/api/config`);
|
|
const sanitized = (await getResponse.json()) as typeof config;
|
|
if (sanitized.global_env?.env) {
|
|
delete sanitized.global_env.env.DEBUG;
|
|
}
|
|
|
|
const putResponse = await putJson(baseUrl, '/api/config', sanitized);
|
|
expect(putResponse.status).toBe(200);
|
|
|
|
const savedConfig = loadUnifiedConfig();
|
|
expect(savedConfig?.global_env?.env.DEBUG).toBeUndefined();
|
|
expect(savedConfig?.global_env?.env.OPENAI_API_KEY).toBe('sk-test-123456');
|
|
});
|
|
|
|
it('allows deleting visible cliproxy_server and dashboard_auth fields while preserving secrets', async () => {
|
|
const config = createEmptyUnifiedConfig();
|
|
config.cliproxy_server = {
|
|
remote: {
|
|
enabled: true,
|
|
host: 'proxy.example.com',
|
|
port: 8443,
|
|
protocol: 'https',
|
|
auth_token: 'remote-auth-token',
|
|
management_key: 'management-secret',
|
|
},
|
|
fallback: {
|
|
enabled: true,
|
|
auto_start: false,
|
|
},
|
|
local: {
|
|
port: 8317,
|
|
auto_start: true,
|
|
},
|
|
};
|
|
config.dashboard_auth = {
|
|
enabled: true,
|
|
username: 'admin',
|
|
password_hash: '$2b$10$123456789012345678901u4cPFsKnzGWxZmfq6OnpZnN0UiM6Qf7e',
|
|
session_timeout_hours: 24,
|
|
};
|
|
saveUnifiedConfig(config);
|
|
|
|
const getResponse = await fetch(`${baseUrl}/api/config`);
|
|
const sanitized = (await getResponse.json()) as typeof config;
|
|
if (sanitized.cliproxy_server?.remote) {
|
|
delete sanitized.cliproxy_server.remote.port;
|
|
}
|
|
if (sanitized.dashboard_auth) {
|
|
delete sanitized.dashboard_auth.username;
|
|
}
|
|
|
|
const putResponse = await putJson(baseUrl, '/api/config', sanitized);
|
|
expect(putResponse.status).toBe(200);
|
|
|
|
const savedConfig = loadUnifiedConfig();
|
|
expect(savedConfig?.cliproxy_server?.remote.port).toBeUndefined();
|
|
expect(savedConfig?.cliproxy_server?.remote.auth_token).toBe('remote-auth-token');
|
|
expect(savedConfig?.cliproxy_server?.remote.management_key).toBe('management-secret');
|
|
expect(savedConfig?.dashboard_auth?.username).toBeUndefined();
|
|
expect(savedConfig?.dashboard_auth?.password_hash).toBe(
|
|
'$2b$10$123456789012345678901u4cPFsKnzGWxZmfq6OnpZnN0UiM6Qf7e'
|
|
);
|
|
});
|
|
|
|
it('returns alreadyMigrated when migration is not needed', async () => {
|
|
const response = await postJson(baseUrl, '/api/config/migrate');
|
|
|
|
expect(response.status).toBe(200);
|
|
const payload = (await response.json()) as {
|
|
success: boolean;
|
|
migratedFiles: string[];
|
|
warnings: string[];
|
|
alreadyMigrated?: boolean;
|
|
};
|
|
expect(payload.success).toBe(true);
|
|
expect(payload.migratedFiles).toEqual([]);
|
|
expect(payload.warnings).toEqual([]);
|
|
expect(payload.alreadyMigrated).toBe(true);
|
|
});
|
|
|
|
it('excludes unmanaged backup directories from format response', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
const managedBackup = path.join(ccsDir, 'backup-v1-2026-03-24');
|
|
const externalBackup = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-format-backup-'));
|
|
const symlinkBackup = path.join(ccsDir, 'backup-v1-symlink');
|
|
|
|
fs.mkdirSync(managedBackup, { recursive: true });
|
|
fs.symlinkSync(externalBackup, symlinkBackup, 'dir');
|
|
|
|
const response = await fetch(`${baseUrl}/api/config/format`);
|
|
expect(response.status).toBe(200);
|
|
|
|
const payload = (await response.json()) as {
|
|
format: string;
|
|
migrationNeeded: boolean;
|
|
backups: string[];
|
|
};
|
|
expect(payload.backups).toContain(managedBackup);
|
|
expect(payload.backups).not.toContain(symlinkBackup);
|
|
|
|
fs.rmSync(externalBackup, { recursive: true, force: true });
|
|
});
|
|
|
|
it('rejects rollback paths outside managed CCS backups', async () => {
|
|
const externalBackup = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-external-backup-'));
|
|
|
|
const response = await postJson(baseUrl, '/api/config/rollback', {
|
|
backupPath: externalBackup,
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(await response.json()).toEqual({
|
|
error: 'Invalid backupPath. Must reference a managed CCS migration backup directory.',
|
|
});
|
|
|
|
fs.rmSync(externalBackup, { recursive: true, force: true });
|
|
});
|
|
});
|