Files
ccs/tests/unit/cliproxy/account-registry-integrity.test.ts
T

368 lines
14 KiB
TypeScript

import { describe, expect, it, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { runWithScopedCcsHome } from '../../../src/utils/config-manager';
async function withIsolatedHome<T>(fn: (homeDir: string) => Promise<T> | T): Promise<T> {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-account-registry-'));
try {
return await runWithScopedCcsHome(homeDir, () => fn(homeDir));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
}
async function loadRegistryModule() {
return import(`../../../src/cliproxy/accounts/registry?registry-integrity=${Date.now()}`);
}
async function loadAccountManager() {
return import(`../../../src/cliproxy/account-manager?account-registry-integrity=${Date.now()}`);
}
async function captureConsoleError<T>(fn: () => Promise<T> | T): Promise<{ result: T; messages: string[] }> {
const originalError = console.error;
const messages: string[] = [];
console.error = ((...args: unknown[]) => {
messages.push(args.map(String).join(' '));
}) as typeof console.error;
try {
return {
result: await fn(),
messages,
};
} finally {
console.error = originalError;
}
}
function listRegistryBackups(registryPath: string): string[] {
return fs
.readdirSync(path.dirname(registryPath))
.filter((file) => /^accounts\.json\.corrupt-.*\.bak$/.test(file));
}
describe('account registry integrity', () => {
it('does not create accounts.json during no-op discovery', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
fs.mkdirSync(authDir, { recursive: true });
const { discoverExistingAccounts } = await loadRegistryModule();
discoverExistingAccounts();
expect(fs.existsSync(registryPath)).toBe(false);
});
});
it('does not write accounts.json during provider account reads', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
fs.mkdirSync(authDir, { recursive: true });
const { getProviderAccounts } = await loadAccountManager();
expect(getProviderAccounts('kiro')).toEqual([]);
expect(fs.existsSync(registryPath)).toBe(false);
});
});
it('removes stale accounts before choosing the next default during registration', async () => {
await withIsolatedHome(async (homeDir) => {
const cliproxyDir = path.join(homeDir, '.ccs', 'cliproxy');
const authDir = path.join(cliproxyDir, 'auth');
const registryPath = path.join(cliproxyDir, 'accounts.json');
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(
path.join(authDir, 'kiro-github-ABC123.json'),
JSON.stringify({ type: 'kiro' })
);
fs.writeFileSync(
registryPath,
JSON.stringify({
version: 1,
providers: {
kiro: {
default: 'github-OLD999',
accounts: {
'github-OLD999': {
nickname: 'old',
tokenFile: 'kiro-github-OLD999.json',
createdAt: '2025-01-01T00:00:00.000Z',
lastUsedAt: '2025-01-01T00:00:00.000Z',
},
},
},
},
}),
'utf8'
);
const { registerAccount } = await loadAccountManager();
const account = registerAccount('kiro', 'kiro-github-ABC123.json');
const { loadAccountsRegistry } = await loadRegistryModule();
const registry = loadAccountsRegistry();
const kiroAccounts = registry.providers.kiro;
expect(account.id).toBe('github-ABC123');
expect(kiroAccounts?.default).toBe('github-ABC123');
expect(kiroAccounts?.accounts['github-OLD999']).toBeUndefined();
expect(Object.keys(kiroAccounts?.accounts ?? {})).toEqual(['github-ABC123']);
});
});
it('recovers corrupted accounts.json from token-backed accounts and preserves a backup', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(
path.join(authDir, 'codex-user@example.com.json'),
JSON.stringify({ type: 'codex', email: 'user@example.com' }),
'utf8'
);
fs.writeFileSync(registryPath, '{not-valid-json', 'utf8');
const { getProviderAccounts } = await loadAccountManager();
const accounts = getProviderAccounts('codex');
const { loadAccountsRegistry } = await loadRegistryModule();
const registry = loadAccountsRegistry();
expect(accounts).toHaveLength(1);
expect(accounts[0]?.id).toBe('user@example.com');
expect(accounts[0]?.isDefault).toBe(true);
expect(registry.providers.codex?.accounts['user@example.com']?.tokenFile).toBe(
'codex-user@example.com.json'
);
const backups = listRegistryBackups(registryPath);
expect(backups).toHaveLength(1);
expect(
fs.readFileSync(path.join(path.dirname(registryPath), backups[0] as string), 'utf8')
).toBe('{not-valid-json');
});
});
it('recovers paused token-backed accounts when rebuilding a corrupted registry', async () => {
await withIsolatedHome(async (homeDir) => {
const pausedDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth-paused');
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
fs.mkdirSync(pausedDir, { recursive: true });
fs.writeFileSync(
path.join(pausedDir, 'codex-paused@example.com.json'),
JSON.stringify({ type: 'codex', email: 'paused@example.com' }),
'utf8'
);
fs.writeFileSync(registryPath, '{"providers":', 'utf8');
const { getProviderAccounts } = await loadAccountManager();
const accounts = getProviderAccounts('codex');
expect(accounts).toHaveLength(1);
expect(accounts[0]?.id).toBe('paused@example.com');
expect(accounts[0]?.paused).toBe(true);
});
});
it('surfaces skipped token files only during corruption recovery', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(
path.join(authDir, 'codex-valid@example.com.json'),
JSON.stringify({ type: 'codex', email: 'valid@example.com' }),
'utf8'
);
fs.writeFileSync(
path.join(authDir, 'codex-invalid@example.com.json'),
JSON.stringify({ type: 123, email: 'invalid@example.com' }),
'utf8'
);
fs.writeFileSync(registryPath, '{"providers":', 'utf8');
const { getProviderAccounts } = await loadAccountManager();
const { result: accounts, messages } = await captureConsoleError(() =>
getProviderAccounts('codex')
);
expect(accounts).toHaveLength(1);
expect(accounts[0]?.id).toBe('valid@example.com');
expect(messages).toHaveLength(1);
expect(messages[0]).toContain('Recovered corrupted account registry');
expect(messages[0]).toContain('auth/codex-invalid@example.com.json');
expect(messages[0]).toContain('invalid token type');
});
});
it('logs successful corruption recovery even when every token file is recoverable', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(
path.join(authDir, 'codex-clean@example.com.json'),
JSON.stringify({ type: 'codex', email: 'clean@example.com' }),
'utf8'
);
fs.writeFileSync(registryPath, '{"providers":', 'utf8');
const { getProviderAccounts } = await loadAccountManager();
const { result: accounts, messages } = await captureConsoleError(() =>
getProviderAccounts('codex')
);
expect(accounts).toHaveLength(1);
expect(messages).toHaveLength(1);
expect(messages[0]).toContain('Recovered corrupted account registry');
expect(messages[0]).toContain('backup saved to');
expect(messages[0]).not.toContain('skipped');
});
});
it('prefers active tokens over paused duplicates during corruption recovery', async () => {
await withIsolatedHome(async (homeDir) => {
const cliproxyDir = path.join(homeDir, '.ccs', 'cliproxy');
const authDir = path.join(cliproxyDir, 'auth');
const pausedDir = path.join(cliproxyDir, 'auth-paused');
const registryPath = path.join(cliproxyDir, 'accounts.json');
fs.mkdirSync(authDir, { recursive: true });
fs.mkdirSync(pausedDir, { recursive: true });
fs.writeFileSync(
path.join(authDir, 'codex-shared@example.com.json'),
JSON.stringify({ type: 'codex', email: 'active@example.com' }),
'utf8'
);
fs.writeFileSync(
path.join(pausedDir, 'codex-shared@example.com.json'),
JSON.stringify({ type: 'codex', email: 'paused@example.com' }),
'utf8'
);
fs.writeFileSync(registryPath, '{"providers":', 'utf8');
const { getProviderAccounts } = await loadAccountManager();
const accounts = getProviderAccounts('codex');
expect(accounts).toHaveLength(1);
expect(accounts[0]?.id).toBe('active@example.com');
expect(accounts[0]?.tokenFile).toBe('codex-shared@example.com.json');
expect(accounts[0]?.paused).toBeUndefined();
});
});
it('infers subdomain emails from token file names during corruption recovery', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(
path.join(authDir, 'codex-user@mail.example.com.json'),
JSON.stringify({ type: 'codex' }),
'utf8'
);
fs.writeFileSync(registryPath, '{"providers":', 'utf8');
const { getProviderAccounts } = await loadAccountManager();
const accounts = getProviderAccounts('codex');
expect(accounts).toHaveLength(1);
expect(accounts[0]?.id).toBe('user@mail.example.com');
expect(accounts[0]?.email).toBe('user@mail.example.com');
expect(accounts[0]?.tokenFile).toBe('codex-user@mail.example.com.json');
});
});
it('preserves the corrupted registry backup when recovery cannot rewrite accounts.json', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(
path.join(authDir, 'codex-user@example.com.json'),
JSON.stringify({ type: 'codex', email: 'user@example.com' }),
'utf8'
);
fs.writeFileSync(registryPath, '{"providers":', 'utf8');
const originalWriteFileSync = fs.writeFileSync;
const writeSpy = spyOn(fs, 'writeFileSync').mockImplementation(((file, data, options) => {
if (String(file).includes('accounts.json.tmp.')) {
throw new Error('simulated recovery write failure');
}
return originalWriteFileSync(file, data, options as never);
}) as typeof fs.writeFileSync);
try {
const { loadAccountsRegistry } = await loadRegistryModule();
expect(() => loadAccountsRegistry()).toThrow('simulated recovery write failure');
} finally {
writeSpy.mockRestore();
}
expect(fs.existsSync(registryPath)).toBe(false);
const backups = listRegistryBackups(registryPath);
expect(backups).toHaveLength(1);
expect(
fs.readFileSync(path.join(path.dirname(registryPath), backups[0] as string), 'utf8')
).toBe('{"providers":');
const { loadAccountsRegistry } = await loadRegistryModule();
expect(loadAccountsRegistry()).toEqual({
version: 1,
providers: {},
});
});
});
it('does not repopulate paused tokens during normal startup discovery', async () => {
await withIsolatedHome(async (homeDir) => {
const pausedDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth-paused');
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
fs.mkdirSync(pausedDir, { recursive: true });
fs.writeFileSync(
path.join(pausedDir, 'codex-paused@example.com.json'),
JSON.stringify({ type: 'codex', email: 'paused@example.com' }),
'utf8'
);
const { discoverExistingAccounts } = await loadRegistryModule();
discoverExistingAccounts();
expect(fs.existsSync(registryPath)).toBe(false);
const { getProviderAccounts } = await loadAccountManager();
expect(getProviderAccounts('codex')).toEqual([]);
});
});
it('recovers to an empty registry when no token files are available', async () => {
await withIsolatedHome(async (homeDir) => {
const cliproxyDir = path.join(homeDir, '.ccs', 'cliproxy');
const registryPath = path.join(cliproxyDir, 'accounts.json');
fs.mkdirSync(cliproxyDir, { recursive: true });
fs.writeFileSync(registryPath, '{"providers":', 'utf8');
const { loadAccountsRegistry } = await loadRegistryModule();
const { result: registry, messages } = await captureConsoleError(() => loadAccountsRegistry());
expect(registry).toEqual({
version: 1,
providers: {},
});
expect(fs.existsSync(registryPath)).toBe(true);
expect(messages).toHaveLength(1);
expect(messages[0]).toContain('Recovered corrupted account registry');
expect(messages[0]).toContain('backup saved to');
expect(listRegistryBackups(registryPath)).toHaveLength(1);
});
});
});