Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ extension StatusItemController {
self.loginLogger.info("Claude login", metadata: ["outcome": outcome, "length": "\(length)"])
if case .success = result.outcome {
let metadata = self.store.metadata(for: .claude)
self.settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true)
if self.settings.claudeUsageDataSource == .web {
self.settings.claudeUsageDataSource = .oauth

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep enabling Claude after a successful login

When Claude is disabled—the setup used by both login-flow tests and a normal sign-in onboarding path—replacing the unconditional setProviderEnabled(..., enabled: true) call with this source-only update leaves ProviderConfig.enabled false. Changing claudeUsageDataSource does not alter enablement, so the flow reports success and posts a notification while Claude remains hidden and is not refreshed; the new isProviderEnabledCached assertions consequently fail. Restore provider enablement independently of the selected source.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve a CLI fallback when OAuth reads are disabled

When “Disable Keychain access” or the no-prompt OAuth policy is enabled, claude auth login --claudeai can still succeed because it runs as an external CLI process, but this assignment then forces the provider into strict OAuth mode. ClaudeOAuthFetchStrategy cannot obtain the newly stored Keychain credential in that configuration, and its shouldFallback implementation only permits the working CLI strategy in .auto mode, so the refresh immediately fails despite the successful login. Keep a CLI fallback for this case rather than unconditionally selecting .oauth.

Useful? React with 👍 / 👎.

}
self.postLoginNotification(for: .claude)
return true
}
Expand Down
153 changes: 153 additions & 0 deletions Tests/CodexBarTests/ClaudeIssue2403ReproductionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import CodexBarCore
import Foundation
import Testing
@testable import CodexBar

@MainActor
@Suite(.serialized)
struct ClaudeIssue2403ReproductionTests {
private func makeSettings(suiteSuffix: String) -> SettingsStore {
let suite = "ClaudeIssue2403ReproductionTests-\(suiteSuffix)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
return SettingsStore(
userDefaults: defaults,
configStore: testConfigStore(suiteName: suite),
zaiTokenStore: NoopZaiTokenStore(),
syntheticTokenStore: NoopSyntheticTokenStore())
}

@Test
func `web refresh failure disconnects after login`() async throws {
let registry = ProviderRegistry.shared
let claudeMetadata = try #require(registry.metadata[.claude])

let settings = self.makeSettings(suiteSuffix: "web-unauthorized")
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.providerDetectionCompleted = true
settings.claudeUsageDataSource = .web
settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: false)

let fetcher = UsageFetcher()
let store = UsageStore(
fetcher: fetcher,
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings)

// 1. Simulate user clicking "Sign in with Claude Code..." which runs login runner and succeeds
await withStatusItemControllerForTesting(store: store, settings: settings, fetcher: fetcher) { controller in
let didLogin = await controller.runClaudeLoginFlow { _, onPhaseChange in
onPhaseChange(.requesting)
await Task.yield()
onPhaseChange(.waitingBrowser)
await Task.yield()
return ClaudeLoginRunner.Result(
outcome: .success,
output: "Successfully logged in",
authLink: nil)
}

#expect(didLogin)
#expect(settings.isProviderEnabledCached(provider: .claude, metadataByProvider: registry.metadata))
}

// 2. Immediately after login, store refreshes provider.
// If web API is unauthorized (e.g. browser not logged in), web fetch fails with unauthorized
store.errors[.claude] = ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription

// 3. Verify menu actions immediately revert to re-login / disconnect state
let actions = MenuDescriptor.build(
provider: .claude,
store: store,
settings: settings,
account: fetcher.loadAccountInfo(),
updateReady: false)
.sections
.flatMap(\.entries)
.compactMap { entry -> (String, MenuDescriptor.MenuAction)? in
guard case let .action(label, action) = entry else { return nil }
return (label, action)
}

#expect(actions.contains {
$0.0 == "Re-login at claude.ai" && $0.1 == .loginToProvider(url: "https://claude.ai/")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop expecting a web re-login action after switching to OAuth

After the successful flow changes this test's source from .web to .oauth, shouldOpenBrowserForWebSessionError rejects the source because it only accepts .auto or .web. Injecting a web unauthorized error therefore cannot produce the asserted “Re-login at claude.ai” action, so this test will fail once the earlier enablement assertion is repaired and does not validate the new routing behavior; assert the OAuth behavior or verify that the obsolete web action is absent instead.

Useful? React with 👍 / 👎.

})
}

@Test
func `login flow succeeds but stale token account causes immediate refresh failure and disconnect`() async throws {
let registry = ProviderRegistry.shared
let claudeMetadata = try #require(registry.metadata[.claude])

let settings = self.makeSettings(suiteSuffix: "token-account-stale")
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.providerDetectionCompleted = true
settings.claudeUsageDataSource = .auto

// User previously configured a stale/invalid token account
settings.addTokenAccount(provider: .claude, label: "Old Account", token: "invalid-cookie-session-token")
settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: false)

let fetcher = UsageFetcher()
let store = UsageStore(
fetcher: fetcher,
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings)

// 1. User signs in with Claude Code CLI
await withStatusItemControllerForTesting(store: store, settings: settings, fetcher: fetcher) { controller in
let didLogin = await controller.runClaudeLoginFlow { _, onPhaseChange in
onPhaseChange(.requesting)
await Task.yield()
return ClaudeLoginRunner.Result(
outcome: .success,
output: "Logged in successfully",
authLink: nil)
}

#expect(didLogin)
}

// 2. Token account override causes fetch to fail on refresh
store.errors[.claude] = "Claude session key invalid"

withStatusItemControllerForTesting(store: store, settings: settings, fetcher: fetcher) { controller in
let model = controller.menuCardModel(for: .claude)
#expect(model?.subtitleStyle == .error)
#expect(model?.subtitleText == "Claude session key invalid")
}
}

@Test
func `oauth unauthorized error after login forces terminal re-auth prompt`() {
let settings = self.makeSettings(suiteSuffix: "oauth-unauthorized")
settings.claudeUsageDataSource = .oauth

let fetcher = UsageFetcher()
let store = UsageStore(
fetcher: fetcher,
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings)

store.errors[.claude] = ClaudeOAuthFetchError.unauthorized.localizedDescription

let actions = MenuDescriptor.build(
provider: .claude,
store: store,
settings: settings,
account: fetcher.loadAccountInfo(),
updateReady: false)
.sections
.flatMap(\.entries)
.compactMap { entry -> (String, MenuDescriptor.MenuAction)? in
guard case let .action(label, action) = entry else { return nil }
return (label, action)
}

#expect(actions.contains {
$0.0 == "Open Terminal" && $0.1 == .openTerminal(command: "claude")
})
}
}
3 changes: 2 additions & 1 deletion Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ struct ClaudeLoginFlowTests {
#expect(controller.loginPhase == .idle)
}

#expect(settings.claudeUsageDataSource == source)
let expectedSource: ClaudeUsageDataSource = (source == .web) ? .oauth : source
#expect(settings.claudeUsageDataSource == expectedSource)
#expect(settings.isProviderEnabledCached(provider: .claude, metadataByProvider: registry.metadata))
}
}
Expand Down
Loading