PM-10692 pass a generated password back to the complete registration … (#3806)

This commit is contained in:
Dave Severns 2024-08-26 08:56:28 -04:00 committed by GitHub
parent 666c165b6f
commit 76a3265bbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 229 additions and 92 deletions

View file

@ -10,6 +10,7 @@ import com.x8bit.bitwarden.ui.auth.feature.checkemail.checkEmailDestination
import com.x8bit.bitwarden.ui.auth.feature.checkemail.navigateToCheckEmail
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.completeRegistrationDestination
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.navigateToCompleteRegistration
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.popUpToCompleteRegistration
import com.x8bit.bitwarden.ui.auth.feature.createaccount.createAccountDestination
import com.x8bit.bitwarden.ui.auth.feature.createaccount.navigateToCreateAccount
import com.x8bit.bitwarden.ui.auth.feature.enterprisesignon.enterpriseSignOnDestination
@ -184,6 +185,9 @@ fun NavGraphBuilder.authGraph(
masterPasswordGeneratorDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToPreventLockout = { navController.navigateToPreventAccountLockout() },
onNavigateBackWithPassword = {
navController.popUpToCompleteRegistration()
},
)
}
}

View file

@ -72,3 +72,10 @@ fun NavGraphBuilder.completeRegistrationDestination(
)
}
}
/**
* Pop up to the complete registration screen.
*/
fun NavController.popUpToCompleteRegistration() {
popBackStack(route = COMPLETE_REGISTRATION_ROUTE, inclusive = false)
}

View file

@ -216,7 +216,8 @@ private fun CompleteRegistrationContent(
callToActionText = stringResource(id = R.string.learn_more),
onCardClicked = handler.onMakeStrongPassword,
modifier = Modifier
.fillMaxWidth(),
.fillMaxWidth()
.standardHorizontalMargin(),
)
} else {
LegacyHeaderContent(

View file

@ -12,6 +12,8 @@ import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.BackClick
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange
@ -28,6 +30,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.isValidEmail
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
@ -48,27 +51,27 @@ private const val MIN_PASSWORD_LENGTH = 12
class CompleteRegistrationViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
featureFlagManager: FeatureFlagManager,
generatorRepository: GeneratorRepository,
private val authRepository: AuthRepository,
private val environmentRepository: EnvironmentRepository,
private val specialCircumstanceManager: SpecialCircumstanceManager,
) : BaseViewModel<CompleteRegistrationState, CompleteRegistrationEvent, CompleteRegistrationAction>(
initialState = savedStateHandle[KEY_STATE]
?: run {
val args = CompleteRegistrationArgs(savedStateHandle)
CompleteRegistrationState(
userEmail = args.emailAddress,
emailVerificationToken = args.verificationToken,
fromEmail = args.fromEmail,
passwordInput = "",
confirmPasswordInput = "",
passwordHintInput = "",
isCheckDataBreachesToggled = true,
dialog = null,
passwordStrengthState = PasswordStrengthState.NONE,
onboardingEnabled = featureFlagManager.getFeatureFlag(FlagKey.OnboardingFlow),
minimumPasswordLength = MIN_PASSWORD_LENGTH,
)
},
initialState = savedStateHandle[KEY_STATE] ?: run {
val args = CompleteRegistrationArgs(savedStateHandle)
CompleteRegistrationState(
userEmail = args.emailAddress,
emailVerificationToken = args.verificationToken,
fromEmail = args.fromEmail,
passwordInput = "",
confirmPasswordInput = "",
passwordHintInput = "",
isCheckDataBreachesToggled = true,
dialog = null,
passwordStrengthState = PasswordStrengthState.NONE,
onboardingEnabled = featureFlagManager.getFeatureFlag(FlagKey.OnboardingFlow),
minimumPasswordLength = MIN_PASSWORD_LENGTH,
)
},
) {
/**
@ -91,6 +94,15 @@ class CompleteRegistrationViewModel @Inject constructor(
}
.onEach(::sendAction)
.launchIn(viewModelScope)
generatorRepository
.generatorResultFlow
.filterIsInstance<GeneratorResult.Password>()
.map {
Internal.GeneratedPasswordResult(generatedPassword = it.password)
}
.onEach(::sendAction)
.launchIn(viewModelScope)
}
@VisibleForTesting
@ -124,9 +136,25 @@ class CompleteRegistrationViewModel @Inject constructor(
CompleteRegistrationAction.CallToActionClick -> handleCallToActionClick()
is Internal.UpdateOnboardingFeatureState -> handleUpdateOnboardingFeatureState(action)
is Internal.GeneratedPasswordResult -> handleGeneratedPasswordResult(
action,
)
}
}
private fun handleGeneratedPasswordResult(
action: Internal.GeneratedPasswordResult,
) {
val password = action.generatedPassword
mutableStateFlow.update {
it.copy(
passwordInput = password,
confirmPasswordInput = password,
)
}
checkPasswordStrength(input = password)
}
private fun verifyEmailAddress() {
if (!state.fromEmail) {
return
@ -262,21 +290,7 @@ class CompleteRegistrationViewModel @Inject constructor(
private fun handlePasswordInputChanged(action: PasswordInputChange) {
// Update input:
mutableStateFlow.update { it.copy(passwordInput = action.input) }
// Update password strength:
passwordStrengthJob.cancel()
if (action.input.isEmpty()) {
mutableStateFlow.update {
it.copy(passwordStrengthState = PasswordStrengthState.NONE)
}
} else {
passwordStrengthJob = viewModelScope.launch {
val result = authRepository.getPasswordStrength(
email = state.userEmail,
password = action.input,
)
trySendAction(ReceivePasswordStrengthResult(result))
}
}
checkPasswordStrength(action.input)
}
private fun handleConfirmPasswordInputChanged(action: ConfirmPasswordInputChange) {
@ -367,6 +381,24 @@ class CompleteRegistrationViewModel @Inject constructor(
)
}
}
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(
email = state.userEmail,
password = input,
)
trySendAction(ReceivePasswordStrengthResult(result))
}
}
}
}
/**
@ -564,5 +596,10 @@ sealed class CompleteRegistrationAction {
* Indicate on boarding feature state has been updated.
*/
data class UpdateOnboardingFeatureState(val newValue: Boolean) : Internal()
/**
* Indicates a generated password has been received.
*/
data class GeneratedPasswordResult(val generatedPassword: String) : Internal()
}
}

