From 85bc7224edd3b9592cf63c545ccbe48de4bcb748 Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Mon, 7 Sep 2026 18:05:12 +0300 Subject: [PATCH] Fix opening external container when one is already open --- .../MobileIdLib/Error/MobileIdError.swift | 1 + .../Networking/Handler/ResponseHandler.swift | 6 +- .../Service/MobileIdSignService.swift | 33 +++-- .../Handler/ResponseHandlerTests.swift | 16 +++ .../Service/MobileIdSignServiceTests.swift | 129 ++++++++++++++++++ RIADigiDoc/DI/AppContainer.swift | 3 +- .../Container/Signing/SigningRootView.swift | 12 +- RIADigiDoc/UI/Component/HomeView.swift | 19 ++- .../Presentation/ModalPresentationUtil.swift | 35 +++++ RIADigiDoc/ViewModel/HomeViewModel.swift | 6 + .../Protocols/HomeViewModelProtocol.swift | 1 + .../Signing/MobileId/MobileIdViewModel.swift | 12 +- .../RootView/SigningRootViewModel.swift | 26 +++- .../SigningRootViewModelProtocol.swift | 6 + .../Signing/SmartId/SmartIdViewModel.swift | 5 + .../ViewModel/HomeViewModelTests.swift | 67 +++++++++ .../MobileId/MobileIdViewModelTests.swift | 32 +++++ .../RootView/SigningRootViewModelTests.swift | 107 +++++++++++++++ 18 files changed, 494 insertions(+), 22 deletions(-) create mode 100644 RIADigiDoc/Util/Presentation/ModalPresentationUtil.swift create mode 100644 RIADigiDocTests/ViewModel/Signing/RootView/SigningRootViewModelTests.swift diff --git a/Modules/MobileIdLib/Sources/MobileIdLib/Error/MobileIdError.swift b/Modules/MobileIdLib/Sources/MobileIdLib/Error/MobileIdError.swift index b19fccd5..265c0af3 100644 --- a/Modules/MobileIdLib/Sources/MobileIdLib/Error/MobileIdError.swift +++ b/Modules/MobileIdLib/Sources/MobileIdLib/Error/MobileIdError.swift @@ -21,4 +21,5 @@ public enum MobileIdError: Error { case invalidAccessRights case invalidSslHandshake case technicalError + case requestInterrupted } diff --git a/Modules/MobileIdLib/Sources/MobileIdLib/Networking/Handler/ResponseHandler.swift b/Modules/MobileIdLib/Sources/MobileIdLib/Networking/Handler/ResponseHandler.swift index f05ffa3d..d28fe455 100644 --- a/Modules/MobileIdLib/Sources/MobileIdLib/Networking/Handler/ResponseHandler.swift +++ b/Modules/MobileIdLib/Sources/MobileIdLib/Networking/Handler/ResponseHandler.swift @@ -90,7 +90,11 @@ struct ResponseHandler: ResponseHandlerProtocol { } func handleStatusCodeError(_ statusCode: Int?) throws { - switch statusCode ?? -1 { + guard let statusCode else { + throw MobileIdError.requestInterrupted + } + + switch statusCode { case 400: throw MobileIdError.incorrectParameters case 401: diff --git a/Modules/MobileIdLib/Sources/MobileIdLib/Service/MobileIdSignService.swift b/Modules/MobileIdLib/Sources/MobileIdLib/Service/MobileIdSignService.swift index 6610cdab..8d3c8ac0 100644 --- a/Modules/MobileIdLib/Sources/MobileIdLib/Service/MobileIdSignService.swift +++ b/Modules/MobileIdLib/Sources/MobileIdLib/Service/MobileIdSignService.swift @@ -96,16 +96,33 @@ actor MobileIdSignService: MobileIdSignServiceProtocol, Loggable { userAgent: String ) async throws -> MobileIdSessionResponse { let pollingTimeoutMs = pollingTimeout * 1000 + var retriedPolls = 0 while true { - let sessionResponse: MobileIdSessionResponse? = try await requestPerformer.performRequest( - url: "\(url)/\(sessionId)", - method: .get, - parameters: ["timeoutMs": pollingTimeoutMs], - trustedCertificates: trustedCertificates, - proxyInfo: proxyInfo, - userAgent: userAgent - ) + try Task.checkCancellation() + + let sessionResponse: MobileIdSessionResponse? + + do { + sessionResponse = try await requestPerformer.performRequest( + url: "\(url)/\(sessionId)", + method: .get, + parameters: ["timeoutMs": pollingTimeoutMs], + trustedCertificates: trustedCertificates, + proxyInfo: proxyInfo, + userAgent: userAgent + ) + } catch MobileIdError.requestInterrupted { + retriedPolls += 1 + + guard retriedPolls <= Constants.Signing.MaxPollRetries else { + throw MobileIdError.requestInterrupted + } + + continue + } + + retriedPolls = 0 if let response = sessionResponse, response.state == .complete { diff --git a/Modules/MobileIdLib/Tests/MobileIdLibTests/Networking/Handler/ResponseHandlerTests.swift b/Modules/MobileIdLib/Tests/MobileIdLibTests/Networking/Handler/ResponseHandlerTests.swift index f5220f2e..ce46557b 100644 --- a/Modules/MobileIdLib/Tests/MobileIdLibTests/Networking/Handler/ResponseHandlerTests.swift +++ b/Modules/MobileIdLib/Tests/MobileIdLibTests/Networking/Handler/ResponseHandlerTests.swift @@ -78,6 +78,22 @@ struct ResponseHandlerTests { } } + @Test + func handleNetworkError_throwsRequestInterruptedWhenNoUrlErrorAndNoStatusCode() { + let afError = AFError.serverTrustEvaluationFailed(reason: .noRequiredEvaluator(host: "host.test")) + + #expect(throws: MobileIdError.requestInterrupted) { + try handler.handleNetworkError(afError, statusCode: nil) + } + } + + @Test + func handleStatusCodeError_throwsRequestInterruptedWhenStatusCodeIsMissing() { + #expect(throws: MobileIdError.requestInterrupted) { + try handler.handleStatusCodeError(nil) + } + } + @Test func handleStatusCodeError_throwsStatusCodeErrors() { #expect(throws: MobileIdError.incorrectParameters) { try handler.handleStatusCodeError(400) } diff --git a/Modules/MobileIdLib/Tests/MobileIdLibTests/Service/MobileIdSignServiceTests.swift b/Modules/MobileIdLib/Tests/MobileIdLibTests/Service/MobileIdSignServiceTests.swift index a2f680cd..695fd25d 100644 --- a/Modules/MobileIdLib/Tests/MobileIdLibTests/Service/MobileIdSignServiceTests.swift +++ b/Modules/MobileIdLib/Tests/MobileIdLibTests/Service/MobileIdSignServiceTests.swift @@ -140,4 +140,133 @@ struct MobileIdSignServiceTests { #expect(code == nil) } + + @Test + func getSessionRequest_retriesPollWhenRequestIsInterrupted() async throws { + let completedResponse = MobileIdSessionResponse( + state: .complete, + result: .ok, + signature: MobileIdSessionSignatureResponse( + value: Data([0xAA, 0xBB]), + algorithm: "TestAlgorithm" + ), + cert: "TestCert", + time: Date.now.formatted(), + traceId: "TestTraceId" + ) + + mockRequestPerfomer.performRequestHandler = { [mockRequestPerfomer] _, _, _, _, _, _ in + if mockRequestPerfomer.performRequestCallCount == 1 { + throw MobileIdError.requestInterrupted + } + return completedResponse + } + + let response = try await service.getSessionRequest( + url: "https://url.test/session", + sessionId: "abc", + pollingTimeout: 1, + trustedCertificates: [], + proxyInfo: ProxyInfo(), + userAgent: "MobileIdSignServiceTestsUserAgent" + ) + + #expect(response.state == .complete) + #expect(mockRequestPerfomer.performRequestCallCount == 2) + } + + @Test + func getSessionRequest_throwsRequestInterruptedWhenEveryRetryIsInterrupted() async { + mockRequestPerfomer.performRequestHandler = { _, _, _, _, _, _ in + throw MobileIdError.requestInterrupted + } + + await #expect(throws: MobileIdError.requestInterrupted) { + try await service.getSessionRequest( + url: "https://url.test/session", + sessionId: "abc", + pollingTimeout: 1, + trustedCertificates: [], + proxyInfo: ProxyInfo(), + userAgent: "MobileIdSignServiceTestsUserAgent" + ) + } + + #expect(mockRequestPerfomer.performRequestCallCount == Constants.Signing.MaxPollRetries + 1) + } + + @Test + func getSessionRequest_resetsRetryCountAfterASuccessfulPoll() async throws { + let runningResponse = MobileIdSessionResponse( + state: .running, + result: nil, + signature: nil, + cert: nil, + time: Date.now.formatted(), + traceId: "TestTraceId" + ) + let completedResponse = MobileIdSessionResponse( + state: .complete, + result: .ok, + signature: MobileIdSessionSignatureResponse( + value: Data([0xAA, 0xBB]), + algorithm: "TestAlgorithm" + ), + cert: "TestCert", + time: Date.now.formatted(), + traceId: "TestTraceId" + ) + + // Interrupt, recover, interrupt again: the second run of interruptions must get a full + // retry budget, so the poll still completes instead of giving up. + mockRequestPerfomer.performRequestHandler = { [mockRequestPerfomer] _, _, _, _, _, _ in + switch mockRequestPerfomer.performRequestCallCount { + case 1, 2, 3, 5, 6, 7: + throw MobileIdError.requestInterrupted + case 4: + return runningResponse + default: + return completedResponse + } + } + + let response = try await service.getSessionRequest( + url: "https://url.test/session", + sessionId: "abc", + pollingTimeout: 1, + trustedCertificates: [], + proxyInfo: ProxyInfo(), + userAgent: "MobileIdSignServiceTestsUserAgent" + ) + + #expect(response.state == .complete) + #expect(mockRequestPerfomer.performRequestCallCount == 8) + } + + @Test + func getSessionRequest_endsWithCancellationErrorWhenTaskIsCancelled() async { + // Tearing the signing screen down (for example when a container opened from another app + // replaces the one being signed) cancels the task mid-poll. That must surface as a + // CancellationError, which the view model swallows, and never as a MobileIdError that + // would show the user an error about the container they just left. + mockRequestPerfomer.performRequestHandler = { _, _, _, _, _, _ in + throw MobileIdError.requestInterrupted + } + + let task = Task { [service] in + try await service.getSessionRequest( + url: "https://url.test/session", + sessionId: "abc", + pollingTimeout: 1, + trustedCertificates: [], + proxyInfo: ProxyInfo(), + userAgent: "MobileIdSignServiceTestsUserAgent" + ) + } + task.cancel() + + await #expect(throws: CancellationError.self) { + _ = try await task.value + } + } } diff --git a/RIADigiDoc/DI/AppContainer.swift b/RIADigiDoc/DI/AppContainer.swift index cedd9839..077cf8e1 100644 --- a/RIADigiDoc/DI/AppContainer.swift +++ b/RIADigiDoc/DI/AppContainer.swift @@ -465,7 +465,8 @@ extension Container { var signingRootViewModel: Factory { self { @MainActor in SigningRootViewModel( - dataStore: self.dataStore() + dataStore: self.dataStore(), + sharedContainerViewModel: self.sharedContainerViewModel() ) } } diff --git a/RIADigiDoc/UI/Component/Container/Signing/SigningRootView.swift b/RIADigiDoc/UI/Component/Container/Signing/SigningRootView.swift index 1b57ec6f..2cce3f64 100644 --- a/RIADigiDoc/UI/Component/Container/Signing/SigningRootView.swift +++ b/RIADigiDoc/UI/Component/Container/Signing/SigningRootView.swift @@ -44,9 +44,7 @@ struct SigningRootView: View { signedContainer: container, onSuccess: { container in isSuccess = true - sharedContainerViewModel.removeLastContainer() - sharedContainerViewModel.setSignedContainer(container) - sharedContainerViewModel.setIsSignatureAdded(true) + viewModel.applySignedContainer(container, replacing: signedContainer) } ) } @@ -55,9 +53,7 @@ struct SigningRootView: View { MobileIdView( signedContainer: container, onSuccess: { container in - sharedContainerViewModel.removeLastContainer() - sharedContainerViewModel.setSignedContainer(container) - sharedContainerViewModel.setIsSignatureAdded(true) + viewModel.applySignedContainer(container, replacing: signedContainer) } ) } @@ -66,9 +62,7 @@ struct SigningRootView: View { SmartIdView( signedContainer: container, onSuccess: { container in - sharedContainerViewModel.removeLastContainer() - sharedContainerViewModel.setSignedContainer(container) - sharedContainerViewModel.setIsSignatureAdded(true) + viewModel.applySignedContainer(container, replacing: signedContainer) } ) } diff --git a/RIADigiDoc/UI/Component/HomeView.swift b/RIADigiDoc/UI/Component/HomeView.swift index 469e414b..7f7b2914 100644 --- a/RIADigiDoc/UI/Component/HomeView.swift +++ b/RIADigiDoc/UI/Component/HomeView.swift @@ -293,7 +293,14 @@ struct HomeView: View { } private func handleFiles(_ files: [URL]) { - if !files.isEmpty { + guard !files.isEmpty else { return } + + dismissHomeModals() + ModalPresentationUtil.dismissPresentedModals() + + DispatchQueue.main.async { + pathManager.popToRoot() + viewModel.closeOpenContainers() isFileOpeningLoading = true viewModel.isImporting = false viewModel.setFileOpeningMethod(.all) @@ -301,6 +308,16 @@ struct HomeView: View { } } + private func dismissHomeModals() { + showFilesBottomSheet = false + showSignatureBottomSheet = false + showCryptoBottomSheet = false + showHomeMenuBottomSheet = false + isAllFilesImporting = false + isSigningImporting = false + isCryptoImporting = false + } + private func handleIncoming(url: URL) { let webEidURL = (WebEidUriUtil.isWebEidUri(url)) ? url : nil diff --git a/RIADigiDoc/Util/Presentation/ModalPresentationUtil.swift b/RIADigiDoc/Util/Presentation/ModalPresentationUtil.swift new file mode 100644 index 00000000..958b174a --- /dev/null +++ b/RIADigiDoc/Util/Presentation/ModalPresentationUtil.swift @@ -0,0 +1,35 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import UIKit + +class ModalPresentationUtil { + + @MainActor + public static func dismissPresentedModals() { + let rootViewControllers = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .compactMap { $0.rootViewController } + + for rootViewController in rootViewControllers where rootViewController.presentedViewController != nil { + rootViewController.dismiss(animated: false) + } + } +} diff --git a/RIADigiDoc/ViewModel/HomeViewModel.swift b/RIADigiDoc/ViewModel/HomeViewModel.swift index d35d1426..1275c05d 100644 --- a/RIADigiDoc/ViewModel/HomeViewModel.swift +++ b/RIADigiDoc/ViewModel/HomeViewModel.swift @@ -47,6 +47,12 @@ class HomeViewModel: HomeViewModelProtocol, Loggable { sharedContainerViewModel.setFileOpeningMethod(method) } + func closeOpenContainers() { + sharedContainerViewModel.setSignedContainer(nil) + sharedContainerViewModel.setCryptoContainer(nil) + sharedContainerViewModel.clearContainers() + } + func getRecentDocumentsFolder() -> URL? { do { return try Directories.getCacheDirectory(fileManager: fileManager) diff --git a/RIADigiDoc/ViewModel/Protocols/HomeViewModelProtocol.swift b/RIADigiDoc/ViewModel/Protocols/HomeViewModelProtocol.swift index 70e2cba2..8a906b03 100644 --- a/RIADigiDoc/ViewModel/Protocols/HomeViewModelProtocol.swift +++ b/RIADigiDoc/ViewModel/Protocols/HomeViewModelProtocol.swift @@ -12,4 +12,5 @@ public protocol HomeViewModelProtocol: Sendable { func getRecentDocumentsFolder() -> URL? func getSharedFiles() async -> [URL] func setFileOpeningMethod(_ method: FileOpeningMethod) + func closeOpenContainers() } diff --git a/RIADigiDoc/ViewModel/Signing/MobileId/MobileIdViewModel.swift b/RIADigiDoc/ViewModel/Signing/MobileId/MobileIdViewModel.swift index 8c0f0302..1222bed0 100644 --- a/RIADigiDoc/ViewModel/Signing/MobileId/MobileIdViewModel.swift +++ b/RIADigiDoc/ViewModel/Signing/MobileId/MobileIdViewModel.swift @@ -406,6 +406,11 @@ class MobileIdViewModel: MobileIdViewModelProtocol, Loggable { private func handleSigningError(_ error: Error) { MobileIdViewModel.logger().error("Unable to sign container with Mobile-ID: \(error)") + guard !Task.isCancelled else { + MobileIdViewModel.logger().info("Mobile-ID signing cancelled. Not showing the error") + return + } + if let cancellationError = error as? CancellationError { MobileIdViewModel.logger().error("Mobile-ID signing manually cancelled: \(cancellationError)") // Do not throw an error if user cancelled signing with back button @@ -451,7 +456,7 @@ class MobileIdViewModel: MobileIdViewModelProtocol, Loggable { case .simError: mobileIdErrorMessageKey = "SIM error" - case .noInternetConnection: + case .noInternetConnection, .requestInterrupted: mobileIdErrorMessageKey = "No Internet connection" case .tooManyRequests: @@ -482,6 +487,11 @@ class MobileIdViewModel: MobileIdViewModelProtocol, Loggable { private func handleSignatureAddingError(_ error: Error) { MobileIdViewModel.logger().error("Unable to sign container with Mobile-ID: \(error)") + guard !Task.isCancelled else { + MobileIdViewModel.logger().info("Mobile-ID signing cancelled. Not showing the error") + return + } + if let cancellationError = error as? CancellationError { MobileIdViewModel.logger().error("Mobile-ID signing manually cancelled: \(cancellationError)") return diff --git a/RIADigiDoc/ViewModel/Signing/RootView/SigningRootViewModel.swift b/RIADigiDoc/ViewModel/Signing/RootView/SigningRootViewModel.swift index 06f740f3..001bd015 100644 --- a/RIADigiDoc/ViewModel/Signing/RootView/SigningRootViewModel.swift +++ b/RIADigiDoc/ViewModel/Signing/RootView/SigningRootViewModel.swift @@ -4,18 +4,42 @@ import UtilsLib import Foundation import Observation +import CommonsLib +import LibdigidocLibSwift @Observable @MainActor class SigningRootViewModel: SigningRootViewModelProtocol, Loggable { private let dataStore: DataStoreProtocol + private let sharedContainerViewModel: SharedContainerViewModelProtocol - init(dataStore: DataStoreProtocol) { + init( + dataStore: DataStoreProtocol, + sharedContainerViewModel: SharedContainerViewModelProtocol + ) { self.dataStore = dataStore + self.sharedContainerViewModel = sharedContainerViewModel } func getSelectedSigningMethod() async -> ActionMethod { return await dataStore.getSelectedSigningMethod() } + + func applySignedContainer( + _ signedContainer: SignedContainerProtocol, + replacing containerBeingSigned: GeneralContainer? + ) { + guard let containerBeingSigned, + sharedContainerViewModel.currentContainer() === containerBeingSigned else { + SigningRootViewModel.logger().info( + "Container was replaced while signing. Dropping the signed container" + ) + return + } + + sharedContainerViewModel.removeLastContainer() + sharedContainerViewModel.setSignedContainer(signedContainer) + sharedContainerViewModel.setIsSignatureAdded(true) + } } diff --git a/RIADigiDoc/ViewModel/Signing/RootView/SigningRootViewModelProtocol.swift b/RIADigiDoc/ViewModel/Signing/RootView/SigningRootViewModelProtocol.swift index c25a26d5..58489a51 100644 --- a/RIADigiDoc/ViewModel/Signing/RootView/SigningRootViewModelProtocol.swift +++ b/RIADigiDoc/ViewModel/Signing/RootView/SigningRootViewModelProtocol.swift @@ -2,9 +2,15 @@ // SPDX-License-Identifier: LGPL-2.1-or-later import Foundation +import CommonsLib +import LibdigidocLibSwift /// @mockable @MainActor public protocol SigningRootViewModelProtocol: Sendable { func getSelectedSigningMethod() async -> ActionMethod + func applySignedContainer( + _ signedContainer: SignedContainerProtocol, + replacing containerBeingSigned: GeneralContainer? + ) } diff --git a/RIADigiDoc/ViewModel/Signing/SmartId/SmartIdViewModel.swift b/RIADigiDoc/ViewModel/Signing/SmartId/SmartIdViewModel.swift index 69617a5c..f7733815 100644 --- a/RIADigiDoc/ViewModel/Signing/SmartId/SmartIdViewModel.swift +++ b/RIADigiDoc/ViewModel/Signing/SmartId/SmartIdViewModel.swift @@ -584,6 +584,11 @@ class SmartIdViewModel: SmartIdViewModelProtocol, Loggable { private func handleSignatureAddingError(_ error: Error) { SmartIdViewModel.logger().error("Unable to sign container with Smart-ID: \(error)") + guard !Task.isCancelled else { + SmartIdViewModel.logger().info("Smart-ID signing cancelled. Not showing the error") + return + } + if let cancellationError = error as? CancellationError { SmartIdViewModel.logger().error("Smart-ID signing manually cancelled: \(cancellationError)") return diff --git a/RIADigiDocTests/ViewModel/HomeViewModelTests.swift b/RIADigiDocTests/ViewModel/HomeViewModelTests.swift index aa3a9414..52f53c72 100644 --- a/RIADigiDocTests/ViewModel/HomeViewModelTests.swift +++ b/RIADigiDocTests/ViewModel/HomeViewModelTests.swift @@ -7,18 +7,21 @@ import CommonsLib import CommonsLibMocks import UtilsLib import UtilsLibMocks +import LibdigidocLibSwift @MainActor struct HomeViewModelTests { private let mockSharedContainerViewModel: SharedContainerViewModelProtocolMock private let mockFileManager: FileManagerProtocolMock private let mockFileUtil: FileUtilProtocolMock + private let mockContainerUtil: ContainerUtilProtocolMock private let viewModel: HomeViewModel init() async throws { mockSharedContainerViewModel = SharedContainerViewModelProtocolMock() mockFileManager = FileManagerProtocolMock() mockFileUtil = FileUtilProtocolMock() + mockContainerUtil = ContainerUtilProtocolMock() viewModel = HomeViewModel( sharedContainerViewModel: mockSharedContainerViewModel, fileManager: mockFileManager, @@ -118,4 +121,68 @@ struct HomeViewModelTests { return } } + + @Test + func closeOpenContainers_clearsTheOpenContainer() async { + let sharedContainerViewModel = SharedContainerViewModel() + let viewModel = makeViewModel(sharedContainerViewModel: sharedContainerViewModel) + sharedContainerViewModel.setSignedContainer(makeSignedContainer()) + + viewModel.closeOpenContainers() + + #expect(sharedContainerViewModel.containers().isEmpty) + #expect(sharedContainerViewModel.currentContainer() == nil) + } + + @Test + func closeOpenContainers_clearsEveryNestedContainer() async { + let sharedContainerViewModel = SharedContainerViewModel() + let viewModel = makeViewModel(sharedContainerViewModel: sharedContainerViewModel) + sharedContainerViewModel.setSignedContainer(makeSignedContainer()) + sharedContainerViewModel.setSignedContainer(makeSignedContainer()) + + #expect(sharedContainerViewModel.containers().count == 2) + + viewModel.closeOpenContainers() + + #expect(sharedContainerViewModel.containers().isEmpty) + #expect(sharedContainerViewModel.currentContainer() == nil) + } + + @Test + func closeOpenContainers_doesNothingWhenNoContainerIsOpen() async { + let sharedContainerViewModel = SharedContainerViewModel() + let viewModel = makeViewModel(sharedContainerViewModel: sharedContainerViewModel) + + viewModel.closeOpenContainers() + + #expect(sharedContainerViewModel.containers().isEmpty) + #expect(sharedContainerViewModel.currentContainer() == nil) + } + + @Test + func closeOpenContainers_releasesSignedAndCryptoContainerReferences() async { + viewModel.closeOpenContainers() + + #expect(mockSharedContainerViewModel.setSignedContainerCallCount == 1) + #expect(mockSharedContainerViewModel.setSignedContainerArgValues.allSatisfy { $0 == nil }) + #expect(mockSharedContainerViewModel.setCryptoContainerCallCount == 1) + #expect(mockSharedContainerViewModel.setCryptoContainerArgValues.allSatisfy { $0 == nil }) + #expect(mockSharedContainerViewModel.clearContainersCallCount == 1) + } + + private func makeViewModel(sharedContainerViewModel: SharedContainerViewModelProtocol) -> HomeViewModel { + HomeViewModel( + sharedContainerViewModel: sharedContainerViewModel, + fileManager: mockFileManager, + fileUtil: mockFileUtil + ) + } + + private func makeSignedContainer() -> SignedContainer { + SignedContainer( + fileManager: mockFileManager, + containerUtil: mockContainerUtil + ) + } } diff --git a/RIADigiDocTests/ViewModel/Signing/MobileId/MobileIdViewModelTests.swift b/RIADigiDocTests/ViewModel/Signing/MobileId/MobileIdViewModelTests.swift index 5e7e3f46..71c5baae 100644 --- a/RIADigiDocTests/ViewModel/Signing/MobileId/MobileIdViewModelTests.swift +++ b/RIADigiDocTests/ViewModel/Signing/MobileId/MobileIdViewModelTests.swift @@ -938,6 +938,38 @@ struct MobileIdViewModelTests { traceId: traceId ) } + + @Test + func sign_doesNotSetErrorMessageWhenSigningIsCancelledWhileRequestingCertificate() async { + mockMobileIdSignService.getCertificateRequestHandler = { _, _, _, _, _, _, _, _ in + var spins = 0 + while !Task.isCancelled && spins < 10_000 { + await Task.yield() + spins += 1 + } + + throw MobileIdError.requestInterrupted + } + + mockProxyUtil.getProxyInfoHandler = { ProxyInfo() } + + let container = mockContainer() + let task = Task { [viewModel, roleData] in + await viewModel.sign( + phoneNumber: "37251234567", + personalCode: "60001019906", + roleData: roleData, + signedContainer: container + ) + } + task.cancel() + + let result = await task.value + + #expect(result == nil) + #expect(viewModel.mobileIdErrorMessageKey == nil) + #expect(viewModel.mobileIdAlertMessageKey == nil) + } } extension MobileIdViewModelTests { diff --git a/RIADigiDocTests/ViewModel/Signing/RootView/SigningRootViewModelTests.swift b/RIADigiDocTests/ViewModel/Signing/RootView/SigningRootViewModelTests.swift new file mode 100644 index 00000000..8a814c56 --- /dev/null +++ b/RIADigiDocTests/ViewModel/Signing/RootView/SigningRootViewModelTests.swift @@ -0,0 +1,107 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import Testing +import LibdigidocLibSwift +import CommonsLibMocks +import UtilsLibMocks + +@MainActor +struct SigningRootViewModelTests { + private let mockDataStore: DataStoreProtocolMock + private let mockFileManager: FileManagerProtocolMock + private let mockContainerUtil: ContainerUtilProtocolMock + private let sharedContainerViewModel: SharedContainerViewModel + private let viewModel: SigningRootViewModel + + init() async throws { + mockDataStore = DataStoreProtocolMock() + mockFileManager = FileManagerProtocolMock() + mockContainerUtil = ContainerUtilProtocolMock() + sharedContainerViewModel = SharedContainerViewModel() + viewModel = SigningRootViewModel( + dataStore: mockDataStore, + sharedContainerViewModel: sharedContainerViewModel + ) + } + + private func makeSignedContainer() -> SignedContainer { + SignedContainer( + fileManager: mockFileManager, + containerUtil: mockContainerUtil + ) + } + + @Test + func applySignedContainer_replacesContainerWhenItIsStillTheOpenOne() async { + let containerBeingSigned = makeSignedContainer() + sharedContainerViewModel.setSignedContainer(containerBeingSigned) + let signedContainer = makeSignedContainer() + + viewModel.applySignedContainer(signedContainer, replacing: containerBeingSigned) + + #expect(sharedContainerViewModel.currentContainer() === signedContainer) + #expect(sharedContainerViewModel.containers().count == 1) + #expect(sharedContainerViewModel.getIsSignatureAdded()) + } + + @Test + func applySignedContainer_dropsResultWhenContainersWereCleared() async { + let containerBeingSigned = makeSignedContainer() + sharedContainerViewModel.setSignedContainer(containerBeingSigned) + sharedContainerViewModel.clearContainers() + let signedContainer = makeSignedContainer() + + viewModel.applySignedContainer(signedContainer, replacing: containerBeingSigned) + + #expect(sharedContainerViewModel.containers().isEmpty) + #expect(!sharedContainerViewModel.getIsSignatureAdded()) + } + + @Test + func applySignedContainer_dropsResultWhenAnotherContainerWasOpened() async { + let containerBeingSigned = makeSignedContainer() + sharedContainerViewModel.setSignedContainer(containerBeingSigned) + sharedContainerViewModel.clearContainers() + + let externallyOpenedContainer = makeSignedContainer() + sharedContainerViewModel.setSignedContainer(externallyOpenedContainer) + + let signedContainer = makeSignedContainer() + + viewModel.applySignedContainer(signedContainer, replacing: containerBeingSigned) + + #expect(sharedContainerViewModel.currentContainer() === externallyOpenedContainer) + #expect(sharedContainerViewModel.containers().count == 1) + #expect(!sharedContainerViewModel.getIsSignatureAdded()) + } + + @Test + func applySignedContainer_dropsResultWhenThereWasNoContainerBeingSigned() async { + let externallyOpenedContainer = makeSignedContainer() + sharedContainerViewModel.setSignedContainer(externallyOpenedContainer) + let signedContainer = makeSignedContainer() + + viewModel.applySignedContainer(signedContainer, replacing: nil) + + #expect(sharedContainerViewModel.currentContainer() === externallyOpenedContainer) + #expect(!sharedContainerViewModel.getIsSignatureAdded()) + } +}