mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 04:15:52 +00:00
Merge pull request #627 from kaitranntt/kai/feat/624-share-context
fix(auth): harden shared-context isolation edge cases
This commit is contained in:
26 files changed
+1644
-106
No files matched your search
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
MAX_CONTEXT_GROUP_LENGTH,
|
||||
isValidAccountProfileName,
|
||||
policyToAccountContextMetadata,
|
||||
resolveAccountContextPolicy,
|
||||
resolveCreateAccountContext,
|
||||
} from '../../src/auth/account-context';
|
||||
|
||||
describe('account context helpers', () => {
|
||||
it('rejects context groups that exceed the max length', () => {
|
||||
const group = `a${'x'.repeat(MAX_CONTEXT_GROUP_LENGTH)}`;
|
||||
const result = resolveCreateAccountContext({ shareContext: false, contextGroup: group });
|
||||
|
||||
expect(result.error).toContain('Invalid context group');
|
||||
});
|
||||
|
||||
it('rejects profile names with unsupported characters', () => {
|
||||
expect(isValidAccountProfileName('work')).toBe(true);
|
||||
expect(isValidAccountProfileName('gemini:default')).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to default shared group for invalid persisted metadata', () => {
|
||||
const resolved = resolveAccountContextPolicy({
|
||||
context_mode: 'shared',
|
||||
context_group: '###',
|
||||
});
|
||||
|
||||
expect(resolved.mode).toBe('shared');
|
||||
expect(resolved.group).toBe('default');
|
||||
});
|
||||
|
||||
it('round-trips shared policy metadata with normalized context group', () => {
|
||||
const metadata = policyToAccountContextMetadata({
|
||||
mode: 'shared',
|
||||
group: 'Sprint-A',
|
||||
});
|
||||
|
||||
const resolved = resolveAccountContextPolicy(metadata);
|
||||
expect(resolved.mode).toBe('shared');
|
||||
expect(resolved.group).toBe('sprint-a');
|
||||
});
|
||||
|
||||
it('normalizes whitespace in explicit shared context group names', () => {
|
||||
const result = resolveCreateAccountContext({
|
||||
shareContext: false,
|
||||
contextGroup: ' Team Alpha ',
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.policy.mode).toBe('shared');
|
||||
expect(result.policy.group).toBe('team-alpha');
|
||||
});
|
||||
});
|
||||
@@ -31,4 +31,18 @@ describe('auth command args parsing', () => {
|
||||
expect(parsed.profileName).toBe('work');
|
||||
expect(parsed.contextGroup).toBe('');
|
||||
});
|
||||
|
||||
it('flags empty inline context group as empty string', () => {
|
||||
const parsed = parseArgs(['work', '--context-group=']);
|
||||
|
||||
expect(parsed.profileName).toBe('work');
|
||||
expect(parsed.contextGroup).toBe('');
|
||||
});
|
||||
|
||||
it('tracks unknown flags and keeps positional profile intact', () => {
|
||||
const parsed = parseArgs(['--foo', 'bar', 'work']);
|
||||
|
||||
expect(parsed.profileName).toBe('work');
|
||||
expect(parsed.unknownFlags).toEqual(['--foo']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
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 ProfileRegistry from '../../src/auth/profile-registry';
|
||||
import InstanceManager from '../../src/management/instance-manager';
|
||||
import { handleList } from '../../src/auth/commands/list-command';
|
||||
|
||||
describe('auth list context metadata', () => {
|
||||
let tempRoot = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCcsUnified: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auth-list-context-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCcsUnified = process.env.CCS_UNIFIED_CONFIG;
|
||||
|
||||
process.env.CCS_HOME = tempRoot;
|
||||
process.env.CCS_UNIFIED_CONFIG = '1';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
if (originalCcsUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalCcsUnified;
|
||||
else delete process.env.CCS_UNIFIED_CONFIG;
|
||||
|
||||
if (tempRoot && fs.existsSync(tempRoot)) {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps unified account context metadata in JSON list output', async () => {
|
||||
const ccsDir = path.join(tempRoot, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
' context_mode: shared',
|
||||
' context_group: sprint-a',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
|
||||
const instanceMgr = new InstanceManager();
|
||||
const lines: string[] = [];
|
||||
const originalLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
lines.push(args.map(String).join(' '));
|
||||
};
|
||||
|
||||
try {
|
||||
await handleList(
|
||||
{
|
||||
registry,
|
||||
instanceMgr,
|
||||
version: 'test',
|
||||
},
|
||||
['--json']
|
||||
);
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(lines.join('\n')) as {
|
||||
profiles: Array<{ name: string; context_mode?: string; context_group?: string | null }>;
|
||||
};
|
||||
const work = payload.profiles.find((profile) => profile.name === 'work');
|
||||
|
||||
expect(work).toBeTruthy();
|
||||
expect(work?.context_mode).toBe('shared');
|
||||
expect(work?.context_group).toBe('sprint-a');
|
||||
});
|
||||
|
||||
it('prefers unified context metadata over legacy when profile names overlap', async () => {
|
||||
const ccsDir = path.join(tempRoot, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'profiles.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: '2.0.0',
|
||||
profiles: {
|
||||
work: {
|
||||
type: 'account',
|
||||
created: '2026-01-01T00:00:00.000Z',
|
||||
last_used: null,
|
||||
context_mode: 'isolated',
|
||||
},
|
||||
},
|
||||
default: null,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
' context_mode: shared',
|
||||
' context_group: sprint-a',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
const instanceMgr = new InstanceManager();
|
||||
const lines: string[] = [];
|
||||
const originalLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
lines.push(args.map(String).join(' '));
|
||||
};
|
||||
|
||||
try {
|
||||
await handleList(
|
||||
{
|
||||
registry,
|
||||
instanceMgr,
|
||||
version: 'test',
|
||||
},
|
||||
['--json']
|
||||
);
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(lines.join('\n')) as {
|
||||
profiles: Array<{ name: string; context_mode?: string; context_group?: string | null }>;
|
||||
};
|
||||
const work = payload.profiles.find((profile) => profile.name === 'work');
|
||||
|
||||
expect(work).toBeTruthy();
|
||||
expect(work?.context_mode).toBe('shared');
|
||||
expect(work?.context_group).toBe('sprint-a');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
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 ProfileRegistry from '../../../src/auth/profile-registry';
|
||||
|
||||
describe('profile-registry context normalization', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalUnifiedMode: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-registry-context-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
if (originalUnifiedMode !== undefined) process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode;
|
||||
else delete process.env.CCS_UNIFIED_CONFIG;
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('drops non-string legacy context_group values without throwing', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'profiles.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: '2.0.0',
|
||||
default: null,
|
||||
profiles: {
|
||||
work: {
|
||||
type: 'account',
|
||||
created: '2026-02-24T00:00:00.000Z',
|
||||
last_used: null,
|
||||
context_mode: 'shared',
|
||||
context_group: { invalid: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
const profile = registry.getProfile('work');
|
||||
|
||||
expect(profile.context_mode).toBe('shared');
|
||||
expect(profile.context_group).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops non-string unified context_group values without throwing', () => {
|
||||
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-02-24T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
' context_mode: shared',
|
||||
' context_group: 123',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
const accounts = registry.getAllAccountsUnified();
|
||||
|
||||
expect(accounts.work.context_mode).toBe('shared');
|
||||
expect(accounts.work.context_group).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { loadMigrationCheckData, migrate } from '../../../src/config/migration-manager';
|
||||
import { saveUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
import { loadUnifiedConfig, saveUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types';
|
||||
|
||||
describe('migration-manager legacy kimi compatibility', () => {
|
||||
@@ -132,4 +132,83 @@ describe('migration-manager legacy kimi compatibility', () => {
|
||||
const checkData = loadMigrationCheckData();
|
||||
expect(checkData.needsMigration).toBe(false);
|
||||
});
|
||||
|
||||
it('migrates account context metadata from profiles.json', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'profiles.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
default: 'work',
|
||||
profiles: {
|
||||
work: {
|
||||
type: 'account',
|
||||
created: '2026-02-01T00:00:00.000Z',
|
||||
last_used: null,
|
||||
context_mode: 'shared',
|
||||
context_group: 'sprint-a',
|
||||
},
|
||||
personal: {
|
||||
type: 'account',
|
||||
created: '2026-02-02T00:00:00.000Z',
|
||||
last_used: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const result = await migrate(false);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const unified = loadUnifiedConfig();
|
||||
expect(unified).toBeTruthy();
|
||||
expect(unified?.accounts.work.context_mode).toBe('shared');
|
||||
expect(unified?.accounts.work.context_group).toBe('sprint-a');
|
||||
expect(unified?.accounts.personal.context_mode).toBe('isolated');
|
||||
expect(unified?.accounts.personal.context_group).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalizes valid legacy shared groups and drops invalid ones during migration', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'profiles.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
default: 'work',
|
||||
profiles: {
|
||||
work: {
|
||||
type: 'account',
|
||||
created: '2026-02-01T00:00:00.000Z',
|
||||
last_used: null,
|
||||
context_mode: 'shared',
|
||||
context_group: 'Sprint-A',
|
||||
},
|
||||
broken: {
|
||||
type: 'account',
|
||||
created: '2026-02-02T00:00:00.000Z',
|
||||
last_used: null,
|
||||
context_mode: 'shared',
|
||||
context_group: '###',
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const result = await migrate(false);
|
||||
expect(result.success).toBe(true);
|
||||
expect(
|
||||
result.warnings.some((warning) =>
|
||||
warning.includes('Skipped invalid context group for account "broken"')
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
const unified = loadUnifiedConfig();
|
||||
expect(unified?.accounts.work.context_group).toBe('sprint-a');
|
||||
expect(unified?.accounts.broken.context_mode).toBe('shared');
|
||||
expect(unified?.accounts.broken.context_group).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import SharedManager from '../../src/management/shared-manager';
|
||||
import InstanceManager from '../../src/management/instance-manager';
|
||||
import type { AccountContextPolicy } from '../../src/auth/account-context';
|
||||
|
||||
function getTestCcsDir(): string {
|
||||
@@ -12,6 +13,12 @@ function getTestCcsDir(): string {
|
||||
return path.join(path.resolve(process.env.CCS_HOME), '.ccs');
|
||||
}
|
||||
|
||||
function createDirectorySymlink(targetPath: string, linkPath: string): void {
|
||||
const symlinkType: 'dir' | 'junction' = process.platform === 'win32' ? 'junction' : 'dir';
|
||||
const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath;
|
||||
fs.symlinkSync(linkTarget, linkPath, symlinkType);
|
||||
}
|
||||
|
||||
describe('SharedManager context policy', () => {
|
||||
let tempRoot = '';
|
||||
let originalHome: string | undefined;
|
||||
@@ -118,4 +125,57 @@ describe('SharedManager context policy', () => {
|
||||
expect(fs.existsSync(projectFile)).toBe(true);
|
||||
expect(fs.readFileSync(projectFile, 'utf8')).toBe('shared history');
|
||||
});
|
||||
|
||||
it('serializes concurrent context sync for the same profile', async () => {
|
||||
const instanceMgr = new InstanceManager();
|
||||
const jobs = Array.from({ length: 6 }, () =>
|
||||
instanceMgr.ensureInstance('work', { mode: 'shared', group: 'sprint-a' })
|
||||
);
|
||||
|
||||
await Promise.all(jobs);
|
||||
|
||||
const ccsDir = getTestCcsDir();
|
||||
const projectsPath = path.join(ccsDir, 'instances', 'work', 'projects');
|
||||
const stats = fs.lstatSync(projectsPath);
|
||||
|
||||
expect(stats.isDirectory() || stats.isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('skips merge when projects symlink target is outside canonical CCS roots', async () => {
|
||||
const ccsDir = getTestCcsDir();
|
||||
const instancePath = path.join(ccsDir, 'instances', 'work');
|
||||
const projectsPath = path.join(instancePath, 'projects');
|
||||
const unsafeProjectsPath = path.join(ccsDir, 'shared', 'context-groups-evil', 'projects');
|
||||
const unsafeFile = path.join(unsafeProjectsPath, '-tmp-project', 'notes.md');
|
||||
|
||||
fs.mkdirSync(path.dirname(unsafeFile), { recursive: true });
|
||||
fs.writeFileSync(unsafeFile, 'unsafe source', 'utf8');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
createDirectorySymlink(unsafeProjectsPath, projectsPath);
|
||||
|
||||
const manager = new SharedManager();
|
||||
await manager.syncProjectContext(instancePath, { mode: 'isolated' });
|
||||
|
||||
expect(fs.lstatSync(projectsPath).isDirectory()).toBe(true);
|
||||
expect(fs.existsSync(path.join(projectsPath, '-tmp-project', 'notes.md'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not detach project memory symlink from lookalike shared path prefixes', async () => {
|
||||
const ccsDir = getTestCcsDir();
|
||||
const instancePath = path.join(ccsDir, 'instances', 'work');
|
||||
const projectPath = path.join(instancePath, 'projects', '-tmp-project');
|
||||
const memoryPath = path.join(projectPath, 'memory');
|
||||
const unsafeMemoryTarget = path.join(ccsDir, 'shared', 'memory-evil', '-tmp-project');
|
||||
const unsafeMemoryFile = path.join(unsafeMemoryTarget, 'MEMORY.md');
|
||||
|
||||
fs.mkdirSync(path.dirname(unsafeMemoryFile), { recursive: true });
|
||||
fs.writeFileSync(unsafeMemoryFile, 'unsafe memory', 'utf8');
|
||||
fs.mkdirSync(projectPath, { recursive: true });
|
||||
createDirectorySymlink(unsafeMemoryTarget, memoryPath);
|
||||
|
||||
const manager = new SharedManager();
|
||||
await manager.syncProjectContext(instancePath, { mode: 'isolated' });
|
||||
|
||||
expect(fs.lstatSync(memoryPath).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
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 accountRoutes from '../../../src/web-server/routes/account-routes';
|
||||
import ProfileRegistry from '../../../src/auth/profile-registry';
|
||||
import { InstanceManager } from '../../../src/management/instance-manager';
|
||||
|
||||
async function getJson<T>(baseUrl: string, routePath: string): Promise<T> {
|
||||
const response = await fetch(`${baseUrl}${routePath}`);
|
||||
expect(response.status).toBe(200);
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function deletePath(baseUrl: string, routePath: string): Promise<Response> {
|
||||
return fetch(`${baseUrl}${routePath}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
describe('web-server account-routes context normalization', () => {
|
||||
let server: Server;
|
||||
let baseUrl = '';
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCcsUnified: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = express();
|
||||
app.use('/api/accounts', accountRoutes);
|
||||
|
||||
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-account-routes-context-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCcsUnified = process.env.CCS_UNIFIED_CONFIG;
|
||||
|
||||
process.env.CCS_HOME = tempHome;
|
||||
process.env.CCS_UNIFIED_CONFIG = '1';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
if (originalCcsUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalCcsUnified;
|
||||
else delete process.env.CCS_UNIFIED_CONFIG;
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes invalid persisted account context metadata in API response', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
' context_mode: weird',
|
||||
' context_group: "###"',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const payload = await getJson<{
|
||||
accounts: Array<{ name: string; context_mode?: string; context_group?: string }>;
|
||||
}>(baseUrl, '/api/accounts');
|
||||
|
||||
const work = payload.accounts.find((account) => account.name === 'work');
|
||||
expect(work).toBeTruthy();
|
||||
expect(work?.context_mode).toBe('isolated');
|
||||
expect(work && 'context_group' in work).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back shared accounts with invalid groups to default shared group', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
' context_mode: shared',
|
||||
' context_group: "###"',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const payload = await getJson<{
|
||||
accounts: Array<{ name: string; context_mode?: string; context_group?: string }>;
|
||||
}>(baseUrl, '/api/accounts');
|
||||
|
||||
const work = payload.accounts.find((account) => account.name === 'work');
|
||||
expect(work).toBeTruthy();
|
||||
expect(work?.context_mode).toBe('shared');
|
||||
expect(work?.context_group).toBe('default');
|
||||
});
|
||||
|
||||
it('does not delete metadata when instance deletion fails', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
' context_mode: shared',
|
||||
' context_group: sprint-a',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
const registry = new ProfileRegistry();
|
||||
|
||||
const originalDeleteInstance = InstanceManager.prototype.deleteInstance;
|
||||
InstanceManager.prototype.deleteInstance = () => {
|
||||
throw new Error('simulated instance delete failure');
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await deletePath(baseUrl, '/api/accounts/work');
|
||||
expect(response.status).toBe(500);
|
||||
expect(registry.hasAccountUnified('work')).toBe(true);
|
||||
} finally {
|
||||
InstanceManager.prototype.deleteInstance = originalDeleteInstance;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
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 } 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 tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
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;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
fs.mkdirSync(path.join(tempHome, '.ccs'), { recursive: true });
|
||||
});
|
||||
|
||||
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 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 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',
|
||||
};
|
||||
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');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user