diff --git a/Modules/CommonsLib/Sources/CommonsLib/Constants.swift b/Modules/CommonsLib/Sources/CommonsLib/Constants.swift index e6ea5d41..c8bcd68a 100644 --- a/Modules/CommonsLib/Sources/CommonsLib/Constants.swift +++ b/Modules/CommonsLib/Sources/CommonsLib/Constants.swift @@ -152,6 +152,7 @@ public struct Constants { public static let HashType = "SHA256" public static let Timeout = 120 // Seconds public static let DefaultTimeout = 5 // Seconds + public static let MaxPollRetries = 3 } public struct CryptoDefaultValues { diff --git a/Modules/SmartIdLib/Sources/SmartIdLib/Error/SmartIdError.swift b/Modules/SmartIdLib/Sources/SmartIdLib/Error/SmartIdError.swift index d3fa7162..6d278256 100644 --- a/Modules/SmartIdLib/Sources/SmartIdLib/Error/SmartIdError.swift +++ b/Modules/SmartIdLib/Sources/SmartIdLib/Error/SmartIdError.swift @@ -43,4 +43,5 @@ public enum SmartIdError: Error { case noResponse case invalidSslHandshake case technicalError + case requestInterrupted } diff --git a/Modules/SmartIdLib/Sources/SmartIdLib/Model/Response/SmartIdSessionResponse.swift b/Modules/SmartIdLib/Sources/SmartIdLib/Model/Response/SmartIdSessionResponse.swift index 798dabf0..7ee61711 100644 --- a/Modules/SmartIdLib/Sources/SmartIdLib/Model/Response/SmartIdSessionResponse.swift +++ b/Modules/SmartIdLib/Sources/SmartIdLib/Model/Response/SmartIdSessionResponse.swift @@ -137,4 +137,10 @@ public enum SmartIdSessionStatusResponseCode: String, Sendable, Decodable { case timeout = "TIMEOUT" case documentUnusable = "DOCUMENT_UNUSABLE" case wrongVc = "WRONG_VC" + case unknown = "UNKNOWN" + + public init(from decoder: any Decoder) throws { + let rawValue = try decoder.singleValueContainer().decode(String.self) + self = SmartIdSessionStatusResponseCode(rawValue: rawValue) ?? .unknown + } } diff --git a/Modules/SmartIdLib/Sources/SmartIdLib/Networking/Handler/ResponseHandler.swift b/Modules/SmartIdLib/Sources/SmartIdLib/Networking/Handler/ResponseHandler.swift index 3ea5f91e..5b351066 100644 --- a/Modules/SmartIdLib/Sources/SmartIdLib/Networking/Handler/ResponseHandler.swift +++ b/Modules/SmartIdLib/Sources/SmartIdLib/Networking/Handler/ResponseHandler.swift @@ -44,6 +44,7 @@ struct ResponseHandler: ResponseHandlerProtocol { case .wrongVc: throw SmartIdError.wrongVC case .documentUnusable: throw SmartIdError.documentUnusable case .requiredInteractionNotSupportedByApp: throw SmartIdError.oldApi + case .unknown: throw SmartIdError.technicalError default: break } } @@ -69,17 +70,21 @@ struct ResponseHandler: ResponseHandlerProtocol { func handleURLError(_ error: URLError) throws { switch error.code { - case .notConnectedToInternet, .networkConnectionLost: + case .notConnectedToInternet: throw SmartIdError.noInternetConnection - case .timedOut: - throw SmartIdError.timeout + case .networkConnectionLost, .timedOut: + throw SmartIdError.requestInterrupted default: throw SmartIdError.noInternetConnection } } func handleStatusCodeError(_ statusCode: Int?) throws { - switch statusCode ?? -1 { + guard let statusCode else { + throw SmartIdError.requestInterrupted + } + + switch statusCode { case 400: throw SmartIdError.incorrectParameters case 401: diff --git a/Modules/SmartIdLib/Sources/SmartIdLib/Networking/Request/RequestPerformer.swift b/Modules/SmartIdLib/Sources/SmartIdLib/Networking/Request/RequestPerformer.swift index 6cca51de..e198bd4a 100644 --- a/Modules/SmartIdLib/Sources/SmartIdLib/Networking/Request/RequestPerformer.swift +++ b/Modules/SmartIdLib/Sources/SmartIdLib/Networking/Request/RequestPerformer.swift @@ -89,11 +89,6 @@ struct RequestPerformer: RequestPerfomerProtocol, Loggable { } } catch { - Task { @MainActor in - continuation.resume(throwing: SmartIdError.timeout) - return - } - continuation.resume(with: Result { try responseHandler.handleCancellationError(error) guard let smartIdError = error as? SmartIdError else { diff --git a/Modules/SmartIdLib/Sources/SmartIdLib/Service/SmartIdSignService.swift b/Modules/SmartIdLib/Sources/SmartIdLib/Service/SmartIdSignService.swift index 450f7bc0..7e876786 100644 --- a/Modules/SmartIdLib/Sources/SmartIdLib/Service/SmartIdSignService.swift +++ b/Modules/SmartIdLib/Sources/SmartIdLib/Service/SmartIdSignService.swift @@ -108,16 +108,42 @@ public actor SmartIdSignService: SmartIdSignServiceProtocol, Loggable { userAgent: String ) async throws -> SmartIdSessionResponse { let pollingTimeoutMs = pollingTimeout * 1000 + var retriedPolls = 0 + var hasPolledSuccessfully = false while true { - let sessionResponse: SmartIdSessionResponse? = try await requestPerformer.performRequest( - url: "\(url)/\(sessionId)", - method: .get, - parameters: ["timeoutMs": pollingTimeoutMs], - trustedCertificates: trustedCertificates, - proxyInfo: proxyInfo, - userAgent: userAgent - ) + try Task.checkCancellation() + + let sessionResponse: SmartIdSessionResponse? + + do { + sessionResponse = try await requestPerformer.performRequest( + url: "\(url)/\(sessionId)", + method: .get, + parameters: ["timeoutMs": pollingTimeoutMs], + trustedCertificates: trustedCertificates, + proxyInfo: proxyInfo, + userAgent: userAgent + ) + } catch let error as SmartIdError where Self.isRetryablePollError(error) { + retriedPolls += 1 + + guard retriedPolls <= Constants.Signing.MaxPollRetries else { + guard hasPolledSuccessfully, error == .underMaintenance else { + throw error + } + + throw SmartIdError.timeout + } + + if error == .underMaintenance, !hasPolledSuccessfully { + try await Task.sleep(for: .seconds(Double(pollingTimeout))) + } + continue + } + + retriedPolls = 0 + hasPolledSuccessfully = true if let response = sessionResponse, response.state == .complete { @@ -128,6 +154,10 @@ public actor SmartIdSignService: SmartIdSignServiceProtocol, Loggable { } } + private static func isRetryablePollError(_ error: SmartIdError) -> Bool { + error == .requestInterrupted || error == .underMaintenance + } + public func getVerificationCode(digest: Data) async -> String { let code = UInt16(digest[digest.count - 2]) << 8 | UInt16(digest[digest.count - 1]) return String(format: "%04d", (code % 10000)) diff --git a/Modules/SmartIdLib/Tests/SmartIdLibTests/Networking/Handler/ResponseHandlerTests.swift b/Modules/SmartIdLib/Tests/SmartIdLibTests/Networking/Handler/ResponseHandlerTests.swift index fc5784a6..bf58eaaa 100644 --- a/Modules/SmartIdLib/Tests/SmartIdLibTests/Networking/Handler/ResponseHandlerTests.swift +++ b/Modules/SmartIdLib/Tests/SmartIdLibTests/Networking/Handler/ResponseHandlerTests.swift @@ -169,12 +169,25 @@ struct ResponseHandlerTests { } } + @Test( + "handleNetworkError_throwsRequestInterruptedWhenNoAnswerReachedTheApp", + arguments: [URLError.Code.timedOut, .networkConnectionLost] + ) + func handleNetworkError_throwsRequestInterruptedWhenNoAnswerReachedTheApp( + code: URLError.Code + ) { + let afError = AFError.sessionTaskFailed(error: URLError(code)) + + #expect(throws: SmartIdError.requestInterrupted) { + try handler.handleNetworkError(afError, statusCode: nil) + } + } + @Test - func handleNetworkError_throwsTimeoutWhenSessionTaskFailedErrorWithTimeout() { - let urlError = URLError(.timedOut) - let afError = AFError.sessionTaskFailed(error: urlError) + func handleNetworkError_throwsRequestInterruptedWhenNoUrlErrorAndNoStatusCode() { + let afError = AFError.serverTrustEvaluationFailed(reason: .noRequiredEvaluator(host: "host.test")) - #expect(throws: SmartIdError.timeout) { + #expect(throws: SmartIdError.requestInterrupted) { try handler.handleNetworkError(afError, statusCode: nil) } } @@ -197,6 +210,26 @@ struct ResponseHandlerTests { } } + @Test + func handleSessionResult_throwsTechnicalErrorWhenEndResultIsUnknown() { + #expect(throws: SmartIdError.technicalError) { + try handler.handleSessionResult(.unknown) + } + } + + @Test + func decodingSessionResponse_mapsUnlistedEndResultToUnknown() throws { + let json = Data( + """ + {"state":"COMPLETE","result":{"endResult":"SERVER_ERROR"}} + """.utf8 + ) + + let response = try JSONDecoder().decode(SmartIdSessionResponse.self, from: json) + + #expect(response.result?.endResult == .unknown) + } + @Test func handleNetworkError_throwsTechnicalErrorWhenUnacceptableStatusCode999Returned() { let afError = AFError.responseValidationFailed(reason: .unacceptableStatusCode(code: 999)) diff --git a/Modules/SmartIdLib/Tests/SmartIdLibTests/Service/SmartIdSignServiceTests.swift b/Modules/SmartIdLib/Tests/SmartIdLibTests/Service/SmartIdSignServiceTests.swift index 1640b4ec..d562c1ef 100644 --- a/Modules/SmartIdLib/Tests/SmartIdLibTests/Service/SmartIdSignServiceTests.swift +++ b/Modules/SmartIdLib/Tests/SmartIdLibTests/Service/SmartIdSignServiceTests.swift @@ -123,6 +123,222 @@ struct SmartIdSignServiceTests { #expect(result.state == .complete) } + @Test + func getSessionRequest_returnsResponseWhenLaterPollCompletes() async throws { + let running = SmartIdSessionResponse(state: .running, result: nil, signature: nil, cert: nil) + let complete = SmartIdSessionResponse(state: .complete, result: nil, signature: nil, cert: nil) + + requestPerformer.performRequestHandler = { [requestPerformer] _, _, _, _, _, _ in + requestPerformer.performRequestCallCount == 1 ? running : complete + } + + let result = try await service.getSessionRequest( + url: url, + sessionId: "SESSION", + pollingTimeout: 1, + trustedCertificates: certificates, + proxyInfo: proxy, + userAgent: "TestUserAgent" + ) + + #expect(result.state == .complete) + #expect(requestPerformer.performRequestCallCount == 2) + } + + @Test + func getSessionRequest_retriesImmediatelyOnMaintenanceOnceSessionHasAnswered() async throws { + requestPerformer.performRequestHandler = { [requestPerformer] _, _, _, _, _, _ in + if requestPerformer.performRequestCallCount == 1 { + return SmartIdSessionResponse(state: .running, result: nil, signature: nil, cert: nil) + } + throw SmartIdError.underMaintenance + } + + let elapsed = try await ContinuousClock().measure { + await #expect(throws: SmartIdError.timeout) { + try await service.getSessionRequest( + url: url, + sessionId: "SESSION", + pollingTimeout: 2, + trustedCertificates: certificates, + proxyInfo: proxy, + userAgent: "TestUserAgent" + ) + } + } + + #expect(elapsed < .seconds(5)) + } + + @Test + func getSessionRequest_waitsBetweenMaintenanceRetriesWhenNoPollHasAnswered() async throws { + requestPerformer.performRequestHandler = { _, _, _, _, _, _ in + throw SmartIdError.underMaintenance + } + + let elapsed = try await ContinuousClock().measure { + await #expect(throws: SmartIdError.underMaintenance) { + try await service.getSessionRequest( + url: url, + sessionId: "SESSION", + pollingTimeout: 1, + trustedCertificates: certificates, + proxyInfo: proxy, + userAgent: "TestUserAgent" + ) + } + } + + #expect(elapsed > .seconds(2)) + } + + @Test + func getSessionRequest_stopsPollingWhenAttemptIsCancelled() async { + requestPerformer.performRequestHandler = { _, _, _, _, _, _ in + SmartIdSessionResponse(state: .running, result: nil, signature: nil, cert: nil) + } + + let service = self.service + let url = self.url + let certificates = self.certificates + let proxy = self.proxy + + let task = Task { + try await service.getSessionRequest( + url: url, + sessionId: "SESSION", + pollingTimeout: 1, + trustedCertificates: certificates, + proxyInfo: proxy, + userAgent: "TestUserAgent" + ) + } + task.cancel() + + await #expect(throws: CancellationError.self) { + try await task.value + } + } + + @Test + func getSessionRequest_asksAgainWhenPollIsInterrupted() async throws { + let expected = SmartIdSessionResponse( + state: .complete, + result: nil, + signature: nil, + cert: nil + ) + + requestPerformer.performRequestHandler = { [requestPerformer] _, _, _, _, _, _ in + if requestPerformer.performRequestCallCount == 1 { + throw SmartIdError.requestInterrupted + } + return expected + } + + let result = try await service.getSessionRequest( + url: url, + sessionId: "SESSION", + pollingTimeout: 1, + trustedCertificates: certificates, + proxyInfo: proxy, + userAgent: "TestUserAgent" + ) + + #expect(result.state == .complete) + #expect(requestPerformer.performRequestCallCount == 2) + } + + @Test + func getSessionRequest_throwRequestInterruptedWhenEveryRetryIsInterrupted() async { + requestPerformer.performRequestHandler = { _, _, _, _, _, _ in + throw SmartIdError.requestInterrupted + } + + await #expect(throws: SmartIdError.requestInterrupted) { + try await service.getSessionRequest( + url: url, + sessionId: "SESSION", + pollingTimeout: 1, + trustedCertificates: certificates, + proxyInfo: proxy, + userAgent: "TestUserAgent" + ) + } + + #expect(requestPerformer.performRequestCallCount == Constants.Signing.MaxPollRetries + 1) + } + + @Test + func getSessionRequest_asksAgainWhenServerReportsMaintenance() async throws { + let expected = SmartIdSessionResponse( + state: .complete, + result: nil, + signature: nil, + cert: nil + ) + + requestPerformer.performRequestHandler = { [requestPerformer] _, _, _, _, _, _ in + if requestPerformer.performRequestCallCount == 1 { + throw SmartIdError.underMaintenance + } + return expected + } + + let result = try await service.getSessionRequest( + url: url, + sessionId: "SESSION", + pollingTimeout: 1, + trustedCertificates: certificates, + proxyInfo: proxy, + userAgent: "TestUserAgent" + ) + + #expect(result.state == .complete) + #expect(requestPerformer.performRequestCallCount == 2) + } + + @Test + func getSessionRequest_reportsExpiryWhenMaintenancePersistsAfterSessionAnswered() async { + requestPerformer.performRequestHandler = { [requestPerformer] _, _, _, _, _, _ in + if requestPerformer.performRequestCallCount == 1 { + return SmartIdSessionResponse(state: .running, result: nil, signature: nil, cert: nil) + } + throw SmartIdError.underMaintenance + } + + await #expect(throws: SmartIdError.timeout) { + try await service.getSessionRequest( + url: url, + sessionId: "SESSION", + pollingTimeout: 1, + trustedCertificates: certificates, + proxyInfo: proxy, + userAgent: "TestUserAgent" + ) + } + } + + @Test + func getSessionRequest_throwUnderMaintenanceWhenEveryRetryReportsMaintenance() async { + requestPerformer.performRequestHandler = { _, _, _, _, _, _ in + throw SmartIdError.underMaintenance + } + + await #expect(throws: SmartIdError.underMaintenance) { + try await service.getSessionRequest( + url: url, + sessionId: "SESSION", + pollingTimeout: 1, + trustedCertificates: certificates, + proxyInfo: proxy, + userAgent: "TestUserAgent" + ) + } + + #expect(requestPerformer.performRequestCallCount == Constants.Signing.MaxPollRetries + 1) + } + @Test func getSessionRequest_throwGeneralErrorWhenErrorThrownDuringRequest() async { requestPerformer.performRequestHandler = { _, _, _, _, _, _ in diff --git a/RIADigiDoc/UI/Component/Container/Signing/SmartId/SmartIdView.swift b/RIADigiDoc/UI/Component/Container/Signing/SmartId/SmartIdView.swift index 08cd3cfb..5f617768 100644 --- a/RIADigiDoc/UI/Component/Container/Signing/SmartId/SmartIdView.swift +++ b/RIADigiDoc/UI/Component/Container/Signing/SmartId/SmartIdView.swift @@ -222,6 +222,8 @@ struct SmartIdView: View { signedContainer: signedContainer, liveActivityTexts: liveActivityTexts ) + guard !Task.isCancelled else { return } + guard let container = updatedContainer else { cancelSigning() isSigning = false diff --git a/RIADigiDoc/ViewModel/Signing/SmartId/SmartIdViewModel.swift b/RIADigiDoc/ViewModel/Signing/SmartId/SmartIdViewModel.swift index 4f2fdc10..ac7f4a39 100644 --- a/RIADigiDoc/ViewModel/Signing/SmartId/SmartIdViewModel.swift +++ b/RIADigiDoc/ViewModel/Signing/SmartId/SmartIdViewModel.swift @@ -515,6 +515,10 @@ class SmartIdViewModel: SmartIdViewModelProtocol, Loggable { return } + if Task.isCancelled { + return + } + guard let smartIdError = error as? SmartIdError else { smartIdErrorMessageKey = "General error" return @@ -529,7 +533,7 @@ class SmartIdViewModel: SmartIdViewModelProtocol, Loggable { SmartIdViewModel.logger().info("Smart-ID signing manually cancelled") smartIdErrorMessageKey = nil - case .noInternetConnection, .noResponse: + case .noInternetConnection, .noResponse, .requestInterrupted: smartIdErrorMessageKey = "No Internet connection" case .incorrectParameters: diff --git a/RIADigiDocTests/ViewModel/Signing/SmartId/SmartIdViewModelTests.swift b/RIADigiDocTests/ViewModel/Signing/SmartId/SmartIdViewModelTests.swift index 60310a04..20a9cb5d 100644 --- a/RIADigiDocTests/ViewModel/Signing/SmartId/SmartIdViewModelTests.swift +++ b/RIADigiDocTests/ViewModel/Signing/SmartId/SmartIdViewModelTests.swift @@ -834,7 +834,8 @@ struct SmartIdViewModelTests { "sign_setNoInternetMessageWhenNoInternetConnectionErrorsThrown", arguments: [ SmartIdError.noInternetConnection, - .noResponse + .noResponse, + .requestInterrupted ] ) func sign_setNoInternetMessageWhenNoInternetConnectionErrorsThrown( @@ -866,6 +867,43 @@ struct SmartIdViewModelTests { #expect(viewModel.smartIdErrorMessageKey == "No Internet connection") } + @Test + func sign_doesNotSetErrorMessageWhenAttemptWasAlreadyCancelled() async { + mockConfigurationRepository.getConfigurationHandler = { + try? TestConfigurationProvider.mockConfigurationProvider() + } + + mockSmartIdSignService.getCertificateRequestHandler = { _, _, _, _, _, _, _, _ in + throw SmartIdError.timeout + } + + mockProxyUtil.getProxyInfoHandler = { ProxyInfo() } + + let viewModel = self.viewModel + let roleData = self.roleData + let container = mockContainer() + + let task = Task { @MainActor in + await viewModel.sign( + country: .estonia, + personalCode: "60001019906", + roleData: roleData, + signedContainer: container, + liveActivityTexts: SmartIdLiveActivityTexts( + initialMessage: "Initial message", + controlCodeTitle: "Control code", + compactTitle: "Code" + ) + ) + } + task.cancel() + + let result = await task.value + + #expect(result == nil) + #expect(viewModel.smartIdErrorMessageKey == nil) + } + @Test func sign_setInvalidSigningAccessRightsWhenIncorrectParametersErrorThrown() async { mockConfigurationRepository.getConfigurationHandler = {