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

918 lines
48 KiB
Swift

import Foundation
import Testing
@testable import CodexBarCore
@Suite(.serialized)
struct ClaudeOAuthCredentialsStoreTests {
private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data {
let millis = Int(expiresAt.timeIntervalSince1970 * 1000)
let refreshField: String = {
guard let refreshToken else { return "" }
return ",\n \"refreshToken\": \"\(refreshToken)\""
}()
let json = """
{
"claudeAiOauth": {
"accessToken": "\(accessToken)",
"expiresAt": \(millis),
"scopes": ["user:profile"]\(refreshField)
}
}
"""
return Data(json.utf8)
}
@Test
func `persistent reference hash stays stable across keychain metadata refresh`() {
let first = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 1,
createdAt: 1,
persistentRefHash: "opaque-ref")
let refreshed = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 2,
createdAt: 1,
persistentRefHash: "opaque-ref")
let firstHash = ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
data: nil,
fingerprint: first)
{
ClaudeOAuthCredentialsStore.claudeKeychainPersistentRefHashWithoutPrompt()
}
let refreshedHash = ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
data: nil,
fingerprint: refreshed)
{
ClaudeOAuthCredentialsStore.claudeKeychainPersistentRefHashWithoutPrompt()
}
#expect(firstHash == "opaque-ref")
#expect(refreshedHash == firstHash)
}
@Test
func `loads from keychain cache before expired file`() throws {
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
try ProviderInteractionContext.$current.withValue(.background) {
try KeychainCacheStore.withServiceOverrideForTesting(service) {
try KeychainAccessGate.withTaskOverrideForTesting(false) {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let fileURL = tempDir.appendingPathComponent("credentials.json")
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
let expiredData = self.makeCredentialsData(
accessToken: "expired",
expiresAt: Date(timeIntervalSinceNow: -3600))
try expiredData.write(to: fileURL)
let cachedData = self.makeCredentialsData(
accessToken: "cached",
expiresAt: Date(timeIntervalSinceNow: 3600))
let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(
data: cachedData,
storedAt: Date())
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
ClaudeOAuthCredentialsStore.invalidateCache()
KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
defer { KeychainCacheStore.clear(key: cacheKey) }
_ = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
.securityFramework)
{
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
.onlyOnUserAction)
{
try ClaudeOAuthCredentialsStore.load(
environment: [:],
allowKeychainPrompt: false)
}
}
// Re-store to cache after file check has marked file as "seen"
KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
.securityFramework)
{
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
.onlyOnUserAction)
{
try ClaudeOAuthCredentialsStore.load(
environment: [:],
allowKeychainPrompt: false)
}
}
#expect(creds.accessToken == "cached")
#expect(creds.isExpired == false)
}
}
}
}
}
}
}
@Test
func `load record non interactive repair can be disabled`() throws {
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
try KeychainCacheStore.withServiceOverrideForTesting(service) {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
// Ensure file-based lookup doesn't interfere (and avoid touching ~/.claude).
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let fileURL = tempDir.appendingPathComponent("credentials.json")
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
ClaudeOAuthCredentialsStore.invalidateCache()
let keychainData = self.makeCredentialsData(
accessToken: "claude-keychain",
expiresAt: Date(timeIntervalSinceNow: 3600))
// Simulate Claude Keychain containing creds, without querying the real Keychain.
try ProviderInteractionContext.$current.withValue(.userInitiated) {
try ClaudeOAuthCredentialsStore
.withClaudeKeychainOverridesForTesting(data: keychainData, fingerprint: nil) {
// When repair is disabled, non-interactive loads should not consult Claude's
// keychain data.
do {
_ = try ClaudeOAuthCredentialsStore.loadRecord(
environment: [:],
allowKeychainPrompt: false,
respectKeychainPromptCooldown: true,
allowClaudeKeychainRepairWithoutPrompt: false)
Issue.record("Expected ClaudeOAuthCredentialsError.notFound")
} catch let error as ClaudeOAuthCredentialsError {
guard case .notFound = error else {
Issue.record("Expected .notFound, got \(error)")
return
}
}
// With repair enabled, we should be able to seed from the "Claude keychain"
// override.
let record = try ClaudeOAuthCredentialsStore.loadRecord(
environment: [:],
allowKeychainPrompt: false,
respectKeychainPromptCooldown: true,
allowClaudeKeychainRepairWithoutPrompt: true)
#expect(record.credentials.accessToken == "claude-keychain")
}
}
}
}
}
}
}
@Test
func `invalidates cache when credentials file changes`() throws {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
// Avoid interacting with the real Keychain in unit tests.
try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let fileURL = tempDir.appendingPathComponent("credentials.json")
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
let first = self.makeCredentialsData(
accessToken: "first",
expiresAt: Date(timeIntervalSinceNow: 3600))
try first.write(to: fileURL)
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(data: first, storedAt: Date())
KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
_ = try ClaudeOAuthCredentialsStore.load(environment: [:])
let updated = self.makeCredentialsData(
accessToken: "second",
expiresAt: Date(timeIntervalSinceNow: 3600))
try updated.write(to: fileURL)
#expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged())
KeychainCacheStore.clear(key: cacheKey)
let creds = try ClaudeOAuthCredentialsStore.load(environment: [:])
#expect(creds.accessToken == "second")
}
}
}
}
}
@Test
func `returns expired file when no other sources`() throws {
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
try KeychainCacheStore.withServiceOverrideForTesting(service) {
try KeychainAccessGate.withTaskOverrideForTesting(true) {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let fileURL = tempDir.appendingPathComponent("credentials.json")
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
let expiredData = self.makeCredentialsData(
accessToken: "expired-only",
expiresAt: Date(timeIntervalSinceNow: -3600))
try expiredData.write(to: fileURL)
try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
ClaudeOAuthCredentialsStore.invalidateCache()
let creds = try ClaudeOAuthCredentialsStore.load(environment: [:])
#expect(creds.accessToken == "expired-only")
#expect(creds.isExpired == true)
}
}
}
}
}
}
@Test
func `load with auto refresh expired claude CLI owner throws delegated refresh`() async throws {
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
try await KeychainCacheStore.withServiceOverrideForTesting(service) {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let fileURL = tempDir.appendingPathComponent("credentials.json")
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
ClaudeOAuthCredentialsStore.invalidateCache()
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
defer { KeychainCacheStore.clear(key: cacheKey) }
let expiredData = self.makeCredentialsData(
accessToken: "expired-claude-cli-owner",
expiresAt: Date(timeIntervalSinceNow: -3600),
refreshToken: "refresh-token")
KeychainCacheStore.store(
key: cacheKey,
entry: ClaudeOAuthCredentialsStore.CacheEntry(
data: expiredData,
storedAt: Date(),
owner: .claudeCLI))
do {
_ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh(
environment: [:],
allowKeychainPrompt: false,
respectKeychainPromptCooldown: true)
Issue.record("Expected delegated refresh error for Claude CLI-owned credentials")
} catch let error as ClaudeOAuthCredentialsError {
guard case .refreshDelegatedToClaudeCLI = error else {
Issue.record("Expected .refreshDelegatedToClaudeCLI, got \(error)")
return
}
} catch {
Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)")
}
}
}
}
}
@Test
func `load with auto refresh expired codexbar owner uses direct refresh path`() async throws {
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
try await KeychainCacheStore.withServiceOverrideForTesting(service) {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let fileURL = tempDir.appendingPathComponent("credentials.json")
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
ClaudeOAuthCredentialsStore.invalidateCache()
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
defer { KeychainCacheStore.clear(key: cacheKey) }
let expiredData = self.makeCredentialsData(
accessToken: "expired-codexbar-owner",
expiresAt: Date(timeIntervalSinceNow: -3600),
refreshToken: "refresh-token")
KeychainCacheStore.store(
key: cacheKey,
entry: ClaudeOAuthCredentialsStore.CacheEntry(
data: expiredData,
storedAt: Date(),
owner: .codexbar))
await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(false) {
do {
_ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh(
environment: [:],
allowKeychainPrompt: false,
respectKeychainPromptCooldown: true)
Issue.record("Expected refresh failure for CodexBar-owned direct refresh path")
} catch let error as ClaudeOAuthCredentialsError {
guard case .refreshFailed = error else {
Issue.record("Expected .refreshFailed, got \(error)")
return
}
} catch {
Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)")
}
}
}
}
}
}
@Test
func `load record legacy cache entry without owner defaults to claude CLI owner`() throws {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let fileURL = tempDir.appendingPathComponent("credentials.json")
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
ClaudeOAuthCredentialsStore.invalidateCache()
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
defer { KeychainCacheStore.clear(key: cacheKey) }
let validData = self.makeCredentialsData(
accessToken: "legacy-owner",
expiresAt: Date(timeIntervalSinceNow: 3600),
refreshToken: "refresh-token")
KeychainCacheStore.store(
key: cacheKey,
entry: ClaudeOAuthCredentialsStore.CacheEntry(
data: validData,
storedAt: Date()))
let record = try ClaudeOAuthCredentialsStore.loadRecord(
environment: [:],
allowKeychainPrompt: false,
respectKeychainPromptCooldown: true)
#expect(record.owner == .claudeCLI)
#expect(record.source == .cacheKeychain)
}
}
}
}
@Test
func `has cached credentials returns false for expired unrefreshable codexbar cache entry`() throws {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let fileURL = tempDir.appendingPathComponent("credentials.json")
ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
ClaudeOAuthCredentialsStore.invalidateCache()
let expiredData = self.makeCredentialsData(
accessToken: "expired-no-refresh",
expiresAt: Date(timeIntervalSinceNow: -3600),
refreshToken: nil)
let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(
data: expiredData,
storedAt: Date(),
owner: .codexbar)
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
#expect(ClaudeOAuthCredentialsStore.hasCachedCredentials() == false)
}
}
@Test
func `has cached credentials returns true for expired refreshable cache entry`() throws {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let fileURL = tempDir.appendingPathComponent("credentials.json")
ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
ClaudeOAuthCredentialsStore.invalidateCache()
let expiredData = self.makeCredentialsData(
accessToken: "expired-refreshable",
expiresAt: Date(timeIntervalSinceNow: -3600),
refreshToken: "refresh")
let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(data: expiredData, storedAt: Date())
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
#expect(ClaudeOAuthCredentialsStore.hasCachedCredentials() == true)
}
}
@Test
func `has cached credentials returns true for expired claude CLI backed credentials file`() throws {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let fileURL = tempDir.appendingPathComponent("credentials.json")
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
ClaudeOAuthCredentialsStore.invalidateCache()
let expiredData = self.makeCredentialsData(
accessToken: "expired-file-no-refresh",
expiresAt: Date(timeIntervalSinceNow: -3600),
refreshToken: nil)
try expiredData.write(to: fileURL)
#expect(ClaudeOAuthCredentialsStore.hasCachedCredentials() == true)
}
}
@Test
func `syncs cache when claude keychain fingerprint changes and token differs`() throws {
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
try KeychainCacheStore.withServiceOverrideForTesting(service) {
try KeychainAccessGate.withTaskOverrideForTesting(false) {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
ClaudeOAuthKeychainAccessGate.resetForTesting()
defer { ClaudeOAuthKeychainAccessGate.resetForTesting() }
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let fileURL = tempDir.appendingPathComponent("credentials.json")
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
ClaudeOAuthCredentialsStore.invalidateCache()
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
defer {
ClaudeOAuthCredentialsStore.invalidateCache()
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil)
ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil)
}
// Avoid cross-suite interference from UserDefaults fingerprint persistence.
let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore()
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
let cachedData = self.makeCredentialsData(
accessToken: "cached-token",
expiresAt: Date(timeIntervalSinceNow: 3600))
KeychainCacheStore.store(
key: cacheKey,
entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date()))
let fingerprint1 = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 1,
createdAt: 1,
persistentRefHash: "ref1")
let first = try ProviderInteractionContext.$current.withValue(.userInitiated) {
try ClaudeOAuthCredentialsStore.withClaudeKeychainFingerprintStoreOverrideForTesting(
fingerprintStore)
{
try ClaudeOAuthKeychainAccessGate.withShouldAllowPromptOverrideForTesting(true) {
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
data: cachedData,
fingerprint: fingerprint1)
{
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
}
}
}
}
#expect(first.accessToken == "cached-token")
#expect(fingerprintStore.fingerprint == fingerprint1)
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting()
let fingerprint2 = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 2,
createdAt: 2,
persistentRefHash: "ref2")
let keychainData = self.makeCredentialsData(
accessToken: "keychain-token",
expiresAt: Date(timeIntervalSinceNow: 3600))
let second = try ProviderInteractionContext.$current.withValue(.userInitiated) {
try ClaudeOAuthCredentialsStore.withClaudeKeychainFingerprintStoreOverrideForTesting(
fingerprintStore)
{
try ClaudeOAuthKeychainAccessGate.withShouldAllowPromptOverrideForTesting(true) {
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
data: keychainData,
fingerprint: fingerprint2)
{
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
}
}
}
}
#expect(second.accessToken == "keychain-token")
#expect(fingerprintStore.fingerprint == fingerprint2)
switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) {
case let .found(entry):
let parsed = try ClaudeOAuthCredentials.parse(data: entry.data)
#expect(parsed.accessToken == "keychain-token")
default:
#expect(Bool(false))
}
}
}
}
}
@Test
func `does not sync in background when cache valid and prompt mode only on user action`() throws {
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
try KeychainCacheStore.withServiceOverrideForTesting(service) {
try KeychainAccessGate.withTaskOverrideForTesting(false) {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
ClaudeOAuthKeychainAccessGate.resetForTesting()
defer { ClaudeOAuthKeychainAccessGate.resetForTesting() }
ClaudeOAuthCredentialsStore.invalidateCache()
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
defer {
ClaudeOAuthCredentialsStore.invalidateCache()
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil)
ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil)
}
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let fileURL = tempDir.appendingPathComponent("credentials.json")
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore()
let fingerprint1 = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 1,
createdAt: 1,
persistentRefHash: "ref1")
fingerprintStore.fingerprint = fingerprint1
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
let cachedData = self.makeCredentialsData(
accessToken: "cached-token",
expiresAt: Date(timeIntervalSinceNow: 3600))
KeychainCacheStore.store(
key: cacheKey,
entry: ClaudeOAuthCredentialsStore.CacheEntry(
data: cachedData,
storedAt: Date(),
owner: .claudeCLI))
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting()
let fingerprint2 = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 2,
createdAt: 2,
persistentRefHash: "ref2")
let keychainData = self.makeCredentialsData(
accessToken: "keychain-token",
expiresAt: Date(timeIntervalSinceNow: 3600))
let creds = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
try ProviderInteractionContext.$current.withValue(.background) {
try ClaudeOAuthCredentialsStore.withClaudeKeychainFingerprintStoreOverrideForTesting(
fingerprintStore)
{
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
data: keychainData,
fingerprint: fingerprint2)
{
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
}
}
}
}
#expect(creds.accessToken == "cached-token")
#expect(fingerprintStore.fingerprint == fingerprint1)
switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) {
case let .found(entry):
let parsed = try ClaudeOAuthCredentials.parse(data: entry.data)
#expect(parsed.accessToken == "cached-token")
default:
#expect(Bool(false))
}
}
}
}
}
@Test
func `does not sync when claude keychain fingerprint unchanged`() throws {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
ClaudeOAuthCredentialsStore.invalidateCache()
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
defer {
ClaudeOAuthCredentialsStore.invalidateCache()
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
}
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
let cachedData = self.makeCredentialsData(
accessToken: "cached-token",
expiresAt: Date(timeIntervalSinceNow: 3600))
KeychainCacheStore.store(
key: cacheKey,
entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date()))
let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 1,
createdAt: 1,
persistentRefHash: "ref1")
let first = try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
data: cachedData,
fingerprint: fingerprint,
operation: {
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
})
#expect(first.accessToken == "cached-token")
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting()
let keychainData = self.makeCredentialsData(
accessToken: "keychain-token",
expiresAt: Date(timeIntervalSinceNow: 3600))
let second = try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
data: keychainData,
fingerprint: fingerprint,
operation: {
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
})
#expect(second.accessToken == "cached-token")
switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) {
case let .found(entry):
let parsed = try ClaudeOAuthCredentials.parse(data: entry.data)
#expect(parsed.accessToken == "cached-token")
default:
#expect(Bool(false))
}
}
}
}
@Test
func `does not sync when keychain credentials expired but cache valid`() throws {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
ClaudeOAuthCredentialsStore.invalidateCache()
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
defer {
ClaudeOAuthCredentialsStore.invalidateCache()
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
}
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
let cachedData = self.makeCredentialsData(
accessToken: "cached-token",
expiresAt: Date(timeIntervalSinceNow: 3600))
KeychainCacheStore.store(
key: cacheKey,
entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date()))
let first = try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
data: cachedData,
fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 1,
createdAt: 1,
persistentRefHash: "ref1"),
operation: {
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
})
#expect(first.accessToken == "cached-token")
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting()
let expiredKeychainData = self.makeCredentialsData(
accessToken: "expired-keychain-token",
expiresAt: Date(timeIntervalSinceNow: -3600))
let second = try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
data: expiredKeychainData,
fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 2,
createdAt: 2,
persistentRefHash: "ref2"),
operation: {
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
})
#expect(second.accessToken == "cached-token")
switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) {
case let .found(entry):
let parsed = try ClaudeOAuthCredentials.parse(data: entry.data)
#expect(parsed.accessToken == "cached-token")
default:
#expect(Bool(false))
}
}
}
}
@Test
func `respects prompt cooldown gate when disabled prompting`() throws {
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
try KeychainCacheStore.withServiceOverrideForTesting(service) {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
ClaudeOAuthKeychainAccessGate.resetForTesting()
defer { ClaudeOAuthKeychainAccessGate.resetForTesting() }
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let fileURL = tempDir.appendingPathComponent("credentials.json")
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
ClaudeOAuthCredentialsStore.invalidateCache()
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
defer {
ClaudeOAuthCredentialsStore.invalidateCache()
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
}
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
let cachedData = self.makeCredentialsData(
accessToken: "cached-token",
expiresAt: Date(timeIntervalSinceNow: 3600))
KeychainCacheStore.store(
key: cacheKey,
entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date()))
let first = try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
data: cachedData,
fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 1,
createdAt: 1,
persistentRefHash: "ref1"),
operation: {
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
})
#expect(first.accessToken == "cached-token")
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting()
ClaudeOAuthKeychainAccessGate.recordDenied(now: Date())
let keychainData = self.makeCredentialsData(
accessToken: "keychain-token",
expiresAt: Date(timeIntervalSinceNow: 3600))
let second = try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
data: keychainData,
fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 2,
createdAt: 2,
persistentRefHash: "ref2"),
operation: {
try ClaudeOAuthCredentialsStore.load(
environment: [:],
allowKeychainPrompt: false,
respectKeychainPromptCooldown: true)
})
#expect(second.accessToken == "cached-token")
switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) {
case let .found(entry):
let parsed = try ClaudeOAuthCredentials.parse(data: entry.data)
#expect(parsed.accessToken == "cached-token")
default:
#expect(Bool(false))
}
}
}
}
}
}
@Test
func `sync from claude keychain without prompt respects backoff in background`() {
ProviderInteractionContext.$current.withValue(.background) {
KeychainAccessGate.withTaskOverrideForTesting(true) {
ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
let store = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore(
data: self.makeCredentialsData(
accessToken: "override-token",
expiresAt: Date(timeIntervalSinceNow: 3600)),
fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 1,
createdAt: 1,
persistentRefHash: "deadbeefdead"))
let deniedStore = ClaudeOAuthKeychainAccessGate.DeniedUntilStore()
deniedStore.deniedUntil = Date(timeIntervalSinceNow: 3600)
ClaudeOAuthKeychainAccessGate.withDeniedUntilStoreOverrideForTesting(deniedStore) {
ClaudeOAuthCredentialsStore.withMutableClaudeKeychainOverrideStoreForTesting(store) {
#expect(ClaudeOAuthCredentialsStore
.syncFromClaudeKeychainWithoutPrompt(now: Date()) == false)
}
}
}
}
}
}
@Test
func `testing override snapshot forwards mutable Claude keychain override store across detached task`() async {
let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
modifiedAt: 11,
createdAt: 7,
persistentRefHash: "snapshot-store")
let store = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore(
data: nil,
fingerprint: fingerprint)
let forwarded = await ClaudeOAuthCredentialsStore.withMutableClaudeKeychainOverrideStoreForTesting(store) {
let snapshot = ClaudeOAuthCredentialsStore.currentTestingOverridesSnapshotForTask
return await Task.detached {
ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
ClaudeOAuthCredentialsStore.withTestingOverridesSnapshotForTask(snapshot) {
ClaudeOAuthCredentialsStore.currentClaudeKeychainFingerprintWithoutPromptForAuthGate()
}
}
}.value
}
#expect(forwarded == fingerprint)
}
}