diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetpassword/ResetPasswordScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetpassword/ResetPasswordScreen.kt index 82e3189331..139f57f18e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetpassword/ResetPasswordScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetpassword/ResetPasswordScreen.kt @@ -33,7 +33,10 @@ import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.bitwarden.ui.platform.components.appbar.action.BitwardenOverflowActionItem import com.bitwarden.ui.platform.components.appbar.model.OverflowMenuItemData import com.bitwarden.ui.platform.components.button.BitwardenTextButton +import com.bitwarden.ui.platform.components.button.model.BitwardenButtonData import com.bitwarden.ui.platform.components.card.BitwardenInfoCalloutCard +import com.bitwarden.ui.platform.components.content.BitwardenErrorContent +import com.bitwarden.ui.platform.components.content.BitwardenLoadingContent import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog @@ -42,8 +45,10 @@ import com.bitwarden.ui.platform.components.field.BitwardenTextField import com.bitwarden.ui.platform.components.model.CardStyle import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.bitwarden.ui.platform.components.text.BitwardenHyperTextLink +import com.bitwarden.ui.platform.components.util.throttledClick import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.theme.BitwardenTheme +import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthIndicator import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState @@ -113,11 +118,13 @@ fun ResetPasswordScreen( navigationIcon = null, scrollBehavior = scrollBehavior, actions = { - BitwardenTextButton( - label = stringResource(id = BitwardenString.save), - onClick = { viewModel.trySendAction(ResetPasswordAction.SaveClick) }, - modifier = Modifier.testTag("SaveButton"), - ) + if (state.viewState.shouldShowSaveButton) { + BitwardenTextButton( + label = stringResource(id = BitwardenString.save), + onClick = { viewModel.trySendAction(ResetPasswordAction.SaveClick) }, + modifier = Modifier.testTag("SaveButton"), + ) + } BitwardenOverflowActionItem( menuItemDataList = persistentListOf( OverflowMenuItemData( @@ -130,32 +137,56 @@ fun ResetPasswordScreen( ) }, ) { - ResetPasswordScreenContent( - state = state, - onCurrentPasswordInputChanged = { - viewModel.trySendAction(ResetPasswordAction.CurrentPasswordInputChanged(it)) - }, - onPasswordInputChanged = { - viewModel.trySendAction(ResetPasswordAction.PasswordInputChanged(it)) - }, - onRetypePasswordInputChanged = { - viewModel.trySendAction(ResetPasswordAction.RetypePasswordInputChanged(it)) - }, - onPasswordHintInputChanged = { - viewModel.trySendAction(ResetPasswordAction.PasswordHintInputChanged(it)) - }, - onLearnToPreventLockout = { - viewModel.trySendAction(ResetPasswordAction.LearnHowPreventLockoutClick) - }, - modifier = Modifier.fillMaxSize(), - ) + when (val viewState = state.viewState) { + is ResetPasswordState.ViewState.Content -> { + ResetPasswordScreenContent( + state = viewState, + onCurrentPasswordInputChanged = { + viewModel.trySendAction(ResetPasswordAction.CurrentPasswordInputChanged(it)) + }, + onPasswordInputChanged = { + viewModel.trySendAction(ResetPasswordAction.PasswordInputChanged(it)) + }, + onRetypePasswordInputChanged = { + viewModel.trySendAction(ResetPasswordAction.RetypePasswordInputChanged(it)) + }, + onPasswordHintInputChanged = { + viewModel.trySendAction(ResetPasswordAction.PasswordHintInputChanged(it)) + }, + onLearnToPreventLockout = { + viewModel.trySendAction(ResetPasswordAction.LearnHowPreventLockoutClick) + }, + modifier = Modifier.fillMaxSize(), + ) + } + + is ResetPasswordState.ViewState.Error -> { + BitwardenErrorContent( + message = viewState.message(), + buttonData = BitwardenButtonData( + label = BitwardenString.try_again.asText(), + onClick = throttledClick { + viewModel.trySendAction(ResetPasswordAction.RetryClick) + }, + ), + modifier = Modifier.fillMaxSize(), + ) + } + + ResetPasswordState.ViewState.Loading -> { + BitwardenLoadingContent( + text = stringResource(id = BitwardenString.syncing), + modifier = Modifier.fillMaxSize(), + ) + } + } } } @Composable @Suppress("LongMethod") private fun ResetPasswordScreenContent( - state: ResetPasswordState, + state: ResetPasswordState.ViewState.Content, onCurrentPasswordInputChanged: (String) -> Unit, onPasswordInputChanged: (String) -> Unit, onRetypePasswordInputChanged: (String) -> Unit, @@ -258,7 +289,7 @@ private fun ResetPasswordScreenContent( Column { Text( text = stringResource( - BitwardenString + id = BitwardenString .bitwarden_cannot_reset_a_lost_or_forgotten_master_password, ), style = BitwardenTheme.typography.bodySmall, @@ -292,16 +323,15 @@ private fun ResetPasswordScreenContent( private fun ResetPasswordScreenContent_preview() { BitwardenTheme { ResetPasswordScreenContent( - state = ResetPasswordState( - policies = listOf(), + state = ResetPasswordState.ViewState.Content( resetReason = null, - dialogState = null, + policies = persistentListOf(), + minimumPasswordLength = 12, currentPasswordInput = "", passwordInput = "", retypePasswordInput = "", passwordHintInput = "", passwordStrengthState = PasswordStrengthState.GOOD, - minimumPasswordLength = 12, ), onCurrentPasswordInputChanged = {}, onPasswordInputChanged = {}, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetpassword/ResetPasswordViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetpassword/ResetPasswordViewModel.kt index a28c5d623e..4a1129d7ea 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetpassword/ResetPasswordViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetpassword/ResetPasswordViewModel.kt @@ -17,13 +17,13 @@ import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult +import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult +import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState import com.x8bit.bitwarden.ui.auth.feature.resetpassword.util.toDisplayLabels import com.x8bit.bitwarden.ui.tools.feature.generator.util.toStrictestPolicy import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @@ -39,38 +39,17 @@ private const val MIN_PASSWORD_LENGTH = 12 @Suppress("TooManyFunctions") class ResetPasswordViewModel @Inject constructor( private val authRepository: AuthRepository, + private val vaultRepository: VaultRepository, savedStateHandle: SavedStateHandle, ) : BaseViewModel( - initialState = savedStateHandle[KEY_STATE] - ?: run { - val policies = authRepository.passwordPolicies - ResetPasswordState( - policies = policies.toDisplayLabels(), - resetReason = authRepository.passwordResetReason, - dialogState = null, - currentPasswordInput = "", - passwordInput = "", - retypePasswordInput = "", - passwordHintInput = "", - passwordStrengthState = PasswordStrengthState.NONE, - minimumPasswordLength = policies - .toStrictestPolicy() - .minLength - ?: MIN_PASSWORD_LENGTH, - ) - }, + // We load the state from the savedStateHandle for testing purposes. + initialState = savedStateHandle[KEY_STATE] ?: ResetPasswordState( + viewState = ResetPasswordState.ViewState.Loading, + dialogState = null, + ), ) { - /** - * Keeps track of async request to get password strength. Should be cancelled - * when user input changes. - */ - private var passwordStrengthJob: Job = Job().apply { complete() } - init { - // As state updates, write to saved state handle. - stateFlow - .onEach { savedStateHandle[KEY_STATE] = it } - .launchIn(viewModelScope) + syncVault() } override fun handleAction(action: ResetPasswordAction) { @@ -78,7 +57,7 @@ class ResetPasswordViewModel @Inject constructor( ResetPasswordAction.ConfirmLogoutClick -> handleConfirmLogoutClick() ResetPasswordAction.SaveClick -> handleSaveClicked() ResetPasswordAction.DialogDismiss -> handleDialogDismiss() - + ResetPasswordAction.RetryClick -> handleRetryClick() is ResetPasswordAction.CurrentPasswordInputChanged -> { handleCurrentPasswordInputChanged(action) } @@ -93,6 +72,7 @@ class ResetPasswordViewModel @Inject constructor( handlePasswordHintInputChanged(action) } + is ResetPasswordAction.Internal.ReceiveSyncResult -> handleReceiveSyncResult(action) is ResetPasswordAction.Internal.ReceiveResetPasswordResult -> { handleReceiveResetPasswordResult(action) } @@ -105,60 +85,62 @@ class ResetPasswordViewModel @Inject constructor( handleReceiveValidatePasswordResult(action) } - is ResetPasswordAction.Internal.ReceivePasswordStrengthResult -> { - handlePasswordStrengthResult(action) - } - ResetPasswordAction.LearnHowPreventLockoutClick -> { handleLearnHowToPreventLockoutClick() } } } - private fun handleLearnHowToPreventLockoutClick() { - sendEvent(ResetPasswordEvent.NavigateToPreventAccountLockout) - } - - private fun checkPasswordStrength(input: String) { - // Update password strength: - passwordStrengthJob.cancel() - if (input.isEmpty()) { - mutableStateFlow.update { - it.copy(passwordStrengthState = PasswordStrengthState.NONE) - } - } else { - passwordStrengthJob = viewModelScope.launch { - val result = authRepository.getPasswordStrength( - password = input, - ) - trySendAction(ResetPasswordAction.Internal.ReceivePasswordStrengthResult(result)) - } + private fun handleRetryClick() { + mutableStateFlow.update { + it.copy(viewState = ResetPasswordState.ViewState.Loading) } + syncVault() } - private fun handlePasswordStrengthResult( - action: ResetPasswordAction.Internal.ReceivePasswordStrengthResult, - ) { - when (val result = action.result) { - is PasswordStrengthResult.Success -> { - val updatedState = when (result.passwordStrength) { - PasswordStrength.LEVEL_0 -> PasswordStrengthState.WEAK_1 - PasswordStrength.LEVEL_1 -> PasswordStrengthState.WEAK_2 - PasswordStrength.LEVEL_2 -> PasswordStrengthState.WEAK_3 - PasswordStrength.LEVEL_3 -> PasswordStrengthState.GOOD - PasswordStrength.LEVEL_4 -> PasswordStrengthState.STRONG - } + /** + * Display the content if the sync succeeded, refreshing the policies in case they changed, + * and otherwise display an error. + */ + private fun handleReceiveSyncResult(action: ResetPasswordAction.Internal.ReceiveSyncResult) { + when (action.result) { + is SyncVaultDataResult.Error -> { mutableStateFlow.update { it.copy( - passwordStrengthState = updatedState, + viewState = ResetPasswordState.ViewState.Error( + message = BitwardenString.generic_error_message.asText(), + ), ) } } - is PasswordStrengthResult.Error -> Unit + is SyncVaultDataResult.Success -> { + val policies = authRepository.passwordPolicies + mutableStateFlow.update { + it.copy( + viewState = ResetPasswordState.ViewState.Content( + resetReason = authRepository.passwordResetReason, + policies = policies.toDisplayLabels(), + minimumPasswordLength = policies + .toStrictestPolicy() + .minLength + ?: MIN_PASSWORD_LENGTH, + currentPasswordInput = "", + passwordInput = "", + retypePasswordInput = "", + passwordHintInput = "", + passwordStrengthState = PasswordStrengthState.NONE, + ), + ) + } + } } } + private fun handleLearnHowToPreventLockoutClick() { + sendEvent(ResetPasswordEvent.NavigateToPreventAccountLockout) + } + /** * Dismiss the view if the user confirms logging out. */ @@ -170,51 +152,67 @@ class ResetPasswordViewModel @Inject constructor( * Validate the user's current password when they submit. */ private fun handleSaveClicked() { - // Display an error dialog if the new password field is blank. - if (state.passwordInput.isBlank()) { - mutableStateFlow.update { - it.copy( - dialogState = ResetPasswordState.DialogState.Error( - title = BitwardenString.an_error_has_occurred.asText(), - message = BitwardenString.validation_field_required - .asText(BitwardenString.master_password.asText()), - ), - ) - } - return - } - - // Check if the new password meets the policy requirements, if applicable. - if (state.resetReason == ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN) { - viewModelScope.launch { - val result = authRepository.validatePasswordAgainstPolicies(state.passwordInput) - sendAction( - ResetPasswordAction.Internal.ReceiveValidatePasswordAgainstPoliciesResult( - result, - ), - ) - } - } else { - // Otherwise, simply verify that the password meets the minimum length requirement. - if (state.passwordInput.length < MIN_PASSWORD_LENGTH) { + onContent { content -> + // Display an error dialog if the new password field is blank. + if (content.passwordInput.isBlank()) { mutableStateFlow.update { it.copy( dialogState = ResetPasswordState.DialogState.Error( title = BitwardenString.an_error_has_occurred.asText(), - message = BitwardenPlurals.master_password_length_val_message_x - .asPluralsText( - quantity = MIN_PASSWORD_LENGTH, - args = arrayOf(MIN_PASSWORD_LENGTH), - ), + message = BitwardenString + .validation_field_required + .asText(BitwardenString.master_password.asText()), ), ) } - } else { - // Check that the re-typed password matches. - if (!checkRetypedPassword()) return + return@onContent + } - // Otherwise, if the password checks out, attempt to reset it. - resetPassword() + when (content.resetReason) { + ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN -> { + // Check if the new password meets the policy requirements, if applicable. + viewModelScope.launch { + val result = authRepository.validatePasswordAgainstPolicies( + password = content.passwordInput, + ) + sendAction( + ResetPasswordAction + .Internal + .ReceiveValidatePasswordAgainstPoliciesResult( + meetsRequirements = result, + ), + ) + } + } + + ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET, + ForcePasswordResetReason.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION, + null, + -> { + // Otherwise, simply verify that the password meets the + // minimum length requirement. + if (content.passwordInput.length < MIN_PASSWORD_LENGTH) { + mutableStateFlow.update { + it.copy( + dialogState = ResetPasswordState.DialogState.Error( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenPlurals + .master_password_length_val_message_x + .asPluralsText( + quantity = MIN_PASSWORD_LENGTH, + args = arrayOf(MIN_PASSWORD_LENGTH), + ), + ), + ) + } + } else { + // Check that the re-typed password matches. + if (!checkRetypedPassword(content = content)) return@onContent + + // Otherwise, if the password checks out, attempt to reset it. + resetPassword() + } + } } } } @@ -224,9 +222,7 @@ class ResetPasswordViewModel @Inject constructor( */ private fun handleDialogDismiss() { mutableStateFlow.update { - it.copy( - dialogState = null, - ) + it.copy(dialogState = null) } } @@ -236,10 +232,8 @@ class ResetPasswordViewModel @Inject constructor( private fun handleCurrentPasswordInputChanged( action: ResetPasswordAction.CurrentPasswordInputChanged, ) { - mutableStateFlow.update { - it.copy( - currentPasswordInput = action.input, - ) + updateContent { + it.copy(currentPasswordInput = action.input) } } @@ -247,12 +241,32 @@ class ResetPasswordViewModel @Inject constructor( * Update the state with the new password input. */ private fun handlePasswordInputChanged(action: ResetPasswordAction.PasswordInputChanged) { - mutableStateFlow.update { - it.copy( - passwordInput = action.input, - ) + updateContent { content -> + val input = action.input + if (input.isEmpty()) { + content.copy( + passwordStrengthState = PasswordStrengthState.NONE, + passwordInput = input, + ) + } else { + when (val result = authRepository.getPasswordStrength(password = input)) { + is PasswordStrengthResult.Success -> { + content.copy( + passwordStrengthState = when (result.passwordStrength) { + PasswordStrength.LEVEL_0 -> PasswordStrengthState.WEAK_1 + PasswordStrength.LEVEL_1 -> PasswordStrengthState.WEAK_2 + PasswordStrength.LEVEL_2 -> PasswordStrengthState.WEAK_3 + PasswordStrength.LEVEL_3 -> PasswordStrengthState.GOOD + PasswordStrength.LEVEL_4 -> PasswordStrengthState.STRONG + }, + passwordInput = input, + ) + } + + is PasswordStrengthResult.Error -> content.copy(passwordInput = input) + } + } } - checkPasswordStrength(input = action.input) } /** @@ -261,10 +275,8 @@ class ResetPasswordViewModel @Inject constructor( private fun handleRetypePasswordInputChanged( action: ResetPasswordAction.RetypePasswordInputChanged, ) { - mutableStateFlow.update { - it.copy( - retypePasswordInput = action.input, - ) + updateContent { + it.copy(retypePasswordInput = action.input) } } @@ -274,10 +286,8 @@ class ResetPasswordViewModel @Inject constructor( private fun handlePasswordHintInputChanged( action: ResetPasswordAction.PasswordHintInputChanged, ) { - mutableStateFlow.update { - it.copy( - passwordHintInput = action.input, - ) + updateContent { + it.copy(passwordHintInput = action.input) } } @@ -369,14 +379,15 @@ class ResetPasswordViewModel @Inject constructor( return } - // Check that the re-typed password matches. - if (!checkRetypedPassword()) return + onContent { content -> + // Check that the re-typed password matches. + if (!checkRetypedPassword(content = content)) return@onContent - // Check that the entered current password is correct. - viewModelScope.launch { - val currentPassword = state.currentPasswordInput - val result = authRepository.validatePassword(currentPassword) - trySendAction(ResetPasswordAction.Internal.ReceiveValidatePasswordResult(result)) + // Check that the entered current password is correct. + viewModelScope.launch { + val result = authRepository.validatePassword(content.currentPasswordInput) + trySendAction(ResetPasswordAction.Internal.ReceiveValidatePasswordResult(result)) + } } } @@ -384,8 +395,10 @@ class ResetPasswordViewModel @Inject constructor( * A helper function to determine if the re-typed password matches and * display an alert if not. Returns true if the passwords match. */ - private fun checkRetypedPassword(): Boolean { - if (state.passwordInput == state.retypePasswordInput) return true + private fun checkRetypedPassword( + content: ResetPasswordState.ViewState.Content, + ): Boolean { + if (content.passwordInput == content.retypePasswordInput) return true mutableStateFlow.update { it.copy( @@ -398,29 +411,56 @@ class ResetPasswordViewModel @Inject constructor( return false } + /** + * A helper function to launch the vault sync request. + */ + private fun syncVault() { + viewModelScope.launch { + val result = vaultRepository.syncForResult() + sendAction(ResetPasswordAction.Internal.ReceiveSyncResult(result)) + } + } + /** * A helper function to launch the reset password request. */ private fun resetPassword() { - // Show the loading dialog. - mutableStateFlow.update { - it.copy( - dialogState = ResetPasswordState.DialogState.Loading( - message = BitwardenString.updating_password.asText(), - ), - ) - } - viewModelScope.launch { - val result = authRepository.resetPassword( - currentPassword = state.currentPasswordInput.orNullIfBlank(), - newPassword = state.passwordInput, - passwordHint = state.passwordHintInput, - ) - trySendAction( - ResetPasswordAction.Internal.ReceiveResetPasswordResult(result), - ) + onContent { content -> + // Show the loading dialog. + mutableStateFlow.update { + it.copy( + dialogState = ResetPasswordState.DialogState.Loading( + message = BitwardenString.updating_password.asText(), + ), + ) + } + viewModelScope.launch { + val result = authRepository.resetPassword( + currentPassword = content.currentPasswordInput.orNullIfBlank(), + newPassword = content.passwordInput, + passwordHint = content.passwordHintInput, + ) + trySendAction(ResetPasswordAction.Internal.ReceiveResetPasswordResult(result)) + } } } + + private inline fun onContent( + crossinline block: (ResetPasswordState.ViewState.Content) -> Unit, + ) { + (state.viewState as? ResetPasswordState.ViewState.Content)?.let(block) + } + + private inline fun updateContent( + crossinline block: ( + ResetPasswordState.ViewState.Content, + ) -> ResetPasswordState.ViewState.Content, + ) { + val updatedContent = (state.viewState as? ResetPasswordState.ViewState.Content) + ?.let(block) + ?: return + mutableStateFlow.update { it.copy(viewState = updatedContent) } + } } /** @@ -428,16 +468,54 @@ class ResetPasswordViewModel @Inject constructor( */ @Parcelize data class ResetPasswordState( - val policies: List, - val resetReason: ForcePasswordResetReason?, + val viewState: ViewState, val dialogState: DialogState?, - val currentPasswordInput: String, - val passwordInput: String, - val retypePasswordInput: String, - val passwordHintInput: String, - val passwordStrengthState: PasswordStrengthState, - val minimumPasswordLength: Int, ) : Parcelable { + /** + * Represents the specific view states for the Reset Password screen. + */ + sealed class ViewState : Parcelable { + /** + * Indicates if the save button should be displayed. + */ + abstract val shouldShowSaveButton: Boolean + + /** + * Represents a loading state while the vault is syncing. + */ + @Parcelize + data object Loading : ViewState() { + override val shouldShowSaveButton: Boolean get() = false + } + + /** + * Represents an error state with the given [message]. + */ + @Parcelize + data class Error( + val message: Text, + ) : ViewState() { + override val shouldShowSaveButton: Boolean get() = false + } + + /** + * Represents the loaded content state. + */ + @Parcelize + data class Content( + val resetReason: ForcePasswordResetReason?, + val policies: ImmutableList, + val minimumPasswordLength: Int, + val currentPasswordInput: String, + val passwordInput: String, + val retypePasswordInput: String, + val passwordHintInput: String, + val passwordStrengthState: PasswordStrengthState, + ) : ViewState() { + override val shouldShowSaveButton: Boolean get() = true + } + } + /** * Represents the current state of any dialogs on the screen. */ @@ -493,6 +571,11 @@ sealed class ResetPasswordAction { */ data object DialogDismiss : ResetPasswordAction() + /** + * Indicates that the user has clicked the retry button after a failed sync. + */ + data object RetryClick : ResetPasswordAction() + /** * Indicates that the current password input has changed. */ @@ -522,6 +605,13 @@ sealed class ResetPasswordAction { * Models actions that the [ResetPasswordViewModel] might send itself. */ sealed class Internal : ResetPasswordAction() { + /** + * Indicates that a vault sync result has been received. + */ + data class ReceiveSyncResult( + val result: SyncVaultDataResult, + ) : Internal() + /** * Indicates that a reset password result has been received. */ @@ -542,12 +632,5 @@ sealed class ResetPasswordAction { data class ReceiveValidatePasswordAgainstPoliciesResult( val meetsRequirements: Boolean, ) : Internal() - - /** - * Indicates a password strength result has been received. - */ - data class ReceivePasswordStrengthResult( - val result: PasswordStrengthResult, - ) : Internal() } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetpassword/util/PolicyInformationMasterPasswordExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetpassword/util/PolicyInformationMasterPasswordExtensions.kt index fcbbe5e878..0ec6debe71 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetpassword/util/PolicyInformationMasterPasswordExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetpassword/util/PolicyInformationMasterPasswordExtensions.kt @@ -4,12 +4,14 @@ 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.model.PolicyInformation +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList /** * Convert a list of master password policies into a list of text instructions * for the user about what requirements the password must meet. */ -fun List.toDisplayLabels(): List { +fun List.toDisplayLabels(): ImmutableList { val list = mutableListOf() mapNotNull { it.minLength }.maxOrNull()?.let { @@ -36,5 +38,5 @@ fun List.toDisplayLabels(): List { list.add(BitwardenString.policy_in_effect_special.asText()) } - return list + return list.toImmutableList() } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetPassword/ResetPasswordScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetPassword/ResetPasswordScreenTest.kt index ff2b0cdc6f..754357227b 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetPassword/ResetPasswordScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetPassword/ResetPasswordScreenTest.kt @@ -25,6 +25,7 @@ import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import org.junit.Assert.assertTrue @@ -52,6 +53,39 @@ class ResetPasswordScreenTest : BitwardenComposeTest() { } } + @Test + fun `loading view state should display the loading content and hide the save button`() { + mutableStateFlow.update { it.copy(viewState = ResetPasswordState.ViewState.Loading) } + + composeTestRule.onNodeWithText("Syncing…").assertIsDisplayed() + composeTestRule.onNodeWithText("Save").assertDoesNotExist() + composeTestRule + .onNodeWithText("Re-type new master password (required)") + .assertDoesNotExist() + } + + @Test + fun `error view state should display the error content and hide the save button`() { + mutableStateFlow.update { + it.copy(viewState = ResetPasswordState.ViewState.Error(message = "Fail".asText())) + } + + composeTestRule.onNodeWithText("Fail").assertIsDisplayed() + composeTestRule.onNodeWithText("Try again").assertIsDisplayed() + composeTestRule.onNodeWithText("Save").assertDoesNotExist() + } + + @Test + fun `try again click in error state should send RetryClick action`() { + mutableStateFlow.update { + it.copy(viewState = ResetPasswordState.ViewState.Error(message = "Fail".asText())) + } + + composeTestRule.onNodeWithText("Try again").performClick() + + verify { viewModel.trySendAction(ResetPasswordAction.RetryClick) } + } + @Test fun `basicDialog should update according to state`() { composeTestRule @@ -120,6 +154,7 @@ class ResetPasswordScreenTest : BitwardenComposeTest() { fun `submit button click should emit SubmitClick`() { composeTestRule .onNodeWithText("Save") + .assertIsDisplayed() .performClick() verify { @@ -140,7 +175,9 @@ class ResetPasswordScreenTest : BitwardenComposeTest() { mutableStateFlow.update { it.copy( - resetReason = ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET, + viewState = DEFAULT_CONTENT.copy( + resetReason = ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET, + ), ) } @@ -165,7 +202,9 @@ class ResetPasswordScreenTest : BitwardenComposeTest() { mutableStateFlow.update { it.copy( - policies = listOf("Make password better".asText()), + viewState = DEFAULT_CONTENT.copy( + policies = persistentListOf("Make password better".asText()), + ), ) } @@ -180,7 +219,9 @@ class ResetPasswordScreenTest : BitwardenComposeTest() { mutableStateFlow.update { it.copy( - resetReason = ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET, + viewState = DEFAULT_CONTENT.copy( + resetReason = ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET, + ), ) } @@ -208,7 +249,9 @@ class ResetPasswordScreenTest : BitwardenComposeTest() { mutableStateFlow.update { it.copy( - resetReason = ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET, + viewState = DEFAULT_CONTENT.copy( + resetReason = ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET, + ), ) } @@ -289,14 +332,18 @@ class ResetPasswordScreenTest : BitwardenComposeTest() { } } -private val DEFAULT_STATE = ResetPasswordState( - policies = emptyList(), +private val DEFAULT_CONTENT = ResetPasswordState.ViewState.Content( resetReason = ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN, - dialogState = null, + policies = persistentListOf(), + minimumPasswordLength = 12, currentPasswordInput = "", passwordInput = "", retypePasswordInput = "", passwordHintInput = "", passwordStrengthState = PasswordStrengthState.NONE, - minimumPasswordLength = 12, +) + +private val DEFAULT_STATE = ResetPasswordState( + viewState = DEFAULT_CONTENT, + dialogState = null, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetPassword/ResetPasswordViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetPassword/ResetPasswordViewModelTest.kt index c7b9d77853..bedbd17cac 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetPassword/ResetPasswordViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetPassword/ResetPasswordViewModelTest.kt @@ -12,13 +12,17 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult +import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult +import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult +import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState import com.x8bit.bitwarden.ui.auth.feature.resetpassword.ResetPasswordAction import com.x8bit.bitwarden.ui.auth.feature.resetpassword.ResetPasswordEvent import com.x8bit.bitwarden.ui.auth.feature.resetpassword.ResetPasswordState import com.x8bit.bitwarden.ui.auth.feature.resetpassword.ResetPasswordViewModel +import com.x8bit.bitwarden.ui.auth.feature.resetpassword.util.toDisplayLabels import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -26,6 +30,8 @@ import io.mockk.just import io.mockk.mockk import io.mockk.runs import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -36,7 +42,129 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { every { passwordResetReason } returns ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN } - private val savedStateHandle = SavedStateHandle() + private val vaultRepository: VaultRepository = mockk { + coEvery { syncForResult() } returns SyncVaultDataResult.Success(itemsAvailable = true) + } + + @Test + fun `initial state should be Loading and transition to Content on sync success`() = runTest { + val syncResult = CompletableDeferred() + coEvery { vaultRepository.syncForResult() } coAnswers { syncResult.await() } + + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE.copy(viewState = ResetPasswordState.ViewState.Loading), + awaitItem(), + ) + syncResult.complete(SyncVaultDataResult.Success(itemsAvailable = true)) + assertEquals(DEFAULT_STATE, awaitItem()) + } + coVerify(exactly = 1) { vaultRepository.syncForResult() } + } + + @Test + fun `sync success should populate the content from the latest policies and reset reason`() { + val policy = PolicyInformation.MasterPassword( + minLength = 20, + minComplexity = null, + requireUpper = null, + requireLower = null, + requireNumbers = null, + requireSpecial = null, + enforceOnLogin = true, + ) + every { authRepository.passwordPolicies } returns listOf(policy) + every { + authRepository.passwordResetReason + } returns ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET + + val viewModel = createViewModel() + + assertEquals( + DEFAULT_STATE.copy( + viewState = DEFAULT_CONTENT.copy( + resetReason = ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET, + policies = listOf(policy).toDisplayLabels(), + minimumPasswordLength = 20, + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `sync error should display the error view state`() { + coEvery { + vaultRepository.syncForResult() + } returns SyncVaultDataResult.Error(throwable = Throwable("Fail!")) + + val viewModel = createViewModel() + + assertEquals( + DEFAULT_STATE.copy( + viewState = ResetPasswordState.ViewState.Error( + message = BitwardenString.generic_error_message.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `RetryClick should display Loading and sync again`() = runTest { + coEvery { + vaultRepository.syncForResult() + } returns SyncVaultDataResult.Error(throwable = Throwable("Fail!")) + val viewModel = createViewModel() + val syncResult = CompletableDeferred() + coEvery { vaultRepository.syncForResult() } coAnswers { syncResult.await() } + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE.copy( + viewState = ResetPasswordState.ViewState.Error( + message = BitwardenString.generic_error_message.asText(), + ), + ), + awaitItem(), + ) + + viewModel.trySendAction(ResetPasswordAction.RetryClick) + assertEquals( + DEFAULT_STATE.copy(viewState = ResetPasswordState.ViewState.Loading), + awaitItem(), + ) + + syncResult.complete(SyncVaultDataResult.Success(itemsAvailable = true)) + assertEquals(DEFAULT_STATE, awaitItem()) + } + coVerify(exactly = 2) { vaultRepository.syncForResult() } + } + + @Test + fun `input and save actions should be ignored when not in the Content state`() { + coEvery { + vaultRepository.syncForResult() + } returns SyncVaultDataResult.Error(throwable = Throwable("Fail!")) + every { + authRepository.getPasswordStrength(password = any()) + } returns PasswordStrengthResult.Success(passwordStrength = PasswordStrength.LEVEL_4) + val viewModel = createViewModel() + val errorState = DEFAULT_STATE.copy( + viewState = ResetPasswordState.ViewState.Error( + message = BitwardenString.generic_error_message.asText(), + ), + ) + + viewModel.trySendAction(ResetPasswordAction.CurrentPasswordInputChanged("Test123")) + viewModel.trySendAction(ResetPasswordAction.PasswordInputChanged("Test123")) + viewModel.trySendAction(ResetPasswordAction.RetypePasswordInputChanged("Test123")) + viewModel.trySendAction(ResetPasswordAction.PasswordHintInputChanged("Test123")) + viewModel.trySendAction(ResetPasswordAction.SaveClick) + + assertEquals(errorState, viewModel.stateFlow.value) + } @Test fun `ConfirmLogoutClick logs out`() = runTest { @@ -59,7 +187,9 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { assertEquals( DEFAULT_STATE.copy( - currentPasswordInput = "Test123", + viewState = DEFAULT_CONTENT.copy( + currentPasswordInput = "Test123", + ), ), viewModel.stateFlow.value, ) @@ -92,10 +222,10 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { @Test fun `SubmitClicked with invalid password shows error alert for weak password reason`() { val password = "Test123" - coEvery { - authRepository.validatePasswordAgainstPolicies(password) + every { + authRepository.validatePasswordAgainstPolicies(password = password) } returns false - coEvery { + every { authRepository.getPasswordStrength(password = any()) } returns PasswordStrengthResult.Success(passwordStrength = PasswordStrength.LEVEL_0) @@ -105,12 +235,14 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { assertEquals( DEFAULT_STATE.copy( + viewState = DEFAULT_CONTENT.copy( + passwordInput = password, + passwordStrengthState = PasswordStrengthState.WEAK_1, + ), dialogState = ResetPasswordState.DialogState.Error( title = BitwardenString.master_password_policy_validation_title.asText(), message = BitwardenString.master_password_policy_validation_message.asText(), ), - passwordInput = password, - passwordStrengthState = PasswordStrengthState.WEAK_1, ), viewModel.stateFlow.value, ) @@ -122,7 +254,7 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { every { authRepository.passwordResetReason } returns ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET - coEvery { + every { authRepository.getPasswordStrength(password = any()) } returns PasswordStrengthResult.Success(passwordStrength = PasswordStrength.LEVEL_0) @@ -132,7 +264,11 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { assertEquals( DEFAULT_STATE.copy( - resetReason = ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET, + viewState = DEFAULT_CONTENT.copy( + resetReason = ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET, + passwordInput = password, + passwordStrengthState = PasswordStrengthState.WEAK_1, + ), dialogState = ResetPasswordState.DialogState.Error( title = BitwardenString.an_error_has_occurred.asText(), message = BitwardenPlurals.master_password_length_val_message_x.asPluralsText( @@ -140,8 +276,6 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { args = arrayOf(MIN_PASSWORD_LENGTH), ), ), - passwordInput = password, - passwordStrengthState = PasswordStrengthState.WEAK_1, ), viewModel.stateFlow.value, ) @@ -150,10 +284,10 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { @Test fun `SubmitClicked with non-matching retyped password shows error alert`() { val password = "Test123" - coEvery { - authRepository.validatePasswordAgainstPolicies(password) + every { + authRepository.validatePasswordAgainstPolicies(password = password) } returns true - coEvery { + every { authRepository.getPasswordStrength(password = any()) } returns PasswordStrengthResult.Success(passwordStrength = PasswordStrength.LEVEL_0) @@ -164,12 +298,14 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { assertEquals( DEFAULT_STATE.copy( + viewState = DEFAULT_CONTENT.copy( + passwordInput = password, + passwordStrengthState = PasswordStrengthState.WEAK_1, + ), dialogState = ResetPasswordState.DialogState.Error( title = BitwardenString.an_error_has_occurred.asText(), message = BitwardenString.master_password_confirmation_val_message.asText(), ), - passwordInput = password, - passwordStrengthState = PasswordStrengthState.WEAK_1, ), viewModel.stateFlow.value, ) @@ -180,13 +316,13 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { val currentPassword = "CurrentTest123" val password = "Test123" val error = Throwable("Fail!") - coEvery { - authRepository.validatePasswordAgainstPolicies(password) + every { + authRepository.validatePasswordAgainstPolicies(password = password) } returns true coEvery { authRepository.validatePassword(currentPassword) } returns ValidatePasswordResult.Error(error = error) - coEvery { + every { authRepository.getPasswordStrength(password = any()) } returns PasswordStrengthResult.Success(passwordStrength = PasswordStrength.LEVEL_0) @@ -199,15 +335,17 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { assertEquals( DEFAULT_STATE.copy( + viewState = DEFAULT_CONTENT.copy( + currentPasswordInput = currentPassword, + passwordInput = password, + retypePasswordInput = password, + passwordStrengthState = PasswordStrengthState.WEAK_1, + ), dialogState = ResetPasswordState.DialogState.Error( title = BitwardenString.an_error_has_occurred.asText(), message = BitwardenString.generic_error_message.asText(), error = error, ), - currentPasswordInput = currentPassword, - passwordInput = password, - retypePasswordInput = password, - passwordStrengthState = PasswordStrengthState.WEAK_1, ), viewModel.stateFlow.value, ) @@ -217,13 +355,13 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { fun `SubmitClicked with invalid current password shows alert`() { val currentPassword = "CurrentTest123" val password = "Test123" - coEvery { - authRepository.validatePasswordAgainstPolicies(password) + every { + authRepository.validatePasswordAgainstPolicies(password = password) } returns true coEvery { authRepository.validatePassword(currentPassword) } returns ValidatePasswordResult.Success(isValid = false) - coEvery { + every { authRepository.getPasswordStrength(password = any()) } returns PasswordStrengthResult.Success(passwordStrength = PasswordStrength.LEVEL_0) @@ -236,14 +374,16 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { assertEquals( DEFAULT_STATE.copy( + viewState = DEFAULT_CONTENT.copy( + currentPasswordInput = currentPassword, + passwordInput = password, + retypePasswordInput = password, + passwordStrengthState = PasswordStrengthState.WEAK_1, + ), dialogState = ResetPasswordState.DialogState.Error( title = BitwardenString.an_error_has_occurred.asText(), message = BitwardenString.invalid_master_password.asText(), ), - currentPasswordInput = currentPassword, - passwordInput = password, - retypePasswordInput = password, - passwordStrengthState = PasswordStrengthState.WEAK_1, ), viewModel.stateFlow.value, ) @@ -253,8 +393,8 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { fun `SubmitClicked with all valid inputs resets password`() = runTest { val currentPassword = "CurrentTest123" val password = "Test123" - coEvery { - authRepository.validatePasswordAgainstPolicies(password) + every { + authRepository.validatePasswordAgainstPolicies(password = password) } returns true coEvery { authRepository.validatePassword(currentPassword) @@ -262,7 +402,7 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { coEvery { authRepository.resetPassword(any(), any(), any()) } returns ResetPasswordResult.Success - coEvery { + every { authRepository.getPasswordStrength(password = any()) } returns PasswordStrengthResult.Success(passwordStrength = PasswordStrength.LEVEL_0) @@ -274,11 +414,13 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { viewModel.stateFlow.test { assertEquals( DEFAULT_STATE.copy( + viewState = DEFAULT_CONTENT.copy( + currentPasswordInput = currentPassword, + passwordInput = password, + retypePasswordInput = password, + passwordStrengthState = PasswordStrengthState.WEAK_1, + ), dialogState = null, - currentPasswordInput = currentPassword, - passwordInput = password, - retypePasswordInput = password, - passwordStrengthState = PasswordStrengthState.WEAK_1, ), awaitItem(), ) @@ -287,24 +429,28 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { assertEquals( DEFAULT_STATE.copy( + viewState = DEFAULT_CONTENT.copy( + currentPasswordInput = currentPassword, + passwordInput = password, + retypePasswordInput = password, + passwordStrengthState = PasswordStrengthState.WEAK_1, + ), dialogState = ResetPasswordState.DialogState.Loading( message = BitwardenString.updating_password.asText(), ), - currentPasswordInput = currentPassword, - passwordInput = password, - retypePasswordInput = password, - passwordStrengthState = PasswordStrengthState.WEAK_1, ), awaitItem(), ) assertEquals( DEFAULT_STATE.copy( + viewState = DEFAULT_CONTENT.copy( + currentPasswordInput = currentPassword, + passwordInput = password, + retypePasswordInput = password, + passwordStrengthState = PasswordStrengthState.WEAK_1, + ), dialogState = null, - currentPasswordInput = currentPassword, - passwordInput = password, - retypePasswordInput = password, - passwordStrengthState = PasswordStrengthState.WEAK_1, ), awaitItem(), ) @@ -315,38 +461,55 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `PasswordInputChanged should update the password input in the state along with the password strength state`() = - runTest { - val passwordInput = "Test123" - val viewModel = createViewModel() - coEvery { - authRepository.getPasswordStrength(password = any()) - } returns PasswordStrengthResult.Success(passwordStrength = PasswordStrength.LEVEL_4) + fun `PasswordInputChanged should update the password input in the state along with the password strength state`() { + val passwordInput = "Test123" + every { + authRepository.getPasswordStrength(password = passwordInput) + } returns PasswordStrengthResult.Success(passwordStrength = PasswordStrength.LEVEL_4) + val viewModel = createViewModel() - viewModel.stateFlow.test { - assertEquals(DEFAULT_STATE, awaitItem()) - viewModel.trySendAction(ResetPasswordAction.PasswordInputChanged(input = passwordInput)) - assertEquals( - DEFAULT_STATE.copy( - passwordInput = passwordInput, - ), - awaitItem(), - ) + viewModel.trySendAction(ResetPasswordAction.PasswordInputChanged(input = passwordInput)) - assertEquals( - DEFAULT_STATE.copy( - passwordInput = passwordInput, - passwordStrengthState = PasswordStrengthState.STRONG, - ), - awaitItem(), - ) - } - coVerify { - authRepository.getPasswordStrength( - password = passwordInput, - ) - } - } + assertEquals( + DEFAULT_STATE.copy( + viewState = DEFAULT_CONTENT.copy( + passwordInput = passwordInput, + passwordStrengthState = PasswordStrengthState.STRONG, + ), + ), + viewModel.stateFlow.value, + ) + verify(exactly = 1) { authRepository.getPasswordStrength(password = passwordInput) } + } + + @Test + fun `PasswordInputChanged with empty input should reset the password strength state`() { + every { + authRepository.getPasswordStrength(password = "a") + } returns PasswordStrengthResult.Success(passwordStrength = PasswordStrength.LEVEL_4) + val viewModel = createViewModel() + viewModel.trySendAction(ResetPasswordAction.PasswordInputChanged(input = "a")) + + viewModel.trySendAction(ResetPasswordAction.PasswordInputChanged(input = "")) + + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + verify(exactly = 0) { authRepository.getPasswordStrength(password = "") } + } + + @Test + fun `PasswordInputChanged with password strength error should not update the strength`() { + every { + authRepository.getPasswordStrength(password = "Test123") + } returns PasswordStrengthResult.Error(error = Throwable("Fail!")) + val viewModel = createViewModel() + + viewModel.trySendAction(ResetPasswordAction.PasswordInputChanged(input = "Test123")) + + assertEquals( + DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT.copy(passwordInput = "Test123")), + viewModel.stateFlow.value, + ) + } @Test fun `RetypePasswordInputChanged should update the retype password input in the state`() { @@ -355,7 +518,9 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { assertEquals( DEFAULT_STATE.copy( - retypePasswordInput = "Test123", + viewState = DEFAULT_CONTENT.copy( + retypePasswordInput = "Test123", + ), ), viewModel.stateFlow.value, ) @@ -368,7 +533,9 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { assertEquals( DEFAULT_STATE.copy( - passwordHintInput = "Test123", + viewState = DEFAULT_CONTENT.copy( + passwordHintInput = "Test123", + ), ), viewModel.stateFlow.value, ) @@ -387,22 +554,28 @@ class ResetPasswordViewModelTest : BaseViewModelTest() { } } - private fun createViewModel(): ResetPasswordViewModel = + private fun createViewModel( + initialState: ResetPasswordState? = null, + ): ResetPasswordViewModel = ResetPasswordViewModel( authRepository = authRepository, - savedStateHandle = savedStateHandle, + vaultRepository = vaultRepository, + savedStateHandle = SavedStateHandle().apply { set("state", initialState) }, ) } private const val MIN_PASSWORD_LENGTH = 12 -private val DEFAULT_STATE = ResetPasswordState( - policies = emptyList(), +private val DEFAULT_CONTENT = ResetPasswordState.ViewState.Content( resetReason = ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN, - dialogState = null, + policies = persistentListOf(), + minimumPasswordLength = MIN_PASSWORD_LENGTH, currentPasswordInput = "", passwordInput = "", retypePasswordInput = "", passwordHintInput = "", passwordStrengthState = PasswordStrengthState.NONE, - minimumPasswordLength = 12, +) +private val DEFAULT_STATE = ResetPasswordState( + viewState = DEFAULT_CONTENT, + dialogState = null, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetPassword/util/PolicyInformationMasterPasswordExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetPassword/util/PolicyInformationMasterPasswordExtensionsTest.kt index 8c14e7bbf3..e629e640c7 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetPassword/util/PolicyInformationMasterPasswordExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/resetPassword/util/PolicyInformationMasterPasswordExtensionsTest.kt @@ -4,6 +4,7 @@ import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.data.auth.repository.model.createMockMasterPasswordPolicy import com.x8bit.bitwarden.ui.auth.feature.resetpassword.util.toDisplayLabels +import kotlinx.collections.immutable.persistentListOf import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -16,7 +17,7 @@ class PolicyInformationMasterPasswordExtensionsTest { createMockMasterPasswordPolicy(minLength = 2), ) assertEquals( - listOf(BitwardenString.policy_in_effect_min_length.asText(10)), + persistentListOf(BitwardenString.policy_in_effect_min_length.asText(10)), policyList.toDisplayLabels(), ) } @@ -29,7 +30,7 @@ class PolicyInformationMasterPasswordExtensionsTest { createMockMasterPasswordPolicy(minComplexity = 2), ) assertEquals( - listOf(BitwardenString.policy_in_effect_min_complexity.asText(2)), + persistentListOf(BitwardenString.policy_in_effect_min_complexity.asText(2)), policyList.toDisplayLabels(), ) } @@ -43,7 +44,7 @@ class PolicyInformationMasterPasswordExtensionsTest { createMockMasterPasswordPolicy(requireSpecial = true), ) assertEquals( - listOf( + persistentListOf( BitwardenString.policy_in_effect_uppercase.asText(), BitwardenString.policy_in_effect_lowercase.asText(), BitwardenString.policy_in_effect_numbers.asText(),