diff --git a/BitwardenResources/Icons.xcassets/archive24.imageset/Contents.json b/BitwardenResources/Icons.xcassets/archive24.imageset/Contents.json new file mode 100644 index 000000000..f36d3aabf --- /dev/null +++ b/BitwardenResources/Icons.xcassets/archive24.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "archive24.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/BitwardenResources/Icons.xcassets/archive24.imageset/archive24.pdf b/BitwardenResources/Icons.xcassets/archive24.imageset/archive24.pdf new file mode 100644 index 000000000..9344b79b9 Binary files /dev/null and b/BitwardenResources/Icons.xcassets/archive24.imageset/archive24.pdf differ diff --git a/BitwardenResources/Localizations/en.lproj/Localizable.strings b/BitwardenResources/Localizations/en.lproj/Localizable.strings index f1c994286..7eaa4f6dd 100644 --- a/BitwardenResources/Localizations/en.lproj/Localizable.strings +++ b/BitwardenResources/Localizations/en.lproj/Localizable.strings @@ -1279,4 +1279,14 @@ "YourOrganizationHasSetTheDefaultSessionTimeoutToX" = "Your organization has set the default session timeout to %1$@."; "YourOrganizationHasSetTheMaximumSessionTimeoutToX" = "Your organization has set the maximum session timeout to %1$@."; "YourOrganizationHasSetTheMaximumSessionTimeoutToXAndY" = "Your organization has set the maximum session timeout to %1$@ and %2$@."; +"HiddenItems" = "Hidden items"; +"Archive" = "Archive"; +"Unarchive" = "Unarchive"; +"ItemArchived" = "Item archived"; +"ItemUnarchived" = "Item unarchived"; +"SendingToArchive" = "Sending to archive…"; +"Unarchiving" = "Unarchiving…"; +"DoYouReallyWantToUnarchiveThisItem" = "Do you really want to unarchive this item?"; +"DoYouReallyWantToArchiveThisItem" = "Do you really want to archive this item?"; +"ThereAreNoItemsInTheArchive" = "There are no items in the archive."; "OrganizationNotFound" = "Organization not found."; diff --git a/BitwardenShared/Core/Autofill/Services/AutofillCredentialService+AppExtensionTests.swift b/BitwardenShared/Core/Autofill/Services/AutofillCredentialService+AppExtensionTests.swift index f5229e1c0..6dfd913e5 100644 --- a/BitwardenShared/Core/Autofill/Services/AutofillCredentialService+AppExtensionTests.swift +++ b/BitwardenShared/Core/Autofill/Services/AutofillCredentialService+AppExtensionTests.swift @@ -24,6 +24,7 @@ class AutofillCredentialServiceAppExtensionTests: BitwardenTestCase { // swiftli var autofillCredentialServiceDelegate: MockAutofillCredentialServiceDelegate! var cipherService: MockCipherService! var clientService: MockClientService! + var configService: MockConfigService! var credentialIdentityFactory: MockCredentialIdentityFactory! var errorReporter: MockErrorReporter! var eventService: MockEventService! @@ -50,6 +51,7 @@ class AutofillCredentialServiceAppExtensionTests: BitwardenTestCase { // swiftli autofillCredentialServiceDelegate = MockAutofillCredentialServiceDelegate() cipherService = MockCipherService() clientService = MockClientService() + configService = MockConfigService() credentialIdentityFactory = MockCredentialIdentityFactory() errorReporter = MockErrorReporter() eventService = MockEventService() @@ -68,6 +70,7 @@ class AutofillCredentialServiceAppExtensionTests: BitwardenTestCase { // swiftli appContextHelper: appContextHelper, cipherService: cipherService, clientService: clientService, + configService: configService, credentialIdentityFactory: credentialIdentityFactory, errorReporter: errorReporter, eventService: eventService, @@ -90,6 +93,7 @@ class AutofillCredentialServiceAppExtensionTests: BitwardenTestCase { // swiftli autofillCredentialServiceDelegate = nil cipherService = nil clientService = nil + configService = nil credentialIdentityFactory = nil errorReporter = nil eventService = nil diff --git a/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift b/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift index c67a85817..3f0092f0e 100644 --- a/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift +++ b/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift @@ -107,6 +107,9 @@ class DefaultAutofillCredentialService { /// The service that handles common client functionality such as encryption and decryption. private let clientService: ClientService + /// The service to get server-specified configuration. + private let configService: ConfigService + /// The factory to create credential identities. private let credentialIdentityFactory: CredentialIdentityFactory @@ -159,6 +162,7 @@ class DefaultAutofillCredentialService { /// - appContextHelper: The helper to know about the app context. /// - cipherService: The service used to manage syncing and updates to the user's ciphers. /// - clientService: The service that handles common client functionality such as encryption and decryption. + /// - configService: The service to get server-specified configuration. /// - credentialIdentityFactory: The factory to create credential identities. /// - errorReporter: The service used by the application to report non-fatal errors. /// - eventService: The service to manage events. @@ -177,6 +181,7 @@ class DefaultAutofillCredentialService { appContextHelper: AppContextHelper, cipherService: CipherService, clientService: ClientService, + configService: ConfigService, credentialIdentityFactory: CredentialIdentityFactory, errorReporter: ErrorReporter, eventService: EventService, @@ -193,6 +198,7 @@ class DefaultAutofillCredentialService { self.appContextHelper = appContextHelper self.cipherService = cipherService self.clientService = clientService + self.configService = configService self.credentialIdentityFactory = credentialIdentityFactory self.errorReporter = errorReporter self.eventService = eventService @@ -311,8 +317,10 @@ class DefaultAutofillCredentialService { do { await flightRecorder.log("[AutofillCredentialService] Replacing all credential identities") + let archiveItemsFeatureFlagEnabled: Bool = await configService.getFeatureFlag(.archiveVaultItems) + let decryptedCiphers = try await cipherService.fetchAllCiphers() - .filter { $0.type == .login && $0.deletedDate == nil } + .filter { $0.type == .login && !$0.isHiddenWithArchiveFF(flag: archiveItemsFeatureFlagEnabled) } .asyncMap { cipher in try await self.clientService.vault().ciphers().decrypt(cipher: cipher) } diff --git a/BitwardenShared/Core/Autofill/Services/AutofillCredentialServiceTests.swift b/BitwardenShared/Core/Autofill/Services/AutofillCredentialServiceTests.swift index 6164f3ef2..d1c8d69ab 100644 --- a/BitwardenShared/Core/Autofill/Services/AutofillCredentialServiceTests.swift +++ b/BitwardenShared/Core/Autofill/Services/AutofillCredentialServiceTests.swift @@ -16,6 +16,7 @@ class AutofillCredentialServiceTests: BitwardenTestCase { // swiftlint:disable:t var autofillCredentialServiceDelegate: MockAutofillCredentialServiceDelegate! var cipherService: MockCipherService! var clientService: MockClientService! + var configService: MockConfigService! var credentialIdentityFactory: MockCredentialIdentityFactory! var errorReporter: MockErrorReporter! var eventService: MockEventService! @@ -40,6 +41,7 @@ class AutofillCredentialServiceTests: BitwardenTestCase { // swiftlint:disable:t autofillCredentialServiceDelegate = MockAutofillCredentialServiceDelegate() cipherService = MockCipherService() clientService = MockClientService() + configService = MockConfigService() credentialIdentityFactory = MockCredentialIdentityFactory() errorReporter = MockErrorReporter() eventService = MockEventService() @@ -58,6 +60,7 @@ class AutofillCredentialServiceTests: BitwardenTestCase { // swiftlint:disable:t appContextHelper: appContextHelper, cipherService: cipherService, clientService: clientService, + configService: configService, credentialIdentityFactory: credentialIdentityFactory, errorReporter: errorReporter, eventService: eventService, @@ -86,6 +89,7 @@ class AutofillCredentialServiceTests: BitwardenTestCase { // swiftlint:disable:t autofillCredentialServiceDelegate = nil cipherService = nil clientService = nil + configService = nil credentialIdentityFactory = nil errorReporter = nil eventService = nil diff --git a/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift b/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift index 1286f2cf8..aed5a29a9 100644 --- a/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift +++ b/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift @@ -5,6 +5,9 @@ import Foundation /// An enum to represent a feature flag sent by the server extension FeatureFlag: @retroactive CaseIterable { + /// A feature flag to enable/disable ciphers archive option. + static let archiveVaultItems = FeatureFlag(rawValue: "pm-19148-innovation-archive") + /// Flag to enable/disable Credential Exchange export flow. static let cxpExportMobile = FeatureFlag(rawValue: "cxp-export-mobile") @@ -28,6 +31,7 @@ extension FeatureFlag: @retroactive CaseIterable { public static var allCases: [FeatureFlag] { [ + .archiveVaultItems, .cxpExportMobile, .cxpImportMobile, .cipherKeyEncryption, diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift index 4cb58f7fd..a9229b556 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift @@ -222,8 +222,10 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// private func decryptTOTPs(_ ciphers: [Cipher], account: Account) async throws -> [AuthenticatorBridgeItemDataView] { + let archiveItemsFeatureFlagEnabled: Bool = await configService.getFeatureFlag(.archiveVaultItems) + let totpCiphers = ciphers.filter { cipher in - cipher.deletedDate == nil + !cipher.isHiddenWithArchiveFF(flag: archiveItemsFeatureFlagEnabled) && cipher.type == .login && cipher.login?.totp != nil } diff --git a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift index c020ba9e0..2e55dea28 100644 --- a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift +++ b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift @@ -567,6 +567,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le let watchService = DefaultWatchService( cipherService: cipherService, clientService: clientService, + configService: configService, environmentService: environmentService, errorReporter: errorReporter, organizationService: organizationService, @@ -771,6 +772,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le vaultListBuilderFactory: DefaultVaultListSectionsBuilderFactory( clientService: clientService, collectionHelper: collectionHelper, + configService: configService, errorReporter: errorReporter, ), vaultListDataPreparator: DefaultVaultListDataPreparator( @@ -821,6 +823,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le fido2CredentialStore: Fido2CredentialStoreService( cipherService: cipherService, clientService: clientService, + configService: configService, errorReporter: errorReporter, stateService: stateService, syncService: syncService, @@ -830,6 +833,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le let fido2CredentialStore = Fido2CredentialStoreService( cipherService: cipherService, clientService: clientService, + configService: configService, errorReporter: errorReporter, stateService: stateService, syncService: syncService, @@ -841,6 +845,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le appContextHelper: appContextHelper, cipherService: cipherService, clientService: clientService, + configService: configService, credentialIdentityFactory: credentialIdentityFactory, errorReporter: errorReporter, eventService: eventService, diff --git a/BitwardenShared/Core/Platform/Services/WatchService.swift b/BitwardenShared/Core/Platform/Services/WatchService.swift index 57eaea9fb..92f976e5d 100644 --- a/BitwardenShared/Core/Platform/Services/WatchService.swift +++ b/BitwardenShared/Core/Platform/Services/WatchService.swift @@ -25,6 +25,9 @@ class DefaultWatchService: NSObject, WatchService { /// The service that handles common client functionality such as encryption and decryption. private let clientService: ClientService + /// The service to get server-specified configuration. + private let configService: ConfigService + /// The service used by the application to manage the environment settings. private let environmentService: EnvironmentService @@ -51,6 +54,7 @@ class DefaultWatchService: NSObject, WatchService { /// - Parameters: /// - cipherService: The service used to manage syncing and updates to the user's ciphers. /// - clientService: The service that handles common client functionality such as encryption and decryption. + /// - configService: The service to get server-specified configuration. /// - environmentService: The service used by the application to manage the environment settings. /// - errorReporter: The service used by the application to report non-fatal errors. /// - organizationService: The service used to manage syncing and updates to the user's organizations. @@ -59,6 +63,7 @@ class DefaultWatchService: NSObject, WatchService { init( cipherService: CipherService, clientService: ClientService, + configService: ConfigService, environmentService: EnvironmentService, errorReporter: ErrorReporter, organizationService: OrganizationService, @@ -66,6 +71,7 @@ class DefaultWatchService: NSObject, WatchService { ) { self.cipherService = cipherService self.clientService = clientService + self.configService = configService self.environmentService = environmentService self.errorReporter = errorReporter self.organizationService = organizationService @@ -100,8 +106,10 @@ class DefaultWatchService: NSObject, WatchService { try await self.clientService.vault().ciphers().decrypt(cipher: cipher) } + let archiveItemsFeatureFlagEnabled: Bool = await configService.getFeatureFlag(.archiveVaultItems) + return decryptedCiphers.filter { cipher in - cipher.deletedDate == nil + !cipher.isHiddenWithArchiveFF(flag: archiveItemsFeatureFlagEnabled) && cipher.type == .login && cipher.login?.totp != nil } diff --git a/BitwardenShared/Core/Vault/Extensions/Cipher+Extensions.swift b/BitwardenShared/Core/Vault/Extensions/Cipher+Extensions.swift index c61b91da3..64cc9958b 100644 --- a/BitwardenShared/Core/Vault/Extensions/Cipher+Extensions.swift +++ b/BitwardenShared/Core/Vault/Extensions/Cipher+Extensions.swift @@ -1,6 +1,13 @@ import BitwardenSdk extension Cipher { + // MARK: Properties + + /// Whether the cipher is normally hidden for flows by being archived or deleted. + var isHidden: Bool { + archivedDate != nil || deletedDate != nil + } + // MARK: Methods /// Whether the cipher belongs to a group. @@ -8,6 +15,8 @@ extension Cipher { /// - Returns: `true` if the cipher belongs to the group, `false` otherwise. func belongsToGroup(_ group: VaultListGroup) -> Bool { switch group { + case .archive: + archivedDate != nil case .card: type == .card case let .collection(id, _, _): diff --git a/BitwardenShared/Core/Vault/Extensions/CipherExtensionsTests.swift b/BitwardenShared/Core/Vault/Extensions/CipherExtensionsTests.swift index 2d50b24b1..a0ec892c3 100644 --- a/BitwardenShared/Core/Vault/Extensions/CipherExtensionsTests.swift +++ b/BitwardenShared/Core/Vault/Extensions/CipherExtensionsTests.swift @@ -1,4 +1,5 @@ import BitwardenSdk +import BitwardenSharedMocks import XCTest @testable import BitwardenShared @@ -8,6 +9,15 @@ import XCTest class CipherExtensionsTests: BitwardenTestCase { // MARK: Tests + /// `belongsToGroup(_:)` returns `true` when the cipher is archived and the group is `.archive`. + func test_belongsToGroup_archive() { + let cipher = Cipher.fixture(archivedDate: .now, type: .login) + XCTAssertTrue(cipher.belongsToGroup(.archive)) + XCTAssertFalse(cipher.belongsToGroup(.card)) + XCTAssertFalse(cipher.belongsToGroup(.identity)) + XCTAssertFalse(Cipher.fixture(archivedDate: nil, type: .login).belongsToGroup(.archive)) + } + /// `belongsToGroup(_:)` returns `true` when the cipher is a card type and the group is `.card`. func test_belongsToGroup_card() { let cipher = Cipher.fixture(type: .card) @@ -145,4 +155,12 @@ class CipherExtensionsTests: BitwardenTestCase { let cipher = Cipher.fixture(deletedDate: nil) XCTAssertFalse(cipher.belongsToGroup(.trash)) } + + /// `isHidden` return `true` when the cipher is hidden, i.e. archived or deleted; `false` otherwise. + func test_isHidden() { + XCTAssertTrue(Cipher.fixture(archivedDate: .now).isHidden) + XCTAssertTrue(Cipher.fixture(deletedDate: .now).isHidden) + XCTAssertTrue(Cipher.fixture(archivedDate: .now, deletedDate: .now).isHidden) + XCTAssertFalse(Cipher.fixture(archivedDate: nil, deletedDate: nil).isHidden) + } } diff --git a/BitwardenShared/Core/Vault/Extensions/CipherListView+Extensions.swift b/BitwardenShared/Core/Vault/Extensions/CipherListView+Extensions.swift index 7fbe10681..a1702e713 100644 --- a/BitwardenShared/Core/Vault/Extensions/CipherListView+Extensions.swift +++ b/BitwardenShared/Core/Vault/Extensions/CipherListView+Extensions.swift @@ -21,6 +21,16 @@ extension CipherListView { } } + /// Whether the cipher is archived. + var isArchived: Bool { + archivedDate != nil + } + + /// Whether the cipher is normally hidden for flows by being archived or deleted. + var isHidden: Bool { + archivedDate != nil || deletedDate != nil + } + // MARK: Methods /// Whether the cipher belongs to a group. @@ -28,6 +38,8 @@ extension CipherListView { /// - Returns: `true` if the cipher belongs to the group, `false` otherwise. func belongsToGroup(_ group: VaultListGroup) -> Bool { switch group { + case .archive: + archivedDate != nil case .card: type.isCard case let .collection(id, _, _): diff --git a/BitwardenShared/Core/Vault/Extensions/CipherListViewExtensionsTests.swift b/BitwardenShared/Core/Vault/Extensions/CipherListViewExtensionsTests.swift index 0d98d0b1a..cb96523df 100644 --- a/BitwardenShared/Core/Vault/Extensions/CipherListViewExtensionsTests.swift +++ b/BitwardenShared/Core/Vault/Extensions/CipherListViewExtensionsTests.swift @@ -9,6 +9,14 @@ import XCTest class CipherListViewExtensionsTests: BitwardenTestCase { // swiftlint:disable:this type_body_length // MARK: Tests + /// `belongsToGroup(_:)` returns `true` when the cipher is archived and the group is `.archive`. + func test_belongsToGroup_archive() { + let cipher = CipherListView.fixture(archivedDate: .now) + XCTAssertTrue(cipher.belongsToGroup(.archive)) + XCTAssertFalse(cipher.belongsToGroup(.trash)) + XCTAssertFalse(cipher.belongsToGroup(.identity)) + } + /// `belongsToGroup(_:)` returns `true` when the cipher is a card type and the group is `.card`. func test_belongsToGroup_card() { let cipher = CipherListView.fixture(type: .card(.fixture())) @@ -366,6 +374,20 @@ class CipherListViewExtensionsTests: BitwardenTestCase { // swiftlint:disable:th XCTAssertEqual(cipher.matchesSearchQuery("mysite"), .exact) } + /// `isArchived` returns `true` when there's an archived date, `false` otherwise. + func test_isArchived() { + XCTAssertTrue(CipherListView.fixture(archivedDate: .now).isArchived) + XCTAssertFalse(CipherListView.fixture(archivedDate: nil).isArchived) + } + + /// `isHidden` return `true` when the cipher is hidden, i.e. archived or deleted; `false` otherwise. + func test_isHidden() { + XCTAssertTrue(CipherListView.fixture(archivedDate: .now).isHidden) + XCTAssertTrue(CipherListView.fixture(deletedDate: .now).isHidden) + XCTAssertTrue(CipherListView.fixture(deletedDate: .now, archivedDate: .now).isHidden) + XCTAssertFalse(CipherListView.fixture(deletedDate: nil, archivedDate: nil).isHidden) + } + /// `passesRestrictItemTypesPolicy(_:)` passes the policy when there are no organization IDs. func test_passesRestrictItemTypesPolicy_noOrgIds() { XCTAssertTrue(CipherListView.fixture().passesRestrictItemTypesPolicy([])) diff --git a/BitwardenShared/Core/Vault/Extensions/CipherView+Extensions.swift b/BitwardenShared/Core/Vault/Extensions/CipherView+Extensions.swift new file mode 100644 index 000000000..4987b374d --- /dev/null +++ b/BitwardenShared/Core/Vault/Extensions/CipherView+Extensions.swift @@ -0,0 +1,10 @@ +import BitwardenSdk + +extension CipherView { + // MARK: Properties + + /// Whether the cipher is normally hidden for flows by being archived or deleted. + var isHidden: Bool { + archivedDate != nil || deletedDate != nil + } +} diff --git a/BitwardenShared/Core/Vault/Extensions/CipherViewExtensionsTests.swift b/BitwardenShared/Core/Vault/Extensions/CipherViewExtensionsTests.swift new file mode 100644 index 000000000..ff6fb5b9f --- /dev/null +++ b/BitwardenShared/Core/Vault/Extensions/CipherViewExtensionsTests.swift @@ -0,0 +1,19 @@ +import BitwardenSdk +import BitwardenSharedMocks +import XCTest + +@testable import BitwardenShared + +// MARK: - CipherViewExtensionsTests + +class CipherViewExtensionsTests: BitwardenTestCase { + // MARK: Tests + + /// `isHidden` return `true` when the cipher is hidden, i.e. archived or deleted; `false` otherwise. + func test_isHidden() { + XCTAssertTrue(CipherView.fixture(archivedDate: .now).isHidden) + XCTAssertTrue(CipherView.fixture(deletedDate: .now).isHidden) + XCTAssertTrue(CipherView.fixture(archivedDate: .now, deletedDate: .now).isHidden) + XCTAssertFalse(CipherView.fixture(archivedDate: nil, deletedDate: nil).isHidden) + } +} diff --git a/BitwardenShared/Core/Vault/Extensions/CipherWithArchive.swift b/BitwardenShared/Core/Vault/Extensions/CipherWithArchive.swift new file mode 100644 index 000000000..135967561 --- /dev/null +++ b/BitwardenShared/Core/Vault/Extensions/CipherWithArchive.swift @@ -0,0 +1,41 @@ +import BitwardenSdk +import Foundation + +// TODO: PM-30129: remove this file. + +/// A helper protocol to centralize archive logic. +protocol CipherWithArchive { + /// The date the cipher was archived. + var archivedDate: Date? { get } + + /// The date the cipher was deleted. + var deletedDate: Date? { get } +} + +/// Extension with logic for archive functionality. +extension CipherWithArchive { + /// Whether the cipher is normally hidden for flows by being archived or deleted. + /// This is similar to the above `isHidden` property but taking into consideration + /// the `FeatureFlag.archiveVaultItems` flag. + /// + /// TODO: PM-30129 When FF gets removed, replace all calls to this function with the above `isHidden` property + /// and remove this function. + /// + /// - Parameter archiveVaultItemsFeatureFlagEnabled: The `FeatureFlag.archiveVaultItems` flag value. + /// - Returns: `true` if hidden, `false` othewise. + func isHiddenWithArchiveFF(flag archiveVaultItemsFeatureFlagEnabled: Bool) -> Bool { + if deletedDate != nil { + return true + } + + guard archiveVaultItemsFeatureFlagEnabled else { + return false + } + + return archivedDate != nil + } +} + +extension Cipher: CipherWithArchive {} +extension CipherListView: CipherWithArchive {} +extension CipherView: CipherWithArchive {} diff --git a/BitwardenShared/Core/Vault/Extensions/CipherWithArchiveTests.swift b/BitwardenShared/Core/Vault/Extensions/CipherWithArchiveTests.swift new file mode 100644 index 000000000..6ca9de81d --- /dev/null +++ b/BitwardenShared/Core/Vault/Extensions/CipherWithArchiveTests.swift @@ -0,0 +1,54 @@ +import XCTest + +@testable import BitwardenShared + +// MARK: - CipherWithArchiveTests + +class CipherWithArchiveTests: BitwardenTestCase { + // MARK: Tests + + /// `isHiddenWithArchiveFF` returns `true` when cipher is deleted, regardless of feature flag state. + func test_isHiddenWithArchiveFF_deleted() { + let deletedCipher = CipherWithArchiveStub(archivedDate: nil, deletedDate: .now) + XCTAssertTrue(deletedCipher.isHiddenWithArchiveFF(flag: true)) + XCTAssertTrue(deletedCipher.isHiddenWithArchiveFF(flag: false)) + } + + /// `isHiddenWithArchiveFF` returns `true` when cipher is both archived and deleted, + /// regardless of feature flag state. + func test_isHiddenWithArchiveFF_archivedAndDeleted() { + let archivedAndDeletedCipher = CipherWithArchiveStub(archivedDate: .now, deletedDate: .now) + XCTAssertTrue(archivedAndDeletedCipher.isHiddenWithArchiveFF(flag: true)) + XCTAssertTrue(archivedAndDeletedCipher.isHiddenWithArchiveFF(flag: false)) + } + + /// `isHiddenWithArchiveFF` returns `true` when cipher is archived and feature flag is enabled. + func test_isHiddenWithArchiveFF_archivedWithFlagEnabled() { + let archivedCipher = CipherWithArchiveStub(archivedDate: .now, deletedDate: nil) + XCTAssertTrue(archivedCipher.isHiddenWithArchiveFF(flag: true)) + } + + /// `isHiddenWithArchiveFF` returns `false` when cipher is archived but feature flag is disabled. + func test_isHiddenWithArchiveFF_archivedWithFlagDisabled() { + let archivedCipher = CipherWithArchiveStub(archivedDate: .now, deletedDate: nil) + XCTAssertFalse(archivedCipher.isHiddenWithArchiveFF(flag: false)) + } + + /// `isHiddenWithArchiveFF` returns `false` when cipher is neither archived nor deleted, + /// regardless of feature flag state. + func test_isHiddenWithArchiveFF_notHidden() { + let normalCipher = CipherWithArchiveStub(archivedDate: nil, deletedDate: nil) + XCTAssertFalse(normalCipher.isHiddenWithArchiveFF(flag: true)) + XCTAssertFalse(normalCipher.isHiddenWithArchiveFF(flag: false)) + } +} + +// MARK: - CipherWithArchiveStub + +/// Stub to be use for the `CipherWithArchive` protocol. +struct CipherWithArchiveStub: CipherWithArchive { + /// The archived date. + let archivedDate: Date? + /// The deleted date. + let deletedDate: Date? +} diff --git a/BitwardenShared/Core/Vault/Helpers/CipherMatchingHelper.swift b/BitwardenShared/Core/Vault/Helpers/CipherMatchingHelper.swift index 17f16e365..a31baf64c 100644 --- a/BitwardenShared/Core/Vault/Helpers/CipherMatchingHelper.swift +++ b/BitwardenShared/Core/Vault/Helpers/CipherMatchingHelper.swift @@ -10,8 +10,10 @@ protocol CipherMatchingHelper { // sourcery: AutoMockable /// /// - Parameters: /// - cipher: The cipher to check if it matches the URI. + /// - archiveVaultItemsFF: The `FeatureFlag.archiveVaultItems` flag value. func doesCipherMatch( cipher: CipherListView, + archiveVaultItemsFF: Bool, ) -> CipherMatchResult /// Prepares the cipher matching helper given the URI. @@ -59,11 +61,11 @@ class DefaultCipherMatchingHelper: CipherMatchingHelper { // MARK: Methods - func doesCipherMatch(cipher: CipherListView) -> CipherMatchResult { + func doesCipherMatch(cipher: CipherListView, archiveVaultItemsFF: Bool) -> CipherMatchResult { guard let uriToMatch, let login = cipher.type.loginListView, let loginUris = login.uris, - cipher.deletedDate == nil else { + !cipher.isHiddenWithArchiveFF(flag: archiveVaultItemsFF) else { return .none } diff --git a/BitwardenShared/Core/Vault/Helpers/CipherMatchingHelperTests.swift b/BitwardenShared/Core/Vault/Helpers/CipherMatchingHelperTests.swift index 98a8e778d..968eb894f 100644 --- a/BitwardenShared/Core/Vault/Helpers/CipherMatchingHelperTests.swift +++ b/BitwardenShared/Core/Vault/Helpers/CipherMatchingHelperTests.swift @@ -45,6 +45,7 @@ class CipherMatchingHelperTests: BitwardenTestCase { // swiftlint:disable:this t cipher: .fixture( type: .login(.fixture()), ), + archiveVaultItemsFF: false, ) XCTAssertEqual(result, .none) } @@ -58,6 +59,7 @@ class CipherMatchingHelperTests: BitwardenTestCase { // swiftlint:disable:this t cipher: .fixture( type: type, ), + archiveVaultItemsFF: false, ) XCTAssertEqual(result, .none) } @@ -70,6 +72,7 @@ class CipherMatchingHelperTests: BitwardenTestCase { // swiftlint:disable:this t cipher: .fixture( type: .login(.fixture()), ), + archiveVaultItemsFF: false, ) XCTAssertEqual(result, .none) } @@ -82,10 +85,39 @@ class CipherMatchingHelperTests: BitwardenTestCase { // swiftlint:disable:this t type: .login(.fixture(uris: [.fixture()])), deletedDate: .now, ), + archiveVaultItemsFF: false, ) XCTAssertEqual(result, .none) } + /// `doesCipherMatch(cipher:)` returns `.none` when cipher is archived and feature flag is enabled. + func test_doesCipherMatch_archivedWithFlagEnabled() async { + subject.uriToMatch = "https://google.com" + subject.matchingDomains = ["google.com"] + let result = subject.doesCipherMatch( + cipher: .fixture( + type: .login(.fixture(uris: [.fixture(uri: "https://google.com", match: .domain)])), + archivedDate: .now, + ), + archiveVaultItemsFF: true, + ) + XCTAssertEqual(result, .none) + } + + /// `doesCipherMatch(cipher:)` returns `.exact` when cipher is archived but feature flag is disabled. + func test_doesCipherMatch_archivedWithFlagDisabled() async { + subject.uriToMatch = "https://google.com" + subject.matchingDomains = ["google.com"] + let result = subject.doesCipherMatch( + cipher: .fixture( + type: .login(.fixture(uris: [.fixture(uri: "https://google.com", match: .domain)])), + archivedDate: .now, + ), + archiveVaultItemsFF: false, + ) + XCTAssertEqual(result, .exact) + } + /// `doesCipherMatch(cipher:)` returns `.exact` when match type is `.domain` and the match URI base domain /// is the same as of the logins URI's base domains. func test_doesCipherMatch_domainExact() async { @@ -102,6 +134,7 @@ class CipherMatchingHelperTests: BitwardenTestCase { // swiftlint:disable:this t cipher: .fixture( type: .login(.fixture(uris: [.fixture(uri: uri, match: .domain)])), ), + archiveVaultItemsFF: false, ) XCTAssertEqual(result, .exact) } @@ -123,6 +156,7 @@ class CipherMatchingHelperTests: BitwardenTestCase { // swiftlint:disable:this t cipher: .fixture( type: .login(.fixture(uris: [.fixture(uri: uri, match: .domain)])), ), + archiveVaultItemsFF: false, ) XCTAssertEqual(result, .none) } @@ -143,6 +177,7 @@ class CipherMatchingHelperTests: BitwardenTestCase { // swiftlint:disable:this t cipher: .fixture( type: .login(.fixture(uris: [.fixture(uri: uri, match: .domain)])), ), + archiveVaultItemsFF: false, ) XCTAssertEqual(result, .exact, "On \(uri)") } @@ -164,6 +199,7 @@ class CipherMatchingHelperTests: BitwardenTestCase { // swiftlint:disable:this t cipher: .fixture( type: .login(.fixture(uris: [.fixture(uri: uri, match: .domain)])), ), + archiveVaultItemsFF: false, ) XCTAssertEqual(result, .fuzzy) } @@ -184,6 +220,7 @@ class CipherMatchingHelperTests: BitwardenTestCase { // swiftlint:disable:this t cipher: .fixture( type: .login(.fixture(uris: [.fixture(uri: uri, match: .host)])), ), + archiveVaultItemsFF: false, ) XCTAssertEqual(result, .exact) } @@ -207,6 +244,7 @@ class CipherMatchingHelperTests: BitwardenTestCase { // swiftlint:disable:this t cipher: .fixture( type: .login(.fixture(uris: [.fixture(uri: uri, match: .host)])), ), + archiveVaultItemsFF: false, ) XCTAssertEqual(result, .none) } @@ -230,6 +268,7 @@ class CipherMatchingHelperTests: BitwardenTestCase { // swiftlint:disable:this t .fixture(uri: uri, match: .startsWith), ])), ), + archiveVaultItemsFF: false, ) XCTAssertEqual(result, .exact) } @@ -254,6 +293,7 @@ class CipherMatchingHelperTests: BitwardenTestCase { // swiftlint:disable:this t .fixture(uri: uri, match: .startsWith), ])), ), + archiveVaultItemsFF: false, ) XCTAssertEqual(result, .none) } @@ -271,6 +311,7 @@ class CipherMatchingHelperTests: BitwardenTestCase { // swiftlint:disable:this t .fixture(uri: "https://vault.bitwarden.com", match: .exact), ])), ), + archiveVaultItemsFF: false, ) XCTAssertEqual(result, .exact) } @@ -294,6 +335,7 @@ class CipherMatchingHelperTests: BitwardenTestCase { // swiftlint:disable:this t .fixture(uri: uri, match: .exact), ])), ), + archiveVaultItemsFF: false, ) XCTAssertEqual(result, .none) } @@ -317,6 +359,7 @@ class CipherMatchingHelperTests: BitwardenTestCase { // swiftlint:disable:this t .fixture(uri: #"^https://[a-z]+\.wikipedia\.org/w/index\.php"#, match: .regularExpression), ])), ), + archiveVaultItemsFF: false, ) XCTAssertEqual(result, .exact) } @@ -339,6 +382,7 @@ class CipherMatchingHelperTests: BitwardenTestCase { // swiftlint:disable:this t .fixture(uri: #"^https://[a-z]+\.wikipedia\.org/w/index\.php"#, match: .regularExpression), ])), ), + archiveVaultItemsFF: false, ) XCTAssertEqual(result, .none) } @@ -361,6 +405,7 @@ class CipherMatchingHelperTests: BitwardenTestCase { // swiftlint:disable:this t .fixture(uri: uri, match: .never), ])), ), + archiveVaultItemsFF: false, ) XCTAssertEqual(result, .none) } diff --git a/BitwardenShared/Core/Vault/Helpers/TestHelpers/MockVaultListPreparedDataBuilder+Extensions.swift b/BitwardenShared/Core/Vault/Helpers/TestHelpers/MockVaultListPreparedDataBuilder+Extensions.swift index 0633d61dd..70a4ba16b 100644 --- a/BitwardenShared/Core/Vault/Helpers/TestHelpers/MockVaultListPreparedDataBuilder+Extensions.swift +++ b/BitwardenShared/Core/Vault/Helpers/TestHelpers/MockVaultListPreparedDataBuilder+Extensions.swift @@ -41,6 +41,10 @@ extension MockVaultListPreparedDataBuilder { helper.recordCall("addSearchResultItem") return self } + incrementCipherArchivedCountClosure = { () -> VaultListPreparedDataBuilder in + helper.recordCall("incrementCipherArchivedCount") + return self + } incrementCipherTypeCountClosure = { _ -> VaultListPreparedDataBuilder in helper.recordCall("incrementCipherTypeCount") return self diff --git a/BitwardenShared/Core/Vault/Helpers/TestHelpers/MockVaultListSectionsBuilder+Extensions.swift b/BitwardenShared/Core/Vault/Helpers/TestHelpers/MockVaultListSectionsBuilder+Extensions.swift index 4d6157458..e4863f06e 100644 --- a/BitwardenShared/Core/Vault/Helpers/TestHelpers/MockVaultListSectionsBuilder+Extensions.swift +++ b/BitwardenShared/Core/Vault/Helpers/TestHelpers/MockVaultListSectionsBuilder+Extensions.swift @@ -33,6 +33,10 @@ extension MockVaultListSectionsBuilder { helper.recordCall("addGroupSection") return self } + addHiddenItemsSectionClosure = { () -> VaultListSectionsBuilder in + helper.recordCall("addHiddenItemsSection") + return self + } addTypesSectionClosure = { () -> VaultListSectionsBuilder in helper.recordCall("addTypesSection") return self @@ -49,10 +53,6 @@ extension MockVaultListSectionsBuilder { helper.recordCall("addSearchResultsSection") return self } - addTrashSectionClosure = { () -> VaultListSectionsBuilder in - helper.recordCall("addTrashSection") - return self - } return helper } diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparator+SearchTests.swift b/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparator+SearchTests.swift index e8baa020f..79d033cb0 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparator+SearchTests.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparator+SearchTests.swift @@ -79,6 +79,75 @@ class VaultListDataPreparatorSearchTests: BitwardenTestCase { // swiftlint:disab // MARK: Tests + /// `prepareSearchAutofillCombinedMultipleData(from:filter:withFido2Credentials:)` returns the + /// prepared data filtering out archived cipher when feature flag is enabled. + @MainActor + func test_prepareSearchAutofillCombinedMultipleData_archivedCipherFeatureFlagEnabled() async throws { + configService.featureFlagsBool[.archiveVaultItems] = true + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + login: .fixture( + hasFido2: false, + uris: [.fixture(uri: "https://example.com", match: .exact)], + ), + name: "Example Site", + archivedDate: .now, + copyableFields: [.loginPassword], + ) + + let result = await subject.prepareSearchAutofillCombinedMultipleData( + from: [ + .fixture( + login: .fixture( + uris: [.fixture(uri: "https://example.com", match: .exact)], + ), + ), + ], + filter: VaultListFilter(searchText: "example"), + withFido2Credentials: nil, + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + ]) + XCTAssertNotNil(result) + } + + /// `prepareSearchAutofillCombinedMultipleData(from:filter:withFido2Credentials:)` returns the + /// prepared data including archived cipher when feature flag is disabled. + @MainActor + func test_prepareSearchAutofillCombinedMultipleData_archivedCipherFeatureFlagDisabled() async throws { + configService.featureFlagsBool[.archiveVaultItems] = false + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + login: .fixture( + hasFido2: false, + uris: [.fixture(uri: "https://example.com", match: .exact)], + ), + name: "Example Site", + archivedDate: .now, + copyableFields: [.loginPassword], + ) + + let result = await subject.prepareSearchAutofillCombinedMultipleData( + from: [ + .fixture( + login: .fixture( + uris: [.fixture(uri: "https://example.com", match: .exact)], + ), + ), + ], + filter: VaultListFilter(searchText: "example"), + withFido2Credentials: nil, + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + "addItemForGroup", + ]) + XCTAssertNotNil(result) + } + /// `prepareSearchAutofillCombinedMultipleData(from:filter:withFido2Credentials:)` returns `nil` /// when no ciphers passed. func test_prepareSearchAutofillCombinedMultipleData_noCiphers() async throws { @@ -407,6 +476,74 @@ class VaultListDataPreparatorSearchTests: BitwardenTestCase { // swiftlint:disab XCTAssertNotNil(result) } + /// `prepareSearchData(from:filter:)` returns the prepared data filtering out archived cipher + /// when feature flag is enabled and not in archive group. + @MainActor + func test_prepareSearchData_archivedCipherFeatureFlagEnabled() async throws { + configService.featureFlagsBool[.archiveVaultItems] = true + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + name: "Example Site", + archivedDate: .now, + ) + + let result = await subject.prepareSearchData( + from: [.fixture()], + filter: VaultListFilter(searchText: "example"), + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + ]) + XCTAssertNotNil(result) + } + + /// `prepareSearchData(from:filter:)` returns the prepared data including archived cipher + /// when feature flag is enabled and in archive group. + @MainActor + func test_prepareSearchData_archivedCipherArchiveGroup() async throws { + configService.featureFlagsBool[.archiveVaultItems] = true + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + name: "Example Site", + archivedDate: .now, + ) + + let result = await subject.prepareSearchData( + from: [.fixture(archivedDate: .now)], + filter: VaultListFilter(group: .archive, searchText: "example"), + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + "addSearchResultItem", + ]) + XCTAssertNotNil(result) + } + + /// `prepareSearchData(from:filter:)` returns the prepared data including archived cipher + /// when feature flag is disabled. + @MainActor + func test_prepareSearchData_archivedCipherFeatureFlagDisabled() async throws { + configService.featureFlagsBool[.archiveVaultItems] = false + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + name: "Example Site", + archivedDate: .now, + ) + + let result = await subject.prepareSearchData( + from: [.fixture()], + filter: VaultListFilter(searchText: "example"), + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + "addSearchResultItem", + ]) + XCTAssertNotNil(result) + } + /// `prepareSearchData(from:filter:)` returns `nil` when no ciphers passed. func test_prepareSearchData_noCiphers() async throws { let result = await subject.prepareSearchData( diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparator.swift b/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparator.swift index 3eb14d03b..a8761dad3 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparator.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparator.swift @@ -131,6 +131,8 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { // swiftlint:di var preparedDataBuilder = vaultListPreparedDataBuilderFactory.make() + let archiveItemsFeatureFlagEnabled: Bool = await configService.getFeatureFlag(.archiveVaultItems) + await decryptAndProcessCiphersInBatch( ciphers: ciphers, filter: filter, @@ -142,7 +144,10 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { // swiftlint:di return } - let matchResult = cipherMatchingHelper.doesCipherMatch(cipher: decryptedCipher) + let matchResult = cipherMatchingHelper.doesCipherMatch( + cipher: decryptedCipher, + archiveVaultItemsFF: archiveItemsFeatureFlagEnabled, + ) preparedDataBuilder = await preparedDataBuilder.addItem( withMatchResult: matchResult, @@ -166,6 +171,8 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { // swiftlint:di var preparedDataBuilder = vaultListPreparedDataBuilderFactory.make() + let archiveItemsFeatureFlagEnabled: Bool = await configService.getFeatureFlag(.archiveVaultItems) + await decryptAndProcessCiphersInBatch( ciphers: ciphers, filter: filter, @@ -176,7 +183,14 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { // swiftlint:di return } - let matchResult = cipherMatchingHelper.doesCipherMatch(cipher: decryptedCipher) + if archiveItemsFeatureFlagEnabled, decryptedCipher.isArchived { + return + } + + let matchResult = cipherMatchingHelper.doesCipherMatch( + cipher: decryptedCipher, + archiveVaultItemsFF: archiveItemsFeatureFlagEnabled, + ) guard matchResult != .none else { return } @@ -202,6 +216,8 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { // swiftlint:di var preparedDataBuilder = vaultListPreparedDataBuilderFactory.make() + let archiveItemsFeatureFlagEnabled: Bool = await configService.getFeatureFlag(.archiveVaultItems) + await decryptAndProcessCiphersInBatch( ciphers: ciphers, filter: filter, @@ -212,7 +228,14 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { // swiftlint:di return } - let matchResult = cipherMatchingHelper.doesCipherMatch(cipher: decryptedCipher) + if archiveItemsFeatureFlagEnabled, decryptedCipher.isArchived { + return + } + + let matchResult = cipherMatchingHelper.doesCipherMatch( + cipher: decryptedCipher, + archiveVaultItemsFF: archiveItemsFeatureFlagEnabled, + ) guard matchResult != .none else { return } @@ -245,6 +268,8 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { // swiftlint:di .prepareFolders(folders: folders, filterType: filter.filterType) .prepareCollections(collections: collections, filterType: filter.filterType) + let archiveItemsFeatureFlagEnabled: Bool = await configService.getFeatureFlag(.archiveVaultItems) + await decryptAndProcessCiphersInBatch( ciphers: ciphers, filter: filter, @@ -255,6 +280,11 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { // swiftlint:di return } + if archiveItemsFeatureFlagEnabled, decryptedCipher.isArchived { + preparedDataBuilder = preparedDataBuilder.incrementCipherArchivedCount() + return + } + if filter.options.contains(.addTOTPGroup) { preparedDataBuilder = await preparedDataBuilder.incrementTOTPCount(cipher: decryptedCipher) } @@ -285,6 +315,8 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { // swiftlint:di .prepareFolders(folders: folders, filterType: filter.filterType) .prepareCollections(collections: collections, filterType: filter.filterType) + let archiveItemsFeatureFlagEnabled: Bool = await configService.getFeatureFlag(.archiveVaultItems) + await decryptAndProcessCiphersInBatch( ciphers: ciphers, filter: filter, @@ -294,6 +326,13 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { // swiftlint:di return } + if archiveItemsFeatureFlagEnabled, + filter.group != .archive, + filter.group != .trash, + decryptedCipher.isArchived { + return + } + if case .folder = group { preparedDataBuilder = preparedDataBuilder.addFolderItem( cipher: decryptedCipher, @@ -323,6 +362,8 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { // swiftlint:di var preparedDataBuilder = vaultListPreparedDataBuilderFactory.make() + let archiveItemsFeatureFlagEnabled: Bool = await configService.getFeatureFlag(.archiveVaultItems) + await decryptAndProcessCiphersInBatch( ciphers: ciphers, filter: filter, @@ -331,6 +372,10 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { // swiftlint:di filterEncryptedCipherByDeletedAndGroup(cipher: cipher, filter: filter) }, onCipher: { decryptedCipher in + if archiveItemsFeatureFlagEnabled, decryptedCipher.isArchived { + return + } + let matchResult = decryptedCipher.matchesSearchQuery(searchText) guard matchResult != .none else { return @@ -356,6 +401,8 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { // swiftlint:di var preparedDataBuilder = vaultListPreparedDataBuilderFactory.make() + let archiveItemsFeatureFlagEnabled: Bool = await configService.getFeatureFlag(.archiveVaultItems) + await decryptAndProcessCiphersInBatch( ciphers: ciphers, filter: filter, @@ -364,6 +411,12 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { // swiftlint:di filterEncryptedCipherByDeletedAndGroup(cipher: cipher, filter: filter) }, onCipher: { decryptedCipher in + if archiveItemsFeatureFlagEnabled, decryptedCipher.isArchived { + guard let group = filter.group, group == .archive else { + return + } + } + let matchResult = decryptedCipher.matchesSearchQuery(searchText) preparedDataBuilder = await preparedDataBuilder.addSearchResultItem( withMatchResult: matchResult, diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparatorTests.swift b/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparatorTests.swift index e7fabe506..3ce40da09 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparatorTests.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparatorTests.swift @@ -77,6 +77,73 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi // MARK: Tests + /// `prepareAutofillCombinedSingleData(from:filter:)` returns the prepared data filtering out + /// archived cipher when feature flag is enabled. + @MainActor + func test_prepareAutofillCombinedSingleData_archivedCipherFeatureFlagEnabled() async throws { + configService.featureFlagsBool[.archiveVaultItems] = true + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + login: .fixture( + hasFido2: false, + uris: [.fixture(uri: "https://example.com", match: .exact)], + ), + archivedDate: .now, + copyableFields: [.loginPassword], + ) + cipherMatchingHelper.doesCipherMatchReturnValue = .exact + + let result = await subject.prepareAutofillCombinedSingleData( + from: [ + .fixture( + login: .fixture( + uris: [.fixture(uri: "https://example.com", match: .exact)], + ), + ), + ], + filter: VaultListFilter(uri: "https://example.com"), + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + ]) + XCTAssertNotNil(result) + } + + /// `prepareAutofillCombinedSingleData(from:filter:)` returns the prepared data including + /// archived cipher when feature flag is disabled. + @MainActor + func test_prepareAutofillCombinedSingleData_archivedCipherFeatureFlagDisabled() async throws { + configService.featureFlagsBool[.archiveVaultItems] = false + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + login: .fixture( + hasFido2: false, + uris: [.fixture(uri: "https://example.com", match: .exact)], + ), + archivedDate: .now, + copyableFields: [.loginPassword], + ) + cipherMatchingHelper.doesCipherMatchReturnValue = .exact + + let result = await subject.prepareAutofillCombinedSingleData( + from: [ + .fixture( + login: .fixture( + uris: [.fixture(uri: "https://example.com", match: .exact)], + ), + ), + ], + filter: VaultListFilter(uri: "https://example.com"), + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + "addItemForGroup", + ]) + XCTAssertNotNil(result) + } + /// `prepareAutofillCombinedSingleData(from:filter:)` returns `nil` when no ciphers passed. func test_prepareAutofillCombinedSingleData_noCiphers() async throws { let result = await subject.prepareAutofillCombinedSingleData( @@ -244,6 +311,75 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi XCTAssertNotNil(result) } + /// `prepareAutofillCombinedMultipleData(from:filter:withFido2Credentials:)` returns the prepared data filtering out + /// archived cipher when feature flag is enabled. + @MainActor + func test_prepareAutofillCombinedMultipleData_archivedCipherFeatureFlagEnabled() async throws { + configService.featureFlagsBool[.archiveVaultItems] = true + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + login: .fixture( + hasFido2: false, + uris: [.fixture(uri: "https://example.com", match: .exact)], + ), + archivedDate: .now, + copyableFields: [.loginPassword], + ) + cipherMatchingHelper.doesCipherMatchReturnValue = .exact + + let result = await subject.prepareAutofillCombinedMultipleData( + from: [ + .fixture( + login: .fixture( + uris: [.fixture(uri: "https://example.com", match: .exact)], + ), + ), + ], + filter: VaultListFilter(uri: "https://example.com"), + withFido2Credentials: nil, + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + ]) + XCTAssertNotNil(result) + } + + /// `prepareAutofillCombinedMultipleData(from:filter:withFido2Credentials:)` returns the prepared data including + /// archived cipher when feature flag is disabled. + @MainActor + func test_prepareAutofillCombinedMultipleData_archivedCipherFeatureFlagDisabled() async throws { + configService.featureFlagsBool[.archiveVaultItems] = false + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + login: .fixture( + hasFido2: false, + uris: [.fixture(uri: "https://example.com", match: .exact)], + ), + archivedDate: .now, + copyableFields: [.loginPassword], + ) + cipherMatchingHelper.doesCipherMatchReturnValue = .exact + + let result = await subject.prepareAutofillCombinedMultipleData( + from: [ + .fixture( + login: .fixture( + uris: [.fixture(uri: "https://example.com", match: .exact)], + ), + ), + ], + filter: VaultListFilter(uri: "https://example.com"), + withFido2Credentials: nil, + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + "addItemForGroup", + ]) + XCTAssertNotNil(result) + } + /// `prepareAutofillCombinedMultipleData(from:filter:withFido2Credentials:)` returns `nil` when no ciphers passed. func test_prepareAutofillCombinedMultipleData_noCiphers() async throws { let result = await subject.prepareAutofillCombinedMultipleData( @@ -688,6 +824,64 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi XCTAssertNotNil(result) } + /// `prepareData(from:collections:folders:filter:)` returns the prepared data filtering out cipher + /// when having an archived date and feature flag is enabled, but incrementing the count of archived items. + @MainActor + func test_prepareData_withArchivedDateFeatureFlagEnabled() async throws { + configService.featureFlagsBool[.archiveVaultItems] = true + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + organizationId: "1", + archivedDate: .now, + ) + + let result = await subject.prepareData( + from: [.fixture()], + collections: [.fixture(id: "1"), .fixture(id: "2")], + folders: [.fixture(id: "1"), .fixture(id: "2"), .fixture(id: "3")], + filter: VaultListFilter(), + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareFolders", + "prepareCollections", + "prepareRestrictItemsPolicyOrganizations", + "incrementCipherArchivedCount", + ]) + XCTAssertNotNil(result) + } + + /// `prepareData(from:collections:folders:filter:)` returns the prepared data without filtering out cipher + /// when having an archived date and feature flag is disabled. + @MainActor + func test_prepareData_withArchivedDateFeatureFlagDisabled() async throws { + configService.featureFlagsBool[.archiveVaultItems] = false + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + archivedDate: .now, + ) + + let result = await subject.prepareData( + from: [.fixture()], + collections: [.fixture(id: "1"), .fixture(id: "2")], + folders: [.fixture(id: "1"), .fixture(id: "2"), .fixture(id: "3")], + filter: VaultListFilter(options: [.addTOTPGroup]), + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareFolders", + "prepareCollections", + "prepareRestrictItemsPolicyOrganizations", + "incrementTOTPCount", + "addCipherDecryptionFailure", + "addFolderItem", + "addFavoriteItem", + "addNoFolderItem", + "incrementCipherTypeCount", + "incrementCollectionCount", + ]) + XCTAssertNotNil(result) + } + /// `prepareData(from:collections:folders:filter:)` returns the prepared data filtering out cipher /// when having a deleted date, but incrementing the count of deleted items. func test_prepareData_withDeletedDate() async throws { @@ -713,6 +907,83 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi XCTAssertNotNil(result) } + /// `prepareGroupData(from:collections:folders:filter:)` returns the prepared data filtering out cipher + /// when archived and feature flag is enabled and not in archive group. + @MainActor + func test_prepareGroupData_archivedCipherFeatureFlagEnabled() async throws { + configService.featureFlagsBool[.archiveVaultItems] = true + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + archivedDate: .now, + ) + + let result = await subject.prepareGroupData( + from: [.fixture(archivedDate: .now)], + collections: [.fixture(id: "1"), .fixture(id: "2")], + folders: [.fixture(id: "1"), .fixture(id: "2"), .fixture(id: "3")], + filter: VaultListFilter(group: .login), + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareFolders", + "prepareCollections", + "prepareRestrictItemsPolicyOrganizations", + ]) + XCTAssertNotNil(result) + } + + /// `prepareGroupData(from:collections:folders:filter:)` returns the prepared data including cipher + /// when archived and feature flag is enabled and in archive group. + @MainActor + func test_prepareGroupData_archivedCipherArchiveGroup() async throws { + configService.featureFlagsBool[.archiveVaultItems] = true + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + archivedDate: .now, + ) + + let result = await subject.prepareGroupData( + from: [.fixture(archivedDate: .now)], + collections: [.fixture(id: "1"), .fixture(id: "2")], + folders: [.fixture(id: "1"), .fixture(id: "2"), .fixture(id: "3")], + filter: VaultListFilter(group: .archive), + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareFolders", + "prepareCollections", + "prepareRestrictItemsPolicyOrganizations", + "addItemForGroup", + ]) + XCTAssertNotNil(result) + } + + /// `prepareGroupData(from:collections:folders:filter:)` returns the prepared data including cipher + /// when archived and feature flag is disabled. + @MainActor + func test_prepareGroupData_archivedCipherFeatureFlagDisabled() async throws { + configService.featureFlagsBool[.archiveVaultItems] = false + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + archivedDate: .now, + ) + + let result = await subject.prepareGroupData( + from: [.fixture(archivedDate: .now)], + collections: [.fixture(id: "1"), .fixture(id: "2")], + folders: [.fixture(id: "1"), .fixture(id: "2"), .fixture(id: "3")], + filter: VaultListFilter(group: .login), + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareFolders", + "prepareCollections", + "prepareRestrictItemsPolicyOrganizations", + "addItemForGroup", + ]) + XCTAssertNotNil(result) + } + /// `prepareGroupData(from:collections:folders:filter:)` returns `nil` when no ciphers passed. func test_prepareGroupData_noCiphers() async throws { let result = await subject.prepareGroupData( @@ -884,6 +1155,37 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi XCTAssertNotNil(result) } + /// `prepareAutofillPasswordsData(from:filter:)` returns the prepared data including cipher as it's archived + /// when feature flag is disabled. + @MainActor + func test_prepareAutofillPasswordsData_archivedCipherFeatureFlagDisabled() async throws { + configService.featureFlagsBool[.archiveVaultItems] = false + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + archivedDate: .now, + copyableFields: [.loginPassword], + ) + cipherMatchingHelper.doesCipherMatchReturnValue = .exact + + let result = await subject.prepareAutofillPasswordsData( + from: [ + .fixture( + login: .fixture( + uris: [.fixture(uri: "https://example.com", match: .exact)], + ), + type: .login, + ), + ], + filter: VaultListFilter(uri: "https://example.com"), + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + "addItemWithMatchResultCipher", + ]) + XCTAssertNotNil(result) + XCTAssertEqual(cipherMatchingHelper.doesCipherMatchReceivedArguments?.cipher.id, "1") + } + /// `prepareAutofillPasswordsData(from::filter:)` returns `nil` when no ciphers passed. func test_prepareAutofillPasswordsData_noCiphers() async throws { let result = await subject.prepareAutofillPasswordsData( @@ -927,7 +1229,7 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi "addItemWithMatchResultCipher", ]) XCTAssertNotNil(result) - XCTAssertEqual(cipherMatchingHelper.doesCipherMatchReceivedCipher?.id, "1") + XCTAssertEqual(cipherMatchingHelper.doesCipherMatchReceivedArguments?.cipher.id, "1") } /// `prepareAutofillPasswordsData(from:filter:)` returns the prepared data filtering out cipher as it doesn't pass @@ -957,7 +1259,7 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi "prepareRestrictItemsPolicyOrganizations", ]) XCTAssertNotNil(result) - XCTAssertNil(cipherMatchingHelper.doesCipherMatchReceivedCipher) + XCTAssertNil(cipherMatchingHelper.doesCipherMatchReceivedArguments?.cipher) } /// `prepareAutofillPasswordsData(from:filter:)` returns the prepared data filtering out cipher @@ -985,7 +1287,7 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi "prepareRestrictItemsPolicyOrganizations", ]) XCTAssertNotNil(result) - XCTAssertNil(cipherMatchingHelper.doesCipherMatchReceivedCipher) + XCTAssertNil(cipherMatchingHelper.doesCipherMatchReceivedArguments?.cipher) } /// `prepareAutofillPasswordsData(from:filter:)` returns the prepared data filtering out cipher as it's deleted. @@ -1012,7 +1314,7 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi "prepareRestrictItemsPolicyOrganizations", ]) XCTAssertNotNil(result) - XCTAssertNil(cipherMatchingHelper.doesCipherMatchReceivedCipher) + XCTAssertNil(cipherMatchingHelper.doesCipherMatchReceivedArguments?.cipher) } // MARK: Private diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/MainVaultListDirectorStrategy.swift b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/MainVaultListDirectorStrategy.swift index 44720bc78..752e9ae80 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/MainVaultListDirectorStrategy.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/MainVaultListDirectorStrategy.swift @@ -73,8 +73,8 @@ struct MainVaultListDirectorStrategy: VaultListDirectorStrategy { .addCollectionsSection() .addCipherDecryptionFailureIds() - if filter.options.contains(.addTrashGroup) { - builder = builder.addTrashSection() + if filter.options.contains(.addHiddenItemsGroup) { + builder = await builder.addHiddenItemsSection() } return builder.build() diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/MainVaultListDirectorStrategyTests.swift b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/MainVaultListDirectorStrategyTests.swift index 730f55499..058ee10c1 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/MainVaultListDirectorStrategyTests.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/MainVaultListDirectorStrategyTests.swift @@ -101,7 +101,7 @@ class MainVaultListDirectorStrategyTests: BitwardenTestCase { var iteratorPublisher = try await subject.build( filter: VaultListFilter( - options: [.addTOTPGroup, .addTrashGroup], + options: [.addTOTPGroup, .addHiddenItemsGroup], ), ).makeAsyncIterator() let result = try await iteratorPublisher.next() @@ -115,7 +115,7 @@ class MainVaultListDirectorStrategyTests: BitwardenTestCase { "addFoldersSection", "addCollectionsSection", "addCipherDecryptionFailureIds", - "addTrashSection", + "addHiddenItemsSection", ]) } @@ -173,7 +173,7 @@ class MainVaultListDirectorStrategyTests: BitwardenTestCase { var iteratorPublisher = try await subject.build( filter: VaultListFilter( - options: [.addTrashGroup], + options: [.addHiddenItemsGroup], ), ).makeAsyncIterator() let result = try await iteratorPublisher.next() @@ -186,7 +186,7 @@ class MainVaultListDirectorStrategyTests: BitwardenTestCase { "addFoldersSection", "addCollectionsSection", "addCipherDecryptionFailureIds", - "addTrashSection", + "addHiddenItemsSection", ]) } diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListPreparedDataBuilder+AddItemTests.swift b/BitwardenShared/Core/Vault/Helpers/VaultListPreparedDataBuilder+AddItemTests.swift index e354f22c9..98f799531 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListPreparedDataBuilder+AddItemTests.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListPreparedDataBuilder+AddItemTests.swift @@ -51,6 +51,24 @@ class VaultListPreparedDataBuilderAddItemTests: BitwardenTestCase { // MARK: Tests + /// `addItem(forGroup:with:)` adds an archived item to the prepared data when the cipher + /// is archived and group is archive. + func test_addItem_addsArchivedItemWhenCipherIsArchivedAndGroupIsArchive() async { + let cipher = CipherListView.fixture(archivedDate: Date()) + let preparedData = await subject.addItem(forGroup: .archive, with: cipher).build() + + XCTAssertEqual(preparedData.groupItems.count, 1) + XCTAssertEqual(preparedData.groupItems[0].id, cipher.id) + } + + /// `addItem(forGroup:with:)` does not add an item when the cipher is not archived and group is archive. + func test_addItem_doesNotAddWhenCipherIsNotArchivedAndGroupIsArchive() async { + let cipher = CipherListView.fixture(archivedDate: nil) + let preparedData = await subject.addItem(forGroup: .archive, with: cipher).build() + + XCTAssertTrue(preparedData.groupItems.isEmpty) + } + /// `addItem(forGroup:with:)` adds a trash item to the prepared data when the cipher is deleted and group is trash. func test_addItem_addsTrashItemWhenCipherIsDeletedAndGroupIsTrash() async { let cipher = CipherListView.fixture(deletedDate: Date()) diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListPreparedDataBuilder.swift b/BitwardenShared/Core/Vault/Helpers/VaultListPreparedDataBuilder.swift index 68eda32b5..95c9464b0 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListPreparedDataBuilder.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListPreparedDataBuilder.swift @@ -73,6 +73,8 @@ protocol VaultListPreparedDataBuilder { // sourcery: AutoMockable ) async -> VaultListPreparedDataBuilder /// Builds the prepared data. func build() -> VaultListPreparedData + /// Increments the cipher archived count in the prepared data. + func incrementCipherArchivedCount() -> VaultListPreparedDataBuilder /// Increments the cipher type count in the prepared data. func incrementCipherTypeCount(cipher: CipherListView) -> VaultListPreparedDataBuilder /// Increments the cipher deleted count in the prepared data. @@ -235,6 +237,8 @@ class DefaultVaultListPreparedDataBuilder: VaultListPreparedDataBuilder { // swi guard cipher.folderId == id else { return self } case .noFolder: guard cipher.folderId == nil else { return self } + case .archive: + guard cipher.archivedDate != nil else { return self } case .trash: // this case is handled at the beginning of the function. return self @@ -311,6 +315,11 @@ class DefaultVaultListPreparedDataBuilder: VaultListPreparedDataBuilder { // swi return self } + func incrementCipherArchivedCount() -> VaultListPreparedDataBuilder { + preparedData.ciphersArchivedCount += 1 + return self + } + func incrementCollectionCount(cipher: CipherListView) -> VaultListPreparedDataBuilder { if !cipher.collectionIds.isEmpty { let tempCollectionsForCipher = preparedData.collections.filter { collection in diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilder+CollectionTests.swift b/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilder+CollectionTests.swift index 5862ebc69..5ede4d022 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilder+CollectionTests.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilder+CollectionTests.swift @@ -186,6 +186,7 @@ class VaultListSectionsBuilderCollectionTests: BitwardenTestCase { subject = DefaultVaultListSectionsBuilder( clientService: clientService, collectionHelper: collectionHelper, + configService: MockConfigService(), errorReporter: errorReporter, withData: withData, ) diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilder+FolderTests.swift b/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilder+FolderTests.swift index ee9d66af4..4813abbeb 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilder+FolderTests.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilder+FolderTests.swift @@ -273,6 +273,7 @@ class VaultListSectionsBuilderFolderTests: BitwardenTestCase { subject = DefaultVaultListSectionsBuilder( clientService: clientService, collectionHelper: collectionHelper, + configService: MockConfigService(), errorReporter: errorReporter, withData: withData, ) diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilder.swift b/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilder.swift index befdf168a..2192208ad 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilder.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilder.swift @@ -54,9 +54,9 @@ protocol VaultListSectionsBuilder { // sourcery: AutoMockable /// - Returns: The builder for fluent code. func addTOTPSection() -> VaultListSectionsBuilder - /// Adds a section with trash (deleted) items. + /// Adds a section with hidden items: archived and trash (deleted) items. /// - Returns: The builder for fluent code. - func addTrashSection() -> VaultListSectionsBuilder + func addHiddenItemsSection() async -> VaultListSectionsBuilder /// Adds a section with items types. /// - Returns: The builder for fluent code. @@ -98,6 +98,8 @@ class DefaultVaultListSectionsBuilder: VaultListSectionsBuilder { // swiftlint:d let clientService: ClientService /// The helper functions for collections. let collectionHelper: CollectionHelper + /// The service to get server-specified configuration. + let configService: ConfigService /// The service used by the application to report non-fatal errors. let errorReporter: ErrorReporter /// Vault list data prepared to be used by the builder. @@ -117,11 +119,13 @@ class DefaultVaultListSectionsBuilder: VaultListSectionsBuilder { // swiftlint:d init( clientService: ClientService, collectionHelper: CollectionHelper, + configService: ConfigService, errorReporter: ErrorReporter, withData preparedData: VaultListPreparedData, ) { self.clientService = clientService self.collectionHelper = collectionHelper + self.configService = configService self.errorReporter = errorReporter self.preparedData = preparedData } @@ -321,6 +325,25 @@ class DefaultVaultListSectionsBuilder: VaultListSectionsBuilder { // swiftlint:d return self } + func addHiddenItemsSection() async -> VaultListSectionsBuilder { + var items: [VaultListItem] = [] + + if await configService.getFeatureFlag(.archiveVaultItems) { + items.append(VaultListItem(id: "Archive", itemType: .group(.archive, preparedData.ciphersArchivedCount))) + } + + items.append(VaultListItem(id: "Trash", itemType: .group(.trash, preparedData.ciphersDeletedCount))) + + vaultListData.sections.append( + VaultListSection( + id: "HiddenItems", + items: items, + name: Localizations.hiddenItems, + ), + ) + return self + } + func addSearchResultsSection(options: VaultListOptions) -> VaultListSectionsBuilder { guard !preparedData.exactMatchItems.isEmpty || !preparedData.fuzzyMatchItems.isEmpty else { return self @@ -362,14 +385,6 @@ class DefaultVaultListSectionsBuilder: VaultListSectionsBuilder { // swiftlint:d return self } - func addTrashSection() -> VaultListSectionsBuilder { - let ciphersTrashItem = VaultListItem(id: "Trash", itemType: .group(.trash, preparedData.ciphersDeletedCount)) - vaultListData.sections.append( - VaultListSection(id: "Trash", items: [ciphersTrashItem], name: Localizations.trash), - ) - return self - } - func addTypesSection() -> VaultListSectionsBuilder { var types = [ VaultListItem( @@ -420,10 +435,22 @@ class DefaultVaultListSectionsBuilder: VaultListSectionsBuilder { // swiftlint:d /// Metadata helper object to hold temporary prepared (grouped, filtered, counted) data /// the builder can then use to build the list sections. struct VaultListPreparedData { + /// The count of archived ciphers in the vault. + var ciphersArchivedCount: Int = 0 + + /// The list of cipher IDs that failed to decrypt. var cipherDecryptionFailureIds: [Uuid] = [] + + /// The count of deleted (trashed) ciphers in the vault. var ciphersDeletedCount: Int = 0 + + /// The list of collections available to the user. var collections: [Collection] = [] + + /// A dictionary mapping collection IDs to the count of ciphers in each collection. var collectionsCount: [Uuid: Int] = [:] + + /// A dictionary mapping cipher types to their counts in the vault. var countPerCipherType: [CipherType: Int] = [ .card: 0, .identity: 0, @@ -431,15 +458,34 @@ struct VaultListPreparedData { .secureNote: 0, .sshKey: 0, ] + + /// Vault list items that exactly match the search criteria. var exactMatchItems: [VaultListItem] = [] + + /// Vault list items marked as favorites by the user. var favorites: [VaultListItem] = [] + + /// Vault list items containing FIDO2 credentials. var fido2Items: [VaultListItem] = [] + + /// The list of folders available to the user. var folders: [Folder] = [] + + /// A dictionary mapping folder IDs to the count of ciphers in each folder. var foldersCount: [Uuid: Int] = [:] + + /// Vault list items that partially match the search criteria. var fuzzyMatchItems: [VaultListItem] = [] + + /// Vault list items belonging to the currently filtered group. var groupItems: [VaultListItem] = [] + + /// Vault list items that are not assigned to any folder. var noFolderItems: [VaultListItem] = [] - /// Organization Ids with `.restrictItemTypes` policy enabled. + + /// Organization IDs with the `.restrictItemTypes` policy enabled. var restrictedOrganizationIds: [String] = [] + + /// The count of items with TOTP codes in the vault. var totpItemsCount: Int = 0 } // swiftlint:disable:this file_length diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilderFactory.swift b/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilderFactory.swift index d13864434..fa84f3a66 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilderFactory.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilderFactory.swift @@ -19,6 +19,8 @@ struct DefaultVaultListSectionsBuilderFactory: VaultListSectionsBuilderFactory { let clientService: ClientService /// The helper functions for collections. let collectionHelper: CollectionHelper + /// The service to get server-specified configuration. + let configService: ConfigService /// The service used by the application to report non-fatal errors. let errorReporter: ErrorReporter @@ -26,6 +28,7 @@ struct DefaultVaultListSectionsBuilderFactory: VaultListSectionsBuilderFactory { DefaultVaultListSectionsBuilder( clientService: clientService, collectionHelper: collectionHelper, + configService: configService, errorReporter: errorReporter, withData: preparedData, ) diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilderFactoryTests.swift b/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilderFactoryTests.swift index 26bd3c65d..515b71deb 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilderFactoryTests.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilderFactoryTests.swift @@ -11,6 +11,7 @@ class VaultListSectionsBuilderFactoryTests: BitwardenTestCase { var clientService: MockClientService! var collectionHelper: MockCollectionHelper! + var configService: MockConfigService! var errorReporter: MockErrorReporter! var subject: VaultListSectionsBuilderFactory! @@ -21,10 +22,12 @@ class VaultListSectionsBuilderFactoryTests: BitwardenTestCase { clientService = MockClientService() collectionHelper = MockCollectionHelper() + configService = MockConfigService() errorReporter = MockErrorReporter() subject = DefaultVaultListSectionsBuilderFactory( clientService: clientService, collectionHelper: collectionHelper, + configService: configService, errorReporter: errorReporter, ) } @@ -34,6 +37,7 @@ class VaultListSectionsBuilderFactoryTests: BitwardenTestCase { clientService = nil collectionHelper = nil + configService = nil errorReporter = nil subject = nil } diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilderTests.swift b/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilderTests.swift index 9d4966938..d74482366 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilderTests.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilderTests.swift @@ -12,6 +12,7 @@ class VaultListSectionsBuilderTests: BitwardenTestCase { // swiftlint:disable:th // MARK: Properties var clientService: MockClientService! + var configService: MockConfigService! var errorReporter: MockErrorReporter! var subject: DefaultVaultListSectionsBuilder! @@ -21,6 +22,7 @@ class VaultListSectionsBuilderTests: BitwardenTestCase { // swiftlint:disable:th super.setUp() clientService = MockClientService() + configService = MockConfigService() errorReporter = MockErrorReporter() } @@ -28,6 +30,7 @@ class VaultListSectionsBuilderTests: BitwardenTestCase { // swiftlint:disable:th super.tearDown() clientService = nil + configService = nil errorReporter = nil subject = nil } @@ -362,6 +365,44 @@ class VaultListSectionsBuilderTests: BitwardenTestCase { // swiftlint:disable:th } } + /// `addHiddenItemsSection()` adds the hidden items section to the list of sections with the count + /// of deleted ciphers when the archive feature flag is off. + @MainActor + func test_addHiddenItemsSection_archiveFeatureFlagDisabled() async { + configService.featureFlagsBool[.archiveVaultItems] = false + setUpSubject(withData: VaultListPreparedData(ciphersDeletedCount: 10)) + + let vaultListData = await subject.addHiddenItemsSection().build() + + assertInlineSnapshot(of: vaultListData.sections.dump(), as: .lines) { + """ + Section[HiddenItems]: Hidden items + - Group[Trash]: Trash (10) + """ + } + } + + /// `addHiddenItemsSection()` adds the hidden items section to the list of sections with the count of + /// archived and deleted ciphers when the archive feature flag is on. + @MainActor + func test_addHiddenItemsSection_archiveFeatureFlagEnabled() async { + configService.featureFlagsBool[.archiveVaultItems] = true + setUpSubject(withData: VaultListPreparedData( + ciphersArchivedCount: 5, + ciphersDeletedCount: 10, + )) + + let vaultListData = await subject.addHiddenItemsSection().build() + + assertInlineSnapshot(of: vaultListData.sections.dump(), as: .lines) { + """ + Section[HiddenItems]: Hidden items + - Group[Archive]: Archive (5) + - Group[Trash]: Trash (10) + """ + } + } + /// `addTOTPSection()` adds the TOTP section with an item when there are TOTP items. func test_addTOTPSection() { setUpSubject( @@ -396,20 +437,6 @@ class VaultListSectionsBuilderTests: BitwardenTestCase { // swiftlint:disable:th } } - /// `addTrashSection()` adds the trash section to the list of sections with the count of deleted ciphers. - func test_addTrashSection() { - setUpSubject(withData: VaultListPreparedData(ciphersDeletedCount: 10)) - - let vaultListData = subject.addTrashSection().build() - - assertInlineSnapshot(of: vaultListData.sections.dump(), as: .lines) { - """ - Section[Trash]: Trash - - Group[Trash]: Trash (10) - """ - } - } - /// `addTypesSection()` adds the Types section with each item type count, or 0 if not found. func test_addTypesSection() { setUpSubject( @@ -708,7 +735,7 @@ class VaultListSectionsBuilderTests: BitwardenTestCase { // swiftlint:disable:th ) let vaultListData = try await subject - .addTrashSection() + .addHiddenItemsSection() .addCollectionsSection() .addFavoritesSection() .addFoldersSection() @@ -719,7 +746,7 @@ class VaultListSectionsBuilderTests: BitwardenTestCase { // swiftlint:disable:th assertInlineSnapshot(of: vaultListData.sections.dump(), as: .lines) { """ - Section[Trash]: Trash + Section[HiddenItems]: Hidden items - Group[Trash]: Trash (10) Section[Collections]: Collections - Group[1]: Collection 1 (5) @@ -751,6 +778,7 @@ class VaultListSectionsBuilderTests: BitwardenTestCase { // swiftlint:disable:th subject = DefaultVaultListSectionsBuilder( clientService: clientService, collectionHelper: collectionHelper, + configService: configService, errorReporter: errorReporter, withData: withData, ) diff --git a/BitwardenShared/Core/Vault/Models/Enum/CipherType.swift b/BitwardenShared/Core/Vault/Models/Enum/CipherType.swift index 3f09a1125..52226589e 100644 --- a/BitwardenShared/Core/Vault/Models/Enum/CipherType.swift +++ b/BitwardenShared/Core/Vault/Models/Enum/CipherType.swift @@ -37,11 +37,13 @@ extension CipherType { self = .secureNote case .sshKey: self = .sshKey - case .collection, - .folder, - .noFolder, - .totp, - .trash: + case + .archive, + .collection, + .folder, + .noFolder, + .totp, + .trash: return nil } } diff --git a/BitwardenShared/Core/Vault/Models/Enum/CipherTypeTests.swift b/BitwardenShared/Core/Vault/Models/Enum/CipherTypeTests.swift index 1ff82ad0a..61345b812 100644 --- a/BitwardenShared/Core/Vault/Models/Enum/CipherTypeTests.swift +++ b/BitwardenShared/Core/Vault/Models/Enum/CipherTypeTests.swift @@ -33,6 +33,7 @@ class CipherTypeTests: BitwardenTestCase { XCTAssertEqual(CipherType(group: .login), .login) XCTAssertEqual(CipherType(group: .secureNote), .secureNote) XCTAssertEqual(CipherType(group: .sshKey), .sshKey) + XCTAssertNil(CipherType(group: .archive)) XCTAssertNil(CipherType(group: .trash)) } diff --git a/BitwardenShared/Core/Vault/Repositories/TestHelpers/MockVaultRepository.swift b/BitwardenShared/Core/Vault/Repositories/TestHelpers/MockVaultRepository.swift index aebe8d078..6e6c82d4c 100644 --- a/BitwardenShared/Core/Vault/Repositories/TestHelpers/MockVaultRepository.swift +++ b/BitwardenShared/Core/Vault/Repositories/TestHelpers/MockVaultRepository.swift @@ -13,6 +13,9 @@ class MockVaultRepository: VaultRepository { var addCipherCiphers = [CipherView]() var addCipherResult: Result = .success(()) + var archiveCipher = [CipherView]() + var archiveCipherResult: Result = .success(()) + var bulkShareCiphersCiphers = [[CipherView]]() var bulkShareCiphersOrganizationId: String? var bulkShareCiphersCollectionIds: [String]? @@ -114,6 +117,9 @@ class MockVaultRepository: VaultRepository { var timeProvider: TimeProvider = MockTimeProvider(.currentTime) + var unarchiveCipher = [CipherView]() + var unarchiveCipherResult: Result = .success(()) + var updateCipherCiphers = [BitwardenSdk.CipherView]() var updateCipherResult: Result = .success(()) @@ -154,6 +160,11 @@ class MockVaultRepository: VaultRepository { canShowVaultFilter } + func archiveCipher(_ cipher: CipherView) async throws { + archiveCipher.append(cipher) + try archiveCipherResult.get() + } + func cipherPublisher() async throws -> AsyncThrowingPublisher> { ciphersSubject.eraseToAnyPublisher().values } @@ -316,6 +327,11 @@ class MockVaultRepository: VaultRepository { try softDeleteCipherResult.get() } + func unarchiveCipher(_ cipher: CipherView) async throws { + unarchiveCipher.append(cipher) + try unarchiveCipherResult.get() + } + func updateCipher(_ cipher: BitwardenSdk.CipherView) async throws { updateCipherCiphers.append(cipher) try updateCipherResult.get() diff --git a/BitwardenShared/Core/Vault/Repositories/VaultRepository.swift b/BitwardenShared/Core/Vault/Repositories/VaultRepository.swift index 7a79126c8..790c1a09d 100644 --- a/BitwardenShared/Core/Vault/Repositories/VaultRepository.swift +++ b/BitwardenShared/Core/Vault/Repositories/VaultRepository.swift @@ -28,6 +28,12 @@ public protocol VaultRepository: AnyObject { /// func addCipher(_ cipher: CipherView) async throws + /// Archives a cipher. + /// + /// - Parameter cipher: The cipher that the user is archiving. + /// + func archiveCipher(_ cipher: CipherView) async throws + /// Shares multiple ciphers with an organization. /// /// - Parameters: @@ -207,6 +213,12 @@ public protocol VaultRepository: AnyObject { /// func softDeleteCipher(_ cipher: CipherView) async throws + /// Unarchives a cipher from the vault. + /// + /// - Parameter cipher: The cipher that the user is unarchiving. + /// + func unarchiveCipher(_ cipher: CipherView) async throws + /// Updates a cipher in the user's vault. /// /// - Parameter cipher: The cipher that the user is updating. @@ -493,6 +505,15 @@ extension DefaultVaultRepository: VaultRepository { ) } + func archiveCipher(_ cipher: BitwardenSdk.CipherView) async throws { + guard let id = cipher.id else { + throw CipherAPIServiceError.updateMissingId + } + let archivedCipher = cipher.update(archivedDate: timeProvider.presentTime) + let encryptCipher = try await encryptAndUpdateCipher(archivedCipher) + try await cipherService.archiveCipherWithServer(id: id, encryptCipher) + } + func bulkShareCiphers( _ ciphers: [CipherView], newOrganizationId: String, @@ -850,6 +871,15 @@ extension DefaultVaultRepository: VaultRepository { try await cipherService.softDeleteCipherWithServer(id: id, encryptedCipher) } + func unarchiveCipher(_ cipher: BitwardenSdk.CipherView) async throws { + guard let id = cipher.id else { + throw CipherAPIServiceError.updateMissingId + } + let archivedCipher = cipher.update(archivedDate: nil) + let encryptCipher = try await encryptAndUpdateCipher(archivedCipher) + try await cipherService.unarchiveCipherWithServer(id: id, encryptCipher) + } + func updateCipher(_ cipherView: CipherView) async throws { let cipherEncryptionContext = try await clientService.vault().ciphers().encrypt(cipherView: cipherView) try await cipherService.updateCipherWithServer( diff --git a/BitwardenShared/Core/Vault/Repositories/VaultRepositoryTests.swift b/BitwardenShared/Core/Vault/Repositories/VaultRepositoryTests.swift index 67e5f0a05..54301168a 100644 --- a/BitwardenShared/Core/Vault/Repositories/VaultRepositoryTests.swift +++ b/BitwardenShared/Core/Vault/Repositories/VaultRepositoryTests.swift @@ -135,6 +135,42 @@ class VaultRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_b } } + /// `archiveCipher()` throws on id errors. + func test_archiveCipher_idError_nil() async throws { + stateService.accounts = [.fixtureAccountLogin()] + stateService.activeAccount = .fixtureAccountLogin() + await assertAsyncThrows(error: CipherAPIServiceError.updateMissingId) { + try await subject.archiveCipher(.fixture(id: nil)) + } + } + + /// `archiveCipher()` archives cipher for the back end and in local storage. + func test_archiveCipher() async throws { + client.result = .httpSuccess(testData: APITestData(data: Data())) + stateService.accounts = [.fixtureAccountLogin()] + stateService.activeAccount = .fixtureAccountLogin() + let cipherView: CipherView = .fixture(id: "123") + cipherService.archiveCipherResult = .success(()) + try await subject.archiveCipher(cipherView) + XCTAssertNil(cipherView.archivedDate) + XCTAssertNotNil(cipherService.archiveCipher?.archivedDate) + XCTAssertEqual(cipherService.archiveCipherId, "123") + } + + /// `archiveCipher(_:cipher:)` updates the cipher on the server if the SDK adds a cipher key. + func test_archiveCipher_updatesMigratedCipher() async throws { + stateService.activeAccount = .fixture() + let cipherView = CipherView.fixture() + let cipher = Cipher.fixture(key: "new key") + clientCiphers.encryptCipherResult = .success(EncryptionContext(encryptedFor: "1", cipher: cipher)) + + try await subject.archiveCipher(cipherView) + + XCTAssertEqual(cipherService.archiveCipher, cipher) + XCTAssertEqual(cipherService.updateCipherWithServerCiphers, [cipher]) + XCTAssertEqual(cipherService.updateCipherWithServerEncryptedFor, "1") + } + /// `bulkShareCiphers()` ensures cipher keys, prepares ciphers and calls the cipher service. func test_bulkShareCiphers() async throws { stateService.activeAccount = .fixtureAccountLogin() @@ -1609,7 +1645,7 @@ class VaultRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_b vaultListDirectorStrategy.buildReturnValue = AsyncThrowingPublisher(publisher) - let filter = VaultListFilter(options: [.addTOTPGroup, .addTrashGroup]) + let filter = VaultListFilter(options: [.addTOTPGroup, .addHiddenItemsGroup]) var iterator = try await subject.vaultListPublisher(filter: filter).makeAsyncIterator() let vaultListData = try await iterator.next() let sections = try XCTUnwrap(vaultListData?.sections) @@ -1638,7 +1674,7 @@ class VaultRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_b vaultListSearchDirectorStrategy.buildReturnValue = AsyncThrowingPublisher(publisher) - let filter = VaultListFilter(options: [.addTOTPGroup, .addTrashGroup]) + let filter = VaultListFilter(options: [.addTOTPGroup, .addHiddenItemsGroup]) var iterator = try await subject.vaultSearchListPublisher( mode: .all, filterPublisher: filter.asPublisher(), @@ -1655,6 +1691,42 @@ class VaultRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_b XCTAssertEqual(sections[safeIndex: 0]?.items.count, 1) } + /// `unarchiveCipher()` throws on id errors. + func test_unarchiveCipher_idError_nil() async throws { + stateService.accounts = [.fixtureAccountLogin()] + stateService.activeAccount = .fixtureAccountLogin() + await assertAsyncThrows(error: CipherAPIServiceError.updateMissingId) { + try await subject.unarchiveCipher(.fixture(id: nil)) + } + } + + /// `unarchiveCipher()` unarchives cipher for the back end and in local storage. + func test_unarchiveCipher() async throws { + client.result = .httpSuccess(testData: APITestData(data: Data())) + stateService.accounts = [.fixtureAccountLogin()] + stateService.activeAccount = .fixtureAccountLogin() + let cipherView: CipherView = .fixture(archivedDate: .now, id: "123") + cipherService.unarchiveCipherResult = .success(()) + try await subject.unarchiveCipher(cipherView) + XCTAssertNotNil(cipherView.archivedDate) + XCTAssertNil(cipherService.unarchiveCipher?.archivedDate) + XCTAssertEqual(cipherService.unarchiveCipherId, "123") + } + + /// `unarchiveCipher(_:cipher:)` updates the cipher on the server if the SDK adds a cipher key. + func test_unarchiveCipher_updatesMigratedCipher() async throws { + stateService.activeAccount = .fixture() + let cipherView = CipherView.fixture(archivedDate: .now) + let cipher = Cipher.fixture(key: "new key") + clientCiphers.encryptCipherResult = .success(EncryptionContext(encryptedFor: "1", cipher: cipher)) + + try await subject.unarchiveCipher(cipherView) + + XCTAssertEqual(cipherService.unarchiveCipher, cipher) + XCTAssertEqual(cipherService.updateCipherWithServerCiphers, [cipher]) + XCTAssertEqual(cipherService.updateCipherWithServerEncryptedFor, "1") + } + // MARK: Private /// Returns a string containing a description of the vault list items. diff --git a/BitwardenShared/Core/Vault/Services/API/Cipher/CipherAPIService.swift b/BitwardenShared/Core/Vault/Services/API/Cipher/CipherAPIService.swift index 3b53bca79..0f8658c89 100644 --- a/BitwardenShared/Core/Vault/Services/API/Cipher/CipherAPIService.swift +++ b/BitwardenShared/Core/Vault/Services/API/Cipher/CipherAPIService.swift @@ -25,6 +25,13 @@ protocol CipherAPIService { /// func addCipher(_ cipher: Cipher, encryptedFor: String?) async throws -> CipherDetailsResponseModel + /// Performs an API request to archive an existing cipher in the user's vault. + /// + /// - Parameter id: The cipher id that to be archived. + /// - Returns: The `EmptyResponse`. + /// + func archiveCipher(withID id: String) async throws -> EmptyResponse + /// Performs an API request to add a new cipher contained within one or more collections to the /// user's vault. /// @@ -131,6 +138,13 @@ protocol CipherAPIService { /// func softDeleteCipher(withID id: String) async throws -> EmptyResponse + /// Performs an API request to unarchive a cipher in the user's vault. + /// + /// - Parameter id: The id of the cipher to be unarchived. + /// - Returns: The `EmptyResponse`. + /// + func unarchiveCipher(withID id: String) async throws -> EmptyResponse + /// Performs an API request to update an existing cipher in the user's vault. /// /// - Parameters: @@ -159,6 +173,10 @@ extension APIService: CipherAPIService { try await apiService.send(AddCipherRequest(cipher: cipher, encryptedFor: encryptedFor)) } + func archiveCipher(withID id: String) async throws -> Networking.EmptyResponse { + try await apiService.send(ArchiveCipherRequest(id: id)) + } + func addCipherWithCollections(_ cipher: Cipher, encryptedFor: String?) async throws -> CipherDetailsResponseModel { try await apiService.send(AddCipherWithCollectionsRequest(cipher: cipher, encryptedFor: encryptedFor)) } @@ -221,6 +239,10 @@ extension APIService: CipherAPIService { try await apiService.send(SoftDeleteCipherRequest(id: id)) } + func unarchiveCipher(withID id: String) async throws -> Networking.EmptyResponse { + try await apiService.send(UnarchiveCipherRequest(id: id)) + } + func updateCipher(_ cipher: Cipher, encryptedFor: String?) async throws -> CipherDetailsResponseModel { let updateRequest = try UpdateCipherRequest(cipher: cipher, encryptedFor: encryptedFor) return try await apiService.send(updateRequest) diff --git a/BitwardenShared/Core/Vault/Services/API/Cipher/CipherAPIServiceTests.swift b/BitwardenShared/Core/Vault/Services/API/Cipher/CipherAPIServiceTests.swift index cf8be7e89..bc7fa0297 100644 --- a/BitwardenShared/Core/Vault/Services/API/Cipher/CipherAPIServiceTests.swift +++ b/BitwardenShared/Core/Vault/Services/API/Cipher/CipherAPIServiceTests.swift @@ -140,6 +140,18 @@ class CipherAPIServiceTests: XCTestCase { // swiftlint:disable:this type_body_le ) } + /// `archiveCipher()` performs the archive cipher request. + func test_archiveCipher() async throws { + client.result = .httpSuccess(testData: .emptyResponse) + + _ = try await subject.archiveCipher(withID: "123") + + XCTAssertEqual(client.requests.count, 1) + XCTAssertNil(client.requests[0].body) + XCTAssertEqual(client.requests[0].method, .put) + XCTAssertEqual(client.requests[0].url.absoluteString, "https://example.com/api/ciphers/123/archive/") + } + /// `bulkShareCiphers()` performs the bulk share ciphers request and decodes the response. func test_bulkShareCiphers() async throws { client.result = .httpSuccess(testData: .bulkShareCiphersResponse) @@ -375,6 +387,18 @@ class CipherAPIServiceTests: XCTestCase { // swiftlint:disable:this type_body_le XCTAssertEqual(client.requests[0].url.absoluteString, "https://example.com/api/ciphers/123/delete") } + /// `unarchiveCipher()` performs the unarchive cipher request. + func test_unarchiveCipher() async throws { + client.result = .httpSuccess(testData: .emptyResponse) + + _ = try await subject.unarchiveCipher(withID: "123") + + XCTAssertEqual(client.requests.count, 1) + XCTAssertNil(client.requests[0].body) + XCTAssertEqual(client.requests[0].method, .put) + XCTAssertEqual(client.requests[0].url.absoluteString, "https://example.com/api/ciphers/123/unarchive/") + } + /// `updateCipherCollections()` performs the update cipher collections request. func test_updateCipherCollections() async throws { client.result = .httpSuccess(testData: .emptyResponse) diff --git a/BitwardenShared/Core/Vault/Services/API/Cipher/Requests/ArchiveCipherRequest.swift b/BitwardenShared/Core/Vault/Services/API/Cipher/Requests/ArchiveCipherRequest.swift new file mode 100644 index 000000000..2bf65bd19 --- /dev/null +++ b/BitwardenShared/Core/Vault/Services/API/Cipher/Requests/ArchiveCipherRequest.swift @@ -0,0 +1,32 @@ +import BitwardenSdk +import Networking + +/// Data model for performing an archive cipher request. +/// +struct ArchiveCipherRequest: Request { + typealias Response = EmptyResponse + + // MARK: Properties + + /// The id of the Cipher + var id: String + + /// The HTTP method for this request. + let method = HTTPMethod.put + + /// The URL path for this request. + var path: String { + "/ciphers/\(id)/archive/" + } + + // MARK: Initialization + + /// Initialize an `ArchiveCipherRequest` for a `Cipher`. + /// + /// - Parameter id: The id of `Cipher` to be archived in the user's vault. + /// + init(id: String) throws { + guard !id.isEmpty else { throw CipherAPIServiceError.updateMissingId } + self.id = id + } +} diff --git a/BitwardenShared/Core/Vault/Services/API/Cipher/Requests/ArchiveCipherRequestTests.swift b/BitwardenShared/Core/Vault/Services/API/Cipher/Requests/ArchiveCipherRequestTests.swift new file mode 100644 index 000000000..d354a4bdd --- /dev/null +++ b/BitwardenShared/Core/Vault/Services/API/Cipher/Requests/ArchiveCipherRequestTests.swift @@ -0,0 +1,34 @@ +import XCTest + +@testable import BitwardenShared + +class ArchiveCipherRequestTests: BitwardenTestCase { + // MARK: Tests + + /// `init` fails if the cipher has an empty id. + func test_init_fail_empty() throws { + XCTAssertThrowsError( + try ArchiveCipherRequest(id: ""), + ) { error in + XCTAssertEqual(error as? CipherAPIServiceError, .updateMissingId) + } + } + + /// `body` returns nil. + func test_body() throws { + let subject = try ArchiveCipherRequest(id: "1") + XCTAssertNil(subject.body) + } + + /// `method` returns the method of the request. + func test_method() throws { + let subject = try ArchiveCipherRequest(id: "1") + XCTAssertEqual(subject.method, .put) + } + + /// `path` returns the path of the request. + func test_path() throws { + let subject = try ArchiveCipherRequest(id: "1") + XCTAssertEqual(subject.path, "/ciphers/1/archive/") + } +} diff --git a/BitwardenShared/Core/Vault/Services/API/Cipher/Requests/UnarchiveCipherRequest.swift b/BitwardenShared/Core/Vault/Services/API/Cipher/Requests/UnarchiveCipherRequest.swift new file mode 100644 index 000000000..40b486927 --- /dev/null +++ b/BitwardenShared/Core/Vault/Services/API/Cipher/Requests/UnarchiveCipherRequest.swift @@ -0,0 +1,32 @@ +import BitwardenSdk +import Networking + +/// Data model for performing an unarchive cipher request. +/// +struct UnarchiveCipherRequest: Request { + typealias Response = EmptyResponse + + // MARK: Properties + + /// The id of the Cipher + var id: String + + /// The HTTP method for this request. + let method = HTTPMethod.put + + /// The URL path for this request. + var path: String { + "/ciphers/\(id)/unarchive/" + } + + // MARK: Initialization + + /// Initialize a `UnarchiveCipherRequest` for a `Cipher`. + /// + /// - Parameter id: The id of the `Cipher` to be unarchived from the vault. + /// + init(id: String) throws { + guard !id.isEmpty else { throw CipherAPIServiceError.updateMissingId } + self.id = id + } +} diff --git a/BitwardenShared/Core/Vault/Services/API/Cipher/Requests/UnarchiveCipherRequestTests.swift b/BitwardenShared/Core/Vault/Services/API/Cipher/Requests/UnarchiveCipherRequestTests.swift new file mode 100644 index 000000000..0cd6e3595 --- /dev/null +++ b/BitwardenShared/Core/Vault/Services/API/Cipher/Requests/UnarchiveCipherRequestTests.swift @@ -0,0 +1,46 @@ +import InlineSnapshotTesting +import XCTest + +@testable import BitwardenShared + +class UnarchiveCipherRequestTests: BitwardenTestCase { + // MARK: Properties + + var subject: UnarchiveCipherRequest? + + override func tearDown() { + super.tearDown() + + subject = nil + } + + // MARK: Tests + + /// `init` fails if the cipher has an empty id. + func test_init_fail_empty() throws { + XCTAssertThrowsError( + try UnarchiveCipherRequest(id: ""), + ) { error in + XCTAssertEqual(error as? CipherAPIServiceError, .updateMissingId) + } + } + + /// `body` returns nil. + func test_body() throws { + subject = try UnarchiveCipherRequest(id: "123") + XCTAssertNotNil(subject) + XCTAssertNil(subject?.body) + } + + /// `method` returns the method of the request. + func test_method() throws { + subject = try UnarchiveCipherRequest(id: "123") + XCTAssertEqual(subject?.method, .put) + } + + /// `path` returns the path of the request. + func test_path() throws { + subject = try UnarchiveCipherRequest(id: "123") + XCTAssertEqual(subject?.path, "/ciphers/123/unarchive/") + } +} diff --git a/BitwardenShared/Core/Vault/Services/CipherService.swift b/BitwardenShared/Core/Vault/Services/CipherService.swift index 3e45ab020..483df5c10 100644 --- a/BitwardenShared/Core/Vault/Services/CipherService.swift +++ b/BitwardenShared/Core/Vault/Services/CipherService.swift @@ -14,6 +14,14 @@ protocol CipherService { /// - encryptedFor: The user ID who encrypted the `cipher`. func addCipherWithServer(_ cipher: Cipher, encryptedFor: String) async throws + /// Archives a cipher for the current user both in the backend and in local storage. + /// + /// - Parameters: + /// - id: The id of the cipher to be archived. + /// - cipher: The cipher that the user is archiving. + /// + func archiveCipherWithServer(id: String, _ cipher: Cipher) async throws + /// Shares multiple ciphers with an organization and updates the locally stored data. /// /// - Parameters: @@ -123,6 +131,14 @@ protocol CipherService { /// func softDeleteCipherWithServer(id: String, _ cipher: Cipher) async throws + /// Unarchive a cipher both in the backend and in local storage. + /// + /// - Parameters: + /// - id: The id of the cipher to be unarchived. + /// - cipher: The cipher that the user is unarchiving. + /// + func unarchiveCipherWithServer(id: String, _ cipher: Cipher) async throws + /// Updates the cipher's collections for the current user both in the backend and in local storage. /// /// - Parameter cipher: The cipher to update. @@ -215,6 +231,16 @@ extension DefaultCipherService { try await cipherDataStore.upsertCipher(Cipher(responseModel: response), userId: userId) } + func archiveCipherWithServer(id: String, _ cipher: Cipher) async throws { + let userID = try await stateService.getActiveAccountId() + + // Archive cipher on backend. + _ = try await cipherAPIService.archiveCipher(withID: id) + + // Archive cipher on local storage + try await cipherDataStore.upsertCipher(cipher, userId: userID) + } + func bulkShareCiphersWithServer( _ ciphers: [Cipher], collectionIds: [String], @@ -389,6 +415,16 @@ extension DefaultCipherService { try await cipherDataStore.upsertCipher(cipher, userId: userId) } + func unarchiveCipherWithServer(id: String, _ cipher: BitwardenSdk.Cipher) async throws { + let userID = try await stateService.getActiveAccountId() + + // Unarchive cipher from backend. + _ = try await cipherAPIService.unarchiveCipher(withID: id) + + // Unarchive cipher from local storage + try await cipherDataStore.upsertCipher(cipher, userId: userID) + } + func updateCipherCollectionsWithServer(_ cipher: Cipher) async throws { let userId = try await stateService.getActiveAccountId() @@ -433,4 +469,4 @@ extension DefaultCipherService { let userId = try await stateService.getActiveAccountId() return cipherDataStore.cipherPublisher(userId: userId) } -} +} // swiftlint:disable:this file_length diff --git a/BitwardenShared/Core/Vault/Services/CipherServiceTests.swift b/BitwardenShared/Core/Vault/Services/CipherServiceTests.swift index c384b3495..1a0cba7ba 100644 --- a/BitwardenShared/Core/Vault/Services/CipherServiceTests.swift +++ b/BitwardenShared/Core/Vault/Services/CipherServiceTests.swift @@ -72,6 +72,16 @@ class CipherServiceTests: BitwardenTestCase { // swiftlint:disable:this type_bod XCTAssertEqual(cipherDataStore.upsertCipherValue?.id, "3792af7a-4441-11ee-be56-0242ac120002") } + /// `archiveCipherWithServer(id:_:)` archives the cipher in the backend and local storage. + func test_archiveCipherWithServer() async throws { + client.result = .httpSuccess(testData: .emptyResponse) + stateService.activeAccount = .fixture() + + try await subject.archiveCipherWithServer(id: "1", .fixture()) + + XCTAssertEqual(cipherDataStore.upsertCipherValue, .fixture()) + } + /// `bulkShareCiphersWithServer(_:collectionIds:encryptedFor:)` shares multiple ciphers with the /// organization and updates the data store. func test_bulkShareCiphersWithServer() async throws { @@ -344,6 +354,17 @@ class CipherServiceTests: BitwardenTestCase { // swiftlint:disable:this type_bod XCTAssertEqual(cipherDataStore.upsertCipherUserId, "1") } + /// `unarchiveCipherWithServer(id:_:)` unarchives the cipher in the backend and local storage. + func test_unarchiveCipherWithServer() async throws { + client.result = .httpSuccess(testData: .emptyResponse) + stateService.activeAccount = .fixture() + + try await subject.unarchiveCipherWithServer(id: "1", .fixture()) + + XCTAssertEqual(cipherDataStore.upsertCipherValue, .fixture()) + XCTAssertEqual(cipherDataStore.upsertCipherUserId, "1") + } + /// `updateCipherCollectionsWithServer(_:)` updates the cipher's collections and updates the data store. func test_updateCipherCollections() async throws { client.result = .success(.success()) @@ -418,4 +439,4 @@ class CipherServiceTests: BitwardenTestCase { // swiftlint:disable:this type_bod XCTAssertEqual(cipherDataStore.upsertCipherValue?.id, "id") } -} +} // swiftlint:disable:this file_length diff --git a/BitwardenShared/Core/Vault/Services/ExportVaultService.swift b/BitwardenShared/Core/Vault/Services/ExportVaultService.swift index 0a0b17644..71afa7249 100644 --- a/BitwardenShared/Core/Vault/Services/ExportVaultService.swift +++ b/BitwardenShared/Core/Vault/Services/ExportVaultService.swift @@ -212,8 +212,10 @@ class DefultExportVaultService: ExportVaultService { func fetchAllCiphersToExport() async throws -> [Cipher] { let restrictedTypes = await policyService.getRestrictedItemCipherTypes() + let archiveItemsFeatureFlagEnabled: Bool = await configService.getFeatureFlag(.archiveVaultItems) + return try await cipherService.fetchAllCiphers().filter { cipher in - cipher.deletedDate == nil + !cipher.isHiddenWithArchiveFF(flag: archiveItemsFeatureFlagEnabled) && cipher.organizationId == nil && !restrictedTypes.contains(BitwardenShared.CipherType(type: cipher.type)) } diff --git a/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreService.swift b/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreService.swift index 90ff9f18c..1d3846060 100644 --- a/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreService.swift +++ b/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreService.swift @@ -13,6 +13,9 @@ final class Fido2CredentialStoreService: Fido2CredentialStore { /// The service that handles common client functionality such as encryption and decryption. private let clientService: ClientService + /// The service to get server-specified configuration. + private let configService: ConfigService + /// The service used by the application to report non-fatal errors. private let errorReporter: ErrorReporter @@ -28,18 +31,21 @@ final class Fido2CredentialStoreService: Fido2CredentialStore { /// - Parameters: /// - cipherService: The service used to manage syncing and updates to the user's ciphers. /// - clientService: The service that handles common client functionality such as encryption and decryption. + /// - configService: The service that handles common client functionality such as encryption and decryption. /// - errorReporter: The service used by the application to report non-fatal errors. /// - stateService: The service used by the application to manage account state. /// - syncService: The service used to handle syncing vault data with the API. init( cipherService: CipherService, clientService: ClientService, + configService: ConfigService, errorReporter: ErrorReporter, stateService: StateService, syncService: SyncService, ) { self.cipherService = cipherService self.clientService = clientService + self.configService = configService self.errorReporter = errorReporter self.stateService = stateService self.syncService = syncService @@ -48,8 +54,13 @@ final class Fido2CredentialStoreService: Fido2CredentialStore { /// Gets all the active login ciphers that have Fido2 credentials. /// - Returns: Array of active login ciphers that have Fido2 credentials. func allCredentials() async throws -> [BitwardenSdk.CipherListView] { - try await clientService.vault().ciphers().decryptList( - ciphers: cipherService.fetchAllCiphers().filter(\.isActiveWithFido2Credentials), + let archiveItemsFeatureFlagEnabled: Bool = await configService.getFeatureFlag(.archiveVaultItems) + + return try await clientService.vault().ciphers().decryptList( + ciphers: cipherService.fetchAllCiphers().filter { cipher in + cipher.isActiveWithFido2Credentials + && (cipher.archivedDate == nil || !archiveItemsFeatureFlagEnabled) + }, ) } @@ -166,6 +177,8 @@ final class Fido2CredentialStoreService: Fido2CredentialStore { private extension Cipher { /// Whether the cipher is active, is a login and has Fido2 credentials. var isActiveWithFido2Credentials: Bool { + // TODO: PM-30129 When FF gets removed, replace `deletedDate == nil` with + // `isHidden`. deletedDate == nil && type == .login && login?.fido2Credentials?.isEmpty == false diff --git a/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreServiceTests.swift b/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreServiceTests.swift index 3f1b00948..3bd35007f 100644 --- a/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreServiceTests.swift +++ b/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreServiceTests.swift @@ -13,6 +13,7 @@ class Fido2CredentialStoreServiceTests: BitwardenTestCase { // swiftlint:disable var cipherService: MockCipherService! var clientService: MockClientService! + var configService: MockConfigService! var errorReporter: MockErrorReporter! var stateService: MockStateService! var subject: Fido2CredentialStoreService! @@ -25,6 +26,7 @@ class Fido2CredentialStoreServiceTests: BitwardenTestCase { // swiftlint:disable cipherService = MockCipherService() clientService = MockClientService() + configService = MockConfigService() errorReporter = MockErrorReporter() stateService = MockStateService() syncService = MockSyncService() @@ -32,6 +34,7 @@ class Fido2CredentialStoreServiceTests: BitwardenTestCase { // swiftlint:disable subject = Fido2CredentialStoreService( cipherService: cipherService, clientService: clientService, + configService: configService, errorReporter: errorReporter, stateService: stateService, syncService: syncService, @@ -43,6 +46,7 @@ class Fido2CredentialStoreServiceTests: BitwardenTestCase { // swiftlint:disable cipherService = nil clientService = nil + configService = nil errorReporter = nil stateService = nil subject = nil diff --git a/BitwardenShared/Core/Vault/Services/TestHelpers/MockCipherService.swift b/BitwardenShared/Core/Vault/Services/TestHelpers/MockCipherService.swift index ce7741954..8df48019e 100644 --- a/BitwardenShared/Core/Vault/Services/TestHelpers/MockCipherService.swift +++ b/BitwardenShared/Core/Vault/Services/TestHelpers/MockCipherService.swift @@ -9,6 +9,10 @@ class MockCipherService: CipherService { var addCipherWithServerEncryptedFor: String? var addCipherWithServerResult: Result = .success(()) + var archiveCipherId: String? + var archiveCipher: Cipher? + var archiveCipherResult: Result = .success(()) + var bulkShareCiphersWithServerCiphers = [[Cipher]]() var bulkShareCiphersWithServerCollectionIds: [String]? var bulkShareCiphersWithServerEncryptedFor: String? @@ -63,6 +67,10 @@ class MockCipherService: CipherService { var syncCipherWithServerId: String? var syncCipherWithServerResult: Result = .success(()) + var unarchiveCipherId: String? + var unarchiveCipher: Cipher? + var unarchiveCipherResult: Result = .success(()) + var updateCipherWithLocalStorageCiphers = [BitwardenSdk.Cipher]() var updateCipherWithLocalStorageResult: Result = .success(()) @@ -73,12 +81,22 @@ class MockCipherService: CipherService { var updateCipherCollectionsWithServerCiphers = [Cipher]() var updateCipherCollectionsWithServerResult: Result = .success(()) + var unarchivedCipherId: String? + var unarchivedCipher: Cipher? + var unarchiveWithServerResult: Result = .success(()) + func addCipherWithServer(_ cipher: Cipher, encryptedFor: String) async throws { addCipherWithServerCiphers.append(cipher) addCipherWithServerEncryptedFor = encryptedFor try addCipherWithServerResult.get() } + func archiveCipherWithServer(id: String, _ cipher: Cipher) async throws { + archiveCipherId = id + archiveCipher = cipher + try archiveCipherResult.get() + } + func bulkShareCiphersWithServer( _ ciphers: [Cipher], collectionIds: [String], @@ -163,6 +181,12 @@ class MockCipherService: CipherService { return try syncCipherWithServerResult.get() } + func unarchiveCipherWithServer(id: String, _ cipher: Cipher) async throws { + unarchiveCipherId = id + unarchiveCipher = cipher + try unarchiveCipherResult.get() + } + func updateCipherWithLocalStorage(_ cipher: Cipher) async throws { updateCipherWithLocalStorageCiphers.append(cipher) return try updateCipherWithLocalStorageResult.get() diff --git a/BitwardenShared/Core/Vault/Utilities/VaultListFilter.swift b/BitwardenShared/Core/Vault/Utilities/VaultListFilter.swift index aec43ad80..14a7e33cf 100644 --- a/BitwardenShared/Core/Vault/Utilities/VaultListFilter.swift +++ b/BitwardenShared/Core/Vault/Utilities/VaultListFilter.swift @@ -62,8 +62,8 @@ public struct VaultListOptions: OptionSet, Sendable { /// Whether to add the TOTP group to the vault list. static let addTOTPGroup = VaultListOptions(rawValue: 1 << 0) - /// Whether to add the trash group to the vault list. - static let addTrashGroup = VaultListOptions(rawValue: 1 << 1) + /// Whether to add the hidden items group to the vault list. + static let addHiddenItemsGroup = VaultListOptions(rawValue: 1 << 1) /// Whether the vault list is being displayed in picker mode. static let isInPickerMode = VaultListOptions(rawValue: 1 << 2) diff --git a/BitwardenShared/Core/Vault/Utilities/VaultListFilterTests.swift b/BitwardenShared/Core/Vault/Utilities/VaultListFilterTests.swift index 1d2f381db..782625cae 100644 --- a/BitwardenShared/Core/Vault/Utilities/VaultListFilterTests.swift +++ b/BitwardenShared/Core/Vault/Utilities/VaultListFilterTests.swift @@ -26,7 +26,7 @@ class VaultListFilterTests: BitwardenTestCase { filterType: .myVault, group: .card, mode: .all, - options: [.addTOTPGroup, .addTrashGroup], + options: [.addTOTPGroup, .addHiddenItemsGroup], rpID: "example.com", searchText: "test", uri: "https://example.com", @@ -35,7 +35,7 @@ class VaultListFilterTests: BitwardenTestCase { XCTAssertEqual(subject.filterType, .myVault) XCTAssertEqual(subject.group, .card) XCTAssertEqual(subject.mode, .all) - XCTAssertEqual(subject.options, [.addTOTPGroup, .addTrashGroup]) + XCTAssertEqual(subject.options, [.addTOTPGroup, .addHiddenItemsGroup]) XCTAssertEqual(subject.rpID, "example.com") XCTAssertEqual(subject.searchText, "test") XCTAssertEqual(subject.uri, "https://example.com") diff --git a/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupProcessor.swift b/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupProcessor.swift index 2fe9ee760..8e356a00e 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupProcessor.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupProcessor.swift @@ -318,22 +318,35 @@ final class VaultGroupProcessor: StateProcessor< // MARK: - CipherItemOperationDelegate extension VaultGroupProcessor: CipherItemOperationDelegate { + // MARK: Methods + + func itemArchived() { + displayToastAndRefresh(toastTitle: Localizations.itemArchived) + } + func itemDeleted() { - state.toast = Toast(title: Localizations.itemDeleted) - Task { - await perform(.refresh) - } + displayToastAndRefresh(toastTitle: Localizations.itemDeleted) } func itemSoftDeleted() { - state.toast = Toast(title: Localizations.itemSoftDeleted) - Task { - await perform(.refresh) - } + displayToastAndRefresh(toastTitle: Localizations.itemSoftDeleted) } func itemRestored() { - state.toast = Toast(title: Localizations.itemRestored) + displayToastAndRefresh(toastTitle: Localizations.itemRestored) + } + + func itemUnarchived() { + displayToastAndRefresh(toastTitle: Localizations.itemUnarchived) + } + + // MARK: Private methods + + /// Displays a toast and performs a refresh. + /// + /// - Parameter toastTitle: The title of the toast. + func displayToastAndRefresh(toastTitle: String) { + state.toast = Toast(title: toastTitle) Task { await perform(.refresh) } diff --git a/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupProcessorTests.swift b/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupProcessorTests.swift index f637d3b64..5f706e9b0 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupProcessorTests.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupProcessorTests.swift @@ -98,6 +98,16 @@ class VaultGroupProcessorTests: BitwardenTestCase { // swiftlint:disable:this ty // MARK: Tests + /// `itemArchived()` delegate method shows the expected toast. + @MainActor + func test_delegate_itemArchived() { + XCTAssertNil(subject.state.toast) + + subject.itemArchived() + XCTAssertEqual(subject.state.toast, Toast(title: Localizations.itemArchived)) + waitFor(vaultRepository.fetchSyncCalled) + } + /// `itemDeleted()` delegate method shows the expected toast. @MainActor func test_delegate_itemDeleted() { @@ -105,6 +115,7 @@ class VaultGroupProcessorTests: BitwardenTestCase { // swiftlint:disable:this ty subject.itemDeleted() XCTAssertEqual(subject.state.toast, Toast(title: Localizations.itemDeleted)) + waitFor(vaultRepository.fetchSyncCalled) } /// `itemSoftDeleted()` delegate method shows the expected toast. @@ -114,6 +125,7 @@ class VaultGroupProcessorTests: BitwardenTestCase { // swiftlint:disable:this ty subject.itemSoftDeleted() XCTAssertEqual(subject.state.toast, Toast(title: Localizations.itemSoftDeleted)) + waitFor(vaultRepository.fetchSyncCalled) } /// `itemRestored()` delegate method shows the expected toast. @@ -123,6 +135,17 @@ class VaultGroupProcessorTests: BitwardenTestCase { // swiftlint:disable:this ty subject.itemRestored() XCTAssertEqual(subject.state.toast, Toast(title: Localizations.itemRestored)) + waitFor(vaultRepository.fetchSyncCalled) + } + + /// `itemUnarchived()` delegate method shows the expected toast. + @MainActor + func test_delegate_itemUnarchived() { + XCTAssertNil(subject.state.toast) + + subject.itemUnarchived() + XCTAssertEqual(subject.state.toast, Toast(title: Localizations.itemUnarchived)) + waitFor(vaultRepository.fetchSyncCalled) } /// `init(coordinator:masterPasswordRepromptHelper:services:state:vaultItemMoreOptionsHelper:)` initializes diff --git a/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupState.swift b/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupState.swift index 5b272152e..402664693 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupState.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupState.swift @@ -59,7 +59,7 @@ struct VaultGroupState: Equatable, Sendable { return .button case .collection, .folder, .noFolder: return .menu - case .sshKey, .totp, .trash: + case .archive, .sshKey, .totp, .trash: return nil } } @@ -101,6 +101,8 @@ struct VaultGroupState: Equatable, Sendable { Localizations.thereAreNoSSHKeysInYourVault case .trash: Localizations.noItemsTrash + case .archive: + Localizations.thereAreNoItemsInTheArchive default: Localizations.noItems } diff --git a/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupStateTests.swift b/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupStateTests.swift index 21fb84251..4b2be8a25 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupStateTests.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupStateTests.swift @@ -39,6 +39,9 @@ class VaultGroupStateTests: BitwardenTestCase { let subjectTotp = VaultGroupState(group: .totp, vaultFilterType: .myVault) XCTAssertNil(subjectTotp.newItemButtonType) + let subjectArchive = VaultGroupState(group: .archive, vaultFilterType: .myVault) + XCTAssertNil(subjectArchive.newItemButtonType) + let subjectTrash = VaultGroupState(group: .trash, vaultFilterType: .myVault) XCTAssertNil(subjectTrash.newItemButtonType) } diff --git a/BitwardenShared/UI/Vault/Vault/VaultItemSelection/VaultItemSelectionProcessor.swift b/BitwardenShared/UI/Vault/Vault/VaultItemSelection/VaultItemSelectionProcessor.swift index fc9600b2a..1990151e2 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultItemSelection/VaultItemSelectionProcessor.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultItemSelection/VaultItemSelectionProcessor.swift @@ -270,6 +270,14 @@ extension VaultItemSelectionProcessor: CipherItemOperationDelegate { return false } + func itemArchived() { + coordinator.navigate(to: .dismiss) + } + + func itemUnarchived() { + coordinator.navigate(to: .dismiss) + } + func itemUpdated() -> Bool { coordinator.navigate(to: .dismiss) // Return false to notify the calling processor that the dismissal occurs here. diff --git a/BitwardenShared/UI/Vault/Vault/VaultItemSelection/VaultItemSelectionProcessorTests.swift b/BitwardenShared/UI/Vault/Vault/VaultItemSelection/VaultItemSelectionProcessorTests.swift index 5fd98a5fc..9cfed7faf 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultItemSelection/VaultItemSelectionProcessorTests.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultItemSelection/VaultItemSelectionProcessorTests.swift @@ -97,6 +97,22 @@ class VaultItemSelectionProcessorTests: BitwardenTestCase { // swiftlint:disable XCTAssertFalse(shouldDismiss) } + /// `itemArchived()` requests the coordinator dismiss the view. + @MainActor + func test_itemArchived() { + subject.itemArchived() + + XCTAssertEqual(coordinator.routes, [.dismiss]) + } + + /// `itemUnarchived()` requests the coordinator dismiss the view. + @MainActor + func test_itemUnarchived() { + subject.itemUnarchived() + + XCTAssertEqual(coordinator.routes, [.dismiss]) + } + /// `itemUpdated()` requests the coordinator dismiss the view. @MainActor func test_itemUpdated() { diff --git a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListGroup.swift b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListGroup.swift index 39a8de1a3..4a6835832 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListGroup.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListGroup.swift @@ -37,6 +37,11 @@ public enum VaultListGroup: Equatable, Hashable, Sendable { /// A group of ciphers without a folder case noFolder + // MARK: Archive + + /// A group of archived ciphers. + case archive + // MARK: Trash /// A group of ciphers in the trash. @@ -65,6 +70,8 @@ extension VaultListGroup { /// The display name for the group. var name: String { switch self { + case .archive: + Localizations.archive case .card: Localizations.typeCard case let .collection(_, name, _): @@ -91,6 +98,8 @@ extension VaultListGroup { /// The navigation title for the group. var navigationTitle: String { switch self { + case .archive: + Localizations.archive case .card: Localizations.cards case let .collection(_, name, _): diff --git a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListGroupTests.swift b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListGroupTests.swift index 4a66dbef3..c6097160e 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListGroupTests.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListGroupTests.swift @@ -21,6 +21,7 @@ class VaultListGroupTests: BitwardenTestCase { XCTAssertNil(VaultListGroup.secureNote.collectionId) XCTAssertNil(VaultListGroup.sshKey.collectionId) XCTAssertNil(VaultListGroup.totp.collectionId) + XCTAssertNil(VaultListGroup.archive.collectionId) XCTAssertNil(VaultListGroup.trash.collectionId) } @@ -35,6 +36,7 @@ class VaultListGroupTests: BitwardenTestCase { XCTAssertFalse(VaultListGroup.secureNote.isFolder) XCTAssertFalse(VaultListGroup.sshKey.isFolder) XCTAssertFalse(VaultListGroup.totp.isFolder) + XCTAssertFalse(VaultListGroup.archive.isFolder) XCTAssertFalse(VaultListGroup.trash.isFolder) } @@ -50,6 +52,7 @@ class VaultListGroupTests: BitwardenTestCase { XCTAssertNil(VaultListGroup.secureNote.folderId) XCTAssertNil(VaultListGroup.sshKey.folderId) XCTAssertNil(VaultListGroup.totp.folderId) + XCTAssertNil(VaultListGroup.archive.folderId) XCTAssertNil(VaultListGroup.trash.folderId) } @@ -66,6 +69,7 @@ class VaultListGroupTests: BitwardenTestCase { XCTAssertEqual(VaultListGroup.secureNote.name, "Secure note") XCTAssertEqual(VaultListGroup.sshKey.name, "SSH key") XCTAssertEqual(VaultListGroup.totp.name, Localizations.verificationCodes) + XCTAssertEqual(VaultListGroup.archive.name, Localizations.archive) XCTAssertEqual(VaultListGroup.trash.name, "Trash") } @@ -82,6 +86,7 @@ class VaultListGroupTests: BitwardenTestCase { XCTAssertEqual(VaultListGroup.secureNote.navigationTitle, Localizations.secureNotes) XCTAssertEqual(VaultListGroup.sshKey.navigationTitle, Localizations.sshKeys) XCTAssertEqual(VaultListGroup.totp.navigationTitle, Localizations.verificationCodes) + XCTAssertEqual(VaultListGroup.archive.navigationTitle, Localizations.archive) XCTAssertEqual(VaultListGroup.trash.navigationTitle, Localizations.trash) } @@ -98,6 +103,7 @@ class VaultListGroupTests: BitwardenTestCase { XCTAssertNil(VaultListGroup.secureNote.organizationId) XCTAssertNil(VaultListGroup.sshKey.organizationId) XCTAssertNil(VaultListGroup.totp.organizationId) + XCTAssertNil(VaultListGroup.archive.organizationId) XCTAssertNil(VaultListGroup.trash.organizationId) } } diff --git a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListItem.swift b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListItem.swift index c8b7c59d0..c0123e4a0 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListItem.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListItem.swift @@ -132,6 +132,8 @@ extension VaultListItem { SharedAsset.Icons.clock24 case .trash: SharedAsset.Icons.trash24 + case .archive: + SharedAsset.Icons.archive24 } case .totp: SharedAsset.Icons.clock24 diff --git a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListItemTests.swift b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListItemTests.swift index 8d482051e..3525fbe98 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListItemTests.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListItemTests.swift @@ -180,8 +180,8 @@ class VaultListItemTests: BitwardenTestCase { // swiftlint:disable:this type_bod SharedAsset.Icons.clock24.name, ) XCTAssertEqual( - VaultListItem(id: "", itemType: .group(.trash, 1)).icon.name, - SharedAsset.Icons.trash24.name, + VaultListItem(id: "", itemType: .group(.archive, 1)).icon.name, + SharedAsset.Icons.archive24.name, ) XCTAssertEqual( VaultListItem(id: "", itemType: .group(.trash, 1)).icon.name, diff --git a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift index 9098af69d..745e41773 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift @@ -532,7 +532,7 @@ extension VaultListProcessor { .vaultListPublisher( filter: VaultListFilter( filterType: state.vaultFilterType, - options: [.addTOTPGroup, .addTrashGroup], + options: [.addTOTPGroup, .addHiddenItemsGroup], ), ) { // Check if the vault needs a sync. @@ -573,6 +573,10 @@ extension VaultListProcessor { // MARK: - CipherItemOperationDelegate extension VaultListProcessor: CipherItemOperationDelegate { + func itemArchived() { + state.toast = Toast(title: Localizations.itemArchived) + } + func itemDeleted() { state.toast = Toast(title: Localizations.itemDeleted) } @@ -584,6 +588,10 @@ extension VaultListProcessor: CipherItemOperationDelegate { func itemRestored() { state.toast = Toast(title: Localizations.itemRestored) } + + func itemUnarchived() { + state.toast = Toast(title: Localizations.itemUnarchived) + } } // MARK: - MoreOptionsAction diff --git a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift index 79acca70c..143159c3c 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift @@ -147,6 +147,15 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ XCTAssertNil(subject.state.toast?.title) } + /// `itemArchived()` delegate method shows the expected toast. + @MainActor + func test_delegate_itemArchived() { + XCTAssertNil(subject.state.toast) + + subject.itemArchived() + XCTAssertEqual(subject.state.toast, Toast(title: Localizations.itemArchived)) + } + /// `itemDeleted()` delegate method shows the expected toast. @MainActor func test_delegate_itemDeleted() { @@ -174,6 +183,15 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ XCTAssertEqual(subject.state.toast, Toast(title: Localizations.itemRestored)) } + /// `itemUnarchived()` delegate method shows the expected toast. + @MainActor + func test_delegate_itemUnarchived() { + XCTAssertNil(subject.state.toast) + + subject.itemUnarchived() + XCTAssertEqual(subject.state.toast, Toast(title: Localizations.itemUnarchived)) + } + /// `init()` has default values set in the state. @MainActor func test_init_defaultValues() { diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemProcessor.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemProcessor.swift index 002c2214a..b78ad9497 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemProcessor.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemProcessor.swift @@ -17,6 +17,9 @@ protocol CipherItemOperationDelegate: AnyObject { /// func itemAdded() -> Bool + /// Called when the cipher item has been successfully archived. + func itemArchived() + /// Called when the cipher item has been successfully permanently deleted. func itemDeleted() @@ -26,6 +29,9 @@ protocol CipherItemOperationDelegate: AnyObject { /// Called when the cipher item has been successfully soft deleted. func itemSoftDeleted() + /// Called when the cipher item has been successfully unarchived. + func itemUnarchived() + /// Called when a cipher item has been successfully updated. /// /// - Returns: A boolean indicating whether the view should be dismissed. Defaults to `true`. @@ -37,12 +43,16 @@ protocol CipherItemOperationDelegate: AnyObject { extension CipherItemOperationDelegate { func itemAdded() -> Bool { true } + func itemArchived() {} + func itemDeleted() {} func itemRestored() {} func itemSoftDeleted() {} + func itemUnarchived() {} + func itemUpdated() -> Bool { true } } @@ -129,6 +139,7 @@ final class AddEditItemProcessor: StateProcessor Bool { itemAddedCalled = true return itemAddedShouldDismiss } + func itemArchived() { + itemArchivedCalled = true + } + func itemDeleted() { itemDeletedCalled = true } @@ -2876,4 +2890,8 @@ class MockCipherItemOperationDelegate: CipherItemOperationDelegate { itemUpdatedCalled = true return itemUpdatedShouldDismiss } + + func itemUnarchived() { + itemUnarchivedCalled = true + } } diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemState.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemState.swift index 648be1b4f..19542c9a3 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemState.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemState.swift @@ -14,9 +14,15 @@ protocol AddEditItemState: Sendable { /// Whether or not this item can be assigned to collections. var canAssignToCollection: Bool { get } + /// Whether the user is able to archive the item. + var canBeArchived: Bool { get } + /// Whether the user is able to delete the item. var canBeDeleted: Bool { get } + /// Whether the user is able to unarchive the item. + var canBeUnarchived: Bool { get } + /// Whether or not this item can be moved to an organization. var canMoveToOrganization: Bool { get } @@ -59,6 +65,9 @@ protocol AddEditItemState: Sendable { /// Whether the additional options section is expanded. var isAdditionalOptionsExpanded: Bool { get set } + /// Whether archive vault items feature flag is enabled. + var isArchiveVaultItemsFFEnabled: Bool { get set } + /// A flag indicating if this item is favorited. var isFavoriteOn: Bool { get set } diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemView.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemView.swift index d874c2196..3ffaf9576 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemView.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemView.swift @@ -119,11 +119,13 @@ struct AddEditItemView: View { versionDependentOrderingToolbarItemGroup( alfa: { VaultItemManagementMenuView( + isArchiveEnabled: store.state.canBeArchived, isCloneEnabled: false, isCollectionsEnabled: store.state.canAssignToCollection, isDeleteEnabled: store.state.canBeDeleted, isMoveToOrganizationEnabled: store.state.canMoveToOrganization, isRestoreEnabled: false, + isUnarchiveEnabled: store.state.canBeUnarchived, store: store.child( state: { _ in }, mapAction: { .morePressed($0) }, diff --git a/BitwardenShared/UI/Vault/VaultItem/CipherItemState.swift b/BitwardenShared/UI/Vault/VaultItem/CipherItemState.swift index 31431112a..80dc072af 100644 --- a/BitwardenShared/UI/Vault/VaultItem/CipherItemState.swift +++ b/BitwardenShared/UI/Vault/VaultItem/CipherItemState.swift @@ -78,6 +78,9 @@ struct CipherItemState: Equatable { // swiftlint:disable:this type_body_length /// Whether the additional options section is expanded. var isAdditionalOptionsExpanded = false + /// Whether archive vault items feature flag is enabled. + var isArchiveVaultItemsFFEnabled = false + /// A flag indicating if this item is favorited. var isFavoriteOn = false @@ -142,6 +145,12 @@ struct CipherItemState: Equatable { // swiftlint:disable:this type_body_length self } + /// Whether or not this item can be archived by the user. + var canBeArchived: Bool { + isArchiveVaultItemsFFEnabled && accountHasPremium && cipher.archivedDate == nil && cipher.deletedDate == nil + } + + /// Whether the cipher belongs to any organization. var hasOrganizations: Bool { cipher.organizationId != nil || ownershipOptions.contains { !$0.isPersonal } } @@ -185,6 +194,11 @@ struct CipherItemState: Equatable { // swiftlint:disable:this type_body_length return cipherPermissions.restore && isSoftDeleted } + /// Whether or not this item can be unarchived by the user. + var canBeUnarchived: Bool { + cipher.archivedDate != nil && cipher.deletedDate == nil + } + /// Whether or not this item can be moved to an organization. var canMoveToOrganization: Bool { hasOrganizations && cipher.organizationId == nil @@ -486,6 +500,10 @@ extension CipherItemState: ViewVaultItemState { cipher.collectionIds.count > 1 } + var isArchived: Bool { + cipher.archivedDate != nil && cipher.deletedDate == nil + } + var cardItemViewState: any ViewCardItemState { cardItemState } diff --git a/BitwardenShared/UI/Vault/VaultItem/CipherItemStateTests.swift b/BitwardenShared/UI/Vault/VaultItem/CipherItemStateTests.swift index 0bc35fdc8..f00bcbf4f 100644 --- a/BitwardenShared/UI/Vault/VaultItem/CipherItemStateTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/CipherItemStateTests.swift @@ -100,6 +100,28 @@ class CipherItemStateTests: BitwardenTestCase { // swiftlint:disable:this type_b XCTAssertTrue(subject.canAssignToCollection) } + /// `canBeArchived` is `true` if the cipher is not already archived or deleted. + func test_canBeArchived() throws { + XCTAssertTrue( + try CipherItemState.initForArchive(archivedDate: nil).canBeArchived, + ) + XCTAssertFalse( + try CipherItemState.initForArchive(archivedDate: nil, isArchiveVaultItemsFFEnabled: false).canBeArchived, + ) + XCTAssertFalse( + try CipherItemState.initForArchive(archivedDate: nil, hasPremium: false).canBeArchived, + ) + XCTAssertFalse( + try CipherItemState.initForArchive(archivedDate: .now).canBeArchived, + ) + XCTAssertFalse( + try CipherItemState.initForArchive(archivedDate: nil, deletedDate: .now).canBeArchived, + ) + XCTAssertFalse( + try CipherItemState.initForArchive(archivedDate: .now, deletedDate: .now).canBeArchived, + ) + } + /// `canBeDeleted` is true /// if the cipher does not belong to a collection func test_canBeDeleted_notCollection() throws { @@ -242,6 +264,19 @@ class CipherItemStateTests: BitwardenTestCase { // swiftlint:disable:this type_b XCTAssertFalse(state.canBeRestored) } + /// `canBeUnarchived` returns `true` when the cipher has an archived date. + func test_canBeUnarchived() throws { + XCTAssertTrue( + try CipherItemState.initForArchive(archivedDate: .now).canBeUnarchived, + ) + XCTAssertFalse( + try CipherItemState.initForArchive(archivedDate: nil).canBeUnarchived, + ) + XCTAssertFalse( + try CipherItemState.initForArchive(archivedDate: .now, deletedDate: .now).canBeUnarchived, + ) + } + /// `canMoveToOrganization` returns false if the cipher is in an existing organization. func test_canMoveToOrganization_cipherInExistingOrganization() throws { let cipher = CipherView.fixture(organizationId: "1") @@ -364,6 +399,25 @@ class CipherItemStateTests: BitwardenTestCase { // swiftlint:disable:this type_b XCTAssertEqual(state.iconAccessibilityId, "CipherIcon") } + /// `isArchived` is `true` if the cipher is not already archived or deleted. + func test_isArchived() throws { + XCTAssertFalse( + try XCTUnwrap(CipherItemState( + existing: CipherView.loginFixture(login: .fixture()), + hasPremium: true, + )).isArchived, + ) + XCTAssertTrue( + try CipherItemState.initForArchive(archivedDate: .now).isArchived, + ) + XCTAssertFalse( + try CipherItemState.initForArchive(archivedDate: nil, deletedDate: .now).isArchived, + ) + XCTAssertFalse( + try CipherItemState.initForArchive(archivedDate: .now, deletedDate: .now).isArchived, + ) + } + /// `getter:loginView` returns login of the cipher. func test_loginView() throws { let login = BitwardenSdk.LoginView.fixture(username: "1") @@ -658,3 +712,31 @@ class CipherItemStateTests: BitwardenTestCase { // swiftlint:disable:this type_b XCTAssertEqual(subject, expected) } } + +// MARK: - CipherItemState + +private extension CipherItemState { + /// Initializes a `CipherItemState` for archive related tests. + /// - Parameters: + /// - archivedDate: The archived date. + /// - deletedDate: The deleted date. + /// - hasPremium: Whether the user has premium account. + /// - isArchiveVaultItemsFFEnabled: Whether the archive vualt items feature flag is enabled. + static func initForArchive( + archivedDate: Date?, + deletedDate: Date? = nil, + hasPremium: Bool = true, + isArchiveVaultItemsFFEnabled: Bool = true, + ) throws -> CipherItemState { + var state = try XCTUnwrap(CipherItemState( + existing: CipherView.loginFixture( + archivedDate: archivedDate, + deletedDate: deletedDate, + login: .fixture(), + ), + hasPremium: hasPremium, + )) + state.isArchiveVaultItemsFFEnabled = isArchiveVaultItemsFFEnabled + return state + } +} diff --git a/BitwardenShared/UI/Vault/VaultItem/VaultItemManagementMenu/VaultItemManagementMenuEffect.swift b/BitwardenShared/UI/Vault/VaultItem/VaultItemManagementMenu/VaultItemManagementMenuEffect.swift index 9a9b709b5..fb78ed72f 100644 --- a/BitwardenShared/UI/Vault/VaultItem/VaultItemManagementMenu/VaultItemManagementMenuEffect.swift +++ b/BitwardenShared/UI/Vault/VaultItem/VaultItemManagementMenu/VaultItemManagementMenuEffect.swift @@ -2,6 +2,12 @@ /// Effects that can be processed by a `AddEditItemProcessor` and `ViewItemProcessor`. enum VaultItemManagementMenuEffect: Equatable { - /// The delete option pressed. + /// The archive option was pressed. + case archiveItem + + /// The delete option was pressed. case deleteItem + + /// The unarchive option was pressed. + case unarchiveItem } diff --git a/BitwardenShared/UI/Vault/VaultItem/VaultItemManagementMenu/VaultItemManagementMenuView+ViewInspectorTests.swift b/BitwardenShared/UI/Vault/VaultItem/VaultItemManagementMenu/VaultItemManagementMenuView+ViewInspectorTests.swift index d323c6f8a..553a8fe29 100644 --- a/BitwardenShared/UI/Vault/VaultItem/VaultItemManagementMenu/VaultItemManagementMenuView+ViewInspectorTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/VaultItemManagementMenu/VaultItemManagementMenuView+ViewInspectorTests.swift @@ -24,11 +24,13 @@ class VaultItemManagementMenuViewTests: BitwardenTestCase { processor = MockProcessor(state: ()) let store = Store(processor: processor) subject = VaultItemManagementMenuView( + isArchiveEnabled: true, isCloneEnabled: true, isCollectionsEnabled: true, isDeleteEnabled: true, isMoveToOrganizationEnabled: true, isRestoreEnabled: true, + isUnarchiveEnabled: false, store: store, ) } @@ -41,6 +43,14 @@ class VaultItemManagementMenuViewTests: BitwardenTestCase { // MARK: Tests + /// Tapping the archive option dispatches the `.archive` action. + @MainActor + func test_archiveOption_tap() async throws { + let button = try subject.inspect().find(asyncButton: Localizations.archive) + try await button.tap() + XCTAssertEqual(processor.effects.last, .archiveItem) + } + /// Tapping the attachments option dispatches the `.attachments` action. @MainActor func test_attachmentsOption_tap() throws { diff --git a/BitwardenShared/UI/Vault/VaultItem/VaultItemManagementMenu/VaultItemManagementMenuView.swift b/BitwardenShared/UI/Vault/VaultItem/VaultItemManagementMenu/VaultItemManagementMenuView.swift index a8dafc5c9..020ea9845 100644 --- a/BitwardenShared/UI/Vault/VaultItem/VaultItemManagementMenu/VaultItemManagementMenuView.swift +++ b/BitwardenShared/UI/Vault/VaultItem/VaultItemManagementMenu/VaultItemManagementMenuView.swift @@ -9,6 +9,9 @@ import SwiftUI struct VaultItemManagementMenuView: View { // MARK: Properties + /// The flag for whether to show the archive option. + let isArchiveEnabled: Bool + /// The flag for showing/hiding clone option. let isCloneEnabled: Bool @@ -24,6 +27,9 @@ struct VaultItemManagementMenuView: View { /// The flag for whether to show the restore option. let isRestoreEnabled: Bool + /// The flag for whether to show the unarchive option. + let isUnarchiveEnabled: Bool + /// The `Store` for this view. @ObservedObject var store: Store @@ -58,6 +64,18 @@ struct VaultItemManagementMenuView: View { } } + if isArchiveEnabled { + AsyncButton(Localizations.archive) { + await store.perform(.archiveItem) + } + .accessibilityIdentifier("ArchiveButton") + } else if isUnarchiveEnabled { + AsyncButton(Localizations.unarchive) { + await store.perform(.unarchiveItem) + } + .accessibilityIdentifier("UnarchiveButton") + } + if isDeleteEnabled { AsyncButton(Localizations.delete, role: .destructive) { await store.perform(.deleteItem) @@ -74,11 +92,13 @@ struct VaultItemManagementMenuView: View { #Preview { VaultItemManagementMenuView( + isArchiveEnabled: true, isCloneEnabled: true, isCollectionsEnabled: true, isDeleteEnabled: true, isMoveToOrganizationEnabled: true, isRestoreEnabled: true, + isUnarchiveEnabled: false, store: Store( processor: StateProcessor( state: (), diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemEffect.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemEffect.swift index b9ecb6845..53977dcc1 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemEffect.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemEffect.swift @@ -2,6 +2,9 @@ /// Effects that can be processed by a `ViewItemProcessor`. enum ViewItemEffect: Equatable { + /// The archived button was pressed. + case archivedPressed + /// The view item screen appeared. case appeared @@ -16,4 +19,7 @@ enum ViewItemEffect: Equatable { /// The TOTP code for the view expired. case totpCodeExpired + + /// The unarchive button was pressed. + case unarchivePressed } diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessor.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessor.swift index 6e4dfec27..811bc6a1b 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessor.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessor.swift @@ -111,6 +111,8 @@ final class ViewItemProcessor: StateProcessor Void, + onDismiss: @escaping (ViewItemProcessor) -> Void, + ) async { + defer { coordinator.hideLoadingOverlay() } + do { + coordinator.showLoadingOverlay(.init(title: loadingTitle)) + + try await operation() + + coordinator.navigate(to: .dismiss(DismissAction(action: { [weak self] in + guard let self else { return } + onDismiss(self) + }))) + } catch { + await coordinator.showErrorAlert(error: error) + services.errorReporter.log(error: error) + } + } + /// Restores the item currently stored in `state`. /// private func restoreItem(_ cipher: CipherView) async { @@ -513,6 +561,8 @@ private extension ViewItemProcessor { totpState = updatedState } + let isArchiveVaultItemsFFEnabled: Bool = await services.configService.getFeatureFlag(.archiveVaultItems) + guard var newState = ViewItemState( cipherView: cipher, hasPremium: hasPremium, @@ -526,6 +576,8 @@ private extension ViewItemProcessor { itemState.organizationName = organization?.name itemState.ownershipOptions = ownershipOptions itemState.showWebIcons = showWebIcons + itemState.isArchiveVaultItemsFFEnabled = isArchiveVaultItemsFFEnabled + newState.loadingState = .data(itemState) } state = newState @@ -546,6 +598,25 @@ private extension ViewItemProcessor { state.loadingState = .data(cipherState) } + + /// Unarchives cipher with pre confirmation alert. + /// + private func unarchiveItemWithConfirmation() async { + guard case let .data(cipherState) = state.loadingState else { return } + + let alert = Alert.confirmation(title: Localizations.doYouReallyWantToUnarchiveThisItem) { [weak self] in + guard let self else { return } + + await performOperationAndDismiss( + loadingTitle: Localizations.unarchiving, + operation: { + try await self.services.vaultRepository.unarchiveCipher(cipherState.cipher) + }, + onDismiss: { $0.delegate?.itemUnarchived() }, + ) + } + coordinator.showAlert(alert) + } } // MARK: TOTP @@ -582,6 +653,12 @@ private extension ViewItemProcessor { // MARK: - CipherItemOperationDelegate extension ViewItemProcessor: CipherItemOperationDelegate { + func itemArchived() { + coordinator.navigate(to: .dismiss(DismissAction(action: { [weak self] in + self?.delegate?.itemArchived() + }))) + } + func itemDeleted() { coordinator.navigate(to: .dismiss(DismissAction(action: { [weak self] in self?.delegate?.itemDeleted() @@ -597,6 +674,12 @@ extension ViewItemProcessor: CipherItemOperationDelegate { self?.delegate?.itemSoftDeleted() }))) } + + func itemUnarchived() { + coordinator.navigate(to: .dismiss(DismissAction(action: { [weak self] in + self?.delegate?.itemUnarchived() + }))) + } } // MARK: - EditCollectionsProcessorDelegate diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessorTests.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessorTests.swift index 464d4faeb..2d9bceb19 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessorTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessorTests.swift @@ -129,9 +129,38 @@ class ViewItemProcessorTests: BitwardenTestCase { // swiftlint:disable:this type ) } + /// `itemArchived()` presents the dismiss action alert and calls the delegate. + @MainActor + func test_itemArchived() async throws { + subject.itemArchived() + + var dismissAction: DismissAction? + if case let .dismiss(onDismiss) = coordinator.routes.last { + dismissAction = onDismiss + } + XCTAssertNotNil(dismissAction) + dismissAction?.action() + XCTAssertTrue(delegate.itemArchivedCalled) + } + + /// `itemUnarchived()` calls the delegate. + @MainActor + func test_itemUnarchived() async throws { + subject.itemUnarchived() + + var dismissAction: DismissAction? + if case let .dismiss(onDismiss) = coordinator.routes.last { + dismissAction = onDismiss + } + XCTAssertNotNil(dismissAction) + dismissAction?.action() + XCTAssertTrue(delegate.itemUnarchivedCalled) + } + /// `perform(_:)` with `.appeared` starts listening for updates with the vault repository. @MainActor func test_perform_appeared() { // swiftlint:disable:this function_body_length + configService.featureFlagsBool[.archiveVaultItems] = true let account = Account.fixture() stateService.activeAccount = account stateService.showWebIcons = true @@ -180,6 +209,7 @@ class ViewItemProcessorTests: BitwardenTestCase { // swiftlint:disable:this type expectedState.allUserCollections = collections expectedState.ownershipOptions = cipherOwnershipOptions + expectedState.isArchiveVaultItemsFFEnabled = true XCTAssertNotNil(subject.streamCipherDetailsTask) XCTAssertTrue(subject.state.hasPremiumFeatures) @@ -735,9 +765,7 @@ class ViewItemProcessorTests: BitwardenTestCase { // swiftlint:disable:this type let alert = coordinator.alertShown.last XCTAssertEqual(alert, .deleteCipherConfirmation(isSoftDelete: true) {}) - // Tap the "Yes" button on the alert. - let action = try XCTUnwrap(alert?.alertActions.first(where: { $0.title == Localizations.yes })) - await action.handler?(action, []) + try await alert?.tapAction(title: Localizations.yes) // Ensure the generic error alert is displayed. let errorAlert = try XCTUnwrap(coordinator.errorAlertsShown.last) @@ -765,9 +793,7 @@ class ViewItemProcessorTests: BitwardenTestCase { // swiftlint:disable:this type let alert = coordinator.alertShown.last XCTAssertEqual(alert, .deleteCipherConfirmation(isSoftDelete: false) {}) - // Tap the "Yes" button on the alert. - let action = try XCTUnwrap(alert?.alertActions.first(where: { $0.title == Localizations.yes })) - await action.handler?(action, []) + try await alert?.tapAction(title: Localizations.yes) // Ensure the generic error alert is displayed. let errorAlert = try XCTUnwrap(coordinator.errorAlertsShown.last) @@ -833,9 +859,7 @@ class ViewItemProcessorTests: BitwardenTestCase { // swiftlint:disable:this type let alert = coordinator.alertShown.last XCTAssertEqual(alert, .deleteCipherConfirmation(isSoftDelete: true) {}) - // Tap the "Yes" button on the alert. - let action = try XCTUnwrap(alert?.alertActions.first(where: { $0.title == Localizations.yes })) - await action.handler?(action, []) + try await alert?.tapAction(title: Localizations.yes) XCTAssertNil(errorReporter.errors.first) // Ensure the cipher is deleted and the view is dismissed. @@ -868,9 +892,7 @@ class ViewItemProcessorTests: BitwardenTestCase { // swiftlint:disable:this type let alert = coordinator.alertShown.last XCTAssertEqual(alert, .deleteCipherConfirmation(isSoftDelete: false) {}) - // Tap the "Yes" button on the alert. - let action = try XCTUnwrap(alert?.alertActions.first(where: { $0.title == Localizations.yes })) - await action.handler?(action, []) + try await alert?.tapAction(title: Localizations.yes) XCTAssertNil(errorReporter.errors.first) // Ensure the cipher is deleted and the view is dismissed. @@ -1283,9 +1305,7 @@ class ViewItemProcessorTests: BitwardenTestCase { // swiftlint:disable:this type XCTAssertEqual(alert?.title, Localizations.doYouReallyWantToRestoreCipher) XCTAssertNil(alert?.message) - // Tap the "Yes" button on the alert. - let action = try XCTUnwrap(alert?.alertActions.first(where: { $0.title == Localizations.yes })) - await action.handler?(action, []) + try await alert?.tapAction(title: Localizations.yes) // Ensure the generic error alert is displayed. let errorAlert = try XCTUnwrap(coordinator.errorAlertsShown.last) @@ -1313,9 +1333,7 @@ class ViewItemProcessorTests: BitwardenTestCase { // swiftlint:disable:this type XCTAssertEqual(alert?.title, Localizations.doYouReallyWantToRestoreCipher) XCTAssertNil(alert?.message) - // Tap the "Yes" button on the alert. - let action = try XCTUnwrap(alert?.alertActions.first(where: { $0.title == Localizations.yes })) - await action.handler?(action, []) + try await alert?.tapAction(title: Localizations.yes) XCTAssertNil(errorReporter.errors.first) // Ensure the cipher is deleted and the view is dismissed. @@ -1329,6 +1347,134 @@ class ViewItemProcessorTests: BitwardenTestCase { // swiftlint:disable:this type XCTAssertTrue(delegate.itemRestoredCalled) } + /// `perform(_:)` with `.archivedPressed` presents the confirmation alert before archiving + /// the item and archives successfully. + @MainActor + func test_perform_archivedPressed_success() async throws { + let cipherState = CipherItemState( + existing: CipherView.loginFixture(id: "123"), + hasPremium: false, + )! + + let state = ViewItemState( + loadingState: .data(cipherState), + ) + subject.state = state + vaultRepository.archiveCipherResult = .success(()) + await subject.perform(.archivedPressed) + + // Ensure the alert is shown. + let alert = coordinator.alertShown.last + XCTAssertEqual(alert?.title, Localizations.doYouReallyWantToArchiveThisItem) + XCTAssertNil(alert?.message) + + try await alert?.tapAction(title: Localizations.yes) + + XCTAssertNil(errorReporter.errors.first) + // Ensure the cipher is archived and the view is dismissed. + XCTAssertEqual(vaultRepository.archiveCipher.last?.id, "123") + var dismissAction: DismissAction? + if case let .dismiss(onDismiss) = coordinator.routes.last { + dismissAction = onDismiss + } + XCTAssertNotNil(dismissAction) + dismissAction?.action() + XCTAssertTrue(delegate.itemArchivedCalled) + } + + /// `perform(_:)` with `.archivedPressed` presents the confirmation alert before archiving + /// the item and displays generic error alert if archiving fails. + @MainActor + func test_perform_archivedPressed_genericError() async throws { + let cipherState = CipherItemState( + existing: CipherView.loginFixture(id: "123"), + hasPremium: false, + )! + + let state = ViewItemState( + loadingState: .data(cipherState), + ) + subject.state = state + struct TestError: Error, Equatable {} + vaultRepository.archiveCipherResult = .failure(TestError()) + await subject.perform(.archivedPressed) + + // Ensure the alert is shown. + let alert = coordinator.alertShown.last + XCTAssertEqual(alert?.title, Localizations.doYouReallyWantToArchiveThisItem) + XCTAssertNil(alert?.message) + + try await alert?.tapAction(title: Localizations.yes) + + // Ensure the network error alert is displayed. + XCTAssertEqual(coordinator.errorAlertsShown.count, 1) + XCTAssertEqual(errorReporter.errors.first as? TestError, TestError()) + } + + /// `perform(_:)` with `.unarchivePressed` presents the confirmation alert before unarchiving + /// the item and unarchives successfully. + @MainActor + func test_perform_unarchivePressed_success() async throws { + let cipherState = CipherItemState( + existing: CipherView.loginFixture(id: "123"), + hasPremium: false, + )! + + let state = ViewItemState( + loadingState: .data(cipherState), + ) + subject.state = state + vaultRepository.unarchiveCipherResult = .success(()) + await subject.perform(.unarchivePressed) + + // Ensure the alert is shown. + let alert = coordinator.alertShown.last + XCTAssertEqual(alert?.title, Localizations.doYouReallyWantToUnarchiveThisItem) + XCTAssertNil(alert?.message) + + try await alert?.tapAction(title: Localizations.yes) + + XCTAssertNil(errorReporter.errors.first) + // Ensure the cipher is unarchived and the view is dismissed. + XCTAssertEqual(vaultRepository.unarchiveCipher.last?.id, "123") + var dismissAction: DismissAction? + if case let .dismiss(onDismiss) = coordinator.routes.last { + dismissAction = onDismiss + } + XCTAssertNotNil(dismissAction) + dismissAction?.action() + XCTAssertTrue(delegate.itemUnarchivedCalled) + } + + /// `perform(_:)` with `.unarchivePressed` presents the confirmation alert before unarchiving + /// the item and displays generic error alert if unarchiving fails. + @MainActor + func test_perform_unarchivePressed_genericError() async throws { + let cipherState = CipherItemState( + existing: CipherView.loginFixture(id: "123"), + hasPremium: false, + )! + + let state = ViewItemState( + loadingState: .data(cipherState), + ) + subject.state = state + struct TestError: Error, Equatable {} + vaultRepository.unarchiveCipherResult = .failure(TestError()) + await subject.perform(.unarchivePressed) + + // Ensure the alert is shown. + let alert = coordinator.alertShown.last + XCTAssertEqual(alert?.title, Localizations.doYouReallyWantToUnarchiveThisItem) + XCTAssertNil(alert?.message) + + try await alert?.tapAction(title: Localizations.yes) + + // Ensure the network error alert is displayed. + XCTAssertEqual(coordinator.errorAlertsShown.count, 1) + XCTAssertEqual(errorReporter.errors.first as? TestError, TestError()) + } + /// `receive` with `.passwordHistoryPressed` navigates to the password history view. @MainActor func test_receive_passwordHistoryPressed() { diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemView.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemView.swift index 9879c7784..5557ca815 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemView.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemView.swift @@ -14,6 +14,11 @@ struct ViewItemView: View { // MARK: Properties + /// Whether to show the archive option in the toolbar menu. + var isArchiveEnabled: Bool { + store.state.loadingState.data?.canBeArchived ?? false + } + /// Whether to show the collections option in the toolbar menu. var isCollectionsEnabled: Bool { guard let data = store.state.loadingState.data else { return false } @@ -25,15 +30,20 @@ struct ViewItemView: View { store.state.loadingState.data?.canBeDeleted ?? false } + /// Whether to show the move to organization option in the toolbar menu. + var isMoveToOrganizationEnabled: Bool { + store.state.loadingState.data?.canMoveToOrganization ?? false + } + /// Whether the restore option is available. /// New permission model from PM-18091 var isRestoredEnabled: Bool { store.state.loadingState.data?.canBeRestored ?? false } - /// Whether to show the move to organization option in the toolbar menu. - var isMoveToOrganizationEnabled: Bool { - store.state.loadingState.data?.canMoveToOrganization ?? false + /// Whether to show the unarchive option in the toolbar menu. + var isUnarchiveEnabled: Bool { + store.state.loadingState.data?.canBeUnarchived ?? false } /// The `Store` for this view. @@ -69,16 +79,27 @@ struct ViewItemView: View { ToolbarItemGroup(placement: .topBarTrailing) { VaultItemManagementMenuView( + isArchiveEnabled: isArchiveEnabled, isCloneEnabled: store.state.canClone, isCollectionsEnabled: isCollectionsEnabled, isDeleteEnabled: isDeleteEnabled, isMoveToOrganizationEnabled: isMoveToOrganizationEnabled, isRestoreEnabled: isRestoredEnabled, + isUnarchiveEnabled: isUnarchiveEnabled, store: store.child( state: { _ in }, mapAction: { .morePressed($0) }, - mapEffect: { _ in .deletePressed }, - ), + mapEffect: { effect in + return switch effect { + case .archiveItem: + .archivedPressed + case .deleteItem: + .deletePressed + case .unarchiveItem: + .unarchivePressed + } + }, + ) ) } } diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewLoginItem/Extensions/CipherView+Update.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewLoginItem/Extensions/CipherView+Update.swift index b75154723..0abbfb5fa 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewLoginItem/Extensions/CipherView+Update.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewLoginItem/Extensions/CipherView+Update.swift @@ -248,6 +248,22 @@ extension CipherView { } extension CipherView { + /// Returns a copy of the existing cipher with an updated archived date. + /// + /// - Parameter archivedDate: The archived date of the cipher. + /// - Returns: A copy of the existing cipher, with the specified properties updated. + /// + func update(archivedDate: Date?) -> CipherView { + update( + archivedDate: archivedDate, + collectionIds: collectionIds, + deletedDate: deletedDate, + folderId: folderId, + login: login, + organizationId: organizationId, + ) + } + /// Returns a copy of the existing cipher with an updated list of collection IDs. /// /// - Parameter collectionIds: The identifiers of any collections containing the cipher. @@ -255,6 +271,7 @@ extension CipherView { /// func update(collectionIds: [String]) -> CipherView { update( + archivedDate: archivedDate, collectionIds: collectionIds, deletedDate: deletedDate, folderId: folderId, @@ -270,6 +287,7 @@ extension CipherView { /// func update(deletedDate: Date?) -> CipherView { update( + archivedDate: archivedDate, collectionIds: collectionIds, deletedDate: deletedDate, folderId: folderId, @@ -285,6 +303,7 @@ extension CipherView { /// func update(folderId: String?) -> CipherView { update( + archivedDate: archivedDate, collectionIds: collectionIds, deletedDate: deletedDate, folderId: folderId, @@ -300,6 +319,7 @@ extension CipherView { /// func update(login: BitwardenSdk.LoginView) -> CipherView { update( + archivedDate: archivedDate, collectionIds: collectionIds, deletedDate: deletedDate, folderId: folderId, @@ -313,6 +333,7 @@ extension CipherView { /// Returns a copy of the existing cipher, updating any of the specified properties. /// /// - Parameters: + /// - archivedDate: The archived date of the cipher. /// - collectionIds: The identifiers of any collections containing the cipher. /// - deletedDate: The deleted date of the cipher. /// - folderId: The identifier of the cipher's folder @@ -320,7 +341,8 @@ extension CipherView { /// - organizationId: The identifier of the cipher's organization. /// - Returns: A copy of the existing cipher, with the specified properties updated. /// - private func update( + private func update( // swiftlint:disable:this function_parameter_count + archivedDate: Date?, collectionIds: [String], deletedDate: Date?, folderId: String?, @@ -376,4 +398,4 @@ extension BitwardenSdk.LoginView { fido2Credentials: fido2Credentials, ) } -} +} // swiftlint:disable:this file_length diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewLoginItem/Extensions/CipherViewUpdateTests.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewLoginItem/Extensions/CipherViewUpdateTests.swift index 1d888096e..cb30de984 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewLoginItem/Extensions/CipherViewUpdateTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewLoginItem/Extensions/CipherViewUpdateTests.swift @@ -110,6 +110,77 @@ final class CipherViewUpdateTests: BitwardenTestCase { // swiftlint:disable:this XCTAssertEqual(sshKeyItemState.keyFingerprint, "fingerprint") } + /// `update(archivedDate:)` updates the archived date and preserves all other properties. + func test_update_archivedDate() { + let originalCipher = CipherView.fixture( + archivedDate: nil, + id: "123", + name: "Test Cipher", + ) + let archivedDate = Date(year: 2024, month: 3, day: 15) + + let updatedCipher = originalCipher.update(archivedDate: archivedDate) + + XCTAssertEqual(updatedCipher.archivedDate, archivedDate) + XCTAssertEqual(updatedCipher.id, originalCipher.id) + XCTAssertEqual(updatedCipher.name, originalCipher.name) + XCTAssertEqual(updatedCipher.organizationId, originalCipher.organizationId) + XCTAssertEqual(updatedCipher.folderId, originalCipher.folderId) + XCTAssertEqual(updatedCipher.collectionIds, originalCipher.collectionIds) + XCTAssertEqual(updatedCipher.deletedDate, originalCipher.deletedDate) + XCTAssertEqual(updatedCipher.type, originalCipher.type) + XCTAssertEqual(updatedCipher.login, originalCipher.login) + XCTAssertEqual(updatedCipher.notes, originalCipher.notes) + XCTAssertEqual(updatedCipher.favorite, originalCipher.favorite) + XCTAssertEqual(updatedCipher.reprompt, originalCipher.reprompt) + XCTAssertEqual(updatedCipher.creationDate, originalCipher.creationDate) + XCTAssertEqual(updatedCipher.revisionDate, originalCipher.revisionDate) + } + + /// `update(archivedDate:)` sets archived date to nil when unarchiving. + func test_update_archivedDate_nil() { + let originalCipher = CipherView.fixture( + archivedDate: Date(year: 2024, month: 3, day: 15), + id: "123", + name: "Test Cipher", + ) + + let updatedCipher = originalCipher.update(archivedDate: nil) + + XCTAssertNil(updatedCipher.archivedDate) + XCTAssertEqual(updatedCipher.id, originalCipher.id) + XCTAssertEqual(updatedCipher.name, originalCipher.name) + XCTAssertEqual(updatedCipher.organizationId, originalCipher.organizationId) + XCTAssertEqual(updatedCipher.folderId, originalCipher.folderId) + XCTAssertEqual(updatedCipher.collectionIds, originalCipher.collectionIds) + XCTAssertEqual(updatedCipher.deletedDate, originalCipher.deletedDate) + XCTAssertEqual(updatedCipher.type, originalCipher.type) + XCTAssertEqual(updatedCipher.login, originalCipher.login) + XCTAssertEqual(updatedCipher.notes, originalCipher.notes) + XCTAssertEqual(updatedCipher.favorite, originalCipher.favorite) + XCTAssertEqual(updatedCipher.reprompt, originalCipher.reprompt) + XCTAssertEqual(updatedCipher.creationDate, originalCipher.creationDate) + XCTAssertEqual(updatedCipher.revisionDate, originalCipher.revisionDate) + } + + /// `update(archivedDate:)` updates an existing archived date. + func test_update_archivedDate_updateExisting() { + let originalDate = Date(year: 2024, month: 1, day: 1) + let newDate = Date(year: 2024, month: 3, day: 15) + let originalCipher = CipherView.fixture( + archivedDate: originalDate, + id: "123", + name: "Test Cipher", + ) + + let updatedCipher = originalCipher.update(archivedDate: newDate) + + XCTAssertEqual(updatedCipher.archivedDate, newDate) + XCTAssertNotEqual(updatedCipher.archivedDate, originalDate) + XCTAssertEqual(updatedCipher.id, originalCipher.id) + XCTAssertEqual(updatedCipher.name, originalCipher.name) + } + /// Tests that the update succeeds with new properties. func test_update_card_edits_succeeds() { cipherItemState.type = .card