mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
051805074e
* fix(cliproxy): migrate deprecated gemini-claude-* model names to upstream claude-* names (#515) * fix(cliproxy): migrate deprecated gemini-claude-* model names to upstream claude-* names CLIProxyAPI registry no longer recognizes the gemini-claude-* prefix convention. Model names in catalog, base config, and user settings are migrated to upstream claude-* names. Auto-migration in env-builder rewrites existing user settings on load and persists the change. Closes #513 * fix: address code review feedback — sync UI layer and add migration tests - Sync UI isNativeGeminiModel() with backend (remove gemini-claude- exclusion) - Update UI model catalog agy entries from gemini-claude-* to claude-* - Update CI/CD workflow and code-reviewer default model names - Add unit tests for migrateDeprecatedModelNames() logic * fix(hooks): isolate image type check before error-prone processing (#514) * fix(hooks): isolate image type check before error-prone processing Restructure processHook() into two phases so non-image Read calls never see hook error messages. Phase 1 defensively checks tool name and file extension, exiting 0 silently on any failure. Phase 2 only runs for confirmed image/PDF files where errors are relevant. Closes #511 * fix(hooks): sync image analyzer hook file on every profile launch Add installImageAnalyzerHook() call to cliproxy executor, matching the existing installWebSearchHook() pattern. This ensures the .cjs file in ~/.ccs/hooks/ gets refreshed from the npm package on every launch, so users receive hook updates after npm update. * chore(release): 7.41.0-dev.1 [skip ci] * fix(cliproxy): add fork:true for Claude model aliases in config generator (#523) Config generator now outputs fork:true for Claude model alias entries, ensuring both upstream (claude-*) and aliased (gemini-claude-*) model names appear in /v1/models listings. Also preserves fork flag when parsing user-added aliases during config regeneration. Bumps config version to v7 to trigger regeneration on next ccs doctor. Closes #522 * chore(release): 7.41.0-dev.2 [skip ci] * feat(cliproxy): add account safety guards to prevent Google account bans (#516) * feat(cliproxy): add account safety guards to prevent Google account bans Implements cross-provider isolation to prevent Google from flagging concurrent OAuth usage across different client IDs (ref: #509, #512). Three pillars: 1. Auto-pause enforcement at session launch — conflicting accounts in other Google OAuth providers are paused so CLIProxyAPI can't use them, restored on session exit with crash recovery via auto-paused.json 2. Ban/disable detection — error responses matching Google ban patterns auto-pause the affected account to prevent further damage 3. Cross-provider conflict warnings during OAuth registration Key design decisions: - PID-based session tracking for crash recovery (dead PID = restore) - Timestamp comparison prevents restoring ban-paused accounts on exit - Schema validation on auto-paused.json prevents corrupted state - Falls back to warn-only when another session is managing isolation * fix(cliproxy): address code review feedback (attempt 1/5) - Re-read auto-paused.json before write in enforceProviderIsolation to reduce concurrent write race window - Use actual email from registry for display instead of raw accountId - Export maskEmail for testability - Add 27 unit tests covering ban detection, email masking, cross-provider duplicate detection, enforcement lifecycle, crash recovery, and timestamp-guarded restore * fix(cliproxy): address remaining review feedback (attempt 2/5) - Add handleBanDetection test verifying account pause on ban error - Add warnCrossProviderDuplicates tests (true/false/non-Google) - Document PID reuse limitation in isPidAlive JSDoc comment * chore(release): 7.41.0-dev.3 [skip ci] * feat(cliproxy): runtime quota monitoring during active sessions (#529) * feat(cliproxy): add runtime quota monitoring during active sessions Adds adaptive background quota polling to detect and respond to quota exhaustion during active CLIProxy sessions. Prevents rate-limit-driven account bans by auto-cooling exhausted accounts and switching defaults. - Adaptive polling: 300s normal, 60s at 20% threshold, stops at 0% - Stderr warnings at 20%, boxed exhaustion alerts at 0% - Cooldown + default switch on exhaustion (existing patterns) - Configurable via quota_management.runtime_monitor in config.yaml - Timer.unref() prevents blocking process exit - monitorStopped guard for in-flight poll safety Closes #524 * fix: address code review feedback (attempt 1/5) - M1: Round quotaPercent display with Math.round() to avoid ugly floats - M2: Rename exhaust_threshold -> exhaustion_threshold for consistency with existing auto.exhaustion_threshold config field - M3: Replace async not.toThrow() with direct await assertion pattern * fix: address code review feedback (attempt 2/5) - Remove .claude/agent-memory/ from tracking and add to .gitignore - Unify cooldown_minutes default to 5 (was 10 in runtime_monitor, 5 in auto) - Add threshold validation in startQuotaMonitor (warn > exhaustion) - Document intentional post-switch monitoring gap in code comment * chore(release): 7.41.0-dev.4 [skip ci] * fix(cliproxy): mask email in ban detection and fix JSDoc default - Use maskEmail() in handleBanDetection output for consistency - Fix cooldown_minutes JSDoc: default is 5, not 10 * chore(release): 7.41.0-dev.5 [skip ci] * fix(cliproxy): address all review feedback (Low + informational) - Add sync constraint comment on process.exit handler (executor) - Add TOCTOU race acceptability comment (account-safety) - Mask email in handleQuotaExhaustion reason string - Use realistic exhaustion_threshold (5) in test configs * chore(release): 7.41.0-dev.6 [skip ci] --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
622 lines
16 KiB
TypeScript
622 lines
16 KiB
TypeScript
/**
|
|
* Account Safety Guards Unit Tests
|
|
*
|
|
* Tests ban detection, email masking, cross-provider duplicate detection,
|
|
* enforcement lifecycle, and crash recovery.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import {
|
|
isBanResponse,
|
|
maskEmail,
|
|
detectCrossProviderDuplicates,
|
|
enforceProviderIsolation,
|
|
cleanupStaleAutoPauses,
|
|
restoreAutoPausedAccounts,
|
|
checkNewAccountConflict,
|
|
handleBanDetection,
|
|
warnCrossProviderDuplicates,
|
|
} from '../../../src/cliproxy/account-safety';
|
|
|
|
// --- Test isolation: use temp CCS_HOME ---
|
|
|
|
let tmpDir: string;
|
|
let origCcsHome: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-safety-'));
|
|
origCcsHome = process.env.CCS_HOME;
|
|
process.env.CCS_HOME = tmpDir;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (origCcsHome !== undefined) {
|
|
process.env.CCS_HOME = origCcsHome;
|
|
} else {
|
|
delete process.env.CCS_HOME;
|
|
}
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// CCS_HOME appends .ccs — all paths go through getCcsDir() = CCS_HOME/.ccs
|
|
function ccsDir(): string {
|
|
return path.join(tmpDir, '.ccs');
|
|
}
|
|
|
|
// --- Helper: write accounts registry ---
|
|
|
|
function writeRegistry(providers: Record<string, unknown>): void {
|
|
const registryDir = path.join(ccsDir(), 'cliproxy');
|
|
fs.mkdirSync(registryDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(registryDir, 'accounts.json'),
|
|
JSON.stringify({ version: 1, providers }, null, 2)
|
|
);
|
|
}
|
|
|
|
// --- Helper: write auto-paused file ---
|
|
|
|
function writeAutoPaused(sessions: unknown[]): void {
|
|
const dir = path.join(ccsDir(), 'cliproxy');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'auto-paused.json'), JSON.stringify({ sessions }, null, 2));
|
|
}
|
|
|
|
function readAutoPaused(): { sessions: unknown[] } {
|
|
const filePath = path.join(ccsDir(), 'cliproxy', 'auto-paused.json');
|
|
if (!fs.existsSync(filePath)) return { sessions: [] };
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
}
|
|
|
|
// --- Helper: write dummy token files ---
|
|
|
|
function writeTokenFile(filename: string, paused = false): void {
|
|
const dir = paused
|
|
? path.join(ccsDir(), 'cliproxy', 'auth-paused')
|
|
: path.join(ccsDir(), 'cliproxy', 'auth');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, filename), JSON.stringify({ type: 'test' }));
|
|
}
|
|
|
|
// ========================================
|
|
// isBanResponse
|
|
// ========================================
|
|
|
|
describe('isBanResponse', () => {
|
|
it('should detect "disabled in this account"', () => {
|
|
expect(isBanResponse('API access disabled in this account')).toBe(true);
|
|
});
|
|
|
|
it('should detect "violation of terms of service"', () => {
|
|
expect(isBanResponse('Your account was flagged for violation of terms of service')).toBe(true);
|
|
});
|
|
|
|
it('should detect "account has been suspended"', () => {
|
|
expect(isBanResponse('This account has been suspended by Google')).toBe(true);
|
|
});
|
|
|
|
it('should be case-insensitive', () => {
|
|
expect(isBanResponse('ACCOUNT HAS BEEN DISABLED')).toBe(true);
|
|
});
|
|
|
|
it('should return false for normal errors', () => {
|
|
expect(isBanResponse('Rate limit exceeded')).toBe(false);
|
|
expect(isBanResponse('Internal server error')).toBe(false);
|
|
expect(isBanResponse('Network timeout')).toBe(false);
|
|
});
|
|
|
|
it('should return false for empty string', () => {
|
|
expect(isBanResponse('')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// maskEmail
|
|
// ========================================
|
|
|
|
describe('maskEmail', () => {
|
|
it('should mask standard email', () => {
|
|
expect(maskEmail('user@example.com')).toBe('use***@example.com');
|
|
});
|
|
|
|
it('should handle short local part', () => {
|
|
expect(maskEmail('ab@example.com')).toBe('ab***@example.com');
|
|
});
|
|
|
|
it('should handle single char local part', () => {
|
|
expect(maskEmail('a@example.com')).toBe('a***@example.com');
|
|
});
|
|
|
|
it('should return input if no @ sign', () => {
|
|
expect(maskEmail('not-an-email')).toBe('not-an-email');
|
|
});
|
|
|
|
it('should return input if empty string', () => {
|
|
expect(maskEmail('')).toBe('');
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// detectCrossProviderDuplicates
|
|
// ========================================
|
|
|
|
describe('detectCrossProviderDuplicates', () => {
|
|
it('should return empty map when no duplicates', () => {
|
|
writeRegistry({
|
|
gemini: {
|
|
default: 'user1@gmail.com',
|
|
accounts: {
|
|
'user1@gmail.com': {
|
|
email: 'user1@gmail.com',
|
|
tokenFile: 'gemini-user1.json',
|
|
},
|
|
},
|
|
},
|
|
agy: {
|
|
default: 'user2@gmail.com',
|
|
accounts: {
|
|
'user2@gmail.com': {
|
|
email: 'user2@gmail.com',
|
|
tokenFile: 'agy-user2.json',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const dupes = detectCrossProviderDuplicates();
|
|
expect(dupes.size).toBe(0);
|
|
});
|
|
|
|
it('should detect same email across providers', () => {
|
|
writeRegistry({
|
|
gemini: {
|
|
default: 'shared@gmail.com',
|
|
accounts: {
|
|
'shared@gmail.com': {
|
|
email: 'shared@gmail.com',
|
|
tokenFile: 'gemini-shared.json',
|
|
},
|
|
},
|
|
},
|
|
agy: {
|
|
default: 'shared@gmail.com',
|
|
accounts: {
|
|
'shared@gmail.com': {
|
|
email: 'shared@gmail.com',
|
|
tokenFile: 'agy-shared.json',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const dupes = detectCrossProviderDuplicates();
|
|
expect(dupes.size).toBe(1);
|
|
expect(dupes.get('shared@gmail.com')).toEqual(['gemini', 'agy']);
|
|
});
|
|
|
|
it('should skip paused accounts', () => {
|
|
writeRegistry({
|
|
gemini: {
|
|
default: 'shared@gmail.com',
|
|
accounts: {
|
|
'shared@gmail.com': {
|
|
email: 'shared@gmail.com',
|
|
tokenFile: 'gemini-shared.json',
|
|
paused: true,
|
|
},
|
|
},
|
|
},
|
|
agy: {
|
|
default: 'shared@gmail.com',
|
|
accounts: {
|
|
'shared@gmail.com': {
|
|
email: 'shared@gmail.com',
|
|
tokenFile: 'agy-shared.json',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const dupes = detectCrossProviderDuplicates();
|
|
expect(dupes.size).toBe(0);
|
|
});
|
|
|
|
it('should be case-insensitive on email', () => {
|
|
writeRegistry({
|
|
gemini: {
|
|
default: 'User@Gmail.com',
|
|
accounts: {
|
|
'User@Gmail.com': {
|
|
email: 'User@Gmail.com',
|
|
tokenFile: 'gemini-user.json',
|
|
},
|
|
},
|
|
},
|
|
agy: {
|
|
default: 'user@gmail.com',
|
|
accounts: {
|
|
'user@gmail.com': {
|
|
email: 'user@gmail.com',
|
|
tokenFile: 'agy-user.json',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const dupes = detectCrossProviderDuplicates();
|
|
expect(dupes.size).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// checkNewAccountConflict
|
|
// ========================================
|
|
|
|
describe('checkNewAccountConflict', () => {
|
|
it('should return null for non-Google provider', () => {
|
|
const result = checkNewAccountConflict('kiro' as never, 'user@gmail.com');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return null when no conflict', () => {
|
|
writeRegistry({
|
|
gemini: {
|
|
default: 'other@gmail.com',
|
|
accounts: {
|
|
'other@gmail.com': {
|
|
email: 'other@gmail.com',
|
|
tokenFile: 'gemini-other.json',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = checkNewAccountConflict('agy', 'new@gmail.com');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return conflicting providers', () => {
|
|
writeRegistry({
|
|
gemini: {
|
|
default: 'shared@gmail.com',
|
|
accounts: {
|
|
'shared@gmail.com': {
|
|
email: 'shared@gmail.com',
|
|
tokenFile: 'gemini-shared.json',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = checkNewAccountConflict('agy', 'shared@gmail.com');
|
|
expect(result).toEqual(['gemini']);
|
|
});
|
|
|
|
it('should return null when email is undefined', () => {
|
|
const result = checkNewAccountConflict('agy', undefined);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// cleanupStaleAutoPauses
|
|
// ========================================
|
|
|
|
describe('cleanupStaleAutoPauses', () => {
|
|
it('should do nothing when no sessions', () => {
|
|
// No auto-paused.json exists
|
|
cleanupStaleAutoPauses();
|
|
// Should not throw
|
|
});
|
|
|
|
it('should remove sessions with dead PIDs', () => {
|
|
// Use PID 999999999 which is almost certainly dead
|
|
writeAutoPaused([
|
|
{
|
|
initiator: 'gemini',
|
|
pid: 999999999,
|
|
pausedAt: new Date().toISOString(),
|
|
accounts: [{ provider: 'agy', accountId: 'test@gmail.com' }],
|
|
},
|
|
]);
|
|
|
|
// Write registry with the paused account so resumeAccount can find it
|
|
writeRegistry({
|
|
agy: {
|
|
default: 'test@gmail.com',
|
|
accounts: {
|
|
'test@gmail.com': {
|
|
email: 'test@gmail.com',
|
|
tokenFile: 'agy-test.json',
|
|
paused: true,
|
|
pausedAt: new Date().toISOString(),
|
|
},
|
|
},
|
|
},
|
|
});
|
|
writeTokenFile('agy-test.json', true);
|
|
|
|
cleanupStaleAutoPauses();
|
|
|
|
const data = readAutoPaused();
|
|
expect(data.sessions.length).toBe(0);
|
|
});
|
|
|
|
it('should keep sessions with alive PIDs', () => {
|
|
const alivePid = process.pid; // Current process is alive
|
|
|
|
writeAutoPaused([
|
|
{
|
|
initiator: 'gemini',
|
|
pid: alivePid,
|
|
pausedAt: new Date().toISOString(),
|
|
accounts: [{ provider: 'agy', accountId: 'test@gmail.com' }],
|
|
},
|
|
]);
|
|
|
|
cleanupStaleAutoPauses();
|
|
|
|
const data = readAutoPaused();
|
|
expect(data.sessions.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// enforceProviderIsolation
|
|
// ========================================
|
|
|
|
describe('enforceProviderIsolation', () => {
|
|
it('should return 0 for non-Google provider', () => {
|
|
const result = enforceProviderIsolation('kiro' as never);
|
|
expect(result).toBe(0);
|
|
});
|
|
|
|
it('should return 0 when no conflicting accounts', () => {
|
|
writeRegistry({
|
|
gemini: {
|
|
default: 'user1@gmail.com',
|
|
accounts: {
|
|
'user1@gmail.com': {
|
|
email: 'user1@gmail.com',
|
|
tokenFile: 'gemini-user1.json',
|
|
},
|
|
},
|
|
},
|
|
agy: {
|
|
default: 'user2@gmail.com',
|
|
accounts: {
|
|
'user2@gmail.com': {
|
|
email: 'user2@gmail.com',
|
|
tokenFile: 'agy-user2.json',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
writeTokenFile('gemini-user1.json');
|
|
writeTokenFile('agy-user2.json');
|
|
|
|
const result = enforceProviderIsolation('gemini');
|
|
expect(result).toBe(0);
|
|
});
|
|
|
|
it('should pause conflicting accounts and record session', () => {
|
|
writeRegistry({
|
|
gemini: {
|
|
default: 'shared@gmail.com',
|
|
accounts: {
|
|
'shared@gmail.com': {
|
|
email: 'shared@gmail.com',
|
|
tokenFile: 'gemini-shared.json',
|
|
},
|
|
},
|
|
},
|
|
agy: {
|
|
default: 'shared@gmail.com',
|
|
accounts: {
|
|
'shared@gmail.com': {
|
|
email: 'shared@gmail.com',
|
|
tokenFile: 'agy-shared.json',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
writeTokenFile('gemini-shared.json');
|
|
writeTokenFile('agy-shared.json');
|
|
|
|
const result = enforceProviderIsolation('gemini');
|
|
expect(result).toBe(1);
|
|
|
|
// Verify auto-paused.json was written
|
|
const data = readAutoPaused();
|
|
expect(data.sessions.length).toBe(1);
|
|
expect(data.sessions[0].initiator).toBe('gemini');
|
|
expect(data.sessions[0].pid).toBe(process.pid);
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// restoreAutoPausedAccounts
|
|
// ========================================
|
|
|
|
describe('restoreAutoPausedAccounts', () => {
|
|
it('should do nothing when no session exists', () => {
|
|
restoreAutoPausedAccounts('gemini');
|
|
// Should not throw
|
|
});
|
|
|
|
it('should skip accounts re-paused after enforcement', () => {
|
|
const enforcementTime = '2024-01-01T00:00:00.000Z';
|
|
const laterTime = '2024-01-01T01:00:00.000Z';
|
|
|
|
writeAutoPaused([
|
|
{
|
|
initiator: 'gemini',
|
|
pid: process.pid,
|
|
pausedAt: enforcementTime,
|
|
accounts: [{ provider: 'agy', accountId: 'banned@gmail.com' }],
|
|
},
|
|
]);
|
|
|
|
writeRegistry({
|
|
agy: {
|
|
default: 'banned@gmail.com',
|
|
accounts: {
|
|
'banned@gmail.com': {
|
|
email: 'banned@gmail.com',
|
|
tokenFile: 'agy-banned.json',
|
|
paused: true,
|
|
pausedAt: laterTime, // Re-paused AFTER enforcement (e.g., ban)
|
|
},
|
|
},
|
|
},
|
|
});
|
|
writeTokenFile('agy-banned.json', true);
|
|
|
|
restoreAutoPausedAccounts('gemini');
|
|
|
|
// Account should NOT be restored because it was re-paused later
|
|
const registry = JSON.parse(
|
|
fs.readFileSync(path.join(ccsDir(), 'cliproxy', 'accounts.json'), 'utf-8')
|
|
);
|
|
expect(registry.providers.agy.accounts['banned@gmail.com'].paused).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// handleBanDetection
|
|
// ========================================
|
|
|
|
describe('handleBanDetection', () => {
|
|
it('should pause account when ban error detected', () => {
|
|
writeRegistry({
|
|
gemini: {
|
|
default: 'user@gmail.com',
|
|
accounts: {
|
|
'user@gmail.com': {
|
|
email: 'user@gmail.com',
|
|
tokenFile: 'gemini-user.json',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
writeTokenFile('gemini-user.json');
|
|
|
|
const result = handleBanDetection(
|
|
'gemini',
|
|
'user@gmail.com',
|
|
'API access disabled in this account'
|
|
);
|
|
|
|
expect(result).toBe(true);
|
|
|
|
// Verify account was paused in registry
|
|
const registry = JSON.parse(
|
|
fs.readFileSync(path.join(ccsDir(), 'cliproxy', 'accounts.json'), 'utf-8')
|
|
);
|
|
expect(registry.providers.gemini.accounts['user@gmail.com'].paused).toBe(true);
|
|
});
|
|
|
|
it('should return false for non-ban errors', () => {
|
|
writeRegistry({
|
|
gemini: {
|
|
default: 'user@gmail.com',
|
|
accounts: {
|
|
'user@gmail.com': {
|
|
email: 'user@gmail.com',
|
|
tokenFile: 'gemini-user.json',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
writeTokenFile('gemini-user.json');
|
|
|
|
const result = handleBanDetection('gemini', 'user@gmail.com', 'Rate limit exceeded');
|
|
|
|
expect(result).toBe(false);
|
|
|
|
// Verify account was NOT paused
|
|
const registry = JSON.parse(
|
|
fs.readFileSync(path.join(ccsDir(), 'cliproxy', 'accounts.json'), 'utf-8')
|
|
);
|
|
expect(registry.providers.gemini.accounts['user@gmail.com'].paused).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// warnCrossProviderDuplicates
|
|
// ========================================
|
|
|
|
describe('warnCrossProviderDuplicates', () => {
|
|
it('should return true when duplicates exist', () => {
|
|
writeRegistry({
|
|
gemini: {
|
|
default: 'shared@gmail.com',
|
|
accounts: {
|
|
'shared@gmail.com': {
|
|
email: 'shared@gmail.com',
|
|
tokenFile: 'gemini-shared.json',
|
|
},
|
|
},
|
|
},
|
|
agy: {
|
|
default: 'shared@gmail.com',
|
|
accounts: {
|
|
'shared@gmail.com': {
|
|
email: 'shared@gmail.com',
|
|
tokenFile: 'agy-shared.json',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = warnCrossProviderDuplicates('gemini');
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should return false when no duplicates', () => {
|
|
writeRegistry({
|
|
gemini: {
|
|
default: 'user1@gmail.com',
|
|
accounts: {
|
|
'user1@gmail.com': {
|
|
email: 'user1@gmail.com',
|
|
tokenFile: 'gemini-user1.json',
|
|
},
|
|
},
|
|
},
|
|
agy: {
|
|
default: 'user2@gmail.com',
|
|
accounts: {
|
|
'user2@gmail.com': {
|
|
email: 'user2@gmail.com',
|
|
tokenFile: 'agy-user2.json',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = warnCrossProviderDuplicates('gemini');
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should return false for non-Google providers', () => {
|
|
writeRegistry({
|
|
kiro: {
|
|
default: 'user@example.com',
|
|
accounts: {
|
|
'user@example.com': {
|
|
email: 'user@example.com',
|
|
tokenFile: 'kiro-user.json',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = warnCrossProviderDuplicates('kiro' as never);
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|