mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-10 00:42:29 -06:00
[PM-23729] Refactor searches to use new approach with vault list builders (#2132)
This commit is contained in:
parent
8ecf16a922
commit
803f28b31c
@ -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 }
|
||||
|
||||
@ -125,4 +125,3 @@ class AuthenticatorItemCoordinator: NSObject, Coordinator, HasStackNavigator {
|
||||
extension AuthenticatorItemCoordinator: HasErrorAlertServices {
|
||||
var errorAlertServices: ErrorAlertServices { services }
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
13
BitwardenShared/Core/Vault/Helpers/CipherMatchResult.swift
Normal file
13
BitwardenShared/Core/Vault/Helpers/CipherMatchResult.swift
Normal 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
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
])
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
78
BitwardenShared/Core/Vault/Utilities/VaultListFilter.swift
Normal file
78
BitwardenShared/Core/Vault/Utilities/VaultListFilter.swift
Normal 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
|
||||
}
|
||||
}
|
||||
113
BitwardenShared/Core/Vault/Utilities/VaultListFilterTests.swift
Normal file
113
BitwardenShared/Core/Vault/Utilities/VaultListFilterTests.swift
Normal 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!#$%")
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user