Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
10 changes: 7 additions & 3 deletions Xcodes/Backend/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,10 @@ class AppState: ObservableObject {
} catch is CancellationError {
throw CancellationError()
} catch {
// A transport failure does not mean the session has expired.
guard error as? AuthenticationError == .invalidSession || Self.isUnauthorizedInstallError(error) else {
throw error
}
guard
let username = savedUsername,
let password = try? Current.keychain.getString(username)
Expand Down Expand Up @@ -425,8 +429,8 @@ class AppState: ObservableObject {
}

private func handleAuthenticationFlowFailure(_ error: Error) {
// remove saved username and any stored keychain password if authentication fails so it doesn't try again.
clearLoginCredentials()
// Keep saved credentials available for retry after transient failures.
// Explicit sign-out owns removal of the saved account and password.
Logger.appState.error("Authentication error: \(error.legibleDescription)")
self.authError = error
}
Expand Down Expand Up @@ -462,7 +466,7 @@ class AppState: ObservableObject {
handleAuthenticationFlowSuccess()
return authenticationState
} catch {
if authenticationRequestID == requestID {
if !(error is CancellationError), authenticationRequestID == requestID {
handleAuthenticationFlowFailure(error)
}
throw error
Expand Down
36 changes: 36 additions & 0 deletions XcodesTests/AppStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,42 @@ class AppStateTests: XCTestCase {
XCTAssertEqual(subject.authenticationState, expectedState)
}

func test_RestoreAuthenticationStateIfNeeded_NetworkFailureDoesNotRetryLogin() async throws {
let didReadPassword = TestLockedBox(false)
Current.defaults.string = { _ in "jane@example.com" }
Current.keychain.getString = { _ in
didReadPassword.withValue { $0 = true }
return "password"
}
Current.network.validateSessionAsync = { throw URLError(.notConnectedToInternet) }

do {
try await subject.restoreAuthenticationStateIfNeeded()
XCTFail("Expected the network error")
} catch {
XCTAssertEqual((error as? URLError)?.code, .notConnectedToInternet)
}

XCTAssertFalse(didReadPassword.read { $0 })
XCTAssertNil(subject.authError)
}

func test_SignIn_NetworkFailurePreservesSavedCredentials() async throws {
let removedCredentials = TestLockedBox<[String]>([])
Current.keychain.remove = { key in removedCredentials.withValue { $0.append(key) } }
Current.defaults.removeObject = { key in removedCredentials.withValue { $0.append(key) } }
Current.network.session = MockURLProtocol.session { _ in throw URLError(.timedOut) }

do {
_ = try await subject.signInAsync(username: "jane@example.com", password: "password")
XCTFail("Expected authentication to fail")
} catch {
XCTAssertNotNil(subject.authError)
}

XCTAssertTrue(removedCredentials.read { $0.isEmpty })
}

func test_RestoreAuthenticationStateIfNeeded_SkipsValidationWithoutSavedUsername() async throws {
let didValidate = TestLockedBox(false)
Current.network.validateSessionAsync = {
Expand Down