View file

@ -20,6 +20,7 @@ fun NavController.navigateToMasterPasswordGenerator(navOptions: NavOptions? = nu
fun NavGraphBuilder.masterPasswordGeneratorDestination(
onNavigateBack: () -> Unit,
onNavigateToPreventLockout: () -> Unit,
onNavigateBackWithPassword: () -> Unit,
) {
composableWithSlideTransitions(
route = MASTER_PASSWORD_GENERATOR,
@ -27,6 +28,7 @@ fun NavGraphBuilder.masterPasswordGeneratorDestination(
MasterPasswordGeneratorScreen(
onNavigateBack = onNavigateBack,
onNavigateToPreventLockout = onNavigateToPreventLockout,
onNavigateBackWithPassword = onNavigateBackWithPassword,
)
}
}

View file

@ -53,6 +53,7 @@ import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
fun MasterPasswordGeneratorScreen(
onNavigateBack: () -> Unit,
onNavigateToPreventLockout: () -> Unit,
onNavigateBackWithPassword: () -> Unit,
viewModel: MasterPasswordGeneratorViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
@ -71,6 +72,10 @@ fun MasterPasswordGeneratorScreen(
duration = SnackbarDuration.Short,
)
}
is MasterPasswordGeneratorEvent.NavigateBackToRegistration -> {
onNavigateBackWithPassword()
}
}
}

View file

