mirror of
https://github.com/bitwarden/ios.git
synced 2026-02-04 02:14:09 -06:00
[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:
parent
6c9ac67246
commit
f7a2510ea6
16
BitwardenResources/Icons.xcassets/archive24.imageset/Contents.json
vendored
Normal file
16
BitwardenResources/Icons.xcassets/archive24.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
BitwardenResources/Icons.xcassets/archive24.imageset/archive24.pdf
vendored
Normal file
BIN
BitwardenResources/Icons.xcassets/archive24.imageset/archive24.pdf
vendored
Normal file
Binary file not shown.
@ -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.";
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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, _, _):
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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, _, _):
|
||||
|
||||
@ -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([]))
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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?
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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",
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -186,6 +186,7 @@ class VaultListSectionsBuilderCollectionTests: BitwardenTestCase {
|
||||
subject = DefaultVaultListSectionsBuilder(
|
||||
clientService: clientService,
|
||||
collectionHelper: collectionHelper,
|
||||
configService: MockConfigService(),
|
||||
errorReporter: errorReporter,
|
||||
withData: withData,
|
||||
)
|
||||
|
||||
@ -273,6 +273,7 @@ class VaultListSectionsBuilderFolderTests: BitwardenTestCase {
|
||||
subject = DefaultVaultListSectionsBuilder(
|
||||
clientService: clientService,
|
||||
collectionHelper: collectionHelper,
|
||||
configService: MockConfigService(),
|
||||
errorReporter: errorReporter,
|
||||
withData: withData,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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/")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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/")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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, _):
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -132,6 +132,8 @@ extension VaultListItem {
|
||||
SharedAsset.Icons.clock24
|
||||
case .trash:
|
||||
SharedAsset.Icons.trash24
|
||||
case .archive:
|
||||
SharedAsset.Icons.archive24
|
||||
}
|
||||
case .totp:
|
||||
SharedAsset.Icons.clock24
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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`.
|
||||
///
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
|
||||
@ -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) },
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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: (),
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user