From 7bf4acbb2805adbed70abe8ed8a3f167ec6bf7a5 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:57:59 -0400 Subject: [PATCH] [PM-26110] Add verify password screen for item export (#5935) --- .../platform/feature/rootnav/RootNavScreen.kt | 5 +- .../exportitems/ExportItemsNavigation.kt | 14 +- .../VerifyPasswordNavigation.kt | 79 +++ .../verifypassword/VerifyPasswordScreen.kt | 201 ++++++ .../verifypassword/VerifyPasswordViewModel.kt | 392 ++++++++++++ .../handlers/VerifyPasswordHandlers.kt | 49 ++ .../VerifyPasswordScreenTest.kt | 265 ++++++++ .../VerifyPasswordViewModelTest.kt | 577 ++++++++++++++++++ ui/src/main/res/values/strings.xml | 1 + 9 files changed, 1579 insertions(+), 4 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordNavigation.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreen.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModel.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/handlers/VerifyPasswordHandlers.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreenTest.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModelTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index 5fddeb8f39..34b62e0b4a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -65,6 +65,7 @@ import com.x8bit.bitwarden.ui.tools.feature.send.addedit.navigateToAddEditSend import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditArgs import com.x8bit.bitwarden.ui.vault.feature.addedit.navigateToVaultAddEdit import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toVaultItemCipherType +import com.x8bit.bitwarden.ui.vault.feature.exportitems.ExportItemsRoute import com.x8bit.bitwarden.ui.vault.feature.exportitems.exportItemsGraph import com.x8bit.bitwarden.ui.vault.feature.exportitems.navigateToExportItemsGraph import com.x8bit.bitwarden.ui.vault.feature.itemlisting.navigateToVaultItemListingAsRoot @@ -113,7 +114,7 @@ fun RootNavScreen( setupBrowserAutofillDestinationAsRoot() setupAutoFillDestinationAsRoot() setupCompleteDestination() - exportItemsGraph() + exportItemsGraph(navController) } val targetRoute = when (state) { @@ -139,9 +140,9 @@ fun RootNavScreen( is RootNavState.VaultUnlockedForFido2Assertion, is RootNavState.VaultUnlockedForPasswordGet, is RootNavState.VaultUnlockedForProviderGetCredentials, - is RootNavState.CredentialExchangeExport, -> VaultUnlockedGraphRoute + is RootNavState.CredentialExchangeExport -> ExportItemsRoute RootNavState.OnboardingAccountLockSetup -> SetupUnlockRoute.AsRoot RootNavState.OnboardingAutoFillSetup -> SetupAutofillRoute.AsRoot RootNavState.OnboardingBrowserAutofillSetup -> SetupBrowserAutofillRoute.AsRoot diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/ExportItemsNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/ExportItemsNavigation.kt index 1374975428..7aec2f0150 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/ExportItemsNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/ExportItemsNavigation.kt @@ -9,6 +9,8 @@ import androidx.navigation.navigation import com.bitwarden.annotation.OmitFromCoverage import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountRoute import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.selectAccountDestination +import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.navigateToVerifyPassword +import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.verifyPasswordDestination import kotlinx.serialization.Serializable /** @@ -21,13 +23,21 @@ data object ExportItemsRoute /** * Add export items destinations to the nav graph. */ -fun NavGraphBuilder.exportItemsGraph() { +fun NavGraphBuilder.exportItemsGraph( + navController: NavController, +) { navigation( startDestination = SelectAccountRoute, ) { selectAccountDestination( onAccountSelected = { - // TODO: [PM-26110] Navigate to verify password screen. + navController.navigateToVerifyPassword(userId = it) + }, + ) + verifyPasswordDestination( + onNavigateBack = { navController.popBackStack() }, + onPasswordVerified = { + // TODO: [PM-26111] Navigate to confirm export screen. }, ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordNavigation.kt new file mode 100644 index 0000000000..992ad03865 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordNavigation.kt @@ -0,0 +1,79 @@ +@file:OmitFromCoverage + +package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.toRoute +import com.bitwarden.annotation.OmitFromCoverage +import com.bitwarden.ui.platform.base.util.composableWithPushTransitions +import com.bitwarden.ui.platform.util.ParcelableRouteSerializer +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +/** + * The type-safe route for the verify password screen. + */ +@Parcelize +@Serializable(with = VerifyPasswordRoute.Serializer::class) +@OmitFromCoverage +data class VerifyPasswordRoute( + val userId: String, +) : Parcelable { + + /** + * Custom serializer to support polymorphic routes. + */ + class Serializer : ParcelableRouteSerializer( + kClass = VerifyPasswordRoute::class, + ) +} + +/** + * Class to retrieve verify password arguments from the [SavedStateHandle]. + */ +@OmitFromCoverage +data class VerifyPasswordArgs( + val userId: String, +) + +/** + * Constructs a [VerifyPasswordArgs] from the [SavedStateHandle] and internal route data. + */ +fun SavedStateHandle.toVerifyPasswordArgs(): VerifyPasswordArgs { + val route = this.toRoute() + return VerifyPasswordArgs( + userId = route.userId, + ) +} + +/** + * Add the [VerifyPasswordScreen] to the nav graph. + */ +fun NavGraphBuilder.verifyPasswordDestination( + onNavigateBack: () -> Unit, + onPasswordVerified: (userId: String) -> Unit, +) { + composableWithPushTransitions { + VerifyPasswordScreen( + onNavigateBack = onNavigateBack, + onPasswordVerified = onPasswordVerified, + ) + } +} + +/** + * Navigate to the [VerifyPasswordScreen]. + */ +fun NavController.navigateToVerifyPassword( + userId: String, + navOptions: NavOptions? = null, +) { + navigate( + route = VerifyPasswordRoute(userId = userId), + navOptions = navOptions, + ) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreen.kt new file mode 100644 index 0000000000..ab08aaf560 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreen.kt @@ -0,0 +1,201 @@ +package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitwarden.ui.platform.base.util.EventsEffect +import com.bitwarden.ui.platform.base.util.standardHorizontalMargin +import com.bitwarden.ui.platform.components.button.BitwardenFilledButton +import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog +import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog +import com.bitwarden.ui.platform.components.field.BitwardenPasswordField +import com.bitwarden.ui.platform.components.model.CardStyle +import com.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.bitwarden.ui.platform.resource.BitwardenDrawable +import com.bitwarden.ui.platform.resource.BitwardenString +import com.bitwarden.ui.platform.theme.BitwardenTheme +import com.x8bit.bitwarden.ui.vault.feature.exportitems.component.AccountSummaryListItem +import com.x8bit.bitwarden.ui.vault.feature.exportitems.component.ExportItemsScaffold +import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem +import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.handlers.rememberVerifyPasswordHandler + +/** + * Top level composable for the Verify Password screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VerifyPasswordScreen( + onNavigateBack: () -> Unit, + onPasswordVerified: (userId: String) -> Unit, + viewModel: VerifyPasswordViewModel = hiltViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + val handler = rememberVerifyPasswordHandler(viewModel) + + EventsEffect(viewModel) { event -> + when (event) { + VerifyPasswordEvent.NavigateBack -> onNavigateBack() + + is VerifyPasswordEvent.PasswordVerified -> { + onPasswordVerified(event.userId) + } + } + } + + VerifyPasswordDialogs( + dialog = state.dialog, + onDismiss = handler.onDismissDialog, + ) + + ExportItemsScaffold( + navIcon = rememberVectorPainter( + BitwardenDrawable.ic_back, + ), + onNavigationIconClick = handler.onNavigateBackClick, + navigationIconContentDescription = stringResource(BitwardenString.back), + scrollBehavior = scrollBehavior, + modifier = Modifier.fillMaxSize(), + ) { + VerifyPasswordContent( + state = state, + onInputChanged = handler.onInputChanged, + onUnlockClick = handler.onUnlockClick, + modifier = Modifier + .fillMaxSize() + .standardHorizontalMargin(), + ) + } +} + +@Composable +private fun VerifyPasswordDialogs( + dialog: VerifyPasswordState.DialogState?, + onDismiss: () -> Unit, +) { + when (dialog) { + is VerifyPasswordState.DialogState.General -> { + BitwardenBasicDialog( + title = dialog.title(), + message = dialog.message(), + throwable = dialog.error, + onDismissRequest = onDismiss, + ) + } + + is VerifyPasswordState.DialogState.Loading -> { + BitwardenLoadingDialog(text = dialog.message()) + } + + null -> Unit + } +} + +@Composable +private fun VerifyPasswordContent( + state: VerifyPasswordState, + onInputChanged: (String) -> Unit, + onUnlockClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + Spacer(Modifier.height(24.dp)) + + Text( + text = stringResource(BitwardenString.verify_your_master_password), + textAlign = TextAlign.Center, + style = BitwardenTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(16.dp)) + + AccountSummaryListItem( + item = state.accountSummaryListItem, + cardStyle = CardStyle.Full, + clickable = false, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(16.dp)) + + BitwardenPasswordField( + label = stringResource(BitwardenString.master_password), + value = state.input, + onValueChange = onInputChanged, + showPasswordTestTag = "PasswordVisibilityToggle", + imeAction = ImeAction.Done, + keyboardActions = KeyboardActions( + onDone = { + if (state.isUnlockButtonEnabled) { + onUnlockClick() + } else { + defaultKeyboardAction(ImeAction.Done) + } + }, + ), + supportingText = stringResource(BitwardenString.vault_locked_master_password), + passwordFieldTestTag = "MasterPasswordEntry", + cardStyle = CardStyle.Full, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(16.dp)) + + BitwardenFilledButton( + label = stringResource(BitwardenString.unlock), + onClick = onUnlockClick, + isEnabled = state.isUnlockButtonEnabled, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(12.dp)) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true) +@Composable +private fun VerifyPasswordContent_Preview() { + val accountSummaryListItem = AccountSelectionListItem( + userId = "userId", + isItemRestricted = false, + avatarColorHex = "#FF0000", + initials = "JD", + email = "john.doe@example.com", + ) + val state = VerifyPasswordState( + accountSummaryListItem = accountSummaryListItem, + ) + VerifyPasswordContent( + state = state, + onInputChanged = {}, + onUnlockClick = {}, + modifier = Modifier + .fillMaxSize() + .standardHorizontalMargin(), + ) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModel.kt new file mode 100644 index 0000000000..b4568b1178 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModel.kt @@ -0,0 +1,392 @@ +package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.bitwarden.network.model.PolicyTypeJson +import com.bitwarden.ui.platform.base.BaseViewModel +import com.bitwarden.ui.platform.resource.BitwardenString +import com.bitwarden.ui.util.Text +import com.bitwarden.ui.util.asText +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult +import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult +import com.x8bit.bitwarden.data.platform.manager.PolicyManager +import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult +import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem +import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials +import dagger.hilt.android.lifecycle.HiltViewModel +import jakarta.inject.Inject +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize + +private const val KEY_STATE = "state" + +/** + * ViewModel for the VerifyPassword screen. + * + * This view model does not assume password verification is requested for the active user. Switching + * to the provided account is deferred until password verification is explicitly requested. This is + * done to reduce the number of times account switching is performed, since it can be a costly + * operation. + */ +@Suppress("TooManyFunctions") +@HiltViewModel +class VerifyPasswordViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val vaultRepository: VaultRepository, + private val policyManager: PolicyManager, + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] + ?: run { + val args = savedStateHandle.toVerifyPasswordArgs() + val account = authRepository + .userStateFlow + .value + ?.accounts + ?.firstOrNull { it.userId == args.userId } + ?: throw IllegalStateException("Account not found") + + val restrictedItemPolicyOrgIds = policyManager + .getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES) + .filter { it.isEnabled } + .map { it.organizationId } + + VerifyPasswordState( + accountSummaryListItem = AccountSelectionListItem( + userId = args.userId, + avatarColorHex = account.avatarColorHex, + email = account.email, + initials = account.initials, + isItemRestricted = account + .organizations + .any { it.id in restrictedItemPolicyOrgIds }, + ), + ) + }, +) { + + init { + // As state updates, write to saved state handle. + stateFlow + .onEach { savedStateHandle[KEY_STATE] = it } + .launchIn(viewModelScope) + } + + override fun handleAction(action: VerifyPasswordAction) { + when (action) { + VerifyPasswordAction.NavigateBackClick -> { + handleNavigateBackClick() + } + + VerifyPasswordAction.UnlockClick -> { + handleUnlockClick() + } + + is VerifyPasswordAction.PasswordInputChangeReceive -> { + handlePasswordInputChange(action) + } + + VerifyPasswordAction.DismissDialog -> { + handleDismissDialog() + } + + is VerifyPasswordAction.Internal -> { + handleInternalAction(action) + } + } + } + + private fun handleNavigateBackClick() { + sendEvent(VerifyPasswordEvent.NavigateBack) + } + + private fun handleUnlockClick() { + if (state.input.isBlank()) { + mutableStateFlow.update { + it.copy( + dialog = VerifyPasswordState.DialogState.General( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.validation_field_required.asText( + BitwardenString.master_password.asText(), + ), + ), + ) + } + return + } + + mutableStateFlow.update { + it.copy( + dialog = VerifyPasswordState.DialogState.Loading( + message = BitwardenString.loading.asText(), + ), + ) + } + + if (authRepository.activeUserId != state.accountSummaryListItem.userId) { + switchAccountAndVerifyPassword() + } else { + validatePassword() + } + } + + private fun handlePasswordInputChange( + action: VerifyPasswordAction.PasswordInputChangeReceive, + ) { + mutableStateFlow.update { it.copy(input = action.input) } + } + + private fun handleDismissDialog() { + mutableStateFlow.update { it.copy(dialog = null) } + } + + private fun handleInternalAction(action: VerifyPasswordAction.Internal) { + when (action) { + is VerifyPasswordAction.Internal.ValidatePasswordResultReceive -> { + handleValidatePasswordResultReceive(action) + } + + is VerifyPasswordAction.Internal.UnlockVaultResultReceive -> { + handleUnlockVaultResultReceive(action) + } + } + } + + private fun handleValidatePasswordResultReceive( + action: VerifyPasswordAction.Internal.ValidatePasswordResultReceive, + ) { + mutableStateFlow.update { it.copy(dialog = null) } + when (action.result) { + is ValidatePasswordResult.Success -> { + if (action.result.isValid) { + sendEvent( + VerifyPasswordEvent.PasswordVerified( + state.accountSummaryListItem.userId, + ), + ) + } else { + showInvalidMasterPasswordDialog() + } + } + + is ValidatePasswordResult.Error -> { + showGenericErrorDialog(throwable = action.result.error) + } + } + } + + private fun handleUnlockVaultResultReceive( + action: VerifyPasswordAction.Internal.UnlockVaultResultReceive, + ) { + mutableStateFlow.update { it.copy(dialog = null) } + when (action.vaultUnlockResult) { + VaultUnlockResult.Success -> { + // A successful unlock result means the provided password is correct so we can + // consider the password verified and send the event. + sendEvent( + VerifyPasswordEvent.PasswordVerified( + state.accountSummaryListItem.userId, + ), + ) + } + + is VaultUnlockResult.AuthenticationError -> { + showInvalidMasterPasswordDialog( + throwable = action.vaultUnlockResult.error, + ) + } + + is VaultUnlockResult.InvalidStateError, + is VaultUnlockResult.BiometricDecodingError, + is VaultUnlockResult.GenericError, + -> { + showGenericErrorDialog(throwable = action.vaultUnlockResult.error) + } + } + } + + private fun switchAccountAndVerifyPassword() { + val switchAccountResult = authRepository + .switchAccount(userId = state.accountSummaryListItem.userId) + + when (switchAccountResult) { + SwitchAccountResult.AccountSwitched -> validatePassword() + SwitchAccountResult.NoChange -> { + mutableStateFlow.update { + it.copy( + dialog = VerifyPasswordState.DialogState.General( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.generic_error_message.asText(), + ), + ) + } + } + } + } + + private fun validatePassword() { + val userId = state.accountSummaryListItem.userId + + viewModelScope.launch { + if (vaultRepository.isVaultUnlocked(userId)) { + // If the vault is already unlocked, validate the password directly. + sendAction( + VerifyPasswordAction.Internal.ValidatePasswordResultReceive( + authRepository.validatePassword(password = state.input), + ), + ) + } else { + // Otherwise, unlock the vault with the provided password. The unlock result will + // indicate whether the password is correct. + sendAction( + VerifyPasswordAction.Internal.UnlockVaultResultReceive( + vaultRepository + .unlockVaultWithMasterPassword(masterPassword = state.input), + ), + ) + } + } + } + + private fun showInvalidMasterPasswordDialog( + throwable: Throwable? = null, + ) { + mutableStateFlow.update { + it.copy( + dialog = VerifyPasswordState.DialogState.General( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.invalid_master_password.asText(), + error = throwable, + ), + ) + } + } + + private fun showGenericErrorDialog(throwable: Throwable?) { + mutableStateFlow.update { + it.copy( + dialog = VerifyPasswordState.DialogState.General( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.generic_error_message.asText(), + error = throwable, + ), + ) + } + } +} + +/** + * Represents the state of the VerifyPassword screen. + * @param accountSummaryListItem The account summary to display. + * @param input The current password input. + * @param dialog The current dialog state, or null if no dialog is shown. + */ +@Parcelize +data class VerifyPasswordState( + val accountSummaryListItem: AccountSelectionListItem, + val input: String = "", + val dialog: DialogState? = null, +) : Parcelable { + + /** + * Whether the unlock button should be enabled. + */ + val isUnlockButtonEnabled: Boolean + get() = input.isNotBlank() && dialog !is DialogState.Loading + + /** + * Represents the state of a dialog. + */ + @Parcelize + sealed class DialogState : Parcelable { + /** + * Represents a general dialog with a title, message, and optional error. + * @param title The dialog title. + * @param message The dialog message. + * @param error An optional error associated with the dialog. + */ + data class General( + val title: Text, + val message: Text, + val error: Throwable? = null, + ) : DialogState() + + /** + * Represents a loading dialog with a message. + * @param message The loading message. + */ + data class Loading( + val message: Text, + ) : DialogState() + } +} + +/** + * Represents events that can be emitted from the VerifyPasswordViewModel. + */ +sealed class VerifyPasswordEvent { + /** + * Indicates a request to navigate back. + */ + data object NavigateBack : VerifyPasswordEvent() + + /** + * Indicates that the password has been successfully verified. + * @param userId The ID of the user whose password was verified. + */ + data class PasswordVerified(val userId: String) : VerifyPasswordEvent() +} + +/** + * Represents actions that can be handled by the VerifyPasswordViewModel. + */ +sealed class VerifyPasswordAction { + /** + * Represents a click on the navigate back button. + */ + data object NavigateBackClick : VerifyPasswordAction() + + /** + * Represents a click on the unlock button. + */ + data object UnlockClick : VerifyPasswordAction() + + /** + * Dismiss the current dialog. + */ + data object DismissDialog : VerifyPasswordAction() + + /** + * Represents a change in the password input. + * @param input The new password input. + */ + data class PasswordInputChangeReceive(val input: String) : VerifyPasswordAction() + + /** + * Represents internal actions that the VerifyPasswordViewModel itself may send. + */ + sealed class Internal : VerifyPasswordAction() { + + /** + * Represents a result of validating the password. + * @param result The result of validating the password. + */ + data class ValidatePasswordResultReceive( + val result: ValidatePasswordResult, + ) : Internal() + + /** + * Represents a result of unlocking the vault. + * @param vaultUnlockResult The result of unlocking the vault. + */ + data class UnlockVaultResultReceive( + val vaultUnlockResult: VaultUnlockResult, + ) : Internal() + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/handlers/VerifyPasswordHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/handlers/VerifyPasswordHandlers.kt new file mode 100644 index 0000000000..bfc11d8a9b --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/handlers/VerifyPasswordHandlers.kt @@ -0,0 +1,49 @@ +package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.handlers + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.VerifyPasswordAction +import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.VerifyPasswordViewModel + +/** + * A handler for the VerifyPassword screen interactions. + */ +data class VerifyPasswordHandlers( + val onNavigateBackClick: () -> Unit, + val onUnlockClick: () -> Unit, + val onInputChanged: (String) -> Unit, + val onDismissDialog: () -> Unit, +) { + + @Suppress("UndocumentedPublicClass") + companion object { + + /** + * Creates a [VerifyPasswordHandlers] from a [VerifyPasswordViewModel]. + */ + fun create(viewModel: VerifyPasswordViewModel): VerifyPasswordHandlers = + VerifyPasswordHandlers( + onNavigateBackClick = { + viewModel.trySendAction(VerifyPasswordAction.NavigateBackClick) + }, + onUnlockClick = { + viewModel.trySendAction(VerifyPasswordAction.UnlockClick) + }, + onInputChanged = { + viewModel.trySendAction( + VerifyPasswordAction.PasswordInputChangeReceive(it), + ) + }, + onDismissDialog = { + viewModel.trySendAction(VerifyPasswordAction.DismissDialog) + }, + ) + } +} + +/** + * Helper function to remember a [VerifyPasswordHandlers] instance in a [Composable] scope. + */ +@Composable +fun rememberVerifyPasswordHandler(viewModel: VerifyPasswordViewModel): VerifyPasswordHandlers = + remember(viewModel) { VerifyPasswordHandlers.create(viewModel) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreenTest.kt new file mode 100644 index 0000000000..8c42271c11 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreenTest.kt @@ -0,0 +1,265 @@ +package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword + +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.isDisplayed +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow +import com.bitwarden.data.repository.model.Environment +import com.bitwarden.network.model.OrganizationType +import com.bitwarden.ui.util.asText +import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus +import com.x8bit.bitwarden.data.auth.repository.model.Organization +import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState +import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest +import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem +import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class VerifyPasswordScreenTest : BitwardenComposeTest() { + + private var onNavigateBackClicked: Boolean = false + private var onPasswordVerifiedClicked: Boolean = false + private val onPasswordVerifiedArgSlot = mutableListOf() + + private val mockStateFlow = MutableStateFlow(DEFAULT_STATE) + private val mockEventFlow = bufferedMutableSharedFlow() + private val viewModel = mockk { + every { stateFlow } returns mockStateFlow + every { eventFlow } returns mockEventFlow + every { trySendAction(any()) } just runs + } + + @Before + fun verifyPasswordScreen() { + setContent { + VerifyPasswordScreen( + onNavigateBack = { onNavigateBackClicked = true }, + onPasswordVerified = { userId -> + onPasswordVerifiedClicked = true + onPasswordVerifiedArgSlot.add(userId) + }, + viewModel = viewModel, + ) + } + } + + @Test + fun `initial state should be correct`() = runTest { + composeTestRule + .onNodeWithText("Verify your master password") + .isDisplayed() + + composeTestRule + .onNodeWithText("AU") + .isDisplayed() + + composeTestRule + .onNodeWithText("active@bitwarden.com") + .isDisplayed() + + composeTestRule + .onNodeWithText("You vault is locked. Verify your master password to continue.") + + composeTestRule + .onNodeWithText("Unlock") + .assertIsNotEnabled() + } + + @Test + fun `input should update based on state`() = runTest { + composeTestRule + .onNodeWithText("Master password") + .performTextInput("abc123") + + composeTestRule + .onNodeWithTag("PasswordVisibilityToggle") + .performClick() + + composeTestRule + .onNodeWithText("abc123") + .isDisplayed() + } + + @Test + fun `input change should send PasswordInputChangeReceive action`() = runTest { + composeTestRule + .onNodeWithText("Master password") + .performTextInput("abc123") + + composeTestRule + .onNodeWithTag("PasswordVisibilityToggle") + .performClick() + verify { + viewModel.trySendAction( + VerifyPasswordAction.PasswordInputChangeReceive("abc123"), + ) + } + } + + @Test + fun `Unlock button should should update based on input`() = runTest { + composeTestRule + .onNodeWithText("Unlock") + .assertIsNotEnabled() + + mockStateFlow.emit(DEFAULT_STATE.copy(input = "abc123")) + + composeTestRule + .onNodeWithText("Unlock") + .assertIsEnabled() + } + + @Test + fun `Unlock button should send UnlockClick action`() = runTest { + mockStateFlow.emit(DEFAULT_STATE.copy(input = "abc123")) + composeTestRule + .onNodeWithText("Unlock") + .performClick() + verify { + viewModel.trySendAction(VerifyPasswordAction.UnlockClick) + } + } + + @Test + fun `back click should send NavigateBackClick action`() = runTest { + composeTestRule + .onNodeWithContentDescription("Back") + .performClick() + verify { + viewModel.trySendAction(VerifyPasswordAction.NavigateBackClick) + } + } + + @Test + fun `NavigateBack event should trigger onNavigateBack`() = runTest { + mockEventFlow.emit(VerifyPasswordEvent.NavigateBack) + assertTrue(onNavigateBackClicked) + } + + @Test + fun `PasswordVerified event should call onPasswordVerified with userId`() = runTest { + mockEventFlow.emit(VerifyPasswordEvent.PasswordVerified(DEFAULT_USER_ID)) + assertTrue(onPasswordVerifiedClicked) + assertEquals(1, onPasswordVerifiedArgSlot.size) + assertEquals(DEFAULT_USER_ID, onPasswordVerifiedArgSlot.first()) + } + + @Test + fun `General dialog should display based on state`() = runTest { + mockStateFlow.emit( + DEFAULT_STATE.copy( + dialog = VerifyPasswordState.DialogState.General( + title = "title".asText(), + message = "message".asText(), + error = null, + ), + ), + ) + composeTestRule + .onAllNodesWithText("title") + .filterToOne(hasAnyAncestor(isDialog())) + .isDisplayed() + } + + @Test + fun `General dialog dismiss should send DismissDialog action`() = runTest { + mockStateFlow.emit( + DEFAULT_STATE.copy( + dialog = VerifyPasswordState.DialogState.General( + title = "title".asText(), + message = "message".asText(), + error = null, + ), + ), + ) + composeTestRule + .onAllNodesWithText("Okay") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { + viewModel.trySendAction(VerifyPasswordAction.DismissDialog) + } + } + + @Test + fun `Loading dialog should display based on state`() = runTest { + mockStateFlow.emit( + DEFAULT_STATE.copy( + dialog = VerifyPasswordState.DialogState.Loading("message".asText()), + ), + ) + composeTestRule + .onAllNodesWithText("message") + .filterToOne(hasAnyAncestor(isDialog())) + .isDisplayed() + } +} + +private const val DEFAULT_USER_ID: String = "activeUserId" +private const val DEFAULT_ORGANIZATION_ID: String = "activeOrganizationId" +private val DEFAULT_USER_STATE = UserState( + activeUserId = DEFAULT_USER_ID, + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "Active User", + email = "active@bitwarden.com", + avatarColorHex = "#aa00aa", + environment = Environment.Us, + isPremium = true, + isLoggedIn = true, + isVaultUnlocked = true, + needsPasswordReset = false, + isBiometricsEnabled = false, + organizations = listOf( + Organization( + id = DEFAULT_ORGANIZATION_ID, + name = "Organization User", + shouldUseKeyConnector = false, + shouldManageResetPassword = false, + role = OrganizationType.USER, + keyConnectorUrl = null, + userIsClaimedByOrganization = false, + ), + ), + needsMasterPassword = false, + trustedDevice = null, + hasMasterPassword = true, + isUsingKeyConnector = false, + onboardingStatus = OnboardingStatus.COMPLETE, + firstTimeState = FirstTimeState(showImportLoginsCard = true), + ), + ), +) +private val DEFAULT_ACCOUNT_SELECTION_LIST_ITEM = AccountSelectionListItem( + userId = DEFAULT_USER_ID, + email = DEFAULT_USER_STATE.activeAccount.email, + avatarColorHex = DEFAULT_USER_STATE.activeAccount.avatarColorHex, + isItemRestricted = false, + initials = DEFAULT_USER_STATE.activeAccount.initials, +) +private val DEFAULT_STATE = VerifyPasswordState( + accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM, + input = "", + dialog = null, +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModelTest.kt new file mode 100644 index 0000000000..5bd656b7e0 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModelTest.kt @@ -0,0 +1,577 @@ +package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.bitwarden.data.repository.model.Environment +import com.bitwarden.network.model.OrganizationType +import com.bitwarden.network.model.PolicyTypeJson +import com.bitwarden.network.model.createMockPolicy +import com.bitwarden.ui.platform.base.BaseViewModelTest +import com.bitwarden.ui.platform.resource.BitwardenString +import com.bitwarden.ui.util.asText +import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.Organization +import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult +import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult +import com.x8bit.bitwarden.data.platform.manager.PolicyManager +import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState +import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult +import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem +import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials +import io.mockk.awaits +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class VerifyPasswordViewModelTest : BaseViewModelTest() { + + private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE) + private val authRepository = mockk { + every { userStateFlow } returns mutableUserStateFlow + every { activeUserId } returns DEFAULT_USER_ID + } + private val vaultRepository = mockk { + every { isVaultUnlocked(any()) } returns true + coEvery { + unlockVaultWithMasterPassword(masterPassword = any()) + } returns VaultUnlockResult.Success + } + private val policyManager = mockk { + every { getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES) } returns listOf( + createMockPolicy( + number = 1, + organizationId = DEFAULT_ORGANIZATION_ID, + isEnabled = false, + ), + ) + } + + @BeforeEach + fun setUp() { + mockkStatic( + SavedStateHandle::toVerifyPasswordArgs, + ) + } + + @AfterEach + fun tearDown() { + unmockkStatic( + SavedStateHandle::toVerifyPasswordArgs, + ) + } + + @Test + fun `initial state should be correct when account is not restricted`() = runTest { + createViewModel() + .also { + assertEquals( + VerifyPasswordState( + AccountSelectionListItem( + userId = DEFAULT_USER_ID, + email = DEFAULT_USER_STATE.activeAccount.email, + avatarColorHex = DEFAULT_USER_STATE.activeAccount.avatarColorHex, + isItemRestricted = false, + initials = DEFAULT_USER_STATE.activeAccount.initials, + ), + ), + it.stateFlow.value, + ) + } + } + + @Test + fun `initial state should be correct when account has item restrictions`() = runTest { + every { + policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES) + } returns listOf( + createMockPolicy( + number = 1, + organizationId = DEFAULT_ORGANIZATION_ID, + isEnabled = true, + ), + ) + + createViewModel() + .also { + assertEquals( + VerifyPasswordState( + accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM + .copy(isItemRestricted = true), + ), + it.stateFlow.value, + ) + } + } + + @Test + fun `NavigateBackClick should send NavigateBack event`() = runTest { + createViewModel().also { + it.trySendAction(VerifyPasswordAction.NavigateBackClick) + it.eventFlow.test { + assertEquals( + VerifyPasswordEvent.NavigateBack, + awaitItem(), + ) + } + } + } + + @Test + fun `UnlockClick with empty input should show error dialog`() = runTest { + createViewModel().also { + it.trySendAction(VerifyPasswordAction.UnlockClick) + it.stateFlow.test { + assertEquals( + DEFAULT_STATE.copy( + dialog = VerifyPasswordState.DialogState.General( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.validation_field_required.asText( + BitwardenString.master_password.asText(), + ), + ), + ), + awaitItem(), + ) + coVerify(exactly = 0) { + authRepository.activeUserId + authRepository.validatePassword(password = any()) + authRepository.switchAccount(userId = any()) + } + } + } + } + + @Suppress("MaxLineLength") + @Test + fun `UnlockClick with non-empty input should show loading dialog, validate password and send validates password`() = + runTest { + val initialState = DEFAULT_STATE.copy(input = "mockInput") + coEvery { authRepository.validatePassword(password = "mockInput") } just awaits + + createViewModel(state = initialState).also { viewModel -> + viewModel.trySendAction(VerifyPasswordAction.UnlockClick) + + viewModel.stateFlow.test { + assertEquals( + initialState.copy( + dialog = VerifyPasswordState.DialogState.Loading( + message = BitwardenString.loading.asText(), + ), + ), + awaitItem(), + ) + } + + coVerify(exactly = 1) { + authRepository.activeUserId + authRepository.validatePassword(password = "mockInput") + } + coVerify(exactly = 0) { + authRepository.switchAccount(userId = any()) + } + } + } + + @Suppress("MaxLineLength") + @Test + fun `UnlockClick with non-empty input should show loading dialog, switch accounts, then validate password when selected account is not active and switch is successful`() = + runTest { + val initialState = DEFAULT_STATE.copy( + accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM + .copy(userId = "otherUserId"), + input = "mockInput", + ) + every { + authRepository.switchAccount("otherUserId") + } returns SwitchAccountResult.AccountSwitched + coEvery { authRepository.validatePassword(password = "mockInput") } just awaits + createViewModel(state = initialState).also { viewModel -> + viewModel.trySendAction(VerifyPasswordAction.UnlockClick) + viewModel.stateFlow.test { + assertEquals( + initialState.copy( + dialog = VerifyPasswordState.DialogState.Loading( + message = BitwardenString.loading.asText(), + ), + ), + awaitItem(), + ) + } + coVerify { + authRepository.activeUserId + authRepository.switchAccount(userId = "otherUserId") + authRepository.validatePassword(password = "mockInput") + } + } + } + + @Suppress("MaxLineLength") + @Test + fun `UnlockClick with non-empty input should show error dialog when switch account is unsuccessful`() = + runTest { + val initialState = DEFAULT_STATE.copy( + accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM + .copy(userId = "otherUserId"), + input = "mockInput", + ) + every { + authRepository.switchAccount("otherUserId") + } returns SwitchAccountResult.NoChange + coEvery { authRepository.validatePassword(password = "mockInput") } just awaits + + createViewModel(state = initialState).also { viewModel -> + viewModel.stateFlow.test { + // Await initial state update + awaitItem() + viewModel.trySendAction(VerifyPasswordAction.UnlockClick) + coVerify { + authRepository.activeUserId + authRepository.switchAccount(userId = "otherUserId") + } + coVerify(exactly = 0) { + authRepository.validatePassword(password = any()) + } + assertEquals( + initialState.copy( + dialog = VerifyPasswordState.DialogState.General( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.generic_error_message.asText(), + ), + ), + awaitItem(), + ) + } + } + } + + @Suppress("MaxLineLength") + @Test + fun `UnlockClick with non-empty input should show loading dialog, then unlock vault when vault is locked`() = + runTest { + val initialState = DEFAULT_STATE.copy(input = "mockInput") + every { vaultRepository.isVaultUnlocked(any()) } returns false + coEvery { + vaultRepository.unlockVaultWithMasterPassword(masterPassword = "mockInput") + } just awaits + createViewModel(state = initialState).also { viewModel -> + viewModel.trySendAction(VerifyPasswordAction.UnlockClick) + viewModel.stateFlow.test { + assertEquals( + initialState.copy( + dialog = VerifyPasswordState.DialogState.Loading( + message = BitwardenString.loading.asText(), + ), + ), + awaitItem(), + ) + coVerify { + vaultRepository.unlockVaultWithMasterPassword(masterPassword = "mockInput") + } + } + } + } + + @Test + fun `PasswordInputChangeReceive should update state`() = runTest { + createViewModel(state = DEFAULT_STATE).also { viewModel -> + viewModel.trySendAction( + VerifyPasswordAction.PasswordInputChangeReceive("mockInput"), + ) + assertEquals( + DEFAULT_STATE.copy(input = "mockInput"), + viewModel.stateFlow.value, + ) + } + } + + @Test + fun `DismissDialog should update state`() = runTest { + val initialState = DEFAULT_STATE.copy( + dialog = VerifyPasswordState.DialogState.Loading( + message = BitwardenString.loading.asText(), + ), + ) + createViewModel(state = initialState).also { viewModel -> + viewModel.trySendAction(VerifyPasswordAction.DismissDialog) + assertEquals(null, viewModel.stateFlow.value.dialog) + } + } + + @Suppress("MaxLineLength") + @Test + fun `ValidatePasswordResultReceive should send PasswordVerified event when result is Success and isValid is true`() = + runTest { + createViewModel().also { viewModel -> + viewModel.trySendAction( + VerifyPasswordAction.Internal.ValidatePasswordResultReceive( + ValidatePasswordResult.Success(isValid = true), + ), + ) + viewModel.eventFlow.test { + assertEquals( + VerifyPasswordEvent.PasswordVerified(DEFAULT_USER_ID), + awaitItem(), + ) + } + } + } + + @Suppress("MaxLineLength") + @Test + fun `ValidatePasswordResultReceive should show error dialog when result is Success and isValid is false`() = + runTest { + createViewModel().also { viewModel -> + viewModel.trySendAction( + VerifyPasswordAction.Internal.ValidatePasswordResultReceive( + ValidatePasswordResult.Success(isValid = false), + ), + ) + assertEquals( + VerifyPasswordState.DialogState.General( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.invalid_master_password.asText(), + error = null, + ), + viewModel.stateFlow.value.dialog, + ) + } + } + + @Test + fun `ValidatePasswordResultReceive should show error dialog when result is Error`() = runTest { + val throwable = Throwable() + createViewModel().also { viewModel -> + viewModel.trySendAction( + VerifyPasswordAction.Internal.ValidatePasswordResultReceive( + ValidatePasswordResult.Error(error = throwable), + ), + ) + assertEquals( + VerifyPasswordState.DialogState.General( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.generic_error_message.asText(), + error = throwable, + ), + viewModel.stateFlow.value.dialog, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `UnlockVaultResultReceive should send PasswordVerified event when vault unlock result is Success`() = + runTest { + createViewModel().also { viewModel -> + viewModel.trySendAction( + VerifyPasswordAction.Internal.UnlockVaultResultReceive( + VaultUnlockResult.Success, + ), + ) + viewModel.eventFlow.test { + assertEquals( + VerifyPasswordEvent.PasswordVerified(DEFAULT_USER_ID), + awaitItem(), + ) + } + } + } + + @Test + fun `UnlockVaultResultReceive should show error dialog when vault unlock result is Error`() = + runTest { + val throwable = Throwable() + createViewModel().also { viewModel -> + viewModel.trySendAction( + VerifyPasswordAction.Internal.UnlockVaultResultReceive( + VaultUnlockResult.GenericError(error = throwable), + ), + ) + assertEquals( + DEFAULT_STATE.copy( + dialog = VerifyPasswordState.DialogState.General( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.generic_error_message.asText(), + error = throwable, + ), + ), + viewModel.stateFlow.value, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `UnlockVaultResultReceive should show error dialog when vault unlock result is AuthenticationError`() = + runTest { + val throwable = Throwable() + createViewModel().also { viewModel -> + viewModel.trySendAction( + VerifyPasswordAction.Internal.UnlockVaultResultReceive( + VaultUnlockResult.AuthenticationError(error = throwable), + ), + ) + assertEquals( + DEFAULT_STATE.copy( + dialog = VerifyPasswordState.DialogState.General( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.invalid_master_password.asText(), + error = throwable, + ), + ), + viewModel.stateFlow.value, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `UnlockVaultResultReceive should show error dialog when vault unlock result is BiometricDecodingError`() = + runTest { + val throwable = Throwable() + createViewModel().also { viewModel -> + viewModel.trySendAction( + VerifyPasswordAction.Internal.UnlockVaultResultReceive( + VaultUnlockResult.BiometricDecodingError(error = throwable), + ), + ) + assertEquals( + DEFAULT_STATE.copy( + dialog = VerifyPasswordState.DialogState.General( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.generic_error_message.asText(), + error = throwable, + ), + ), + viewModel.stateFlow.value, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `UnlockVaultResultReceive should show error dialog when vault unlock result is InvalidStateError`() = + runTest { + val throwable = Throwable() + createViewModel().also { viewModel -> + viewModel.trySendAction( + VerifyPasswordAction.Internal.UnlockVaultResultReceive( + VaultUnlockResult.InvalidStateError(error = throwable), + ), + ) + assertEquals( + DEFAULT_STATE.copy( + dialog = VerifyPasswordState.DialogState.General( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.generic_error_message.asText(), + error = throwable, + ), + ), + viewModel.stateFlow.value, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `UnlockVaultResultReceive should show error dialog when vault unlock result is GenericError`() = + runTest { + val throwable = Throwable() + createViewModel().also { viewModel -> + viewModel.trySendAction( + VerifyPasswordAction.Internal.UnlockVaultResultReceive( + VaultUnlockResult.GenericError(error = throwable), + ), + ) + assertEquals( + DEFAULT_STATE.copy( + dialog = VerifyPasswordState.DialogState.General( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.generic_error_message.asText(), + error = throwable, + ), + ), + viewModel.stateFlow.value, + ) + } + } + + private fun createViewModel( + state: VerifyPasswordState? = null, + userId: String = DEFAULT_USER_ID, + ): VerifyPasswordViewModel = VerifyPasswordViewModel( + authRepository = authRepository, + vaultRepository = vaultRepository, + policyManager = policyManager, + savedStateHandle = SavedStateHandle().apply { + set("state", state) + set("userId", userId) + every { + toVerifyPasswordArgs() + } returns VerifyPasswordArgs( + userId = DEFAULT_USER_ID, + ) + }, + ) +} + +private const val DEFAULT_USER_ID: String = "activeUserId" +private const val DEFAULT_ORGANIZATION_ID: String = "activeOrganizationId" +private val DEFAULT_USER_STATE = UserState( + activeUserId = DEFAULT_USER_ID, + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "Active User", + email = "active@bitwarden.com", + avatarColorHex = "#aa00aa", + environment = Environment.Us, + isPremium = true, + isLoggedIn = true, + isVaultUnlocked = true, + needsPasswordReset = false, + isBiometricsEnabled = false, + organizations = listOf( + Organization( + id = DEFAULT_ORGANIZATION_ID, + name = "Organization User", + shouldUseKeyConnector = false, + shouldManageResetPassword = false, + role = OrganizationType.USER, + keyConnectorUrl = null, + userIsClaimedByOrganization = false, + ), + ), + needsMasterPassword = false, + trustedDevice = null, + hasMasterPassword = true, + isUsingKeyConnector = false, + onboardingStatus = OnboardingStatus.COMPLETE, + firstTimeState = FirstTimeState(showImportLoginsCard = true), + ), + ), +) +private val DEFAULT_ACCOUNT_SELECTION_LIST_ITEM = AccountSelectionListItem( + userId = DEFAULT_USER_ID, + email = DEFAULT_USER_STATE.activeAccount.email, + avatarColorHex = DEFAULT_USER_STATE.activeAccount.avatarColorHex, + isItemRestricted = false, + initials = DEFAULT_USER_STATE.activeAccount.initials, +) +private val DEFAULT_STATE = VerifyPasswordState( + accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM, + input = "", + dialog = null, +) diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 7ced10111d..5b80ba772b 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1113,4 +1113,5 @@ Do you want to switch to this account? Import from Bitwarden Select account Import restricted, unable to import cards from this account. + Verify your master password