441 lines · 23.4 KB · Swift
RawDownload
import Foundationimport Testing@testable import CodexBarCore @Suite(.serialized)struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests {    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)    }     private func withClaudeOAuthTokenRefreshStub<T>(        handler: @escaping (URLRequest) throws -> (HTTPURLResponse, Data),        operation: () async throws -> T) async rethrows -> T    {        let registered = URLProtocol.registerClass(ClaudeOAuthTokenRefreshStubURLProtocol.self)        ClaudeOAuthTokenRefreshStubURLProtocol.reset()        ClaudeOAuthTokenRefreshStubURLProtocol.handler = handler        defer {            if registered {                URLProtocol.unregisterClass(ClaudeOAuthTokenRefreshStubURLProtocol.self)            }            ClaudeOAuthTokenRefreshStubURLProtocol.reset()        }        return try await operation()    }     private func requestBodyString(_ request: URLRequest) -> String {        if let body = request.httpBody {            return String(data: body, encoding: .utf8) ?? ""        }         guard let stream = request.httpBodyStream else { return "" }        stream.open()        defer { stream.close() }         var data = Data()        let bufferSize = 1024        let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)        defer { buffer.deallocate() }         while stream.hasBytesAvailable {            let count = stream.read(buffer, maxLength: bufferSize)            guard count > 0 else { break }            data.append(buffer, count: count)        }         return String(data: data, encoding: .utf8) ?? ""    }     @Test    func `successful codexbar refresh is re-owned when Claude CLI storage appears`() 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)            defer { try? FileManager.default.removeItem(at: tempDir) }            let fileURL = tempDir.appendingPathComponent("credentials.json")            try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {                try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {                    try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {                        try 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-only",                                expiresAt: Date(timeIntervalSinceNow: -3600),                                refreshToken: "cached-refresh-token")                            KeychainCacheStore.store(                                key: cacheKey,                                entry: ClaudeOAuthCredentialsStore.CacheEntry(                                    data: expiredData,                                    storedAt: Date(timeIntervalSinceNow: 60),                                    owner: .codexbar))                             var tokenRefreshRequestCount = 0                            let refreshed = try await self.withClaudeOAuthTokenRefreshStub(handler: { request in                                tokenRefreshRequestCount += 1                                #expect(request.url?.host == "platform.claude.com")                                #expect(request.url?.path == "/v1/oauth/token")                                #expect(request.httpMethod == "POST")                                #expect(request.value(forHTTPHeaderField: "Accept") == "application/json")                                #expect(                                    request.value(forHTTPHeaderField: "Content-Type") ==                                        "application/x-www-form-urlencoded")                                 let body = self.requestBodyString(request)                                #expect(body.contains("grant_type=refresh_token"))                                #expect(body.contains("refresh_token=cached-refresh-token"))                                #expect(body.contains("client_id=\(ClaudeOAuthCredentialsStore.defaultOAuthClientID)"))                                 let response = try HTTPURLResponse(                                    url: #require(request.url),                                    statusCode: 200,                                    httpVersion: "HTTP/1.1",                                    headerFields: ["Content-Type": "application/json"])!                                let json = """                                {                                  "access_token": "fresh-codexbar-token",                                  "refresh_token": "fresh-refresh-token",                                  "expires_in": 3600,                                  "token_type": "Bearer"                                }                                """                                return (response, Data(json.utf8))                            }, operation: {                                try await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(true) {                                    try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh(                                        environment: [:],                                        allowKeychainPrompt: false,                                        respectKeychainPromptCooldown: true)                                }                            })                             #expect(refreshed.accessToken == "fresh-codexbar-token")                            #expect(refreshed.refreshToken == "fresh-refresh-token")                            #expect(tokenRefreshRequestCount == 1)                             switch KeychainCacheStore.load(                                key: cacheKey,                                as: ClaudeOAuthCredentialsStore.CacheEntry.self)                            {                            case let .found(entry):                                #expect(entry.owner == .codexbar)                                let parsed = try ClaudeOAuthCredentials.parse(data: entry.data)                                #expect(parsed.accessToken == "fresh-codexbar-token")                                #expect(parsed.refreshToken == "fresh-refresh-token")                            default:                                Issue.record("Expected refreshed CodexBar-owned cache entry")                            }                             let keychainData = self.makeCredentialsData(                                accessToken: "claude-keychain",                                expiresAt: Date(timeIntervalSinceNow: 3600),                                refreshToken: "keychain-refresh-token")                             let recordAfterCLIStorageAppears = try ClaudeOAuthCredentialsStore                                .withClaudeKeychainOverridesForTesting(data: keychainData, fingerprint: nil) {                                    try ClaudeOAuthCredentialsStore.loadRecord(                                        environment: [:],                                        allowKeychainPrompt: false,                                        respectKeychainPromptCooldown: true,                                        allowClaudeKeychainRepairWithoutPrompt: false)                                }                             #expect(recordAfterCLIStorageAppears.credentials.accessToken == "fresh-codexbar-token")                            #expect(recordAfterCLIStorageAppears.owner == .claudeCLI)                            #expect(recordAfterCLIStorageAppears.source == .memoryCache)                        }                    }                }            }        }    }     @Test    func `load record treats codexbar cache as claude CLI owned when credentials file exists`() 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() }             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.withIsolatedCredentialsFileTrackingForTesting {                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 fileData = self.makeCredentialsData(                                accessToken: "claude-cli-file",                                expiresAt: Date(timeIntervalSinceNow: 3600),                                refreshToken: "cli-refresh-token")                            try fileData.write(to: fileURL)                             let cachedData = self.makeCredentialsData(                                accessToken: "codexbar-cache",                                expiresAt: Date(timeIntervalSinceNow: 3600),                                refreshToken: "cached-refresh-token")                            KeychainCacheStore.store(                                key: cacheKey,                                entry: ClaudeOAuthCredentialsStore.CacheEntry(                                    data: cachedData,                                    storedAt: Date(timeIntervalSinceNow: 60),                                    owner: .codexbar))                             let record = try ClaudeOAuthCredentialsStore.loadRecord(                                environment: [:],                                allowKeychainPrompt: false,                                respectKeychainPromptCooldown: true,                                allowClaudeKeychainRepairWithoutPrompt: false)                             #expect(record.credentials.accessToken == "codexbar-cache")                            #expect(record.owner == .claudeCLI)                            #expect(record.source == .cacheKeychain)                        }                    }                }            }        }    }     @Test    func `load with auto refresh delegates expired codexbar cache when credentials file exists`() 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")            try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {                try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {                    try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {                        try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {                            ClaudeOAuthCredentialsStore.invalidateCache()                            let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)                            defer { KeychainCacheStore.clear(key: cacheKey) }                             try Data("not valid credentials".utf8).write(to: fileURL)                             let expiredData = self.makeCredentialsData(                                accessToken: "expired-codexbar-with-file",                                expiresAt: Date(timeIntervalSinceNow: -3600),                                refreshToken: "cached-refresh-token")                            KeychainCacheStore.store(                                key: cacheKey,                                entry: ClaudeOAuthCredentialsStore.CacheEntry(                                    data: expiredData,                                    storedAt: Date(timeIntervalSinceNow: 60),                                    owner: .codexbar))                             await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(false) {                                do {                                    _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh(                                        environment: [:],                                        allowKeychainPrompt: false,                                        respectKeychainPromptCooldown: true)                                    Issue.record("Expected delegated refresh error when Claude CLI file is present")                                } 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 keeps codexbar cache ownership without Claude CLI storage`() 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)            defer { try? FileManager.default.removeItem(at: tempDir) }            let fileURL = tempDir.appendingPathComponent("missing-credentials.json")            await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {                await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {                    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-only",                                expiresAt: Date(timeIntervalSinceNow: -3600),                                refreshToken: "cached-refresh-token")                            KeychainCacheStore.store(                                key: cacheKey,                                entry: ClaudeOAuthCredentialsStore.CacheEntry(                                    data: expiredData,                                    storedAt: Date(timeIntervalSinceNow: 60),                                    owner: .codexbar))                             await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(false) {                                do {                                    _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh(                                        environment: [:],                                        allowKeychainPrompt: false,                                        respectKeychainPromptCooldown: true)                                    Issue.record("Expected direct CodexBar refresh failure")                                } catch let error as ClaudeOAuthCredentialsError {                                    guard case let .refreshFailed(message) = error else {                                        Issue.record("Expected .refreshFailed, got \(error)")                                        return                                    }                                    #expect(message.contains("suppressed") || message.contains("backed off"))                                } catch {                                    Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)")                                }                            }                        }                    }                }            }        }    }     @Test    func `load record treats codexbar cache as claude CLI owned when Claude keychain item exists`() 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() }             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.withIsolatedCredentialsFileTrackingForTesting {                try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {                    try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {                        ClaudeOAuthCredentialsStore.invalidateCache()                        let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)                        defer { KeychainCacheStore.clear(key: cacheKey) }                         let cachedData = self.makeCredentialsData(                            accessToken: "codexbar-cache",                            expiresAt: Date(timeIntervalSinceNow: 3600),                            refreshToken: "cached-refresh-token")                        KeychainCacheStore.store(                            key: cacheKey,                            entry: ClaudeOAuthCredentialsStore.CacheEntry(                                data: cachedData,                                storedAt: Date(),                                owner: .codexbar))                         let keychainData = self.makeCredentialsData(                            accessToken: "claude-keychain",                            expiresAt: Date(timeIntervalSinceNow: 3600),                            refreshToken: "keychain-refresh-token")                         let record = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {                            try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(                                data: keychainData,                                fingerprint: nil)                            {                                try ClaudeOAuthCredentialsStore.loadRecord(                                    environment: [:],                                    allowKeychainPrompt: false,                                    respectKeychainPromptCooldown: true,                                    allowClaudeKeychainRepairWithoutPrompt: false)                            }                        }                         #expect(record.credentials.accessToken == "codexbar-cache")                        #expect(record.owner == .claudeCLI)                        #expect(record.source == .cacheKeychain)                    }                }            }        }    }} private final class ClaudeOAuthTokenRefreshStubURLProtocol: URLProtocol {    nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?     static func reset() {        self.handler = nil    }     override static func canInit(with request: URLRequest) -> Bool {        request.url?.host == "platform.claude.com" && request.url?.path == "/v1/oauth/token"    }     override static func canonicalRequest(for request: URLRequest) -> URLRequest {        request    }     override func startLoading() {        guard let handler = Self.handler else {            self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))            return        }         do {            let (response, data) = try handler(self.request)            self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)            self.client?.urlProtocol(self, didLoad: data)            self.client?.urlProtocolDidFinishLoading(self)        } catch {            self.client?.urlProtocol(self, didFailWithError: error)        }    }     override func stopLoading() {}}