diff --git a/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm b/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm index 11678f5a..7b33d3f8 100644 --- a/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm +++ b/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm @@ -53,7 +53,9 @@ - (instancetype)initWithLabel:(const std::string &)label pub:(NSData*)pub concat NSDate *validTo = nil; if (info.contains("server_exp")) { long long epochTime = [[NSString stringWithStdString:info["server_exp"]] longLongValue]; - validTo = [NSDate dateWithTimeIntervalSince1970:epochTime]; + if (epochTime > 0) { + validTo = [NSDate dateWithTimeIntervalSince1970:epochTime]; + } } if (self = [self initWithCnVal:cn serialNumber:serial certType:certTypeFromLabel(type) validTo:validTo data:pub concatKDFAlgorithmURI:concatKDFAlgorithmURI lockLabel:@"" lockType:@""]) { diff --git a/RIADigiDoc.xcodeproj/project.pbxproj b/RIADigiDoc.xcodeproj/project.pbxproj index 03cf38f7..3619da2d 100644 --- a/RIADigiDoc.xcodeproj/project.pbxproj +++ b/RIADigiDoc.xcodeproj/project.pbxproj @@ -166,6 +166,7 @@ DI/AppContainer.swift, Domain/Model/BottomSheetButton.swift, Domain/Model/CertificateExtensionData.swift, + Domain/Model/Crypto/RecipientDecryptionStatus.swift, Domain/Model/DependencyLicense.swift, Domain/Model/EncryptionCdocOption.swift, Domain/Model/EncryptionServerInfo.swift, diff --git a/RIADigiDoc/Domain/Model/Crypto/RecipientDecryptionStatus.swift b/RIADigiDoc/Domain/Model/Crypto/RecipientDecryptionStatus.swift new file mode 100644 index 00000000..ab788ec9 --- /dev/null +++ b/RIADigiDoc/Domain/Model/Crypto/RecipientDecryptionStatus.swift @@ -0,0 +1,71 @@ +/* + * 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 enum RecipientDecryptionStatus: Sendable { + case notEncrypted + case notEncryptedExpired + case expired + case valid + + public static func resolve( + validTo: Date?, + isCDOC2Container: Bool, + isEncryptedOrDecrypted: Bool, + now: Date = Date() + ) -> RecipientDecryptionStatus? { + guard isCDOC2Container, let validTo else { return nil } + + let isExpired = validTo < now + + switch (isEncryptedOrDecrypted, isExpired) { + case (false, true): return .notEncryptedExpired + case (false, false): return .notEncrypted + case (true, true): return .expired + case (true, false): return .valid + } + } + + public static func allExpiredDate( + validTos: [Date?], + isCDOC2Container: Bool, + now: Date = Date() + ) -> Date? { + guard isCDOC2Container, !validTos.isEmpty else { return nil } + + guard validTos.allSatisfy({ validTo in + guard let validTo else { return false } + return validTo < now + }) else { + return nil + } + + return validTos.compactMap { $0 }.max() + } + + public var localizationKey: String { + switch self { + case .notEncrypted: return "Expires on" + case .notEncryptedExpired: return "Expired on" + case .expired: return "Decryption expired" + case .valid: return "Decryption until" + } + } +} diff --git a/RIADigiDoc/Supporting files/Localizable.xcstrings b/RIADigiDoc/Supporting files/Localizable.xcstrings index 25ba3fcc..8054c76d 100644 --- a/RIADigiDoc/Supporting files/Localizable.xcstrings +++ b/RIADigiDoc/Supporting files/Localizable.xcstrings @@ -1493,6 +1493,24 @@ } } }, + "Crypto container must be decrypted" : { + "comment" : "Crypto container files placeholder when contents cannot be listed", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The container must be decrypted in order to see the contents of an encrypted container." + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Krüpteeritud ümbriku sisu nägemiseks tuleb ümbrik dekrüpteerida." + } + } + } + }, "Crypto files encrypted" : { "comment" : "Crypto container files names locked", "extractionState" : "manual", @@ -1817,6 +1835,42 @@ } } }, + "Decryption expired" : { + "comment" : "CDOC2 recipient decryption expired badge", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Decryption expired %@" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dekrüpteerimise võimalus aegus %@" + } + } + } + }, + "Decryption until" : { + "comment" : "CDOC2 recipient decryption valid until badge", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Decryption until %@" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dekrüpteerimine võimalik kuni %@" + } + } + } + }, "Decryption method" : { "comment" : "Title for signing method in container decrypt view", "extractionState" : "manual", @@ -2015,6 +2069,24 @@ } } }, + "Encrypted files" : { + "comment" : "Crypto container files tab title", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Encrypted files" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Krüpteeritud failid" + } + } + } + }, "Encrypt" : { "comment" : "Encrypt button", "extractionState" : "manual", @@ -2249,6 +2321,42 @@ } } }, + "Expired on" : { + "comment" : "CDOC2 recipient certificate expired badge", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expired on %@" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aegus %@" + } + } + } + }, + "Expires on" : { + "comment" : "CDOC2 recipient certificate expiry badge", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expires on %@" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aegub %@" + } + } + } + }, "Extend" : { "comment" : "Confirm button label in extend signatures dialog", "extractionState" : "manual", diff --git a/RIADigiDoc/UI/Component/Container/Crypto/CryptoDataFilesLockedSection.swift b/RIADigiDoc/UI/Component/Container/Crypto/CryptoDataFilesLockedSection.swift index 03131208..ea855704 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/CryptoDataFilesLockedSection.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/CryptoDataFilesLockedSection.swift @@ -26,6 +26,14 @@ struct CryptoDataFilesLockedSection: View { @AppTheme private var theme @AppTypography private var typography + var isDecryptionUnavailable = false + + private var message: String { + isDecryptionUnavailable + ? languageSettings.localized("Crypto container must be decrypted") + : languageSettings.localized("Crypto files encrypted") + } + var body: some View { VStack { HStack { @@ -37,7 +45,7 @@ struct CryptoDataFilesLockedSection: View { .padding(.trailing, Dimensions.Padding.SPadding) .accessibilityHidden(true) - Text(verbatim: languageSettings.localized("Crypto files encrypted")) + Text(verbatim: message) .font(typography.bodyMedium) .fixedSize(horizontal: false, vertical: true) .lineLimit(nil) @@ -45,7 +53,7 @@ struct CryptoDataFilesLockedSection: View { .multilineTextAlignment(TextAlignment.leading) .accessibilityLabel( Text( - verbatim: languageSettings.localized("Crypto files encrypted").lowercased() + verbatim: message.lowercased() ) ) diff --git a/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift b/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift index f6d9bb75..30ba81a8 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift @@ -120,6 +120,10 @@ struct EncryptView: View { languageSettings.localized("Container files") } + private var containerEncryptedFilesTitle: String { + languageSettings.localized("Encrypted files") + } + private var shareTitle: String { languageSettings.localized("Share") } @@ -338,7 +342,7 @@ struct EncryptView: View { .padding(.vertical, Dimensions.Padding.MPadding) } else { TabView(selectedTab: $selectedTab, titles: [ - containerFilesTitle, + containerEncryptedFilesTitle, containerRecipientsTitle ]) { if selectedTab == .files { @@ -358,7 +362,9 @@ struct EncryptView: View { $viewModel.navigateToNestedSignedContainerView ) } else { - CryptoDataFilesLockedSection() + CryptoDataFilesLockedSection( + isDecryptionUnavailable: viewModel.isDecryptionUnavailable + ) .environment(languageSettings) } } else { @@ -366,6 +372,8 @@ struct EncryptView: View { recipients: viewModel.recipients, selectedRecipient: $selectedRecipient, showRemoveRecipientButton: viewModel.isRecipientRemoveButtonShown(), + isCDOC2Container: viewModel.isContainerCDOC2, + isEncryptedOrDecrypted: viewModel.isContainerEncryptedOrDecrypted, showRemoveRecipientModal: $showRemoveRecipientModal, nameUtil: nameUtil, recipientUtil: recipientUtil diff --git a/RIADigiDoc/UI/Component/Container/Crypto/RecipientView.swift b/RIADigiDoc/UI/Component/Container/Crypto/RecipientView.swift index 0cb05cc8..a7c9e178 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/RecipientView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/RecipientView.swift @@ -40,6 +40,8 @@ struct RecipientView: View { let recipientUtil: RecipientUtilProtocol let showMoreOptionsButton: Bool var showRemoveRecipientButton: Bool + let isCDOC2Container: Bool + let isEncryptedOrDecrypted: Bool @Binding var showRemoveRecipientModal: Bool var onSelect: (() -> Void)? @@ -103,6 +105,14 @@ struct RecipientView: View { ).date } + var decryptionStatus: RecipientDecryptionStatus? { + RecipientDecryptionStatus.resolve( + validTo: recipient.validTo, + isCDOC2Container: isCDOC2Container, + isEncryptedOrDecrypted: isEncryptedOrDecrypted + ) + } + init( recipientIndex: Int, recipient: Addressee, @@ -111,6 +121,8 @@ struct RecipientView: View { recipientUtil: RecipientUtilProtocol = Container.shared.recipientUtil(), showMoreOptionsButton: Bool = true, showRemoveRecipientButton: Bool = true, + isCDOC2Container: Bool = false, + isEncryptedOrDecrypted: Bool = false, showRemoveRecipientModal: Binding, onSelect: (() -> Void)? = nil ) { @@ -120,6 +132,8 @@ struct RecipientView: View { self.recipientUtil = recipientUtil self.showMoreOptionsButton = showMoreOptionsButton self.showRemoveRecipientButton = showRemoveRecipientButton + self.isCDOC2Container = isCDOC2Container + self.isEncryptedOrDecrypted = isEncryptedOrDecrypted self._showRemoveRecipientModal = showRemoveRecipientModal self.onSelect = onSelect } @@ -149,7 +163,8 @@ struct RecipientView: View { ) let certType = recipientUtil.getRecipientCertTypeText(certType: recipient.certType) - let validPart = (validToDate.isEmpty || isPasswordRecipient) + let status = decryptionStatus + let validPart = (validToDate.isEmpty || isPasswordRecipient || status != nil) ? "" : " " + languageSettings.localized("Valid to", [validToDate]) @@ -158,6 +173,13 @@ struct RecipientView: View { .foregroundStyle(theme.onSurfaceVariant) .fixedSize(horizontal: false, vertical: true) .multilineTextAlignment(.leading) + + if let status { + ColoredRecipientStatusText( + text: languageSettings.localized(status.localizationKey, [validToDate]), + status: status + ) + } } .accessibilityElement(children: .combine) diff --git a/RIADigiDoc/UI/Component/Container/Crypto/RecipientsListView.swift b/RIADigiDoc/UI/Component/Container/Crypto/RecipientsListView.swift index 2923db99..261abf96 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/RecipientsListView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/RecipientsListView.swift @@ -29,6 +29,8 @@ struct RecipientsListView: View { let recipients: [Addressee] @Binding var selectedRecipient: Addressee? var showRemoveRecipientButton: Bool + var isCDOC2Container: Bool = false + var isEncryptedOrDecrypted: Bool = false @Binding var showRemoveRecipientModal: Bool let nameUtil: NameUtilProtocol @@ -44,6 +46,8 @@ struct RecipientsListView: View { nameUtil: nameUtil, recipientUtil: recipientUtil, showRemoveRecipientButton: showRemoveRecipientButton, + isCDOC2Container: isCDOC2Container, + isEncryptedOrDecrypted: isEncryptedOrDecrypted, showRemoveRecipientModal: $showRemoveRecipientModal, onSelect: { selectedRecipient = recipient @@ -59,6 +63,8 @@ struct RecipientsListView: View { nameUtil: nameUtil, recipientUtil: recipientUtil, showRemoveRecipientButton: showRemoveRecipientButton, + isCDOC2Container: isCDOC2Container, + isEncryptedOrDecrypted: isEncryptedOrDecrypted, showRemoveRecipientModal: $showRemoveRecipientModal, onSelect: { selectedRecipient = recipient diff --git a/RIADigiDoc/UI/Component/Shared/ColoredRecipientStatusText.swift b/RIADigiDoc/UI/Component/Shared/ColoredRecipientStatusText.swift new file mode 100644 index 00000000..60227fae --- /dev/null +++ b/RIADigiDoc/UI/Component/Shared/ColoredRecipientStatusText.swift @@ -0,0 +1,74 @@ +/* + * 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 SwiftUI + +struct ColoredRecipientStatusText: View { + @AppTheme private var theme + + let text: String + let status: RecipientDecryptionStatus + + private var tagBackgroundColor: Color { + switch status { + case .notEncrypted: return theme.surfaceVariant + case .notEncryptedExpired, .expired: return theme.errorContainer + case .valid: return theme.successContainer + } + } + + private var tagContentColor: Color { + switch status { + case .notEncrypted: return theme.onSurface + case .notEncryptedExpired, .expired: return theme.onErrorContainer + case .valid: return theme.onSuccessContainer + } + } + + var body: some View { + TagBadge( + text: text, + tagBackgroundColor: tagBackgroundColor, + tagContentColor: tagContentColor, + additionalTextColor: theme.onSurfaceVariant + ) + } +} + +#Preview { + ColoredRecipientStatusText( + text: "Expires on 12.03.2028", + status: .notEncrypted + ) + + ColoredRecipientStatusText( + text: "Expired on 04.01.2024", + status: .notEncryptedExpired + ) + + ColoredRecipientStatusText( + text: "Decryption until 12.09.2026", + status: .valid + ) + + ColoredRecipientStatusText( + text: "Decryption expired 04.01.2024", + status: .expired + ) +} diff --git a/RIADigiDoc/ViewModel/EncryptViewModel.swift b/RIADigiDoc/ViewModel/EncryptViewModel.swift index 18ab7bbe..28b650f3 100644 --- a/RIADigiDoc/ViewModel/EncryptViewModel.swift +++ b/RIADigiDoc/ViewModel/EncryptViewModel.swift @@ -61,6 +61,8 @@ class EncryptViewModel: EncryptViewModelProtocol, Loggable { private(set) var isContainerWithoutRecipients = false private(set) var isContainerEncrypted = false private(set) var isContainerDecrypted = false + private(set) var isContainerCDOC2 = false + private(set) var isDecryptionUnavailable = false private(set) var isContainerUnlocked = false private(set) var isEncryptButtonShown = false private(set) var isDecryptButtonShown = false @@ -73,6 +75,10 @@ class EncryptViewModel: EncryptViewModelProtocol, Loggable { !isContainerEncrypted && !isContainerDecrypted } + var isContainerEncryptedOrDecrypted: Bool { + isContainerEncrypted || isContainerDecrypted + } + init( sharedContainerViewModel: SharedContainerViewModelProtocol, fileOpeningService: FileOpeningServiceProtocol, @@ -515,7 +521,28 @@ class EncryptViewModel: EncryptViewModelProtocol, Loggable { func isCDOC1Container( cryptoContainer: CryptoContainerProtocol?, ) async -> Bool { - return await cryptoContainer?.getRawContainerFile()?.pathExtension == Constants.Extension.Cdoc + guard let containerFile = await cryptoContainer?.getRawContainerFile() else { + return false + } + return containerFile.pathExtension.lowercased() == Constants.Extension.Cdoc + } + + func isCDOC2Container( + cryptoContainer: CryptoContainerProtocol?, + ) async -> Bool { + guard let containerFile = await cryptoContainer?.getRawContainerFile() else { + return false + } + return containerFile.pathExtension.lowercased() == Constants.Extension.Cdoc2 + } + + func allRecipientsExpiredDate( + cryptoContainer: CryptoContainerProtocol? + ) async -> Date? { + return RecipientDecryptionStatus.allExpiredDate( + validTos: await cryptoContainer?.getRecipients().map(\.validTo) ?? [], + isCDOC2Container: await isCDOC2Container(cryptoContainer: cryptoContainer) + ) } func shouldShowDataFiles( @@ -575,7 +602,11 @@ class EncryptViewModel: EncryptViewModelProtocol, Loggable { cryptoContainer: CryptoContainerProtocol?, isNestedContainer: Bool ) async -> Bool { - return await isEncryptedContainer(cryptoContainer: cryptoContainer) && !isNestedContainer + guard await isEncryptedContainer(cryptoContainer: cryptoContainer), !isNestedContainer else { + return false + } + + return await allRecipientsExpiredDate(cryptoContainer: cryptoContainer) == nil } func isEncryptButtonShown( @@ -663,6 +694,7 @@ class EncryptViewModel: EncryptViewModelProtocol, Loggable { let isContainerWithoutRecipients = await isContainerWithoutRecipients(cryptoContainer: cryptoContainer) let isContainerEncrypted = await isEncryptedContainer(cryptoContainer: cryptoContainer) let isContainerDecrypted = await isDecryptedContainer(cryptoContainer: cryptoContainer) + let isContainerCDOC2 = await isCDOC2Container(cryptoContainer: cryptoContainer) let isContainerUnlocked = await isUnlockedContainer(cryptoContainer: cryptoContainer) let isEncryptButtonShown = await isEncryptButtonShown( cryptoContainer: cryptoContainer, @@ -682,11 +714,14 @@ class EncryptViewModel: EncryptViewModelProtocol, Loggable { isNestedContainer: isNestedContainer() ) let shouldShowDatafiles = await shouldShowDataFiles(cryptoContainer: cryptoContainer) + let isDecryptionUnavailable = isContainerEncrypted && !isDecryptButtonShown await MainActor.run { self.isContainerWithoutRecipients = isContainerWithoutRecipients self.isContainerEncrypted = isContainerEncrypted self.isContainerDecrypted = isContainerDecrypted + self.isContainerCDOC2 = isContainerCDOC2 + self.isDecryptionUnavailable = isDecryptionUnavailable self.isContainerUnlocked = isContainerUnlocked self.isEncryptButtonShown = isEncryptButtonShown self.isDecryptButtonShown = isDecryptButtonShown diff --git a/RIADigiDoc/ViewModel/Protocols/EncryptViewModelProtocol.swift b/RIADigiDoc/ViewModel/Protocols/EncryptViewModelProtocol.swift index 29a972f0..9ccfbe25 100644 --- a/RIADigiDoc/ViewModel/Protocols/EncryptViewModelProtocol.swift +++ b/RIADigiDoc/ViewModel/Protocols/EncryptViewModelProtocol.swift @@ -41,6 +41,7 @@ public protocol EncryptViewModelProtocol: Sendable { func handleBackButton() async -> Bool func isDataFilesInContainer(cryptoContainer: CryptoContainerProtocol?) async -> Bool func isCDOC1Container(cryptoContainer: CryptoContainerProtocol?) async -> Bool + func isCDOC2Container(cryptoContainer: CryptoContainerProtocol?) async -> Bool func shouldShowDataFiles(cryptoContainer: CryptoContainerProtocol?) async -> Bool func isInitialCryptoContainer(cryptoContainer: CryptoContainerProtocol?, isNestedContainer: Bool) async -> Bool func isUnlockedContainer(cryptoContainer: CryptoContainerProtocol?) async -> Bool diff --git a/RIADigiDocTests/Domain/Model/Crypto/RecipientDecryptionStatusTests.swift b/RIADigiDocTests/Domain/Model/Crypto/RecipientDecryptionStatusTests.swift new file mode 100644 index 00000000..62035668 --- /dev/null +++ b/RIADigiDocTests/Domain/Model/Crypto/RecipientDecryptionStatusTests.swift @@ -0,0 +1,186 @@ +/* + * 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 + +struct RecipientDecryptionStatusTests { + + private let now = Date(timeIntervalSince1970: 1_800_000_000) + private var future: Date { now.addingTimeInterval(60) } + private var past: Date { now.addingTimeInterval(-60) } + + @Test + func resolve_returnsNilForCDOC1ContainerEvenWithAValidToDate() { + let status = RecipientDecryptionStatus.resolve( + validTo: future, + isCDOC2Container: false, + isEncryptedOrDecrypted: true, + now: now + ) + + #expect(status == nil) + } + + @Test + func resolve_returnsNilWhenThereIsNoValidToDate() { + let status = RecipientDecryptionStatus.resolve( + validTo: nil, + isCDOC2Container: true, + isEncryptedOrDecrypted: true, + now: now + ) + + #expect(status == nil) + } + + @Test + func resolve_returnsNotEncryptedBeforeEncryptionWhenNotYetExpired() { + let status = RecipientDecryptionStatus.resolve( + validTo: future, + isCDOC2Container: true, + isEncryptedOrDecrypted: false, + now: now + ) + + #expect(status == .notEncrypted) + } + + @Test + func resolve_returnsNotEncryptedExpiredBeforeEncryptionWhenAlreadyExpired() { + let status = RecipientDecryptionStatus.resolve( + validTo: past, + isCDOC2Container: true, + isEncryptedOrDecrypted: false, + now: now + ) + + #expect(status == .notEncryptedExpired) + } + + @Test + func resolve_returnsValidAfterEncryptionWhenNotYetExpired() { + let status = RecipientDecryptionStatus.resolve( + validTo: future, + isCDOC2Container: true, + isEncryptedOrDecrypted: true, + now: now + ) + + #expect(status == .valid) + } + + @Test + func resolve_returnsExpiredAfterEncryptionWhenAlreadyExpired() { + let status = RecipientDecryptionStatus.resolve( + validTo: past, + isCDOC2Container: true, + isEncryptedOrDecrypted: true, + now: now + ) + + #expect(status == .expired) + } + + @Test + func resolve_treatsAnExpiryExactlyAtNowAsStillValid() { + let status = RecipientDecryptionStatus.resolve( + validTo: now, + isCDOC2Container: true, + isEncryptedOrDecrypted: true, + now: now + ) + + #expect(status == .valid) + } + + @Test + func allExpiredDate_returnsTheLatestExpiryWhenEveryRecipientIsExpired() { + let expiry = RecipientDecryptionStatus.allExpiredDate( + validTos: [past, past.addingTimeInterval(-60)], + isCDOC2Container: true, + now: now + ) + + #expect(expiry == past) + } + + @Test + func allExpiredDate_returnsNilWhenOneRecipientIsStillValid() { + let expiry = RecipientDecryptionStatus.allExpiredDate( + validTos: [past, future], + isCDOC2Container: true, + now: now + ) + + #expect(expiry == nil) + } + + @Test + func allExpiredDate_returnsNilWhenARecipientHasNoExpiry() { + let expiry = RecipientDecryptionStatus.allExpiredDate( + validTos: [past, nil], + isCDOC2Container: true, + now: now + ) + + #expect(expiry == nil) + } + + @Test + func allExpiredDate_returnsNilForCDOC1Container() { + let expiry = RecipientDecryptionStatus.allExpiredDate( + validTos: [past], + isCDOC2Container: false, + now: now + ) + + #expect(expiry == nil) + } + + @Test + func allExpiredDate_returnsNilWhenThereAreNoRecipients() { + let expiry = RecipientDecryptionStatus.allExpiredDate( + validTos: [], + isCDOC2Container: true, + now: now + ) + + #expect(expiry == nil) + } + + @Test + func allExpiredDate_treatsAnExpiryExactlyAtNowAsStillValid() { + let expiry = RecipientDecryptionStatus.allExpiredDate( + validTos: [now], + isCDOC2Container: true, + now: now + ) + + #expect(expiry == nil) + } + + @Test + func localizationKey_mapsEachStatusToItsOwnKey() { + #expect(RecipientDecryptionStatus.notEncrypted.localizationKey == "Expires on") + #expect(RecipientDecryptionStatus.notEncryptedExpired.localizationKey == "Expired on") + #expect(RecipientDecryptionStatus.valid.localizationKey == "Decryption until") + #expect(RecipientDecryptionStatus.expired.localizationKey == "Decryption expired") + } +} diff --git a/RIADigiDocTests/ViewModel/EncryptViewModelTests.swift b/RIADigiDocTests/ViewModel/EncryptViewModelTests.swift index 52388750..3977abf9 100644 --- a/RIADigiDocTests/ViewModel/EncryptViewModelTests.swift +++ b/RIADigiDocTests/ViewModel/EncryptViewModelTests.swift @@ -44,10 +44,10 @@ struct EncryptViewModelTests { ) } - private func stubContainer() -> CryptoContainerProtocolMock { + private func stubContainer(named containerName: String = "container.cdoc2") -> CryptoContainerProtocolMock { let container = CryptoContainerProtocolMock() - container.getRawContainerFileHandler = { URL(filePath: "/mock/path/to/container.cdoc2") } - container.getContainerNameHandler = { "container.cdoc2" } + container.getRawContainerFileHandler = { URL(filePath: "/mock/path/to/\(containerName)") } + container.getContainerNameHandler = { containerName } container.getDataFilesHandler = { [URL(filePath: "/mock/path/to/text.txt")] } container.getRecipientsHandler = { [] } container.getContainerMimetypeHandler = { CommonsLib.Constants.MimeType.Cdoc } @@ -125,4 +125,142 @@ struct EncryptViewModelTests { #expect(viewModel.errorMessage == ToastMessage(key: "Encrypt general error", args: [])) } + + @Test + func isCDOC2Container_isTrueForACdoc2Extension() async { + let container = stubContainer(named: "container.cdoc2") + + #expect(await viewModel.isCDOC2Container(cryptoContainer: container) == true) + } + + @Test + func isCDOC2Container_isFalseForACdocExtension() async { + let container = stubContainer(named: "container.cdoc") + + #expect(await viewModel.isCDOC2Container(cryptoContainer: container) == false) + } + + @Test + func isCDOC2Container_isFalseWhenThereIsNoContainer() async { + #expect(await viewModel.isCDOC2Container(cryptoContainer: nil) == false) + } + + @Test + func isCDOC2Container_isTrueForAnUppercaseCdoc2Extension() async { + let container = stubContainer(named: "container.CDOC2") + + #expect(await viewModel.isCDOC2Container(cryptoContainer: container) == true) + } + + @Test + func isCDOC1Container_isTrueForACdocExtension() async { + let container = stubContainer(named: "container.cdoc") + + #expect(await viewModel.isCDOC1Container(cryptoContainer: container) == true) + } + + @Test + func isCDOC1Container_isTrueForAnUppercaseCdocExtension() async { + let container = stubContainer(named: "container.CDOC") + + #expect(await viewModel.isCDOC1Container(cryptoContainer: container) == true) + } + + @Test + func isCDOC1Container_isFalseForACdoc2Extension() async { + let container = stubContainer(named: "container.cdoc2") + + #expect(await viewModel.isCDOC1Container(cryptoContainer: container) == false) + } + + @Test + func isCDOC1Container_isFalseWhenThereIsNoContainer() async { + #expect(await viewModel.isCDOC1Container(cryptoContainer: nil) == false) + } + + @Test + func updateAsyncProperties_recomputesIsContainerCDOC2WhenTheContainerChanges() async { + await viewModel.loadContainerData(cryptoContainer: stubContainer(named: "container.cdoc2")) + await viewModel.updateAsyncProperties() + + #expect(viewModel.isContainerCDOC2 == true) + + await viewModel.loadContainerData(cryptoContainer: stubContainer(named: "container.cdoc")) + await viewModel.updateAsyncProperties() + + #expect(viewModel.isContainerCDOC2 == false) + } + + @Test + func isDecryptionUnavailable_isFalseWhenAnEncryptedContainerCanStillBeDecrypted() async { + let container = stubContainer() + container.isEncryptedHandler = { true } + await viewModel.loadContainerData(cryptoContainer: container) + + await viewModel.updateAsyncProperties() + + #expect(viewModel.isDecryptButtonShown == true) + #expect(viewModel.isDecryptionUnavailable == false) + } + + @Test + func isDecryptionUnavailable_isTrueForANestedEncryptedContainer() async { + let container = stubContainer() + container.isEncryptedHandler = { true } + mockSharedContainerViewModel.isNestedContainerHandler = { _ in true } + await viewModel.loadContainerData(cryptoContainer: container) + + await viewModel.updateAsyncProperties() + + #expect(viewModel.isDecryptButtonShown == false) + #expect(viewModel.isDecryptionUnavailable == true) + } + + @Test + func isDecryptionUnavailable_isFalseWhenTheContainerIsNotEncrypted() async { + _ = stubContainer() + mockSharedContainerViewModel.isNestedContainerHandler = { _ in true } + await viewModel.loadContainerData(cryptoContainer: nil) + + await viewModel.updateAsyncProperties() + + #expect(viewModel.isDecryptionUnavailable == false) + } + + @Test + func isContainerEncryptedOrDecrypted_isTrueWhenTheContainerIsEncrypted() async { + let container = stubContainer() + container.isEncryptedHandler = { true } + await viewModel.loadContainerData(cryptoContainer: container) + + await viewModel.updateAsyncProperties() + + #expect(viewModel.isContainerEncryptedOrDecrypted == true) + } + + @Test + func isContainerEncryptedOrDecrypted_isTrueWhenTheContainerIsDecrypted() async { + let container = stubContainer() + container.isDecryptedHandler = { true } + await viewModel.loadContainerData(cryptoContainer: container) + + await viewModel.updateAsyncProperties() + + #expect(viewModel.isContainerEncryptedOrDecrypted == true) + } + + @Test + func isContainerEncryptedOrDecrypted_recomputesWhenTheContainerIsNoLongerEncrypted() async { + let encrypted = stubContainer() + encrypted.isEncryptedHandler = { true } + await viewModel.loadContainerData(cryptoContainer: encrypted) + await viewModel.updateAsyncProperties() + + #expect(viewModel.isContainerEncryptedOrDecrypted == true) + + await viewModel.loadContainerData(cryptoContainer: stubContainer()) + await viewModel.updateAsyncProperties() + + #expect(viewModel.isContainerEncryptedOrDecrypted == false) + } }