918 lines
48 KiB
Swift
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)
|
|
}
|
|
}
|