[PM-23729] Refactor searches to use new approach with vault list builders (#2132)

This commit is contained in:
Federico Maccaroni 2025-11-14 17:57:21 -03:00 committed by GitHub
parent 8ecf16a922
commit 803f28b31c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 2375 additions and 2271 deletions

View File

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

View File

@ -125,4 +125,3 @@ class AuthenticatorItemCoordinator: NSObject, Coordinator, HasStackNavigator {
extension AuthenticatorItemCoordinator: HasErrorAlertServices {
var errorAlertServices: ErrorAlertServices { services }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<AnyPublisher<VaultListData, Error>> {
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()
}
}

View File

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

View File

@ -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<AnyPublisher<VaultListData, Error>> {
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()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -98,12 +98,6 @@ class MockVaultRepository: VaultRepository {
var saveAttachmentFileName: String?
var saveAttachmentResult: Result<CipherView, Error> = .success(.fixture())
var searchCipherAutofillPublisherCalledWithGroup: VaultListGroup? // swiftlint:disable:this identifier_name
var searchCipherAutofillSubject = CurrentValueSubject<VaultListData, Error>(VaultListData())
var searchVaultListSubject = CurrentValueSubject<[VaultListItem], Error>([])
var searchVaultListFilterType: VaultListFilter?
var shareCipherCiphers = [CipherView]()
var shareCipherResult: Result<Void, Error> = .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<AnyPublisher<VaultListData, Error>> {
searchCipherAutofillPublisherCalledWithGroup = group
return searchCipherAutofillSubject.eraseToAnyPublisher().values
}
func searchVaultListPublisher(
searchText _: String,
group: VaultListGroup?,
filter: BitwardenShared.VaultListFilter,
) async throws -> AsyncThrowingPublisher<AnyPublisher<[VaultListItem], Error>> {
searchVaultListFilterType = filter
return searchVaultListSubject.eraseToAnyPublisher().values
}
func shareCipher(_ cipher: CipherView, newOrganizationId: String, newCollectionIds: [String]) async throws {
shareCipherCiphers.append(cipher)
try shareCipherResult.get()

View File

@ -248,39 +248,6 @@ public protocol VaultRepository: AnyObject {
///
func organizationsPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[Organization], Error>>
/// 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<AnyPublisher<VaultListData, Error>>
/// 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<AnyPublisher<[VaultListItem], Error>>
/// 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<AnyPublisher<[VaultListItem], Error>> {
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<FolderView>,
nestedFolderId: String? = nil,
) -> [VaultListItem] {
let folders: [TreeNode<FolderView>]? = 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<AnyPublisher<VaultListData, Error>> {
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<AnyPublisher<[VaultListItem], Error>> {
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<AnyPublisher<VaultListData, Error>> {
@ -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

View File

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

View File

@ -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!#$%")
}
}

View File

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

View File

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

View File

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

View File

@ -342,17 +342,17 @@ class VaultAutofillListProcessor: StateProcessor<// swiftlint:disable:this type_
return
}
do {
let searchResult = try await services.vaultRepository.searchCipherAutofillPublisher(
availableFido2CredentialsPublisher: services
.fido2UserInterfaceHelper
.availableCredentialsForAuthenticationPublisher(),
mode: autofillListMode,
filter: VaultListFilter(filterType: .allVaults),
group: state.group,
rpID: autofillAppExtensionDelegate?.rpID,
searchText: searchText,
let groupFilter = state.group ?? (autofillListMode == .all ? nil : .login)
let publisher = try await services.vaultRepository.vaultListPublisher(
filter: VaultListFilter(
filterType: .allVaults,
group: groupFilter,
mode: autofillListMode,
rpID: autofillAppExtensionDelegate?.rpID,
searchText: searchText,
),
)
for try await vaultListData in searchResult {
for try await vaultListData in publisher {
let sections = vaultListData.sections
state.ciphersForSearch = sections
state.showNoResults = sections.isEmpty

View File

@ -222,7 +222,7 @@ class VaultAutofillListProcessorTests: BitwardenTestCase { // swiftlint:disable:
items: ciphers.compactMap { VaultListItem(cipherListView: $0) },
name: "",
)
vaultRepository.searchCipherAutofillSubject.value = VaultListData(sections: [expectedSection])
vaultRepository.vaultListSubject.value = VaultListData(sections: [expectedSection])
let task = Task {
await subject.perform(.search("Bit"))
@ -233,6 +233,16 @@ class VaultAutofillListProcessorTests: BitwardenTestCase { // swiftlint:disable:
XCTAssertEqual(subject.state.ciphersForSearch, [expectedSection])
XCTAssertFalse(subject.state.showNoResults)
XCTAssertEqual(
vaultRepository.vaultListFilter,
VaultListFilter(
filterType: .allVaults,
group: .login,
mode: .passwords,
rpID: nil,
searchText: "bit",
),
)
}
/// `perform(_:)` with `.search()` doesn't perform a search if the search string is empty.
@ -251,7 +261,7 @@ class VaultAutofillListProcessorTests: BitwardenTestCase { // swiftlint:disable:
await subject.perform(.search("example"))
}
vaultRepository.searchCipherAutofillSubject.send(completion: .failure(BitwardenTestError.example))
vaultRepository.vaultListSubject.send(completion: .failure(BitwardenTestError.example))
waitFor(!coordinator.alertShown.isEmpty)
task.cancel()

View File

@ -260,13 +260,16 @@ final class VaultGroupProcessor: StateProcessor<
return
}
do {
let result = try await services.vaultRepository.searchVaultListPublisher(
searchText: searchText,
group: state.group,
filter: VaultListFilter(filterType: state.searchVaultFilterType),
let publisher = try await services.vaultRepository.vaultListPublisher(
filter: VaultListFilter(
filterType: state.searchVaultFilterType,
group: state.group,
searchText: searchText,
),
)
for try await ciphers in result {
state.searchResults = ciphers
for try await vaultListData in publisher {
let items = vaultListData.sections.first?.items ?? []
state.searchResults = items
searchTotpExpirationManager?.configureTOTPRefreshScheduling(for: state.searchResults)
}
} catch {

View File

@ -252,20 +252,36 @@ class VaultGroupProcessorTests: BitwardenTestCase { // swiftlint:disable:this ty
@MainActor
func test_perform_search() {
let searchResult: [CipherListView] = [.fixture(name: "example")]
vaultRepository.searchVaultListSubject.value = searchResult.compactMap { VaultListItem(cipherListView: $0) }
vaultRepository.vaultListSubject.value = VaultListData(
sections: [
VaultListSection(
id: "SearchResults",
items: searchResult.compactMap { VaultListItem(cipherListView: $0) },
name: "",
),
],
)
subject.state.searchVaultFilterType = .organization(.fixture(id: "id1"))
let task = Task {
await subject.perform(.search("example"))
}
waitFor(!subject.state.searchResults.isEmpty)
XCTAssertEqual(
vaultRepository.searchVaultListFilterType?.filterType,
vaultRepository.vaultListFilter?.filterType,
.organization(.fixture(id: "id1")),
)
XCTAssertEqual(
subject.state.searchResults,
try [VaultListItem.fixture(cipherListView: XCTUnwrap(searchResult.first))],
)
XCTAssertEqual(
vaultRepository.vaultListFilter,
VaultListFilter(
filterType: .organization(.fixture(id: "id1")),
group: .login,
searchText: "example",
),
)
task.cancel()
}
@ -273,7 +289,7 @@ class VaultGroupProcessorTests: BitwardenTestCase { // swiftlint:disable:this ty
/// `perform(.search)` throws error and error is logged.
@MainActor
func test_perform_search_error() async {
vaultRepository.searchVaultListSubject.send(completion: .failure(BitwardenTestError.example))
vaultRepository.vaultListSubject.send(completion: .failure(BitwardenTestError.example))
await subject.perform(.search("example"))
XCTAssertEqual(subject.state.searchResults.count, 0)
@ -345,10 +361,17 @@ class VaultGroupProcessorTests: BitwardenTestCase { // swiftlint:disable:this ty
),
),
)
vaultRepository.searchVaultListSubject.send([
expired,
stable,
])
vaultRepository.vaultListSubject.send(
VaultListData(
sections: [
VaultListSection(
id: "",
items: [expired, stable],
name: "",
),
],
),
)
waitFor(subject.state.searchResults.count == 2)
task.cancel()
@ -405,10 +428,17 @@ class VaultGroupProcessorTests: BitwardenTestCase { // swiftlint:disable:this ty
),
),
)
vaultRepository.searchVaultListSubject.send([
expired,
stable,
])
vaultRepository.vaultListSubject.send(
VaultListData(
sections: [
VaultListSection(
id: "",
items: [expired, stable],
name: "",
),
],
),
)
waitFor(subject.state.searchResults.count == 2)
task.cancel()

View File

@ -173,12 +173,15 @@ class VaultItemSelectionProcessor: StateProcessor<
return
}
do {
let searchPublisher = try await services.vaultRepository.searchVaultListPublisher(
searchText: searchText,
group: .login,
filter: VaultListFilter(filterType: .allVaults),
let publisher = try await services.vaultRepository.vaultListPublisher(
filter: VaultListFilter(
filterType: .allVaults,
group: .login,
searchText: searchText,
),
)
for try await items in searchPublisher {
for try await vaultListData in publisher {
let items = vaultListData.sections.first?.items ?? []
state.searchResults = items
state.showNoResults = items.isEmpty
}
@ -231,23 +234,15 @@ class VaultItemSelectionProcessor: StateProcessor<
private func streamVaultItems() async {
guard let searchName = state.ciphersMatchingName else { return }
do {
for try await items in try await services.vaultRepository.searchVaultListPublisher(
searchText: searchName,
group: .login,
filter: VaultListFilter(filterType: .allVaults),
for try await vaultListData in try await services.vaultRepository.vaultListPublisher(
filter: VaultListFilter(
filterType: .allVaults,
group: .login,
options: [.isInPickerMode],
searchText: searchName,
),
) {
guard !items.isEmpty else {
state.vaultListSections = []
continue
}
state.vaultListSections = [
VaultListSection(
id: Localizations.matchingItems,
items: items,
name: Localizations.matchingItems,
),
]
state.vaultListSections = vaultListData.sections
}
} catch {
coordinator.showAlert(.defaultAlert(title: Localizations.anErrorHasOccurred))

View File

@ -193,7 +193,12 @@ class VaultItemSelectionProcessorTests: BitwardenTestCase { // swiftlint:disable
XCTUnwrap(VaultListItem(cipherListView: .fixture(id: "2"))),
XCTUnwrap(VaultListItem(cipherListView: .fixture(id: "3"))),
]
vaultRepository.searchVaultListSubject.value = vaultItems
let expectedSection = VaultListSection(
id: "",
items: vaultItems,
name: "",
)
vaultRepository.vaultListSubject.value = VaultListData(sections: [expectedSection])
let task = Task {
await subject.perform(.search("Bit"))
@ -204,6 +209,14 @@ class VaultItemSelectionProcessorTests: BitwardenTestCase { // swiftlint:disable
XCTAssertEqual(subject.state.searchResults, vaultItems)
XCTAssertFalse(subject.state.showNoResults)
XCTAssertEqual(
vaultRepository.vaultListFilter,
VaultListFilter(
filterType: .allVaults,
group: .login,
searchText: "bit",
),
)
}
/// `perform(_:)` with `.search()` doesn't perform a search if the search string is empty.
@ -222,7 +235,7 @@ class VaultItemSelectionProcessorTests: BitwardenTestCase { // swiftlint:disable
await subject.perform(.search("example"))
}
vaultRepository.searchVaultListSubject.send(completion: .failure(BitwardenTestError.example))
vaultRepository.vaultListSubject.send(completion: .failure(BitwardenTestError.example))
waitFor(!coordinator.alertShown.isEmpty)
task.cancel()
@ -252,7 +265,12 @@ class VaultItemSelectionProcessorTests: BitwardenTestCase { // swiftlint:disable
XCTUnwrap(VaultListItem(cipherListView: .fixture(id: "2"))),
XCTUnwrap(VaultListItem(cipherListView: .fixture(id: "3"))),
]
vaultRepository.searchVaultListSubject.value = vaultItems
let expectedSection = VaultListSection(
id: Localizations.matchingItems,
items: vaultItems,
name: Localizations.matchingItems,
)
vaultRepository.vaultListSubject.value = VaultListData(sections: [expectedSection])
let task = Task {
await subject.perform(.streamVaultItems)
@ -271,6 +289,15 @@ class VaultItemSelectionProcessorTests: BitwardenTestCase { // swiftlint:disable
),
],
)
XCTAssertEqual(
vaultRepository.vaultListFilter,
VaultListFilter(
filterType: .allVaults,
group: .login,
options: [.isInPickerMode],
searchText: "Example",
),
)
}
/// `perform(_:)` with `.streamVaultItems` doesn't create an empty section if no results are returned.
@ -279,7 +306,7 @@ class VaultItemSelectionProcessorTests: BitwardenTestCase { // swiftlint:disable
subject.state.vaultListSections = [
VaultListSection(id: "", items: [.fixture()], name: Localizations.matchingItems),
]
vaultRepository.searchVaultListSubject.value = []
vaultRepository.vaultListSubject.value = VaultListData(sections: [])
let task = Task {
await subject.perform(.streamVaultItems)
@ -298,7 +325,7 @@ class VaultItemSelectionProcessorTests: BitwardenTestCase { // swiftlint:disable
await subject.perform(.streamVaultItems)
}
vaultRepository.searchVaultListSubject.send(completion: .failure(BitwardenTestError.example))
vaultRepository.vaultListSubject.send(completion: .failure(BitwardenTestError.example))
waitFor(!coordinator.alertShown.isEmpty)
task.cancel()

View File

@ -429,12 +429,15 @@ extension VaultListProcessor {
return
}
do {
let result = try await services.vaultRepository.searchVaultListPublisher(
searchText: searchText,
filter: VaultListFilter(filterType: state.searchVaultFilterType),
let publisher = try await services.vaultRepository.vaultListPublisher(
filter: VaultListFilter(
filterType: state.searchVaultFilterType,
searchText: searchText,
),
)
for try await ciphers in result {
state.searchResults = ciphers
for try await vaultListData in publisher {
let items = vaultListData.sections.first?.items ?? []
state.searchResults = items
}
} catch {
services.errorReporter.log(error: error)
@ -516,7 +519,12 @@ extension VaultListProcessor {
private func streamVaultList() async {
do {
for try await vaultList in try await services.vaultRepository
.vaultListPublisher(filter: VaultListFilter(filterType: state.vaultFilterType)) {
.vaultListPublisher(
filter: VaultListFilter(
filterType: state.vaultFilterType,
options: [.addTOTPGroup, .addTrashGroup],
),
) {
// Check if the vault needs a sync.
let needsSync = try await services.vaultRepository.needsSync()

View File

@ -786,7 +786,13 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
@MainActor
func test_perform_search() {
let searchResult: [CipherListView] = [.fixture(name: "example")]
vaultRepository.searchVaultListSubject.value = searchResult.compactMap { VaultListItem(cipherListView: $0) }
vaultRepository.vaultListSubject.value = VaultListData(sections: [
VaultListSection(
id: "",
items: searchResult.compactMap { VaultListItem(cipherListView: $0) },
name: "",
),
])
let task = Task {
await subject.perform(.search("example"))
}
@ -803,7 +809,7 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
/// `perform(.search)` throws error and error is logged.
@MainActor
func test_perform_search_error() async {
vaultRepository.searchVaultListSubject.send(completion: .failure(BitwardenTestError.example))
vaultRepository.vaultListSubject.send(completion: .failure(BitwardenTestError.example))
await subject.perform(.search("example"))
XCTAssertEqual(subject.state.searchResults.count, 0)