@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassphraseResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -73,7 +74,8 @@ class MasterPasswordGeneratorViewModel @Inject constructor(
private fun handleBackAction() = sendEvent(MasterPasswordGeneratorEvent.NavigateBack)
private fun handleSavePasswordAction() {
// TODO [PM-10692](https://bitwarden.atlassian.net/browse/PM-10692)
generatorRepository.emitGeneratorResult(GeneratorResult.Password(state.generatedPassword))
sendEvent(MasterPasswordGeneratorEvent.NavigateBackToRegistration)
}
private fun handlePreventLockoutAction() =
@ -163,6 +165,11 @@ sealed class MasterPasswordGeneratorEvent {
* Show a Snackbar message.
*/
data class ShowSnackbar(val text: Text) : MasterPasswordGeneratorEvent()
/**
* Navigate back to the complete registration screen.
*/
data object NavigateBackToRegistration : MasterPasswordGeneratorEvent()
}
/**

View file

@ -20,6 +20,9 @@ import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.BackClick
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.Internal.ReceivePasswordStrengthResult
@ -61,6 +64,10 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
every { getFeatureFlag(FlagKey.OnboardingFlow) } returns false
every { getFeatureFlagFlow(FlagKey.OnboardingFlow) } returns mutableFeatureFlagFlow
}
private val mutableGeneratorResultFlow = bufferedMutableSharedFlow<GeneratorResult>()
private val generatorRepository = mockk<GeneratorRepository>(relaxed = true) {
every { generatorResultFlow } returns mutableGeneratorResultFlow
}
@BeforeEach
fun setUp() {
@ -95,35 +102,34 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
environmentRepository = fakeEnvironmentRepository,
specialCircumstanceManager = specialCircumstanceManager,
featureFlagManager = featureFlagManager,
generatorRepository = generatorRepository,
)
viewModel.onCleared()
assertTrue(specialCircumstanceManager.specialCircumstance == null)
}
@Test
fun `Password below 12 chars should have non-valid state`() =
runTest {
val input = "abcdefghikl"
coEvery {
mockAuthRepository.getPasswordStrength(EMAIL, input)
} returns PasswordStrengthResult.Error
val viewModel = createCompleteRegistrationViewModel()
viewModel.trySendAction(PasswordInputChange(input))
fun `Password below 12 chars should have non-valid state`() = runTest {
val input = "abcdefghikl"
coEvery {
mockAuthRepository.getPasswordStrength(EMAIL, input)
} returns PasswordStrengthResult.Error
val viewModel = createCompleteRegistrationViewModel()
viewModel.trySendAction(PasswordInputChange(input))
assertFalse(viewModel.stateFlow.value.validSubmissionReady)
}
assertFalse(viewModel.stateFlow.value.validSubmissionReady)
}
@Test
fun `Passwords not matching should have non-valid state`() =
runTest {
coEvery {
mockAuthRepository.getPasswordStrength(EMAIL, PASSWORD)
} returns PasswordStrengthResult.Error
val viewModel = createCompleteRegistrationViewModel()
viewModel.trySendAction(PasswordInputChange(PASSWORD))
fun `Passwords not matching should have non-valid state`() = runTest {
coEvery {
mockAuthRepository.getPasswordStrength(EMAIL, PASSWORD)
} returns PasswordStrengthResult.Error
val viewModel = createCompleteRegistrationViewModel()
viewModel.trySendAction(PasswordInputChange(PASSWORD))
assertFalse(viewModel.stateFlow.value.validSubmissionReady)
}
assertFalse(viewModel.stateFlow.value.validSubmissionReady)
}
@Test
fun `CallToActionClick with all inputs valid should show and hide loading dialog`() = runTest {
@ -344,11 +350,10 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
)
} returns RegisterResult.WeakPassword
}
val initialState = VALID_INPUT_STATE
.copy(
passwordStrengthState = PasswordStrengthState.WEAK_1,
isCheckDataBreachesToggled = true,
)
val initialState = VALID_INPUT_STATE.copy(
passwordStrengthState = PasswordStrengthState.WEAK_1,
isCheckDataBreachesToggled = true,
)
val viewModel =
createCompleteRegistrationViewModel(completeRegistrationState = initialState)
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
@ -418,6 +423,47 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
coVerify { mockAuthRepository.getPasswordStrength(EMAIL, PASSWORD) }
}
@Test
fun `Empty PasswordInputChange update should result in password strength being NONE`() =
runTest {
val viewModel = createCompleteRegistrationViewModel(
completeRegistrationState = DEFAULT_STATE.copy(
passwordInput = PASSWORD,
passwordStrengthState = PasswordStrengthState.STRONG,
),
)
viewModel.trySendAction(PasswordInputChange(""))
val expectedStrengthUpdateState = DEFAULT_STATE.copy(
passwordInput = "",
passwordStrengthState = PasswordStrengthState.NONE,
)
viewModel.stateFlow.test {
assertEquals(expectedStrengthUpdateState, awaitItem())
}
coVerify(exactly = 0) { mockAuthRepository.getPasswordStrength(EMAIL, PASSWORD) }
}
@Suppress("MaxLineLength")
@Test
fun `Internal GeneratedPasswordResult update passwordInput and confirmPasswordInput and call getPasswordStrength`() =
runTest {
coEvery {
mockAuthRepository.getPasswordStrength(EMAIL, PASSWORD)
} returns PasswordStrengthResult.Error
val viewModel = createCompleteRegistrationViewModel()
mutableGeneratorResultFlow.emit(GeneratorResult.Password(PASSWORD))
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(
passwordInput = PASSWORD,
confirmPasswordInput = PASSWORD,
),
awaitItem(),
)
}
coVerify { mockAuthRepository.getPasswordStrength(EMAIL, PASSWORD) }
}
@Test
fun `CheckDataBreachesToggle should change isCheckDataBreachesToggled`() = runTest {
val viewModel = createCompleteRegistrationViewModel()
@ -565,47 +611,47 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
}
@Test
fun `CreateAccountClick with no email not should show dialog`() =
runTest {
coEvery {
mockAuthRepository.getPasswordStrength("", PASSWORD)
} returns PasswordStrengthResult.Error
val viewModel = createCompleteRegistrationViewModel(
DEFAULT_STATE.copy(userEmail = ""),
)
viewModel.trySendAction(PasswordInputChange(PASSWORD))
val expectedState = DEFAULT_STATE.copy(
userEmail = "",
passwordInput = PASSWORD,
dialog = CompleteRegistrationDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.validation_field_required
.asText(R.string.email_address.asText()),
fun `CreateAccountClick with no email not should show dialog`() = runTest {
coEvery {
mockAuthRepository.getPasswordStrength("", PASSWORD)
} returns PasswordStrengthResult.Error
val viewModel = createCompleteRegistrationViewModel(
DEFAULT_STATE.copy(userEmail = ""),
)
viewModel.trySendAction(PasswordInputChange(PASSWORD))
val expectedState = DEFAULT_STATE.copy(
userEmail = "",
passwordInput = PASSWORD,
dialog = CompleteRegistrationDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.validation_field_required.asText(
R.string.email_address.asText(),
),
),
)
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
),
)
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
}
private fun createCompleteRegistrationViewModel(
completeRegistrationState: CompleteRegistrationState? = DEFAULT_STATE,
authRepository: AuthRepository = mockAuthRepository,
): CompleteRegistrationViewModel =
CompleteRegistrationViewModel(
savedStateHandle = SavedStateHandle(
mapOf(
"state" to completeRegistrationState,
),
): CompleteRegistrationViewModel = CompleteRegistrationViewModel(
savedStateHandle = SavedStateHandle(
mapOf(
"state" to completeRegistrationState,
),
authRepository = authRepository,
environmentRepository = fakeEnvironmentRepository,
specialCircumstanceManager = specialCircumstanceManager,
featureFlagManager = featureFlagManager,
)
),
authRepository = authRepository,
environmentRepository = fakeEnvironmentRepository,
specialCircumstanceManager = specialCircumstanceManager,
featureFlagManager = featureFlagManager,
generatorRepository = generatorRepository,
)
companion object {
private const val PASSWORD = "longenoughtpassword"

View file

@ -20,6 +20,7 @@ import org.junit.Test
class MasterPasswordGeneratorScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToPreventLockoutCalled = false
private var navigateBackWithPasswordCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<MasterPasswordGeneratorEvent>()
private val mutableStateFlow = MutableStateFlow(
value = MasterPasswordGeneratorState(generatedPassword = "-"),
@ -35,6 +36,7 @@ class MasterPasswordGeneratorScreenTest : BaseComposeTest() {
MasterPasswordGeneratorScreen(
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToPreventLockout = { onNavigateToPreventLockoutCalled = true },
onNavigateBackWithPassword = { navigateBackWithPasswordCalled = true },
viewModel = viewModel,
)
}
@ -42,7 +44,7 @@ class MasterPasswordGeneratorScreenTest : BaseComposeTest() {
@Test
fun `Generated password field state should update with ViewModel state`() {
val updatedValue = "soup-r-stronk-pazzwerd"
val updatedValue = PASSWORD_INPUT
mutableStateFlow.update { it.copy(generatedPassword = updatedValue) }
composeTestRule
@ -104,4 +106,15 @@ class MasterPasswordGeneratorScreenTest : BaseComposeTest() {
verify { viewModel.trySendAction(MasterPasswordGeneratorAction.PreventLockoutClickAction) }
}
@Test
fun `Verify navigating back with password invokes the lambda`() {
mutableEventFlow.tryEmit(
MasterPasswordGeneratorEvent.NavigateBackToRegistration,
)
assertTrue(navigateBackWithPasswordCalled)
}
}
private const val PASSWORD_INPUT = "password1234"

View file

@ -124,6 +124,21 @@ class MasterPasswordGeneratorViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `NavigateBackWithPassword event is sent when SavePasswordClickAction is handled`() =
runTest {
val viewModel = createViewModel(
initialState = MasterPasswordGeneratorState(generatedPassword = "saved-pw"),
)
viewModel.eventFlow.test {
viewModel.trySendAction(MasterPasswordGeneratorAction.SavePasswordClickAction)
assertEquals(
MasterPasswordGeneratorEvent.NavigateBackToRegistration,
awaitItem(),
)
}
}
// region helpers
private fun createViewModel(