import Foundationimport Testing@testable import CodexBarCore @Suite(.serialized)struct ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests { private struct WrongCacheEntry: Codable { let value: String } 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) } #if os(macOS) @Test func `credentials file invalidation preserves keychain cache when temporarily unavailable`() throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" try KeychainCacheStore.withServiceOverrideForTesting(service) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { 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 firstFile = self.makeCredentialsData( accessToken: "first-file", expiresAt: Date(timeIntervalSinceNow: 3600)) try firstFile.write(to: fileURL) #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()) 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)) defer { KeychainCacheStore.clear(key: cacheKey) } let updatedFile = self.makeCredentialsData( accessToken: "updated-file-token-longer", expiresAt: Date(timeIntervalSinceNow: 3600)) try updatedFile.write(to: fileURL) KeychainCacheStore.withLoadFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()) } 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") case .missing, .temporarilyUnavailable, .invalid: #expect(Bool(false), "Expected temporary unavailability not to clear Claude cache") } #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()) switch KeychainCacheStore.load( key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) { case .missing: #expect(true) case .found, .temporarilyUnavailable, .invalid: #expect(Bool(false), "Expected pending invalidation to clear stale Claude cache") } } } } } } @Test func `temporary keychain cache unavailability does not overwrite cache from credentials file fallback`() 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 { try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { 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 fileData = self.makeCredentialsData( accessToken: "file-fallback-token", expiresAt: Date(timeIntervalSinceNow: 3600)) try fileData.write(to: fileURL) 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)) defer { KeychainCacheStore.clear(key: cacheKey) } let loaded = try KeychainCacheStore.withLoadFailureStatusOverrideForTesting( errSecInteractionNotAllowed) { try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) } #expect(loaded.accessToken == "file-fallback-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") case .missing, .temporarilyUnavailable, .invalid: #expect(Bool(false), "Expected file fallback not to overwrite unavailable cache") } } } } } } } @Test func `has cached credentials treats temporary keychain cache unavailability as present`() { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" KeychainCacheStore.withServiceOverrideForTesting(service) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { ClaudeOAuthCredentialsStore.invalidateCache() 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())) defer { KeychainCacheStore.clear(key: cacheKey) } let hasCached = KeychainCacheStore.withLoadFailureStatusOverrideForTesting( errSecInteractionNotAllowed) { ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: [:]) } #expect(hasCached == true) } } } #endif @Test func `invalid keychain cache is cleared by load`() 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) } ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { 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 cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) KeychainCacheStore.store(key: cacheKey, entry: WrongCacheEntry(value: "wrong-shape")) do { _ = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) Issue.record("Expected ClaudeOAuthCredentialsError.notFound") } catch let error as ClaudeOAuthCredentialsError { guard case .notFound = error else { Issue.record("Expected .notFound, got \(error)") return } } switch KeychainCacheStore.load( key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) { case .missing: #expect(true) case .found, .temporarilyUnavailable, .invalid: #expect(Bool(false), "Expected invalid Claude cache to be cleared") } } } } } } }}
import Foundation
import Testing
@testable import CodexBarCore
@Suite(.serialized)
struct ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests {
private struct WrongCacheEntry: Codable {
let value: String
}
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)
}
#if os(macOS)
@Test
func `credentials file invalidation preserves keychain cache when temporarily unavailable`() throws {
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
try KeychainCacheStore.withServiceOverrideForTesting(service) {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
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 firstFile = self.makeCredentialsData(
accessToken: "first-file",
expiresAt: Date(timeIntervalSinceNow: 3600))
try firstFile.write(to: fileURL)
#expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged())
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))
defer { KeychainCacheStore.clear(key: cacheKey) }
let updatedFile = self.makeCredentialsData(
accessToken: "updated-file-token-longer",
expiresAt: Date(timeIntervalSinceNow: 3600))
try updatedFile.write(to: fileURL)
KeychainCacheStore.withLoadFailureStatusOverrideForTesting(errSecInteractionNotAllowed) {
#expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged())
}
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")
case .missing, .temporarilyUnavailable, .invalid:
#expect(Bool(false), "Expected temporary unavailability not to clear Claude cache")
}
#expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged())
switch KeychainCacheStore.load(
key: cacheKey,
as: ClaudeOAuthCredentialsStore.CacheEntry.self)
{
case .missing:
#expect(true)
case .found, .temporarilyUnavailable, .invalid:
#expect(Bool(false), "Expected pending invalidation to clear stale Claude cache")
}
}
}
}
}
}
@Test
func `temporary keychain cache unavailability does not overwrite cache from credentials file fallback`() 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 {
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
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 fileData = self.makeCredentialsData(
accessToken: "file-fallback-token",
expiresAt: Date(timeIntervalSinceNow: 3600))
try fileData.write(to: fileURL)
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))
defer { KeychainCacheStore.clear(key: cacheKey) }
let loaded = try KeychainCacheStore.withLoadFailureStatusOverrideForTesting(
errSecInteractionNotAllowed)
{
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
}
#expect(loaded.accessToken == "file-fallback-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")
case .missing, .temporarilyUnavailable, .invalid:
#expect(Bool(false), "Expected file fallback not to overwrite unavailable cache")
}
}
}
}
}
}
}
@Test
func `has cached credentials treats temporary keychain cache unavailability as present`() {
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
KeychainCacheStore.withServiceOverrideForTesting(service) {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
ClaudeOAuthCredentialsStore.invalidateCache()
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()))
defer { KeychainCacheStore.clear(key: cacheKey) }
let hasCached = KeychainCacheStore.withLoadFailureStatusOverrideForTesting(
errSecInteractionNotAllowed)
{
ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: [:])
}
#expect(hasCached == true)
}
}
}
#endif
@Test
func `invalid keychain cache is cleared by load`() 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) }
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
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 cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
KeychainCacheStore.store(key: cacheKey, entry: WrongCacheEntry(value: "wrong-shape"))
do {
_ = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
Issue.record("Expected ClaudeOAuthCredentialsError.notFound")
} catch let error as ClaudeOAuthCredentialsError {
guard case .notFound = error else {
Issue.record("Expected .notFound, got \(error)")
return
}
}
switch KeychainCacheStore.load(
key: cacheKey,
as: ClaudeOAuthCredentialsStore.CacheEntry.self)
{
case .missing:
#expect(true)
case .found, .temporarilyUnavailable, .invalid:
#expect(Bool(false), "Expected invalid Claude cache to be cleared")
}
}
}
}
}
}
}
}