mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
368 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|