Repository navigation
Surface auth sign-in errors in Settings (#3617) #3621
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| import CMUXAuthCore | ||
| import StackAuth | ||
| import XCTest | ||
|
|
||
| #if canImport(cmux_DEV) | ||
| @testable import cmux_DEV | ||
| #elseif canImport(cmux) | ||
| @testable import cmux | ||
| #endif | ||
|
|
||
| /// Regression coverage for #3617: failed sign-in callbacks were silently | ||
| /// `NSLog`-ed and never surfaced to the UI. The fix exposes the failure as a | ||
| /// published `lastSignInError`. Per AGENTS.md regression test policy this | ||
| /// failing test ships in its own commit so CI proves it catches the bug. | ||
| @MainActor | ||
| final class AuthManagerSignInErrorVisibilityTests: XCTestCase { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SwiftLint Both 🔧 Proposed fix `@MainActor`
final class AuthManagerSignInErrorVisibilityTests: XCTestCase {
+ deinit {}
func testInvalidCallbackURLPublishesLastSignInError() async throws { private final class NoopAuthTestClient: AuthClientProtocol {
+ deinit {}
func currentUser() async throws -> CMUXAuthUser? { nil }Also applies to: 53-53 🧰 Tools🪛 SwiftLint (0.63.2)[Warning] 16-16: Classes should have an explicit deinit method (required_deinit) 🤖 Prompt for AI Agents |
||
| func testInvalidCallbackURLPublishesLastSignInError() async throws { | ||
| let manager = AuthManager( | ||
| client: NoopAuthTestClient(), | ||
| tokenStore: InMemoryTestTokenStore() | ||
| ) | ||
| await manager.awaitBootstrapped() | ||
|
|
||
| XCTAssertNil( | ||
| manager.lastSignInError, | ||
| "AuthManager should not surface an error before any sign-in attempt" | ||
| ) | ||
|
|
||
| // No `stack_refresh` / `stack_access` query items: this is the exact | ||
| // shape that makes `AuthCallbackRouter.callbackPayload` return nil and | ||
| // forces `handleCallbackURL` to throw `.invalidCallback`. Do not | ||
| // "fix" the URL — that would defeat this regression. | ||
| let badCallbackURL = URL(string: "cmux://auth-callback")! | ||
|
|
||
| do { | ||
| try await manager.handleCallbackURL(badCallbackURL) | ||
| XCTFail("Expected handleCallbackURL to throw AuthManagerError.invalidCallback") | ||
| } catch AuthManagerError.invalidCallback { | ||
| } catch let error as AuthManagerError { | ||
| XCTFail("Expected .invalidCallback, got AuthManagerError.\(error)") | ||
| } catch { | ||
| XCTFail("Expected AuthManagerError, got \(type(of: error)): \(error)") | ||
| } | ||
|
|
||
| XCTAssertEqual( | ||
| manager.lastSignInError, | ||
| AuthManagerError.invalidCallback, | ||
| "After a failed sign-in callback, AuthManager.lastSignInError must be populated so AuthSettingsRow can render it" | ||
| ) | ||
| } | ||
|
|
||
| func testSignOutClearsStaleLastSignInError() async throws { | ||
| let manager = AuthManager( | ||
| client: NoopAuthTestClient(), | ||
| tokenStore: InMemoryTestTokenStore() | ||
| ) | ||
| await manager.awaitBootstrapped() | ||
|
|
||
| // Stale-error invariant: a prior failed sign-in leaves .invalidCallback | ||
| // published; signOut → clearSessionState must clear it. | ||
| do { | ||
| try await manager.handleCallbackURL(URL(string: "cmux://auth-callback")!) | ||
| XCTFail("Expected handleCallbackURL to throw") | ||
| } catch { | ||
| } | ||
| XCTAssertEqual(manager.lastSignInError, .invalidCallback) | ||
|
|
||
| await manager.signOut() | ||
|
|
||
| XCTAssertNil( | ||
| manager.lastSignInError, | ||
| "signOut() routes through clearSessionState which must clear lastSignInError so it doesn't survive across auth-state transitions" | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Test doubles | ||
|
|
||
| private final class NoopAuthTestClient: AuthClientProtocol { | ||
| func currentUser() async throws -> CMUXAuthUser? { nil } | ||
| func listTeams() async throws -> [AuthTeamSummary] { [] } | ||
| } | ||
|
|
||
| private final actor InMemoryTestTokenStore: StackAuthTokenStoreProtocol { | ||
| private var accessToken: String? | ||
| private var refreshToken: String? | ||
|
|
||
| func getStoredAccessToken() async -> String? { accessToken } | ||
| func getStoredRefreshToken() async -> String? { refreshToken } | ||
|
|
||
| func setTokens(accessToken: String?, refreshToken: String?) async { | ||
| self.accessToken = accessToken | ||
| self.refreshToken = refreshToken | ||
| } | ||
|
|
||
| func clearTokens() async { | ||
| accessToken = nil | ||
| refreshToken = nil | ||
| } | ||
|
|
||
| func compareAndSet( | ||
| compareRefreshToken: String, | ||
| newRefreshToken: String?, | ||
| newAccessToken: String? | ||
| ) async { | ||
| if refreshToken == compareRefreshToken { | ||
| refreshToken = newRefreshToken | ||
| accessToken = newAccessToken | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Publishedstate onObservableObject— thecmux-swiftui-state-layoutandcmux-swift-concurrency-modernizationrules flag new@Publishedproperties for new state when@Observableis available.AuthManageris alreadyObservableObjectso this extends existing debt, but each new@Publishedwidens the invalidation surface: every subscriber re-renders on everylastSignInErrorwrite. MigratingAuthManagerto@Observableis the correct long-term fix.Rule Used: Flag SwiftUI changes that can cause stale state, b... (source)