mirror of
https://github.com/bitwarden/android.git
synced 2026-11-09 10:34:18 -06:00
PM-45475: bug: Sync before updating password to ensure correct policies are in place
This commit is contained in:
+60
-30
@@ -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 = {},
|
||||
|
||||
+249
-166
@@ -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<ResetPasswordState, ResetPasswordEvent, ResetPasswordAction>(
|
||||
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<Text>,
|
||||
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<Text>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -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<PolicyInformation.MasterPassword>.toDisplayLabels(): List<Text> {
|
||||
fun List<PolicyInformation.MasterPassword>.toDisplayLabels(): ImmutableList<Text> {
|
||||
val list = mutableListOf<Text>()
|
||||
|
||||
mapNotNull { it.minLength }.maxOrNull()?.let {
|
||||
@@ -36,5 +38,5 @@ fun List<PolicyInformation.MasterPassword>.toDisplayLabels(): List<Text> {
|
||||
list.add(BitwardenString.policy_in_effect_special.asText())
|
||||
}
|
||||
|
||||
return list
|
||||
return list.toImmutableList()
|
||||
}
|
||||
|
||||
+55
-8
@@ -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,
|
||||
)
|
||||
|
||||
+256
-83
@@ -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<SyncVaultDataResult>()
|
||||
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<SyncVaultDataResult>()
|
||||
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,
|
||||
)
|
||||
|
||||
+4
-3
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user