From 1f152b7b9f70893701021623559e2fe060c9990f Mon Sep 17 00:00:00 2001 From: zunda Date: Sat, 31 Oct 2026 11:57:30 +0900 Subject: [PATCH] Preserve Apple account credentials after transient authentication failures --- Xcodes/Backend/AppState.swift | 10 ++++++--- XcodesTests/AppStateTests.swift | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 6ac509bb..17e9b075 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -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) @@ -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 } @@ -462,7 +466,7 @@ class AppState: ObservableObject { handleAuthenticationFlowSuccess() return authenticationState } catch { - if authenticationRequestID == requestID { + if !(error is CancellationError), authenticationRequestID == requestID { handleAuthenticationFlowFailure(error) } throw error diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index 2945b06c..bdecc1bc 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -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 = {