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

683 lines
37 KiB
Swift

import Foundation
import 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 `rotated refresh token preserves history owner through cache restart`() 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")
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
defer { KeychainCacheStore.clear(key: cacheKey) }
try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
ClaudeOAuthCredentialsStore.invalidateCache()
let expiredData = self.makeCredentialsData(
accessToken: "access-before-rotation",
expiresAt: Date(timeIntervalSinceNow: -3600),
refreshToken: "refresh-before-rotation")
let originalCredentials = try ClaudeOAuthCredentials.parse(data: expiredData)
let originalHistoryOwner = try #require(originalCredentials.historyOwnerIdentifier)
KeychainCacheStore.store(
key: cacheKey,
entry: ClaudeOAuthCredentialsStore.CacheEntry(
data: expiredData,
storedAt: Date(),
owner: .codexbar))
let refreshedRecord = try await ClaudeOAuthCredentialsStore
.withIsolatedMemoryCacheForTesting {
try await self.withClaudeOAuthTokenRefreshStub(handler: { request in
let response = try HTTPURLResponse(
url: #require(request.url),
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"])!
let json = """
{
"access_token": "access-after-rotation",
"refresh_token": "refresh-after-rotation",
"expires_in": 3600,
"token_type": "Bearer"
}
"""
return (response, Data(json.utf8))
}, operation: {
try await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(true) {
try await ClaudeOAuthCredentialsStore.loadRecordWithAutoRefresh(
environment: [:],
allowKeychainPrompt: false,
respectKeychainPromptCooldown: true)
}
})
}
let rotatedCredentialOwner = try #require(
refreshedRecord.credentials.historyOwnerIdentifier)
#expect(rotatedCredentialOwner != originalHistoryOwner)
#expect(refreshedRecord.historyOwnerIdentifier == originalHistoryOwner)
switch KeychainCacheStore.load(
key: cacheKey,
as: ClaudeOAuthCredentialsStore.CacheEntry.self)
{
case let .found(entry):
#expect(entry.owner == .codexbar)
#expect(entry.historyOwnerIdentifier == originalHistoryOwner)
default:
Issue.record("Expected refreshed cache entry with preserved history lineage")
}
let restartedRecord = try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
try ClaudeOAuthCredentialsStore.loadRecord(
environment: [:],
allowKeychainPrompt: false,
respectKeychainPromptCooldown: true,
allowClaudeKeychainRepairWithoutPrompt: false)
}
#expect(restartedRecord.credentials.accessToken == "access-after-rotation")
#expect(restartedRecord.credentials.refreshToken == "refresh-after-rotation")
#expect(restartedRecord.source == .cacheKeychain)
#expect(restartedRecord.historyOwnerIdentifier == originalHistoryOwner)
}
}
}
}
}
@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(.onlyOnUserAction) {
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)
}
}
}
}
}
@Test
func `load record ignores codexbar cache in never prompt mode`() throws {
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
try KeychainCacheStore.withServiceOverrideForTesting(service) {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
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")
let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore()
try ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) {
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
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))
do {
_ = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
data: self.makeCredentialsData(
accessToken: "claude-keychain",
expiresAt: Date(timeIntervalSinceNow: 3600),
refreshToken: "keychain-refresh-token"),
fingerprint: nil)
{
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
}
}
}
}
}
}
}
}
@Test
func `expired claude CLI owner blocks background mcp O auth but lets user action delegate`() async throws {
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
let mcpOAuthOnly = Data("""
{
"mcpOAuth": {
"plugin:slack:slack": { "accessToken": "" }
}
}
""".utf8)
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 KeychainAccessGate.withTaskOverrideForTesting(false) {
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
.securityCLIExperimental,
operation: {
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
.data(mcpOAuthOnly))
{
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 mcpOAuth-only keychain error")
} catch let error as ClaudeOAuthCredentialsError {
guard case .mcpOAuthOnlyKeychain = error else {
Issue.record("Expected .mcpOAuthOnlyKeychain, got \(error)")
return
}
} catch {
Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)")
}
do {
_ = try await ProviderInteractionContext.$current.withValue(.userInitiated) {
try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh(
environment: [:],
allowKeychainPrompt: false,
respectKeychainPromptCooldown: true)
}
Issue.record("Expected delegated refresh on explicit user action")
} catch let error as ClaudeOAuthCredentialsError {
guard case .refreshDelegatedToClaudeCLI = error else {
Issue.record("Expected .refreshDelegatedToClaudeCLI, got \(error)")
return
}
} catch {
Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)")
}
}
}
})
}
}
}
}
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() {}
}