Files
steipete--codexbar/Tests/CodexBarTests/ClaudeOAuthRefreshFailureGateTests.swift
2026-07-13 12:22:33 +08:00

308 lines
15 KiB
Swift

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