8000 PM-13885: Handle empty vault after import logins by matt-livefront · Pull Request #1064 · bitwarden/ios · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

PM-13885: Handle empty vault after import logins #1064

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Oct 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ class MockVaultRepository: VaultRepository {

var getDisableAutoTotpCopyResult: Result<Bool, Error> = .success(false)

var isVaultEmptyCalled = false
var isVaultEmptyResult: Result<Bool, Error> = .success(false)

var needsSyncCalled = false
var needsSyncResult: Result<Bool, Error> = .success(false)

Expand Down Expand Up @@ -194,6 +197,11 @@ class MockVaultRepository: VaultRepository {
return try needsSyncResult.get()
}

func isVaultEmpty() async throws -> Bool {
isVaultEmptyCalled = true
return try isVaultEmptyResult.get()
}

func organizationsPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[Organization], Error>> {
organizationsPublisherCalled = true
if let organizationsPublisherError {
Expand Down
10 changes: 10 additions & 0 deletions BitwardenShared/Core/Vault/Repositories/VaultRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ public protocol VaultRepository: AnyObject {
///
func getDisableAutoTotpCopy() async throws -> Bool

/// Returns whether the user's vault is empty.
///
/// - Returns: Whether the user's vault is empty.
///
func isVaultEmpty() async throws -> Bool

/// Regenerates the TOTP code for a given key.
///
/// - Parameter key: The key for a TOTP code.
Expand Down Expand Up @@ -1042,6 +1048,10 @@ extension DefaultVaultRepository: VaultRepository {
try await stateService.getDisableAutoTotpCopy()
}

func isVaultEmpty() async throws -> Bool {
try await cipherService.cipherCount() == 0
}

func needsSync() async throws -> Bool {
let userId = try await stateService.getActiveAccountId()
return try await syncService.needsSync(for: userId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,28 @@ class VaultRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_b
XCTAssertTrue(isDisabled)
}

/// `isVaultEmpty()` throws an error if one occurs.
func test_isVaultEmpty_error() async {
cipherService.cipherCountResult = .failure(BitwardenTestError.example)
await assertAsyncThrows(error: BitwardenTestError.example) {
_ = try await subject.isVaultEmpty()
}
}

/// `isVaultEmpty()` returns `false` if the user's vault is not empty.
func test_isVaultEmpty_false() async throws {
cipherService.cipherCountResult = .success(2)
let isEmpty = try await subject.isVaultEmpty()
XCTAssertFalse(isEmpty)
}

/// `isVaultEmpty()` returns `true` if the user's vault is empty.
func test_isVaultEmpty_true() async throws {
cipherService.cipherCountResult = .success(0)
let isEmpty = try await subject.isVaultEmpty()
XCTAssertTrue(isEmpty)
}

/// `refreshTOTPCode(:)` rethrows errors.
func test_refreshTOTPCode_error() async throws {
clientService.mockVault.generateTOTPCodeResult = .failure(BitwardenTestError.example)
Expand Down
9 changes: 9 additions & 0 deletions BitwardenShared/Core/Vault/Services/CipherService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ protocol CipherService {
///
func addCipherWithServer(_ cipher: Cipher) async throws

/// Returns the count of ciphers in the data store for the current user.
///
func cipherCount() async throws -> Int

/// Delete a cipher's attachment for the current user both in the backend and in local storage.
///
/// - Parameters:
Expand Down Expand Up @@ -191,6 +195,11 @@ extension DefaultCipherService {
try await cipherDataStore.upsertCipher(Cipher(responseModel: response), userId: userId)
}

func cipherCount() async throws -> Int {
let userId = try await stateService.getActiveAccountId()
return try await cipherDataStore.cipherCount(userId: userId)
}

func deleteAttachmentWithServer(attachmentId: String, cipherId: String) async throws -> Cipher? {
let userId = try await stateService.getActiveAccountId()

Expand Down
20 changes: 20 additions & 0 deletions BitwardenShared/Core/Vault/Services/CipherServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,26 @@ class CipherServiceTests: BitwardenTestCase {
XCTAssertEqual(cipherDataStore.upsertCipherValue?.id, "3792af7a-4441-11ee-be56-0242ac120002")
}

/// `cipherCount()` returns the number of ciphers in the data store.
func test_ciphersCount() async throws {
stateService.activeAccount = .fixture()

cipherDataStore.cipherCountResult = .success(0)
var count = try await subject.cipherCount()
XCTAssertEqual(count, 0)

cipherDataStore.cipherCountResult = .success(3)
count = try await subject.cipherCount()
XCTAssertEqual(count, 3)
}

/// `cipherCount()` throws an error if one occurs getting the count of ciphers.
func test_ciphersCount_error() async throws {
await assertAsyncThrows(error: StateServiceError.noActiveAccount) {
_ = try await subject.cipherCount()
}
}

/// `ciphersPublisher()` returns a publisher that emits data as the data store changes.
func test_ciphersPublisher() async throws {
stateService.activeAccount = .fixtureAccountLogin()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import CoreData
/// A protocol for a data store that handles performing data requests for ciphers.
///
protocol CipherDataStore: AnyObject {
/// Returns the count of ciphers in the data store belonging to the specified user ID.
///
/// - Parameter userId: The user ID of the user associated with the ciphers.
///
func cipherCount(userId: String) async throws -> Int

/// Deletes all `Cipher` objects for a specific user.
///
/// - Parameter userId: The user ID of the user associated with the objects to delete.
Expand Down Expand Up @@ -62,6 +68,13 @@ protocol CipherDataStore: AnyObject {
}

extension DataStore: CipherDataStore {
func cipherCount(userId: String) async throws -> Int {
try await backgroundContext.perform {
Copy link
Collaborator
@phil-livefront phil-livefront Oct 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 is this block safe to have a strong self because its using the backgroundContext within itself? that might be a dumb question so feel free to ignore me haha

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is safe because it's a short-lived closure. The context is going to perform the closure and then immediately return, releasing the self/backgroundContext reference.

let fetchRequest = CipherData.fetchByUserIdRequest(userId: userId)
return try self.backgroundContext.count(for: fetchRequest)
}
}

func deleteAllCiphers(userId: String) async throws {
try await executeBatchDelete(CipherData.deleteByUserIdRequest(userId: userId))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ class CipherDataStoreTests: BitwardenTestCase {

// MARK: Tests

/// `cipherCount(userId:)` returns the count of ciphers in the data store.
func test_cipherCount() async throws {
var count = try await subject.cipherCount(userId: "1")
XCTAssertEqual(count, 0)

try await insertCiphers(ciphers, userId: "1")
try await insertCiphers(ciphers, userId: "2")

count = try await subject.cipherCount(userId: "1")
XCTAssertEqual(count, 3)
}

/// `cipherPublisher(userId:)` returns a publisher for a user's cipher objects.
func test_cipherPublisher() async throws {
var publishedValues = [[Cipher]]()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import Combine
@testable import BitwardenShared

class MockCipherDataStore: CipherDataStore {
var cipherCountUserId: String?
var cipherCountResult: Result<Int, Error> = .success(0)

var deleteAllCiphersUserId: String?

var deleteCipherId: String?
Expand All @@ -23,6 +26,11 @@ class MockCipherDataStore: CipherDataStore {
var upsertCipherValue: Cipher?
var upsertCipherUserId: String?

func cipherCount(userId: String) async throws -> Int {
cipherCountUserId = userId
return try cipherCountResult.get()
}

func deleteAllCiphers(userId: String) async throws {
deleteAllCiphersUserId = userId
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ class MockCipherService: CipherService {
var addCipherWithServerCiphers = [Cipher]()
var addCipherWithServerResult: Result<Void, Error> = .success(())

var cipherCountResult: Result<Int, Error> = .success(0)

var ciphersSubject = CurrentValueSubject<[Cipher], Error>([])

var deleteAttachmentWithServerAttachmentId: String?
Expand Down Expand Up @@ -63,6 +65,10 @@ class MockCipherService: CipherService {
try addCipherWithServerResult.get()
}

func cipherCount() async throws -> Int {
try cipherCountResult.get()
}

func deleteAttachmentWithServer(attachmentId: String, cipherId _: String) async throws -> Cipher? {
deleteAttachmentWithServerAttachmentId = attachmentId
return try deleteAttachmentWithServerResult.get()
Expand Down
CEB7
Original file line number Diff line number Diff line change
Expand Up @@ -1047,3 +1047,5 @@
"AllowAuthenticatorSyncing" = "Allow authenticator syncing";
"AuthenticatorSync" = "Authenticator sync";
"NoAccountFoundPleaseLogInAgainIfYouContinueToSeeThisError" = "No account found. Please log in again if you continue to see this error.";
"ImportError" = "Import error";
"NoLoginsWereImported" = "No logins were imported";
20 changes: 20 additions & 0 deletions BitwardenShared/UI/Vault/Extensions/Alert+Vault.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,26 @@ extension Alert {
)
}

/// An alert informing the user that no logins were imported.
///
/// - Parameter action: The action taken when the user taps import logins later.
/// - Returns: An alert informing the user that no logins were imported.
///
static func importLoginsEmpty(
action: @escaping () async -> Void
) -> Alert {
Alert(
title: Localizations.importError,
message: Localizations.noLoginsWereImported,
alertActions: [
AlertAction(title: Localizations.tryAgain, style: .cancel),
AlertAction(title: Localizations.importLoginsLater, style: .default) { _ in
await action()
},
]
)
}

/// An alert confirming that the user wants to import logins later in settings.
///
/// - Parameter action: The action taken when the user taps on Confirm to import logins later
Expand Down
20 changes: 20 additions & 0 deletions BitwardenShared/UI/Vault/Extensions/AlertVaultTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,26 @@ class AlertVaultTests: BitwardenTestCase {
XCTAssertTrue(actionCalled)
}

/// `importLoginsEmpty(action:)` constructs an `Alert` that informs the user that no logins
/// were imported.
func test_importLoginsEmpty() async throws {
var actionCalled = false
let subject = Alert.importLoginsEmpty { actionCalled = true }

XCTAssertEqual(subject.title, Localizations.importError)
XCTAssertEqual(subject.message, Localizations.noLoginsWereImported)
XCTAssertEqual(subject.alertActions[0].title, Localizations.tryAgain)
XCTAssertEqual(subject.alertActions[0].style, .cancel)
XCTAssertEqual(subject.alertActions[1].title, Localizations.importLoginsLater)
XCTAssertEqual(subject.alertActions[1].style, .default)

try await subject.tapAction(title: Localizations.tryAgain)
XCTAssertFalse(actionCalled)

try await subject.tapAction(title: Localizations.importLoginsLater)
XCTAssertTrue(actionCalled)
}

/// `static importLoginsLater(action:)` constructs an `Alert` that confirms that the user
/// wants to import logins later in settings.
func test_importLoginsLater() async throws {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class ImportLoginsProcessor: StateProcessor<ImportLoginsState, ImportLoginsActio
typealias Services = HasErrorReporter
& HasSettingsRepository
& HasStateService
& HasVaultRepository

// MARK: Private Properties

Expand Down Expand Up @@ -91,6 +92,17 @@ class ImportLoginsProcessor: StateProcessor<ImportLoginsState, ImportLoginsActio
}
}

/// Sets the user's import logins progress to set up later and dismisses the view.
///
private func setImportLoginsLaterAndDismiss() async {
do {
try await services.stateService.setAccountSetupImportLogins(.setUpLater)
} catch {
services.errorReporter.log(error: error)
}
coordinator.navigate(to: .dismiss)
}

/// Shows the alert confirming the user wants to get started on importing logins.
///
private func showGetStartAlert() {
Expand All @@ -99,16 +111,19 @@ class ImportLoginsProcessor: StateProcessor<ImportLoginsState, ImportLoginsActio
})
}

/// Shows an alert informing the user that their vault is empty after importing logins.
///
private func showImportLoginsEmptyAlert() {
coordinator.showAlert(.importLoginsEmpty {
await self.setImportLoginsLaterAndDismiss()
})
}

/// Shows the alert confirming the user wants to import logins later.
///
private func showImportLoginsLaterAlert() {
coordinator.showAlert(.importLoginsLater {
do {
try await self.services.stateService.setAccountSetupImportLogins(.setUpLater)
} catch {
self.services.errorReporter.log(error: error)
}
self.coordinator.navigate(to: .dismiss)
await self.setImportLoginsLaterAndDismiss()
})
}

Expand All @@ -120,14 +135,19 @@ class ImportLoginsProcessor: StateProcessor<ImportLoginsState, ImportLoginsActio

do {
try await services.settingsRepository.fetchSync()
coordinator.hideLoadingOverlay()

guard try await !services.vaultRepository.isVaultEmpty() else {
showImportLoginsEmptyAlert()
return
}

do {
try await services.stateService.setAccountSetupImportLogins(.complete)
} catch {
services.errorReporter.log(error: error)
}

coordinator.hideLoadingOverlay()
coordinator.navigate(to: .importLoginsSuccess)
} catch {
coordinator.showAlert(.networkResponseError(error))
Expand Down
Loading
Loading
0