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

472 lines
20 KiB
Swift

import Foundation
import Testing
@testable import CodexBarCore
#if os(macOS)
@Suite(.serialized)
struct ClaudeOAuthFetchStrategyAvailabilityTests {
private struct StubClaudeFetcher: ClaudeUsageFetching {
func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot {
throw ClaudeUsageError.parseFailed("stub")
}
func debugRawProbe(model _: String) async -> String {
"stub"
}
func detectVersion() -> String? {
nil
}
}
private func makeContext(
sourceMode: ProviderSourceMode,
env: [String: String] = [:]) -> ProviderFetchContext
{
ProviderFetchContext(
runtime: .app,
sourceMode: sourceMode,
includeCredits: false,
webTimeout: 1,
webDebugDumpHTML: false,
verbose: false,
env: env,
settings: nil,
fetcher: UsageFetcher(environment: env),
claudeFetcher: StubClaudeFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0))
}
private func expiredRecord(owner: ClaudeOAuthCredentialOwner = .claudeCLI) -> ClaudeOAuthCredentialRecord {
ClaudeOAuthCredentialRecord(
credentials: ClaudeOAuthCredentials(
accessToken: "expired-token",
refreshToken: "refresh-token",
expiresAt: Date(timeIntervalSinceNow: -60),
scopes: ["user:profile"],
rateLimitTier: nil),
owner: owner,
source: .cacheKeychain)
}
@Test
func `auto mode expired creds cli available returns available`() async {
let context = self.makeContext(sourceMode: .auto)
let strategy = ClaudeOAuthFetchStrategy()
let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride
.withValue(self.expiredRecord()) {
await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(true) {
await strategy.isAvailable(context)
}
}
#expect(available == true)
}
@Test
func `auto mode expired CLI creds with MCP-only keychain returns unavailable in background`() async {
let available = await self.expiredCLIAvailability(
sourceMode: .auto,
interaction: .background,
keychainData: self.mcpOAuthOnlyKeychainPayload)
#expect(!available)
}
@Test
func `auto mode expired CLI creds with MCP-only keychain remains available for user action`() async {
let available = await self.expiredCLIAvailability(
sourceMode: .auto,
interaction: .userInitiated,
keychainData: self.mcpOAuthOnlyKeychainPayload)
#expect(available)
}
@Test
func `explicit O auth keeps expired CLI credentials available with MCP-only keychain`() async {
let available = await self.expiredCLIAvailability(
sourceMode: .oauth,
interaction: .background,
keychainData: self.mcpOAuthOnlyKeychainPayload)
#expect(available)
}
@Test
func `auto mode keeps expired CLI credentials available with ordinary O auth keychain`() async {
let available = await self.expiredCLIAvailability(
sourceMode: .auto,
interaction: .background,
keychainData: self.ordinaryOAuthKeychainPayload)
#expect(available)
}
@Test
func `auto mode ignores MCP-only keychain when keychain access is disabled`() async {
let available = await self.expiredCLIAvailability(
sourceMode: .auto,
interaction: .background,
keychainData: self.mcpOAuthOnlyKeychainPayload,
keychainAccessDisabled: true)
#expect(available)
}
@Test
func `auto mode expired creds cli unavailable returns unavailable`() async {
let context = self.makeContext(sourceMode: .auto)
let strategy = ClaudeOAuthFetchStrategy()
let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride
.withValue(self.expiredRecord()) {
await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(false) {
await strategy.isAvailable(context)
}
}
#expect(available == false)
}
@Test
func `oauth mode expired creds cli available returns available`() async {
let context = self.makeContext(sourceMode: .oauth)
let strategy = ClaudeOAuthFetchStrategy()
let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride
.withValue(self.expiredRecord()) {
await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(true) {
await strategy.isAvailable(context)
}
}
#expect(available == true)
}
@Test
func `auto mode expired codexbar creds cli unavailable still available`() async {
let context = self.makeContext(sourceMode: .auto)
let strategy = ClaudeOAuthFetchStrategy()
let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride
.withValue(self.expiredRecord(owner: .codexbar)) {
await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(false) {
await strategy.isAvailable(context)
}
}
#expect(available == true)
}
@Test
func `oauth mode does not fallback after O auth failure`() {
let context = self.makeContext(sourceMode: .oauth)
let strategy = ClaudeOAuthFetchStrategy()
#expect(strategy.shouldFallback(
on: ClaudeUsageError.oauthFailed("oauth failed"),
context: context) == false)
}
@Test
func `auto mode falls back after O auth failure`() {
let context = self.makeContext(sourceMode: .auto)
let strategy = ClaudeOAuthFetchStrategy()
#expect(strategy.shouldFallback(
on: ClaudeUsageError.oauthFailed("oauth failed"),
context: context) == true)
}
@Test
func `auto mode user initiated clears keychain cooldown gate`() async {
let context = self.makeContext(sourceMode: .auto)
let strategy = ClaudeOAuthFetchStrategy()
let recordWithoutRequiredScope = ClaudeOAuthCredentialRecord(
credentials: ClaudeOAuthCredentials(
accessToken: "expired-token",
refreshToken: "refresh-token",
expiresAt: Date(timeIntervalSinceNow: -60),
scopes: ["user:inference"],
rateLimitTier: nil),
owner: .claudeCLI,
source: .cacheKeychain)
await KeychainAccessGate.withTaskOverrideForTesting(false) {
ClaudeOAuthKeychainAccessGate.resetForTesting()
defer { ClaudeOAuthKeychainAccessGate.resetForTesting() }
let now = Date(timeIntervalSince1970: 1000)
ClaudeOAuthKeychainAccessGate.recordDenied(now: now)
#expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now) == false)
_ = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride
.withValue(recordWithoutRequiredScope) {
await ProviderInteractionContext.$current.withValue(.userInitiated) {
await self.withAvailabilityKeychainDoubles {
await strategy.isAvailable(context)
}
}
}
#expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now))
}
}
@Test
func `auto mode only on user action background startup without cache is available for bootstrap`() async throws {
let context = self.makeContext(sourceMode: .auto)
let strategy = ClaudeOAuthFetchStrategy()
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
try await KeychainCacheStore.withServiceOverrideForTesting(service) {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
ClaudeOAuthCredentialsStore.invalidateCache()
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
ClaudeOAuthKeychainAccessGate.resetForTesting()
defer {
ClaudeOAuthCredentialsStore.invalidateCache()
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
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")
let available = await KeychainAccessGate.withTaskOverrideForTesting(false) {
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) {
await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
await self.withAvailabilityKeychainDoubles {
await ProviderRefreshContext.$current.withValue(.startup) {
await ProviderInteractionContext.$current.withValue(.background) {
await strategy.isAvailable(context)
}
}
}
}
}
}
}
#expect(available == true)
}
}
}
@Test
func `auto mode expired Claude CLI creds env provided CLI override returns available`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let cliURL = tempDir.appendingPathComponent("claude")
try Data("#!/bin/sh\nexit 0\n".utf8).write(to: cliURL)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cliURL.path)
let context = self.makeContext(
sourceMode: .auto,
env: ["CLAUDE_CLI_PATH": cliURL.path])
let strategy = ClaudeOAuthFetchStrategy()
let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride
.withValue(self.expiredRecord()) {
await strategy.isAvailable(context)
}
#expect(available == true)
}
@Test
func `auto mode default reader keeps background startup bootstrap available`() async throws {
let context = self.makeContext(sourceMode: .auto)
let strategy = ClaudeOAuthFetchStrategy()
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
try await KeychainCacheStore.withServiceOverrideForTesting(service) {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
ClaudeOAuthCredentialsStore.invalidateCache()
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
ClaudeOAuthKeychainAccessGate.resetForTesting()
defer {
ClaudeOAuthCredentialsStore.invalidateCache()
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
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")
let available = await KeychainAccessGate.withTaskOverrideForTesting(false) {
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.nonZeroExit) {
await self.withAvailabilityKeychainDoubles {
await ProviderRefreshContext.$current.withValue(.startup) {
await ProviderInteractionContext.$current.withValue(.background) {
await strategy.isAvailable(context)
}
}
}
}
}
}
}
#expect(available == true)
}
}
}
@Test
func `auto mode experimental reader ignores prompt policy cooldown gate`() async {
let context = self.makeContext(sourceMode: .auto)
let strategy = ClaudeOAuthFetchStrategy()
let securityData = Data("""
{
"claudeAiOauth": {
"accessToken": "security-token",
"expiresAt": \(Int(Date(timeIntervalSinceNow: 3600).timeIntervalSince1970 * 1000)),
"scopes": ["user:profile"]
}
}
""".utf8)
let recordWithoutRequiredScope = ClaudeOAuthCredentialRecord(
credentials: ClaudeOAuthCredentials(
accessToken: "token-no-scope",
refreshToken: "refresh-token",
expiresAt: Date(timeIntervalSinceNow: -60),
scopes: ["user:inference"],
rateLimitTier: nil),
owner: .claudeCLI,
source: .cacheKeychain)
let available = await KeychainAccessGate.withTaskOverrideForTesting(false) {
await ClaudeOAuthKeychainAccessGate.withShouldAllowPromptOverrideForTesting(false) {
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
.securityCLIExperimental)
{
await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride.withValue(
recordWithoutRequiredScope)
{
await ProviderInteractionContext.$current.withValue(.background) {
await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(
securityData))
{
await strategy.isAvailable(context)
}
}
}
}
}
}
}
#expect(available == true)
}
@Test
func `auto mode experimental reader security failure blocks availability when stored policy blocks fallback`()
async
{
let context = self.makeContext(sourceMode: .auto)
let strategy = ClaudeOAuthFetchStrategy()
let fallbackData = Data("""
{
"claudeAiOauth": {
"accessToken": "fallback-token",
"expiresAt": \(Int(Date(timeIntervalSinceNow: 3600).timeIntervalSince1970 * 1000)),
"scopes": ["user:profile"]
}
}
""".utf8)
let recordWithoutRequiredScope = ClaudeOAuthCredentialRecord(
credentials: ClaudeOAuthCredentials(
accessToken: "token-no-scope",
refreshToken: "refresh-token",
expiresAt: Date(timeIntervalSinceNow: -60),
scopes: ["user:inference"],
rateLimitTier: nil),
owner: .claudeCLI,
source: .cacheKeychain)
let available = await KeychainAccessGate.withTaskOverrideForTesting(false) {
await ClaudeOAuthKeychainAccessGate.withShouldAllowPromptOverrideForTesting(true) {
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
.securityCLIExperimental)
{
await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride.withValue(
recordWithoutRequiredScope)
{
await ProviderInteractionContext.$current.withValue(.background) {
await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
data: fallbackData,
fingerprint: nil)
{
await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
.nonZeroExit)
{
await strategy.isAvailable(context)
}
}
}
}
}
}
}
}
#expect(available == false)
}
private func withAvailabilityKeychainDoubles<T>(
operation: () async throws -> T) async rethrows -> T
{
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
return try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(
true,
operation: operation)
}
private var mcpOAuthOnlyKeychainPayload: Data {
Data(#"{"mcpOAuth":{"plugin:test":{"accessToken":"fixture"}}}"#.utf8)
}
private var ordinaryOAuthKeychainPayload: Data {
Data(#"{"claudeAiOauth":{"accessToken":"fixture"}}"#.utf8)
}
private func expiredCLIAvailability(
sourceMode: ProviderSourceMode,
interaction: ProviderInteraction,
keychainData: Data,
keychainAccessDisabled: Bool = false) async -> Bool
{
let context = self.makeContext(sourceMode: sourceMode)
let strategy = ClaudeOAuthFetchStrategy()
return await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride
.withValue(self.expiredRecord()) {
await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(true) {
await KeychainAccessGate.withTaskOverrideForTesting(keychainAccessDisabled) {
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) {
await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
data: keychainData,
fingerprint: nil)
{
await ProviderInteractionContext.$current.withValue(interaction) {
await strategy.isAvailable(context)
}
}
}
}
}
}
}
}
#endif