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, onNavigateBack: () -> Unit,
onNavigateToPasswordGuidance: () -> Unit, onNavigateToPasswordGuidance: () -> Unit,
onNavigateToPreventAccountLockout: () -> Unit, onNavigateToPreventAccountLockout: () -> Unit,
onNavigateToLogin: (email: String, token: String) -> Unit, onNavigateToLogin: (email: String, token: String?) -> Unit,
) { ) {
composableWithSlideTransitions( composableWithSlideTransitions(
route = COMPLETE_REGISTRATION_ROUTE, route = COMPLETE_REGISTRATION_ROUTE,

View file

@ -73,7 +73,7 @@ fun CompleteRegistrationScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToPasswordGuidance: () -> Unit, onNavigateToPasswordGuidance: () -> Unit,
onNavigateToPreventAccountLockout: () -> Unit, onNavigateToPreventAccountLockout: () -> Unit,
onNavigateToLogin: (email: String, token: String) -> Unit, onNavigateToLogin: (email: String, token: String?) -> Unit,
viewModel: CompleteRegistrationViewModel = hiltViewModel(), viewModel: CompleteRegistrationViewModel = hiltViewModel(),
) { ) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle() 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.R
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength 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.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.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
@ -106,18 +107,14 @@ class CompleteRegistrationViewModel @Inject constructor(
override fun handleAction(action: CompleteRegistrationAction) { override fun handleAction(action: CompleteRegistrationAction) {
when (action) { when (action) {
is Internal -> handleInternalAction(action)
is ConfirmPasswordInputChange -> handleConfirmPasswordInputChanged(action) is ConfirmPasswordInputChange -> handleConfirmPasswordInputChanged(action)
is PasswordHintChange -> handlePasswordHintChanged(action) is PasswordHintChange -> handlePasswordHintChanged(action)
is PasswordInputChange -> handlePasswordInputChanged(action) is PasswordInputChange -> handlePasswordInputChanged(action)
is BackClick -> handleBackClicked() is BackClick -> handleBackClicked()
is ErrorDialogDismiss -> handleDialogDismiss() is ErrorDialogDismiss -> handleDialogDismiss()
is CheckDataBreachesToggle -> handleCheckDataBreachesToggle(action) is CheckDataBreachesToggle -> handleCheckDataBreachesToggle(action)
is Internal.ReceiveRegisterResult -> {
handleReceiveRegisterAccountResult(action)
}
ContinueWithBreachedPasswordClick -> handleContinueWithBreachedPasswordClick() ContinueWithBreachedPasswordClick -> handleContinueWithBreachedPasswordClick()
is ReceivePasswordStrengthResult -> handlePasswordStrengthResult(action)
CompleteRegistrationAction.LearnToPreventLockoutClick -> { CompleteRegistrationAction.LearnToPreventLockoutClick -> {
handlePreventAccountLockoutClickAction() handlePreventAccountLockoutClickAction()
} }
@ -127,10 +124,16 @@ class CompleteRegistrationViewModel @Inject constructor(
} }
CompleteRegistrationAction.CallToActionClick -> handleCallToActionClick() 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.UpdateOnboardingFeatureState -> handleUpdateOnboardingFeatureState(action)
is Internal.GeneratedPasswordResult -> handleGeneratedPasswordResult( is Internal.ReceiveLoginResult -> handleLoginResult(action)
action,
)
} }
} }
@ -215,13 +218,19 @@ class CompleteRegistrationViewModel @Inject constructor(
} }
is RegisterResult.Success -> { is RegisterResult.Success -> {
mutableStateFlow.update { it.copy(dialog = null) } viewModelScope.launch {
sendEvent( val loginResult = authRepository.login(
CompleteRegistrationEvent.NavigateToLogin(
email = state.userEmail, email = state.userEmail,
password = state.passwordInput,
captchaToken = registerAccountResult.captchaToken, captchaToken = registerAccountResult.captchaToken,
), )
) sendAction(
Internal.ReceiveLoginResult(
loginResult = loginResult,
captchaToken = registerAccountResult.captchaToken,
),
)
}
} }
RegisterResult.DataBreachFound -> { 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) { private fun handleCheckDataBreachesToggle(action: CheckDataBreachesToggle) {
mutableStateFlow.update { mutableStateFlow.update {
it.copy(isCheckDataBreachesToggled = action.newState) it.copy(isCheckDataBreachesToggled = action.newState)
@ -266,9 +294,7 @@ class CompleteRegistrationViewModel @Inject constructor(
} }
private fun handleDialogDismiss() { private fun handleDialogDismiss() {
mutableStateFlow.update { clearDialogState()
it.copy(dialog = null)
}
} }
private fun handleBackClicked() { 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( data class NavigateToLogin(
val email: String, val email: String,
val captchaToken: String, val captchaToken: String?,
) : CompleteRegistrationEvent() ) : CompleteRegistrationEvent()
} }
@ -595,5 +627,17 @@ sealed class CompleteRegistrationAction {
* Indicates a generated password has been received. * Indicates a generated password has been received.
*/ */
data class GeneratedPasswordResult(val generatedPassword: String) : Internal() 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="yes">Yes</string>
<string name="account">Account</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">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="add_an_item">Add an Item</string>
<string name="app_extension">App extension</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> <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_3
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_4 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.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.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.UserState
@ -58,6 +59,25 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
private val mutableUserStateFlow = MutableStateFlow<UserState?>(null) private val mutableUserStateFlow = MutableStateFlow<UserState?>(null)
private val mockAuthRepository = mockk<AuthRepository>() { private val mockAuthRepository = mockk<AuthRepository>() {
every { userStateFlow } returns mutableUserStateFlow 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() private val fakeEnvironmentRepository = FakeEnvironmentRepository()
@ -119,10 +139,10 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
} }
@Test @Test
fun `CallToActionClick with all inputs valid should show and hide loading dialog`() = runTest { fun `CallToActionClick with all inputs valid should account created toast and hide dialog`() =
val repo = mockk<AuthRepository> { runTest {
coEvery { coEvery {
register( mockAuthRepository.register(
email = EMAIL, email = EMAIL,
masterPassword = PASSWORD, masterPassword = PASSWORD,
masterPasswordHint = null, masterPasswordHint = null,
@ -132,45 +152,39 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
isMasterPasswordStrong = true, isMasterPasswordStrong = true,
) )
} returns RegisterResult.Success(captchaToken = CAPTCHA_BYPASS_TOKEN) } 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 @Test
fun `CallToActionClick register returns error should update errorDialogState`() = runTest { fun `CallToActionClick register returns error should update errorDialogState`() = runTest {
val repo = mockk<AuthRepository> { coEvery {
coEvery { mockAuthRepository.register(
register( email = EMAIL,
email = EMAIL, masterPassword = PASSWORD,
masterPassword = PASSWORD, masterPasswordHint = null,
masterPasswordHint = null, emailVerificationToken = TOKEN,
emailVerificationToken = TOKEN, captchaToken = null,
captchaToken = null, shouldCheckDataBreaches = false,
shouldCheckDataBreaches = false, isMasterPasswordStrong = true,
isMasterPasswordStrong = true, )
) } returns RegisterResult.Error(errorMessage = "mock_error")
} returns RegisterResult.Error(errorMessage = "mock_error") val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE)
}
val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE, repo)
viewModel.stateFlow.test { viewModel.stateFlow.test {
assertEquals(VALID_INPUT_STATE, awaitItem()) assertEquals(VALID_INPUT_STATE, awaitItem())
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick) viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
@ -193,52 +207,68 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
} }
@Test @Test
fun `CallToActionClick register returns Success should emit NavigateToLogin`() = runTest { fun `CallToActionClick register returns Success should attempt login`() = runTest {
val repo = mockk<AuthRepository> { val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE)
coEvery { viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
register( coVerify {
email = EMAIL, mockAuthRepository.login(
masterPassword = PASSWORD, email = EMAIL,
masterPasswordHint = null, password = PASSWORD,
emailVerificationToken = TOKEN, captchaToken = CAPTCHA_BYPASS_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(),
) )
} }
} }
@Test @Test
fun `ContinueWithBreachedPasswordClick should call repository with checkDataBreaches false`() { fun `when login attempt returns success should wait for state based navigation`() = runTest {
val repo = mockk<AuthRepository> { val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE)
coEvery { viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
register( viewModel.eventFlow.test {
email = EMAIL, assertTrue(awaitItem() is CompleteRegistrationEvent.ShowToast)
masterPassword = PASSWORD, expectNoEvents()
masterPasswordHint = null,
emailVerificationToken = TOKEN,
captchaToken = null,
shouldCheckDataBreaches = false,
isMasterPasswordStrong = true,
)
} returns RegisterResult.Error(null)
} }
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) viewModel.trySendAction(CompleteRegistrationAction.ContinueWithBreachedPasswordClick)
coVerify { coVerify {
repo.register( mockAuthRepository.register(
email = EMAIL, email = EMAIL,
masterPassword = PASSWORD, masterPassword = PASSWORD,
masterPasswordHint = null, masterPasswordHint = null,
@ -632,14 +662,13 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
private fun createCompleteRegistrationViewModel( private fun createCompleteRegistrationViewModel(
completeRegistrationState: CompleteRegistrationState? = DEFAULT_STATE, completeRegistrationState: CompleteRegistrationState? = DEFAULT_STATE,
authRepository: AuthRepository = mockAuthRepository,
): CompleteRegistrationViewModel = CompleteRegistrationViewModel( ): CompleteRegistrationViewModel = CompleteRegistrationViewModel(
savedStateHandle = SavedStateHandle( savedStateHandle = SavedStateHandle(
mapOf( mapOf(
"state" to completeRegistrationState, "state" to completeRegistrationState,
), ),
), ),
authRepository = authRepository, authRepository = mockAuthRepository,
environmentRepository = fakeEnvironmentRepository, environmentRepository = fakeEnvironmentRepository,
specialCircumstanceManager = specialCircumstanceManager, specialCircumstanceManager = specialCircumstanceManager,
featureFlagManager = featureFlagManager, featureFlagManager = featureFlagManager,