[PM-19154] feat: Archive vault items (#2207)

Co-authored-by: André Bispo <abispo@bitwarden.com>
Co-authored-by: Matt Czech <matt@livefront.com>
This commit is contained in:
Federico Maccaroni 2026-01-19 15:50:43 -03:00 committed by GitHub
parent 6c9ac67246
commit f7a2510ea6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
81 changed files with 2052 additions and 104 deletions

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "archive24.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

Binary file not shown.

View File

@ -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.";

View File

@ -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

View File

@ -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)
}

View File

@ -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

View File

@ -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,

View File

@ -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
}

View File

@ -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,

View File

@ -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
}

View File

@ -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, _, _):

View File

@ -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)
}
}

View File

@ -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, _, _):

View File

@ -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([]))

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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 {}

View File

@ -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?
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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

View File

@ -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
}

View File

@ -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(

View File

@ -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,

View File

@ -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

View File

@ -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()

View File

@ -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",
])
}

View File

@ -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())

View File

@ -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

View File

@ -186,6 +186,7 @@ class VaultListSectionsBuilderCollectionTests: BitwardenTestCase {
subject = DefaultVaultListSectionsBuilder(
clientService: clientService,
collectionHelper: collectionHelper,
configService: MockConfigService(),
errorReporter: errorReporter,
withData: withData,
)

View File

@ -273,6 +273,7 @@ class VaultListSectionsBuilderFolderTests: BitwardenTestCase {
subject = DefaultVaultListSectionsBuilder(
clientService: clientService,
collectionHelper: collectionHelper,
configService: MockConfigService(),
errorReporter: errorReporter,
withData: withData,
)

View File

@ -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

View File

@ -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,
)

View File

@ -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
}

View File

@ -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,
)

View File

@ -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
}
}

View File

@ -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))
}

View File

@ -13,6 +13,9 @@ class MockVaultRepository: VaultRepository {
var addCipherCiphers = [CipherView]()
var addCipherResult: Result<Void, Error> = .success(())
var archiveCipher = [CipherView]()
var archiveCipherResult: Result<Void, Error> = .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<Void, Error> = .success(())
var updateCipherCiphers = [BitwardenSdk.CipherView]()
var updateCipherResult: Result<Void, Error> = .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<AnyPublisher<[CipherListView], Error>> {
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()

View File

@ -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(

View File

@ -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.

View File

@ -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)

View File

@ -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)

View File

@ -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
}
}

View File

@ -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/")
}
}

View File

@ -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
}
}

View File

@ -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/")
}
}

View File

@ -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

View File

@ -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

View File

@ -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))
}

View File

@ -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

View File

@ -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

View File

@ -9,6 +9,10 @@ class MockCipherService: CipherService {
var addCipherWithServerEncryptedFor: String?
var addCipherWithServerResult: Result<Void, Error> = .success(())
var archiveCipherId: String?
var archiveCipher: Cipher?
var archiveCipherResult: Result<Void, Error> = .success(())
var bulkShareCiphersWithServerCiphers = [[Cipher]]()
var bulkShareCiphersWithServerCollectionIds: [String]?
var bulkShareCiphersWithServerEncryptedFor: String?
@ -63,6 +67,10 @@ class MockCipherService: CipherService {
var syncCipherWithServerId: String?
var syncCipherWithServerResult: Result<Void, Error> = .success(())
var unarchiveCipherId: String?
var unarchiveCipher: Cipher?
var unarchiveCipherResult: Result<Void, Error> = .success(())
var updateCipherWithLocalStorageCiphers = [BitwardenSdk.Cipher]()
var updateCipherWithLocalStorageResult: Result<Void, Error> = .success(())
@ -73,12 +81,22 @@ class MockCipherService: CipherService {
var updateCipherCollectionsWithServerCiphers = [Cipher]()
var updateCipherCollectionsWithServerResult: Result<Void, Error> = .success(())
var unarchivedCipherId: String?
var unarchivedCipher: Cipher?
var unarchiveWithServerResult: Result<Void, Error> = .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()

View File

@ -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)

View File

@ -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")

View File

@ -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)
}

View File

@ -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

View File

@ -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
}

View File

@ -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)
}

View File

@ -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.

View File

@ -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() {

View File

@ -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, _):

View File

@ -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)
}
}

View File

@ -132,6 +132,8 @@ extension VaultListItem {
SharedAsset.Icons.clock24
case .trash:
SharedAsset.Icons.trash24
case .archive:
SharedAsset.Icons.archive24
}
case .totp:
SharedAsset.Icons.clock24

