diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index 3784a4308..ce7be191e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -757,6 +757,7 @@ class AuthRepositoryImpl( ) .flatMap { registerKeyResponse -> if (emailVerificationToken == null) { + // TODO PM-6675: Remove register call and service implementation identityService.register( body = RegisterRequestJson( email = email, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt index 7290519c0..2b308c2af 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt @@ -8,6 +8,8 @@ import androidx.navigation.navOptions import androidx.navigation.navigation 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.createaccount.createAccountDestination import com.x8bit.bitwarden.ui.auth.feature.createaccount.navigateToCreateAccount import com.x8bit.bitwarden.ui.auth.feature.enterprisesignon.enterpriseSignOnDestination @@ -65,7 +67,11 @@ fun NavGraphBuilder.authGraph( startRegistrationDestination( onNavigateBack = { navController.popBackStack() }, onNavigateToCompleteRegistration = { emailAddress, verificationToken -> - // TODO PR-3622 ADD NAVIGATION TO COMPLETE REGISTRATION + navController.navigateToCompleteRegistration( + emailAddress = emailAddress, + verificationToken = verificationToken, + fromEmail = false, + ) }, onNavigateToCheckEmail = { emailAddress -> navController.navigateToCheckEmail(emailAddress) @@ -77,6 +83,12 @@ fun NavGraphBuilder.authGraph( onNavigateBackToLanding = { navController.popBackStack(route = LANDING_ROUTE, inclusive = false) },) + completeRegistrationDestination( + onNavigateBack = { navController.popBackStack() }, + onNavigateToLanding = { + navController.popBackStack(route = LANDING_ROUTE, inclusive = false) + }, + ) enterpriseSignOnDestination( onNavigateBack = { navController.popBackStack() }, onNavigateToSetPassword = { navController.navigateToSetPassword() }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationNavigation.kt new file mode 100644 index 000000000..b4b61094b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationNavigation.kt @@ -0,0 +1,70 @@ +package com.x8bit.bitwarden.ui.auth.feature.completeregistration + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.NavType +import androidx.navigation.navArgument +import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage +import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions + +private const val EMAIL_ADDRESS: String = "email_address" +private const val VERIFICATION_TOKEN: String = "verification_token" +private const val FROM_EMAIL: String = "from_email" +private const val COMPLETE_REGISTRATION_PREFIX = "complete_registration" +private const val COMPLETE_REGISTRATION_ROUTE = + "$COMPLETE_REGISTRATION_PREFIX/{$EMAIL_ADDRESS}/{$VERIFICATION_TOKEN}/{$FROM_EMAIL}" + +/** + * Class to retrieve complete registration arguments from the [SavedStateHandle]. + */ +@OmitFromCoverage +data class CompleteRegistrationArgs( + val emailAddress: String, + val verificationToken: String, + val fromEmail: Boolean, +) { + constructor(savedStateHandle: SavedStateHandle) : this( + emailAddress = checkNotNull(savedStateHandle.get(EMAIL_ADDRESS)), + verificationToken = checkNotNull(savedStateHandle.get(VERIFICATION_TOKEN)), + fromEmail = checkNotNull(savedStateHandle.get(FROM_EMAIL)), + ) +} + +/** + * Navigate to the complete registration screen. + */ +fun NavController.navigateToCompleteRegistration( + emailAddress: String, + verificationToken: String, + fromEmail: Boolean, + navOptions: NavOptions? = null, +) { + this.navigate( + "$COMPLETE_REGISTRATION_PREFIX/$emailAddress/$verificationToken/$fromEmail", + navOptions, + ) +} + +/** + * Add the complete registration screen to the nav graph. + */ +fun NavGraphBuilder.completeRegistrationDestination( + onNavigateBack: () -> Unit, + onNavigateToLanding: () -> Unit, +) { + composableWithSlideTransitions( + route = COMPLETE_REGISTRATION_ROUTE, + arguments = listOf( + navArgument(EMAIL_ADDRESS) { type = NavType.StringType }, + navArgument(VERIFICATION_TOKEN) { type = NavType.StringType }, + navArgument(FROM_EMAIL) { type = NavType.BoolType }, + ), + ) { + CompleteRegistrationScreen( + onNavigateBack = onNavigateBack, + onNavigateToLanding = onNavigateToLanding, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreen.kt new file mode 100644 index 000000000..90acbab82 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreen.kt @@ -0,0 +1,236 @@ +package com.x8bit.bitwarden.ui.auth.feature.completeregistration + +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CheckDataBreachesToggle +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CloseClick +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ContinueWithBreachedPasswordClick +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CreateAccountClick +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ErrorDialogDismiss +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordHintChange +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordInputChange +import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton +import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog +import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog +import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog +import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState +import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordField +import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField +import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenSwitch +import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager + +/** + * Top level composable for the complete registration screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("LongMethod") +@Composable +fun CompleteRegistrationScreen( + onNavigateBack: () -> Unit, + onNavigateToLanding: () -> Unit, + intentManager: IntentManager = LocalIntentManager.current, + viewModel: CompleteRegistrationViewModel = hiltViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val context = LocalContext.current + EventsEffect(viewModel) { event -> + when (event) { + is CompleteRegistrationEvent.NavigateBack -> onNavigateBack.invoke() + is CompleteRegistrationEvent.ShowToast -> { + Toast.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT).show() + } + + is CompleteRegistrationEvent.NavigateToLanding -> { + onNavigateToLanding() + } + } + } + + // Show dialog if needed: + when (val dialog = state.dialog) { + is CompleteRegistrationDialog.Error -> { + BitwardenBasicDialog( + visibilityState = dialog.state, + onDismissRequest = remember(viewModel) { + { viewModel.trySendAction(ErrorDialogDismiss) } + }, + ) + } + + is CompleteRegistrationDialog.HaveIBeenPwned -> { + BitwardenTwoButtonDialog( + title = dialog.title(), + message = dialog.message(), + confirmButtonText = stringResource(id = R.string.yes), + dismissButtonText = stringResource(id = R.string.no), + onConfirmClick = remember(viewModel) { + { viewModel.trySendAction(ContinueWithBreachedPasswordClick) } + }, + onDismissClick = remember(viewModel) { + { viewModel.trySendAction(ErrorDialogDismiss) } + }, + onDismissRequest = remember(viewModel) { + { viewModel.trySendAction(ErrorDialogDismiss) } + }, + ) + } + + CompleteRegistrationDialog.Loading -> { + BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown(R.string.create_account.asText()), + ) + } + + null -> Unit + } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.set_password), + scrollBehavior = scrollBehavior, + navigationIcon = rememberVectorPainter(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = remember(viewModel) { + { viewModel.trySendAction(CloseClick) } + }, + actions = { + BitwardenTextButton( + label = stringResource(id = R.string.create_account), + onClick = remember(viewModel) { + { viewModel.trySendAction(CreateAccountClick) } + }, + modifier = Modifier.testTag("CreateAccountButton"), + ) + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .imePadding() + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + Spacer(modifier = Modifier.height(16.dp)) + + @Suppress("MaxLineLength") + Text( + text = stringResource( + id = R.string.follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account, + state.userEmail, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) + var showPassword by rememberSaveable { mutableStateOf(false) } + BitwardenPasswordField( + label = stringResource(id = R.string.master_password), + showPassword = showPassword, + showPasswordChange = { showPassword = it }, + value = state.passwordInput, + hint = state.passwordLengthLabel(), + onValueChange = remember(viewModel) { + { viewModel.trySendAction(PasswordInputChange(it)) } + }, + modifier = Modifier + .testTag("MasterPasswordEntry") + .fillMaxWidth() + .padding(horizontal = 16.dp), + showPasswordTestTag = "PasswordVisibilityToggle", + ) + Spacer(modifier = Modifier.height(8.dp)) + PasswordStrengthIndicator( + modifier = Modifier.padding(horizontal = 16.dp), + state = state.passwordStrengthState, + ) + Spacer(modifier = Modifier.height(8.dp)) + BitwardenPasswordField( + label = stringResource(id = R.string.retype_master_password), + value = state.confirmPasswordInput, + showPassword = showPassword, + showPasswordChange = { showPassword = it }, + onValueChange = remember(viewModel) { + { viewModel.trySendAction(ConfirmPasswordInputChange(it)) } + }, + modifier = Modifier + .testTag("ConfirmMasterPasswordEntry") + .fillMaxWidth() + .padding(horizontal = 16.dp), + showPasswordTestTag = "ConfirmPasswordVisibilityToggle", + ) + Spacer(modifier = Modifier.height(16.dp)) + BitwardenTextField( + label = stringResource(id = R.string.master_password_hint), + value = state.passwordHintInput, + onValueChange = remember(viewModel) { + { viewModel.trySendAction(PasswordHintChange(it)) } + }, + hint = stringResource(id = R.string.master_password_hint_description), + modifier = Modifier + .testTag("MasterPasswordHintLabel") + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(24.dp)) + BitwardenSwitch( + label = stringResource(id = R.string.check_known_data_breaches_for_this_password), + isChecked = state.isCheckDataBreachesToggled, + onCheckedChange = remember(viewModel) { + { newState -> + viewModel.trySendAction(CheckDataBreachesToggle(newState = newState)) + } + }, + modifier = Modifier + .testTag("CheckExposedMasterPasswordToggle") + .padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.navigationBarsPadding()) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModel.kt new file mode 100644 index 000000000..f1024702a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModel.kt @@ -0,0 +1,493 @@ +package com.x8bit.bitwarden.ui.auth.feature.completeregistration + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.x8bit.bitwarden.R +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.PasswordStrengthResult +import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult +import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager +import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CheckDataBreachesToggle +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CloseClick +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ContinueWithBreachedPasswordClick +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CreateAccountClick +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ErrorDialogDismiss +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.Internal +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.Internal.ReceivePasswordStrengthResult +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordHintChange +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordInputChange +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 +import com.x8bit.bitwarden.ui.platform.base.util.concat +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.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import org.jetbrains.annotations.VisibleForTesting +import javax.inject.Inject + +private const val KEY_STATE = "state" +private const val MIN_PASSWORD_LENGTH = 12 + +/** + * Models logic for the create account screen. + */ +@Suppress("TooManyFunctions") +@HiltViewModel +class CompleteRegistrationViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val authRepository: AuthRepository, + private val environmentRepository: EnvironmentRepository, + private val specialCircumstanceManager: SpecialCircumstanceManager, +) : BaseViewModel( + 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, + ) + }, +) { + + /** + * Keeps track of async request to get password strength. Should be cancelled + * when user input changes. + */ + private var passwordStrengthJob: Job = Job().apply { complete() } + + init { + verifyEmailAddress() + // As state updates, write to saved state handle: + stateFlow + .onEach { savedStateHandle[KEY_STATE] = it } + .launchIn(viewModelScope) + } + + @VisibleForTesting + public override fun onCleared() { + // clean the specialCircumstance after being handled + specialCircumstanceManager.specialCircumstance = null + super.onCleared() + } + + override fun handleAction(action: CompleteRegistrationAction) { + when (action) { + is CreateAccountClick -> handleCreateAccountClick() + is ConfirmPasswordInputChange -> handleConfirmPasswordInputChanged(action) + is PasswordHintChange -> handlePasswordHintChanged(action) + is PasswordInputChange -> handlePasswordInputChanged(action) + is CloseClick -> handleCloseClick() + is ErrorDialogDismiss -> handleDialogDismiss() + is CheckDataBreachesToggle -> handleCheckDataBreachesToggle(action) + is Internal.ReceiveRegisterResult -> { + handleReceiveRegisterAccountResult(action) + } + + ContinueWithBreachedPasswordClick -> handleContinueWithBreachedPasswordClick() + is ReceivePasswordStrengthResult -> handlePasswordStrengthResult(action) + } + } + + private fun verifyEmailAddress() { + if (!state.fromEmail) { + return + } + + viewModelScope.launch { + sendEvent( + CompleteRegistrationEvent.ShowToast( + message = R.string.email_verified.asText(), + ), + ) + } + } + + private fun handlePasswordStrengthResult(action: 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 + } + mutableStateFlow.update { oldState -> + oldState.copy( + passwordStrengthState = updatedState, + ) + } + } + + PasswordStrengthResult.Error -> Unit + } + } + + @Suppress("LongMethod", "MaxLineLength") + private fun handleReceiveRegisterAccountResult( + action: Internal.ReceiveRegisterResult, + ) { + when (val registerAccountResult = action.registerResult) { + // TODO PM-6675: Remove captcha from RegisterResult when old flow gets removed + is RegisterResult.CaptchaRequired -> { + throw IllegalStateException( + "Captcha should not be required for the new registration flow", + ) + } + + is RegisterResult.Error -> { + mutableStateFlow.update { + it.copy( + dialog = CompleteRegistrationDialog.Error( + BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = registerAccountResult.errorMessage?.asText() + ?: R.string.generic_error_message.asText(), + ), + ), + ) + } + } + + is RegisterResult.Success -> { + mutableStateFlow.update { it.copy(dialog = null) } + sendEvent( + CompleteRegistrationEvent.NavigateToLanding, + ) + } + + RegisterResult.DataBreachFound -> { + mutableStateFlow.update { + it.copy( + dialog = CompleteRegistrationDialog.HaveIBeenPwned( + title = R.string.exposed_master_password.asText(), + message = R.string.password_found_in_a_data_breach_alert_description.asText(), + ), + ) + } + } + + RegisterResult.DataBreachAndWeakPassword -> { + mutableStateFlow.update { + it.copy( + dialog = CompleteRegistrationDialog.HaveIBeenPwned( + title = R.string.weak_and_exposed_master_password.asText(), + message = R.string.weak_password_identified_and_found_in_a_data_breach_alert_description.asText(), + ), + ) + } + } + + RegisterResult.WeakPassword -> { + mutableStateFlow.update { + it.copy( + dialog = CompleteRegistrationDialog.HaveIBeenPwned( + title = R.string.weak_master_password.asText(), + message = R.string.weak_password_identified_use_a_strong_password_to_protect_your_account.asText(), + ), + ) + } + } + } + } + + private fun handleCheckDataBreachesToggle(action: CheckDataBreachesToggle) { + mutableStateFlow.update { + it.copy(isCheckDataBreachesToggled = action.newState) + } + } + + private fun handleDialogDismiss() { + mutableStateFlow.update { + it.copy(dialog = null) + } + } + + private fun handleCloseClick() { + sendEvent(CompleteRegistrationEvent.NavigateBack) + } + + private fun handlePasswordHintChanged(action: PasswordHintChange) { + mutableStateFlow.update { it.copy(passwordHintInput = action.input) } + } + + 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)) + } + } + } + + private fun handleConfirmPasswordInputChanged(action: ConfirmPasswordInputChange) { + mutableStateFlow.update { it.copy(confirmPasswordInput = action.input) } + } + + private fun handleCreateAccountClick() = when { + state.userEmail.isBlank() -> { + val dialog = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.validation_field_required + .asText(R.string.email_address.asText()), + ) + mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) } + } + + !state.userEmail.isValidEmail() -> { + val dialog = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.invalid_email.asText(), + ) + mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) } + } + + state.passwordInput.length < MIN_PASSWORD_LENGTH -> { + val dialog = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.master_password_length_val_message_x.asText(MIN_PASSWORD_LENGTH), + ) + mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) } + } + + state.passwordInput != state.confirmPasswordInput -> { + val dialog = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.master_password_confirmation_val_message.asText(), + ) + mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) } + } + + else -> { + submitRegisterAccountRequest( + shouldCheckForDataBreaches = state.isCheckDataBreachesToggled, + shouldIgnorePasswordStrength = false, + ) + } + } + + private fun handleContinueWithBreachedPasswordClick() { + submitRegisterAccountRequest( + shouldCheckForDataBreaches = false, + shouldIgnorePasswordStrength = true, + ) + } + + private fun submitRegisterAccountRequest( + shouldCheckForDataBreaches: Boolean, + shouldIgnorePasswordStrength: Boolean, + ) { + mutableStateFlow.update { + it.copy(dialog = CompleteRegistrationDialog.Loading) + } + viewModelScope.launch { + // Update region accordingly to a user email + environmentRepository.loadEnvironmentForEmail(state.userEmail) + val result = authRepository.register( + shouldCheckDataBreaches = shouldCheckForDataBreaches, + isMasterPasswordStrong = shouldIgnorePasswordStrength || + state.isMasterPasswordStrong, + emailVerificationToken = state.emailVerificationToken, + email = state.userEmail, + masterPassword = state.passwordInput, + masterPasswordHint = state.passwordHintInput.ifBlank { null }, + captchaToken = null, + ) + sendAction( + Internal.ReceiveRegisterResult( + registerResult = result, + ), + ) + } + } +} + +/** + * UI state for the complete registration screen. + */ +@Parcelize +data class CompleteRegistrationState( + val userEmail: String, + val emailVerificationToken: String, + val fromEmail: Boolean, + val passwordInput: String, + val confirmPasswordInput: String, + val passwordHintInput: String, + val isCheckDataBreachesToggled: Boolean, + val dialog: CompleteRegistrationDialog?, + val passwordStrengthState: PasswordStrengthState, +) : Parcelable { + + val passwordLengthLabel: Text + // Have to concat a few strings here, resulting string is: + // Important: Your master password cannot be recovered if you forget it! 12 + // characters minimum + @Suppress("MaxLineLength") + get() = R.string.important.asText() + .concat( + ": ".asText(), + R.string.your_master_password_cannot_be_recovered_if_you_forget_it_x_characters_minimum + .asText(MIN_PASSWORD_LENGTH), + ) + + /** + * Whether or not the provided master password is considered strong. + */ + val isMasterPasswordStrong: Boolean + get() = when (passwordStrengthState) { + PasswordStrengthState.NONE, + PasswordStrengthState.WEAK_1, + PasswordStrengthState.WEAK_2, + PasswordStrengthState.WEAK_3, + -> false + + PasswordStrengthState.GOOD, + PasswordStrengthState.STRONG, + -> true + } +} + +/** + * Models dialogs that can be displayed on the complete registration screen. + */ +sealed class CompleteRegistrationDialog : Parcelable { + /** + * Loading dialog. + */ + @Parcelize + data object Loading : CompleteRegistrationDialog() + + /** + * Confirm the user wants to continue with potentially breached password. + * + * @param title The title for the HaveIBeenPwned dialog. + * @param message The message for the HaveIBeenPwned dialog. + */ + @Parcelize + data class HaveIBeenPwned( + val title: Text, + val message: Text, + ) : CompleteRegistrationDialog() + + /** + * General error dialog with an OK button. + */ + @Parcelize + data class Error(val state: BasicDialogState.Shown) : CompleteRegistrationDialog() +} + +/** + * Models events for the complete registration screen. + */ +sealed class CompleteRegistrationEvent { + + /** + * Navigate back to previous screen. + */ + data object NavigateBack : CompleteRegistrationEvent() + + /** + * Show a toast with the given message. + */ + data class ShowToast( + val message: Text, + ) : CompleteRegistrationEvent() + + /** + * Navigates to the landing screen. + */ + data object NavigateToLanding : CompleteRegistrationEvent() +} + +/** + * Models actions for the complete registration screen. + */ +sealed class CompleteRegistrationAction { + /** + * User clicked create account. + */ + data object CreateAccountClick : CompleteRegistrationAction() + + /** + * User clicked close. + */ + data object CloseClick : CompleteRegistrationAction() + + /** + * User clicked "Yes" when being asked if they are sure they want to use a breached password. + */ + data object ContinueWithBreachedPasswordClick : CompleteRegistrationAction() + + /** + * Password input changed. + */ + data class PasswordInputChange(val input: String) : CompleteRegistrationAction() + + /** + * Confirm password input changed. + */ + data class ConfirmPasswordInputChange(val input: String) : CompleteRegistrationAction() + + /** + * Password hint input changed. + */ + data class PasswordHintChange(val input: String) : CompleteRegistrationAction() + + /** + * User dismissed the error dialog. + */ + data object ErrorDialogDismiss : CompleteRegistrationAction() + + /** + * User tapped check data breaches toggle. + */ + data class CheckDataBreachesToggle(val newState: Boolean) : CompleteRegistrationAction() + + /** + * Models actions that the [CompleteRegistrationViewModel] itself might send. + */ + sealed class Internal : CompleteRegistrationAction() { + /** + * Indicates a [RegisterResult] has been received. + */ + data class ReceiveRegisterResult( + val registerResult: RegisterResult, + ) : Internal() + + /** + * Indicates a password strength result has been received. + */ + data class ReceivePasswordStrengthResult( + val result: PasswordStrengthResult, + ) : Internal() + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/PasswordStrengthIndicator.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/PasswordStrengthIndicator.kt similarity index 98% rename from app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/PasswordStrengthIndicator.kt rename to app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/PasswordStrengthIndicator.kt index a9ca846d2..eb2fc2cef 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/PasswordStrengthIndicator.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/PasswordStrengthIndicator.kt @@ -1,4 +1,4 @@ -package com.x8bit.bitwarden.ui.auth.feature.createaccount +package com.x8bit.bitwarden.ui.auth.feature.completeregistration import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt index 04fc024ee..69eeb6944 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt @@ -49,6 +49,7 @@ import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthIndicator import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt index 1af6680d1..5d6e5b4ed 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt @@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index a1b76b7f5..f3896cf69 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -18,6 +18,7 @@ import androidx.navigation.navOptions import com.x8bit.bitwarden.ui.auth.feature.auth.AUTH_GRAPH_ROUTE import com.x8bit.bitwarden.ui.auth.feature.auth.authGraph import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuthGraph +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.navigateToCompleteRegistration import com.x8bit.bitwarden.ui.auth.feature.resetpassword.RESET_PASSWORD_ROUTE import com.x8bit.bitwarden.ui.auth.feature.resetpassword.navigateToResetPasswordGraph import com.x8bit.bitwarden.ui.auth.feature.resetpassword.resetPasswordDestination @@ -138,6 +139,15 @@ fun RootNavScreen( when (val currentState = state) { RootNavState.Auth -> navController.navigateToAuthGraph(rootNavOptions) RootNavState.AuthWithWelcome -> navController.navigateToWelcome(rootNavOptions) + is RootNavState.CompleteOngoingRegistration -> { + navController.navigateToAuthGraph(rootNavOptions) + navController.navigateToCompleteRegistration( + emailAddress = currentState.email, + verificationToken = currentState.verificationToken, + fromEmail = currentState.fromEmail, + ) + } + RootNavState.ResetPassword -> navController.navigateToResetPasswordGraph(rootNavOptions) RootNavState.SetPassword -> navController.navigateToSetPassword(rootNavOptions) RootNavState.Splash -> navController.navigateToSplash(rootNavOptions) @@ -191,11 +201,6 @@ fun RootNavScreen( navOptions = rootNavOptions, ) } - - is RootNavState.CompleteOngoingRegistration -> { - navController.navigateToAuthGraph(rootNavOptions) - // TODO PR-3622: add navigation to complete registration - } } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt index 5af597d36..4fc52e116 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt @@ -34,7 +34,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R -import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthIndicator +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthIndicator import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledTonalButton diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt index ac30ba858..595178cdb 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt @@ -15,7 +15,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson import com.x8bit.bitwarden.data.vault.manager.FileManager import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult -import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthState +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState 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 diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreenTest.kt new file mode 100644 index 000000000..e28bb40bc --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreenTest.kt @@ -0,0 +1,249 @@ +package com.x8bit.bitwarden.ui.auth.feature.completeregistration + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CheckDataBreachesToggle +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CloseClick +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ContinueWithBreachedPasswordClick +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CreateAccountClick +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ErrorDialogDismiss +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordHintChange +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordInputChange +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class CompleteRegistrationScreenTest : BaseComposeTest() { + + private var onNavigateBackCalled = false + private var onNavigateToLandingCalled = false + + private val intentManager = mockk(relaxed = true) { + every { startCustomTabsActivity(any()) } just runs + every { startActivity(any()) } just runs + } + + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val mutableEventFlow = bufferedMutableSharedFlow() + private val viewModel = mockk(relaxed = true) { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns mutableEventFlow + every { trySendAction(any()) } just runs + } + + @Before + fun setup() { + composeTestRule.setContent { + CompleteRegistrationScreen( + onNavigateBack = { onNavigateBackCalled = true }, + onNavigateToLanding = { onNavigateToLandingCalled = true }, + intentManager = intentManager, + viewModel = viewModel, + ) + } + } + + @Test + fun `app bar submit click should send CreateAccountClick action`() { + composeTestRule.onNodeWithText("Create account").performClick() + verify { viewModel.trySendAction(CreateAccountClick) } + } + + @Test + fun `close click should send CloseClick action`() { + composeTestRule.onNodeWithContentDescription("Close").performClick() + verify { viewModel.trySendAction(CloseClick) } + } + + @Test + fun `check data breaches click should send CheckDataBreachesToggle action`() { + composeTestRule + .onNodeWithText("Check known data breaches for this password") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(CheckDataBreachesToggle(false)) } + } + + @Test + fun `NavigateBack event should invoke navigate back lambda`() { + mutableEventFlow.tryEmit(CompleteRegistrationEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } + + @Test + fun `NavigateToLogin event should invoke navigate login lambda`() { + mutableEventFlow.tryEmit(CompleteRegistrationEvent.NavigateToLanding) + assertTrue(onNavigateToLandingCalled) + } + + @Test + fun `password input change should send PasswordInputChange action`() { + composeTestRule.onNodeWithText("Master password").performTextInput(TEST_INPUT) + verify { viewModel.trySendAction(PasswordInputChange(TEST_INPUT)) } + } + + @Test + fun `confirm password input change should send ConfirmPasswordInputChange action`() { + composeTestRule.onNodeWithText("Re-type master password").performTextInput(TEST_INPUT) + verify { viewModel.trySendAction(ConfirmPasswordInputChange(TEST_INPUT)) } + } + + @Test + fun `password hint input change should send PasswordHintChange action`() { + composeTestRule + .onNodeWithText("Master password hint (optional)") + .performTextInput(TEST_INPUT) + verify { viewModel.trySendAction(PasswordHintChange(TEST_INPUT)) } + } + + @Test + fun `clicking OK on the error dialog should send ErrorDialogDismiss action`() { + mutableStateFlow.update { + it.copy( + dialog = CompleteRegistrationDialog.Error( + BasicDialogState.Shown( + title = "title".asText(), + message = "message".asText(), + ), + ), + ) + } + composeTestRule + .onAllNodesWithText("Ok") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(ErrorDialogDismiss) } + } + + @Test + fun `clicking No on the HIBP dialog should send ErrorDialogDismiss action`() { + mutableStateFlow.update { + it.copy(dialog = createHaveIBeenPwned()) + } + composeTestRule + .onAllNodesWithText("No") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(ErrorDialogDismiss) } + } + + @Test + fun `clicking Yes on the HIBP dialog should send ContinueWithBreachedPasswordClick action`() { + mutableStateFlow.update { + it.copy(dialog = createHaveIBeenPwned()) + } + composeTestRule + .onAllNodesWithText("Yes") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(ContinueWithBreachedPasswordClick) } + } + + @Test + fun `when BasicDialogState is Shown should show dialog`() { + mutableStateFlow.update { + it.copy( + dialog = CompleteRegistrationDialog.Error( + BasicDialogState.Shown( + title = "title".asText(), + message = "message".asText(), + ), + ), + ) + } + composeTestRule.onNode(isDialog()).assertIsDisplayed() + } + + @Test + fun `password strength should change as state changes`() { + mutableStateFlow.update { + DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.WEAK_1) + } + composeTestRule.onNodeWithText("Weak").assertIsDisplayed() + + mutableStateFlow.update { + DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.WEAK_2) + } + composeTestRule.onNodeWithText("Weak").assertIsDisplayed() + + mutableStateFlow.update { + DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.WEAK_3) + } + composeTestRule.onNodeWithText("Weak").assertIsDisplayed() + + mutableStateFlow.update { + DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.GOOD) + } + composeTestRule.onNodeWithText("Good").assertIsDisplayed() + + mutableStateFlow.update { + DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.STRONG) + } + composeTestRule.onNodeWithText("Strong").assertIsDisplayed() + } + + @Test + fun `toggling one password field visibility should toggle the other`() { + // should start with 2 Show buttons: + composeTestRule + .onAllNodesWithContentDescription("Show") + .assertCountEquals(2)[0] + .performClick() + + // after clicking there should be no Show buttons: + composeTestRule + .onAllNodesWithContentDescription("Show") + .assertCountEquals(0) + + // and there should be 2 hide buttons now, and we'll click the second one: + composeTestRule + .onAllNodesWithContentDescription("Hide") + .assertCountEquals(2)[1] + .performClick() + + // then there should be two show buttons again + composeTestRule + .onAllNodesWithContentDescription("Show") + .assertCountEquals(2) + } + + companion object { + private const val EMAIL = "test@test.com" + private const val TOKEN = "token" + private const val TEST_INPUT = "input" + private val DEFAULT_STATE = CompleteRegistrationState( + userEmail = EMAIL, + emailVerificationToken = TOKEN, + fromEmail = true, + passwordInput = "", + confirmPasswordInput = "", + passwordHintInput = "", + isCheckDataBreachesToggled = true, + dialog = null, + passwordStrengthState = PasswordStrengthState.NONE, + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationTestUtil.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationTestUtil.kt new file mode 100644 index 000000000..17761137f --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationTestUtil.kt @@ -0,0 +1,18 @@ +package com.x8bit.bitwarden.ui.auth.feature.completeregistration + +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText + +/** + * Creates a mock [CompleteRegistrationDialog.HaveIBeenPwned]. + */ +fun createHaveIBeenPwned( + title: Text = R.string.weak_and_exposed_master_password.asText(), + message: Text = R.string.weak_password_identified_and_found_in_a_data_breach_alert_description + .asText(), +): CompleteRegistrationDialog.HaveIBeenPwned = + CompleteRegistrationDialog.HaveIBeenPwned( + title = title, + message = message, + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModelTest.kt new file mode 100644 index 000000000..15ebb452c --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModelTest.kt @@ -0,0 +1,540 @@ +package com.x8bit.bitwarden.ui.auth.feature.completeregistration + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import app.cash.turbine.turbineScope +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_0 +import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_1 +import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_2 +import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_3 +import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_4 +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult +import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult +import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha +import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager +import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl +import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData +import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance +import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CloseClick +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.Internal.ReceivePasswordStrengthResult +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordHintChange +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordInputChange +import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@Suppress("LargeClass") +class CompleteRegistrationViewModelTest : BaseViewModelTest() { + + /** + * Saved state handle that has valid inputs. Useful for tests that want to test things + * after the user has entered all valid inputs. + */ + private val mockAuthRepository = mockk() + + private val fakeEnvironmentRepository = FakeEnvironmentRepository() + + private val specialCircumstanceManager: SpecialCircumstanceManager = + SpecialCircumstanceManagerImpl() + + private var viewmodelVerifyEmailCalled = false + + @BeforeEach + fun setUp() { + mockkStatic(::generateUriForCaptcha) + } + + @AfterEach + fun tearDown() { + unmockkStatic(::generateUriForCaptcha) + } + + @Test + fun `initial state should be correct`() { + val viewModel = createCompleteRegistrationViewModel(DEFAULT_STATE) + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + @Test + fun `onCleared should erase specialCircumstance`() = runTest { + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.CompleteRegistration( + completeRegistrationData = CompleteRegistrationData( + email = EMAIL, + verificationToken = TOKEN, + fromEmail = true, + ), + System.currentTimeMillis(), + ) + + val viewModel = CompleteRegistrationViewModel( + savedStateHandle = SavedStateHandle(mapOf("state" to DEFAULT_STATE)), + authRepository = mockAuthRepository, + environmentRepository = fakeEnvironmentRepository, + specialCircumstanceManager = specialCircumstanceManager, + ) + viewModel.onCleared() + assertTrue(specialCircumstanceManager.specialCircumstance == null) + } + + @Test + fun `CreateAccountClick with password below 12 chars should show password length dialog`() = + runTest { + val input = "abcdefghikl" + coEvery { + mockAuthRepository.getPasswordStrength(EMAIL, input) + } returns PasswordStrengthResult.Error + val viewModel = createCompleteRegistrationViewModel() + viewModel.trySendAction(PasswordInputChange(input)) + val expectedState = DEFAULT_STATE.copy( + passwordInput = input, + dialog = CompleteRegistrationDialog.Error( + BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.master_password_length_val_message_x.asText(12), + ), + ), + ) + viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick) + viewModel.stateFlow.test { + assertEquals(expectedState, awaitItem()) + } + } + + @Test + fun `CreateAccountClick with passwords not matching should show password match dialog`() = + runTest { + coEvery { + mockAuthRepository.getPasswordStrength(EMAIL, PASSWORD) + } returns PasswordStrengthResult.Error + val viewModel = createCompleteRegistrationViewModel() + viewModel.trySendAction(PasswordInputChange(PASSWORD)) + val expectedState = DEFAULT_STATE.copy( + userEmail = EMAIL, + passwordInput = PASSWORD, + dialog = CompleteRegistrationDialog.Error( + BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.master_password_confirmation_val_message.asText(), + ), + ), + ) + viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick) + viewModel.stateFlow.test { + assertEquals(expectedState, awaitItem()) + } + } + + @Test + fun `CreateAccountClick with all inputs valid should show and hide loading dialog`() = runTest { + val repo = mockk { + coEvery { + register( + email = EMAIL, + masterPassword = PASSWORD, + masterPasswordHint = null, + emailVerificationToken = TOKEN, + captchaToken = null, + shouldCheckDataBreaches = false, + isMasterPasswordStrong = true, + ) + } returns RegisterResult.Success(captchaToken = CAPTCHA_BYPASS_TOKEN) + } + val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE, repo) + turbineScope { + val stateFlow = viewModel.stateFlow.testIn(backgroundScope) + val eventFlow = viewModel.eventFlow.testIn(backgroundScope) + assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem()) + viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick) + assertEquals( + VALID_INPUT_STATE.copy(dialog = CompleteRegistrationDialog.Loading), + stateFlow.awaitItem(), + ) + assertEquals( + CompleteRegistrationEvent.NavigateToLanding, + eventFlow.awaitItem(), + ) + // Make sure loading dialog is hidden: + assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem()) + } + } + + @Test + fun `CreateAccountClick register returns error should update errorDialogState`() = runTest { + val repo = mockk { + coEvery { + register( + email = EMAIL, + masterPassword = PASSWORD, + masterPasswordHint = null, + emailVerificationToken = TOKEN, + captchaToken = null, + shouldCheckDataBreaches = false, + isMasterPasswordStrong = true, + ) + } returns RegisterResult.Error(errorMessage = "mock_error") + } + val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE, repo) + viewModel.stateFlow.test { + assertEquals(VALID_INPUT_STATE, awaitItem()) + viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick) + assertEquals( + VALID_INPUT_STATE.copy(dialog = CompleteRegistrationDialog.Loading), + awaitItem(), + ) + assertEquals( + VALID_INPUT_STATE.copy( + dialog = CompleteRegistrationDialog.Error( + BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = "mock_error".asText(), + ), + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `CreateAccountClick register returns Success should emit NavigateToLogin`() = runTest { + val repo = mockk { + coEvery { + register( + email = EMAIL, + masterPassword = PASSWORD, + masterPasswordHint = null, + emailVerificationToken = TOKEN, + captchaToken = null, + shouldCheckDataBreaches = false, + isMasterPasswordStrong = true, + ) + } returns RegisterResult.Success(captchaToken = CAPTCHA_BYPASS_TOKEN) + } + val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE, repo) + viewModel.eventFlow.test { + viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick) + assertEquals( + CompleteRegistrationEvent.NavigateToLanding, + awaitItem(), + ) + } + } + + @Test + fun `ContinueWithBreachedPasswordClick should call repository with checkDataBreaches false`() { + val repo = mockk { + coEvery { + register( + email = EMAIL, + masterPassword = PASSWORD, + masterPasswordHint = null, + emailVerificationToken = TOKEN, + captchaToken = null, + shouldCheckDataBreaches = false, + isMasterPasswordStrong = true, + ) + } returns RegisterResult.Error(null) + } + val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE, repo) + viewModel.trySendAction(CompleteRegistrationAction.ContinueWithBreachedPasswordClick) + coVerify { + repo.register( + email = EMAIL, + masterPassword = PASSWORD, + masterPasswordHint = null, + emailVerificationToken = TOKEN, + captchaToken = null, + shouldCheckDataBreaches = false, + isMasterPasswordStrong = true, + ) + } + } + + @Test + fun `CreateAccountClick register returns ShowDataBreaches should show HaveIBeenPwned dialog`() = + runTest { + mockAuthRepository.apply { + coEvery { + register( + email = EMAIL, + masterPassword = PASSWORD, + masterPasswordHint = null, + emailVerificationToken = TOKEN, + captchaToken = null, + shouldCheckDataBreaches = true, + isMasterPasswordStrong = true, + ) + } returns RegisterResult.DataBreachFound + } + val initialState = VALID_INPUT_STATE.copy( + isCheckDataBreachesToggled = true, + ) + val viewModel = createCompleteRegistrationViewModel( + completeRegistrationState = initialState, + ) + viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick) + viewModel.stateFlow.test { + assertEquals( + initialState.copy( + dialog = createHaveIBeenPwned( + title = R.string.exposed_master_password.asText(), + message = R.string.password_found_in_a_data_breach_alert_description + .asText(), + ), + ), + awaitItem(), + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `CreateAccountClick register returns DataBreachAndWeakPassword should show HaveIBeenPwned dialog`() = + runTest { + mockAuthRepository.apply { + coEvery { + register( + email = EMAIL, + masterPassword = PASSWORD, + masterPasswordHint = null, + emailVerificationToken = TOKEN, + captchaToken = null, + shouldCheckDataBreaches = true, + isMasterPasswordStrong = false, + ) + } returns RegisterResult.DataBreachAndWeakPassword + } + val initialState = VALID_INPUT_STATE.copy( + passwordStrengthState = PasswordStrengthState.WEAK_1, + isCheckDataBreachesToggled = true, + ) + + val viewModel = + createCompleteRegistrationViewModel(completeRegistrationState = initialState) + viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick) + viewModel.stateFlow.test { + assertEquals( + initialState.copy(dialog = createHaveIBeenPwned()), + awaitItem(), + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `CreateAccountClick register returns WeakPassword should show HaveIBeenPwned dialog`() = + runTest { + mockAuthRepository.apply { + coEvery { + register( + email = EMAIL, + masterPassword = PASSWORD, + masterPasswordHint = null, + emailVerificationToken = TOKEN, + captchaToken = null, + shouldCheckDataBreaches = true, + isMasterPasswordStrong = false, + ) + } returns RegisterResult.WeakPassword + } + val initialState = VALID_INPUT_STATE + .copy( + passwordStrengthState = PasswordStrengthState.WEAK_1, + isCheckDataBreachesToggled = true, + ) + val viewModel = + createCompleteRegistrationViewModel(completeRegistrationState = initialState) + viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick) + viewModel.stateFlow.test { + assertEquals( + initialState.copy( + dialog = createHaveIBeenPwned( + title = R.string.weak_master_password.asText(), + message = R.string.weak_password_identified_use_a_strong_password_to_protect_your_account.asText(), + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `CloseClick should emit NavigateBack`() = runTest { + val viewModel = createCompleteRegistrationViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(CloseClick) + assertEquals(CompleteRegistrationEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `On init should show toast if from email is true`() = runTest { + val viewModel = createCompleteRegistrationViewModel( + DEFAULT_STATE.copy(fromEmail = true), + ) + viewModel.eventFlow.test { + assertEquals( + CompleteRegistrationEvent.ShowToast(R.string.email_verified.asText()), + awaitItem(), + ) + } + } + + @Test + fun `ConfirmPasswordInputChange update passwordInput`() = runTest { + val viewModel = createCompleteRegistrationViewModel() + viewModel.trySendAction(ConfirmPasswordInputChange(PASSWORD)) + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE.copy(confirmPasswordInput = PASSWORD), awaitItem()) + } + } + + @Test + fun `PasswordHintChange update passwordInput`() = runTest { + val viewModel = createCompleteRegistrationViewModel() + viewModel.trySendAction(PasswordHintChange(PASSWORD)) + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE.copy(passwordHintInput = PASSWORD), awaitItem()) + } + } + + @Test + fun `PasswordInputChange update passwordInput and call getPasswordStrength`() = runTest { + coEvery { + mockAuthRepository.getPasswordStrength(EMAIL, PASSWORD) + } returns PasswordStrengthResult.Error + val viewModel = createCompleteRegistrationViewModel() + viewModel.trySendAction(PasswordInputChange(PASSWORD)) + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE.copy(passwordInput = PASSWORD), awaitItem()) + } + coVerify { mockAuthRepository.getPasswordStrength(EMAIL, PASSWORD) } + } + + @Test + fun `CheckDataBreachesToggle should change isCheckDataBreachesToggled`() = runTest { + val viewModel = createCompleteRegistrationViewModel() + viewModel.trySendAction(CompleteRegistrationAction.CheckDataBreachesToggle(true)) + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE.copy(isCheckDataBreachesToggled = true), awaitItem()) + } + } + + @Test + fun `ReceivePasswordStrengthResult should update password strength state`() = runTest { + val viewModel = createCompleteRegistrationViewModel() + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE.copy( + passwordStrengthState = PasswordStrengthState.NONE, + ), + awaitItem(), + ) + + viewModel.trySendAction( + ReceivePasswordStrengthResult(PasswordStrengthResult.Success(LEVEL_0)), + ) + assertEquals( + DEFAULT_STATE.copy( + passwordStrengthState = PasswordStrengthState.WEAK_1, + ), + awaitItem(), + ) + + viewModel.trySendAction( + ReceivePasswordStrengthResult(PasswordStrengthResult.Success(LEVEL_1)), + ) + assertEquals( + DEFAULT_STATE.copy( + passwordStrengthState = PasswordStrengthState.WEAK_2, + ), + awaitItem(), + ) + + viewModel.trySendAction( + ReceivePasswordStrengthResult(PasswordStrengthResult.Success(LEVEL_2)), + ) + assertEquals( + DEFAULT_STATE.copy( + passwordStrengthState = PasswordStrengthState.WEAK_3, + ), + awaitItem(), + ) + + viewModel.trySendAction( + ReceivePasswordStrengthResult(PasswordStrengthResult.Success(LEVEL_3)), + ) + assertEquals( + DEFAULT_STATE.copy( + passwordStrengthState = PasswordStrengthState.GOOD, + ), + awaitItem(), + ) + + viewModel.trySendAction( + ReceivePasswordStrengthResult(PasswordStrengthResult.Success(LEVEL_4)), + ) + assertEquals( + DEFAULT_STATE.copy( + passwordStrengthState = PasswordStrengthState.STRONG, + ), + awaitItem(), + ) + } + } + + private fun createCompleteRegistrationViewModel( + completeRegistrationState: CompleteRegistrationState? = DEFAULT_STATE, + authRepository: AuthRepository = mockAuthRepository, + ): CompleteRegistrationViewModel = + CompleteRegistrationViewModel( + savedStateHandle = SavedStateHandle(mapOf("state" to completeRegistrationState)), + authRepository = authRepository, + environmentRepository = fakeEnvironmentRepository, + specialCircumstanceManager = specialCircumstanceManager, + ) + + companion object { + private const val PASSWORD = "longenoughtpassword" + private const val EMAIL = "test@test.com" + private const val TOKEN = "token" + private const val CAPTCHA_BYPASS_TOKEN = "captcha_bypass" + private val DEFAULT_STATE = CompleteRegistrationState( + userEmail = EMAIL, + emailVerificationToken = TOKEN, + fromEmail = false, + passwordInput = "", + confirmPasswordInput = "", + passwordHintInput = "", + isCheckDataBreachesToggled = true, + dialog = null, + passwordStrengthState = PasswordStrengthState.NONE, + ) + private val VALID_INPUT_STATE = CompleteRegistrationState( + userEmail = EMAIL, + emailVerificationToken = TOKEN, + fromEmail = false, + passwordInput = PASSWORD, + confirmPasswordInput = PASSWORD, + passwordHintInput = "", + isCheckDataBreachesToggled = false, + dialog = null, + passwordStrengthState = PasswordStrengthState.GOOD, + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt index 0c4e57ed5..72bc69be4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput import androidx.core.net.toUri import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt index bf09b1ae8..0801b442e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt @@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt index dc5c89ab9..82c30db77 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt @@ -12,6 +12,9 @@ import kotlinx.coroutines.test.runTest import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset class RootNavScreenTest : BaseComposeTest() { private val fakeNavHostController = FakeNavHostController() @@ -82,6 +85,19 @@ class RootNavScreenTest : BaseComposeTest() { ) } + // Make sure navigating to complete registration route works as expected: + rootNavStateFlow.value = RootNavState.CompleteOngoingRegistration( + email = "example@email.com", + verificationToken = "verificationToken", + fromEmail = true, + timestamp = FIXED_CLOCK.millis(), + ) + composeTestRule.runOnIdle { + fakeNavHostController.assertLastNavigation( + route = "complete_registration/example@email.com/verificationToken/true", + ) + } + // Make sure navigating to vault locked works as expected: rootNavStateFlow.value = RootNavState.VaultLocked composeTestRule.runOnIdle { @@ -203,3 +219,8 @@ class RootNavScreenTest : BaseComposeTest() { } } } + +private val FIXED_CLOCK: Clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt index 585d2e165..e421c20ae 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow -import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthState +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt index 5860224b0..983700729 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt @@ -19,7 +19,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy import com.x8bit.bitwarden.data.vault.manager.FileManager import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult -import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthState +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat