Merge pull request #627 from kaitranntt/kai/feat/624-share-context

fix(auth): harden shared-context isolation edge cases
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-02-26 01:47:39 -05:00
commit a30d55777f
26 files changed
+1644 -106

No files matched your search

+54
View File
@@ -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');
});
});
+14
View File
@@ -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']);
});
});
+162
View File
@@ -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();
});
});
+80 -1
View File
@@ -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();
});
});
+60
View File
@@ -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);
});
});