PM-11387 on new create account with email verification, attempt login… (#3842)

This commit is contained in:
Dave Severns 2024-08-29 14:07:42 -04:00 committed by GitHub
parent 3c39d8beac
commit 17c579bfc2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 171 additions and 97 deletions

View file

@ -54,7 +54,7 @@ fun NavGraphBuilder.completeRegistrationDestination(
onNavigateBack: () -> Unit,
onNavigateToPasswordGuidance: () -> Unit,
onNavigateToPreventAccountLockout: () -> Unit,
onNavigateToLogin: (email: String, token: String) -> Unit,
onNavigateToLogin: (email: String, token: String?) -> Unit,
) {
composableWithSlideTransitions(
route = COMPLETE_REGISTRATION_ROUTE,

View file

@ -73,7 +73,7 @@ fun CompleteRegistrationScreen(
onNavigateBack: () -> Unit,
onNavigateToPasswordGuidance: () -> Unit,
onNavigateToPreventAccountLockout: () -> Unit,
onNavigateToLogin: (email: String, token: String) -> Unit,
onNavigateToLogin: (email: String, token: String?) -> Unit,
viewModel: CompleteRegistrationViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()

View file

@ -6,6 +6,7 @@ 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.LoginResult
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.FeatureFlagManager
@ -106,18 +107,14 @@ class CompleteRegistrationViewModel @Inject constructor(
override fun handleAction(action: CompleteRegistrationAction) {
when (action) {
is Internal -> handleInternalAction(action)
is ConfirmPasswordInputChange -> handleConfirmPasswordInputChanged(action)
is PasswordHintChange -> handlePasswordHintChanged(action)
is PasswordInputChange -> handlePasswordInputChanged(action)
is BackClick -> handleBackClicked()
is ErrorDialogDismiss -> handleDialogDismiss()
is CheckDataBreachesToggle -> handleCheckDataBreachesToggle(action)
is Internal.ReceiveRegisterResult -> {
handleReceiveRegisterAccountResult(action)
}
ContinueWithBreachedPasswordClick -> handleContinueWithBreachedPasswordClick()
is ReceivePasswordStrengthResult -> handlePasswordStrengthResult(action)
CompleteRegistrationAction.LearnToPreventLockoutClick -> {
handlePreventAccountLockoutClickAction()
}
@ -127,10 +124,16 @@ class CompleteRegistrationViewModel @Inject constructor(
}
CompleteRegistrationAction.CallToActionClick -> handleCallToActionClick()
}
}
private fun handleInternalAction(action: Internal) {
when (action) {
is Internal.GeneratedPasswordResult -> handleGeneratedPasswordResult(action)
is ReceivePasswordStrengthResult -> handlePasswordStrengthResult(action)
is Internal.ReceiveRegisterResult -> handleReceiveRegisterAccountResult(action)
is Internal.UpdateOnboardingFeatureState -> handleUpdateOnboardingFeatureState(action)
is Internal.GeneratedPasswordResult -> handleGeneratedPasswordResult(
action,
)
is Internal.ReceiveLoginResult -> handleLoginResult(action)
}
}
@ -215,13 +218,19 @@ class CompleteRegistrationViewModel @Inject constructor(
}
is RegisterResult.Success -> {
mutableStateFlow.update { it.copy(dialog = null) }
sendEvent(
CompleteRegistrationEvent.NavigateToLogin(
viewModelScope.launch {
val loginResult = authRepository.login(
email = state.userEmail,
password = state.passwordInput,
captchaToken = registerAccountResult.captchaToken,
),
)
)
sendAction(
Internal.ReceiveLoginResult(
loginResult = loginResult,
captchaToken = registerAccountResult.captchaToken,
),
)
}
}
RegisterResult.DataBreachFound -> {
@ -259,6 +268,25 @@ class CompleteRegistrationViewModel @Inject constructor(
}
}
private fun handleLoginResult(action: Internal.ReceiveLoginResult) {
clearDialogState()
sendEvent(
CompleteRegistrationEvent.ShowToast(
message = R.string.account_created_success.asText(),
),
)
// If the login result is Success the state based navigation will take care of it.
// otherwise we need to navigate to the login screen.
if (action.loginResult !is LoginResult.Success) {
sendEvent(
CompleteRegistrationEvent.NavigateToLogin(
email = state.userEmail,
captchaToken = action.captchaToken,
),
)
}
}
private fun handleCheckDataBreachesToggle(action: CheckDataBreachesToggle) {
mutableStateFlow.update {
it.copy(isCheckDataBreachesToggled = action.newState)
@ -266,9 +294,7 @@ class CompleteRegistrationViewModel @Inject constructor(
}
private fun handleDialogDismiss() {
mutableStateFlow.update {
it.copy(dialog = null)
}
clearDialogState()
}
private fun handleBackClicked() {
@ -393,6 +419,12 @@ class CompleteRegistrationViewModel @Inject constructor(
}
}
}
private fun clearDialogState() {
mutableStateFlow.update {
it.copy(dialog = null)
}
}
}
/**
@ -509,7 +541,7 @@ sealed class CompleteRegistrationEvent {
*/
data class NavigateToLogin(
val email: String,
val captchaToken: String,
val captchaToken: String?,
) : CompleteRegistrationEvent()
}
@ -595,5 +627,17 @@ sealed class CompleteRegistrationAction {
* Indicates a generated password has been received.
*/
data class GeneratedPasswordResult(val generatedPassword: String) : Internal()
/**
* Indicates registration was successful and will now attempt to login and unlock the vault.
* @property captchaToken The captcha token to use for login. With the login function this
* is possible to be negative.
*
* @see [AuthRepository.login]
*/
data class ReceiveLoginResult(
val loginResult: LoginResult,
val captchaToken: String?,
) : Internal()
}
}

View file

@ -82,6 +82,7 @@
<string name="yes">Yes</string>
<string name="account">Account</string>
<string name="account_created">Your new account has been created! You may now log in.</string>
<string name="account_created_success">Your new account has been created!</string>
<string name="add_an_item">Add an Item</string>
<string name="app_extension">App extension</string>
<string name="autofill_accessibility_description">Use the Bitwarden accessibility service to auto-fill your logins across apps and the web.</string>

View file

@ -10,6 +10,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL
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.LoginResult
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.model.UserState
@ -58,6 +59,25 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
private val mutableUserStateFlow = MutableStateFlow<UserState?>(null)
private val mockAuthRepository = mockk<AuthRepository>() {
every { userStateFlow } returns mutableUserStateFlow
coEvery {
login(
email = any(),
password = any(),
captchaToken = any(),
)
} returns LoginResult.Success
coEvery {
register(
email = any(),
masterPassword = any(),
masterPasswordHint = any(),
emailVerificationToken = any(),
captchaToken = any(),
shouldCheckDataBreaches = any(),
isMasterPasswordStrong = any(),
)
} returns RegisterResult.Success(captchaToken = CAPTCHA_BYPASS_TOKEN)
}
private val fakeEnvironmentRepository = FakeEnvironmentRepository()
@ -119,10 +139,10 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
}
@Test
fun `CallToActionClick with all inputs valid should show and hide loading dialog`() = runTest {
val repo = mockk<AuthRepository> {
fun `CallToActionClick with all inputs valid should account created toast and hide dialog`() =
runTest {
coEvery {
register(
mockAuthRepository.register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
@ -132,45 +152,39 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
isMasterPasswordStrong = true,
)
} returns RegisterResult.Success(captchaToken = CAPTCHA_BYPASS_TOKEN)
val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE)
turbineScope {
val stateFlow = viewModel.stateFlow.testIn(backgroundScope)
val eventFlow = viewModel.eventFlow.testIn(backgroundScope)
assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem())
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
assertEquals(
VALID_INPUT_STATE.copy(dialog = CompleteRegistrationDialog.Loading),
stateFlow.awaitItem(),
)
assertEquals(
CompleteRegistrationEvent.ShowToast(R.string.account_created_success.asText()),
eventFlow.awaitItem(),
)
// Make sure loading dialog is hidden:
assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem())
}
}
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.CallToActionClick)
assertEquals(
VALID_INPUT_STATE.copy(dialog = CompleteRegistrationDialog.Loading),
stateFlow.awaitItem(),
)
assertEquals(
CompleteRegistrationEvent.NavigateToLogin(
EMAIL,
CAPTCHA_BYPASS_TOKEN,
),
eventFlow.awaitItem(),
)
// Make sure loading dialog is hidden:
assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem())
}
}
@Test
fun `CallToActionClick 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)
coEvery {
mockAuthRepository.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)
viewModel.stateFlow.test {
assertEquals(VALID_INPUT_STATE, awaitItem())
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
@ -193,52 +207,68 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
}
@Test
fun `CallToActionClick 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.CallToActionClick)
assertEquals(
CompleteRegistrationEvent.NavigateToLogin(
EMAIL,
CAPTCHA_BYPASS_TOKEN,
),
awaitItem(),
fun `CallToActionClick register returns Success should attempt login`() = runTest {
val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE)
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
coVerify {
mockAuthRepository.login(
email = EMAIL,
password = PASSWORD,
captchaToken = CAPTCHA_BYPASS_TOKEN,
)
}
}
@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)
fun `when login attempt returns success should wait for state based navigation`() = runTest {
val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE)
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
viewModel.eventFlow.test {
assertTrue(awaitItem() is CompleteRegistrationEvent.ShowToast)
expectNoEvents()
}
val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE, repo)
}
@Suppress("MaxLineLength")
@Test
fun `when login attempt returns anything other than success should send navigate to login event`() =
runTest {
coEvery {
mockAuthRepository.login(
email = EMAIL,
password = PASSWORD,
captchaToken = CAPTCHA_BYPASS_TOKEN,
)
} returns LoginResult.TwoFactorRequired
val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE)
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
viewModel.eventFlow.test {
assertTrue(awaitItem() is CompleteRegistrationEvent.ShowToast)
assertEquals(
CompleteRegistrationEvent.NavigateToLogin(EMAIL, CAPTCHA_BYPASS_TOKEN),
awaitItem(),
)
}
}
@Test
fun `ContinueWithBreachedPasswordClick should call repository with checkDataBreaches false`() {
coEvery {
mockAuthRepository.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)
viewModel.trySendAction(CompleteRegistrationAction.ContinueWithBreachedPasswordClick)
coVerify {
repo.register(
mockAuthRepository.register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
@ -632,14 +662,13 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
private fun createCompleteRegistrationViewModel(
completeRegistrationState: CompleteRegistrationState? = DEFAULT_STATE,
authRepository: AuthRepository = mockAuthRepository,
): CompleteRegistrationViewModel = CompleteRegistrationViewModel(
savedStateHandle = SavedStateHandle(
mapOf(
"state" to completeRegistrationState,
),
),
authRepository = authRepository,
authRepository = mockAuthRepository,
environmentRepository = fakeEnvironmentRepository,
specialCircumstanceManager = specialCircumstanceManager,
featureFlagManager = featureFlagManager,