From 803f28b31c311bc59498265912987b7d3fdbd0ee Mon Sep 17 00:00:00 2001 From: Federico Maccaroni Date: Fri, 14 Nov 2025 17:57:21 -0300 Subject: [PATCH] [PM-23729] Refactor searches to use new approach with vault list builders (#2132) --- .../AuthenticatorItemRepository.swift | 2 +- .../AuthenticatorItemCoordinator.swift | 1 - .../Tools/Repositories/SendRepository.swift | 6 +- .../CipherListView+Extensions.swift | 75 ++ .../CipherListViewExtensionsTests.swift | 271 +++- .../Vault/Helpers/CipherMatchResult.swift | 13 + .../Vault/Helpers/CipherMatchingHelper.swift | 15 - ...ltListPreparedDataBuilder+Extensions.swift | 4 + ...kVaultListSectionsBuilder+Extensions.swift | 6 +- .../VaultListDataPreparator+SearchTests.swift | 589 ++++++++ .../Helpers/VaultListDataPreparator.swift | 212 ++- .../VaultListDataPreparatorTests.swift | 34 +- .../MainVaultListDirectorStrategy.swift | 4 +- .../MainVaultListDirectorStrategyTests.swift | 12 +- ...MultipleAutofillListDirectorStrategy.swift | 67 + ...pleAutofillListDirectorStrategyTests.swift | 127 ++ .../SearchVaultListDirectorStrategy.swift | 53 + ...SearchVaultListDirectorStrategyTests.swift | 102 ++ .../VaultListDirectorStrategy.swift | 3 + .../VaultListDirectorStrategyFactory.swift | 18 +- ...aultListDirectorStrategyFactoryTests.swift | 58 +- .../VaultListPreparedDataBuilder.swift | 54 +- .../VaultListPreparedDataBuilderTests.swift | 147 ++ .../Helpers/VaultListSectionsBuilder.swift | 52 +- .../VaultListSectionsBuilderTests.swift | 191 +++ .../TestHelpers/MockVaultRepository.swift | 27 - .../Vault/Repositories/VaultRepository.swift | 874 +----------- .../Repositories/VaultRepositoryTests.swift | 1191 +---------------- .../Vault/Utilities/VaultListFilter.swift | 78 ++ .../Utilities/VaultListFilterTests.swift | 113 ++ ...llListProcessor+AutofillModeAllTests.swift | 27 +- ...aultAutofillListProcessor+Fido2Tests.swift | 13 +- ...VaultAutofillListProcessor+TotpTests.swift | 2 +- .../VaultAutofillListProcessor.swift | 20 +- .../VaultAutofillListProcessorTests.swift | 14 +- .../VaultGroup/VaultGroupProcessor.swift | 15 +- .../VaultGroup/VaultGroupProcessorTests.swift | 52 +- .../VaultItemSelectionProcessor.swift | 37 +- .../VaultItemSelectionProcessorTests.swift | 37 +- .../Vault/VaultList/VaultListProcessor.swift | 20 +- .../VaultList/VaultListProcessorTests.swift | 10 +- 41 files changed, 2375 insertions(+), 2271 deletions(-) create mode 100644 BitwardenShared/Core/Vault/Helpers/CipherMatchResult.swift create mode 100644 BitwardenShared/Core/Vault/Helpers/VaultListDataPreparator+SearchTests.swift create mode 100644 BitwardenShared/Core/Vault/Helpers/VaultListDirectors/SearchCombinedMultipleAutofillListDirectorStrategy.swift create mode 100644 BitwardenShared/Core/Vault/Helpers/VaultListDirectors/SearchCombinedMultipleAutofillListDirectorStrategyTests.swift create mode 100644 BitwardenShared/Core/Vault/Helpers/VaultListDirectors/SearchVaultListDirectorStrategy.swift create mode 100644 BitwardenShared/Core/Vault/Helpers/VaultListDirectors/SearchVaultListDirectorStrategyTests.swift create mode 100644 BitwardenShared/Core/Vault/Utilities/VaultListFilter.swift create mode 100644 BitwardenShared/Core/Vault/Utilities/VaultListFilterTests.swift diff --git a/AuthenticatorShared/Core/Vault/Repositories/AuthenticatorItemRepository.swift b/AuthenticatorShared/Core/Vault/Repositories/AuthenticatorItemRepository.swift index 1a0264be9..81f752a64 100644 --- a/AuthenticatorShared/Core/Vault/Repositories/AuthenticatorItemRepository.swift +++ b/AuthenticatorShared/Core/Vault/Repositories/AuthenticatorItemRepository.swift @@ -401,7 +401,7 @@ extension DefaultAuthenticatorItemRepository: AuthenticatorItemRepository { return sections.flatMap(\.items) .filter { item in item.name.lowercased() - .folding(options: .diacriticInsensitive, locale: nil) + .folding(options: .diacriticInsensitive, locale: .current) .contains(query) } .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } diff --git a/AuthenticatorShared/UI/Vault/AuthenticatorItem/AuthenticatorItemCoordinator.swift b/AuthenticatorShared/UI/Vault/AuthenticatorItem/AuthenticatorItemCoordinator.swift index eb4f201a4..03f35b72b 100644 --- a/AuthenticatorShared/UI/Vault/AuthenticatorItem/AuthenticatorItemCoordinator.swift +++ b/AuthenticatorShared/UI/Vault/AuthenticatorItem/AuthenticatorItemCoordinator.swift @@ -125,4 +125,3 @@ class AuthenticatorItemCoordinator: NSObject, Coordinator, HasStackNavigator { extension AuthenticatorItemCoordinator: HasErrorAlertServices { var errorAlertServices: ErrorAlertServices { services } } - diff --git a/BitwardenShared/Core/Tools/Repositories/SendRepository.swift b/BitwardenShared/Core/Tools/Repositories/SendRepository.swift index 3ea32cc38..8684708f6 100644 --- a/BitwardenShared/Core/Tools/Repositories/SendRepository.swift +++ b/BitwardenShared/Core/Tools/Repositories/SendRepository.swift @@ -260,13 +260,13 @@ class DefaultSendRepository: SendRepository { // Search the sends. activeSends.forEach { sendView in if sendView.name.lowercased() - .folding(options: .diacriticInsensitive, locale: nil).contains(query) { + .folding(options: .diacriticInsensitive, locale: .current).contains(query) { matchedSends.append(sendView) } else if sendView.text?.text?.lowercased() - .folding(options: .diacriticInsensitive, locale: nil).contains(query) == true { + .folding(options: .diacriticInsensitive, locale: .current).contains(query) == true { lowPriorityMatchedSends.append(sendView) } else if sendView.file?.fileName.lowercased() - .folding(options: .diacriticInsensitive, locale: nil).contains(query) == true { + .folding(options: .diacriticInsensitive, locale: .current).contains(query) == true { lowPriorityMatchedSends.append(sendView) } } diff --git a/BitwardenShared/Core/Vault/Extensions/CipherListView+Extensions.swift b/BitwardenShared/Core/Vault/Extensions/CipherListView+Extensions.swift index a09699bb6..7fbe10681 100644 --- a/BitwardenShared/Core/Vault/Extensions/CipherListView+Extensions.swift +++ b/BitwardenShared/Core/Vault/Extensions/CipherListView+Extensions.swift @@ -2,6 +2,8 @@ import BitwardenResources import BitwardenSdk extension CipherListView { + // MARK: Properties + /// Determines whether the cipher can be used in basic password autofill operations. /// /// A cipher qualifies for basic login autofill if it's a login type and contains at least one @@ -19,6 +21,79 @@ extension CipherListView { } } + // MARK: Methods + + /// Whether the cipher belongs to a group. + /// - Parameter group: The group to filter. + /// - Returns: `true` if the cipher belongs to the group, `false` otherwise. + func belongsToGroup(_ group: VaultListGroup) -> Bool { + switch group { + case .card: + type.isCard + case let .collection(id, _, _): + collectionIds.contains(id) + case let .folder(id, _): + folderId == id + case .identity: + type == .identity + case .login: + type.isLogin + case .noFolder: + folderId == nil + case .secureNote: + type == .secureNote + case .sshKey: + type == .sshKey + case .totp: + type.loginListView?.totp != nil + case .trash: + deletedDate != nil + } + } + + /// Determines how well the cipher matches a search query. + /// + /// This method performs a multi-level search across the cipher's properties to determine + /// the quality of the match. The query should be preprocessed (lowercased and diacritic-folded) + /// before calling this method. + /// + /// - Parameter query: The preprocessed search query (lowercased and diacritic-folded). + /// + /// - Returns: A `CipherMatchResult` indicating the match quality: + /// - `.exact`: The cipher name matches the query + /// - `.fuzzy`: Some other cipher properties match the query + /// - `.none`: No match found + /// + func matchesSearchQuery(_ query: String) -> CipherMatchResult { + guard !query.isEmpty else { + return .none + } + + if name.lowercased() + .folding(options: .diacriticInsensitive, locale: .current).contains(query) { + return .exact + } + + // Fuzzy match: ID starts with query (requires minimum 8 characters for UUID prefix matching) + if query.count >= 8, id?.starts(with: query) == true { + return .fuzzy + } + + // Fuzzy match other fields: Login Username, Card Brand, Last 4 card numbers, Identity full name. + // This can all be done here since how the SDK builds the cipher's subtitle. + if subtitle.lowercased() + .folding(options: .diacriticInsensitive, locale: .current).contains(query) == true { + return .fuzzy + } + + if type.loginListView?.uris? + .contains(where: { $0.uri?.lowercased().contains(query) == true }) == true { + return .fuzzy + } + + return .none + } + /// Whether the cipher passes the `.restrictItemTypes` policy based on the organizations restricted. /// /// - Parameters: diff --git a/BitwardenShared/Core/Vault/Extensions/CipherListViewExtensionsTests.swift b/BitwardenShared/Core/Vault/Extensions/CipherListViewExtensionsTests.swift index 37f1289fd..91bcb52a7 100644 --- a/BitwardenShared/Core/Vault/Extensions/CipherListViewExtensionsTests.swift +++ b/BitwardenShared/Core/Vault/Extensions/CipherListViewExtensionsTests.swift @@ -5,9 +5,164 @@ import XCTest // MARK: - CipherListViewExtensionsTests -class CipherListViewExtensionsTests: BitwardenTestCase { +class CipherListViewExtensionsTests: BitwardenTestCase { // swiftlint:disable:this type_body_length // MARK: Tests + /// `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())) + XCTAssertTrue(cipher.belongsToGroup(.card)) + XCTAssertFalse(cipher.belongsToGroup(.login)) + XCTAssertFalse(cipher.belongsToGroup(.identity)) + } + + /// `belongsToGroup(_:)` returns `true` when the cipher is a login type and the group is `.login`. + func test_belongsToGroup_login() { + let cipher = CipherListView.fixture(type: .login(.fixture())) + XCTAssertTrue(cipher.belongsToGroup(.login)) + XCTAssertFalse(cipher.belongsToGroup(.card)) + XCTAssertFalse(cipher.belongsToGroup(.identity)) + } + + /// `belongsToGroup(_:)` returns `true` when the cipher is an identity type and the group is `.identity`. + func test_belongsToGroup_identity() { + let cipher = CipherListView.fixture(type: .identity) + XCTAssertTrue(cipher.belongsToGroup(.identity)) + XCTAssertFalse(cipher.belongsToGroup(.card)) + XCTAssertFalse(cipher.belongsToGroup(.login)) + } + + /// `belongsToGroup(_:)` returns `true` when the cipher is a secure note type and the group is `.secureNote`. + func test_belongsToGroup_secureNote() { + let cipher = CipherListView.fixture(type: .secureNote) + XCTAssertTrue(cipher.belongsToGroup(.secureNote)) + XCTAssertFalse(cipher.belongsToGroup(.card)) + XCTAssertFalse(cipher.belongsToGroup(.login)) + } + + /// `belongsToGroup(_:)` returns `true` when the cipher is an SSH key type and the group is `.sshKey`. + func test_belongsToGroup_sshKey() { + let cipher = CipherListView.fixture(type: .sshKey) + XCTAssertTrue(cipher.belongsToGroup(.sshKey)) + XCTAssertFalse(cipher.belongsToGroup(.card)) + XCTAssertFalse(cipher.belongsToGroup(.login)) + } + + /// `belongsToGroup(_:)` returns `true` when the cipher is a login with TOTP and the group is `.totp`. + func test_belongsToGroup_totp() { + let loginWithTotp = LoginListView.fixture(totp: "JBSWY3DPEHPK3PXP") + let cipher = CipherListView.fixture(type: .login(loginWithTotp)) + XCTAssertTrue(cipher.belongsToGroup(.totp)) + XCTAssertTrue(cipher.belongsToGroup(.login)) + } + + /// `belongsToGroup(_:)` returns `false` when the cipher is a login without TOTP and the group is `.totp`. + func test_belongsToGroup_totp_noTotp() { + let loginWithoutTotp = LoginListView.fixture(totp: nil) + let cipher = CipherListView.fixture(type: .login(loginWithoutTotp)) + XCTAssertFalse(cipher.belongsToGroup(.totp)) + XCTAssertTrue(cipher.belongsToGroup(.login)) + } + + /// `belongsToGroup(_:)` returns `false` when the cipher is not a login and the group is `.totp`. + func test_belongsToGroup_totp_nonLogin() { + let cipher = CipherListView.fixture(type: .card(.fixture())) + XCTAssertFalse(cipher.belongsToGroup(.totp)) + } + + /// `belongsToGroup(_:)` returns `true` when the cipher has no folder and the group is `.noFolder`. + func test_belongsToGroup_noFolder() { + let cipher = CipherListView.fixture(folderId: nil) + XCTAssertTrue(cipher.belongsToGroup(.noFolder)) + } + + /// `belongsToGroup(_:)` returns `false` when the cipher has a folder and the group is `.noFolder`. + func test_belongsToGroup_noFolder_hasFolder() { + let cipher = CipherListView.fixture(folderId: "folder-123") + XCTAssertFalse(cipher.belongsToGroup(.noFolder)) + } + + /// `belongsToGroup(_:)` returns `true` when the cipher's folder ID matches the group's folder ID. + func test_belongsToGroup_folder_matching() { + let cipher = CipherListView.fixture(folderId: "folder-123") + XCTAssertTrue(cipher.belongsToGroup(.folder(id: "folder-123", name: "My Folder"))) + } + + /// `belongsToGroup(_:)` returns `false` when the cipher's folder ID doesn't match the group's folder ID. + func test_belongsToGroup_folder_notMatching() { + let cipher = CipherListView.fixture(folderId: "folder-123") + XCTAssertFalse(cipher.belongsToGroup(.folder(id: "folder-456", name: "Other Folder"))) + } + + /// `belongsToGroup(_:)` returns `false` when the cipher has no folder and the group is a specific folder. + func test_belongsToGroup_folder_noFolderId() { + let cipher = CipherListView.fixture(folderId: nil) + XCTAssertFalse(cipher.belongsToGroup(.folder(id: "folder-123", name: "My Folder"))) + } + + /// `belongsToGroup(_:)` returns `true` when the cipher's collection ID matches the group's collection ID. + func test_belongsToGroup_collection_matching() { + let cipher = CipherListView.fixture(collectionIds: ["collection-1", "collection-2"]) + XCTAssertTrue( + cipher.belongsToGroup( + .collection( + id: "collection-1", + name: "My Collection", + organizationId: "org-1", + ), + ), + ) + XCTAssertTrue( + cipher.belongsToGroup( + .collection( + id: "collection-2", + name: "Other Collection", + organizationId: "org-1", + ), + ), + ) + } + + /// `belongsToGroup(_:)` returns `false` when the cipher's collection IDs don't include the group's collection ID. + func test_belongsToGroup_collection_notMatching() { + let cipher = CipherListView.fixture(collectionIds: ["collection-1", "collection-2"]) + XCTAssertFalse( + cipher.belongsToGroup( + .collection( + id: "collection-3", + name: "Missing Collection", + organizationId: "org-1", + ), + ), + ) + } + + /// `belongsToGroup(_:)` returns `false` when the cipher has no collections and the group is a collection. + func test_belongsToGroup_collection_noCollections() { + let cipher = CipherListView.fixture(collectionIds: []) + XCTAssertFalse( + cipher.belongsToGroup( + .collection( + id: "collection-1", + name: "My Collection", + organizationId: "org-1", + ), + ), + ) + } + + /// `belongsToGroup(_:)` returns `true` when the cipher is in trash and the group is `.trash`. + func test_belongsToGroup_trash() { + let cipher = CipherListView.fixture(deletedDate: Date()) + XCTAssertTrue(cipher.belongsToGroup(.trash)) + } + + /// `belongsToGroup(_:)` returns `false` when the cipher is not in trash and the group is `.trash`. + func test_belongsToGroup_trash_notDeleted() { + let cipher = CipherListView.fixture(deletedDate: nil) + XCTAssertFalse(cipher.belongsToGroup(.trash)) + } + /// `canBeUsedInBasicLoginAutofill` returns `false` when the cipher is not a login type. func test_canBeUsedInBasicLoginAutofill_nonLoginType() { XCTAssertFalse(CipherListView.fixture(type: .card(.fixture())).canBeUsedInBasicLoginAutofill) @@ -98,6 +253,118 @@ class CipherListViewExtensionsTests: BitwardenTestCase { ) } + /// `matchesSearchQuery(_:)` returns `.exact` when query matches cipher name. + func test_matchesSearchQuery_exactMatchOnName() { + let cipher = CipherListView.fixture(name: "Example Site") + XCTAssertEqual(cipher.matchesSearchQuery("example"), .exact) + XCTAssertEqual(cipher.matchesSearchQuery("site"), .exact) + XCTAssertEqual(cipher.matchesSearchQuery("example site"), .exact) + } + + /// `matchesSearchQuery(_:)` returns `.exact` for case-insensitive name matching. + func test_matchesSearchQuery_exactMatchCaseInsensitive() { + let cipher = CipherListView.fixture(name: "Example Site") + XCTAssertEqual(cipher.matchesSearchQuery("example"), .exact) + + let cipher2 = CipherListView.fixture(name: "EXAMPLE SITE") + XCTAssertEqual(cipher2.matchesSearchQuery("example"), .exact) + } + + /// `matchesSearchQuery(_:)` returns `.exact` for diacritic-insensitive name matching. + func test_matchesSearchQuery_exactMatchDiacriticInsensitive() { + let cipher = CipherListView.fixture(name: "Café") + XCTAssertEqual(cipher.matchesSearchQuery("cafe"), .exact) + } + + /// `matchesSearchQuery(_:)` returns `.fuzzy` when query matches cipher ID prefix with 8+ characters. + func test_matchesSearchQuery_fuzzyMatchOnIdPrefix() { + let cipher = CipherListView.fixture(id: "12345678-90ab-cdef-1234-567890abcdef") + XCTAssertEqual(cipher.matchesSearchQuery("12345678"), .fuzzy) + XCTAssertEqual(cipher.matchesSearchQuery("12345678-90ab"), .fuzzy) + } + + /// `matchesSearchQuery(_:)` returns `.none` when query matches ID but is less than 8 characters. + func test_matchesSearchQuery_noMatchOnShortIdPrefix() { + let cipher = CipherListView.fixture(id: "12345678-90ab-cdef-1234-567890abcdef") + XCTAssertEqual(cipher.matchesSearchQuery("1234567"), .none) + XCTAssertEqual(cipher.matchesSearchQuery("123"), .none) + } + + /// `matchesSearchQuery(_:)` returns `.fuzzy` when query matches cipher subtitle. + func test_matchesSearchQuery_fuzzyMatchOnSubtitle() { + let cipher = CipherListView.fixture(name: "MySite", subtitle: "user@example.com") + XCTAssertEqual(cipher.matchesSearchQuery("user"), .fuzzy) + XCTAssertEqual(cipher.matchesSearchQuery("example"), .fuzzy) + } + + /// `matchesSearchQuery(_:)` returns `.fuzzy` for case-insensitive subtitle matching. + func test_matchesSearchQuery_fuzzyMatchSubtitleCaseInsensitive() { + let cipher = CipherListView.fixture(name: "MySite", subtitle: "Admin User") + XCTAssertEqual(cipher.matchesSearchQuery("admin"), .fuzzy) + XCTAssertEqual(cipher.matchesSearchQuery("user"), .fuzzy) + } + + /// `matchesSearchQuery(_:)` returns `.fuzzy` when query matches login URI. + func test_matchesSearchQuery_fuzzyMatchOnUri() { + let login = LoginListView.fixture( + uris: [LoginUriView(uri: "https://example.com", match: nil, uriChecksum: nil)], + ) + let cipher = CipherListView.fixture(login: login, name: "MySite") + XCTAssertEqual(cipher.matchesSearchQuery("example.com"), .fuzzy) + XCTAssertEqual(cipher.matchesSearchQuery("https://"), .fuzzy) + } + + /// `matchesSearchQuery(_:)` returns `.fuzzy` when query matches any URI in multiple URIs. + func test_matchesSearchQuery_fuzzyMatchOnMultipleUris() { + let login = LoginListView.fixture( + uris: [ + LoginUriView(uri: "https://example.com", match: nil, uriChecksum: nil), + LoginUriView(uri: "https://test.com", match: nil, uriChecksum: nil), + LoginUriView(uri: "https://demo.org", match: nil, uriChecksum: nil), + ], + ) + let cipher = CipherListView.fixture(login: login, name: "MySite") + XCTAssertEqual(cipher.matchesSearchQuery("test.com"), .fuzzy) + XCTAssertEqual(cipher.matchesSearchQuery("demo"), .fuzzy) + } + + /// `matchesSearchQuery(_:)` returns `.none` when query matches nothing. + func test_matchesSearchQuery_noMatch() { + let cipher = CipherListView.fixture(name: "Example", subtitle: "test@example.com") + XCTAssertEqual(cipher.matchesSearchQuery("nonexistent"), .none) + XCTAssertEqual(cipher.matchesSearchQuery("xyz"), .none) + } + + /// `matchesSearchQuery(_:)` prioritizes exact match over fuzzy match. + func test_matchesSearchQuery_exactMatchPriority() { + let login = LoginListView.fixture( + uris: [LoginUriView(uri: "https://example.com", match: nil, uriChecksum: nil)], + ) + let cipher = CipherListView.fixture(login: login, name: "Example Site", subtitle: "example") + XCTAssertEqual(cipher.matchesSearchQuery("example"), .exact) + } + + /// `matchesSearchQuery(_:)` handles empty query. + func test_matchesSearchQuery_emptyQuery() { + let cipher = CipherListView.fixture(name: "Example") + XCTAssertEqual(cipher.matchesSearchQuery(""), .none) + } + + /// `matchesSearchQuery(_:)` handles cipher with nil ID. + func test_matchesSearchQuery_nilId() { + let cipher = CipherListView.fixture(id: nil, name: "Example") + XCTAssertEqual(cipher.matchesSearchQuery("12345678"), .none) + XCTAssertEqual(cipher.matchesSearchQuery("example"), .exact) + } + + /// `matchesSearchQuery(_:)` handles cipher with nil URIs. + func test_matchesSearchQuery_nilUris() { + let login = LoginListView.fixture(uris: nil) + let cipher = CipherListView.fixture(login: login, name: "MySite") + XCTAssertEqual(cipher.matchesSearchQuery("example.com"), .none) + XCTAssertEqual(cipher.matchesSearchQuery("mysite"), .exact) + } + /// `passesRestrictItemTypesPolicy(_:)` passes the policy when there are no organization IDs. func test_passesRestrictItemTypesPolicy_noOrgIds() { XCTAssertTrue(CipherListView.fixture().passesRestrictItemTypesPolicy([])) @@ -139,4 +406,4 @@ class CipherListViewExtensionsTests: BitwardenTestCase { .passesRestrictItemTypesPolicy(["1", "2", "3"]), ) } -} +} // swiftlint:disable:this file_length diff --git a/BitwardenShared/Core/Vault/Helpers/CipherMatchResult.swift b/BitwardenShared/Core/Vault/Helpers/CipherMatchResult.swift new file mode 100644 index 000000000..46c9c57a5 --- /dev/null +++ b/BitwardenShared/Core/Vault/Helpers/CipherMatchResult.swift @@ -0,0 +1,13 @@ +// MARK: CipherMatchResult + +/// An enum describing the strength on a cipher matching operation. +enum CipherMatchResult { + /// The cipher is an exact match for the "query". + case exact + + /// The cipher is a close match for the "query". + case fuzzy + + /// The cipher doesn't match the "query". + case none +} diff --git a/BitwardenShared/Core/Vault/Helpers/CipherMatchingHelper.swift b/BitwardenShared/Core/Vault/Helpers/CipherMatchingHelper.swift index 998940489..17f16e365 100644 --- a/BitwardenShared/Core/Vault/Helpers/CipherMatchingHelper.swift +++ b/BitwardenShared/Core/Vault/Helpers/CipherMatchingHelper.swift @@ -1,21 +1,6 @@ import BitwardenSdk import Foundation -// MARK: CipherMatchResult - -/// An enum describing the strength that a cipher matches a URI. -/// -enum CipherMatchResult { - /// The cipher is an exact match for the URI. - case exact - - /// The cipher is a close match for the URI. - case fuzzy - - /// The cipher doesn't match the URI. - case none -} - // MARK: CipherMatchingHelper /// A helper to handle filtering ciphers that match a URI. diff --git a/BitwardenShared/Core/Vault/Helpers/TestHelpers/MockVaultListPreparedDataBuilder+Extensions.swift b/BitwardenShared/Core/Vault/Helpers/TestHelpers/MockVaultListPreparedDataBuilder+Extensions.swift index 6ccd63a04..cc4b33822 100644 --- a/BitwardenShared/Core/Vault/Helpers/TestHelpers/MockVaultListPreparedDataBuilder+Extensions.swift +++ b/BitwardenShared/Core/Vault/Helpers/TestHelpers/MockVaultListPreparedDataBuilder+Extensions.swift @@ -36,6 +36,10 @@ extension MockVaultListPreparedDataBuilder { helper.recordCall("addNoFolderItem") return self } + addSearchResultItemClosure = { _, _, _ -> VaultListPreparedDataBuilder in + helper.recordCall("addSearchResultItem") + return self + } incrementCipherTypeCountClosure = { _ -> VaultListPreparedDataBuilder in helper.recordCall("incrementCipherTypeCount") return self diff --git a/BitwardenShared/Core/Vault/Helpers/TestHelpers/MockVaultListSectionsBuilder+Extensions.swift b/BitwardenShared/Core/Vault/Helpers/TestHelpers/MockVaultListSectionsBuilder+Extensions.swift index 20d94b5e5..79c6a1583 100644 --- a/BitwardenShared/Core/Vault/Helpers/TestHelpers/MockVaultListSectionsBuilder+Extensions.swift +++ b/BitwardenShared/Core/Vault/Helpers/TestHelpers/MockVaultListSectionsBuilder+Extensions.swift @@ -8,7 +8,7 @@ extension MockVaultListSectionsBuilder { helper.recordCall("addAutofillPasswordsSection") return self } - addAutofillCombinedMultipleSectionClosure = { (_: String?) -> VaultListSectionsBuilder in + addAutofillCombinedMultipleSectionClosure = { (_: String?, _: String?) -> VaultListSectionsBuilder in helper.recordCall("addAutofillCombinedMultipleSection") return self } @@ -44,6 +44,10 @@ extension MockVaultListSectionsBuilder { helper.recordCall("addCollectionsSection") return self } + addSearchResultsSectionClosure = { _ -> VaultListSectionsBuilder in + helper.recordCall("addSearchResultsSection") + return self + } addTrashSectionClosure = { () -> VaultListSectionsBuilder in helper.recordCall("addTrashSection") return self diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparator+SearchTests.swift b/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparator+SearchTests.swift new file mode 100644 index 000000000..65972ce39 --- /dev/null +++ b/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparator+SearchTests.swift @@ -0,0 +1,589 @@ +// swiftlint:disable:this file_name + +import BitwardenKitMocks +import BitwardenSdk +import XCTest + +@testable import BitwardenShared + +// MARK: - VaultListDataPreparatorSearchTests + +class VaultListDataPreparatorSearchTests: BitwardenTestCase { // swiftlint:disable:this type_body_length + // MARK: Properties + + var cipherMatchingHelper: MockCipherMatchingHelper! + var cipherMatchingHelperFactory: MockCipherMatchingHelperFactory! + var ciphersClientWrapperService: MockCiphersClientWrapperService! + var clientService: MockClientService! + var configService: MockConfigService! + var errorReporter: MockErrorReporter! + var mockCallOrderHelper: MockCallOrderHelper! + var policyService: MockPolicyService! + var stateService: MockStateService! + var subject: VaultListDataPreparator! + var vaultListPreparedDataBuilder: MockVaultListPreparedDataBuilder! + var vaultListPreparedDataBuilderFactory: MockVaultListPreparedDataBuilderFactory! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + cipherMatchingHelper = MockCipherMatchingHelper() + cipherMatchingHelperFactory = MockCipherMatchingHelperFactory() + cipherMatchingHelperFactory.makeReturnValue = cipherMatchingHelper + + ciphersClientWrapperService = MockCiphersClientWrapperService() + clientService = MockClientService() + configService = MockConfigService() + errorReporter = MockErrorReporter() + policyService = MockPolicyService() + stateService = MockStateService() + + vaultListPreparedDataBuilder = MockVaultListPreparedDataBuilder() + mockCallOrderHelper = vaultListPreparedDataBuilder.setUpCallOrderHelper() + vaultListPreparedDataBuilder.buildReturnValue = VaultListPreparedData() + + vaultListPreparedDataBuilderFactory = MockVaultListPreparedDataBuilderFactory() + vaultListPreparedDataBuilderFactory.makeReturnValue = vaultListPreparedDataBuilder + + subject = DefaultVaultListDataPreparator( + cipherMatchingHelperFactory: cipherMatchingHelperFactory, + ciphersClientWrapperService: ciphersClientWrapperService, + clientService: clientService, + configService: configService, + errorReporter: errorReporter, + policyService: policyService, + stateService: stateService, + vaultListPreparedDataBuilderFactory: vaultListPreparedDataBuilderFactory, + ) + } + + override func tearDown() { + super.tearDown() + + cipherMatchingHelper = nil + cipherMatchingHelperFactory = nil + ciphersClientWrapperService = nil + clientService = nil + configService = nil + errorReporter = nil + mockCallOrderHelper = nil + policyService = nil + stateService = nil + vaultListPreparedDataBuilder = nil + vaultListPreparedDataBuilderFactory = nil + subject = nil + } + + // MARK: Tests + + /// `prepareSearchAutofillCombinedMultipleData(from:filter:withFido2Credentials:)` returns `nil` + /// when no ciphers passed. + func test_prepareSearchAutofillCombinedMultipleData_noCiphers() async throws { + let result = await subject.prepareSearchAutofillCombinedMultipleData( + from: [], + filter: VaultListFilter(searchText: "example"), + withFido2Credentials: nil, + ) + XCTAssertNil(result) + } + + /// `prepareSearchAutofillCombinedMultipleData(from:filter:withFido2Credentials:)` returns `nil` + /// when filter has no search text. + func test_prepareSearchAutofillCombinedMultipleData_noSearchText() async throws { + let result = await subject.prepareSearchAutofillCombinedMultipleData( + from: [.fixture()], + filter: VaultListFilter(), + withFido2Credentials: nil, + ) + XCTAssertNil(result) + } + + /// `prepareSearchAutofillCombinedMultipleData(from:filter:withFido2Credentials:)` returns `nil` + /// when filter has empty search text. + func test_prepareSearchAutofillCombinedMultipleData_emptySearchText() async throws { + let result = await subject.prepareSearchAutofillCombinedMultipleData( + from: [.fixture()], + filter: VaultListFilter(searchText: ""), + withFido2Credentials: nil, + ) + XCTAssertNil(result) + } + + /// `prepareSearchAutofillCombinedMultipleData(from:filter:withFido2Credentials:)` returns the + /// prepared data for a login cipher matching search query without Fido2. + func test_prepareSearchAutofillCombinedMultipleData_returnsPreparedDataForLoginNoFido2() async throws { + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + login: .fixture( + hasFido2: false, + uris: [.fixture(uri: "https://example.com", match: .exact)], + ), + name: "Example Site", + copyableFields: [.loginPassword], + ) + + let result = await subject.prepareSearchAutofillCombinedMultipleData( + from: [ + .fixture( + login: .fixture( + fido2Credentials: [], + 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 the + /// prepared data for a login cipher with Fido2. + func test_prepareSearchAutofillCombinedMultipleData_returnsPreparedDataForLoginWithFido2() async throws { + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + login: .fixture( + hasFido2: true, + uris: [.fixture(uri: "https://example.com", match: .exact)], + ), + name: "Example Site", + copyableFields: [.loginPassword], + ) + + let result = await subject.prepareSearchAutofillCombinedMultipleData( + from: [ + .fixture( + id: "1", + login: .fixture( + fido2Credentials: [.fixture()], + uris: [.fixture(uri: "https://example.com", match: .exact)], + ), + ), + ], + filter: VaultListFilter(searchText: "example"), + withFido2Credentials: [.fixture(id: "1")], + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + "addFido2Item", + "addItemForGroup", + ]) + XCTAssertNotNil(result) + } + + /// `prepareSearchAutofillCombinedMultipleData(from:filter:withFido2Credentials:)` returns the + /// prepared data for a login cipher with Fido2 but no Fido2 credentials provided. + func test_prepareSearchAutofillCombinedMultipleData_returnsDataWithFido2NoCredentialsProvided() async throws { + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + login: .fixture( + hasFido2: true, + uris: [.fixture(uri: "https://example.com", match: .exact)], + ), + name: "Example Site", + copyableFields: [.loginPassword], + ) + + let result = await subject.prepareSearchAutofillCombinedMultipleData( + from: [ + .fixture( + id: "1", + login: .fixture( + fido2Credentials: [.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 the + /// prepared data for a login cipher with Fido2 but cipher ID doesn't match credentials. + func test_prepareSearchAutofillCombinedMultipleData_returnsDataWithFido2NonMatchingCredentials() async throws { + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + login: .fixture( + hasFido2: true, + uris: [.fixture(uri: "https://example.com", match: .exact)], + ), + name: "Example Site", + copyableFields: [.loginPassword], + ) + + let result = await subject.prepareSearchAutofillCombinedMultipleData( + from: [ + .fixture( + id: "1", + login: .fixture( + fido2Credentials: [.fixture()], + uris: [.fixture(uri: "https://example.com", match: .exact)], + ), + ), + ], + filter: VaultListFilter(searchText: "example"), + withFido2Credentials: [.fixture(id: "2")], + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + "addItemForGroup", + ]) + XCTAssertNotNil(result) + } + + /// `prepareSearchAutofillCombinedMultipleData(from:filter:withFido2Credentials:)` returns the + /// prepared data filtering out cipher as it doesn't pass restrict item type policy. + @MainActor + func test_prepareSearchAutofillCombinedMultipleData_doesNotPassRestrictItemPolicy() async throws { + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + organizationId: "1", + type: .card(.fixture()), + ) + policyService.policyAppliesToUserPolicies = [ + .fixture(organizationId: "1"), + ] + + let result = await subject.prepareSearchAutofillCombinedMultipleData( + from: [ + .fixture(type: .card), + ], + filter: VaultListFilter(searchText: "example"), + withFido2Credentials: nil, + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + ]) + XCTAssertNotNil(result) + } + + /// `prepareSearchAutofillCombinedMultipleData(from:filter:withFido2Credentials:)` returns the + /// prepared data filtering out cipher as it's deleted. + @MainActor + func test_prepareSearchAutofillCombinedMultipleData_deletedCipher() async throws { + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + name: "Example Site", + deletedDate: .now, + ) + + 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 filtering out cipher as it's not a login type. + @MainActor + func test_prepareSearchAutofillCombinedMultipleData_nonLoginCipher() async throws { + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + name: "Example Card", + type: .card(.fixture()), + ) + + let result = await subject.prepareSearchAutofillCombinedMultipleData( + from: [ + .fixture(type: .card), + ], + filter: VaultListFilter(searchText: "example"), + withFido2Credentials: nil, + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + ]) + XCTAssertNotNil(result) + } + + /// `prepareSearchAutofillCombinedMultipleData(from:filter:withFido2Credentials:)` returns the + /// prepared data filtering out cipher as it doesn't have any copyable login fields. + @MainActor + func test_prepareSearchAutofillCombinedMultipleData_noCopyableLoginFields() async throws { + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + login: .fixture( + hasFido2: false, + uris: [.fixture(uri: "https://example.com", match: .exact)], + ), + name: "Example Site", + copyableFields: [], + ) + + 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 filtering out cipher as it doesn't match the search query. + @MainActor + func test_prepareSearchAutofillCombinedMultipleData_nonMatchingSearchQuery() async throws { + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + login: .fixture( + hasFido2: false, + uris: [.fixture(uri: "https://other.com", match: .exact)], + ), + name: "Other Site", + copyableFields: [.loginPassword], + ) + + let result = await subject.prepareSearchAutofillCombinedMultipleData( + from: [ + .fixture( + login: .fixture( + uris: [.fixture(uri: "https://other.com", match: .exact)], + ), + ), + ], + filter: VaultListFilter(searchText: "example"), + withFido2Credentials: nil, + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + ]) + XCTAssertNotNil(result) + } + + /// `prepareSearchData(from:filter:)` returns `nil` when no ciphers passed. + func test_prepareSearchData_noCiphers() async throws { + let result = await subject.prepareSearchData( + from: [], + filter: VaultListFilter(searchText: "example"), + ) + XCTAssertNil(result) + } + + /// `prepareSearchData(from:filter:)` returns `nil` when filter has no search text. + func test_prepareSearchData_noSearchText() async throws { + let result = await subject.prepareSearchData( + from: [.fixture()], + filter: VaultListFilter(), + ) + XCTAssertNil(result) + } + + /// `prepareSearchData(from:filter:)` returns `nil` when filter has empty search text. + func test_prepareSearchData_emptySearchText() async throws { + let result = await subject.prepareSearchData( + from: [.fixture()], + filter: VaultListFilter(searchText: ""), + ) + XCTAssertNil(result) + } + + /// `prepareSearchData(from:filter:)` returns the prepared data for a cipher matching search query. + func test_prepareSearchData_returnsPreparedData() async throws { + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + name: "Example Site", + ) + + let result = await subject.prepareSearchData( + from: [.fixture()], + filter: VaultListFilter(searchText: "example"), + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + "addSearchResultItem", + ]) + XCTAssertNotNil(result) + } + + /// `prepareSearchData(from:filter:)` returns the prepared data filtering out cipher + /// as it doesn't pass restrict item type policy. + @MainActor + func test_prepareSearchData_doesNotPassRestrictItemPolicy() async throws { + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + organizationId: "1", + type: .card(.fixture()), + ) + policyService.policyAppliesToUserPolicies = [ + .fixture(organizationId: "1"), + ] + + let result = await subject.prepareSearchData( + from: [.fixture(type: .card)], + filter: VaultListFilter(searchText: "example"), + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + ]) + XCTAssertNotNil(result) + } + + /// `prepareSearchData(from:filter:)` returns the prepared data filtering out cipher + /// as it's deleted and filter group is not trash. + @MainActor + func test_prepareSearchData_deletedCipherNotTrashGroup() async throws { + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + name: "Example Site", + deletedDate: .now, + ) + + let result = await subject.prepareSearchData( + from: [.fixture()], + filter: VaultListFilter(group: .login, searchText: "example"), + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + ]) + XCTAssertNotNil(result) + } + + /// `prepareSearchData(from:filter:)` returns the prepared data including deleted cipher + /// when filter group is trash. + @MainActor + func test_prepareSearchData_deletedCipherTrashGroup() async throws { + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + name: "Example Site", + deletedDate: .now, + ) + + let result = await subject.prepareSearchData( + from: [.fixture(deletedDate: .now)], + filter: VaultListFilter(group: .trash, searchText: "example"), + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + "addSearchResultItem", + ]) + XCTAssertNotNil(result) + } + + /// `prepareSearchData(from:filter:)` returns the prepared data filtering out cipher + /// as it doesn't belong to the filter group. + @MainActor + func test_prepareSearchData_cipherDoesNotBelongToGroup() async throws { + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + name: "Example Card", + type: .card(.fixture()), + ) + + let result = await subject.prepareSearchData( + from: [.fixture(type: .card)], + filter: VaultListFilter(group: .login, searchText: "example"), + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + ]) + XCTAssertNotNil(result) + } + + /// `prepareSearchData(from:filter:)` returns the prepared data including cipher + /// when it belongs to the filter group. + @MainActor + func test_prepareSearchData_cipherBelongsToGroup() async throws { + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + name: "Example Login", + type: .login(.fixture()), + ) + + let result = await subject.prepareSearchData( + from: [.fixture(login: .fixture(), type: .login)], + filter: VaultListFilter(group: .login, searchText: "example"), + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + "addSearchResultItem", + ]) + XCTAssertNotNil(result) + } + + /// `prepareSearchData(from:filter:)` returns the prepared data for all ciphers + /// when no group filter is specified. + @MainActor + func test_prepareSearchData_noGroupFilter() async throws { + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + name: "Example Card", + type: .card(.fixture()), + ) + + let result = await subject.prepareSearchData( + from: [.fixture(type: .card)], + filter: VaultListFilter(searchText: "example"), + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + "addSearchResultItem", + ]) + XCTAssertNotNil(result) + } + + /// `prepareSearchData(from:filter:)` returns the prepared data filtering out cipher + /// as it doesn't match the search query. + @MainActor + func test_prepareSearchData_nonMatchingSearchQuery() async throws { + ciphersClientWrapperService.decryptAndProcessCiphersInBatchOnCipherParameterToPass = .fixture( + id: "1", + name: "Other Site", + ) + + let result = await subject.prepareSearchData( + from: [.fixture()], + filter: VaultListFilter(searchText: "nonexistent"), + ) + + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "prepareRestrictItemsPolicyOrganizations", + "addSearchResultItem", + ]) + XCTAssertNotNil(result) + } +} // swiftlint:disable:this file_length diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparator.swift b/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparator.swift index 171c4c8e1..2644554b1 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparator.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparator.swift @@ -70,6 +70,30 @@ protocol VaultListDataPreparator { // sourcery: AutoMockable folders: [Folder], filter: VaultListFilter, ) async -> VaultListPreparedData? + + /// Prepares search data for the autofill's data on passwords + Fido2 combined in multiple sections + /// vault list bulider. + /// - Parameters: + /// - ciphers: An array of `Cipher` objects to be processed. + /// - filter: A `VaultListFilter` object that defines the filtering criteria for the vault list. + /// - Returns: An optional `VaultListPreparedData` object containing the prepared data for the vault list. + /// Returns `nil` if the vault is empty. + func prepareSearchAutofillCombinedMultipleData( + from ciphers: [Cipher], + filter: VaultListFilter, + withFido2Credentials fido2Credentials: [CipherView]?, + ) async -> VaultListPreparedData? + + /// Prepares search data for the vault list builder. + /// - Parameters: + /// - ciphers: An array of `Cipher` objects to be processed. + /// - filter: A `VaultListFilter` object that defines the filtering criteria for the vault list. + /// - Returns: An optional `VaultListPreparedData` object containing the prepared data for the vault list. + /// Returns `nil` if the vault is empty. + func prepareSearchData( + from ciphers: [Cipher], + filter: VaultListFilter, + ) async -> VaultListPreparedData? } /// Default implementation of `VaultListDataPreparator`. @@ -106,15 +130,15 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { let cipherMatchingHelper = await cipherMatchingHelperFactory.make(uri: uri) var preparedDataBuilder = vaultListPreparedDataBuilderFactory.make() - let restrictedOrganizationIds = await prepareRestrictedOrganizationIds(builder: preparedDataBuilder) - await ciphersClientWrapperService.decryptAndProcessCiphersInBatch( + await decryptAndProcessCiphersInBatch( ciphers: ciphers, + filter: filter, + preparedDataBuilder: preparedDataBuilder, ) { decryptedCipher in guard decryptedCipher.type.isLogin, decryptedCipher.deletedDate == nil, - decryptedCipher.canBeUsedInBasicLoginAutofill, - decryptedCipher.passesRestrictItemTypesPolicy(restrictedOrganizationIds) else { + decryptedCipher.canBeUsedInBasicLoginAutofill else { return } @@ -141,14 +165,14 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { let cipherMatchingHelper = await cipherMatchingHelperFactory.make(uri: uri) var preparedDataBuilder = vaultListPreparedDataBuilderFactory.make() - let restrictedOrganizationIds = await prepareRestrictedOrganizationIds(builder: preparedDataBuilder) - await ciphersClientWrapperService.decryptAndProcessCiphersInBatch( + await decryptAndProcessCiphersInBatch( ciphers: ciphers, + filter: filter, + preparedDataBuilder: preparedDataBuilder, ) { decryptedCipher in guard decryptedCipher.type.isLogin, - decryptedCipher.deletedDate == nil, - decryptedCipher.passesRestrictItemTypesPolicy(restrictedOrganizationIds) else { + decryptedCipher.deletedDate == nil else { return } @@ -157,18 +181,10 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { return } - if let fido2Credentials, - decryptedCipher.type.loginListView?.hasFido2 == true, - fido2Credentials.contains(where: { $0.id == decryptedCipher.id }) { - preparedDataBuilder = await preparedDataBuilder.addFido2Item(cipher: decryptedCipher) - } - - if decryptedCipher.canBeUsedInBasicLoginAutofill { - preparedDataBuilder = await preparedDataBuilder.addItem( - forGroup: .login, - with: decryptedCipher, - ) - } + preparedDataBuilder = await preparedDataBuilder.addItemsForCombinedMultipleSections( + decryptedCipher: decryptedCipher, + withFido2Credentials: fido2Credentials, + ) } return preparedDataBuilder.build() @@ -185,14 +201,14 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { let cipherMatchingHelper = await cipherMatchingHelperFactory.make(uri: uri) var preparedDataBuilder = vaultListPreparedDataBuilderFactory.make() - let restrictedOrganizationIds = await prepareRestrictedOrganizationIds(builder: preparedDataBuilder) - await ciphersClientWrapperService.decryptAndProcessCiphersInBatch( + await decryptAndProcessCiphersInBatch( ciphers: ciphers, + filter: filter, + preparedDataBuilder: preparedDataBuilder, ) { decryptedCipher in guard decryptedCipher.type.isLogin, - decryptedCipher.deletedDate == nil, - decryptedCipher.passesRestrictItemTypesPolicy(restrictedOrganizationIds) else { + decryptedCipher.deletedDate == nil else { return } @@ -226,26 +242,20 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { } var preparedDataBuilder = vaultListPreparedDataBuilderFactory.make() - let restrictedOrganizationIds = await prepareRestrictedOrganizationIds(builder: preparedDataBuilder) - - preparedDataBuilder = preparedDataBuilder .prepareFolders(folders: folders, filterType: filter.filterType) .prepareCollections(collections: collections, filterType: filter.filterType) - await ciphersClientWrapperService.decryptAndProcessCiphersInBatch( + await decryptAndProcessCiphersInBatch( ciphers: ciphers, + filter: filter, + preparedDataBuilder: preparedDataBuilder, ) { decryptedCipher in - guard filter.filterType.cipherFilter(decryptedCipher), - decryptedCipher.passesRestrictItemTypesPolicy(restrictedOrganizationIds) else { - return - } - guard decryptedCipher.deletedDate == nil else { preparedDataBuilder = preparedDataBuilder.incrementCipherDeletedCount() return } - if filter.addTOTPGroup { + if filter.options.contains(.addTOTPGroup) { preparedDataBuilder = await preparedDataBuilder.incrementTOTPCount(cipher: decryptedCipher) } @@ -272,20 +282,14 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { } var preparedDataBuilder = vaultListPreparedDataBuilderFactory.make() - let restrictedOrganizationIds: [String] = await prepareRestrictedOrganizationIds(builder: preparedDataBuilder) - - preparedDataBuilder = preparedDataBuilder .prepareFolders(folders: folders, filterType: filter.filterType) .prepareCollections(collections: collections, filterType: filter.filterType) - await ciphersClientWrapperService.decryptAndProcessCiphersInBatch( + await decryptAndProcessCiphersInBatch( ciphers: ciphers, + filter: filter, + preparedDataBuilder: preparedDataBuilder, ) { decryptedCipher in - guard filter.filterType.cipherFilter(decryptedCipher), - decryptedCipher.passesRestrictItemTypesPolicy(restrictedOrganizationIds) else { - return - } - if filter.group != .trash, decryptedCipher.deletedDate != nil { return } @@ -308,8 +312,107 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { return preparedDataBuilder.build() } + func prepareSearchAutofillCombinedMultipleData( + from ciphers: [Cipher], + filter: VaultListFilter, + withFido2Credentials fido2Credentials: [CipherView]?, + ) async -> VaultListPreparedData? { + guard !ciphers.isEmpty, let searchText = filter.searchText, !searchText.isEmpty else { + return nil + } + + var preparedDataBuilder = vaultListPreparedDataBuilderFactory.make() + + await decryptAndProcessCiphersInBatch( + ciphers: ciphers, + filter: filter, + preparedDataBuilder: preparedDataBuilder, + ) { decryptedCipher in + guard decryptedCipher.deletedDate == nil, + decryptedCipher.belongsToGroup(.login) else { + return + } + + let matchResult = decryptedCipher.matchesSearchQuery(searchText) + guard matchResult != .none else { + return + } + + preparedDataBuilder = await preparedDataBuilder.addItemsForCombinedMultipleSections( + decryptedCipher: decryptedCipher, + withFido2Credentials: fido2Credentials, + ) + } + + return preparedDataBuilder.build() + } + + func prepareSearchData( + from ciphers: [Cipher], + filter: VaultListFilter, + ) async -> VaultListPreparedData? { + guard !ciphers.isEmpty, let searchText = filter.searchText, !searchText.isEmpty else { + return nil + } + + var preparedDataBuilder = vaultListPreparedDataBuilderFactory.make() + + await decryptAndProcessCiphersInBatch( + ciphers: ciphers, + filter: filter, + preparedDataBuilder: preparedDataBuilder, + ) { decryptedCipher in + if decryptedCipher.deletedDate != nil { + guard let group = filter.group, group == .trash else { + return + } + } + + if let group = filter.group, !decryptedCipher.belongsToGroup(group) { + return + } + + let matchResult = decryptedCipher.matchesSearchQuery(searchText) + preparedDataBuilder = await preparedDataBuilder.addSearchResultItem( + withMatchResult: matchResult, + cipher: decryptedCipher, + for: filter.group, + ) + } + + return preparedDataBuilder.build() + } + // MARK: Private + /// Decrypts `ciphers` in batch and perform process on each decrypted cipher of the batch. + /// This consolidates common cipher filtering on all data preparation. + /// + /// - Parameters: + /// - ciphers: The ciphers to decrypt and process + /// - filter: A `VaultListFilter` object that defines the filtering criteria for the vault list + /// - preparedDataBuilder: The data builder that is used to prepare the ciphers data. + /// - onCipher: The action to perform on each decrypted cipher. + func decryptAndProcessCiphersInBatch( + ciphers: [Cipher], + filter: VaultListFilter, + preparedDataBuilder: VaultListPreparedDataBuilder, + onCipher: (CipherListView) async throws -> Void, + ) async { + let restrictedOrganizationIds: [String] = await prepareRestrictedOrganizationIds(builder: preparedDataBuilder) + + await ciphersClientWrapperService.decryptAndProcessCiphersInBatch( + ciphers: ciphers, + ) { decryptedCipher in + guard filter.filterType.cipherFilter(decryptedCipher), + decryptedCipher.passesRestrictItemTypesPolicy(restrictedOrganizationIds) else { + return + } + + try await onCipher(decryptedCipher) + } + } + /// Returns the restricted organization IDs for the `.restrictItemTypes` policy and adds them /// to the builder. /// - Returns: The restricted organization IDs. @@ -319,3 +422,26 @@ struct DefaultVaultListDataPreparator: VaultListDataPreparator { return restrictedOrganizationIds } } + +// MARK: VaultListPreparedDataBuilder + +private extension VaultListPreparedDataBuilder { + func addItemsForCombinedMultipleSections( + decryptedCipher: CipherListView, + withFido2Credentials fido2Credentials: [CipherView]?, + ) async -> VaultListPreparedDataBuilder { + if let fido2Credentials, + decryptedCipher.type.loginListView?.hasFido2 == true, + fido2Credentials.contains(where: { $0.id == decryptedCipher.id }) { + _ = await addFido2Item(cipher: decryptedCipher) + } + + if decryptedCipher.canBeUsedInBasicLoginAutofill { + _ = await addItem( + forGroup: .login, + with: decryptedCipher, + ) + } + return self + } +} // swiftlint:disable:this file_length diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparatorTests.swift b/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparatorTests.swift index ac6156a51..b9eb4f295 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparatorTests.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListDataPreparatorTests.swift @@ -561,13 +561,13 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi from: [.fixture()], collections: [.fixture(id: "1"), .fixture(id: "2")], folders: [.fixture(id: "1"), .fixture(id: "2"), .fixture(id: "3")], - filter: VaultListFilter(addTOTPGroup: true), + filter: VaultListFilter(options: [.addTOTPGroup]), ) XCTAssertEqual(mockCallOrderHelper.callOrder, [ - "prepareRestrictItemsPolicyOrganizations", "prepareFolders", "prepareCollections", + "prepareRestrictItemsPolicyOrganizations", "incrementTOTPCount", "addCipherDecryptionFailure", "addFolderItem", @@ -588,13 +588,13 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi from: [.fixture()], collections: [.fixture(id: "1"), .fixture(id: "2")], folders: [.fixture(id: "1"), .fixture(id: "2"), .fixture(id: "3")], - filter: VaultListFilter(addTOTPGroup: false), + filter: VaultListFilter(options: []), ) XCTAssertEqual(mockCallOrderHelper.callOrder, [ - "prepareRestrictItemsPolicyOrganizations", "prepareFolders", "prepareCollections", + "prepareRestrictItemsPolicyOrganizations", "addCipherDecryptionFailure", "addFolderItem", "addFavoriteItem", @@ -617,13 +617,13 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi from: [.fixture()], collections: [.fixture(id: "1"), .fixture(id: "2")], folders: [.fixture(id: "1"), .fixture(id: "2"), .fixture(id: "3")], - filter: VaultListFilter(addTOTPGroup: true, filterType: .myVault), + filter: VaultListFilter(filterType: .myVault, options: [.addTOTPGroup]), ) XCTAssertEqual(mockCallOrderHelper.callOrder, [ - "prepareRestrictItemsPolicyOrganizations", "prepareFolders", "prepareCollections", + "prepareRestrictItemsPolicyOrganizations", ]) XCTAssertNotNil(result) } @@ -647,9 +647,9 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi ) XCTAssertEqual(mockCallOrderHelper.callOrder, [ - "prepareRestrictItemsPolicyOrganizations", "prepareFolders", "prepareCollections", + "prepareRestrictItemsPolicyOrganizations", ]) XCTAssertNotNil(result) } @@ -669,13 +669,13 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi from: [.fixture(organizationId: "1", type: .card)], collections: [.fixture(id: "1"), .fixture(id: "2")], folders: [.fixture(id: "1"), .fixture(id: "2"), .fixture(id: "3")], - filter: VaultListFilter(addTOTPGroup: true), + filter: VaultListFilter(options: [.addTOTPGroup]), ) XCTAssertEqual(mockCallOrderHelper.callOrder, [ - "prepareRestrictItemsPolicyOrganizations", "prepareFolders", "prepareCollections", + "prepareRestrictItemsPolicyOrganizations", "incrementTOTPCount", "addCipherDecryptionFailure", "addFolderItem", @@ -704,9 +704,9 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi ) XCTAssertEqual(mockCallOrderHelper.callOrder, [ - "prepareRestrictItemsPolicyOrganizations", "prepareFolders", "prepareCollections", + "prepareRestrictItemsPolicyOrganizations", "incrementCipherDeletedCount", ]) XCTAssertNotNil(result) @@ -740,9 +740,9 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi ) XCTAssertEqual(mockCallOrderHelper.callOrder, [ - "prepareRestrictItemsPolicyOrganizations", "prepareFolders", "prepareCollections", + "prepareRestrictItemsPolicyOrganizations", ]) XCTAssertNotNil(result) } @@ -766,9 +766,9 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi ) XCTAssertEqual(mockCallOrderHelper.callOrder, [ - "prepareRestrictItemsPolicyOrganizations", "prepareFolders", "prepareCollections", + "prepareRestrictItemsPolicyOrganizations", ]) XCTAssertNotNil(result) } @@ -786,9 +786,9 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi // should not call incrementCollectionCount and addItemForGroup XCTAssertEqual(mockCallOrderHelper.callOrder, [ - "prepareRestrictItemsPolicyOrganizations", "prepareFolders", "prepareCollections", + "prepareRestrictItemsPolicyOrganizations", ]) XCTAssertNotNil(result) } @@ -808,9 +808,9 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi ) XCTAssertEqual(mockCallOrderHelper.callOrder, [ - "prepareRestrictItemsPolicyOrganizations", "prepareFolders", "prepareCollections", + "prepareRestrictItemsPolicyOrganizations", "addFolderItem", "addItemForGroup", ]) @@ -832,9 +832,9 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi ) XCTAssertEqual(mockCallOrderHelper.callOrder, [ - "prepareRestrictItemsPolicyOrganizations", "prepareFolders", "prepareCollections", + "prepareRestrictItemsPolicyOrganizations", "incrementCollectionCount", "addItemForGroup", ]) @@ -875,9 +875,9 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi ) XCTAssertEqual(mockCallOrderHelper.callOrder, [ - "prepareRestrictItemsPolicyOrganizations", "prepareFolders", "prepareCollections", + "prepareRestrictItemsPolicyOrganizations", "addItemForGroup", ]) XCTAssertNotNil(result) @@ -1027,9 +1027,9 @@ class VaultListDataPreparatorTests: BitwardenTestCase { // swiftlint:disable:thi ) XCTAssertEqual(mockCallOrderHelper.callOrder, [ - "prepareRestrictItemsPolicyOrganizations", "prepareFolders", "prepareCollections", + "prepareRestrictItemsPolicyOrganizations", "addItemForGroup", ]) XCTAssertNotNil(result) diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/MainVaultListDirectorStrategy.swift b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/MainVaultListDirectorStrategy.swift index 95220d8da..44720bc78 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/MainVaultListDirectorStrategy.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/MainVaultListDirectorStrategy.swift @@ -62,7 +62,7 @@ struct MainVaultListDirectorStrategy: VaultListDirectorStrategy { var builder = builderFactory.make(withData: preparedData) - if filter.addTOTPGroup { + if filter.options.contains(.addTOTPGroup) { builder = builder.addTOTPSection() } @@ -73,7 +73,7 @@ struct MainVaultListDirectorStrategy: VaultListDirectorStrategy { .addCollectionsSection() .addCipherDecryptionFailureIds() - if filter.addTrashGroup { + if filter.options.contains(.addTrashGroup) { builder = builder.addTrashSection() } diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/MainVaultListDirectorStrategyTests.swift b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/MainVaultListDirectorStrategyTests.swift index 2a9522fbd..01066d39e 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/MainVaultListDirectorStrategyTests.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/MainVaultListDirectorStrategyTests.swift @@ -100,8 +100,7 @@ class MainVaultListDirectorStrategyTests: BitwardenTestCase { var iteratorPublisher = try await subject.build( filter: VaultListFilter( - addTOTPGroup: true, - addTrashGroup: true, + options: [.addTOTPGroup, .addTrashGroup], ), ).makeAsyncIterator() let result = try await iteratorPublisher.next() @@ -137,8 +136,7 @@ class MainVaultListDirectorStrategyTests: BitwardenTestCase { var iteratorPublisher = try await subject.build( filter: VaultListFilter( - addTOTPGroup: true, - addTrashGroup: false, + options: [.addTOTPGroup], ), ).makeAsyncIterator() let result = try await iteratorPublisher.next() @@ -174,8 +172,7 @@ class MainVaultListDirectorStrategyTests: BitwardenTestCase { var iteratorPublisher = try await subject.build( filter: VaultListFilter( - addTOTPGroup: false, - addTrashGroup: true, + options: [.addTrashGroup], ), ).makeAsyncIterator() let result = try await iteratorPublisher.next() @@ -210,8 +207,7 @@ class MainVaultListDirectorStrategyTests: BitwardenTestCase { var iteratorPublisher = try await subject.build( filter: VaultListFilter( - addTOTPGroup: false, - addTrashGroup: false, + options: [], ), ).makeAsyncIterator() let result = try await iteratorPublisher.next() diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/SearchCombinedMultipleAutofillListDirectorStrategy.swift b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/SearchCombinedMultipleAutofillListDirectorStrategy.swift new file mode 100644 index 000000000..b0c8cb229 --- /dev/null +++ b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/SearchCombinedMultipleAutofillListDirectorStrategy.swift @@ -0,0 +1,67 @@ +import BitwardenKit +import BitwardenSdk +import Combine + +// MARK: - SearchVaultListDirectorStrategy + +/// The director strategy to be used to build the search Autofill's passwords + Fido2 combined in multiple sections. +/// This would show two sections where passwords and Fido2 credentials are displayed in each section accordingly. +struct SearchCombinedMultipleAutofillListDirectorStrategy: VaultListDirectorStrategy { + // MARK: Properties + + /// The factory for creating vault list builders. + let builderFactory: VaultListSectionsBuilderFactory + /// The service used to manage syncing and updates to the user's ciphers. + let cipherService: CipherService + /// A helper to be used on Fido2 flows that requires user interaction and extends the capabilities + /// of the `Fido2UserInterface` from the SDK. + let fido2UserInterfaceHelper: Fido2UserInterfaceHelper + /// The helper used to prepare data for the vault list builder. + let vaultListDataPreparator: VaultListDataPreparator + + func build( + filter: VaultListFilter, + ) async throws -> AsyncThrowingPublisher> { + try await Publishers.CombineLatest( + cipherService.ciphersPublisher(), + fido2UserInterfaceHelper.availableCredentialsForAuthenticationPublisher(), + ) + .asyncTryMap { ciphers, availableFido2Credentials in + try await build( + from: ciphers, + filter: filter, + withFido2Credentials: availableFido2Credentials, + ) + } + .eraseToAnyPublisher() + .values + } + + // MARK: Private methods + + /// Builds the vault list sections. + /// - Parameters: + /// - ciphers: Ciphers to filter and include in the sections. + /// - filter: Filter to be used to build the sections. + /// - withFido2Credentials: Available Fido2 credentials to build the vault list section. + /// - Returns: Sections to be displayed to the user. + func build( + from ciphers: [Cipher], + filter: VaultListFilter, + withFido2Credentials fido2Credentials: [CipherView]?, + ) async throws -> VaultListData { + guard !ciphers.isEmpty else { return VaultListData() } + + guard let preparedData = await vaultListDataPreparator.prepareSearchAutofillCombinedMultipleData( + from: ciphers, + filter: filter, + withFido2Credentials: fido2Credentials, + ) else { + return VaultListData() + } + + return builderFactory.make(withData: preparedData) + .addAutofillCombinedMultipleSection(searchText: filter.searchText, rpID: filter.rpID) + .build() + } +} diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/SearchCombinedMultipleAutofillListDirectorStrategyTests.swift b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/SearchCombinedMultipleAutofillListDirectorStrategyTests.swift new file mode 100644 index 000000000..3db78d95d --- /dev/null +++ b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/SearchCombinedMultipleAutofillListDirectorStrategyTests.swift @@ -0,0 +1,127 @@ +import BitwardenSdk +import XCTest + +@testable import BitwardenShared + +// MARK: - SearchCombinedMultipleAutofillListDirectorStrategyTests + +class SearchCombinedMultipleAutofillListDirectorStrategyTests: BitwardenTestCase { + // swiftlint:disable:previous type_name + + // MARK: Properties + + var cipherService: MockCipherService! + var fido2UserInterfaceHelper: MockFido2UserInterfaceHelper! + var mockCallOrderHelper: MockCallOrderHelper! + var subject: SearchCombinedMultipleAutofillListDirectorStrategy! + var vaultListDataPreparator: MockVaultListDataPreparator! + var vaultListSectionsBuilder: MockVaultListSectionsBuilder! + var vaultListSectionsBuilderFactory: MockVaultListSectionsBuilderFactory! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + cipherService = MockCipherService() + fido2UserInterfaceHelper = MockFido2UserInterfaceHelper() + vaultListDataPreparator = MockVaultListDataPreparator() + + vaultListSectionsBuilder = MockVaultListSectionsBuilder() + mockCallOrderHelper = vaultListSectionsBuilder.setUpCallOrderHelper() + vaultListSectionsBuilderFactory = MockVaultListSectionsBuilderFactory() + vaultListSectionsBuilderFactory.makeReturnValue = vaultListSectionsBuilder + + subject = SearchCombinedMultipleAutofillListDirectorStrategy( + builderFactory: vaultListSectionsBuilderFactory, + cipherService: cipherService, + fido2UserInterfaceHelper: fido2UserInterfaceHelper, + vaultListDataPreparator: vaultListDataPreparator, + ) + } + + override func tearDown() { + super.tearDown() + + cipherService = nil + fido2UserInterfaceHelper = nil + mockCallOrderHelper = nil + vaultListDataPreparator = nil + vaultListSectionsBuilder = nil + vaultListSectionsBuilderFactory = nil + subject = nil + } + + // MARK: Tests + + /// `build(filter:)` returns empty vault list data when there are no ciphers. + @MainActor + func test_build_emptyCiphers() async throws { + cipherService.ciphersSubject.value = [] + fido2UserInterfaceHelper.credentialsForAuthenticationSubject.value = nil + + var iteratorPublisher = try await subject.build(filter: VaultListFilter()).makeAsyncIterator() + let vaultListData = try await iteratorPublisher.next() + + XCTAssertEqual(vaultListData, VaultListData()) + } + + /// `build(filter:)` returns empty when preparing search data fails to return data. + @MainActor + func test_build_returnsEmptyWhenPreparingSearchDataFailsToReturnData() async throws { + cipherService.ciphersSubject.value = [.fixture()] + fido2UserInterfaceHelper.credentialsForAuthenticationSubject.value = nil + + vaultListDataPreparator.prepareSearchAutofillCombinedMultipleDataReturnValue = nil + + var iteratorPublisher = try await subject.build( + filter: VaultListFilter(searchText: "test"), + ).makeAsyncIterator() + let vaultListData = try await iteratorPublisher.next() + + XCTAssertEqual(vaultListData, VaultListData()) + } + + /// `build(filter:)` returns empty when preparing search data fails to return data with Fido2 credentials. + @MainActor + func test_build_returnsEmptyWhenPreparingSearchDataFailsToReturnDataWithFido2Credentials() async throws { + cipherService.ciphersSubject.value = [.fixture()] + fido2UserInterfaceHelper.credentialsForAuthenticationSubject.value = [.fixture()] + + vaultListDataPreparator.prepareSearchAutofillCombinedMultipleDataReturnValue = nil + + var iteratorPublisher = try await subject.build( + filter: VaultListFilter(rpID: "example.com", searchText: "test"), + ).makeAsyncIterator() + let vaultListData = try await iteratorPublisher.next() + + XCTAssertEqual(vaultListData, VaultListData()) + } + + /// `build(filter:)` passes the correct rpID and search text to the sections builder. + @MainActor + func test_build_passesRPIDAndSearchTextToSectionsBuilder() async throws { + cipherService.ciphersSubject.value = [.fixture()] + fido2UserInterfaceHelper.credentialsForAuthenticationSubject.value = nil + + vaultListDataPreparator.prepareSearchAutofillCombinedMultipleDataReturnValue = VaultListPreparedData() + + vaultListSectionsBuilder.buildReturnValue = VaultListData() + + let rpID = "example.com" + let searchText = "test query" + var iteratorPublisher = try await subject.build( + filter: VaultListFilter(rpID: rpID, searchText: searchText), + ).makeAsyncIterator() + _ = try await iteratorPublisher.next() + + XCTAssertEqual( + vaultListSectionsBuilder.addAutofillCombinedMultipleSectionReceivedArguments?.searchText, + searchText, + ) + XCTAssertEqual( + vaultListSectionsBuilder.addAutofillCombinedMultipleSectionReceivedArguments?.rpID, + rpID, + ) + } +} diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/SearchVaultListDirectorStrategy.swift b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/SearchVaultListDirectorStrategy.swift new file mode 100644 index 000000000..316f69ff6 --- /dev/null +++ b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/SearchVaultListDirectorStrategy.swift @@ -0,0 +1,53 @@ +import BitwardenKit +import BitwardenSdk +import Combine + +// MARK: - SearchVaultListDirectorStrategy + +/// The director strategy to be used to build the search vault sections. +struct SearchVaultListDirectorStrategy: VaultListDirectorStrategy { + // MARK: Properties + + /// The factory for creating vault list builders. + let builderFactory: VaultListSectionsBuilderFactory + /// The service used to manage syncing and updates to the user's ciphers. + let cipherService: CipherService + /// The helper used to prepare data for the vault list builder. + let vaultListDataPreparator: VaultListDataPreparator + + func build( + filter: VaultListFilter, + ) async throws -> AsyncThrowingPublisher> { + try await cipherService.ciphersPublisher() + .asyncTryMap { ciphers in + try await build(from: ciphers, filter: filter) + } + .eraseToAnyPublisher() + .values + } + + // MARK: Private methods + + /// Builds the vault list sections. + /// - Parameters: + /// - ciphers: Ciphers to filter and include in the sections. + /// - filter: Filter to be used to build the sections. + /// - Returns: Sections to be displayed to the user. + func build( + from ciphers: [Cipher], + filter: VaultListFilter, + ) async throws -> VaultListData { + guard !ciphers.isEmpty else { return VaultListData() } + + guard let preparedData = await vaultListDataPreparator.prepareSearchData( + from: ciphers, + filter: filter, + ) else { + return VaultListData() + } + + return builderFactory.make(withData: preparedData) + .addSearchResultsSection(options: filter.options) + .build() + } +} diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/SearchVaultListDirectorStrategyTests.swift b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/SearchVaultListDirectorStrategyTests.swift new file mode 100644 index 000000000..479d7b992 --- /dev/null +++ b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/SearchVaultListDirectorStrategyTests.swift @@ -0,0 +1,102 @@ +import BitwardenSdk +import Combine +import XCTest + +@testable import BitwardenShared + +// MARK: - SearchVaultListDirectorStrategyTests + +class SearchVaultListDirectorStrategyTests: BitwardenTestCase { + // MARK: Properties + + var cipherService: MockCipherService! + var mockCallOrderHelper: MockCallOrderHelper! + var subject: SearchVaultListDirectorStrategy! + var vaultListDataPreparator: MockVaultListDataPreparator! + var vaultListSectionsBuilder: MockVaultListSectionsBuilder! + var vaultListSectionsBuilderFactory: MockVaultListSectionsBuilderFactory! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + cipherService = MockCipherService() + vaultListDataPreparator = MockVaultListDataPreparator() + + vaultListSectionsBuilder = MockVaultListSectionsBuilder() + mockCallOrderHelper = vaultListSectionsBuilder.setUpCallOrderHelper() + vaultListSectionsBuilderFactory = MockVaultListSectionsBuilderFactory() + vaultListSectionsBuilderFactory.makeReturnValue = vaultListSectionsBuilder + + subject = SearchVaultListDirectorStrategy( + builderFactory: vaultListSectionsBuilderFactory, + cipherService: cipherService, + vaultListDataPreparator: vaultListDataPreparator, + ) + } + + override func tearDown() { + super.tearDown() + + cipherService = nil + mockCallOrderHelper = nil + vaultListDataPreparator = nil + vaultListSectionsBuilder = nil + vaultListSectionsBuilderFactory = nil + subject = nil + } + + // MARK: Tests + + /// `build(filter:)` returns empty vault list data when there are no ciphers. + @MainActor + func test_build_emptyCiphers() async throws { + cipherService.ciphersSubject.value = [] + + var iteratorPublisher = try await subject.build(filter: VaultListFilter()).makeAsyncIterator() + let vaultListData = try await iteratorPublisher.next() + + XCTAssertEqual(vaultListData, VaultListData()) + } + + /// `build(filter:)` returns empty when preparing search data fails to return data. + @MainActor + func test_build_returnsEmptyWhenPreparingSearchDataFailsToReturnData() async throws { + cipherService.ciphersSubject.value = [.fixture()] + + vaultListDataPreparator.prepareSearchDataReturnValue = nil + + var iteratorPublisher = try await subject.build( + filter: VaultListFilter(searchText: "test"), + ).makeAsyncIterator() + let vaultListData = try await iteratorPublisher.next() + + XCTAssertEqual(vaultListData, VaultListData()) + } + + /// `build(filter:)` returns the search results sections built. + @MainActor + func test_build_returnsSectionsBuiltForSearch() async throws { + cipherService.ciphersSubject.value = [.fixture()] + + vaultListDataPreparator.prepareSearchDataReturnValue = VaultListPreparedData() + + vaultListSectionsBuilder.buildReturnValue = VaultListData( + sections: [ + VaultListSection(id: "SearchResults", items: [.fixture()], name: ""), + ], + ) + + var iteratorPublisher = try await subject.build( + filter: VaultListFilter(searchText: "test"), + ).makeAsyncIterator() + let result = try await iteratorPublisher.next() + let vaultListData = try XCTUnwrap(result) + + XCTAssertEqual(vaultListData.sections.map(\.id), ["SearchResults"]) + XCTAssertEqual(mockCallOrderHelper.callOrder, [ + "addSearchResultsSection", + ]) + } +} diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/VaultListDirectorStrategy.swift b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/VaultListDirectorStrategy.swift index ee5f86941..ad5aa5ccc 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/VaultListDirectorStrategy.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/VaultListDirectorStrategy.swift @@ -28,6 +28,9 @@ import Combine /// combining passwords + Fido2 credentials in one section. /// - `CombinedMultipleAutofillVaultListDirectorStrategy`: Autofill extension vault list view /// combining passwords + Fido2 credentials in different sections. +/// - `SearchVaultListDirectorStrategy`: Search results for vault list views +/// - `SearchCombinedMultipleAutofillListDirectorStrategy`: Search results for Autofill extension vault list +/// combining passwords + Fido2 credentials in different sections. /// /// ## Example Usage /// ```swift diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/VaultListDirectorStrategyFactory.swift b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/VaultListDirectorStrategyFactory.swift index 8ce431753..90926a7ab 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/VaultListDirectorStrategyFactory.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/VaultListDirectorStrategyFactory.swift @@ -28,7 +28,23 @@ struct DefaultVaultListDirectorStrategyFactory: VaultListDirectorStrategyFactory /// The helper used to prepare data for the vault list builder. let vaultListDataPreparator: VaultListDataPreparator - func make(filter: VaultListFilter) -> VaultListDirectorStrategy { + func make(filter: VaultListFilter) -> VaultListDirectorStrategy { // swiftlint:disable:this function_body_length + guard filter.searchText == nil else { + if case .combinedMultipleSections = filter.mode { + return SearchCombinedMultipleAutofillListDirectorStrategy( + builderFactory: vaultListBuilderFactory, + cipherService: cipherService, + fido2UserInterfaceHelper: fido2UserInterfaceHelper, + vaultListDataPreparator: vaultListDataPreparator, + ) + } + return SearchVaultListDirectorStrategy( + builderFactory: vaultListBuilderFactory, + cipherService: cipherService, + vaultListDataPreparator: vaultListDataPreparator, + ) + } + switch filter.mode { case .combinedMultipleSections: return CombinedMultipleAutofillVaultListDirectorStrategy( diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/VaultListDirectorStrategyFactoryTests.swift b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/VaultListDirectorStrategyFactoryTests.swift index 375f3da0f..a19908397 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/VaultListDirectorStrategyFactoryTests.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListDirectors/VaultListDirectorStrategyFactoryTests.swift @@ -36,33 +36,71 @@ class VaultListDirectorStrategyFactoryTests: BitwardenTestCase { /// `make(filter:)` returns `CombinedMultipleAutofillVaultListDirectorStrategy` when /// filtering by mode `.combinedMultipleSections`. func test_make_returnsCombinedMultipleAutofillVaultListDirectorStrategy() { - let stragegy = subject.make(filter: VaultListFilter(mode: .combinedMultipleSections)) - XCTAssertTrue(stragegy is CombinedMultipleAutofillVaultListDirectorStrategy) + let strategy = subject.make(filter: VaultListFilter(mode: .combinedMultipleSections)) + XCTAssertTrue(strategy is CombinedMultipleAutofillVaultListDirectorStrategy) } /// `make(filter:)` returns `CombinedSingleAutofillVaultListDirectorStrategy` when /// filtering by mode `.combinedSingleSection`. func test_make_returnsCombinedSingleAutofillVaultListDirectorStrategy() { - let stragegy = subject.make(filter: VaultListFilter(mode: .combinedSingleSection)) - XCTAssertTrue(stragegy is CombinedSingleAutofillVaultListDirectorStrategy) + let strategy = subject.make(filter: VaultListFilter(mode: .combinedSingleSection)) + XCTAssertTrue(strategy is CombinedSingleAutofillVaultListDirectorStrategy) } /// `make(filter:)` returns `MainVaultListDirectorStrategy` when not filtering by group. func test_make_returnsMainVaultStrategy() { - let stragegy = subject.make(filter: VaultListFilter(group: nil)) - XCTAssertTrue(stragegy is MainVaultListDirectorStrategy) + let strategy = subject.make(filter: VaultListFilter(group: nil)) + XCTAssertTrue(strategy is MainVaultListDirectorStrategy) } /// `make(filter:)` returns `MainVaultListGroupDirectorStrategy` when filtering by group. func test_make_returnsMainVaultGroupStrategy() { - let stragegy = subject.make(filter: VaultListFilter(group: .login)) - XCTAssertTrue(stragegy is MainVaultListGroupDirectorStrategy) + let strategy = subject.make(filter: VaultListFilter(group: .login)) + XCTAssertTrue(strategy is MainVaultListGroupDirectorStrategy) } /// `make(filter:)` returns `PasswordsAutofillVaultListDirectorStrategy` when filtering by passwords /// autofill mode. func test_make_returnsPasswordsAutofillVaultListDirectorStrategy() { - let stragegy = subject.make(filter: VaultListFilter(mode: .passwords)) - XCTAssertTrue(stragegy is PasswordsAutofillVaultListDirectorStrategy) + let strategy = subject.make(filter: VaultListFilter(mode: .passwords)) + XCTAssertTrue(strategy is PasswordsAutofillVaultListDirectorStrategy) + } + + /// `make(filter:)` returns `SearchCombinedMultipleAutofillListDirectorStrategy` when + /// filtering by search text and by mode `.combinedMultipleSections`. + func test_make_returnsSearchCombinedMultipleAutofillListDirectorStrategy() { + let strategy = subject.make(filter: VaultListFilter( + mode: .combinedMultipleSections, + searchText: "search test", + )) + XCTAssertTrue(strategy is SearchCombinedMultipleAutofillListDirectorStrategy) + } + + /// `make(filter:)` returns `SearchVaultListDirectorStrategy` when + /// filtering by search text and mode is not `.combinedMultipleSections`. + func test_make_returnsSearchVaultListDirectorStrategy() { + let strategy1 = subject.make(filter: VaultListFilter( + mode: .all, + searchText: "search test", + )) + XCTAssertTrue(strategy1 is SearchVaultListDirectorStrategy) + + let strategy2 = subject.make(filter: VaultListFilter( + mode: .combinedSingleSection, + searchText: "search test", + )) + XCTAssertTrue(strategy2 is SearchVaultListDirectorStrategy) + + let strategy3 = subject.make(filter: VaultListFilter( + mode: .passwords, + searchText: "search test", + )) + XCTAssertTrue(strategy3 is SearchVaultListDirectorStrategy) + + let strategy4 = subject.make(filter: VaultListFilter( + mode: .totp, + searchText: "search test", + )) + XCTAssertTrue(strategy4 is SearchVaultListDirectorStrategy) } } diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListPreparedDataBuilder.swift b/BitwardenShared/Core/Vault/Helpers/VaultListPreparedDataBuilder.swift index d53281da5..68eda32b5 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListPreparedDataBuilder.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListPreparedDataBuilder.swift @@ -60,6 +60,17 @@ protocol VaultListPreparedDataBuilder { // sourcery: AutoMockable ) async -> VaultListPreparedDataBuilder /// Adds a no folder item to the prepared data. func addNoFolderItem(cipher: CipherListView) -> VaultListPreparedDataBuilder + /// Adds a search result item to the prepared exact/fuzzy data. + /// - Parameters: + /// - matchResult: The match result of the cipher search. + /// - cipher: The target cipher. + /// - group: The group filter, if any. + /// - Returns: This same instance builder for fluent coding. + func addSearchResultItem( + withMatchResult matchResult: CipherMatchResult, + cipher: CipherListView, + for group: VaultListGroup?, + ) async -> VaultListPreparedDataBuilder /// Builds the prepared data. func build() -> VaultListPreparedData /// Increments the cipher type count in the prepared data. @@ -82,7 +93,7 @@ protocol VaultListPreparedDataBuilder { // sourcery: AutoMockable // MARK: - DefaultVaultListPreparedDataBuilder /// Default implementation of `VaultListPreparedDataBuilder`. -class DefaultVaultListPreparedDataBuilder: VaultListPreparedDataBuilder { +class DefaultVaultListPreparedDataBuilder: VaultListPreparedDataBuilder { // swiftlint:disable:this type_body_length // MARK: Properties /// The service used to manage syncing and updates to the user's ciphers. @@ -264,6 +275,28 @@ class DefaultVaultListPreparedDataBuilder: VaultListPreparedDataBuilder { return self } + func addSearchResultItem( + withMatchResult matchResult: CipherMatchResult, + cipher: CipherListView, + for group: VaultListGroup?, + ) async -> VaultListPreparedDataBuilder { + guard matchResult != .none, + let vaultListItem = await buildVaultListItemForGroupOrDefault(cipher: cipher, group: group) else { + return self + } + + switch matchResult { + case .exact: + preparedData.exactMatchItems.append(vaultListItem) + case .fuzzy: + preparedData.fuzzyMatchItems.append(vaultListItem) + case .none: + break + } + + return self + } + func build() -> VaultListPreparedData { preparedData } @@ -326,6 +359,23 @@ class DefaultVaultListPreparedDataBuilder: VaultListPreparedDataBuilder { // MARK: Private methods + /// Builds the `VaultListItem` for the given `group` or returns the default `VaultListItem` for a given cipher. + /// - Parameters: + /// - cipher: The cipher to build the vault list item. + /// - group: The group to build the item for, if passed. + /// - Returns: The built `VaultListItem` for the `cipher` and `group`. + func buildVaultListItemForGroupOrDefault(cipher: CipherListView, group: VaultListGroup?) async -> VaultListItem? { + if case .totp = group { + guard await shouldIncludeTOTP(cipher: cipher) else { + return nil + } + + return await totpItem(for: cipher) + } + + return VaultListItem(cipherListView: cipher) + } + private func getHasPremiumFeaturesAccess() async -> Bool { guard let hasPremiumFeaturesAccess else { hasPremiumFeaturesAccess = await stateService.doesActiveAccountHavePremium() @@ -383,4 +433,4 @@ class DefaultVaultListPreparedDataBuilder: VaultListPreparedDataBuilder { ), ) } -} +} // swiftlint:disable:this file_length diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListPreparedDataBuilderTests.swift b/BitwardenShared/Core/Vault/Helpers/VaultListPreparedDataBuilderTests.swift index c96f554c3..69115ee9a 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListPreparedDataBuilderTests.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListPreparedDataBuilderTests.swift @@ -490,4 +490,151 @@ class VaultListPreparedDataBuilderTests: BitwardenTestCase { // swiftlint:disabl .build() XCTAssertEqual(preparedData.restrictedOrganizationIds, restrictedOrganizationIds) } + + // MARK: addSearchResultItem Tests + + /// `addSearchResultItem(withMatchResult:cipher:for:)` adds an exact match item to the prepared data + /// when match result is exact and no group is specified. + func test_addSearchResultItem_exactMatch_noGroup() async { + let cipher = CipherListView.fixture(id: "1") + let preparedData = await subject + .addSearchResultItem(withMatchResult: .exact, cipher: cipher, for: nil) + .build() + + XCTAssertEqual(preparedData.exactMatchItems.count, 1) + XCTAssertEqual(preparedData.exactMatchItems[0].id, "1") + XCTAssertTrue(preparedData.fuzzyMatchItems.isEmpty) + } + + /// `addSearchResultItem(withMatchResult:cipher:for:)` adds a fuzzy match item to the prepared data + /// when match result is fuzzy and no group is specified. + func test_addSearchResultItem_fuzzyMatch_noGroup() async { + let cipher = CipherListView.fixture(id: "2") + let preparedData = await subject + .addSearchResultItem(withMatchResult: .fuzzy, cipher: cipher, for: nil) + .build() + + XCTAssertEqual(preparedData.fuzzyMatchItems.count, 1) + XCTAssertEqual(preparedData.fuzzyMatchItems[0].id, "2") + XCTAssertTrue(preparedData.exactMatchItems.isEmpty) + } + + /// `addSearchResultItem(withMatchResult:cipher:for:)` does not add an item to the prepared data + /// when match result is none. + func test_addSearchResultItem_noneMatch() async { + let cipher = CipherListView.fixture(id: "3") + let preparedData = await subject + .addSearchResultItem(withMatchResult: .none, cipher: cipher, for: nil) + .build() + + XCTAssertTrue(preparedData.exactMatchItems.isEmpty) + XCTAssertTrue(preparedData.fuzzyMatchItems.isEmpty) + } + + /// `addSearchResultItem(withMatchResult:cipher:for:)` adds an exact match TOTP item to the prepared data + /// when match result is exact and group is TOTP. + func test_addSearchResultItem_exactMatch_totpGroup() async { + let cipher = CipherListView.fixture(id: "1", type: .login(.fixture(totp: "123456"))) + stateService.doesActiveAccountHavePremiumResult = true + + let preparedData = await subject + .addSearchResultItem(withMatchResult: .exact, cipher: cipher, for: .totp) + .build() + + XCTAssertEqual(preparedData.exactMatchItems.count, 1) + XCTAssertEqual(preparedData.exactMatchItems[0].id, "1") + XCTAssertTrue(preparedData.fuzzyMatchItems.isEmpty) + } + + /// `addSearchResultItem(withMatchResult:cipher:for:)` adds a fuzzy match TOTP item to the prepared data + /// when match result is fuzzy and group is TOTP. + func test_addSearchResultItem_fuzzyMatch_totpGroup() async { + let cipher = CipherListView.fixture(id: "2", type: .login(.fixture(totp: "654321"))) + stateService.doesActiveAccountHavePremiumResult = true + + let preparedData = await subject + .addSearchResultItem(withMatchResult: .fuzzy, cipher: cipher, for: .totp) + .build() + + XCTAssertEqual(preparedData.fuzzyMatchItems.count, 1) + XCTAssertEqual(preparedData.fuzzyMatchItems[0].id, "2") + XCTAssertTrue(preparedData.exactMatchItems.isEmpty) + } + + /// `addSearchResultItem(withMatchResult:cipher:for:)` does not add a TOTP item when the cipher + /// has no TOTP configured. + func test_addSearchResultItem_totpGroup_noTOTP() async { + let cipher = CipherListView.fixture(id: "3", type: .login(.fixture(totp: nil))) + stateService.doesActiveAccountHavePremiumResult = true + + let preparedData = await subject + .addSearchResultItem(withMatchResult: .exact, cipher: cipher, for: .totp) + .build() + + XCTAssertTrue(preparedData.exactMatchItems.isEmpty) + XCTAssertTrue(preparedData.fuzzyMatchItems.isEmpty) + } + + /// `addSearchResultItem(withMatchResult:cipher:for:)` does not add a TOTP item when the user + /// does not have premium access. + func test_addSearchResultItem_totpGroup_noPremiumAccess() async { + let cipher = CipherListView.fixture(id: "4", type: .login(.fixture(totp: "123456")), organizationUseTotp: false) + stateService.doesActiveAccountHavePremiumResult = false + + let preparedData = await subject + .addSearchResultItem(withMatchResult: .exact, cipher: cipher, for: .totp) + .build() + + XCTAssertTrue(preparedData.exactMatchItems.isEmpty) + XCTAssertTrue(preparedData.fuzzyMatchItems.isEmpty) + } + + /// `addSearchResultItem(withMatchResult:cipher:for:)` adds multiple items correctly when called multiple times + /// with different match results. + func test_addSearchResultItem_multipleItems() async { + let cipher1 = CipherListView.fixture(id: "1") + let cipher2 = CipherListView.fixture(id: "2") + let cipher3 = CipherListView.fixture(id: "3") + let cipher4 = CipherListView.fixture(id: "4") + + let preparedData = await subject + .addSearchResultItem(withMatchResult: .exact, cipher: cipher1, for: nil) + .addSearchResultItem(withMatchResult: .exact, cipher: cipher2, for: nil) + .addSearchResultItem(withMatchResult: .fuzzy, cipher: cipher3, for: nil) + .addSearchResultItem(withMatchResult: .none, cipher: cipher4, for: nil) + .build() + + XCTAssertEqual(preparedData.exactMatchItems.count, 2) + XCTAssertEqual(preparedData.exactMatchItems.map(\.id), ["1", "2"]) + XCTAssertEqual(preparedData.fuzzyMatchItems.count, 1) + XCTAssertEqual(preparedData.fuzzyMatchItems[0].id, "3") + } + + /// `addSearchResultItem(withMatchResult:cipher:for:)` adds items for different group types correctly. + func test_addSearchResultItem_differentGroups() async { + let loginCipher = CipherListView.fixture(id: "1", type: .login(.fixture())) + let cardCipher = CipherListView.fixture(id: "2", type: .card(.fixture())) + + let preparedData = await subject + .addSearchResultItem(withMatchResult: .exact, cipher: loginCipher, for: .login) + .addSearchResultItem(withMatchResult: .fuzzy, cipher: cardCipher, for: .card) + .build() + + XCTAssertEqual(preparedData.exactMatchItems.count, 1) + XCTAssertEqual(preparedData.exactMatchItems[0].id, "1") + XCTAssertEqual(preparedData.fuzzyMatchItems.count, 1) + XCTAssertEqual(preparedData.fuzzyMatchItems[0].id, "2") + } + + /// `addSearchResultItem(withMatchResult:cipher:for:)` does not add an item when cipher has nil ID. + func test_addSearchResultItem_nilCipherId() async { + let cipher = CipherListView.fixture(id: nil) + + let preparedData = await subject + .addSearchResultItem(withMatchResult: .exact, cipher: cipher, for: nil) + .build() + + XCTAssertTrue(preparedData.exactMatchItems.isEmpty) + XCTAssertTrue(preparedData.fuzzyMatchItems.isEmpty) + } } diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilder.swift b/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilder.swift index 93ee10d6d..e4871b781 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilder.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilder.swift @@ -9,10 +9,11 @@ import OSLog /// A protocol for a vault list builder which helps build items and sections for the vault lists. protocol VaultListSectionsBuilder { // sourcery: AutoMockable - /// Adds a section with passwords and Fido2 items combined for Autofill vault list in multiple sections. + /// Adds passwords and Fido2 items combined for Autofill vault list in multiple sections. + /// - Parameter searchText: The text query to search ciphers. /// - Parameter rpID: The relying party identifier of the Fido2 request. /// - Returns: The builder for fluent code. - func addAutofillCombinedMultipleSection(rpID: String?) -> VaultListSectionsBuilder + func addAutofillCombinedMultipleSection(searchText: String?, rpID: String?) -> VaultListSectionsBuilder /// Adds a section with passwords and Fido2 items for Autofill vault list in a combined single section. /// - Returns: The builder for fluent code. @@ -44,6 +45,11 @@ protocol VaultListSectionsBuilder { // sourcery: AutoMockable /// - Returns: The builder for fluent code. func addGroupSection() -> VaultListSectionsBuilder + /// Adds a section with search results items. + /// - Parameter options: The vault list options configured. + /// - Returns: The builder for fluent code. + func addSearchResultsSection(options: VaultListOptions) -> VaultListSectionsBuilder + /// Adds a section with TOTP items. /// - Returns: The builder for fluent code. func addTOTPSection() -> VaultListSectionsBuilder @@ -62,6 +68,13 @@ protocol VaultListSectionsBuilder { // sourcery: AutoMockable } extension VaultListSectionsBuilder { + /// Adds passwords and Fido2 items combined for Autofill vault list in multiple sections. + /// - Parameter rpID: The relying party identifier of the Fido2 request. + /// - Returns: The builder for fluent code. + func addAutofillCombinedMultipleSection(rpID: String?) -> VaultListSectionsBuilder { + addAutofillCombinedMultipleSection(searchText: nil, rpID: rpID) + } + /// Adds a section with available collections. /// - Returns: The builder for fluent code. func addCollectionsSection() async throws -> VaultListSectionsBuilder { @@ -78,7 +91,7 @@ extension VaultListSectionsBuilder { // MARK: - DefaultVaultListSectionsBuilder /// The default vault list sections builder. -class DefaultVaultListSectionsBuilder: VaultListSectionsBuilder { +class DefaultVaultListSectionsBuilder: VaultListSectionsBuilder { // swiftlint:disable:this type_body_length // MARK: Properties /// The service used by the application to handle encryption and decryption tasks. @@ -115,18 +128,20 @@ class DefaultVaultListSectionsBuilder: VaultListSectionsBuilder { // MARK: Methods - func addAutofillCombinedMultipleSection(rpID: String?) -> VaultListSectionsBuilder { + func addAutofillCombinedMultipleSection(searchText: String?, rpID: String?) -> VaultListSectionsBuilder { if !preparedData.fido2Items.isEmpty, let rpID { vaultListData.sections.append(VaultListSection( - id: Localizations.passkeysForX(rpID), + id: Localizations.passkeysForX(searchText ?? rpID), items: preparedData.fido2Items.sorted(using: VaultListItem.defaultSortDescriptor), - name: Localizations.passkeysForX(rpID), + name: Localizations.passkeysForX(searchText ?? rpID), )) } if !preparedData.groupItems.isEmpty { let passwordsSectionName = if let rpID { Localizations.passwordsForX(rpID) + } else if let searchText { + Localizations.passwordsForX(searchText) } else { Localizations.passwords } @@ -306,6 +321,31 @@ class DefaultVaultListSectionsBuilder: VaultListSectionsBuilder { return self } + func addSearchResultsSection(options: VaultListOptions) -> VaultListSectionsBuilder { + guard !preparedData.exactMatchItems.isEmpty || !preparedData.fuzzyMatchItems.isEmpty else { + return self + } + + let matchingItems = preparedData.exactMatchItems + preparedData.fuzzyMatchItems + + var sectionID = "SearchResults" + var sectionName = "" + + if options.contains(.isInPickerMode) { + sectionID = Localizations.matchingItems + sectionName = Localizations.matchingItems + } + + vaultListData.sections.append( + VaultListSection( + id: sectionID, + items: matchingItems.sorted(using: VaultListItem.defaultSortDescriptor), + name: sectionName, + ), + ) + return self + } + func addTOTPSection() -> VaultListSectionsBuilder { if preparedData.totpItemsCount > 0 { vaultListData.sections.append(VaultListSection( diff --git a/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilderTests.swift b/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilderTests.swift index 4c8e4a66b..3cd02762a 100644 --- a/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilderTests.swift +++ b/BitwardenShared/Core/Vault/Helpers/VaultListSectionsBuilderTests.swift @@ -1,4 +1,5 @@ import BitwardenKitMocks +import BitwardenResources import InlineSnapshotTesting import XCTest @@ -492,6 +493,196 @@ class VaultListSectionsBuilderTests: BitwardenTestCase { // swiftlint:disable:th } } + // MARK: addSearchResultsSection Tests + + /// `addSearchResultsSection(options:)` adds a search results section with exact and fuzzy match items combined. + func test_addSearchResultsSection_exactAndFuzzyMatches() { + setUpSubject(withData: VaultListPreparedData( + exactMatchItems: [ + .fixture(cipherListView: .fixture(id: "1", name: "Exact-2")), + .fixture(cipherListView: .fixture(id: "2", name: "Exact-1")), + .fixture(cipherListView: .fixture(id: "4", name: "Exact-3")), + ], + fuzzyMatchItems: [ + .fixture(cipherListView: .fixture(id: "3", name: "Fuzzy-2")), + .fixture(cipherListView: .fixture(id: "6", name: "Fuzzy-1")), + ], + )) + + let vaultListData = subject.addSearchResultsSection(options: []).build() + + assertInlineSnapshot(of: vaultListData.sections.dump(), as: .lines) { + """ + Section[SearchResults]: + - Cipher: Exact-1 + - Cipher: Exact-2 + - Cipher: Exact-3 + - Cipher: Fuzzy-1 + - Cipher: Fuzzy-2 + """ + } + } + + /// `addSearchResultsSection(options:)` adds a search results section with only exact match items + /// when no fuzzy items. + func test_addSearchResultsSection_onlyExactMatches() { + setUpSubject(withData: VaultListPreparedData( + exactMatchItems: [ + .fixture(cipherListView: .fixture(id: "1", name: "Item-C")), + .fixture(cipherListView: .fixture(id: "2", name: "Item-A")), + .fixture(cipherListView: .fixture(id: "3", name: "Item-B")), + ], + fuzzyMatchItems: [], + )) + + let vaultListData = subject.addSearchResultsSection(options: []).build() + + assertInlineSnapshot(of: vaultListData.sections.dump(), as: .lines) { + """ + Section[SearchResults]: + - Cipher: Item-A + - Cipher: Item-B + - Cipher: Item-C + """ + } + } + + /// `addSearchResultsSection(options:)` adds a search results section with only fuzzy match items + /// when no exact items. + func test_addSearchResultsSection_onlyFuzzyMatches() { + setUpSubject(withData: VaultListPreparedData( + exactMatchItems: [], + fuzzyMatchItems: [ + .fixture(cipherListView: .fixture(id: "1", name: "Fuzzy-3")), + .fixture(cipherListView: .fixture(id: "2", name: "Fuzzy-1")), + .fixture(cipherListView: .fixture(id: "3", name: "Fuzzy-2")), + ], + )) + + let vaultListData = subject.addSearchResultsSection(options: []).build() + + assertInlineSnapshot(of: vaultListData.sections.dump(), as: .lines) { + """ + Section[SearchResults]: + - Cipher: Fuzzy-1 + - Cipher: Fuzzy-2 + - Cipher: Fuzzy-3 + """ + } + } + + /// `addSearchResultsSection(options:)` doesn't add a section when there are no exact or fuzzy match items. + func test_addSearchResultsSection_empty() { + setUpSubject(withData: VaultListPreparedData( + exactMatchItems: [], + fuzzyMatchItems: [], + )) + + let vaultListData = subject.addSearchResultsSection(options: []).build() + + assertInlineSnapshot(of: vaultListData.sections.dump(), as: .lines) { + """ + """ + } + } + + /// `addSearchResultsSection(options:)` sorts exact and fuzzy match items together alphabetically by name. + func test_addSearchResultsSection_sortingOrder() { + setUpSubject(withData: VaultListPreparedData( + exactMatchItems: [ + .fixture(cipherListView: .fixture(id: "1", name: "Zebra")), + .fixture(cipherListView: .fixture(id: "2", name: "Apple")), + .fixture(cipherListView: .fixture(id: "3", name: "Banana")), + ], + fuzzyMatchItems: [ + .fixture(cipherListView: .fixture(id: "4", name: "Xylophone")), + .fixture(cipherListView: .fixture(id: "5", name: "Cherry")), + .fixture(cipherListView: .fixture(id: "6", name: "Mango")), + ], + )) + + let vaultListData = subject.addSearchResultsSection(options: []).build() + + assertInlineSnapshot(of: vaultListData.sections.dump(), as: .lines) { + """ + Section[SearchResults]: + - Cipher: Apple + - Cipher: Banana + - Cipher: Cherry + - Cipher: Mango + - Cipher: Xylophone + - Cipher: Zebra + """ + } + } + + /// `addSearchResultsSection(options:)` correctly handles single exact match item. + func test_addSearchResultsSection_singleExactMatch() { + setUpSubject(withData: VaultListPreparedData( + exactMatchItems: [ + .fixture(cipherListView: .fixture(id: "1", name: "SingleItem")), + ], + fuzzyMatchItems: [], + )) + + let vaultListData = subject.addSearchResultsSection(options: []).build() + + assertInlineSnapshot(of: vaultListData.sections.dump(), as: .lines) { + """ + Section[SearchResults]: + - Cipher: SingleItem + """ + } + } + + /// `addSearchResultsSection(options:)` correctly handles single fuzzy match item. + func test_addSearchResultsSection_singleFuzzyMatch() { + setUpSubject(withData: VaultListPreparedData( + exactMatchItems: [], + fuzzyMatchItems: [ + .fixture(cipherListView: .fixture(id: "1", name: "FuzzyItem")), + ], + )) + + let vaultListData = subject.addSearchResultsSection(options: []).build() + + assertInlineSnapshot(of: vaultListData.sections.dump(), as: .lines) { + """ + Section[SearchResults]: + - Cipher: FuzzyItem + """ + } + } + + /// `addSearchResultsSection(options:)` adds a search results section with exact and fuzzy match items combined + /// in picker mode. + func test_addSearchResultsSection_exactAndFuzzyMatchesPickerMode() { + setUpSubject(withData: VaultListPreparedData( + exactMatchItems: [ + .fixture(cipherListView: .fixture(id: "1", name: "Exact-2")), + .fixture(cipherListView: .fixture(id: "2", name: "Exact-1")), + .fixture(cipherListView: .fixture(id: "4", name: "Exact-3")), + ], + fuzzyMatchItems: [ + .fixture(cipherListView: .fixture(id: "3", name: "Fuzzy-2")), + .fixture(cipherListView: .fixture(id: "6", name: "Fuzzy-1")), + ], + )) + + let vaultListData = subject.addSearchResultsSection(options: [.isInPickerMode]).build() + + assertInlineSnapshot(of: vaultListData.sections.dump(), as: .lines) { + """ + Section[\(Localizations.matchingItems)]: \(Localizations.matchingItems) + - Cipher: Exact-1 + - Cipher: Exact-2 + - Cipher: Exact-3 + - Cipher: Fuzzy-1 + - Cipher: Fuzzy-2 + """ + } + } + /// `build()` returns the built sections. /// Using this test also to verify that sections get appended and to verify fluent code usage of the builder. func test_build() async throws { // swiftlint:disable:this function_body_length diff --git a/BitwardenShared/Core/Vault/Repositories/TestHelpers/MockVaultRepository.swift b/BitwardenShared/Core/Vault/Repositories/TestHelpers/MockVaultRepository.swift index 6df433be8..e79af3e1b 100644 --- a/BitwardenShared/Core/Vault/Repositories/TestHelpers/MockVaultRepository.swift +++ b/BitwardenShared/Core/Vault/Repositories/TestHelpers/MockVaultRepository.swift @@ -98,12 +98,6 @@ class MockVaultRepository: VaultRepository { var saveAttachmentFileName: String? var saveAttachmentResult: Result = .success(.fixture()) - var searchCipherAutofillPublisherCalledWithGroup: VaultListGroup? // swiftlint:disable:this identifier_name - var searchCipherAutofillSubject = CurrentValueSubject(VaultListData()) - - var searchVaultListSubject = CurrentValueSubject<[VaultListItem], Error>([]) - var searchVaultListFilterType: VaultListFilter? - var shareCipherCiphers = [CipherView]() var shareCipherResult: Result = .success(()) @@ -285,27 +279,6 @@ class MockVaultRepository: VaultRepository { return try saveAttachmentResult.get() } - func searchCipherAutofillPublisher( // swiftlint:disable:this function_parameter_count - availableFido2CredentialsPublisher: AnyPublisher<[BitwardenSdk.CipherView]?, Error>, - mode: BitwardenShared.AutofillListMode, - filter: BitwardenShared.VaultListFilter, - group: BitwardenShared.VaultListGroup?, - rpID: String?, - searchText: String, - ) async throws -> AsyncThrowingPublisher> { - searchCipherAutofillPublisherCalledWithGroup = group - return searchCipherAutofillSubject.eraseToAnyPublisher().values - } - - func searchVaultListPublisher( - searchText _: String, - group: VaultListGroup?, - filter: BitwardenShared.VaultListFilter, - ) async throws -> AsyncThrowingPublisher> { - searchVaultListFilterType = filter - return searchVaultListSubject.eraseToAnyPublisher().values - } - func shareCipher(_ cipher: CipherView, newOrganizationId: String, newCollectionIds: [String]) async throws { shareCipherCiphers.append(cipher) try shareCipherResult.get() diff --git a/BitwardenShared/Core/Vault/Repositories/VaultRepository.swift b/BitwardenShared/Core/Vault/Repositories/VaultRepository.swift index b00663248..4a87c6d95 100644 --- a/BitwardenShared/Core/Vault/Repositories/VaultRepository.swift +++ b/BitwardenShared/Core/Vault/Repositories/VaultRepository.swift @@ -248,39 +248,6 @@ public protocol VaultRepository: AnyObject { /// func organizationsPublisher() async throws -> AsyncThrowingPublisher> - /// A publisher for searching a user's cipher objects for autofill. This only includes login ciphers. - /// - /// - Parameters: - /// - availableFido2CredentialsPublisher: The publisher for available Fido2 credentials for Fido2 autofill list. - /// - mode: The mode in which the autofill list is presented. - /// - filter: The vault filter to apply to the cipher list. - /// - rpID: The relying party identifier of the Fido2 request - /// - searchText: The search text to filter the cipher list. - /// - /// - Returns: A publisher for searching the user's ciphers for autofill. - func searchCipherAutofillPublisher( // swiftlint:disable:this function_parameter_count - availableFido2CredentialsPublisher: AnyPublisher<[BitwardenSdk.CipherView]?, Error>, - mode: AutofillListMode, - filter: VaultListFilter, - group: VaultListGroup?, - rpID: String?, - searchText: String, - ) async throws -> AsyncThrowingPublisher> - - /// A publisher for searching a user's cipher objects based on the specified search text and filter type. - /// - /// - Parameters: - /// - searchText: The search text to filter the cipher list. - /// - group: The group to search. Searches all groups if nil. - /// - filter: The vault filter to apply to the cipher list. - /// - Returns: A publisher searching for the user's ciphers. - /// - func searchVaultListPublisher( - searchText: String, - group: VaultListGroup?, - filter: VaultListFilter, - ) async throws -> AsyncThrowingPublisher> - /// A publisher for the vault list which returns a list of sections and items that are /// displayed in the vault. /// @@ -304,29 +271,11 @@ extension VaultRepository { } return cipherView } - - /// A publisher for searching a user's cipher objects based on the specified search text and filter type. - /// - /// - Parameters: - /// - searchText: The search text to filter the cipher list. - /// - filter: The vault filter type to apply to the cipher list. - /// - Returns: A publisher searching for the user's ciphers. - /// - func searchVaultListPublisher( - searchText: String, - filter: VaultListFilter, - ) async throws -> AsyncThrowingPublisher> { - try await searchVaultListPublisher( - searchText: searchText, - group: nil, - filter: filter, - ) - } } /// A default implementation of a `VaultRepository`. /// -class DefaultVaultRepository { // swiftlint:disable:this type_body_length +class DefaultVaultRepository { // MARK: Properties /// The service used to manage syncing and updates to the user's ciphers. @@ -490,504 +439,6 @@ class DefaultVaultRepository { // swiftlint:disable:this type_body_length return updatedCipher } - - /// Returns a list of `VaultListItem`s for the folders within a nested tree. By default, this - /// will return the list items for the folders at the root of the tree. Specifying a - /// `nestedFolderId` will return the list items for the children of the folder with the - /// specified ID. - /// - /// - Parameters: - /// - activeCiphers: The list of active (non-deleted) ciphers, used to determine the count of - /// ciphers within a folder. - /// - folderTree: The nested tree of folders. - /// - nestedFolderId: An optional folder ID of a nested folder to create the list items from - /// the children of that folder. Defaults to `nil` which will return the list items for the - /// folders at the root of the tree. - /// - Returns: A list of `VaultListItem`s for the folders within a nested tree. - /// - private func folderVaultListItems( - activeCiphers: [CipherListView], - folderTree: Tree, - nestedFolderId: String? = nil, - ) -> [VaultListItem] { - let folders: [TreeNode]? = if let nestedFolderId { - folderTree.getTreeNodeObject(with: nestedFolderId)?.children - } else { - folderTree.rootNodes - } - - guard let folders else { return [] } - - return folders.compactMap { folderNode in - guard let folderId = folderNode.node.id else { - self.errorReporter.log( - error: BitwardenError.dataError("Received a folder from the API with a missing ID."), - ) - return nil - } - let cipherCount = activeCiphers.lazy.count(where: { $0.folderId == folderId }) - return VaultListItem( - id: folderId, - itemType: .group(.folder(id: folderId, name: folderNode.name), cipherCount), - ) - } - } - - /// A publisher for searching a user's ciphers based on the specified search text and filter type. - /// - /// - Parameters: - /// - searchText: The search text to filter the cipher list. - /// - filter: The vault filter to apply to the cipher list. - /// - cipherFilter: An optional additional filter to apply to the cipher list. - /// - Returns: A publisher searching for the user's ciphers. - /// - private func searchPublisher( - searchText: String, - filter: VaultListFilter, - isActive: Bool, - cipherFilter: ((CipherListView) -> Bool)? = nil, - ) async throws -> AnyPublisher<[CipherListView], Error> { - let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .folding(options: .diacriticInsensitive, locale: .current) - - let isMatchingCipher: (CipherListView) -> Bool = isActive - ? { $0.deletedDate == nil } - : { $0.deletedDate != nil } - let restrictItemTypesOrgIds = await policyService.getOrganizationIdsForRestricItemTypesPolicy() - - return try await cipherService.ciphersPublisher().asyncTryMap { ciphers -> [CipherListView] in - // Convert the Ciphers to CipherViews and filter appropriately. - let matchingCiphers = try await self.clientService.vault().ciphers() - .decryptListWithFailures(ciphers: ciphers).successes - .filter { cipher in - filter.filterType.cipherFilter(cipher) && - isMatchingCipher(cipher) && - (cipherFilter?(cipher) ?? true) && - self.filterBasedOnRestrictItemTypesPolicy(cipher: cipher, restrictItemTypesOrgIds) - } - - var matchedCiphers: [CipherListView] = [] - var lowPriorityMatchedCiphers: [CipherListView] = [] - - // Search the ciphers. - matchingCiphers.forEach { cipher in - if cipher.name.lowercased() - .folding(options: .diacriticInsensitive, locale: nil).contains(query) { - matchedCiphers.append(cipher) - } else if query.count >= 8, cipher.id?.starts(with: query) == true { - lowPriorityMatchedCiphers.append(cipher) - } else if cipher.subtitle.lowercased() - .folding(options: .diacriticInsensitive, locale: nil).contains(query) == true { - lowPriorityMatchedCiphers.append(cipher) - } else if cipher.type.loginListView?.uris? - .contains(where: { $0.uri?.contains(query) == true }) == true { - lowPriorityMatchedCiphers.append(cipher) - } - } - - // Return the result. - return matchedCiphers.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + - lowPriorityMatchedCiphers - }.eraseToAnyPublisher() - } - - /// Returns a list of TOTP type items from a SyncResponseModel. - /// - /// - Parameters: - /// - ciphers: The ciphers containing the list of TOTP keys. - /// - filter: The filter applied to the response. - /// - Returns: A list of totpKey type items in the vault list. - /// - private func totpListItems( - from ciphers: [CipherListView], - filter: VaultFilterType?, - ) async throws -> [VaultListItem] { - let hasPremiumFeaturesAccess = await doesActiveAccountHavePremium() - let userHasMasterPassword = await (try? stateService.getUserHasMasterPassword()) ?? false - - // Filter and sort the list. - let activeCiphers = ciphers - .filter(filter?.cipherFilter(_:) ?? { _ in true }) - .filter { cipher in - cipher.deletedDate == nil - && cipher.type.loginListView?.totp != nil - && (hasPremiumFeaturesAccess || cipher.organizationUseTotp) - } - .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } - - // Convert the CipherViews into VaultListItem. - let totpItems: [VaultListItem] = try await activeCiphers - .asyncMap { try await totpItem(for: $0, userHasMasterPassword: userHasMasterPassword) } - .compactMap(\.self) - - return totpItems - } - - /// A transform to convert a `CipherListView` into a TOTP `VaultListItem`. - /// - /// - Parameters: - /// - cipherListView: The cipher view that may have a TOTP key. - /// - userHasMasterPassword: Whether the user has a master password. - /// - Returns: A `VaultListItem` if the cipher supports TOTP. - /// - private func totpItem( - for cipherListView: CipherListView, - userHasMasterPassword: Bool, - ) async throws -> VaultListItem? { - guard let id = cipherListView.id, - cipherListView.type.loginListView?.totp != nil else { - return nil - } - guard let code = try? await clientService.vault().generateTOTPCode( - for: cipherListView, - date: timeProvider.presentTime, - ) else { - errorReporter.log( - error: TOTPServiceError - .unableToGenerateCode("Unable to create TOTP code for cipher id \(id)"), - ) - return nil - } - - let listModel = VaultListTOTP( - id: id, - cipherListView: cipherListView, - requiresMasterPassword: cipherListView.reprompt == .password && userHasMasterPassword, - totpCode: code, - ) - return VaultListItem( - id: id, - itemType: .totp( - name: cipherListView.name, - totpModel: listModel, - ), - ) - } - - /// Returns a `VaultListSection` for the collection section, if one exists. - /// - /// - Parameters: - /// - activeCiphers: The list of active (non-deleted) ciphers, used to determine the count of - /// ciphers within a collection. - /// - collections: The list of all collections. - /// - filter: A filter to apply to the vault items. - /// - nestedCollectionId: An optional collection ID of a nested collection to create the list - /// items from the children of that collection. Defaults to `nil` which will return the list - /// of collections at the root of the tree. - /// - Returns: A `VaultListSection` for the collection section, if one exists. - /// - private func vaultListCollectionSection( - activeCiphers: [CipherListView], - collections: [Collection], - filter: VaultFilterType, - nestedCollectionId: String? = nil, - ) async throws -> VaultListSection? { - let decryptedCollections = try await clientService.vault().collections() - .decryptList(collections: collections) - .filter(filter.collectionFilter) - .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } - let collectionTree = decryptedCollections.asNestedNodes() - - let nestedCollections = if let nestedCollectionId { - collectionTree.getTreeNodeObject(with: nestedCollectionId)?.children - } else { - collectionTree.rootNodes - } - - guard let nestedCollections else { return nil } - - let collectionItems: [VaultListItem] = nestedCollections.compactMap { collectionNode in - let collection = collectionNode.node - guard let collectionId = collection.id else { - self.errorReporter.log( - error: BitwardenError.dataError("Received a collection from the API with a missing ID."), - ) - return nil - } - let collectionCount = activeCiphers.lazy.count(where: { $0.collectionIds.contains(collectionId) }) - return VaultListItem( - id: collectionId, - itemType: .group( - .collection(id: collectionId, name: collectionNode.name, organizationId: collection.organizationId), - collectionCount, - ), - ) - } - - return VaultListSection(id: "Collections", items: collectionItems, name: Localizations.collections) - } - - /// Returns a `VaultListSection` for the folder section, if one exists. - /// - /// - Parameters: - /// - activeCiphers: The list of active (non-deleted) ciphers, used to determine the count of - /// ciphers within a folder. - /// - group: The group of items to get. - /// - filter: A filter to apply to the vault items. - /// - folders: The list of all folders. This is used to show any nested folders within a - /// folder group. - /// - Returns: A `VaultListSection` for the folder section, if one exists. - /// - private func vaultListFolderSection( - activeCiphers: [CipherListView], - group: VaultListGroup, - filter: VaultFilterType, - folders: [Folder], - ) async throws -> VaultListSection? { - guard let folderId = group.folderId else { return nil } - - let folders = try await clientService.vault().folders() - .decryptList(folders: folders) - .filter { filter.folderFilter($0, ciphers: activeCiphers) } - .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } - - let folderItems = folderVaultListItems( - activeCiphers: activeCiphers, - folderTree: folders.asNestedNodes(), - nestedFolderId: folderId, - ) - - return VaultListSection( - id: "Folders", - items: folderItems, - name: Localizations.folder, - ) - } - - /// Returns a `VaultListSection` for the vault items section. - /// - /// - Parameters: - /// - activeCiphers: The list of active (non-deleted) ciphers. - /// - deletedCiphers: The list of deleted ciphers. - /// - group: The group of items to get. - /// - filter: A filter to apply to the vault items. - /// - Returns: A `VaultListSection` for the vault items section. - /// - private func vaultListItemsSection( - activeCiphers: [CipherListView], - deletedCiphers: [CipherListView], - group: VaultListGroup, - filter: VaultFilterType, - ) async throws -> VaultListSection { - let items: [VaultListItem] = switch group { - case .card: - activeCiphers.filter(\.type.isCard).compactMap(VaultListItem.init) - case let .collection(id, _, _): - activeCiphers.filter { $0.collectionIds.contains(id) }.compactMap(VaultListItem.init) - case let .folder(id, _): - activeCiphers.filter { $0.folderId == id }.compactMap(VaultListItem.init) - case .identity: - activeCiphers.filter { $0.type == .identity }.compactMap(VaultListItem.init) - case .login: - activeCiphers.filter(\.type.isLogin).compactMap(VaultListItem.init) - case .noFolder: - activeCiphers.filter { $0.folderId == nil }.compactMap(VaultListItem.init) - case .secureNote: - activeCiphers.filter { $0.type == .secureNote }.compactMap(VaultListItem.init) - case .sshKey: - activeCiphers.filter { $0.type == .sshKey }.compactMap(VaultListItem.init) - case .totp: - try await totpListItems(from: activeCiphers, filter: filter) - case .trash: - deletedCiphers.compactMap(VaultListItem.init) - } - - return VaultListSection(id: "Items", items: items, name: Localizations.items) - } - - /// Returns a list of sections containing the items that are grouped together in the vault list - /// from a list of encrypted ciphers. - /// - /// - Parameters: - /// - group: The group of items to get. - /// - filter: A filter to apply to the vault items. - /// - ciphers: The ciphers to build the list of items. - /// - folders: The list of all folders. This is used to show any nested folders within a - /// folder group. - /// - Returns: A list of items for the group in the vault list. - /// - private func vaultListItems( - group: VaultListGroup, - filter: VaultListFilter, - ciphers: [Cipher], - collections: [Collection], - folders: [Folder] = [], - ) async throws -> [VaultListSection] { - let restrictItemTypesOrgIds = await policyService.getOrganizationIdsForRestricItemTypesPolicy() - let ciphers = try await clientService.vault().ciphers().decryptListWithFailures(ciphers: ciphers) - .successes - .filter { cipher in - filter.filterType.cipherFilter(cipher) && - filterBasedOnRestrictItemTypesPolicy(cipher: cipher, restrictItemTypesOrgIds) - } - .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } - - let activeCiphers = ciphers.filter { $0.deletedDate == nil } - let deletedCiphers = ciphers.filter { $0.deletedDate != nil } - - let folderSection = try await vaultListFolderSection( - activeCiphers: activeCiphers, - group: group, - filter: filter.filterType, - folders: folders, - ) - - let collectionSection: VaultListSection? = if let collectionId = group.collectionId { - try await vaultListCollectionSection( - activeCiphers: activeCiphers, - collections: collections, - filter: filter.filterType, - nestedCollectionId: collectionId, - ) - } else { - nil - } - - let itemsSection = try await vaultListItemsSection( - activeCiphers: activeCiphers, - deletedCiphers: deletedCiphers, - group: group, - filter: filter.filterType, - ) - - return [ - folderSection, - collectionSection, - itemsSection, - ] - .compactMap(\.self) - .filter { !$0.items.isEmpty } - } - - /// Filters ciphers based on the organization's restrictItemTypes policy. - /// - /// - Parameters: - /// - cipher: The cipher to check against the policy. - /// - restrictItemTypesOrgIds: The list of organization IDs that are restricted by the policy. - /// - Returns: `true` if the cipher is allowed by the policy, `false` otherwise. - /// - func filterBasedOnRestrictItemTypesPolicy(cipher: CipherListView, _ restrictItemTypesOrgIds: [String]) -> Bool { - guard !restrictItemTypesOrgIds.isEmpty, cipher.type.isCard else { return true } - guard let orgId = cipher.organizationId, !orgId.isEmpty else { return false } - return !restrictItemTypesOrgIds.contains(orgId) - } - - /// Returns a list of the sections in the vault list from a sync response. - /// - /// - Parameters: - /// - ciphers: The encrypted ciphers in the user's vault. - /// - collections: The encrypted list of collections the user has access to. - /// - folders: The encrypted list of folders the user has. - /// - filter: A filter to apply to the vault items. - /// - Returns: A list of the sections to display in the vault list. - /// - private func vaultListSections( // swiftlint:disable:this function_body_length - from ciphers: [Cipher], - collections: [Collection], - folders: [Folder], - filter: VaultListFilter, - ) async throws -> [VaultListSection] { - let restrictItemTypesOrgIds = await policyService.getOrganizationIdsForRestricItemTypesPolicy() - let ciphers = try await clientService.vault().ciphers().decryptListWithFailures(ciphers: ciphers) - .successes - .filter { cipher in - filter.filterType.cipherFilter(cipher) && - filterBasedOnRestrictItemTypesPolicy(cipher: cipher, restrictItemTypesOrgIds) - } - .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } - - guard !ciphers.isEmpty else { return [] } - - let activeCiphers = ciphers.filter { cipher in - cipher.deletedDate == nil - } - - let folders = try await clientService.vault().folders() - .decryptList(folders: folders) - .filter { filter.filterType.folderFilter($0, ciphers: activeCiphers) } - .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } - - var sections: [VaultListSection] = [] - - let ciphersFavorites = activeCiphers.filter(\.favorite).compactMap(VaultListItem.init) - let ciphersNoFolder = activeCiphers.filter { $0.folderId == nil }.compactMap(VaultListItem.init) - - if filter.addTOTPGroup { - // Add TOTP items for premium accounts (or if organization uses TOTP without premium). - let totpItemsCount = try await totpListItems(from: ciphers, filter: filter.filterType).count - let totpItems = [totpItemsCount].filter { $0 > 0 }.map { count in - VaultListItem( - id: "Types.VerificationCodes", - itemType: .group(.totp, count), - ) - } - sections.append(VaultListSection(id: "TOTP", items: totpItems, name: Localizations.totp)) - } - - let collectionSection = try await vaultListCollectionSection( - activeCiphers: activeCiphers, - collections: collections, - filter: filter.filterType, - ) - - var folderItems = folderVaultListItems( - activeCiphers: activeCiphers, - folderTree: folders.asNestedNodes(), - ) - - // Add no folder to folders item if needed. - let showNoFolderCipherGroup = (collectionSection?.items.isEmpty ?? false) - && ciphersNoFolder.count < Constants.noFolderListSize - if !showNoFolderCipherGroup { - folderItems.append( - VaultListItem( - id: "NoFolderFolderItem", - itemType: .group(.noFolder, ciphersNoFolder.count), - ), - ) - } - - let typesCardCount = activeCiphers.lazy.filter(\.type.isCard).count - let typesIdentityCount = activeCiphers.lazy.count(where: { $0.type == .identity }) - let typesLoginCount = activeCiphers.lazy.filter(\.type.isLogin).count - let typesSecureNoteCount = activeCiphers.lazy.count(where: { $0.type == .secureNote }) - let typesSSHKeyCount = activeCiphers.lazy.count(where: { $0.type == .sshKey }) - - var types = [VaultListItem(id: "Types.Logins", itemType: .group(.login, typesLoginCount))] - - // Only show the card section if there are cards or restrictItemTypes policy is not enabled. - if typesCardCount != 0 || restrictItemTypesOrgIds.isEmpty { - types.append(VaultListItem(id: "Types.Cards", itemType: .group(.card, typesCardCount))) - } - - types.append(contentsOf: [ - VaultListItem(id: "Types.Identities", itemType: .group(.identity, typesIdentityCount)), - VaultListItem(id: "Types.SecureNotes", itemType: .group(.secureNote, typesSecureNoteCount)), - VaultListItem(id: "Types.SSHKeys", itemType: .group(.sshKey, typesSSHKeyCount)), - ]) - - sections.append(contentsOf: [ - VaultListSection(id: "Favorites", items: ciphersFavorites, name: Localizations.favorites), - VaultListSection(id: "Types", items: types, name: Localizations.types), - VaultListSection(id: "Folders", items: folderItems, name: Localizations.folders), - VaultListSection( - id: "NoFolder", - items: showNoFolderCipherGroup ? ciphersNoFolder : [], - name: Localizations.folderNone, - ), - ]) - if let collectionSection { - sections.append(collectionSection) - } - if filter.addTrashGroup { - let ciphersTrashCount = ciphers.lazy.count(where: { $0.deletedDate != nil }) - let ciphersTrashItem = VaultListItem(id: "Trash", itemType: .group(.trash, ciphersTrashCount)) - sections.append(VaultListSection(id: "Trash", items: [ciphersTrashItem], name: Localizations.trash)) - } - - return sections.filter { !$0.items.isEmpty } - } } extension DefaultVaultRepository: VaultRepository { @@ -1340,8 +791,6 @@ extension DefaultVaultRepository: VaultRepository { case .all: try await vaultListPublisher( filter: VaultListFilter( - addTOTPGroup: false, - addTrashGroup: false, filterType: .allVaults, group: group, ), @@ -1349,8 +798,6 @@ extension DefaultVaultRepository: VaultRepository { case .combinedMultipleSections, .combinedSingleSection, .passwords: try await vaultListPublisher( filter: VaultListFilter( - addTOTPGroup: false, - addTrashGroup: false, filterType: .allVaults, mode: mode, rpID: rpID, @@ -1360,8 +807,6 @@ extension DefaultVaultRepository: VaultRepository { case .totp: try await vaultListPublisher( filter: VaultListFilter( - addTOTPGroup: false, - addTrashGroup: false, group: .totp, ), ) @@ -1372,63 +817,6 @@ extension DefaultVaultRepository: VaultRepository { try await organizationService.organizationsPublisher().eraseToAnyPublisher().values } - func searchCipherAutofillPublisher( - availableFido2CredentialsPublisher: AnyPublisher<[BitwardenSdk.CipherView]?, Error>, - mode: AutofillListMode, - filter: VaultListFilter, - group: VaultListGroup? = nil, - rpID: String?, - searchText: String, - ) async throws -> AsyncThrowingPublisher> { - try await Publishers.CombineLatest( - searchPublisher( - searchText: searchText, - filter: filter, - isActive: true, - ) { cipher in - guard let group else { - return mode == .all || cipher.type.isLogin - } - return cipher.belongsToGroup(group) - }, - availableFido2CredentialsPublisher, - ) - .asyncTryMap { ciphers, availableFido2Credentials in - try await self.createAutofillListSections( - availableFido2Credentials: availableFido2Credentials, - from: ciphers, - mode: mode, - rpID: rpID, - searchText: searchText, - ) - } - .eraseToAnyPublisher() - .values - } - - func searchVaultListPublisher( - searchText: String, - group: VaultListGroup? = nil, - filter: VaultListFilter, - ) async throws -> AsyncThrowingPublisher> { - try await searchPublisher( - searchText: searchText, - filter: filter, - isActive: group != .trash, - ) { cipher in - guard let group else { return true } - return cipher.belongsToGroup(group) - } - .asyncTryMap { ciphers in - guard case .totp = group else { - return ciphers.compactMap(VaultListItem.init) - } - return try await self.totpListItems(from: ciphers, filter: filter.filterType) - } - .eraseToAnyPublisher() - .values - } - func vaultListPublisher( filter: VaultListFilter, ) async throws -> AsyncThrowingPublisher> { @@ -1439,109 +827,6 @@ extension DefaultVaultRepository: VaultRepository { // MARK: Private - /// Creates the vault list sections from given ciphers and search text. - /// This is to centralize sections creation from loading and searching. - /// - /// - Parameters: - /// - availableFido2Credentials: The available Fido2 credentials for Fido2 autofill list. - /// - from: The ciphers to create the sections, either load or search results. - /// - mode: The mode in which the autofill list is presented. - /// - rpID: The relying party identifier of the Fido2 request. - /// - searchText: The current search text. - /// - Returns: The sections for the autofill list. - private func createAutofillListSections( // swiftlint:disable:this function_body_length - availableFido2Credentials: [CipherView]?, - from ciphers: [CipherListView], - mode: AutofillListMode, - rpID: String?, - searchText: String?, - ) async throws -> VaultListData { - switch mode { - case .all: - guard !ciphers.isEmpty else { - return VaultListData() - } - return VaultListData(sections: [ - VaultListSection( - id: "SearchResults", - items: ciphers.compactMap { .init(cipherListView: $0) }, - name: "", - ), - ]) - case .combinedMultipleSections, .passwords: - var sections = [VaultListSection]() - if #available(iOSApplicationExtension 17.0, *), - let fido2Section = try await loadAutofillFido2Section( - availableFido2Credentials: availableFido2Credentials, - mode: mode, - rpID: rpID, - searchText: searchText, - searchResults: searchText != nil ? ciphers : nil, - ) { - sections.append(fido2Section) - } else if ciphers.isEmpty { - return VaultListData() - } - - let sectionName = getAutofillPasswordsSectionName( - mode: mode, - rpID: rpID, - searchText: searchText, - ) - - sections.append( - VaultListSection( - id: sectionName, - items: ciphers.compactMap { .init(cipherListView: $0) }, - name: sectionName, - ), - ) - return VaultListData(sections: sections) - case .combinedSingleSection: - guard !ciphers.isEmpty else { - return VaultListData() - } - - let section = try await createAutofillListCombinedSingleSection(from: ciphers) - return VaultListData(sections: [section]) - case .totp: - let totpVaultListItems = try await totpListItems(from: ciphers, filter: .allVaults) - guard !totpVaultListItems.isEmpty else { - return VaultListData() - } - - return VaultListData(sections: [ - VaultListSection( - id: "", - items: totpVaultListItems, - name: "", - ), - ]) - } - } - - /// Creates the single vault list section for passwords + Fido2 credentials. - /// - Parameter ciphers: Ciphers to load. - /// - Returns: The section to display passwords + Fido2 credentials. - private func createAutofillListCombinedSingleSection( - from ciphers: [CipherListView], - ) async throws -> VaultListSection { - let vaultItems = try await ciphers - .asyncMap { cipher in - guard cipher.type.loginListView?.hasFido2 == true else { - return VaultListItem(cipherListView: cipher) - } - return try await createFido2VaultListItem(from: cipher) - } - .compactMap(\.self) - - return VaultListSection( - id: Localizations.chooseALoginToSaveThisPasskeyTo, - items: vaultItems, - name: Localizations.chooseALoginToSaveThisPasskeyTo, - ) - } - /// Creates a `VaultListItem` from a `CipherView` with Fido2 credentials. /// - Parameter cipher: Cipher from which create the item. /// - Returns: The `VaultListItem` with the cipher and Fido2 credentials. @@ -1594,161 +879,4 @@ extension DefaultVaultRepository: VaultRepository { fido2CredentialAutofillView: fido2CredentialAutofillView, ) } - - /// Gets the passwords vault list section name depending on the context. - /// - /// - Parameters: - /// - mode: The mode in which the autofill list is presented. - /// - rpID: The relying party identifier of the Fido2 request. - /// - searchText: The current search text. - /// - private func getAutofillPasswordsSectionName( - mode: AutofillListMode, - rpID: String?, - searchText: String?, - ) -> String { - guard mode != .passwords else { - return "" - } - - if let searchText { - return Localizations.passwordsForX(searchText) - } - - if let rpID { - return Localizations.passwordsForX(rpID) - } - - return Localizations.passwords - } - - /// Loads the autofill Fido2 section if needed. - /// - Parameters: - /// - availableFido2Credentials: The available Fido2 credentials for Fido2 autofill list. - /// - mode: The mode in which the autofill list is presented. - /// - rpID: The relying party identifier of the Fido2 request. - /// - searchText: The current search text. - /// - searchResults: The search results. - /// - Returns: The vault list section for Fido2 autofill if needed. - private func loadAutofillFido2Section( - availableFido2Credentials: [CipherView]?, - mode: AutofillListMode, - rpID: String?, - searchText: String? = nil, - searchResults: [CipherListView]? = nil, - ) async throws -> VaultListSection? { - guard let fido2Credentials = availableFido2Credentials, - !fido2Credentials.isEmpty, - case .combinedMultipleSections = mode, - let rpID else { - return nil - } - - var filteredFido2Credentials = fido2Credentials - if let searchResults { - filteredFido2Credentials = filteredFido2Credentials.filter { cipher in - searchResults.contains(where: { $0.id == cipher.id }) - } - } - - guard !filteredFido2Credentials.isEmpty else { - return nil - } - - let fido2ListItems: [VaultListItem?] = try await filteredFido2Credentials - .asyncMap { cipher in - try await createFido2VaultListItem(from: cipher) - } - - return VaultListSection( - id: Localizations.passkeysForX(searchText ?? rpID), - items: fido2ListItems.compactMap(\.self), - name: Localizations.passkeysForX(searchText ?? rpID), - ) - } -} - -// MARK: - CipherListView - -private extension CipherListView { - /// Whether the cipher belongs to a group. - /// - Parameter group: The group to filter. - /// - Returns: `true` if the cipher belongs to the group, `false` otherwise. - func belongsToGroup(_ group: VaultListGroup) -> Bool { - switch group { - case .card: - type.isCard - case let .collection(id, _, _): - collectionIds.contains(id) - case let .folder(id, _): - folderId == id - case .identity: - type == .identity - case .login: - type.isLogin - case .noFolder: - folderId == nil - case .secureNote: - type == .secureNote - case .sshKey: - type == .sshKey - case .totp: - type.loginListView?.totp != nil - case .trash: - deletedDate != nil - } - } -} - -// MARK: - VaultListFilter - -/// The filter to be used when getting the vault list. -public struct VaultListFilter: Sendable { - /// Whether to add the TOTP group. - let addTOTPGroup: Bool - - /// Whether to add the trash group. - let addTrashGroup: Bool - - /// The vault filter type. - let filterType: VaultFilterType - - /// The vault list group to filter. - let group: VaultListGroup? - - /// The mode in which the autofill list is presented. - let mode: AutofillListMode? - - /// The relying party identifier of the Fido2 request. - let rpID: String? - - /// The URI used to filter ciphers that have a matching URI. - let uri: String? - - /// Initializes the filter. - /// - Parameters: - /// - addTOTPGroup: Whether to add the TOTP group. - /// - addTrashGroup: Whether to add the trash group. - /// - filterType: The vault filter type. - /// - group: The vault list group to filter. - /// - mode: The mode in which the autofill list is presented. - /// - rpID: The relying party identifier of the Fido2 request. - /// - uri: The URI used to filter ciphers that have a matching URI - init( - addTOTPGroup: Bool = true, - addTrashGroup: Bool = true, - filterType: VaultFilterType = .allVaults, - group: VaultListGroup? = nil, - mode: AutofillListMode? = nil, - rpID: String? = nil, - uri: String? = nil, - ) { - self.addTOTPGroup = addTOTPGroup - self.addTrashGroup = addTrashGroup - self.filterType = filterType - self.group = group - self.mode = mode - self.rpID = rpID - self.uri = uri - } } // swiftlint:disable:this file_length diff --git a/BitwardenShared/Core/Vault/Repositories/VaultRepositoryTests.swift b/BitwardenShared/Core/Vault/Repositories/VaultRepositoryTests.swift index 7a84e8ed5..876677d06 100644 --- a/BitwardenShared/Core/Vault/Repositories/VaultRepositoryTests.swift +++ b/BitwardenShared/Core/Vault/Repositories/VaultRepositoryTests.swift @@ -1100,1195 +1100,6 @@ class VaultRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_b } } - /// `searchCipherAutofillPublisher(availableFido2CredentialsPublisher:mode:filterType:rpID:searchText:)` - /// returns search matching cipher name in passwords mode. - func test_searchCipherAutofillPublisher_searchText_name() async throws { - stateService.activeAccount = .fixtureAccountLogin() - cipherService.ciphersSubject.value = [ - .fixture(id: "1", name: "dabcd", type: .login), - .fixture(id: "2", name: "qwe", type: .login), - .fixture(id: "3", name: "Café", type: .login), - ] - let cipherListView = try CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value.last)) - var iterator = try await subject - .searchCipherAutofillPublisher( - availableFido2CredentialsPublisher: MockFido2UserInterfaceHelper() - .availableCredentialsForAuthenticationPublisher(), - mode: .passwords, - filter: VaultListFilter(filterType: .allVaults), - rpID: nil, - searchText: "cafe", - ) - .makeAsyncIterator() - let sections = try await iterator.next()?.sections - XCTAssertEqual( - sections, - [ - VaultListSection( - id: "", - items: [ - VaultListItem( - cipherListView: cipherListView, - )!, - ], - name: "", - ), - ], - ) - } - - /// `searchCipherAutofillPublisher(availableFido2CredentialsPublisher:mode:filterType:rpID:searchText:)` - /// returns matching ciphers excludes items from trash in passwords mode. - func test_searchCipherAutofillPublisher_searchText_excludesTrashedItems() async throws { - stateService.activeAccount = .fixtureAccountLogin() - cipherService.ciphersSubject.value = [ - .fixture(id: "1", name: "dabcd"), - .fixture(id: "2", name: "qwe"), - .fixture(deletedDate: .now, id: "3", name: "deleted Café"), - .fixture(id: "4", name: "Café"), - ] - let cipherListView = try CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value.last)) - var iterator = try await subject - .searchCipherAutofillPublisher( - availableFido2CredentialsPublisher: MockFido2UserInterfaceHelper() - .availableCredentialsForAuthenticationPublisher(), - mode: .passwords, - filter: VaultListFilter(filterType: .allVaults), - rpID: nil, - searchText: "cafe", - ) - .makeAsyncIterator() - let sections = try await iterator.next()?.sections - XCTAssertEqual( - sections, - [ - VaultListSection( - id: "", - items: [ - VaultListItem(cipherListView: cipherListView)!, - ], - name: "", - ), - ], - ) - } - - /// `searchCipherAutofillPublisher(availableFido2CredentialsPublisher:mode:filterType:rpID:searchText:)` - /// returns search matching cipher id in passwords mode. - func test_searchCipherAutofillPublisher_searchText_id() async throws { - stateService.activeAccount = .fixtureAccountLogin() - cipherService.ciphersSubject.value = [ - .fixture(id: "1223123", name: "dabcd"), - .fixture(id: "31232131245435234", name: "qwe"), - .fixture(id: "434343434", name: "Café"), - ] - let cipherListView = try CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value[1])) - var iterator = try await subject - .searchCipherAutofillPublisher( - availableFido2CredentialsPublisher: MockFido2UserInterfaceHelper() - .availableCredentialsForAuthenticationPublisher(), - mode: .passwords, - filter: VaultListFilter(filterType: .allVaults), - rpID: nil, - searchText: "312321312", - ) - .makeAsyncIterator() - let sections = try await iterator.next()?.sections - XCTAssertEqual( - sections, - [ - VaultListSection( - id: "", - items: [ - VaultListItem( - cipherListView: cipherListView, - )!, - ], - name: "", - ), - ], - ) - } - - /// `searchCipherAutofillPublisher(availableFido2CredentialsPublisher:mode:filterType:rpID:searchText:)` - /// returns matching ciphers and only includes login items in passwords mode - func test_searchCipherAutofillPublisher_searchText_includesOnlyLogins() async throws { - stateService.activeAccount = .fixtureAccountLogin() - cipherService.ciphersSubject.value = [ - .fixture(id: "1", name: "Café", type: .card), - .fixture(id: "2", name: "Café", type: .identity), - .fixture(id: "4", name: "Café", type: .secureNote), - .fixture(id: "3", name: "Café", type: .login), - ] - let cipherListView = try CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value.last)) - var iterator = try await subject - .searchCipherAutofillPublisher( - availableFido2CredentialsPublisher: MockFido2UserInterfaceHelper() - .availableCredentialsForAuthenticationPublisher(), - mode: .passwords, - filter: VaultListFilter(filterType: .allVaults), - rpID: nil, - searchText: "cafe", - ) - .makeAsyncIterator() - let sections = try await iterator.next()?.sections - XCTAssertEqual( - sections, - [ - VaultListSection( - id: "", - items: [ - VaultListItem( - cipherListView: cipherListView, - )!, - ], - name: "", - ), - ], - ) - } - - /// `searchCipherAutofillPublisher(searchText:, filterType:)` returns search matching cipher URI - /// in passwords mode. - func test_searchCipherAutofillPublisher_searchText_uri() async throws { - stateService.activeAccount = .fixtureAccountLogin() - cipherService.ciphersSubject.value = [ - .fixture(id: "1", name: "dabcd"), - .fixture(id: "2", name: "qwe"), - .fixture( - id: "3", - login: .init( - username: "name", - password: "pwd", - passwordRevisionDate: nil, - uris: [.fixture(uri: "www.domain.com", match: .domain)], - totp: nil, - autofillOnPageLoad: nil, - fido2Credentials: nil, - ), - name: "Café", - ), - ] - let cipherListView = try CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value.last)) - var iterator = try await subject - .searchCipherAutofillPublisher( - availableFido2CredentialsPublisher: MockFido2UserInterfaceHelper() - .availableCredentialsForAuthenticationPublisher(), - mode: .passwords, - filter: VaultListFilter(filterType: .allVaults), - rpID: nil, - searchText: "domain", - ) - .makeAsyncIterator() - let sections = try await iterator.next()?.sections - XCTAssertEqual( - sections, - [ - VaultListSection( - id: "", - items: [ - VaultListItem( - cipherListView: cipherListView, - )!, - ], - name: "", - ), - ], - ) - } - - /// `searchCipherAutofillPublisher(availableFido2CredentialsPublisher:mode:filterType:rpID:searchText:)` - /// returns search matching cipher name in `.combinedMultipleSections` mode. - func test_searchCipherAutofillPublisher_mode_combinedMultiple() async throws { - // swiftlint:disable:previous function_body_length - stateService.activeAccount = .fixtureAccountLogin() - let expectedCredentialId = Data(repeating: 123, count: 16) - setupDefaultDecryptFido2AutofillCredentialsMocker(expectedCredentialId: expectedCredentialId) - let ciphers = [ - Cipher.fixture(id: "1", name: "dabcd", type: .login), - Cipher.fixture(id: "2", name: "qwe", type: .login), - Cipher.fixture(id: "3", name: "Café", type: .login), - ] - cipherService.ciphersSubject.value = ciphers - let cipherListView = try CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value.last)) - - cipherService.fetchCipherByIdResult = { cipherId in - switch cipherId { - case "1": - .success(ciphers[0]) - case "2": - .success(ciphers[1]) - case "3": - .success(ciphers[2]) - default: - .success(.fixture()) - } - } - - await fido2UserInterfaceHelper.credentialsForAuthenticationSubject.send([ - .fixture(id: "2", name: "qwe", type: .login), - .fixture(id: "3", name: "Café", type: .login), - .fixture(id: "4"), - ]) - var iterator = try await subject - .searchCipherAutofillPublisher( - availableFido2CredentialsPublisher: fido2UserInterfaceHelper - .availableCredentialsForAuthenticationPublisher(), - mode: .combinedMultipleSections, - filter: VaultListFilter(filterType: .allVaults), - rpID: "myApp.com", - searchText: "cafe", - ) - .makeAsyncIterator() - let vaultListData = try await iterator.next() - let sections = try XCTUnwrap(vaultListData?.sections) - - XCTAssertEqual( - sections[0], - VaultListSection( - id: Localizations.passkeysForX("cafe"), - items: ciphers.suffix(from: 2).compactMap { cipher in - VaultListItem( - cipherListView: CipherListView(cipher: cipher), - fido2CredentialAutofillView: .fixture( - credentialId: expectedCredentialId, - cipherId: cipher.id ?? "", - rpId: "myApp.com", - ), - ) - }, - name: Localizations.passkeysForX("cafe"), - ), - ) - XCTAssertEqual( - sections[1], - VaultListSection( - id: Localizations.passwordsForX("cafe"), - items: [VaultListItem(cipherListView: cipherListView)!], - name: Localizations.passwordsForX("cafe"), - ), - ) - } - - /// `searchCipherAutofillPublisher(availableFido2CredentialsPublisher:mode:filterType:rpID:searchText:)` - /// returns search matching cipher name in `.combinedMultipleSections` mode. - func test_searchCipherAutofillPublisher_mode_combinedMultiple_noSearchResults() async throws { - stateService.activeAccount = .fixtureAccountLogin() - let expectedCredentialId = Data(repeating: 123, count: 16) - setupDefaultDecryptFido2AutofillCredentialsMocker(expectedCredentialId: expectedCredentialId) - cipherService.ciphersSubject.value = [] - - await fido2UserInterfaceHelper.credentialsForAuthenticationSubject.send([ - .fixture(id: "2", name: "qwe", type: .login), - .fixture(id: "3", name: "Café", type: .login), - .fixture(id: "4"), - ]) - var iterator = try await subject - .searchCipherAutofillPublisher( - availableFido2CredentialsPublisher: fido2UserInterfaceHelper - .availableCredentialsForAuthenticationPublisher(), - mode: .combinedMultipleSections, - filter: VaultListFilter(filterType: .allVaults), - rpID: "myApp.com", - searchText: "cafe", - ) - .makeAsyncIterator() - let vaultListData = try await iterator.next() - let sections = try XCTUnwrap(vaultListData?.sections) - - XCTAssertTrue(sections.isEmpty) - } - - /// `searchCipherAutofillPublisher(availableFido2CredentialsPublisher:mode:filterType:rpID:searchText:)` - /// returns search matching cipher name in `.combinedMultipleSections` mode with empty available credentials. - func test_searchCipherAutofillPublisher_mode_combinedMultiple_noAvailableCredentials() async throws { - stateService.activeAccount = .fixtureAccountLogin() - let expectedCredentialId = Data(repeating: 123, count: 16) - setupDefaultDecryptFido2AutofillCredentialsMocker(expectedCredentialId: expectedCredentialId) - let ciphers = [ - Cipher.fixture(id: "1", name: "dabcd", type: .login), - Cipher.fixture(id: "2", name: "qwe", type: .login), - Cipher.fixture(id: "3", name: "Café", type: .login), - ] - cipherService.ciphersSubject.value = ciphers - let cipherListView = try CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value.last)) - - await fido2UserInterfaceHelper.credentialsForAuthenticationSubject.send([]) - var iterator = try await subject - .searchCipherAutofillPublisher( - availableFido2CredentialsPublisher: fido2UserInterfaceHelper - .availableCredentialsForAuthenticationPublisher(), - mode: .combinedMultipleSections, - filter: VaultListFilter(filterType: .allVaults), - rpID: "myApp.com", - searchText: "cafe", - ) - .makeAsyncIterator() - let vaultListData = try await iterator.next() - let sections = try XCTUnwrap(vaultListData?.sections) - - XCTAssertEqual( - sections[0], - VaultListSection( - id: Localizations.passwordsForX("cafe"), - items: [VaultListItem(cipherListView: cipherListView)!], - name: Localizations.passwordsForX("cafe"), - ), - ) - } - - /// `searchCipherAutofillPublisher(availableFido2CredentialsPublisher:mode:filterType:rpID:searchText:)` - /// returns search matching cipher name in `.combinedMultipleSections` mode - /// throwing when decrypting Fido2 credentials. - func test_searchCipherAutofillPublisher_mode_combinedMultiple_throwingWhenDecryptingFido2() async throws { - stateService.activeAccount = .fixtureAccountLogin() - - clientService.mockPlatform.fido2Mock.decryptFido2AutofillCredentialsMocker - .throwing(BitwardenTestError.example) - - let ciphers = [ - Cipher.fixture(id: "1", name: "dabcd", type: .login), - Cipher.fixture(id: "2", name: "qwe", type: .login), - Cipher.fixture(id: "3", name: "Café", type: .login), - ] - cipherService.ciphersSubject.value = ciphers - - cipherService.fetchCipherByIdResult = { cipherId in - switch cipherId { - case "1": - .success(ciphers[0]) - case "2": - .success(ciphers[1]) - case "3": - .success(ciphers[2]) - default: - .success(.fixture()) - } - } - - await fido2UserInterfaceHelper.credentialsForAuthenticationSubject.send([ - .fixture(id: "2", name: "qwe", type: .login), - .fixture(id: "3", name: "Café", type: .login), - .fixture(id: "4"), - ]) - var iterator = try await subject - .searchCipherAutofillPublisher( - availableFido2CredentialsPublisher: fido2UserInterfaceHelper - .availableCredentialsForAuthenticationPublisher(), - mode: .combinedMultipleSections, - filter: VaultListFilter(filterType: .allVaults), - rpID: "myApp.com", - searchText: "cafe", - ) - .makeAsyncIterator() - await assertAsyncThrows(error: BitwardenTestError.example) { - _ = try await iterator.next() - } - } - - /// `searchCipherAutofillPublisher(availableFido2CredentialsPublisher:mode:filterType:rpID:searchText:)` - /// returns search matching cipher name in `.combinedSingleSection` mode. - func test_searchCipherAutofillPublisher_mode_combinedSingle() async throws { - // swiftlint:disable:previous function_body_length - stateService.activeAccount = .fixtureAccountLogin() - let expectedCredentialId = Data(repeating: 123, count: 16) - setupDefaultDecryptFido2AutofillCredentialsMocker(expectedCredentialId: expectedCredentialId) - let ciphers = [ - Cipher.fixture(id: "1", name: "dabcd", type: .login), - Cipher.fixture(id: "2", name: "qwe", type: .login), - Cipher.fixture(id: "3", name: "Café", type: .login), - Cipher.fixture( - id: "4", - login: .fixture( - fido2Credentials: [.fixture()], - ), - name: "Cafffffffe", - type: .login, - ), - ] - cipherService.ciphersSubject.value = ciphers - - cipherService.fetchCipherByIdResult = { cipherId in - guard let cipherIntId = Int(cipherId), cipherIntId <= ciphers.count else { - return .success(.fixture()) - } - return .success(ciphers[cipherIntId - 1]) - } - - var iterator = try await subject - .searchCipherAutofillPublisher( - availableFido2CredentialsPublisher: fido2UserInterfaceHelper - .availableCredentialsForAuthenticationPublisher(), - mode: .combinedSingleSection, - filter: VaultListFilter(filterType: .allVaults), - rpID: "myApp.com", - searchText: "caf", - ) - .makeAsyncIterator() - let vaultListData = try await iterator.next() - let sections = try XCTUnwrap(vaultListData?.sections) - - XCTAssertEqual( - sections[0], - VaultListSection( - id: Localizations.chooseALoginToSaveThisPasskeyTo, - items: [ - VaultListItem( - cipherListView: CipherListView(cipher: ciphers[2]), - )!, - VaultListItem( - cipherListView: CipherListView(cipher: ciphers[3]), - fido2CredentialAutofillView: .fixture( - credentialId: expectedCredentialId, - cipherId: ciphers[3].id ?? "", - rpId: "myApp.com", - ), - )!, - ], - name: Localizations.chooseALoginToSaveThisPasskeyTo, - ), - ) - } - - /// `searchCipherAutofillPublisher(availableFido2CredentialsPublisher:mode:filterType:rpID:searchText:)` - /// returns empty matching cipher name in `.combinedMultipleSections` mode because of no search results.. - func test_searchCipherAutofillPublisher_mode_combinedSingle_noSearchResults() async throws { - stateService.activeAccount = .fixtureAccountLogin() - let expectedCredentialId = Data(repeating: 123, count: 16) - setupDefaultDecryptFido2AutofillCredentialsMocker(expectedCredentialId: expectedCredentialId) - cipherService.ciphersSubject.value = [] - - var iterator = try await subject - .searchCipherAutofillPublisher( - availableFido2CredentialsPublisher: fido2UserInterfaceHelper - .availableCredentialsForAuthenticationPublisher(), - mode: .combinedSingleSection, - filter: VaultListFilter(filterType: .allVaults), - rpID: "myApp.com", - searchText: "cafe", - ) - .makeAsyncIterator() - let vaultListData = try await iterator.next() - let sections = try XCTUnwrap(vaultListData?.sections) - - XCTAssertTrue(sections.isEmpty) - } - - /// `searchCipherAutofillPublisher(availableFido2CredentialsPublisher:mode:filterType:rpID:searchText:)` - /// throws when in `.combinedSingleSection` mode and decrypting Fido2 credentials throws.. - func test_searchCipherAutofillPublisher_mode_combinedSingle_throwingWhenDecryptingFido2() async throws { - stateService.activeAccount = .fixtureAccountLogin() - - clientService.mockPlatform.fido2Mock.decryptFido2AutofillCredentialsMocker - .throwing(BitwardenTestError.example) - - let ciphers = [ - Cipher.fixture(id: "1", name: "dabcd", type: .login), - Cipher.fixture(id: "2", name: "qwe", type: .login), - Cipher.fixture(id: "3", name: "Café", type: .login), - Cipher.fixture( - id: "4", - login: .fixture( - fido2Credentials: [.fixture()], - ), - name: "Cafffffffe", - type: .login, - ), - ] - cipherService.ciphersSubject.value = ciphers - - cipherService.fetchCipherByIdResult = { cipherId in - guard let cipherIntId = Int(cipherId), cipherIntId <= ciphers.count else { - return .success(.fixture()) - } - return .success(ciphers[cipherIntId - 1]) - } - - var iterator = try await subject - .searchCipherAutofillPublisher( - availableFido2CredentialsPublisher: fido2UserInterfaceHelper - .availableCredentialsForAuthenticationPublisher(), - mode: .combinedSingleSection, - filter: VaultListFilter(filterType: .allVaults), - rpID: "myApp.com", - searchText: "caf", - ) - .makeAsyncIterator() - - await assertAsyncThrows(error: BitwardenTestError.example) { - _ = try await iterator.next() - } - } - - /// `searchCipherAutofillPublisher(searchText,filterType:)` only returns ciphers based on - /// search text and VaultFilterType in passwords mode. - func test_searchCipherAutofillPublisher_vaultType() async throws { - stateService.activeAccount = .fixtureAccountLogin() - cipherService.ciphersSubject.value = [ - .fixture(id: "1", name: "bcd", organizationId: "testOrg"), - .fixture(id: "2", name: "bcdew"), - .fixture(id: "3", name: "dabcd"), - ] - let cipherListView = try CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value.first)) - var iterator = try await subject - .searchCipherAutofillPublisher( - availableFido2CredentialsPublisher: MockFido2UserInterfaceHelper() - .availableCredentialsForAuthenticationPublisher(), - mode: .passwords, - filter: VaultListFilter(filterType: .organization(.fixture(id: "testOrg"))), - rpID: nil, - searchText: "bcd", - ) - .makeAsyncIterator() - let sections = try await iterator.next()?.sections - XCTAssertEqual( - sections, - [ - VaultListSection( - id: "", - items: [ - VaultListItem( - cipherListView: cipherListView, - )!, - ], - name: "", - ), - ], - ) - } - - /// `searchCipherAutofillPublisher(availableFido2CredentialsPublisher:mode:filterType:rpID:searchText:)` - /// returns search matching cipher name in `.totp` mode. - func test_searchCipherAutofillPublisher_mode_totp() async throws { - stateService.activeAccount = .fixtureAccountLogin() - let ciphers = [ - Cipher.fixture(id: "1", name: "dabcd", type: .login), - Cipher.fixture(id: "2", name: "qwe", type: .login), - Cipher.fixture(id: "3", name: "Café", type: .login), - Cipher.fixture( - id: "4", - login: .fixture( - totp: "123", - ), - name: "Cafffffffe", - type: .login, - ), - ] - cipherService.ciphersSubject.value = ciphers - - var iterator = try await subject - .searchCipherAutofillPublisher( - availableFido2CredentialsPublisher: fido2UserInterfaceHelper - .availableCredentialsForAuthenticationPublisher(), - mode: .totp, - filter: VaultListFilter(filterType: .allVaults), - rpID: nil, - searchText: "caf", - ) - .makeAsyncIterator() - let vaultListData = try await iterator.next() - let sections = try XCTUnwrap(vaultListData?.sections) - - assertInlineSnapshot(of: dumpVaultListSections(sections), as: .lines) { - """ - Section: - - TOTP: 4 Cafffffffe 123 456 - """ - } - } - - /// `searchCipherAutofillPublisher(availableFido2CredentialsPublisher:mode:filterType:rpID:searchText:)` - /// returns empty items in `.totp` mode when totp generation throws. - func test_searchCipherAutofillPublisher_mode_totpGenerationThrows() async throws { - stateService.activeAccount = .fixtureAccountLogin() - let ciphers = [ - Cipher.fixture(id: "1", name: "dabcd", type: .login), - Cipher.fixture(id: "2", name: "qwe", type: .login), - Cipher.fixture(id: "3", name: "Café", type: .login), - Cipher.fixture( - id: "4", - login: .fixture( - totp: "123", - ), - name: "Cafffffffe", - type: .login, - ), - ] - cipherService.ciphersSubject.value = ciphers - clientService.mockVault.generateTOTPCodeResult = .failure(BitwardenTestError.example) - - var iterator = try await subject - .searchCipherAutofillPublisher( - availableFido2CredentialsPublisher: fido2UserInterfaceHelper - .availableCredentialsForAuthenticationPublisher(), - mode: .totp, - filter: VaultListFilter(filterType: .allVaults), - rpID: nil, - searchText: "caf", - ) - .makeAsyncIterator() - let vaultListData = try await iterator.next() - let sections = try XCTUnwrap(vaultListData?.sections) - - XCTAssertTrue(sections.isEmpty) - XCTAssertEqual( - errorReporter.errors as? [TOTPServiceError], - [.unableToGenerateCode("Unable to create TOTP code for cipher id 4")], - ) - } - - /// `searchCipherAutofillPublisher(availableFido2CredentialsPublisher:mode:filterType:rpID:searchText:)` - /// returns search matching cipher name in `.all` mode. - @MainActor - func test_searchCipherAutofillPublisher_searchText_name_allMode() async throws { - stateService.activeAccount = .fixtureAccountLogin() - cipherService.ciphersSubject.value = [ - .fixture(id: "1", name: "dabcd", type: .login), - .fixture(id: "2", name: "qwe", type: .secureNote), - .fixture(id: "3", name: "Café", type: .identity), - .fixture(id: "4", name: "Caféeee", type: .card), - .fixture(id: "5", name: "Cafée12312ee", type: .sshKey), - ] - var iterator = try await subject - .searchCipherAutofillPublisher( - availableFido2CredentialsPublisher: MockFido2UserInterfaceHelper() - .availableCredentialsForAuthenticationPublisher(), - mode: .all, - filter: VaultListFilter(filterType: .allVaults), - rpID: nil, - searchText: "cafe", - ) - .makeAsyncIterator() - let sections = try await iterator.next()?.sections - XCTAssertEqual(sections?.count, 1) - let section = try XCTUnwrap(sections?.first) - XCTAssertEqual(section.items.count, 3) - XCTAssertEqual(section.items[0].id, "3") - XCTAssertEqual(section.items[1].id, "5") - XCTAssertEqual(section.items[2].id, "4") - } - - /// `searchCipherAutofillPublisher(availableFido2CredentialsPublisher:mode:filterType:rpID:searchText:)` - /// returns search matching cipher name in `.all` mode and `.identity` group. - @MainActor - func test_searchCipherAutofillPublisher_searchText_name_allModeIdentityGroup() async throws { - stateService.activeAccount = .fixtureAccountLogin() - cipherService.ciphersSubject.value = [ - .fixture(id: "1", name: "dabcd", type: .login), - .fixture(id: "2", name: "qwe", type: .secureNote), - .fixture(id: "3", name: "Café", type: .identity), - .fixture(id: "4", name: "Caféeee", type: .card), - .fixture(id: "5", name: "Cafée12312ee", type: .sshKey), - ] - var iterator = try await subject - .searchCipherAutofillPublisher( - availableFido2CredentialsPublisher: MockFido2UserInterfaceHelper() - .availableCredentialsForAuthenticationPublisher(), - mode: .all, - filter: VaultListFilter(filterType: .allVaults), - group: .identity, - rpID: nil, - searchText: "cafe", - ) - .makeAsyncIterator() - let sections = try await iterator.next()?.sections - XCTAssertEqual(sections?.count, 1) - let section = try XCTUnwrap(sections?.first) - XCTAssertEqual(section.items.count, 1) - XCTAssertEqual(section.items[0].id, "3") - } - - /// `searchVaultListPublisher(searchText:, filterType:)` returns search matching cipher name. - func test_searchVaultListPublisher_searchText_name() async throws { - stateService.activeAccount = .fixtureAccountLogin() - cipherService.ciphersSubject.value = [ - .fixture(id: "1", name: "dabcd"), - .fixture(id: "2", name: "qwe"), - .fixture(id: "3", name: "Café"), - ] - let cipherListView = try CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value.last)) - let expectedSearchResult = try [XCTUnwrap(VaultListItem(cipherListView: cipherListView))] - var iterator = try await subject - .searchVaultListPublisher(searchText: "cafe", filter: VaultListFilter(filterType: .allVaults)) - .makeAsyncIterator() - let ciphers = try await iterator.next() - XCTAssertEqual(ciphers, expectedSearchResult) - } - - /// `searchVaultListPublisher(searchText:, filterType:)` returns search matching cipher name - /// excludes items from trash. - func test_searchVaultListPublisher_searchText_excludesTrashedItems() async throws { - stateService.activeAccount = .fixtureAccountLogin() - cipherService.ciphersSubject.value = [ - .fixture(id: "1", name: "dabcd"), - .fixture(id: "2", name: "qwe"), - .fixture(deletedDate: .now, id: "3", name: "deleted Café"), - .fixture(id: "4", name: "Café"), - ] - let cipherListView = try CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value.last)) - let expectedSearchResult = try [XCTUnwrap(VaultListItem(cipherListView: cipherListView))] - var iterator = try await subject - .searchVaultListPublisher(searchText: "cafe", filter: VaultListFilter(filterType: .allVaults)) - .makeAsyncIterator() - let ciphers = try await iterator.next() - XCTAssertEqual(ciphers, expectedSearchResult) - } - - /// `searchVaultListPublisher(searchText:, group: .trash, filterType:)` - /// returns only matching items form the trash. - func test_searchVaultListPublisher_searchText_trashGroup() async throws { - stateService.activeAccount = .fixture() - cipherService.ciphersSubject.value = [ - .fixture(id: "1", name: "dabcd"), - .fixture(id: "2", name: "qwe"), - .fixture(deletedDate: .now, id: "3", name: "deleted Café"), - .fixture(id: "4", name: "Café"), - ] - let cipherListView = try CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value[2])) - let expectedSearchResult = try [XCTUnwrap(VaultListItem(cipherListView: cipherListView))] - var iterator = try await subject - .searchVaultListPublisher( - searchText: "cafe", - group: .trash, - filter: VaultListFilter(filterType: .allVaults), - ) - .makeAsyncIterator() - let ciphers = try await iterator.next() - XCTAssertEqual(ciphers, expectedSearchResult) - } - - /// `searchVaultListPublisher(searchText:, group: .card, filterType:)` - /// returns search results with card items matching a name. - func test_searchVaultListPublisher_searchText_cardGroup() async throws { - stateService.activeAccount = .fixture() - cipherService.ciphersSubject.value = [ - .fixture(id: "1", name: "café", type: .card), - .fixture(id: "2", name: "cafepass", type: .login), - .fixture(deletedDate: .now, id: "3", name: "deleted Café"), - .fixture(id: "4", name: "Café Friend", type: .identity), - .fixture(id: "5", name: "Café thoughts", type: .secureNote), - .fixture( - id: "5", - login: .fixture(totp: .standardTotpKey), - name: "one time cafefe", - type: .login, - ), - .fixture(id: "6", name: "Some sshkey", type: .sshKey), - ] - let cipherListView = try CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value[0])) - let expectedSearchResult = try [XCTUnwrap(VaultListItem(cipherListView: cipherListView))] - var iterator = try await subject - .searchVaultListPublisher( - searchText: "cafe", - group: .card, - filter: VaultListFilter(filterType: .allVaults), - ) - .makeAsyncIterator() - let ciphers = try await iterator.next() - XCTAssertEqual(ciphers, expectedSearchResult) - } - - /// `searchVaultListPublisher(searchText:, group: .card, filterType:)` - /// returns search items matching a cipher name within a folder. - func test_searchVaultListPublisher_searchText_folderGroup() async throws { - stateService.activeAccount = .fixture() - cipherService.ciphersSubject.value = [ - .fixture(id: "1", name: "café", type: .card), - .fixture(id: "2", name: "cafepass", type: .login), - .fixture(deletedDate: .now, id: "3", name: "deleted Café"), - .fixture( - folderId: "coffee", - id: "0", - name: "Best Cafes", - type: .secureNote, - ), - .fixture(id: "4", name: "Café Friend", type: .identity), - .fixture(id: "5", name: "Café thoughts", type: .secureNote), - .fixture( - id: "5", - login: .fixture(totp: .standardTotpKey), - name: "one time cafefe", - type: .login, - ), - .fixture(id: "6", name: "Some sshkey", type: .sshKey), - ] - let cipherListView = try CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value[3])) - let expectedSearchResult = try [XCTUnwrap(VaultListItem(cipherListView: cipherListView))] - var iterator = try await subject - .searchVaultListPublisher( - searchText: "cafe", - group: .folder(id: "coffee", name: "Caff-fiend"), - filter: VaultListFilter(filterType: .allVaults), - ) - .makeAsyncIterator() - let ciphers = try await iterator.next() - XCTAssertEqual(ciphers, expectedSearchResult) - } - - /// `searchVaultListPublisher(searchText:, group: .collection, filterType:)` - /// returns search items matching a cipher name within collections. - func test_searchVaultListPublisher_searchText_collection() async throws { - stateService.activeAccount = .fixture() - cipherService.ciphersSubject.value = [ - .fixture(id: "1", name: "café", type: .card), - .fixture(id: "2", name: "cafepass", type: .login), - .fixture(deletedDate: .now, id: "3", name: "deleted Café"), - .fixture( - folderId: "coffee", - id: "0", - name: "Best Cafes", - type: .secureNote, - ), - .fixture( - collectionIds: ["123", "meep"], - id: "4", - name: "Café Friend", - type: .identity, - ), - .fixture(id: "5", name: "Café thoughts", type: .secureNote), - .fixture( - id: "5", - login: .fixture(totp: .standardTotpKey), - name: "one time cafefe", - type: .login, - ), - .fixture(id: "6", name: "Some sshkey", type: .sshKey), - ] - let cipherListView = try CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value[4])) - let expectedSearchResult = try [XCTUnwrap(VaultListItem(cipherListView: cipherListView))] - var iterator = try await subject - .searchVaultListPublisher( - searchText: "cafe", - group: .collection( - id: "123", - name: "The beans", - organizationId: "Giv-em-da-beanz", - ), - filter: VaultListFilter(filterType: .allVaults), - ) - .makeAsyncIterator() - let ciphers = try await iterator.next() - XCTAssertEqual(ciphers, expectedSearchResult) - } - - /// `searchVaultListPublisher(searchText:, group: .identity, filterType:)` - /// returns search matching cipher name for identities. - func test_searchVaultListPublisher_searchText_identity() async throws { - stateService.activeAccount = .fixture() - cipherService.ciphersSubject.value = [ - .fixture(id: "1", name: "café", type: .card), - .fixture(id: "2", name: "cafepass", type: .login), - .fixture(deletedDate: .now, id: "3", name: "deleted Café"), - .fixture( - folderId: "coffee", - id: "0", - name: "Best Cafes", - type: .secureNote, - ), - .fixture( - collectionIds: ["123", "meep"], - id: "4", - name: "Café Friend", - type: .identity, - ), - .fixture(id: "5", name: "Café thoughts", type: .secureNote), - .fixture( - id: "5", - login: .fixture(totp: .standardTotpKey), - name: "one time cafefe", - type: .login, - ), - .fixture(id: "6", name: "Some sshkey", type: .sshKey), - ] - let cipherListView = try CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value[4])) - let expectedSearchResult = try [XCTUnwrap(VaultListItem(cipherListView: cipherListView))] - var iterator = try await subject - .searchVaultListPublisher( - searchText: "cafe", - group: .identity, - filter: VaultListFilter(filterType: .allVaults), - ) - .makeAsyncIterator() - let ciphers = try await iterator.next() - XCTAssertEqual(ciphers, expectedSearchResult) - } - - /// `searchVaultListPublisher(searchText:, group: .login, filterType:)` - /// returns search matching cipher name for login items. - func test_searchVaultListPublisher_searchText_login() async throws { - stateService.activeAccount = .fixture() - cipherService.ciphersSubject.value = [ - .fixture(id: "1", name: "café", type: .card), - .fixture(id: "2", name: "cafepass", type: .login), - .fixture(deletedDate: .now, id: "3", name: "deleted Café"), - .fixture( - folderId: "coffee", - id: "0", - name: "Best Cafes", - type: .secureNote, - ), - .fixture( - collectionIds: ["123", "meep"], - id: "4", - name: "Café Friend", - type: .identity, - ), - .fixture(id: "5", name: "Café thoughts", type: .secureNote), - .fixture( - id: "6", - login: .fixture(totp: .standardTotpKey), - name: "one time cafefe", - type: .login, - ), - .fixture(id: "6", name: "Some sshkey", type: .sshKey), - ] - let expectedSearchResult = try [ - XCTUnwrap( - VaultListItem( - cipherListView: CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value[1])), - ), - ), - XCTUnwrap( - VaultListItem( - cipherListView: CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value[6])), - ), - ), - ] - var iterator = try await subject - .searchVaultListPublisher( - searchText: "cafe", - group: .login, - filter: VaultListFilter(filterType: .allVaults), - ) - .makeAsyncIterator() - let ciphers = try await iterator.next() - XCTAssertEqual(ciphers, expectedSearchResult) - } - - /// `searchVaultListPublisher(searchText:, group: .secureNote, filterType:)` - /// returns search matching cipher name for secure note items. - func test_searchVaultListPublisher_searchText_secureNote() async throws { - stateService.activeAccount = .fixture() - cipherService.ciphersSubject.value = [ - .fixture(id: "1", name: "café", type: .card), - .fixture(id: "2", name: "cafepass", type: .login), - .fixture(deletedDate: .now, id: "3", name: "deleted Café"), - .fixture( - folderId: "coffee", - id: "0", - name: "Best Cafes", - type: .secureNote, - ), - .fixture( - collectionIds: ["123", "meep"], - id: "4", - name: "Café Friend", - type: .identity, - ), - .fixture(id: "5", name: "Café thoughts", type: .secureNote), - .fixture( - id: "6", - login: .fixture(totp: .standardTotpKey), - name: "one time cafefe", - type: .login, - ), - .fixture(id: "6", name: "Some sshkey", type: .sshKey), - ] - let expectedSearchResult = try [ - XCTUnwrap( - VaultListItem( - cipherListView: CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value[3])), - ), - ), - XCTUnwrap( - VaultListItem( - cipherListView: CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value[5])), - ), - ), - ] - var iterator = try await subject - .searchVaultListPublisher( - searchText: "cafe", - group: .secureNote, - filter: VaultListFilter(filterType: .allVaults), - ) - .makeAsyncIterator() - let ciphers = try await iterator.next() - XCTAssertEqual(ciphers, expectedSearchResult) - } - - /// `searchVaultListPublisher(searchText:, group: .sshKey, filterType:)` - /// returns search matching cipher name for SSH key items. - @MainActor - func test_searchVaultListPublisher_searchText_sshKey() async throws { - stateService.activeAccount = .fixture() - cipherService.ciphersSubject.value = [ - .fixture(id: "1", name: "café", type: .card), - .fixture(id: "2", name: "cafepass", type: .login), - .fixture(deletedDate: .now, id: "3", name: "deleted Café"), - .fixture( - folderId: "coffee", - id: "0", - name: "Best Cafes", - type: .secureNote, - ), - .fixture( - collectionIds: ["123", "meep"], - id: "4", - name: "Café Friend", - type: .identity, - ), - .fixture(id: "5", name: "Café thoughts", type: .secureNote), - .fixture( - id: "6", - login: .fixture(totp: .standardTotpKey), - name: "one time cafefe", - type: .login, - ), - .fixture(id: "7", name: "cafe", type: .sshKey), - ] - let expectedSearchResult = try [ - XCTUnwrap( - VaultListItem( - cipherListView: CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value[7])), - ), - ), - ] - var iterator = try await subject - .searchVaultListPublisher( - searchText: "cafe", - group: .sshKey, - filter: VaultListFilter(filterType: .allVaults), - ) - .makeAsyncIterator() - let ciphers = try await iterator.next() - XCTAssertEqual(ciphers, expectedSearchResult) - } - - /// `searchVaultListPublisher(searchText:, group: .totp, filterType:)` - /// returns search matching cipher name for TOTP login items. - func test_searchVaultListPublisher_searchText_totp() async throws { - stateService.activeAccount = .fixture() - cipherService.ciphersSubject.value = [ - .fixture(id: "1", name: "café", type: .login), - .fixture(id: "2", name: "cafepass", type: .login), - .fixture(id: "5", name: "Café thoughts", type: .login), - .fixture( - id: "6", - login: .fixture(totp: .standardTotpKey), - name: "one time cafefe", - type: .login, - ), - ] - let totpCipher = try CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value[3])) - guard case .login = totpCipher.type else { - XCTFail("Cipher type should be login.") - return - } - - let expectedResults = try [ - VaultListItem( - id: "6", - itemType: .totp( - name: "one time cafefe", - totpModel: .init( - id: "6", - cipherListView: XCTUnwrap(totpCipher), - requiresMasterPassword: false, - totpCode: .init( - code: "123456", - codeGenerationDate: timeProvider.presentTime, - period: 30, - ), - ), - ), - ), - ] - - var iterator = try await subject - .searchVaultListPublisher( - searchText: "cafe", - group: .totp, - filter: VaultListFilter(filterType: .allVaults), - ) - .makeAsyncIterator() - let ciphers = try await iterator.next() - XCTAssertEqual(ciphers, expectedResults) - } - - /// `searchVaultListPublisher(searchText:, filterType:)` returns search matching cipher id. - func test_searchVaultListPublisher_searchText_id() async throws { - stateService.activeAccount = .fixtureAccountLogin() - cipherService.ciphersSubject.value = [ - .fixture(id: "1223123", name: "dabcd"), - .fixture(id: "31232131245435234", name: "qwe"), - .fixture(id: "434343434", name: "Café"), - ] - let cipherListView = try CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value[1])) - let expectedSearchResult = try [XCTUnwrap(VaultListItem(cipherListView: cipherListView))] - var iterator = try await subject - .searchVaultListPublisher(searchText: "312321312", filter: VaultListFilter(filterType: .allVaults)) - .makeAsyncIterator() - let ciphers = try await iterator.next() - XCTAssertEqual(ciphers, expectedSearchResult) - } - - /// `searchVaultListPublisher(searchText:, filterType:)` returns search matching cipher uri. - func test_searchVaultListPublisher_searchText_uri() async throws { - stateService.activeAccount = .fixtureAccountLogin() - cipherService.ciphersSubject.value = [ - .fixture(id: "1", name: "dabcd"), - .fixture(id: "2", name: "qwe"), - .fixture( - id: "3", - login: .init( - username: "name", - password: "pwd", - passwordRevisionDate: nil, - uris: [.fixture(uri: "www.domain.com", match: .domain)], - totp: nil, - autofillOnPageLoad: nil, - fido2Credentials: nil, - ), - name: "Café", - ), - ] - let cipherListView = try CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value.last)) - let expectedSearchResult = try [XCTUnwrap(VaultListItem(cipherListView: cipherListView))] - var iterator = try await subject - .searchVaultListPublisher(searchText: "domain", filter: VaultListFilter(filterType: .allVaults)) - .makeAsyncIterator() - let ciphers = try await iterator.next() - XCTAssertEqual(ciphers, expectedSearchResult) - } - - /// `searchVaultListPublisher(searchText:filterType:)` only returns ciphers based on search - /// text and VaultFilterType. - func test_searchVaultListPublisher_vaultType() async throws { - stateService.activeAccount = .fixtureAccountLogin() - cipherService.ciphersSubject.value = [ - .fixture(id: "1", name: "bcd", organizationId: "testOrg"), - .fixture(id: "2", name: "bcdew"), - .fixture(id: "3", name: "dabcd"), - ] - let cipherListView = try CipherListView(cipher: XCTUnwrap(cipherService.ciphersSubject.value.first)) - let expectedSearchResult = try [XCTUnwrap(VaultListItem(cipherListView: cipherListView))] - var iterator = try await subject - .searchVaultListPublisher( - searchText: "bcd", - filter: VaultListFilter( - filterType: .organization( - .fixture( - id: "testOrg", - ), - ), - ), - ) - .makeAsyncIterator() - let ciphers = try await iterator.next() - XCTAssertEqual(ciphers, expectedSearchResult) - } - /// `shareCipher()` has the cipher service share the cipher and updates the vault. func test_shareCipher() async throws { stateService.activeAccount = .fixtureAccountLogin() @@ -2605,7 +1416,7 @@ class VaultRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_b vaultListDirectorStrategy.buildReturnValue = AsyncThrowingPublisher(publisher) - let filter = VaultListFilter(addTOTPGroup: true) + let filter = VaultListFilter(options: [.addTOTPGroup, .addTrashGroup]) var iterator = try await subject.vaultListPublisher(filter: filter).makeAsyncIterator() let vaultListData = try await iterator.next() let sections = try XCTUnwrap(vaultListData?.sections) diff --git a/BitwardenShared/Core/Vault/Utilities/VaultListFilter.swift b/BitwardenShared/Core/Vault/Utilities/VaultListFilter.swift new file mode 100644 index 000000000..aec43ad80 --- /dev/null +++ b/BitwardenShared/Core/Vault/Utilities/VaultListFilter.swift @@ -0,0 +1,78 @@ +// MARK: - VaultListFilter + +/// The filter to be used when getting the vault list. +public struct VaultListFilter: Sendable, Equatable { + /// The vault filter type. + let filterType: VaultFilterType + + /// The vault list group to filter. + let group: VaultListGroup? + + /// The mode in which the autofill list is presented. + let mode: AutofillListMode? + + /// Options to configure the vault list behavior. + let options: VaultListOptions + + /// The relying party identifier of the Fido2 request. + let rpID: String? + + /// The search text to use as the query to filter ciphers. + /// + /// On init this has been lowercased, whitespaces and new lines trimmed and converted to diacritic insensitive. + let searchText: String? + + /// The URI used to filter ciphers that have a matching URI. + let uri: String? + + /// Initializes the filter. + /// - Parameters: + /// - filterType: The vault filter type. + /// - group: The vault list group to filter. + /// - mode: The mode in which the autofill list is presented. + /// - options: Options to configure the vault list behavior. + /// - rpID: The relying party identifier of the Fido2 request. + /// - searchText: The search text to use as the query to filter ciphers. + /// - uri: The URI used to filter ciphers that have a matching URI + init( + filterType: VaultFilterType = .allVaults, + group: VaultListGroup? = nil, + mode: AutofillListMode? = nil, + options: VaultListOptions = [], + rpID: String? = nil, + searchText: String? = nil, + uri: String? = nil, + ) { + self.filterType = filterType + self.group = group + self.mode = mode + self.options = options + self.rpID = rpID + self.searchText = searchText?.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .folding(options: .diacriticInsensitive, locale: .current) + self.uri = uri + } +} + +// MARK: - VaultListOptions + +/// Options to configure the vault list behavior. +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 the vault list is being displayed in picker mode. + static let isInPickerMode = VaultListOptions(rawValue: 1 << 2) + + public let rawValue: UInt + + /// Initializes a `VaultListOptions` with a `rawValue` + /// - Parameter rawValue: The raw value for the option. + public init(rawValue: UInt) { + self.rawValue = rawValue + } +} diff --git a/BitwardenShared/Core/Vault/Utilities/VaultListFilterTests.swift b/BitwardenShared/Core/Vault/Utilities/VaultListFilterTests.swift new file mode 100644 index 000000000..1d2f381db --- /dev/null +++ b/BitwardenShared/Core/Vault/Utilities/VaultListFilterTests.swift @@ -0,0 +1,113 @@ +import XCTest + +@testable import BitwardenShared + +// MARK: - VaultListFilterTests + +class VaultListFilterTests: BitwardenTestCase { + // MARK: Tests + + /// `init` with default parameters initializes with expected values. + func test_init_defaults() { + let subject = VaultListFilter() + + XCTAssertEqual(subject.filterType, .allVaults) + XCTAssertNil(subject.group) + XCTAssertNil(subject.mode) + XCTAssertEqual(subject.options, []) + XCTAssertNil(subject.rpID) + XCTAssertNil(subject.searchText) + XCTAssertNil(subject.uri) + } + + /// `init` with custom parameters initializes with expected values. + func test_init_customParameters() { + let subject = VaultListFilter( + filterType: .myVault, + group: .card, + mode: .all, + options: [.addTOTPGroup, .addTrashGroup], + rpID: "example.com", + searchText: "test", + uri: "https://example.com", + ) + + XCTAssertEqual(subject.filterType, .myVault) + XCTAssertEqual(subject.group, .card) + XCTAssertEqual(subject.mode, .all) + XCTAssertEqual(subject.options, [.addTOTPGroup, .addTrashGroup]) + XCTAssertEqual(subject.rpID, "example.com") + XCTAssertEqual(subject.searchText, "test") + XCTAssertEqual(subject.uri, "https://example.com") + } + + /// `init` with `nil` search text sets `searchText` to `nil`. + func test_init_searchText_nil() { + let subject = VaultListFilter(searchText: nil) + + XCTAssertNil(subject.searchText) + } + + /// `init` with empty search text sets `searchText` to empty string. + func test_init_searchText_empty() { + let subject = VaultListFilter(searchText: "") + + XCTAssertEqual(subject.searchText, "") + } + + /// `init` with search text trims whitespace and newlines. + func test_init_searchText_trimsWhitespaceAndNewlines() { + let subject = VaultListFilter(searchText: " \n test \n ") + + XCTAssertEqual(subject.searchText, "test") + } + + /// `init` with search text converts to lowercase. + func test_init_searchText_lowercased() { + let subject = VaultListFilter(searchText: "TeSt SeArCh") + + XCTAssertEqual(subject.searchText, "test search") + } + + /// `init` with search text removes diacritics. + func test_init_searchText_diacriticInsensitive() { + let subject = VaultListFilter(searchText: "café") + + XCTAssertEqual(subject.searchText, "cafe") + } + + /// `init` with search text applies all transformations: trim, lowercase, and diacritic removal. + func test_init_searchText_allTransformations() { + let subject = VaultListFilter(searchText: " \n CaFé Niño \n ") + + XCTAssertEqual(subject.searchText, "cafe nino") + } + + /// `init` with search text containing only whitespace sets `searchText` to empty string. + func test_init_searchText_onlyWhitespace() { + let subject = VaultListFilter(searchText: " \n\n ") + + XCTAssertEqual(subject.searchText, "") + } + + /// `init` with search text containing various diacritics removes them correctly. + func test_init_searchText_variousDiacritics() { + let subject = VaultListFilter(searchText: "àáâãäåèéêëìíîïòóôõöùúûü") + + XCTAssertEqual(subject.searchText, "aaaaaaeeeeiiiiooooouuuu") + } + + /// `init` with search text containing non-Latin characters preserves them. + func test_init_searchText_nonLatinCharacters() { + let subject = VaultListFilter(searchText: "テスト") + + XCTAssertEqual(subject.searchText, "テスト") + } + + /// `init` with search text containing special characters preserves them. + func test_init_searchText_specialCharacters() { + let subject = VaultListFilter(searchText: "test@123!#$%") + + XCTAssertEqual(subject.searchText, "test@123!#$%") + } +} diff --git a/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor+AutofillModeAllTests.swift b/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor+AutofillModeAllTests.swift index 42666f571..74572f432 100644 --- a/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor+AutofillModeAllTests.swift +++ b/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor+AutofillModeAllTests.swift @@ -9,7 +9,7 @@ import XCTest @testable import BitwardenShared @available(iOS 18.0, *) -class VaultAutofillListProcessorAutofillModeAllTests: BitwardenTestCase { +class VaultAutofillListProcessorAutofillModeAllTests: BitwardenTestCase { // swiftlint:disable:this type_body_length // MARK: Properties var appExtensionDelegate: MockAutofillAppExtensionDelegate! @@ -125,7 +125,7 @@ class VaultAutofillListProcessorAutofillModeAllTests: BitwardenTestCase { items: items, name: "", ) - vaultRepository.searchCipherAutofillSubject.value = VaultListData(sections: [expectedSection]) + vaultRepository.vaultListSubject.value = VaultListData(sections: [expectedSection]) let task = Task { await subject.perform(.search("Bit")) @@ -136,6 +136,16 @@ class VaultAutofillListProcessorAutofillModeAllTests: BitwardenTestCase { XCTAssertEqual(subject.state.ciphersForSearch, [expectedSection]) XCTAssertFalse(subject.state.showNoResults) + XCTAssertEqual( + vaultRepository.vaultListFilter, + VaultListFilter( + filterType: .allVaults, + group: nil, + mode: .all, + rpID: nil, + searchText: "bit", + ), + ) } /// `perform(_:)` with `.search()` performs a cipher search and updates the state with the results @@ -167,7 +177,7 @@ class VaultAutofillListProcessorAutofillModeAllTests: BitwardenTestCase { items: items, name: "", ) - vaultRepository.searchCipherAutofillSubject.value = VaultListData(sections: [expectedSection]) + vaultRepository.vaultListSubject.value = VaultListData(sections: [expectedSection]) subject.state.group = .card let task = Task { @@ -179,7 +189,16 @@ class VaultAutofillListProcessorAutofillModeAllTests: BitwardenTestCase { XCTAssertEqual(subject.state.ciphersForSearch, [expectedSection]) XCTAssertFalse(subject.state.showNoResults) - XCTAssertEqual(vaultRepository.searchCipherAutofillPublisherCalledWithGroup, .card) + XCTAssertEqual( + vaultRepository.vaultListFilter, + VaultListFilter( + filterType: .allVaults, + group: .card, + mode: .all, + rpID: nil, + searchText: "bit", + ), + ) } /// `perform(_:)` with `.streamAutofillItems` streams the list of autofill ciphers. diff --git a/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor+Fido2Tests.swift b/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor+Fido2Tests.swift index 7fce08f27..eb611dd43 100644 --- a/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor+Fido2Tests.swift +++ b/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor+Fido2Tests.swift @@ -954,7 +954,7 @@ class VaultAutofillListProcessorFido2Tests: BitwardenTestCase { // swiftlint:dis name: Localizations.passwordsForX("Bit"), ), ] - vaultRepository.searchCipherAutofillSubject.value = VaultListData(sections: expectedSections) + vaultRepository.vaultListSubject.value = VaultListData(sections: expectedSections) let task = Task { await subject.perform(.search("Bit")) @@ -990,6 +990,17 @@ class VaultAutofillListProcessorFido2Tests: BitwardenTestCase { // swiftlint:dis ) XCTAssertFalse(subject.state.showNoResults) + + XCTAssertEqual( + vaultRepository.vaultListFilter, + VaultListFilter( + filterType: .allVaults, + group: .login, + mode: .combinedMultipleSections, + rpID: "myApp.com", + searchText: "bit", + ), + ) } /// `perform(_:)` with `.streamAutofillItems` streams the list of autofill ciphers when creating Fido2 credential. diff --git a/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor+TotpTests.swift b/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor+TotpTests.swift index 46048479c..e078986fb 100644 --- a/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor+TotpTests.swift +++ b/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor+TotpTests.swift @@ -114,7 +114,7 @@ class VaultAutofillListProcessorTotpTests: BitwardenTestCase { // swiftlint:disa items: items, name: "", ) - vaultRepository.searchCipherAutofillSubject.value = VaultListData(sections: [expectedSection]) + vaultRepository.vaultListSubject.value = VaultListData(sections: [expectedSection]) let task = Task { await subject.perform(.search("Bit")) diff --git a/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor.swift b/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor.swift index fbc73079f..ba9e4fec0 100644 --- a/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor.swift +++ b/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor.swift @@ -342,17 +342,17 @@ class VaultAutofillListProcessor: StateProcessor