View File

@ -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,

View File

@ -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

View File

@ -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() {

View File

@ -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<// swiftlint:disable:this type_
override func perform(_ effect: AddEditItemEffect) async {
switch effect {
case .appeared:
await loadFeatureFlags()
await showPasswordAutofillAlertIfNeeded()
await checkIfUserHasMasterPassword()
await checkLearnNewLoginActionCardEligibility()
@ -404,6 +415,11 @@ final class AddEditItemProcessor: StateProcessor<// swiftlint:disable:this type_
}
}
/// Loads the feature flags required for this processor.
private func loadFeatureFlags() async {
state.isArchiveVaultItemsFFEnabled = await services.configService.getFeatureFlag(.archiveVaultItems)
}
/// Receives an `AddEditCardItem` action from the `AddEditCardView` view's store, and updates
/// the `AddEditCardState`.
///

View File

@ -675,6 +675,14 @@ class AddEditItemProcessorTests: BitwardenTestCase {
)
}
/// `perform(_:)` with `.appeared` loads the archive vault items feature flag.
@MainActor
func test_perform_appeared_featureFlags() async {
configService.featureFlagsBool[.archiveVaultItems] = true
await subject.perform(.appeared)
XCTAssertTrue(subject.state.isArchiveVaultItemsFFEnabled)
}
/// `perform(_:)` with `.appeared` doesn't show the password autofill alert if it has already been shown.
@MainActor
func test_perform_appeared_showPasswordAutofill_alreadyShown() async {
@ -2849,17 +2857,23 @@ class AddEditItemProcessorTests: BitwardenTestCase {
class MockCipherItemOperationDelegate: CipherItemOperationDelegate {
var itemAddedCalled = false
var itemAddedShouldDismiss = true
var itemArchivedCalled = false
var itemDeletedCalled = false
var itemRestoredCalled = false
var itemSoftDeletedCalled = false
var itemUpdatedCalled = false
var itemUpdatedShouldDismiss = true
var itemUnarchivedCalled = false
func itemAdded() -> 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
}
}

View File

@ -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 }

View File

@ -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) },

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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<Void, VaultItemManagementMenuAction, VaultItemManagementMenuEffect>
@ -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: (),

View File

@ -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
}

View File

@ -111,6 +111,8 @@ final class ViewItemProcessor: StateProcessor<ViewItemState, ViewItemAction, Vie
} catch {
services.errorReporter.log(error: error)
}
case .archivedPressed:
await archiveItemWithConfirmation()
case .deletePressed:
guard case let .data(cipherState) = state.loadingState else { return }
if cipherState.cipher.deletedDate == nil {
@ -122,6 +124,8 @@ final class ViewItemProcessor: StateProcessor<ViewItemState, ViewItemAction, Vie
toggleDisplayMultipleCollections()
case .totpCodeExpired:
await updateTOTPCode()
case .unarchivePressed:
await unarchiveItemWithConfirmation()
}
}
@ -196,6 +200,24 @@ final class ViewItemProcessor: StateProcessor<ViewItemState, ViewItemAction, Vie
private extension ViewItemProcessor {
// MARK: Private Methods
/// Archives a cipher with a pre confirmation alert.
///
private func archiveItemWithConfirmation() async {
guard case let .data(cipherState) = state.loadingState else { return }
let alert = Alert.confirmation(title: Localizations.doYouReallyWantToArchiveThisItem) { [weak self] in
guard let self else { return }
await performOperationAndDismiss(
loadingTitle: Localizations.sendingToArchive,
operation: {
try await self.services.vaultRepository.archiveCipher(cipherState.cipher)
},
onDismiss: { $0.delegate?.itemArchived() },
)
}
coordinator.showAlert(alert)
}
/// Navigates to the clone item view. If the cipher contains FIDO2 credentials, an alert is
/// shown confirming that the user wants to proceed cloning the cipher without a FIDO2 credential.
///
@ -426,6 +448,32 @@ private extension ViewItemProcessor {
}
}
/// Performs an operation and dismisses the view with an action.
/// - Parameters:
/// - loadingTitle: The title of the loading overlay.
/// - operation: The operation to execute.
/// - onDismiss: The action to execute when dismissing.
private func performOperationAndDismiss(
loadingTitle: String,
operation: () async throws -> 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

View File

@ -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() {

View File

@ -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
}
},
)
)
}
}

View File

@ -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

View File

@ -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