From 043aace5eacbe6f87a7778ee6a76be04a9503f4b Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Tue, 1 Sep 2026 14:56:50 +0300 Subject: [PATCH] Add No Internet connection error messages --- .../Sources/CryptoObjC/include/Decrypt.mm | 10 ++- .../Sources/CryptoObjC/include/Encrypt.mm | 10 +-- .../Sources/CryptoObjC/include/Extensions.h | 8 +- .../Domain/Models/OpenLdapSearchResult.swift | 7 ++ .../Errors/NSError+CryptoNetwork.swift | 27 +++++++ .../Sources/CryptoSwift/Ldap/OpenLdap.swift | 64 +++++++++++----- .../NSErrorCryptoNetworkTests.swift | 47 ++++++++++++ .../Domain/Models/ErrorDetail.swift | 7 ++ .../Errors/DigiDocErrorTests.swift | 14 ++++ RIADigiDoc/Domain/Model/ToastMessage.swift | 11 +++ RIADigiDoc/Domain/NFC/OperationDecrypt.swift | 7 ++ .../Container/Crypto/EncryptView.swift | 12 ++- .../Recipient/EncryptRecipientView.swift | 19 +---- .../Container/Signing/SigningView.swift | 18 +++-- .../UI/Component/EncryptionSettingsView.swift | 10 ++- .../HomeView/CryptoImportButton.swift | 12 ++- .../HomeView/SigningImportButton.swift | 12 ++- .../TimeStampSettingsView.swift | 10 ++- .../UI/Component/ValidationSettingsView.swift | 10 ++- .../ViewModel/CryptoHomeViewModel.swift | 7 ++ .../ViewModel/EncryptRecipientViewModel.swift | 42 +++++++---- RIADigiDoc/ViewModel/EncryptViewModel.swift | 18 ++++- .../EncryptionSettingsViewModel.swift | 7 ++ .../ViewModel/FileOpeningViewModel.swift | 6 +- RIADigiDoc/ViewModel/HomeViewModel.swift | 7 ++ .../Signing/MobileId/MobileIdViewModel.swift | 10 +-- .../ViewModel/Signing/NFC/NFCViewModel.swift | 7 ++ .../Signing/SmartId/SmartIdViewModel.swift | 10 +-- RIADigiDoc/ViewModel/SigningViewModel.swift | 15 +++- .../TimeStampSettingsViewModel.swift | 7 ++ .../ValidationSettingsViewModel.swift | 7 ++ .../EncryptRecipientViewModelTests.swift | 60 +++++++++++++++ .../ViewModel/EncryptViewModelTests.swift | 17 +++++ .../ViewModel/FileOpeningViewModelTests.swift | 73 +++++++++++++++++++ .../MobileId/MobileIdViewModelTests.swift | 6 +- .../Signing/NFC/NFCViewModelTests.swift | 30 ++++++++ .../SmartId/SmartIdViewModelTests.swift | 19 ++--- 37 files changed, 556 insertions(+), 107 deletions(-) create mode 100644 Modules/CryptoLib/Sources/CryptoSwift/Errors/NSError+CryptoNetwork.swift create mode 100644 Modules/CryptoLib/Tests/CryptoSwiftTests/NSErrorCryptoNetworkTests.swift diff --git a/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm b/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm index 11678f5a..cc4ff1dc 100644 --- a/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm +++ b/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm @@ -199,8 +199,10 @@ + (void)decryptFile:(NSString *)fullPath withCert:(NSData *)certData withToken:( return completion(nil, [NSError cryptoError:@"Failed to find lock for cert"]); } std::vector fmk; - if(reader->getFMK(fmk, unsigned(idx)) != 0 || fmk.empty()) { - return completion(nil, token.lastError() ?: [NSError cryptoError:@"Failed to get FMK"]); + libcdoc::result_t fmkResult = reader->getFMK(fmk, unsigned(idx)); + if(fmkResult != 0 || fmk.empty()) { + return completion(nil, token.lastError() ?: [NSError cryptoError:@"Failed to get FMK" + code:fmkResult != 0 ? fmkResult : 1000]); } NSError *error = nil; completion([self decryptReader:*reader withFMK:fmk error:&error], error); @@ -246,7 +248,7 @@ + (void)decryptFile:(NSString *)fullPath withCert:(NSData *)certData withToken:( + (NSDictionary *)decryptReader:(libcdoc::CDocReader&)reader withFMK:(const std::vector&)fmk error:(NSError**)error { if(reader.beginDecryption(fmk) != 0) { - return [NSError cryptoError:@"Failed to start encryption" error:error]; + return [NSError cryptoError:@"Failed to start decryption" error:error]; } NSMutableDictionary *response = [NSMutableDictionary new]; @@ -275,7 +277,7 @@ + (void)decryptFile:(NSString *)fullPath withCert:(NSData *)certData withToken:( [response setObject:data forKey:[NSString stringWithStdString:name]]; } if (reader.finishDecryption() != 0) - return [NSError cryptoError:@"Failed to end encryption" error:error]; + return [NSError cryptoError:@"Failed to end decryption" error:error]; return response; } diff --git a/Modules/CryptoLib/Sources/CryptoObjC/include/Encrypt.mm b/Modules/CryptoLib/Sources/CryptoObjC/include/Encrypt.mm index 57875d1b..dfba8dfb 100644 --- a/Modules/CryptoLib/Sources/CryptoObjC/include/Encrypt.mm +++ b/Modules/CryptoLib/Sources/CryptoObjC/include/Encrypt.mm @@ -164,9 +164,9 @@ + (void)encryptFile:(NSString *)fullPath withDataFiles:(NSArray } } } - - if (writer->beginEncryption() != 0) { - return completion([NSError cryptoError:@"Failed to start encryption"]); + + if (libcdoc::result_t result = writer->beginEncryption(); result != 0) { + return completion([NSError cryptoError:@"Failed to start encryption" code:result]); } for (CryptoDataFile *dataFile in dataFiles) { @@ -219,8 +219,8 @@ + (void)encryptFile:(NSString *)fullPath withDataFiles:(NSArray return completion([NSError cryptoError:@"Failed to create key"]); } - if (writer->beginEncryption() != 0) { - return completion([NSError cryptoError:@"Failed to start encryption"]); + if (libcdoc::result_t result = writer->beginEncryption(); result != 0) { + return completion([NSError cryptoError:@"Failed to start encryption" code:result]); } for (CryptoDataFile *dataFile in dataFiles) { diff --git a/Modules/CryptoLib/Sources/CryptoObjC/include/Extensions.h b/Modules/CryptoLib/Sources/CryptoObjC/include/Extensions.h index 5c8af62d..e6eaa041 100644 --- a/Modules/CryptoLib/Sources/CryptoObjC/include/Extensions.h +++ b/Modules/CryptoLib/Sources/CryptoObjC/include/Extensions.h @@ -23,9 +23,11 @@ #include static const NSInteger CryptoLibWrongKeyErrorCode = -109; // libcdoc::WRONG_KEY +static const NSInteger CryptoLibNetworkErrorCode = -300; // libcdoc::NetworkBackend::NETWORK_ERROR @interface NSError (CryptoLib) + (NSError*)cryptoError:(NSString*)msg; ++ (NSError*)cryptoError:(NSString*)msg code:(NSInteger)code; + (id)cryptoError:(NSString*)msg error:(NSError**)error; + (id)cryptoWrongKeyError:(NSError**)error; @end @@ -71,7 +73,11 @@ static const NSInteger CryptoLibWrongKeyErrorCode = -109; // libcdoc::WRONG_KEY @implementation NSError (CryptoLib) + (NSError*)cryptoError:(NSString *)msg { - return [[NSError alloc] initWithDomain:@"ee.ria.digidoc.CryptoLib" code:1000 userInfo: @{NSLocalizedDescriptionKey: msg}]; + return [NSError cryptoError:msg code:1000]; +} + ++ (NSError*)cryptoError:(NSString *)msg code:(NSInteger)code { + return [[NSError alloc] initWithDomain:@"ee.ria.digidoc.CryptoLib" code:code userInfo: @{NSLocalizedDescriptionKey: msg}]; } + (id)cryptoError:(NSString*)msg error:(NSError**)error { diff --git a/Modules/CryptoLib/Sources/CryptoSwift/Domain/Models/OpenLdapSearchResult.swift b/Modules/CryptoLib/Sources/CryptoSwift/Domain/Models/OpenLdapSearchResult.swift index eff3c785..5e97fda1 100644 --- a/Modules/CryptoLib/Sources/CryptoSwift/Domain/Models/OpenLdapSearchResult.swift +++ b/Modules/CryptoLib/Sources/CryptoSwift/Domain/Models/OpenLdapSearchResult.swift @@ -22,4 +22,11 @@ import CryptoObjCWrapper public struct OpenLdapSearchResult: Sendable { public var addressees: [Addressee] public var tooManyResults: Bool + public var isNetworkError: Bool + + public init(addressees: [Addressee], tooManyResults: Bool, isNetworkError: Bool = false) { + self.addressees = addressees + self.tooManyResults = tooManyResults + self.isNetworkError = isNetworkError + } } diff --git a/Modules/CryptoLib/Sources/CryptoSwift/Errors/NSError+CryptoNetwork.swift b/Modules/CryptoLib/Sources/CryptoSwift/Errors/NSError+CryptoNetwork.swift new file mode 100644 index 00000000..cfa9e65a --- /dev/null +++ b/Modules/CryptoLib/Sources/CryptoSwift/Errors/NSError+CryptoNetwork.swift @@ -0,0 +1,27 @@ +/* + * 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 + +public extension NSError { + // libcdoc NetworkBackend::NETWORK_ERROR + var isCryptoNetworkError: Bool { + return domain == "ee.ria.digidoc.CryptoLib" && code == -300 + } +} diff --git a/Modules/CryptoLib/Sources/CryptoSwift/Ldap/OpenLdap.swift b/Modules/CryptoLib/Sources/CryptoSwift/Ldap/OpenLdap.swift index c3653623..278fc4f3 100644 --- a/Modules/CryptoLib/Sources/CryptoSwift/Ldap/OpenLdap.swift +++ b/Modules/CryptoLib/Sources/CryptoSwift/Ldap/OpenLdap.swift @@ -53,6 +53,22 @@ final public class OpenLdap: OpenLdapProtocol, Loggable { case decipherOnly = 8 } + private static let unreachableServerCodes: Set = [ + LDAP_SERVER_DOWN, LDAP_CONNECT_ERROR, LDAP_TIMEOUT, LDAP_X_CONNECTING + ] + + private struct SearchOutcome { + let addressees: [Addressee] + let totalAddressees: Int + let isNetworkError: Bool + + init(addressees: [Addressee] = [], totalAddressees: Int = 0, isNetworkError: Bool = false) { + self.addressees = addressees + self.totalAddressees = totalAddressees + self.isNetworkError = isNetworkError + } + } + enum SearchType { case personalCode(String) case registryCode(String) @@ -99,33 +115,39 @@ final public class OpenLdap: OpenLdapProtocol, Loggable { OpenLdap.logger().info("Searching with personal code from LDAP") var result = [Addressee]() var tooManyResults = false + var isNetworkError = false for url in await self.ldapConfiguration.getLdapPersonURLS() { let ldapPersonUrl = url - let (addresses, found) = OpenLdap.search( + let outcome = OpenLdap.search( searchType: searchType, url: ldapPersonUrl, certificatePath: filePath ) - result.append(contentsOf: addresses) - if found >= 50 { + result.append(contentsOf: outcome.addressees) + if outcome.totalAddressees >= 50 { tooManyResults = true } + if outcome.isNetworkError { + isNetworkError = true + } } return OpenLdapSearchResult( addressees: result, - tooManyResults: tooManyResults + tooManyResults: tooManyResults, + isNetworkError: isNetworkError ) } else { if let ldapCorpURL = await self.ldapConfiguration.getLdapCorpURL() { OpenLdap.logger().info("Searching with corporation keyword from LDAP") - let (addresses, found) = OpenLdap.search( + let outcome = OpenLdap.search( searchType: searchType, url: ldapCorpURL, certificatePath: filePath ) return OpenLdapSearchResult( - addressees: addresses, - tooManyResults: found >= 50 + addressees: outcome.addressees, + tooManyResults: outcome.totalAddressees >= 50, + isNetworkError: outcome.isNetworkError ) } else { return OpenLdapSearchResult( @@ -141,13 +163,15 @@ final public class OpenLdap: OpenLdapProtocol, Loggable { searchType: SearchType, url: URL, certificatePath: String? - ) -> (addressees: [Addressee], totalAddressees: Int) { + ) -> SearchOutcome { if url.scheme?.lowercased() == "ldaps" { if let certificatePath = certificatePath, !certificatePath.isEmpty { - guard setLdapOption(option: LDAP_OPT_X_TLS_CACERTFILE, value: certificatePath) else { return ([], 0) } + guard setLdapOption(option: LDAP_OPT_X_TLS_CACERTFILE, value: certificatePath) else { + return SearchOutcome() + } } else { - guard let bundlePath = Bundle(for: OpenLdap.self).resourcePath else { return ([], 0) } - guard setLdapOption(option: LDAP_OPT_X_TLS_CACERTDIR, value: bundlePath) else { return ([], 0) } + guard let bundlePath = Bundle(for: OpenLdap.self).resourcePath else { return SearchOutcome() } + guard setLdapOption(option: LDAP_OPT_X_TLS_CACERTDIR, value: bundlePath) else { return SearchOutcome() } } var ldapConnectionReset = 0 let result = ldap_set_option(nil, LDAP_OPT_X_TLS_NEWCTX, &ldapConnectionReset) @@ -155,7 +179,7 @@ final public class OpenLdap: OpenLdapProtocol, Loggable { OpenLdap.logger().info( "ldap_set_option(LDAP_OPT_X_TLS_NEWCTX) failed: \(String(cString: ldap_err2string(result)))" ) - return ([], 0) + return SearchOutcome() } } @@ -175,7 +199,7 @@ final public class OpenLdap: OpenLdapProtocol, Loggable { } guard ldapReturnCode == LDAP_SUCCESS else { OpenLdap.logger().info("Failed to initialize LDAP: \(String(cString: ldap_err2string(ldapReturnCode)))") - return ([], 0) + return SearchOutcome() } var ldapVersion = LDAP_VERSION3 @@ -184,7 +208,7 @@ final public class OpenLdap: OpenLdapProtocol, Loggable { OpenLdap.logger().info( "ldap_set_option(PROTOCOL_VERSION) failed: \(String(cString: ldap_err2string(ldapReturnCode)))" ) - return ([], 0) + return SearchOutcome() } var distinguishedName = url.path @@ -217,7 +241,7 @@ final public class OpenLdap: OpenLdapProtocol, Loggable { guard ldapReturnCode == LDAP_SUCCESS else { OpenLdap.logger().info("ldap_search_ext failed: \(String(cString: ldap_err2string(ldapReturnCode)))") - return ([], 0) + return SearchOutcome(isNetworkError: unreachableServerCodes.contains(ldapReturnCode)) } var result = [Addressee]() @@ -234,18 +258,22 @@ final public class OpenLdap: OpenLdapProtocol, Loggable { result.append(contentsOf: addressees) totalAddressees += 1 case Int32(LDAP_RES_SEARCH_RESULT): - return (addressees: result, totalAddressees: totalAddressees) + return SearchOutcome(addressees: result, totalAddressees: totalAddressees) case Int32(LDAP_SUCCESS): break default: OpenLdap.logger().info("ldap_result failed: \(String(cString: ldap_err2string(ldapReturnCode)))") - return (addressees: result, totalAddressees: totalAddressees) + return SearchOutcome( + addressees: result, + totalAddressees: totalAddressees, + isNetworkError: unreachableServerCodes.contains(ldapReturnCode) + ) } } ldap_abandon_ext(ldap, msgId, nil, nil) - return (addressees: result, totalAddressees: totalAddressees) + return SearchOutcome(addressees: result, totalAddressees: totalAddressees) } static private func setLdapOption(option: Int32, value: String) -> Bool { diff --git a/Modules/CryptoLib/Tests/CryptoSwiftTests/NSErrorCryptoNetworkTests.swift b/Modules/CryptoLib/Tests/CryptoSwiftTests/NSErrorCryptoNetworkTests.swift new file mode 100644 index 00000000..fcfe0b92 --- /dev/null +++ b/Modules/CryptoLib/Tests/CryptoSwiftTests/NSErrorCryptoNetworkTests.swift @@ -0,0 +1,47 @@ +/* + * 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 + +@testable import CryptoSwift + +struct NSErrorCryptoNetworkTests { + + @Test + func isCryptoNetworkError_trueForLibcdocNetworkErrorFromCryptoLib() { + let error = NSError(domain: "ee.ria.digidoc.CryptoLib", code: -300) + + #expect(error.isCryptoNetworkError) + } + + @Test + func isCryptoNetworkError_falseForOtherCryptoLibCodes() { + let error = NSError(domain: "ee.ria.digidoc.CryptoLib", code: 1000) + + #expect(!error.isCryptoNetworkError) + } + + @Test + func isCryptoNetworkError_falseForSameCodeFromAnotherDomain() { + let error = NSError(domain: "LibdigidocLib", code: -300) + + #expect(!error.isCryptoNetworkError) + } +} diff --git a/Modules/LibdigidocLib/Sources/LibdigidocSwift/Domain/Models/ErrorDetail.swift b/Modules/LibdigidocLib/Sources/LibdigidocSwift/Domain/Models/ErrorDetail.swift index 2a8755c0..d03f5678 100644 --- a/Modules/LibdigidocLib/Sources/LibdigidocSwift/Domain/Models/ErrorDetail.swift +++ b/Modules/LibdigidocLib/Sources/LibdigidocSwift/Domain/Models/ErrorDetail.swift @@ -21,6 +21,8 @@ import Foundation import LibdigidocLibObjC public struct ErrorDetail: Sendable { + private static let networkErrorCode = 20 + public let message: String public let code: Int public let userInfo: [String: Sendable] @@ -44,6 +46,11 @@ public struct ErrorDetail: Sendable { .merging(extraInfo) { (_, combined) in combined } } + // libdigidocpp reports connection failures as Exception::NetworkError (20) + public var isNetworkError: Bool { + return code == ErrorDetail.networkErrorCode + } + public var description: String { return """ Error: \(self.message) diff --git a/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/Errors/DigiDocErrorTests.swift b/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/Errors/DigiDocErrorTests.swift index 5171d6b9..4ec966b1 100644 --- a/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/Errors/DigiDocErrorTests.swift +++ b/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/Errors/DigiDocErrorTests.swift @@ -59,6 +59,20 @@ final class DigiDocErrorTests { #expect(retrievedDetail.userInfo.isEmpty) } + @Test + func isNetworkError_trueWhenCodeIsLibdigidocppNetworkError() { + let errorDetail = ErrorDetail(message: "Failed to create connection with host", code: 20) + + #expect(errorDetail.isNetworkError) + } + + @Test + func isNetworkError_falseWhenCodeIsGeneral() { + let errorDetail = ErrorDetail(message: "Failed to send request to SiVa", code: 0) + + #expect(!errorDetail.isNetworkError) + } + @Test func errorDetailDescription_successWithContainerOpeningFailedError() { let errorDetail = ErrorDetail(message: "An error occurred", code: 123, userInfo: ["reason": "test case"]) diff --git a/RIADigiDoc/Domain/Model/ToastMessage.swift b/RIADigiDoc/Domain/Model/ToastMessage.swift index 834c60dd..ea7afd12 100644 --- a/RIADigiDoc/Domain/Model/ToastMessage.swift +++ b/RIADigiDoc/Domain/Model/ToastMessage.swift @@ -18,6 +18,7 @@ */ import Foundation +import LibdigidocLibSwift struct ToastMessage: Sendable, Equatable { let key: String @@ -28,3 +29,13 @@ struct ToastMessage: Sendable, Equatable { self.args = args } } + +extension ToastMessage { + static func containerOpeningFailed(fileName: String, error: Error) -> ToastMessage { + guard let digiDocError = error as? DigiDocError, digiDocError.errorDetail.isNetworkError else { + return ToastMessage(key: "Failed to open container", args: [fileName]) + } + + return ToastMessage(key: "No Internet connection") + } +} diff --git a/RIADigiDoc/Domain/NFC/OperationDecrypt.swift b/RIADigiDoc/Domain/NFC/OperationDecrypt.swift index 8e90f11b..8dec1bc4 100644 --- a/RIADigiDoc/Domain/NFC/OperationDecrypt.swift +++ b/RIADigiDoc/Domain/NFC/OperationDecrypt.swift @@ -151,6 +151,13 @@ public class OperationDecrypt: NFCOperationBase, OperationDecryptProtocol { return } + if (error as NSError).isCryptoNetworkError { + OperationDecrypt.logger().error("NFC: Unable to reach the key server") + operationError = error + session.invalidate(errorMessage: strings?.networkErrorMessage ?? "") + return + } + handleUnknownError(error, session: session) } } diff --git a/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift b/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift index f6d9bb75..32a912bd 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift @@ -443,8 +443,9 @@ struct EncryptView: View { } } - case .failure: + case .failure(let error): isImportingAddedFiles = false + viewModel.handleFileImportFailure(error) } } } @@ -618,7 +619,7 @@ struct EncryptView: View { private func handleRemoveRecipient() async { guard let recipient = selectedRecipient else { - Toast.show(languageSettings.localized("Failed to remove recipient from container")) + showMessage("Failed to remove recipient from container") return } @@ -629,7 +630,7 @@ struct EncryptView: View { private func handleRemoveDataFile() async { guard let dataFile = selectedDataFile else { - Toast.show(languageSettings.localized("Failed to remove datafile from container", [""])) + showMessage("Failed to remove datafile from container") return } @@ -647,10 +648,7 @@ struct EncryptView: View { private func convertToSignedContainer() async { let isConverted = await viewModel.convertToSignedContainer() if isConverted { - Toast.show( - languageSettings.localized("Converted to a signature container"), - type: .success - ) + showMessage("Converted to a signature container", type: .success) await MainActor.run { pathManager.replaceLast(to: .signingView) } diff --git a/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift b/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift index 4df32a32..8820e176 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift @@ -41,8 +41,6 @@ struct EncryptRecipientView: View { @State private var encryptionButtonEnabled = true - @State private var showNoRecipientsFoundMessage = false - @State private var selectedRecipient: Addressee? @State private var showRemoveRecipientModal = false @State private var showPasswordEncryptModal = false @@ -80,10 +78,6 @@ struct EncryptRecipientView: View { languageSettings.localized("Next") } - var noSearchResultsMessage: String { - languageSettings.localized("Person or company does not own a valid certificate") - } - private var recipientTabTitle: String { languageSettings.localized("Encrypt based on recipient") } @@ -286,12 +280,8 @@ struct EncryptRecipientView: View { .listStyle(.plain) .scrollDisabled(true) .scrollContentBackground(.hidden) - } else if showNoRecipientsFoundMessage { - emptyStateView( - languageSettings.localized( - "Person or company does not own a valid certificate" - ) - ) + } else if let emptyStateMessageKey = viewModel.emptyStateMessageKey { + emptyStateView(languageSettings.localized(emptyStateMessageKey)) } else { filteredRecipientsSection } @@ -394,7 +384,7 @@ struct EncryptRecipientView: View { message: languageSettings.localized("Remove recipient from container"), onConfirm: { guard let recipient = selectedRecipient else { - Toast.show(languageSettings.localized("Failed to remove recipient")) + showMessage("Failed to remove recipient") return } Task { @@ -418,9 +408,6 @@ struct EncryptRecipientView: View { addedRecipients = await viewModel.filteredAddedRecipients() } } - .onChange(of: viewModel.searchText) { _, _ in - showNoRecipientsFoundMessage = false - } .onChange(of: viewModel.errorMessage) { _, error in guard let error, !error.key.isEmpty else { return } diff --git a/RIADigiDoc/UI/Component/Container/Signing/SigningView.swift b/RIADigiDoc/UI/Component/Container/Signing/SigningView.swift index db75f08d..6ef3ee27 100644 --- a/RIADigiDoc/UI/Component/Container/Signing/SigningView.swift +++ b/RIADigiDoc/UI/Component/Container/Signing/SigningView.swift @@ -401,8 +401,9 @@ struct SigningView: View { } } - case .failure: + case .failure(let error): isImportingAddedFiles = false + viewModel.handleFileImportFailure(error) } } } @@ -584,9 +585,15 @@ struct SigningView: View { } } + private func showMessage(_ key: String, type: ToastType = .error) { + let message = languageSettings.localized(key) + Toast.show(message, type: type) + AccessibilityUtil.announceMessage(message) + } + private func handleRemoveSignature() async { guard let signature = selectedSignature else { - Toast.show(languageSettings.localized("Failed to remove signature from container")) + showMessage("Failed to remove signature from container") return } @@ -598,7 +605,7 @@ struct SigningView: View { private func handleRemoveDataFile() async { guard let dataFile = selectedDataFile else { - Toast.show(languageSettings.localized("Failed to remove datafile from container", [""])) + showMessage("Failed to remove datafile from container") return } @@ -627,10 +634,7 @@ struct SigningView: View { private func convertToCryptoContainer() async { let isConverted = await viewModel.convertToCryptoContainer() if isConverted { - Toast.show( - languageSettings.localized("Converted to crypto container"), - type: .success - ) + showMessage("Converted to crypto container", type: .success) let cdocOption = await Container.shared.dataStore().getEncryptionCdocOption(false) await MainActor.run { pathManager.replaceLast( diff --git a/RIADigiDoc/UI/Component/EncryptionSettingsView.swift b/RIADigiDoc/UI/Component/EncryptionSettingsView.swift index e2da547f..1e584a20 100644 --- a/RIADigiDoc/UI/Component/EncryptionSettingsView.swift +++ b/RIADigiDoc/UI/Component/EncryptionSettingsView.swift @@ -120,8 +120,10 @@ struct EncryptionSettingsView: View { await viewModel.importCert(from: url) } viewModel.isImportingCert = false - case .failure: + case .failure(let error): viewModel.isImportingCert = false + viewModel.handleFileImportFailure(error) + showFileImportFailureMessage() } } } @@ -312,6 +314,12 @@ struct EncryptionSettingsView: View { ) .buttonStyle(.plain) } + + private func showFileImportFailureMessage() { + let message = languageSettings.localized("Could not load selected files") + Toast.show(message) + AccessibilityUtil.announceMessage(message) + } } // MARK: - Preview diff --git a/RIADigiDoc/UI/Component/HomeView/CryptoImportButton.swift b/RIADigiDoc/UI/Component/HomeView/CryptoImportButton.swift index 8ad31a92..2c8969aa 100644 --- a/RIADigiDoc/UI/Component/HomeView/CryptoImportButton.swift +++ b/RIADigiDoc/UI/Component/HomeView/CryptoImportButton.swift @@ -20,6 +20,8 @@ import SwiftUI struct CryptoImportButton: View { + @Environment(LanguageSettings.self) private var languageSettings + let title: String let titleAccessibility: String let description: String @@ -86,8 +88,10 @@ struct CryptoImportButton: View { url.stopAccessingSecurityScopedResource() } - case .failure: + case .failure(let error): isImporting = false + viewModel.handleFileImportFailure(error) + showFileImportFailureMessage() } } .fullScreenCover(isPresented: $isFileOpeningLoading) { @@ -97,4 +101,10 @@ struct CryptoImportButton: View { ) } } + + private func showFileImportFailureMessage() { + let message = languageSettings.localized("Could not load selected files") + Toast.show(message) + AccessibilityUtil.announceMessage(message) + } } diff --git a/RIADigiDoc/UI/Component/HomeView/SigningImportButton.swift b/RIADigiDoc/UI/Component/HomeView/SigningImportButton.swift index 5ea7f4f3..e5a8ac1a 100644 --- a/RIADigiDoc/UI/Component/HomeView/SigningImportButton.swift +++ b/RIADigiDoc/UI/Component/HomeView/SigningImportButton.swift @@ -21,6 +21,8 @@ import SwiftUI import FactoryKit struct SigningImportButton: View { + @Environment(LanguageSettings.self) private var languageSettings + let title: String let titleAccessibility: String let description: String @@ -90,8 +92,10 @@ struct SigningImportButton: View { url.stopAccessingSecurityScopedResource() } - case .failure: + case .failure(let error): isImporting = false + viewModel.handleFileImportFailure(error) + showFileImportFailureMessage() } } .fullScreenCover(isPresented: $isFileOpeningLoading) { @@ -102,4 +106,10 @@ struct SigningImportButton: View { ) } } + + private func showFileImportFailureMessage() { + let message = languageSettings.localized("Could not load selected files") + Toast.show(message) + AccessibilityUtil.announceMessage(message) + } } diff --git a/RIADigiDoc/UI/Component/SigningServicesSettingsView/TimeStampSettingsView.swift b/RIADigiDoc/UI/Component/SigningServicesSettingsView/TimeStampSettingsView.swift index 5c0da586..77b1f16d 100644 --- a/RIADigiDoc/UI/Component/SigningServicesSettingsView/TimeStampSettingsView.swift +++ b/RIADigiDoc/UI/Component/SigningServicesSettingsView/TimeStampSettingsView.swift @@ -98,11 +98,19 @@ struct TimeStampSettingsView: View { await viewModel.importTSACert(from: url) } viewModel.isImportingTSACert = false - case .failure: + case .failure(let error): viewModel.isImportingTSACert = false + viewModel.handleFileImportFailure(error) + showFileImportFailureMessage() } } } + + private func showFileImportFailureMessage() { + let message = languageSettings.localized("Could not load selected files") + Toast.show(message) + AccessibilityUtil.announceMessage(message) + } } // MARK: - Preview diff --git a/RIADigiDoc/UI/Component/ValidationSettingsView.swift b/RIADigiDoc/UI/Component/ValidationSettingsView.swift index c542a3b1..37c10498 100644 --- a/RIADigiDoc/UI/Component/ValidationSettingsView.swift +++ b/RIADigiDoc/UI/Component/ValidationSettingsView.swift @@ -113,11 +113,19 @@ struct ValidationSettingsView: View { await viewModel.importSiVaCert(from: url) } viewModel.isImportingCert = false - case .failure: + case .failure(let error): viewModel.isImportingCert = false + viewModel.handleFileImportFailure(error) + showFileImportFailureMessage() } } } + + private func showFileImportFailureMessage() { + let message = languageSettings.localized("Could not load selected files") + Toast.show(message) + AccessibilityUtil.announceMessage(message) + } } // MARK: - Preview diff --git a/RIADigiDoc/ViewModel/CryptoHomeViewModel.swift b/RIADigiDoc/ViewModel/CryptoHomeViewModel.swift index 2bd3583c..d36816f7 100644 --- a/RIADigiDoc/ViewModel/CryptoHomeViewModel.swift +++ b/RIADigiDoc/ViewModel/CryptoHomeViewModel.swift @@ -70,4 +70,11 @@ class CryptoHomeViewModel: CryptoHomeViewModelProtocol, Loggable { return nil } } + + func handleFileImportFailure(_ error: Error) { + let nsError = error as NSError + CryptoHomeViewModel.logger().error( + "File import failed: \(nsError.domain, privacy: .public) \(nsError.code, privacy: .public)" + ) + } } diff --git a/RIADigiDoc/ViewModel/EncryptRecipientViewModel.swift b/RIADigiDoc/ViewModel/EncryptRecipientViewModel.swift index c16bb348..7c2ecc81 100644 --- a/RIADigiDoc/ViewModel/EncryptRecipientViewModel.swift +++ b/RIADigiDoc/ViewModel/EncryptRecipientViewModel.swift @@ -33,6 +33,7 @@ class EncryptRecipientViewModel: EncryptRecipientViewModelProtocol, Loggable { var searchText: String = "" private(set) var successMessage: ToastMessage? private(set) var errorMessage: ToastMessage? + private(set) var emptyStateMessageKey: String? private let sharedContainerViewModel: SharedContainerViewModelProtocol private let openLdap: OpenLdapProtocol @@ -79,27 +80,42 @@ class EncryptRecipientViewModel: EncryptRecipientViewModelProtocol, Loggable { } func loadRecipients() async { - if !searchText.isEmpty { - let result = await openLdap.search(identityCode: searchText) - - if result.tooManyResults { - recipients = [] - errorMessage = ToastMessage(key: "Too many results", args: []) - EncryptRecipientViewModel.logger().error("Too many results for \(self.searchText)") - } else if result.addressees.isEmpty { - recipients = [] - errorMessage = ToastMessage(key: "Person or company does not own a valid certificate", args: []) - EncryptRecipientViewModel.logger().error("No recipients found for \(self.searchText)") + guard !searchText.isEmpty else { + recipients = [] + emptyStateMessageKey = nil + return + } + + let result = await openLdap.search(identityCode: searchText) + + if result.tooManyResults { + recipients = [] + emptyStateMessageKey = nil + errorMessage = ToastMessage(key: "Too many results", args: []) + EncryptRecipientViewModel.logger().error("Too many results for \(self.searchText)") + } else if result.addressees.isEmpty { + let messageKey = result.isNetworkError + ? "No Internet connection" + : "Person or company does not own a valid certificate" + + if result.isNetworkError { + EncryptRecipientViewModel.logger().error("Unable to reach the LDAP server") } else { - recipients = result.addressees + EncryptRecipientViewModel.logger().error("No recipients found for \(self.searchText)") } - } else { + recipients = [] + emptyStateMessageKey = messageKey + errorMessage = ToastMessage(key: messageKey, args: []) + } else { + recipients = result.addressees + emptyStateMessageKey = nil } } func handleSearchTextChange() { recipients = [] + emptyStateMessageKey = nil } func getContainerRecipientList() async -> [Addressee] { diff --git a/RIADigiDoc/ViewModel/EncryptViewModel.swift b/RIADigiDoc/ViewModel/EncryptViewModel.swift index 18ab7bbe..d89d0200 100644 --- a/RIADigiDoc/ViewModel/EncryptViewModel.swift +++ b/RIADigiDoc/ViewModel/EncryptViewModel.swift @@ -324,6 +324,11 @@ class EncryptViewModel: EncryptViewModelProtocol, Loggable { } private func handleEncryptionError(_ error: Error) { + if (error as NSError).isCryptoNetworkError { + errorMessage = ToastMessage(key: "No Internet connection", args: []) + return + } + if let cryptoError = error as? CryptoError { switch cryptoError { case .containerCreationFailed(let detail): @@ -417,7 +422,10 @@ class EncryptViewModel: EncryptViewModelProtocol, Loggable { SigningViewModel.logger().error( "Failed to open nested signed container: \(String(reflecting: error))" ) - errorMessage = ToastMessage(key: "Failed to open container", args: [dataFile.lastPathComponent]) + errorMessage = ToastMessage.containerOpeningFailed( + fileName: dataFile.lastPathComponent, + error: error + ) return } } else { @@ -785,4 +793,12 @@ class EncryptViewModel: EncryptViewModelProtocol, Loggable { return try await sivaRepository .getTimestampedContainer(parentContainer: parentContainer) } + + func handleFileImportFailure(_ error: Error) { + let nsError = error as NSError + EncryptViewModel.logger().error( + "File import failed: \(nsError.domain, privacy: .public) \(nsError.code, privacy: .public)" + ) + errorMessage = ToastMessage(key: "Could not load selected files", args: []) + } } diff --git a/RIADigiDoc/ViewModel/EncryptionSettingsViewModel.swift b/RIADigiDoc/ViewModel/EncryptionSettingsViewModel.swift index dcf86cc3..0b55907a 100644 --- a/RIADigiDoc/ViewModel/EncryptionSettingsViewModel.swift +++ b/RIADigiDoc/ViewModel/EncryptionSettingsViewModel.swift @@ -205,4 +205,11 @@ class EncryptionSettingsViewModel: EncryptionSettingsViewModelProtocol, Loggable certificateBaseName: CommonsLib.Constants.FileBaseName.EncryptionKeyTransferCert ) } + + func handleFileImportFailure(_ error: Error) { + let nsError = error as NSError + EncryptionSettingsViewModel.logger().error( + "File import failed: \(nsError.domain, privacy: .public) \(nsError.code, privacy: .public)" + ) + } } diff --git a/RIADigiDoc/ViewModel/FileOpeningViewModel.swift b/RIADigiDoc/ViewModel/FileOpeningViewModel.swift index 2f0b9f73..e3839c8c 100644 --- a/RIADigiDoc/ViewModel/FileOpeningViewModel.swift +++ b/RIADigiDoc/ViewModel/FileOpeningViewModel.swift @@ -317,9 +317,9 @@ class FileOpeningViewModel: FileOpeningViewModelProtocol, Loggable { case .containerCreationFailed(let errorDetail), .containerOpeningFailed(let errorDetail), .containerSavingFailed(let errorDetail): - return ToastMessage( - key: "Failed to open container", - args: [errorDetail.userInfo["fileName"] as? String ?? ""] + return ToastMessage.containerOpeningFailed( + fileName: errorDetail.userInfo["fileName"] as? String ?? "", + error: error ) case .addingFilesToContainerFailed(let errorDetail): return ToastMessage( diff --git a/RIADigiDoc/ViewModel/HomeViewModel.swift b/RIADigiDoc/ViewModel/HomeViewModel.swift index 429ff2ca..09bfdf1c 100644 --- a/RIADigiDoc/ViewModel/HomeViewModel.swift +++ b/RIADigiDoc/ViewModel/HomeViewModel.swift @@ -96,4 +96,11 @@ class HomeViewModel: HomeViewModelProtocol, Loggable { return [] } } + + func handleFileImportFailure(_ error: Error) { + let nsError = error as NSError + HomeViewModel.logger().error( + "File import failed: \(nsError.domain, privacy: .public) \(nsError.code, privacy: .public)" + ) + } } diff --git a/RIADigiDoc/ViewModel/Signing/MobileId/MobileIdViewModel.swift b/RIADigiDoc/ViewModel/Signing/MobileId/MobileIdViewModel.swift index 6c33e283..af31b3c6 100644 --- a/RIADigiDoc/ViewModel/Signing/MobileId/MobileIdViewModel.swift +++ b/RIADigiDoc/ViewModel/Signing/MobileId/MobileIdViewModel.swift @@ -513,9 +513,7 @@ class MobileIdViewModel: MobileIdViewModelProtocol, Loggable { let tooManyRequestsError = "Too Many Requests" let ocspError = "OCSP response not in valid time slot" let revokedCertError = "Certificate status: revoked" - let connectError = "CONNECT: 403" - let failedToConnectError = "Failed to connect" - let proxyError = "Failed to authenticate with proxy" + let proxyError = "Failed to create proxy connection with host" switch true { case message.contains(sslError): @@ -535,12 +533,12 @@ class MobileIdViewModel: MobileIdViewModelProtocol, Loggable { case message.contains(revokedCertError): mobileIdErrorMessageKey = "Certificate status revoked" - case message.contains(connectError), message.contains(failedToConnectError): - mobileIdErrorMessageKey = "No Internet connection" - case message.contains(proxyError): mobileIdErrorMessageKey = "Invalid proxy settings" + case errorDetail.isNetworkError: + mobileIdErrorMessageKey = "No Internet connection" + default: mobileIdErrorMessageKey = "Signing technical error" mobileIdAlertMessageExtraArguments = ["Mobile-ID"] diff --git a/RIADigiDoc/ViewModel/Signing/NFC/NFCViewModel.swift b/RIADigiDoc/ViewModel/Signing/NFC/NFCViewModel.swift index 9263fb7d..c5d74960 100644 --- a/RIADigiDoc/ViewModel/Signing/NFC/NFCViewModel.swift +++ b/RIADigiDoc/ViewModel/Signing/NFC/NFCViewModel.swift @@ -360,6 +360,13 @@ class NFCViewModel: NFCViewModelProtocol, Loggable { return nil } + if (error as NSError).isCryptoNetworkError { + NFCViewModel.logger().error("NFC: Unable to reach the key server") + nfcErrorKey = "No Internet connection" + nfcErrorExtraArguments = [] + return nil + } + NFCViewModel.logger().error("NFC: Unexpected error type: \(type(of: error))") NFCViewModel.logger().error("NFC: Error details: \(error)") nfcErrorKey = "NFC session error" diff --git a/RIADigiDoc/ViewModel/Signing/SmartId/SmartIdViewModel.swift b/RIADigiDoc/ViewModel/Signing/SmartId/SmartIdViewModel.swift index 4f2fdc10..1d325f28 100644 --- a/RIADigiDoc/ViewModel/Signing/SmartId/SmartIdViewModel.swift +++ b/RIADigiDoc/ViewModel/Signing/SmartId/SmartIdViewModel.swift @@ -614,9 +614,7 @@ class SmartIdViewModel: SmartIdViewModelProtocol, Loggable { let tooManyRequestsError = "Too Many Requests" let ocspError = "OCSP response not in valid time slot" let revokedCertError = "Certificate status: revoked" - let connectError = "CONNECT: 403" - let failedToConnectError = "Failed to connect" - let proxyError = "Failed to authenticate with proxy" + let proxyError = "Failed to create proxy connection with host" switch true { case message.contains(sslError): @@ -636,12 +634,12 @@ class SmartIdViewModel: SmartIdViewModelProtocol, Loggable { case message.contains(revokedCertError): smartIdErrorMessageKey = "Certificate status revoked" - case message.contains(connectError), message.contains(failedToConnectError): - smartIdErrorMessageKey = "No Internet connection" - case message.contains(proxyError): smartIdErrorMessageKey = "Invalid proxy settings" + case errorDetail.isNetworkError: + smartIdErrorMessageKey = "No Internet connection" + default: smartIdErrorMessageKey = "Signing technical error" smartIdAlertMessageExtraArguments = ["Smart-ID"] diff --git a/RIADigiDoc/ViewModel/SigningViewModel.swift b/RIADigiDoc/ViewModel/SigningViewModel.swift index c8d49594..e2396bd6 100644 --- a/RIADigiDoc/ViewModel/SigningViewModel.swift +++ b/RIADigiDoc/ViewModel/SigningViewModel.swift @@ -401,8 +401,6 @@ class SigningViewModel: SigningViewModelProtocol, Loggable { } try await openNestedContainer(fileURL: fileURL, isSivaConfirmed: isSivaConfirmed) } catch { - SigningViewModel.logger().error("Failed to open nested container: \(error)") - errorMessage = ToastMessage(key: "Failed to open container", args: [dataFile.fileName]) if error.localizedDescription.contains("Online validation disabled") { SigningViewModel.logger().error( "Unable to open container '\([dataFile.fileName])'. Sending to SiVa not allowed." @@ -410,7 +408,10 @@ class SigningViewModel: SigningViewModelProtocol, Loggable { errorMessage = nil } else { SigningViewModel.logger().error("Failed to open nested container: \(error)") - errorMessage = ToastMessage(key: "Failed to open container", args: [dataFile.fileName]) + errorMessage = ToastMessage.containerOpeningFailed( + fileName: dataFile.fileName, + error: error + ) } } } else if isCryptoContainer { @@ -725,4 +726,12 @@ class SigningViewModel: SigningViewModelProtocol, Loggable { sharedContainerViewModel.setCryptoContainer(container) } + + func handleFileImportFailure(_ error: Error) { + let nsError = error as NSError + SigningViewModel.logger().error( + "File import failed: \(nsError.domain, privacy: .public) \(nsError.code, privacy: .public)" + ) + errorMessage = ToastMessage(key: "Could not load selected files", args: []) + } } diff --git a/RIADigiDoc/ViewModel/TimeStampSettingsViewModel.swift b/RIADigiDoc/ViewModel/TimeStampSettingsViewModel.swift index 241c8e32..3ffc5ad6 100644 --- a/RIADigiDoc/ViewModel/TimeStampSettingsViewModel.swift +++ b/RIADigiDoc/ViewModel/TimeStampSettingsViewModel.swift @@ -135,4 +135,11 @@ class TimeStampSettingsViewModel: TimeStampSettingsViewModelProtocol, Loggable { certificateBaseName: CommonsLib.Constants.FileBaseName.TSACert ) } + + func handleFileImportFailure(_ error: Error) { + let nsError = error as NSError + TimeStampSettingsViewModel.logger().error( + "File import failed: \(nsError.domain, privacy: .public) \(nsError.code, privacy: .public)" + ) + } } diff --git a/RIADigiDoc/ViewModel/ValidationSettingsViewModel.swift b/RIADigiDoc/ViewModel/ValidationSettingsViewModel.swift index 6b6ca1d1..391e1b76 100644 --- a/RIADigiDoc/ViewModel/ValidationSettingsViewModel.swift +++ b/RIADigiDoc/ViewModel/ValidationSettingsViewModel.swift @@ -131,4 +131,11 @@ class ValidationSettingsViewModel: ValidationSettingsViewModelProtocol, Loggable certificateBaseName: CommonsLib.Constants.FileBaseName.SiVaCert ) } + + func handleFileImportFailure(_ error: Error) { + let nsError = error as NSError + ValidationSettingsViewModel.logger().error( + "File import failed: \(nsError.domain, privacy: .public) \(nsError.code, privacy: .public)" + ) + } } diff --git a/RIADigiDocTests/ViewModel/EncryptRecipientViewModelTests.swift b/RIADigiDocTests/ViewModel/EncryptRecipientViewModelTests.swift index 8843c09e..43b31c4f 100644 --- a/RIADigiDocTests/ViewModel/EncryptRecipientViewModelTests.swift +++ b/RIADigiDocTests/ViewModel/EncryptRecipientViewModelTests.swift @@ -40,6 +40,66 @@ struct EncryptRecipientViewModelTests { ) } + @Test + func loadRecipients_showNoInternetConnectionWhenLdapServerIsUnreachable() async { + mockOpenLdap.searchHandler = { _ in + OpenLdapSearchResult(addressees: [], tooManyResults: false, isNetworkError: true) + } + + viewModel.searchText = "60001019906" + await viewModel.loadRecipients() + + #expect(viewModel.recipients.isEmpty) + #expect(viewModel.errorMessage?.key == "No Internet connection") + #expect(viewModel.emptyStateMessageKey == "No Internet connection") + } + + @Test + func loadRecipients_showNoValidCertificateWhenSearchCompletesWithNoResults() async { + mockOpenLdap.searchHandler = { _ in + OpenLdapSearchResult(addressees: [], tooManyResults: false, isNetworkError: false) + } + + viewModel.searchText = "60001019906" + await viewModel.loadRecipients() + + #expect(viewModel.recipients.isEmpty) + #expect(viewModel.errorMessage?.key == "Person or company does not own a valid certificate") + #expect(viewModel.emptyStateMessageKey == "Person or company does not own a valid certificate") + } + + @Test + func loadRecipients_showRecipientsWhenServerWasUnreachableButAnotherReturnedResults() async { + let addressee = Addressee(data: Data([0x01]), cnVal: "TESTNUMBER,SEITSMES,60001019906") + + mockOpenLdap.searchHandler = { _ in + OpenLdapSearchResult(addressees: [addressee], tooManyResults: false, isNetworkError: true) + } + + viewModel.searchText = "60001019906" + await viewModel.loadRecipients() + + #expect(viewModel.recipients.count == 1) + #expect(viewModel.errorMessage == nil) + #expect(viewModel.emptyStateMessageKey == nil) + } + + @Test + func handleSearchTextChange_clearsEmptyStateFromThePreviousSearch() async { + mockOpenLdap.searchHandler = { _ in + OpenLdapSearchResult(addressees: [], tooManyResults: false, isNetworkError: true) + } + + viewModel.searchText = "60001019906" + await viewModel.loadRecipients() + #expect(viewModel.emptyStateMessageKey == "No Internet connection") + + viewModel.handleSearchTextChange() + + #expect(viewModel.emptyStateMessageKey == nil) + #expect(viewModel.recipients.isEmpty) + } + @Test func encryptWithPassword_successClearsAndSetsNewContainer() async throws { let mockContainer = CryptoContainerProtocolMock() diff --git a/RIADigiDocTests/ViewModel/EncryptViewModelTests.swift b/RIADigiDocTests/ViewModel/EncryptViewModelTests.swift index 52388750..9b5a5ebb 100644 --- a/RIADigiDocTests/ViewModel/EncryptViewModelTests.swift +++ b/RIADigiDocTests/ViewModel/EncryptViewModelTests.swift @@ -116,6 +116,23 @@ struct EncryptViewModelTests { #expect(viewModel.successMessage == nil) } + @Test + func encryptContainer_showsNoInternetConnectionWhenKeyServerIsUnreachable() async { + _ = stubContainer() + viewModel.encryptAction = { _, _, _ in + throw NSError( + domain: "ee.ria.digidoc.CryptoLib", + code: -300, + userInfo: [NSLocalizedDescriptionKey: "Failed to start encryption"] + ) + } + + await viewModel.encryptContainer() + + #expect(viewModel.errorMessage == ToastMessage(key: "No Internet connection", args: [])) + #expect(viewModel.successMessage == nil) + } + @Test func encryptContainer_usesGeneralKeyForOtherCryptoErrors() async { _ = stubContainer() diff --git a/RIADigiDocTests/ViewModel/FileOpeningViewModelTests.swift b/RIADigiDocTests/ViewModel/FileOpeningViewModelTests.swift index 6b4c907e..035f5a5b 100644 --- a/RIADigiDocTests/ViewModel/FileOpeningViewModelTests.swift +++ b/RIADigiDocTests/ViewModel/FileOpeningViewModelTests.swift @@ -350,6 +350,79 @@ struct FileOpeningViewModelTests { #expect(!viewModel.isNavigatingToSigningView) } + @Test + func handleSivaConfirmation_showNoInternetConnectionWhenSivaRequestFailsWithNetworkError() async { + let mockContainer = try? TestContainerUtil.createMockContainer( + with: ["mimetype": Constants.MimeType.Ddoc], + containerExtension: Constants.Extension.Ddoc + ) + + guard let container = mockContainer else { + Issue.record("Expected a valid container URL") + return + } + + mockFileOpeningRepository.getValidFilesHandler = { _ in [container] } + mockFileUtil.removeSharedFilesHandler = { _ in } + mockFileManager.containerURLHandler = { _ in URL(fileURLWithPath: "/mock/appGroup/") } + mockFileManager.fileExistsHandler = { _ in true } + mockFileUtil.getFileFromZipFileHandler = { _, _ in URL(fileURLWithPath: "mimetype") } + mockSharedContainerViewModel.getFileOpeningMethodHandler = { .signing } + + mockFileOpeningRepository.openOrCreateContainerHandler = { _, _ in + throw DigiDocError.containerOpeningFailed( + ErrorDetail( + message: "Failed to create connection with host: 'siva.eesti.ee'", + code: 20 + ) + ) + } + + await viewModel.handleFiles() + await viewModel.handleSivaConfirmation() + + #expect(viewModel.errorMessage?.key == "No Internet connection") + #expect(viewModel.errorMessage?.args.isEmpty == true) + #expect(!viewModel.isNavigatingToSigningView) + } + + @Test + func handleSivaConfirmation_showFailedToOpenContainerWhenErrorIsNotNetworkRelated() async { + let mockContainer = try? TestContainerUtil.createMockContainer( + with: ["mimetype": Constants.MimeType.Ddoc], + containerExtension: Constants.Extension.Ddoc + ) + + guard let container = mockContainer else { + Issue.record("Expected a valid container URL") + return + } + + mockFileOpeningRepository.getValidFilesHandler = { _ in [container] } + mockFileUtil.removeSharedFilesHandler = { _ in } + mockFileManager.containerURLHandler = { _ in URL(fileURLWithPath: "/mock/appGroup/") } + mockFileManager.fileExistsHandler = { _ in true } + mockFileUtil.getFileFromZipFileHandler = { _, _ in URL(fileURLWithPath: "mimetype") } + mockSharedContainerViewModel.getFileOpeningMethodHandler = { .signing } + + mockFileOpeningRepository.openOrCreateContainerHandler = { _, _ in + throw DigiDocError.containerOpeningFailed( + ErrorDetail( + message: "Cannot create or open container", + code: 0, + userInfo: ["fileName": "test.ddoc"] + ) + ) + } + + await viewModel.handleFiles() + await viewModel.handleSivaConfirmation() + + #expect(viewModel.errorMessage?.key == "Failed to open container") + #expect(viewModel.errorMessage?.args == ["test.ddoc"]) + #expect(!viewModel.isNavigatingToSigningView) + } + @Test func handleSivaCancellation_handleDdocCancelling() async { let mockContainer = try? TestContainerUtil.createMockContainer( diff --git a/RIADigiDocTests/ViewModel/Signing/MobileId/MobileIdViewModelTests.swift b/RIADigiDocTests/ViewModel/Signing/MobileId/MobileIdViewModelTests.swift index b1ec1971..a4fb7934 100644 --- a/RIADigiDocTests/ViewModel/Signing/MobileId/MobileIdViewModelTests.swift +++ b/RIADigiDocTests/ViewModel/Signing/MobileId/MobileIdViewModelTests.swift @@ -404,7 +404,7 @@ struct MobileIdViewModelTests { mockProxyUtil.getProxyInfoHandler = { ProxyInfo() } let digidocError = DigiDocError.signatureAddingFailed( - ErrorDetail(message: "Failed to connect") + ErrorDetail(message: "Failed to create connection with host: 'ocsp.sk.ee'", code: 20) ) let container = mockContainer(addSignatureError: digidocError) @@ -555,7 +555,7 @@ struct MobileIdViewModelTests { mockProxyUtil.getProxyInfoHandler = { ProxyInfo() } let error = DigiDocError.signatureAddingFailed( - ErrorDetail(message: "Failed to create ssl connection with host") + ErrorDetail(message: "Failed to create ssl connection with host: 'ocsp.sk.ee'", code: 20) ) let container = mockContainer(addSignatureError: error) @@ -659,7 +659,7 @@ struct MobileIdViewModelTests { mockProxyUtil.getProxyInfoHandler = { ProxyInfo() } let error = DigiDocError.signatureAddingFailed( - ErrorDetail(message: "Failed to authenticate with proxy") + ErrorDetail(message: "Failed to create proxy connection with host: 'ocsp.sk.ee'", code: 20) ) let container = mockContainer(addSignatureError: error) diff --git a/RIADigiDocTests/ViewModel/Signing/NFC/NFCViewModelTests.swift b/RIADigiDocTests/ViewModel/Signing/NFC/NFCViewModelTests.swift index 96f50af8..2e2c11e6 100644 --- a/RIADigiDocTests/ViewModel/Signing/NFC/NFCViewModelTests.swift +++ b/RIADigiDocTests/ViewModel/Signing/NFC/NFCViewModelTests.swift @@ -1288,6 +1288,36 @@ final class NFCViewModelTests { #expect(viewModel.nfcErrorKey == "NFC session error") } + @Test + func decrypt_showsNoInternetConnectionWhenKeyServerIsUnreachable() async { + let mockContainer = CryptoContainerProtocolMock() + + mockContainer.getRawContainerFileHandler = { + URL(fileURLWithPath: "/tmp/test.cdoc") + } + mockContainer.getRecipientsHandler = { + [] + } + mockKeychainStore.removeHandler = { _ in } + mockOperationDecrypt.processDecryptHandler = { _, _, _, _, _ in + throw NSError( + domain: "ee.ria.digidoc.CryptoLib", + code: -300, + userInfo: [NSLocalizedDescriptionKey: "Failed to get FMK"] + ) + } + + let result = await viewModel.decrypt( + CAN: "123456", + pin1: "1234", + cryptoContainer: mockContainer, + strings: mockNFCSessionStrings + ) + + #expect(result == nil) + #expect(viewModel.nfcErrorKey == "No Internet connection") + } + @Test func decrypt_handlesDecryptCancelledWithoutErrorKey() async { let mockContainer = CryptoContainerProtocolMock() diff --git a/RIADigiDocTests/ViewModel/Signing/SmartId/SmartIdViewModelTests.swift b/RIADigiDocTests/ViewModel/Signing/SmartId/SmartIdViewModelTests.swift index 60310a04..e3083c69 100644 --- a/RIADigiDocTests/ViewModel/Signing/SmartId/SmartIdViewModelTests.swift +++ b/RIADigiDocTests/ViewModel/Signing/SmartId/SmartIdViewModelTests.swift @@ -1167,24 +1167,25 @@ struct SmartIdViewModelTests { @Test( "sign_setExpectedMessagesWhenSignatureAddingFailedErrorsThrown", arguments: [ - ("Failed to create ssl connection with host", "SSL handshake failed", false, []), - ("Too Many Requests", "Too many requests", true, ["Smart-ID"]), - ("OCSP response not in valid time slot", "OCSP response not in valid time slot", true, []), - ("Certificate status: revoked", "Certificate status revoked", false, []), - ("CONNECT: 403", "No Internet connection", false, []), - ("Failed to connect", "No Internet connection", false, []), - ("Failed to authenticate with proxy", "Invalid proxy settings", false, []), - ("Random error message", "Signing technical error", false, ["Smart-ID"]) + ("Failed to create ssl connection with host: 'siva.eesti.ee'", 20, "SSL handshake failed", false, []), + ("Too Many Requests", 18, "Too many requests", true, ["Smart-ID"]), + ("OCSP response not in valid time slot", 7, "OCSP response not in valid time slot", true, []), + ("Certificate status: revoked", 5, "Certificate status revoked", false, []), + ("Failed to create proxy connection with host: 'ocsp.sk.ee'", 20, "Invalid proxy settings", false, []), + ("Failed to create connection with host: 'ocsp.sk.ee'", 20, "No Internet connection", false, []), + ("Failed to create connection with host timeout: 'ocsp.sk.ee'", 20, "No Internet connection", false, []), + ("Random error message", 0, "Signing technical error", false, ["Smart-ID"]) ] ) func sign_setExpectedMessagesWhenSignatureAddingFailedErrorsThrown( errorMessage: String, + errorCode: Int, expectedMessage: String, expectsAlert: Bool, extraArg: [String] ) async { let digidocError = DigiDocError.signatureAddingFailed( - ErrorDetail(message: errorMessage) + ErrorDetail(message: errorMessage, code: errorCode) ) let mockContainer = mockContainer( addSignatureError: digidocError