mirror of
https://github.com/bitwarden/android.git
synced 2024-11-22 01:16:02 +03:00
[PM-6702] 6# Complete registration screen (#3622)
This commit is contained in:
parent
72e5aedccd
commit
acb125b2b9
19 changed files with 1660 additions and 11 deletions
|
@ -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,
|
||||
|
|
|
@ -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() },
|
||||
|
|
|
@ -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<String>(EMAIL_ADDRESS)),
|
||||
verificationToken = checkNotNull(savedStateHandle.get<String>(VERIFICATION_TOKEN)),
|
||||
fromEmail = checkNotNull(savedStateHandle.get<Boolean>(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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<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,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<IntentManager>(relaxed = true) {
|
||||
every { startCustomTabsActivity(any()) } just runs
|
||||
every { startActivity(any()) } just runs
|
||||
}
|
||||
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<CompleteRegistrationEvent>()
|
||||
private val viewModel = mockk<CompleteRegistrationViewModel>(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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -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<AuthRepository>()
|
||||
|
||||
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<AuthRepository> {
|
||||
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<AuthRepository> {
|
||||
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<AuthRepository> {
|
||||
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<AuthRepository> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue