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 index ab08aaf560..79f83a7179 100644 --- 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 @@ -5,6 +5,7 @@ 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.layout.navigationBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.verticalScroll @@ -15,8 +16,10 @@ import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -25,14 +28,18 @@ 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.button.BitwardenOutlinedButton 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.snackbar.model.BitwardenSnackbarHostState +import com.bitwarden.ui.platform.components.snackbar.model.rememberBitwardenSnackbarHostState 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.bitwarden.ui.util.asText 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 @@ -47,6 +54,7 @@ fun VerifyPasswordScreen( onNavigateBack: () -> Unit, onPasswordVerified: (userId: String) -> Unit, viewModel: VerifyPasswordViewModel = hiltViewModel(), + snackbarHostState: BitwardenSnackbarHostState = rememberBitwardenSnackbarHostState(), ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) @@ -59,6 +67,10 @@ fun VerifyPasswordScreen( is VerifyPasswordEvent.PasswordVerified -> { onPasswordVerified(event.userId) } + + is VerifyPasswordEvent.ShowSnackbar -> { + snackbarHostState.showSnackbar(event.data) + } } } @@ -79,10 +91,9 @@ fun VerifyPasswordScreen( VerifyPasswordContent( state = state, onInputChanged = handler.onInputChanged, - onUnlockClick = handler.onUnlockClick, - modifier = Modifier - .fillMaxSize() - .standardHorizontalMargin(), + onContinueClick = handler.onContinueClick, + onResendCodeClick = handler.onSendCodeClick, + modifier = Modifier.fillMaxSize(), ) } } @@ -110,11 +121,13 @@ private fun VerifyPasswordDialogs( } } +@Suppress("LongMethod") @Composable private fun VerifyPasswordContent( state: VerifyPasswordState, onInputChanged: (String) -> Unit, - onUnlockClick: () -> Unit, + onContinueClick: () -> Unit, + onResendCodeClick: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -125,61 +138,121 @@ private fun VerifyPasswordContent( Spacer(Modifier.height(24.dp)) Text( - text = stringResource(BitwardenString.verify_your_master_password), + text = state.title(), textAlign = TextAlign.Center, style = BitwardenTheme.typography.titleMedium, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin(), ) + state.subtext?.let { subtext -> + Spacer(Modifier.height(8.dp)) + Text( + text = subtext(), + textAlign = TextAlign.Center, + style = BitwardenTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin(), + ) + } + Spacer(Modifier.height(16.dp)) AccountSummaryListItem( item = state.accountSummaryListItem, cardStyle = CardStyle.Full, clickable = false, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin(), ) 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(), - ) + if (state.showResendCodeButton) { + BitwardenPasswordField( + label = stringResource(id = BitwardenString.verification_code), + value = state.input, + onValueChange = onInputChanged, + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + keyboardActions = KeyboardActions( + onDone = { + if (state.isContinueButtonEnabled) { + onContinueClick() + } else { + defaultKeyboardAction(ImeAction.Done) + } + }, + ), + autoFocus = true, + cardStyle = CardStyle.Full, + passwordFieldTestTag = "VerificationCodeEntry", + showPasswordTestTag = "VerificationCodeVisibilityToggle", + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin(), + ) + } else { + BitwardenPasswordField( + label = stringResource(BitwardenString.master_password), + value = state.input, + onValueChange = onInputChanged, + showPasswordTestTag = "PasswordVisibilityToggle", + imeAction = ImeAction.Done, + keyboardActions = KeyboardActions( + onDone = { + if (state.isContinueButtonEnabled) { + onContinueClick() + } else { + defaultKeyboardAction(ImeAction.Done) + } + }, + ), + autoFocus = true, + supportingText = stringResource(BitwardenString.vault_locked_master_password), + passwordFieldTestTag = "MasterPasswordEntry", + cardStyle = CardStyle.Full, + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin(), + ) + } Spacer(Modifier.height(16.dp)) BitwardenFilledButton( - label = stringResource(BitwardenString.unlock), - onClick = onUnlockClick, - isEnabled = state.isUnlockButtonEnabled, - modifier = Modifier.fillMaxWidth(), + label = stringResource(BitwardenString.continue_text), + onClick = onContinueClick, + isEnabled = state.isContinueButtonEnabled, + modifier = Modifier + .testTag("ContinueImportButton") + .fillMaxWidth() + .standardHorizontalMargin(), ) + if (state.showResendCodeButton) { + BitwardenOutlinedButton( + label = stringResource(BitwardenString.resend_code), + onClick = onResendCodeClick, + modifier = Modifier + .testTag("ResendTOTPCodeButton") + .fillMaxWidth() + .standardHorizontalMargin(), + ) + } + Spacer(Modifier.height(12.dp)) + Spacer(Modifier.navigationBarsPadding()) } } @OptIn(ExperimentalMaterial3Api::class) @Preview(showBackground = true) @Composable -private fun VerifyPasswordContent_Preview() { +private fun VerifyPasswordContent_MasterPassword_preview() { val accountSummaryListItem = AccountSelectionListItem( userId = "userId", isItemRestricted = false, @@ -188,14 +261,65 @@ private fun VerifyPasswordContent_Preview() { email = "john.doe@example.com", ) val state = VerifyPasswordState( + title = BitwardenString.verify_your_master_password.asText(), + subtext = null, accountSummaryListItem = accountSummaryListItem, ) - VerifyPasswordContent( - state = state, - onInputChanged = {}, - onUnlockClick = {}, - modifier = Modifier - .fillMaxSize() - .standardHorizontalMargin(), - ) + ExportItemsScaffold( + navIcon = rememberVectorPainter( + BitwardenDrawable.ic_back, + ), + onNavigationIconClick = {}, + navigationIconContentDescription = stringResource(BitwardenString.back), + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()), + modifier = Modifier.fillMaxSize(), + ) { + VerifyPasswordContent( + state = state, + onInputChanged = {}, + onContinueClick = {}, + onResendCodeClick = {}, + modifier = Modifier + .fillMaxSize(), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true) +@Composable +private fun VerifyPasswordContent_Otp_preview() { + val accountSummaryListItem = AccountSelectionListItem( + userId = "userId", + isItemRestricted = false, + avatarColorHex = "#FF0000", + initials = "JD", + email = "john.doe@example.com", + ) + val state = VerifyPasswordState( + title = BitwardenString.verify_your_account_email_address.asText(), + subtext = BitwardenString + .enter_the_6_digit_code_that_was_emailed_to_the_address_below + .asText(), + accountSummaryListItem = accountSummaryListItem, + showResendCodeButton = true, + ) + ExportItemsScaffold( + navIcon = rememberVectorPainter( + BitwardenDrawable.ic_back, + ), + onNavigationIconClick = {}, + navigationIconContentDescription = stringResource(BitwardenString.back), + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()), + modifier = Modifier.fillMaxSize(), + ) { + VerifyPasswordContent( + state = state, + onInputChanged = {}, + onContinueClick = {}, + onResendCodeClick = {}, + modifier = Modifier + .fillMaxSize(), + ) + } } 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 index 854273e6ed..7d694dc290 100644 --- 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 @@ -5,12 +5,15 @@ 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.components.snackbar.model.BitwardenSnackbarData 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.RequestOtpResult import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult +import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult 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 @@ -59,6 +62,15 @@ class VerifyPasswordViewModel @Inject constructor( .map { it.organizationId } VerifyPasswordState( + title = if (account.hasMasterPassword) { + BitwardenString.verify_your_master_password.asText() + } else { + BitwardenString.verify_your_account_email_address.asText() + }, + subtext = BitwardenString + .enter_the_6_digit_code_that_was_emailed_to_the_address_below + .asText() + .takeUnless { account.hasMasterPassword }, accountSummaryListItem = AccountSelectionListItem( userId = args.userId, avatarColorHex = account.avatarColorHex, @@ -68,6 +80,7 @@ class VerifyPasswordViewModel @Inject constructor( .organizations .any { it.id in restrictedItemPolicyOrgIds }, ), + showResendCodeButton = !account.hasMasterPassword, ) }, ) { @@ -77,6 +90,16 @@ class VerifyPasswordViewModel @Inject constructor( stateFlow .onEach { savedStateHandle[KEY_STATE] = it } .launchIn(viewModelScope) + + if (stateFlow.value.showResendCodeButton) { + viewModelScope.launch { + sendAction( + VerifyPasswordAction.Internal.SendOtpCodeResultReceive( + result = authRepository.requestOneTimePasscode(), + ), + ) + } + } } override fun onCleared() { @@ -92,8 +115,8 @@ class VerifyPasswordViewModel @Inject constructor( handleNavigateBackClick() } - VerifyPasswordAction.UnlockClick -> { - handleUnlockClick() + VerifyPasswordAction.ContinueClick -> { + handleContinueClick() } is VerifyPasswordAction.PasswordInputChangeReceive -> { @@ -104,6 +127,10 @@ class VerifyPasswordViewModel @Inject constructor( handleDismissDialog() } + VerifyPasswordAction.ResendCodeClick -> { + handleResendCodeClick() + } + is VerifyPasswordAction.Internal -> { handleInternalAction(action) } @@ -114,7 +141,7 @@ class VerifyPasswordViewModel @Inject constructor( sendEvent(VerifyPasswordEvent.NavigateBack) } - private fun handleUnlockClick() { + private fun handleContinueClick() { if (state.input.isBlank()) { mutableStateFlow.update { it.copy( @@ -154,6 +181,23 @@ class VerifyPasswordViewModel @Inject constructor( mutableStateFlow.update { it.copy(dialog = null) } } + private fun handleResendCodeClick() { + mutableStateFlow.update { + it.copy( + dialog = VerifyPasswordState.DialogState.Loading( + message = BitwardenString.sending.asText(), + ), + ) + } + viewModelScope.launch { + sendAction( + VerifyPasswordAction.Internal.SendOtpCodeResultReceive( + result = authRepository.requestOneTimePasscode(), + ), + ) + } + } + private fun handleInternalAction(action: VerifyPasswordAction.Internal) { when (action) { is VerifyPasswordAction.Internal.ValidatePasswordResultReceive -> { @@ -163,6 +207,14 @@ class VerifyPasswordViewModel @Inject constructor( is VerifyPasswordAction.Internal.UnlockVaultResultReceive -> { handleUnlockVaultResultReceive(action) } + + is VerifyPasswordAction.Internal.SendOtpCodeResultReceive -> { + handleSendOtpCodeResultReceive(action) + } + + is VerifyPasswordAction.Internal.VerifyOtpResultReceive -> { + handleVerifyOtpResultReceive(action) + } } } @@ -221,6 +273,60 @@ class VerifyPasswordViewModel @Inject constructor( } } + private fun handleSendOtpCodeResultReceive( + action: VerifyPasswordAction.Internal.SendOtpCodeResultReceive, + ) { + when (val result = action.result) { + is RequestOtpResult.Error -> { + val message = result.message?.asText() + ?: BitwardenString.generic_error_message.asText() + mutableStateFlow.update { + it.copy( + dialog = VerifyPasswordState.DialogState.General( + title = BitwardenString.an_error_has_occurred.asText(), + message = message, + ), + ) + } + } + + RequestOtpResult.Success -> { + mutableStateFlow.update { + it.copy(dialog = null) + } + sendEvent( + VerifyPasswordEvent.ShowSnackbar(BitwardenString.code_sent.asText()), + ) + } + } + } + + private fun handleVerifyOtpResultReceive( + action: VerifyPasswordAction.Internal.VerifyOtpResultReceive, + ) { + when (action.result) { + is VerifyOtpResult.Verified -> { + mutableStateFlow.update { it.copy(dialog = null) } + sendEvent( + VerifyPasswordEvent.PasswordVerified( + state.accountSummaryListItem.userId, + ), + ) + } + + is VerifyOtpResult.NotVerified -> { + mutableStateFlow.update { + it.copy( + dialog = VerifyPasswordState.DialogState.General( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.invalid_verification_code.asText(), + ), + ) + } + } + } + } + private fun switchAccountAndVerifyPassword() { val switchAccountResult = authRepository .switchAccount(userId = state.accountSummaryListItem.userId) @@ -244,7 +350,15 @@ class VerifyPasswordViewModel @Inject constructor( val userId = state.accountSummaryListItem.userId viewModelScope.launch { - if (vaultRepository.isVaultUnlocked(userId)) { + if (state.showResendCodeButton) { + sendAction( + VerifyPasswordAction.Internal.VerifyOtpResultReceive( + result = authRepository.verifyOneTimePasscode( + oneTimePasscode = state.input, + ), + ), + ) + } else if (vaultRepository.isVaultUnlocked(userId)) { // If the vault is already unlocked, validate the password directly. sendAction( VerifyPasswordAction.Internal.ValidatePasswordResultReceive( @@ -300,19 +414,23 @@ class VerifyPasswordViewModel @Inject constructor( * @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. + * @param showResendCodeButton Whether to show the send code button. */ @Parcelize data class VerifyPasswordState( val accountSummaryListItem: AccountSelectionListItem, + val title: Text, + val subtext: Text?, // We never want this saved since the input is sensitive data. @IgnoredOnParcel val input: String = "", val dialog: DialogState? = null, + val showResendCodeButton: Boolean = false, ) : Parcelable { /** * Whether the unlock button should be enabled. */ - val isUnlockButtonEnabled: Boolean + val isContinueButtonEnabled: Boolean get() = input.isNotBlank() && dialog !is DialogState.Loading /** @@ -356,6 +474,27 @@ sealed class VerifyPasswordEvent { * @param userId The ID of the user whose password was verified. */ data class PasswordVerified(val userId: String) : VerifyPasswordEvent() + + /** + * Show a snackbar with the given data. + */ + data class ShowSnackbar( + val data: BitwardenSnackbarData, + ) : VerifyPasswordEvent() { + constructor( + message: Text, + messageHeader: Text? = null, + actionLabel: Text? = null, + withDismissAction: Boolean = false, + ) : this( + data = BitwardenSnackbarData( + message = message, + messageHeader = messageHeader, + actionLabel = actionLabel, + withDismissAction = withDismissAction, + ), + ) + } } /** @@ -368,15 +507,20 @@ sealed class VerifyPasswordAction { data object NavigateBackClick : VerifyPasswordAction() /** - * Represents a click on the unlock button. + * Represents a click on the Continue button. */ - data object UnlockClick : VerifyPasswordAction() + data object ContinueClick : VerifyPasswordAction() /** * Dismiss the current dialog. */ data object DismissDialog : VerifyPasswordAction() + /** + * Represents a click on the resend code button. + */ + data object ResendCodeClick : VerifyPasswordAction() + /** * Represents a change in the password input. * @param input The new password input. @@ -403,5 +547,17 @@ sealed class VerifyPasswordAction { data class UnlockVaultResultReceive( val vaultUnlockResult: VaultUnlockResult, ) : Internal() + + /** + * Represents a result of requesting an OTP code. + * @param result The result of requesting an OTP code. + */ + data class SendOtpCodeResultReceive(val result: RequestOtpResult) : Internal() + + /** + * Represents a result of verifying an OTP code. + * @param result The result of verifying an OTP code. + */ + data class VerifyOtpResultReceive(val result: VerifyOtpResult) : 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 index bfc11d8a9b..04cbfdea70 100644 --- 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 @@ -10,8 +10,9 @@ import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.VerifyPas */ data class VerifyPasswordHandlers( val onNavigateBackClick: () -> Unit, - val onUnlockClick: () -> Unit, + val onContinueClick: () -> Unit, val onInputChanged: (String) -> Unit, + val onSendCodeClick: () -> Unit, val onDismissDialog: () -> Unit, ) { @@ -26,14 +27,17 @@ data class VerifyPasswordHandlers( onNavigateBackClick = { viewModel.trySendAction(VerifyPasswordAction.NavigateBackClick) }, - onUnlockClick = { - viewModel.trySendAction(VerifyPasswordAction.UnlockClick) + onContinueClick = { + viewModel.trySendAction(VerifyPasswordAction.ContinueClick) }, onInputChanged = { viewModel.trySendAction( VerifyPasswordAction.PasswordInputChangeReceive(it), ) }, + onSendCodeClick = { + viewModel.trySendAction(VerifyPasswordAction.ResendCodeClick) + }, onDismissDialog = { viewModel.trySendAction(VerifyPasswordAction.DismissDialog) }, 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 index 8c42271c11..f654b7d46b 100644 --- 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 @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.filterToOne @@ -15,6 +16,7 @@ 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.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.model.Organization @@ -81,10 +83,34 @@ class VerifyPasswordScreenTest : BitwardenComposeTest() { .onNodeWithText("You vault is locked. Verify your master password to continue.") composeTestRule - .onNodeWithText("Unlock") + .onNodeWithText("Continue") .assertIsNotEnabled() } + @Test + fun `otp state should be correct`() = runTest { + mockStateFlow.emit( + DEFAULT_STATE.copy( + title = BitwardenString.verify_your_account_email_address.asText(), + subtext = BitwardenString + .enter_the_6_digit_code_that_was_emailed_to_the_address_below + .asText(), + showResendCodeButton = true, + ), + ) + + composeTestRule + .onNodeWithText("Verify your account email address") + + composeTestRule + .onNodeWithText("Enter the 6-digit code that was emailed to the address below") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Resend code") + .assertIsDisplayed() + } + @Test fun `input should update based on state`() = runTest { composeTestRule @@ -105,10 +131,6 @@ class VerifyPasswordScreenTest : BitwardenComposeTest() { composeTestRule .onNodeWithText("Master password") .performTextInput("abc123") - - composeTestRule - .onNodeWithTag("PasswordVisibilityToggle") - .performClick() verify { viewModel.trySendAction( VerifyPasswordAction.PasswordInputChangeReceive("abc123"), @@ -117,26 +139,37 @@ class VerifyPasswordScreenTest : BitwardenComposeTest() { } @Test - fun `Unlock button should should update based on input`() = runTest { + fun `Continue button should should update based on input`() = runTest { composeTestRule - .onNodeWithText("Unlock") + .onNodeWithText("Continue") .assertIsNotEnabled() mockStateFlow.emit(DEFAULT_STATE.copy(input = "abc123")) composeTestRule - .onNodeWithText("Unlock") + .onNodeWithText("Continue") .assertIsEnabled() } @Test - fun `Unlock button should send UnlockClick action`() = runTest { + fun `Continue button should send ContinueClick action`() = runTest { mockStateFlow.emit(DEFAULT_STATE.copy(input = "abc123")) composeTestRule - .onNodeWithText("Unlock") + .onNodeWithText("Continue") .performClick() verify { - viewModel.trySendAction(VerifyPasswordAction.UnlockClick) + viewModel.trySendAction(VerifyPasswordAction.ContinueClick) + } + } + + @Test + fun `Resend code button should send SendCodeClick action`() = runTest { + mockStateFlow.emit(DEFAULT_STATE.copy(showResendCodeButton = true)) + composeTestRule + .onNodeWithText("Resend code") + .performClick() + verify { + viewModel.trySendAction(VerifyPasswordAction.ResendCodeClick) } } @@ -259,6 +292,8 @@ private val DEFAULT_ACCOUNT_SELECTION_LIST_ITEM = AccountSelectionListItem( initials = DEFAULT_USER_STATE.activeAccount.initials, ) private val DEFAULT_STATE = VerifyPasswordState( + title = BitwardenString.verify_your_master_password.asText(), + subtext = null, 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 index b7da34a968..fd303f696a 100644 --- 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 @@ -12,9 +12,11 @@ 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.RequestOtpResult 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.auth.repository.model.VerifyOtpResult 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 @@ -34,6 +36,7 @@ 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.Nested import org.junit.jupiter.api.Test class VerifyPasswordViewModelTest : BaseViewModelTest() { @@ -42,6 +45,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() { private val authRepository = mockk { every { userStateFlow } returns mutableUserStateFlow every { activeUserId } returns DEFAULT_USER_ID + coEvery { requestOneTimePasscode() } returns RequestOtpResult.Success } private val vaultRepository = mockk { every { isVaultUnlocked(any()) } returns true @@ -73,323 +77,409 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() { ) } - @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, - ) - } - } + @Nested + inner class State { + @Test + fun `initial state should be correct when account has no master password`() = runTest { + mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( + accounts = DEFAULT_USER_STATE.accounts.map { + it.copy(hasMasterPassword = false) + }, + ) + coEvery { authRepository.requestOneTimePasscode() } returns RequestOtpResult.Success - @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 { + createViewModel() + .also { assertEquals( - initialState.copy( - dialog = VerifyPasswordState.DialogState.Loading( - message = BitwardenString.loading.asText(), + VerifyPasswordState( + title = BitwardenString.verify_your_account_email_address.asText(), + subtext = BitwardenString + .enter_the_6_digit_code_that_was_emailed_to_the_address_below + .asText(), + accountSummaryListItem = 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, + ), + showResendCodeButton = true, + ), + it.stateFlow.value, + ) + coVerify { authRepository.requestOneTimePasscode() } + } + } + + @Test + fun `initial state should be correct when account is not restricted`() = runTest { + createViewModel() + .also { + assertEquals( + VerifyPasswordState( + title = BitwardenString.verify_your_master_password.asText(), + subtext = null, + accountSummaryListItem = 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, ), ), - awaitItem(), + it.stateFlow.value, ) } - - 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", - ) + @Test + fun `initial state should be correct when account has item restrictions`() = runTest { 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 { + policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES) + } returns listOf( + createMockPolicy( + number = 1, + organizationId = DEFAULT_ORGANIZATION_ID, + isEnabled = true, + ), + ) + + createViewModel() + .also { assertEquals( - initialState.copy( - dialog = VerifyPasswordState.DialogState.Loading( - message = BitwardenString.loading.asText(), - ), + VerifyPasswordState( + title = BitwardenString.verify_your_master_password.asText(), + subtext = null, + accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM + .copy(isItemRestricted = true), ), - awaitItem(), + it.stateFlow.value, ) } - 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 { + @Nested + inner class ViewActions { + + @Test + fun `SendCodeClick should request otp code`() = runTest { val initialState = DEFAULT_STATE.copy( - accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM - .copy(userId = "otherUserId"), - input = "mockInput", + title = BitwardenString.verify_your_account_email_address.asText(), + subtext = BitwardenString + .enter_the_6_digit_code_that_was_emailed_to_the_address_below + .asText(), + showResendCodeButton = true, ) - every { - authRepository.switchAccount("otherUserId") - } returns SwitchAccountResult.NoChange - coEvery { authRepository.validatePassword(password = "mockInput") } just awaits - + coEvery { authRepository.requestOneTimePasscode() } returns RequestOtpResult.Success 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(), - ) - } + viewModel.trySendAction(VerifyPasswordAction.ResendCodeClick) + coVerify { authRepository.requestOneTimePasscode() } } } - @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 and clear input when result is Success and isValid is true`() = - runTest { - createViewModel(state = DEFAULT_STATE.copy(input = "mockInput")) - .also { viewModel -> - viewModel.trySendAction( - VerifyPasswordAction.Internal.ValidatePasswordResultReceive( - ValidatePasswordResult.Success(isValid = true), - ), - ) - assertEquals( - DEFAULT_STATE.copy(input = ""), - viewModel.stateFlow.value, - ) - 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 { + @Test + fun `SendOtpCodeResultReceive success should show snackbar`() = runTest { createViewModel().also { viewModel -> viewModel.trySendAction( - VerifyPasswordAction.Internal.ValidatePasswordResultReceive( - ValidatePasswordResult.Success(isValid = false), + VerifyPasswordAction.Internal.SendOtpCodeResultReceive( + RequestOtpResult.Success, ), ) + + viewModel.eventFlow.test { + assertEquals( + VerifyPasswordEvent.ShowSnackbar(BitwardenString.code_sent.asText()), + awaitItem(), + ) + } + } + } + + @Test + fun `SendOtpCodeResultReceive error should show dialog`() = runTest { + createViewModel().also { viewModel -> + viewModel.trySendAction( + VerifyPasswordAction.Internal.SendOtpCodeResultReceive( + RequestOtpResult.Error( + message = "error", + error = IllegalStateException(), + ), + ), + ) + assertEquals( VerifyPasswordState.DialogState.General( title = BitwardenString.an_error_has_occurred.asText(), - message = BitwardenString.invalid_master_password.asText(), - error = null, + message = "error".asText(), ), 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), + @Test + fun `ContinueClick with otp should verify otp`() = runTest { + val initialState = DEFAULT_STATE.copy(showResendCodeButton = true, input = "123456") + coEvery { + authRepository.verifyOneTimePasscode("123456") + } returns VerifyOtpResult.Verified + + createViewModel(state = initialState).also { viewModel -> + viewModel.trySendAction(VerifyPasswordAction.ContinueClick) + + coVerify { authRepository.verifyOneTimePasscode("123456") } + } + } + + @Test + fun `VerifyOtpResultReceive verified should send event`() = runTest { + createViewModel().also { viewModel -> + viewModel.trySendAction( + VerifyPasswordAction.Internal.VerifyOtpResultReceive( + VerifyOtpResult.Verified, + ), + ) + + viewModel.eventFlow.test { + assertEquals( + VerifyPasswordEvent.PasswordVerified(DEFAULT_USER_ID), + awaitItem(), + ) + } + } + } + + @Test + fun `VerifyOtpResultReceive not verified should show dialog`() = runTest { + createViewModel().also { viewModel -> + viewModel.trySendAction( + VerifyPasswordAction.Internal.VerifyOtpResultReceive( + VerifyOtpResult.NotVerified( + error = IllegalStateException(), + errorMessage = null, + ), + ), + ) + + assertEquals( + VerifyPasswordState.DialogState.General( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.invalid_verification_code.asText(), + ), + viewModel.stateFlow.value.dialog, + ) + } + } + + @Test + fun `NavigateBackClick should send NavigateBack event`() = runTest { + createViewModel().also { + it.trySendAction(VerifyPasswordAction.NavigateBackClick) + it.eventFlow.test { + assertEquals( + VerifyPasswordEvent.NavigateBack, + awaitItem(), + ) + } + } + } + + @Test + fun `ContinueClick with empty input should show error dialog`() = runTest { + createViewModel().also { + it.trySendAction(VerifyPasswordAction.ContinueClick) + 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 `ContinueClick 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.ContinueClick) + + 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 `ContinueClick 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.ContinueClick) + 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 `ContinueClick 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.ContinueClick) + 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 `ContinueClick 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.ContinueClick) + 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(), ), ) - assertEquals( - VerifyPasswordState.DialogState.General( - title = BitwardenString.an_error_has_occurred.asText(), - message = BitwardenString.generic_error_message.asText(), - error = throwable, - ), - viewModel.stateFlow.value.dialog, - ) + createViewModel(state = initialState).also { viewModel -> + viewModel.trySendAction(VerifyPasswordAction.DismissDialog) + assertEquals(null, viewModel.stateFlow.value.dialog) + } } } - @Suppress("MaxLineLength") - @Test - fun `UnlockVaultResultReceive should send PasswordVerified event and clear inputs when vault unlock result is Success`() = - runTest { - createViewModel(state = DEFAULT_STATE.copy(input = "mockInput")) - .also { viewModel -> + @Nested + inner class InternalActions { + + @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.UnlockVaultResultReceive( - VaultUnlockResult.Success, + VerifyPasswordAction.Internal.ValidatePasswordResultReceive( + ValidatePasswordResult.Success(isValid = true), ), ) - assertEquals( - DEFAULT_STATE.copy(input = ""), - viewModel.stateFlow.value, - ) viewModel.eventFlow.test { assertEquals( VerifyPasswordEvent.PasswordVerified(DEFAULT_USER_ID), @@ -397,126 +487,189 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() { ) } } - } - - @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( + @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 = throwable, + error = null, ), - ), - viewModel.stateFlow.value, - ) + viewModel.stateFlow.value.dialog, + ) + } } - } - @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( + @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, - ) + viewModel.stateFlow.value.dialog, + ) + } } - } - @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, + @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.stateFlow.value, - ) + ) + viewModel.eventFlow.test { + assertEquals( + VerifyPasswordEvent.PasswordVerified(DEFAULT_USER_ID), + awaitItem(), + ) + } + } } - } - @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, + @Suppress("MaxLineLength") + @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), ), - ), - viewModel.stateFlow.value, - ) + ) + 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, @@ -581,6 +734,8 @@ private val DEFAULT_ACCOUNT_SELECTION_LIST_ITEM = AccountSelectionListItem( initials = DEFAULT_USER_STATE.activeAccount.initials, ) private val DEFAULT_STATE = VerifyPasswordState( + title = BitwardenString.verify_your_master_password.asText(), + subtext = null, 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 12e912f202..4520334153 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1135,4 +1135,6 @@ Do you want to switch to this account? Your vault may be empty, or importing some item types isn’t allowed for your account. No items available to import Select a different account + Verify your account email address + Enter the 6-digit code that was emailed to the address below