import Foundation import Testing @testable import CodexBarCore #if os(macOS) @Suite(.serialized) struct ClaudeOAuthRefreshFailureGateTests { private let legacyBlockedUntilKey = "claudeOAuthRefreshBackoffBlockedUntilV1" private let legacyFailureCountKey = "claudeOAuthRefreshBackoffFailureCountV1" private let legacyFingerprintKey = "claudeOAuthRefreshBackoffFingerprintV2" private let terminalBlockedKey = "claudeOAuthRefreshTerminalBlockedV1" private let transientBlockedUntilKey = "claudeOAuthRefreshTransientBlockedUntilV1" private let transientFailureCountKey = "claudeOAuthRefreshTransientFailureCountV1" @Test func `blocks indefinitely when fingerprint unchanged`() { ClaudeOAuthRefreshFailureGate.resetForTesting() defer { ClaudeOAuthRefreshFailureGate.resetForTesting() } var fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( modifiedAt: 1, createdAt: 1, persistentRefHash: "ref1"), credentialsFile: "file1") ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint } defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) } let start = Date(timeIntervalSince1970: 1000) ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start) #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60)) == false) // Ensure we do not get unblocked unless fingerprint changes. fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( modifiedAt: 1, createdAt: 1, persistentRefHash: "ref1"), credentialsFile: "file1") #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 4)) == false) #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 60 * 24)) == false) } @Test func `migrates legacy blocked until in past does not block and clears key`() { ClaudeOAuthRefreshFailureGate.resetForTesting() defer { ClaudeOAuthRefreshFailureGate.resetForTesting() } let now = Date(timeIntervalSince1970: 10000) UserDefaults.standard.set(now.addingTimeInterval(-60).timeIntervalSince1970, forKey: self.legacyBlockedUntilKey) UserDefaults.standard.set(0, forKey: self.legacyFailureCountKey) UserDefaults.standard.removeObject(forKey: self.terminalBlockedKey) #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: now) == true) #expect(UserDefaults.standard.object(forKey: self.legacyBlockedUntilKey) == nil) } @Test func `migrates legacy backoff to transient backoff does not set terminal block`() throws { ClaudeOAuthRefreshFailureGate.resetForTesting() defer { ClaudeOAuthRefreshFailureGate.resetForTesting() } let now = Date(timeIntervalSince1970: 20000) let fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( modifiedAt: 1, createdAt: 1, persistentRefHash: "ref1"), credentialsFile: "file1") ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint } defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) } let legacyBlockedUntil = now.addingTimeInterval(60 * 10) UserDefaults.standard.set(2, forKey: self.legacyFailureCountKey) UserDefaults.standard.removeObject(forKey: self.terminalBlockedKey) UserDefaults.standard.set(legacyBlockedUntil.timeIntervalSince1970, forKey: self.legacyBlockedUntilKey) let data = try JSONEncoder().encode(fingerprint) UserDefaults.standard.set(data, forKey: self.legacyFingerprintKey) #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: now) == false) #expect(UserDefaults.standard.bool(forKey: self.terminalBlockedKey) == false) #expect(UserDefaults.standard.object(forKey: self.legacyBlockedUntilKey) == nil) #expect(UserDefaults.standard.object(forKey: self.transientBlockedUntilKey) != nil) #expect(UserDefaults.standard.integer(forKey: self.transientFailureCountKey) == 2) } @Test func `unblocks when fingerprint becomes available after being unknown at failure`() { ClaudeOAuthRefreshFailureGate.resetForTesting() defer { ClaudeOAuthRefreshFailureGate.resetForTesting() } var fingerprint: ClaudeOAuthRefreshFailureGate.AuthFingerprint? ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint } defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) } let start = Date(timeIntervalSince1970: 25000) ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start) // Still blocked while fingerprint is unavailable. #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(20)) == false) // Once fingerprint becomes available, the sentinel differs and we unblock. fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( modifiedAt: 1, createdAt: 1, persistentRefHash: "ref1"), credentialsFile: "file1") #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(40)) == true) } @Test func `unblocks immediately when fingerprint changes`() { ClaudeOAuthRefreshFailureGate.resetForTesting() defer { ClaudeOAuthRefreshFailureGate.resetForTesting() } var fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( modifiedAt: 1, createdAt: 1, persistentRefHash: "ref1"), credentialsFile: "file1") ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint } defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) } let start = Date(timeIntervalSince1970: 2000) ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start) #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60)) == false) fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( modifiedAt: 2, createdAt: 2, persistentRefHash: "ref2"), credentialsFile: "file2") #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 2)) == true) } @Test func `throttles fingerprint recheck while terminal blocked`() { ClaudeOAuthRefreshFailureGate.resetForTesting() defer { ClaudeOAuthRefreshFailureGate.resetForTesting() } var calls = 0 let fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( modifiedAt: 1, createdAt: 1, persistentRefHash: "ref1"), credentialsFile: "file1") ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { calls += 1 return fingerprint } defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) } let start = Date(timeIntervalSince1970: 30000) ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start) #expect(calls == 1) // First blocked check is throttled (we already captured fingerprint at failure). #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(1)) == false) #expect(calls == 1) // After the throttle window, it should re-read. #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(20)) == false) #expect(calls == 2) // Subsequent checks within the throttle window should not re-read again. #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(21)) == false) #expect(calls == 2) } @Test func `terminal block is monotonic when transient failure is recorded`() { ClaudeOAuthRefreshFailureGate.resetForTesting() defer { ClaudeOAuthRefreshFailureGate.resetForTesting() } let fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( modifiedAt: 1, createdAt: 1, persistentRefHash: "ref1"), credentialsFile: "file1") ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint } defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) } let start = Date(timeIntervalSince1970: 35000) ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start) ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: start.addingTimeInterval(1)) #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(20)) == false) #expect(UserDefaults.standard.bool(forKey: self.terminalBlockedKey) == true) #expect(UserDefaults.standard.object(forKey: self.transientBlockedUntilKey) == nil) } @Test func `record success clears terminal block`() { ClaudeOAuthRefreshFailureGate.resetForTesting() defer { ClaudeOAuthRefreshFailureGate.resetForTesting() } let fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( modifiedAt: 1, createdAt: 1, persistentRefHash: "ref1"), credentialsFile: "file1") ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint } defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) } let start = Date(timeIntervalSince1970: 5000) ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start) #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60)) == false) ClaudeOAuthRefreshFailureGate.recordSuccess() #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60)) == true) } @Test func `transient backoff blocks until expiry then unblocks`() { ClaudeOAuthRefreshFailureGate.resetForTesting() defer { ClaudeOAuthRefreshFailureGate.resetForTesting() } let fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( modifiedAt: 1, createdAt: 1, persistentRefHash: "ref1"), credentialsFile: "file1") ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint } defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) } let start = Date(timeIntervalSince1970: 60000) ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: start) #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(1)) == false) #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 5 - 1)) == false) #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 5 + 1)) == true) } @Test func `transient backoff is exponential and capped`() { ClaudeOAuthRefreshFailureGate.resetForTesting() defer { ClaudeOAuthRefreshFailureGate.resetForTesting() } let fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( modifiedAt: 1, createdAt: 1, persistentRefHash: "ref1"), credentialsFile: "file1") ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint } defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) } let start = Date(timeIntervalSince1970: 70000) ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: start) // Second failure before the first window expires should double the backoff. let secondFailureAt = start.addingTimeInterval(1) ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: secondFailureAt) #expect(ClaudeOAuthRefreshFailureGate .shouldAttempt(now: secondFailureAt.addingTimeInterval(60 * 10 - 1)) == false) #expect(ClaudeOAuthRefreshFailureGate .shouldAttempt(now: secondFailureAt.addingTimeInterval(60 * 10 + 1)) == true) ClaudeOAuthRefreshFailureGate.resetInMemoryStateForTesting() for _ in 0..<20 { ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: start) } #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 60 * 6 - 1)) == false) #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 60 * 6 + 1)) == true) } @Test func `transient backoff unblocks early when fingerprint changes`() { ClaudeOAuthRefreshFailureGate.resetForTesting() defer { ClaudeOAuthRefreshFailureGate.resetForTesting() } var fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( modifiedAt: 1, createdAt: 1, persistentRefHash: "ref1"), credentialsFile: "file1") ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint } defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) } let start = Date(timeIntervalSince1970: 80000) ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: start) // Still blocked while timer is active and fingerprint unchanged. #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(20)) == false) fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( modifiedAt: 2, createdAt: 2, persistentRefHash: "ref2"), credentialsFile: "file2") // Even though the 5-minute cooldown window hasn't elapsed, a fingerprint change should unblock. #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(40)) == true) } } #endif