Spaces:
Running
Running
| /** | |
| * Integration Test: 403 Account Rotation Handler Flow | |
| * | |
| * Tests the ACTUAL handler control flow for 403 VALIDATION_REQUIRED errors, | |
| * verifying that: | |
| * 1. Account is marked invalid with verifyUrl when 403 + VALIDATION_REQUIRED | |
| * 2. Handler rotates to next account (doesn't retry same account) | |
| * 3. Generic 403s fall through to endpoint rotation (not account rotation) | |
| * 4. clearInvalid() properly re-enables accounts after verification | |
| * 5. WebUI refresh endpoint uses AccountManager.clearInvalid() (encapsulated) | |
| */ | |
| const { strict: assert } = require('assert'); | |
| // ============================================================================ | |
| // Mock infrastructure | |
| // ============================================================================ | |
| /** Minimal AccountManager mock that tracks all mutations */ | |
| class MockAccountManager { | |
| constructor(accounts) { | |
| this._accounts = accounts.map(a => ({ | |
| ...a, | |
| isInvalid: false, | |
| invalidReason: null, | |
| verifyUrl: null, | |
| modelRateLimits: {}, | |
| consecutiveFailures: 0, | |
| enabled: true, | |
| })); | |
| this._log = []; // Track all method calls for assertions | |
| this._savedToDisk = 0; | |
| } | |
| getAvailableAccounts(model) { | |
| return this._accounts.filter(a => !a.isInvalid && a.enabled !== false); | |
| } | |
| getAllAccounts() { | |
| return this._accounts; | |
| } | |
| isAllAccountsInvalid() { | |
| const enabled = this._accounts.filter(a => a.enabled !== false); | |
| return enabled.length > 0 && enabled.every(a => a.isInvalid); | |
| } | |
| markInvalid(email, reason, verifyUrl = null) { | |
| const acc = this._accounts.find(a => a.email === email); | |
| if (!acc) return false; | |
| acc.isInvalid = true; | |
| acc.invalidReason = reason; | |
| acc.verifyUrl = verifyUrl || null; | |
| acc.invalidAt = Date.now(); | |
| this._log.push({ action: 'markInvalid', email, reason, verifyUrl }); | |
| return true; | |
| } | |
| clearInvalid(email) { | |
| const acc = this._accounts.find(a => a.email === email); | |
| if (!acc) return false; | |
| acc.isInvalid = false; | |
| acc.invalidReason = null; | |
| acc.verifyUrl = null; | |
| this._log.push({ action: 'clearInvalid', email }); | |
| this._savedToDisk++; | |
| return true; | |
| } | |
| markRateLimited(email, resetMs, model) { | |
| this._log.push({ action: 'markRateLimited', email, resetMs, model }); | |
| } | |
| notifySuccess(account, model) { | |
| this._log.push({ action: 'notifySuccess', email: account.email, model }); | |
| } | |
| notifyFailure(account, model) { | |
| this._log.push({ action: 'notifyFailure', email: account.email, model }); | |
| } | |
| notifyRateLimit(account, model) { | |
| this._log.push({ action: 'notifyRateLimit', email: account.email, model }); | |
| } | |
| getConsecutiveFailures(email) { return 0; } | |
| incrementConsecutiveFailures(email) { return 1; } | |
| saveToDisk() { this._savedToDisk++; } | |
| clearTokenCache() {} | |
| clearProjectCache() {} | |
| } | |
| // Import the actual detection/classification functions | |
| const { | |
| isValidationRequired, | |
| extractVerificationUrl, | |
| isPermanentAuthFailure, | |
| } = require('../src/cloudcode/rate-limit-state.js'); | |
| const { | |
| AccountForbiddenError, | |
| isAccountForbiddenError, | |
| isRateLimitError, | |
| isAuthError, | |
| } = require('../src/errors.js'); | |
| // ============================================================================ | |
| // Test runner | |
| // ============================================================================ | |
| let passed = 0; | |
| let failed = 0; | |
| const failures = []; | |
| function test(name, fn) { | |
| try { | |
| fn(); | |
| passed++; | |
| console.log(` β ${name}`); | |
| } catch (e) { | |
| failed++; | |
| failures.push({ name, error: e.message }); | |
| console.log(` β ${name}`); | |
| console.log(` ${e.message}`); | |
| } | |
| } | |
| // ============================================================================ | |
| // Integration Tests | |
| // ============================================================================ | |
| console.log('ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ'); | |
| console.log('β INTEGRATION TEST: 403 Account Rotation Handler Flow β'); | |
| console.log('ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ\n'); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Test Group 1: Handler-level account rotation on 403 VALIDATION_REQUIRED | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| console.log('ββ Handler: 403 VALIDATION_REQUIRED β account rotation ββββββββ'); | |
| test('403 + VALIDATION_REQUIRED marks account invalid and rotates', () => { | |
| const mgr = new MockAccountManager([ | |
| { email: 'a@test.com' }, | |
| { email: 'b@test.com' }, | |
| ]); | |
| // Simulate what the handler does on 403 + VALIDATION_REQUIRED | |
| const errorText = '{"error":{"code":403,"message":"VALIDATION_REQUIRED: Please verify","status":"PERMISSION_DENIED"}}'; | |
| const account = mgr.getAvailableAccounts()[0]; | |
| assert.ok(isValidationRequired(errorText), 'should detect VALIDATION_REQUIRED'); | |
| const verifyUrl = extractVerificationUrl(errorText); | |
| mgr.markInvalid(account.email, 'Account requires verification', verifyUrl); | |
| const err = new AccountForbiddenError(errorText, account.email); | |
| assert.ok(isAccountForbiddenError(err), 'should classify as account forbidden'); | |
| // Verify account was marked invalid | |
| assert.strictEqual(account.isInvalid, true, 'account should be invalid'); | |
| assert.strictEqual(account.invalidReason, 'Account requires verification'); | |
| // Verify second account is still available for rotation | |
| const available = mgr.getAvailableAccounts(); | |
| assert.strictEqual(available.length, 1, 'should have 1 available account'); | |
| assert.strictEqual(available[0].email, 'b@test.com', 'should rotate to b@test.com'); | |
| // Verify mutation was logged | |
| const markCall = mgr._log.find(l => l.action === 'markInvalid'); | |
| assert.ok(markCall, 'markInvalid should have been called'); | |
| assert.strictEqual(markCall.email, 'a@test.com'); | |
| }); | |
| test('403 + VALIDATION_REQUIRED with verification URL stores it', () => { | |
| const mgr = new MockAccountManager([{ email: 'a@test.com' }]); | |
| const errorText = '403 VALIDATION_REQUIRED: Visit https://accounts.google.com/signin/continue?sarp=1&scc=1&otp=abc123 to verify'; | |
| const verifyUrl = extractVerificationUrl(errorText); | |
| assert.ok(verifyUrl, 'should extract verification URL'); | |
| assert.ok(verifyUrl.startsWith('https://accounts.google.com/signin/continue'), 'URL should be Google verification'); | |
| assert.ok(!verifyUrl.endsWith('.'), 'URL should not end with punctuation'); | |
| mgr.markInvalid('a@test.com', 'Account requires verification', verifyUrl); | |
| const acc = mgr.getAllAccounts()[0]; | |
| assert.strictEqual(acc.verifyUrl, verifyUrl, 'verifyUrl should be stored'); | |
| }); | |
| test('generic 403 (no VALIDATION_REQUIRED) does NOT trigger account rotation', () => { | |
| const errorText = '{"error":{"code":403,"message":"Forbidden","status":"FORBIDDEN"}}'; | |
| assert.strictEqual(isValidationRequired(errorText), false, 'generic 403 should not be validation required'); | |
| // Handler would NOT create AccountForbiddenError, just fall through to endpoint rotation | |
| const genericErr = new Error(`API error 403: ${errorText}`); | |
| assert.strictEqual(isAccountForbiddenError(genericErr), false, 'generic 403 should not be account forbidden'); | |
| }); | |
| test('403 PERMISSION_DENIED without VALIDATION_REQUIRED does NOT trigger account rotation', () => { | |
| const errorText = '{"error":{"code":403,"message":"The caller does not have permission","status":"PERMISSION_DENIED"}}'; | |
| assert.strictEqual(isValidationRequired(errorText), false, 'generic PERMISSION_DENIED should not trigger'); | |
| const err = new Error(`API error 403: ${errorText}`); | |
| assert.strictEqual(isAccountForbiddenError(err), false, 'should not be classified as account forbidden'); | |
| }); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Test Group 2: Multi-account rotation chain | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| console.log('\nββ Handler: Multi-account rotation chain ββββββββββββββββββββββ'); | |
| test('all accounts marked invalid β no accounts available', () => { | |
| const mgr = new MockAccountManager([ | |
| { email: 'a@test.com' }, | |
| { email: 'b@test.com' }, | |
| { email: 'c@test.com' }, | |
| ]); | |
| // Simulate all accounts hitting VALIDATION_REQUIRED | |
| for (const acc of mgr.getAllAccounts()) { | |
| mgr.markInvalid(acc.email, 'Account requires verification'); | |
| } | |
| const available = mgr.getAvailableAccounts(); | |
| assert.strictEqual(available.length, 0, 'no accounts should be available'); | |
| }); | |
| test('one account invalid, others still available for rotation', () => { | |
| const mgr = new MockAccountManager([ | |
| { email: 'a@test.com' }, | |
| { email: 'b@test.com' }, | |
| { email: 'c@test.com' }, | |
| ]); | |
| mgr.markInvalid('b@test.com', 'Account requires verification'); | |
| const available = mgr.getAvailableAccounts(); | |
| assert.strictEqual(available.length, 2, 'should have 2 available accounts'); | |
| assert.ok(available.every(a => a.email !== 'b@test.com'), 'b@test.com should be excluded'); | |
| }); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Test Group 3: clearInvalid() re-enables accounts (WebUI refresh flow) | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| console.log('\nββ Handler: clearInvalid() re-enables accounts (WebUI flow) βββ'); | |
| test('clearInvalid() re-enables account after verification', () => { | |
| const mgr = new MockAccountManager([{ email: 'a@test.com' }]); | |
| // Mark invalid | |
| mgr.markInvalid('a@test.com', 'Account requires verification', 'https://accounts.google.com/signin/continue?sarp=1'); | |
| assert.strictEqual(mgr.getAvailableAccounts().length, 0, 'should have 0 available'); | |
| // Simulate WebUI refresh endpoint (user completed verification) | |
| mgr.clearInvalid('a@test.com'); | |
| assert.strictEqual(mgr.getAvailableAccounts().length, 1, 'should have 1 available after clear'); | |
| const acc = mgr.getAllAccounts()[0]; | |
| assert.strictEqual(acc.isInvalid, false, 'isInvalid should be false'); | |
| assert.strictEqual(acc.invalidReason, null, 'invalidReason should be null'); | |
| assert.strictEqual(acc.verifyUrl, null, 'verifyUrl should be null'); | |
| }); | |
| test('clearInvalid() persists to disk via saveToDisk()', () => { | |
| const mgr = new MockAccountManager([{ email: 'a@test.com' }]); | |
| mgr.markInvalid('a@test.com', 'test'); | |
| const beforeSaves = mgr._savedToDisk; | |
| mgr.clearInvalid('a@test.com'); | |
| assert.ok(mgr._savedToDisk > beforeSaves, 'should save to disk after clearInvalid'); | |
| }); | |
| test('WebUI refresh does NOT clear auth errors (only verification errors)', () => { | |
| const mgr = new MockAccountManager([{ email: 'a@test.com' }]); | |
| // Auth error (no verifyUrl) β requires OAuth re-auth, NOT just refresh | |
| mgr.markInvalid('a@test.com', 'Token revoked', null); | |
| const acc = mgr.getAllAccounts()[0]; | |
| // WebUI refresh logic: only clear if account has verifyUrl | |
| if (acc.isInvalid && acc.verifyUrl) { | |
| mgr.clearInvalid(acc.email); | |
| } | |
| // Should NOT have been cleared β no verifyUrl | |
| assert.strictEqual(acc.isInvalid, true, 'auth error should NOT be cleared by refresh'); | |
| }); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Test Group 4: Error classification doesn't interfere with other handlers | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| console.log('\nββ Handler: Error classification isolation βββββββββββββββββββββ'); | |
| test('AccountForbiddenError does not trigger rate limit handler', () => { | |
| const err = new AccountForbiddenError('403 VALIDATION_REQUIRED', 'a@test.com'); | |
| assert.strictEqual(isRateLimitError(err), false, 'should not be rate limit'); | |
| assert.strictEqual(isAuthError(err), false, 'should not be auth error'); | |
| assert.strictEqual(isAccountForbiddenError(err), true, 'should be account forbidden'); | |
| }); | |
| test('rate limit errors do not trigger account forbidden handler', () => { | |
| const err = new Error('429 RESOURCE_EXHAUSTED: quota exceeded'); | |
| assert.strictEqual(isAccountForbiddenError(err), false); | |
| assert.strictEqual(isRateLimitError(err), true); | |
| }); | |
| test('permanent auth failure is distinct from validation required', () => { | |
| const authText = 'invalid_grant: Token has been expired or revoked'; | |
| const validText = '403 VALIDATION_REQUIRED: Please verify'; | |
| assert.strictEqual(isPermanentAuthFailure(authText), true); | |
| assert.strictEqual(isValidationRequired(authText), false); | |
| assert.strictEqual(isPermanentAuthFailure(validText), false); | |
| assert.strictEqual(isValidationRequired(validText), true); | |
| }); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Test Group 5: Edge cases in handler flow | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| console.log('\nββ Handler: Edge cases ββββββββββββββββββββββββββββββββββββββββ'); | |
| test('ACCOUNT_DISABLED triggers account rotation (same as VALIDATION_REQUIRED)', () => { | |
| const errorText = '{"error":{"code":403,"message":"ACCOUNT_DISABLED","status":"PERMISSION_DENIED"}}'; | |
| assert.strictEqual(isValidationRequired(errorText), true); | |
| const mgr = new MockAccountManager([{ email: 'a@test.com' }, { email: 'b@test.com' }]); | |
| mgr.markInvalid('a@test.com', 'Account disabled'); | |
| assert.strictEqual(mgr.getAvailableAccounts().length, 1); | |
| }); | |
| test('USER_DISABLED triggers account rotation', () => { | |
| const errorText = 'user_disabled: This account has been suspended'; | |
| assert.strictEqual(isValidationRequired(errorText), true); | |
| }); | |
| test('mixed case detection works', () => { | |
| assert.strictEqual(isValidationRequired('Validation_Required'), true); | |
| assert.strictEqual(isValidationRequired('ACCOUNT_DISABLED'), true); | |
| assert.strictEqual(isValidationRequired('User_Disabled'), true); | |
| }); | |
| test('extractVerificationUrl handles URL with trailing bracket', () => { | |
| const text = 'Visit [https://accounts.google.com/signin/continue?sarp=1&scc=1] to verify'; | |
| const url = extractVerificationUrl(text); | |
| assert.ok(url, 'should extract URL'); | |
| assert.ok(!url.includes(']'), 'URL should not include trailing bracket'); | |
| }); | |
| test('extractVerificationUrl preserves dots inside URL (subdomain in query param)', () => { | |
| const text = 'Visit https://accounts.google.com/signin/continue?sarp=1&scc=1&continue=https://aistudio.google.com to verify'; | |
| const url = extractVerificationUrl(text); | |
| assert.ok(url, 'should extract URL'); | |
| assert.ok(url.includes('aistudio.google.com'), 'URL should preserve subdomain dots'); | |
| }); | |
| test('extractVerificationUrl handles URL with trailing angle bracket', () => { | |
| const text = 'Visit <https://accounts.google.com/signin/continue?sarp=1&scc=1> to verify'; | |
| const url = extractVerificationUrl(text); | |
| assert.ok(url, 'should extract URL'); | |
| assert.ok(!url.includes('>'), 'URL should not include trailing angle bracket'); | |
| }); | |
| test('extractVerificationUrl handles URL with trailing brace', () => { | |
| const text = '{"url":"https://accounts.google.com/signin/continue?sarp=1&scc=1"}'; | |
| const url = extractVerificationUrl(text); | |
| assert.ok(url, 'should extract URL'); | |
| assert.ok(!url.endsWith('"'), 'URL should not include trailing quote'); | |
| assert.ok(!url.endsWith('}'), 'URL should not include trailing brace'); | |
| }); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Test Group 6: isAllAccountsInvalid() β prevents infinite wait loop | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| console.log('\nββ Handler: isAllAccountsInvalid() βββββββββββββββββββββββββββββ'); | |
| test('all accounts invalid β isAllAccountsInvalid returns true', () => { | |
| const mgr = new MockAccountManager([ | |
| { email: 'a@test.com' }, | |
| { email: 'b@test.com' }, | |
| ]); | |
| mgr.markInvalid('a@test.com', 'Account requires verification'); | |
| mgr.markInvalid('b@test.com', 'Account requires verification'); | |
| assert.strictEqual(mgr.isAllAccountsInvalid(), true); | |
| assert.strictEqual(mgr.getAvailableAccounts().length, 0); | |
| }); | |
| test('one invalid, one healthy β isAllAccountsInvalid returns false', () => { | |
| const mgr = new MockAccountManager([ | |
| { email: 'a@test.com' }, | |
| { email: 'b@test.com' }, | |
| ]); | |
| mgr.markInvalid('a@test.com', 'Account requires verification'); | |
| assert.strictEqual(mgr.isAllAccountsInvalid(), false); | |
| }); | |
| test('one invalid, one disabled β isAllAccountsInvalid returns true', () => { | |
| const mgr = new MockAccountManager([ | |
| { email: 'a@test.com' }, | |
| { email: 'b@test.com' }, | |
| ]); | |
| mgr._accounts[1].enabled = false; | |
| mgr.markInvalid('a@test.com', 'Account requires verification'); | |
| assert.strictEqual(mgr.isAllAccountsInvalid(), true, 'disabled accounts dont count as healthy'); | |
| }); | |
| test('clearInvalid makes isAllAccountsInvalid return false', () => { | |
| const mgr = new MockAccountManager([{ email: 'a@test.com' }]); | |
| mgr.markInvalid('a@test.com', 'Account requires verification'); | |
| assert.strictEqual(mgr.isAllAccountsInvalid(), true); | |
| mgr.clearInvalid('a@test.com'); | |
| assert.strictEqual(mgr.isAllAccountsInvalid(), false); | |
| }); | |
| // ============================================================================ | |
| // Results | |
| // ============================================================================ | |
| console.log('\nββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ'); | |
| console.log(`Results: ${passed} passed, ${failed} failed, ${passed + failed} total`); | |
| console.log('ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ\n'); | |
| if (failures.length > 0) { | |
| console.log('Failures:'); | |
| failures.forEach(f => console.log(` β ${f.name}: ${f.error}`)); | |
| process.exit(1); | |
| } else { | |
| console.log('β All integration tests passed!'); | |
| process.exit(0); | |
| } | |