import { describe, expect, it } from 'bun:test'; import { authFileCooldownResetIndex, changedOAuthAuthFileNames, dedupeAuthFiles, isOAuthCredentialFile, normalizeAuthFilePriorityInput, normalizeOAuthProvider, oauthModelProvidersFromAuthFiles, parseAuthFilePriority, setOAuthCredentialFileDisabled, snapshotAuthFiles, } from '../src/services/authFiles'; describe('认证文件列表规范化', () => { it('合并同名的磁盘和运行时记录并优先保留磁盘状态', () => { const files = dedupeAuthFiles([ { name: 'codex-user.json', provider: 'codex', runtime_only: true, account_type: 'api_key', auth_index: 'runtime-index', email: 'user@example.com', }, { name: 'codex-user.json', provider: 'codex', source: 'file', path: '/tmp/codex-user.json', disabled: false, modtime: 100, }, ]); expect(files).toHaveLength(1); expect(files[0].source).toBe('file'); expect(files[0].path).toBe('/tmp/codex-user.json'); expect(files[0].email).toBe('user@example.com'); expect(files[0].auth_index).toBe('runtime-index'); expect(files[0].runtime_only).toBeUndefined(); expect(files[0].account_type).toBeUndefined(); expect(isOAuthCredentialFile(files[0])).toBe(true); }); const cooldown = { scope: 'model', model_key: 'model-b', reason: 'quota', retry_at: '2040-01-01T00:00:00Z', remaining_seconds: 30, }; it('keeps different auth indexes separate when filenames match', () => { const disk = { name: 'same.json', auth_index: 'A', source: 'file', path: '/synthetic/a', disabled: false }; const runtime = { name: 'same.json', auth_index: 'B', runtime_only: true, cooldowns: [cooldown] }; for (const entries of [[disk, runtime], [runtime, disk]]) { const files = dedupeAuthFiles(entries); expect(files).toHaveLength(2); expect(files.find((file) => authFileCooldownResetIndex(file) === 'A')).toEqual(disk); expect(files.find((file) => authFileCooldownResetIndex(file) === 'B')).toEqual(runtime); } }); it('merges the same normalized index and preserves atomic empty or unknown cooldowns', () => { for (const cooldowns of [[], null]) { const files = dedupeAuthFiles([ { name: 'same.json', auth_index: 0, source: 'file', path: '/synthetic/a', cooldowns }, { name: 'same.json', authIndex: ' 0 ', runtime_only: true, email: 'account@example.test', cooldowns: [cooldown] }, { name: 'same.json', auth_index: 'B', runtime_only: true, cooldowns: [cooldown] }, ]); expect(files).toHaveLength(2); const sameIdentity = files.find((file) => authFileCooldownResetIndex(file) === '0')!; expect(sameIdentity.path).toBe('/synthetic/a'); expect(sameIdentity.email).toBe('account@example.test'); expect(sameIdentity.runtime_only).toBeUndefined(); expect(sameIdentity.cooldowns).toEqual(cooldowns); expect(files.find((file) => authFileCooldownResetIndex(file) === 'B')?.cooldowns).toEqual([cooldown]); } }); it('keeps unindexed records separate when the filename has conflicting identities', () => { const files = dedupeAuthFiles([ { name: 'same.json', source: 'file', path: '/synthetic/unknown', cooldowns: [cooldown] }, { name: 'same.json', auth_index: 'A', runtime_only: true, cooldowns: [] }, { name: 'same.json', authIndex: 'B', runtime_only: true, cooldowns: null }, ]); expect(files).toHaveLength(3); const unknown = files.find((file) => authFileCooldownResetIndex(file) === undefined)!; expect(unknown.path).toBe('/synthetic/unknown'); expect(unknown.cooldowns).toEqual([cooldown]); for (const index of ['A', 'B']) { const file = files.find((entry) => authFileCooldownResetIndex(entry) === index)!; expect(file.path).toBeUndefined(); expect(file.runtime_only).toBe(true); expect(file.cooldowns).toEqual(index === 'A' ? [] : null); } }); }); const oauthFile = { name: 'codex-user.json', provider: 'codex', source: 'file', account_type: 'oauth' }; const nonOAuthFiles = [ { ...oauthFile, runtime_only: true }, { ...oauthFile, runtimeOnly: true }, { ...oauthFile, account_type: 'api_key' }, { ...oauthFile, account_type: 'API-KEY' }, { ...oauthFile, auth_kind: 'apikey' }, { ...oauthFile, authKind: 'api_key' }, { ...oauthFile, source: 'memory' }, { ...oauthFile, source: 'config:codex[key]' }, { ...oauthFile, name: 'codex:apikey:runtime-id' }, { ...oauthFile, name: '' }, ]; describe('OAuth credential file boundaries', () => { it('accepts disk-backed OAuth files, disabled files, and legacy disk listings', () => { expect(isOAuthCredentialFile(oauthFile)).toBe(true); expect(isOAuthCredentialFile({ ...oauthFile, disabled: true })).toBe(true); expect(isOAuthCredentialFile({ name: 'legacy.JSON', type: 'codex' })).toBe(true); expect(isOAuthCredentialFile({})).toBe(false); }); it('rejects API-key and runtime records even when they use an OAuth provider or JSON name', () => { for (const file of nonOAuthFiles) expect(isOAuthCredentialFile(file)).toBe(false); }); it('offers only providers with OAuth credential files, preserving aliases and plugin providers', () => { expect(oauthModelProvidersFromAuthFiles([ ...nonOAuthFiles.map((file) => ({ ...file, provider: 'api-only' })), oauthFile, { ...oauthFile, name: 'second.json' }, { name: 'claude.json', type: 'anthropic' }, { name: 'devin.json', type: 'cognition' }, { name: 'antigravity.json', type: 'anti-gravity' }, { name: 'openai.json', type: 'openai' }, { name: 'muse.json', type: 'muse' }, { name: 'plugin.json', provider: 'custom-oauth', source: 'file' }, { name: 'unknown.json' }, ])).toEqual(['antigravity', 'claude', 'codex', 'custom-oauth', 'devin', 'meta']); expect(oauthModelProvidersFromAuthFiles(nonOAuthFiles)).toEqual([]); }); it('enables and disables only the selected OAuth file without touching API configuration', async () => { const writes: unknown[] = []; const api = { patch: async (path: string, body: Record) => { writes.push({ path, body }); } }; await setOAuthCredentialFileDisabled(oauthFile, true, api); await setOAuthCredentialFileDisabled({ ...oauthFile, disabled: true }, false, api); expect(writes).toEqual([ { path: '/credentials/status', body: { name: 'codex-user.json', disabled: true } }, { path: '/credentials/status', body: { name: 'codex-user.json', disabled: false } }, ]); }); it('rejects runtime and API-key status changes before any management request', async () => { const writes: unknown[] = []; const api = { patch: async (path: string, body: Record) => { writes.push({ path, body }); } }; for (const file of nonOAuthFiles) { for (const disabled of [true, false]) { await expect(setOAuthCredentialFileDisabled(file, disabled, api)).rejects.toThrow('OAuth'); } } expect(writes).toEqual([]); }); it('normalizes Meta OAuth aliases used by the core and Management Center', () => { expect(normalizeOAuthProvider(' Muse ')).toBe('meta'); expect(normalizeOAuthProvider('meta_ai')).toBe('meta_ai'); expect(normalizeOAuthProvider('anthropic')).toBe('claude'); }); }); describe('authentication file priority', () => { it('accepts safe integers from API values', () => { expect(parseAuthFilePriority(10)).toBe(10); expect(parseAuthFilePriority(' -3 ')).toBe(-3); expect(parseAuthFilePriority(1.5)).toBeUndefined(); expect(parseAuthFilePriority('high')).toBeUndefined(); }); it('uses zero to restore the default and rejects invalid input', () => { expect(normalizeAuthFilePriorityInput('')).toBe(0); expect(normalizeAuthFilePriorityInput('0')).toBe(0); expect(normalizeAuthFilePriorityInput('12')).toBe(12); expect(normalizeAuthFilePriorityInput('1.5')).toBeNull(); }); it('finds only credentials created or updated by the completed OAuth provider', () => { const before = snapshotAuthFiles([ { name: 'codex-old.json', provider: 'codex', modtime: 1, priority: 8 }, { name: 'codex-custom.json', provider: 'codex', modtime: 1, priority: 5 }, { name: 'claude-old.json', provider: 'claude', modtime: 1 }, ]); expect(changedOAuthAuthFileNames(before, [ { name: 'codex-old.json', provider: 'codex', modtime: 2 }, { name: 'codex-custom.json', provider: 'codex', modtime: 2, priority: 5 }, { name: 'codex-new.json', type: 'codex', modtime: 2 }, { name: 'claude-old.json', provider: 'claude', modtime: 2 }, ], 'codex')).toEqual(['codex-old.json', 'codex-new.json']); }); it('applies the default priority to a newly created Muse credential through the muse alias', () => { const before = snapshotAuthFiles([ { name: 'muse-existing.json', provider: 'muse', modtime: 1 }, ]); expect(changedOAuthAuthFileNames(before, [ { name: 'muse-existing.json', provider: 'muse', modtime: 2 }, { name: 'muse-new.json', provider: 'meta', modtime: 2 }, { name: 'muse-priority.json', provider: 'muse', modtime: 2, priority: 4 }, ], 'muse')).toEqual(['muse-existing.json', 'muse-new.json']); }); });