From 886431534224b38b3f45958e27ea791ce2b22dad Mon Sep 17 00:00:00 2001 From: Andrew Haisting <142518658+ahaisting-livefront@users.noreply.github.com> Date: Tue, 24 Oct 2023 11:36:11 -0500 Subject: [PATCH] BIT-189 Check for data breaches during create account (#154) --- .../network/api/HaveIBeenPwnedApi.kt | 18 +++ .../datasource/network/di/NetworkModule.kt | 13 ++ .../network/service/HaveIBeenPwnedService.kt | 14 ++ .../service/HaveIBeenPwnedServiceImpl.kt | 36 +++++ .../data/auth/repository/AuthRepository.kt | 1 + .../auth/repository/AuthRepositoryImpl.kt | 17 +++ .../auth/repository/di/RepositoryModule.kt | 6 +- .../auth/repository/model/RegisterResult.kt | 5 + .../createaccount/CreateAccountScreen.kt | 54 ++++++- .../createaccount/CreateAccountViewModel.kt | 105 +++++++++---- .../components/BitwardenTwoButtonDialog.kt | 61 ++++++++ .../service/HaveIBeenPwnedServiceTest.kt | 48 ++++++ .../auth/repository/AuthRepositoryTest.kt | 76 +++++++++- .../createaccount/CreateAccountScreenTest.kt | 66 ++++++-- .../CreateAccountViewModelTest.kt | 141 +++++++++++++----- 15 files changed, 577 insertions(+), 84 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/HaveIBeenPwnedApi.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedService.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedServiceImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTwoButtonDialog.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedServiceTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/HaveIBeenPwnedApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/HaveIBeenPwnedApi.kt new file mode 100644 index 000000000..27d8d89b1 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/HaveIBeenPwnedApi.kt @@ -0,0 +1,18 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.api + +import okhttp3.ResponseBody +import retrofit2.http.GET +import retrofit2.http.Path + +/** + * Defines endpoints for the "have I been pwned" API. For docs see + * https://haveibeenpwned.com/API/v2. + */ +interface HaveIBeenPwnedApi { + + @GET("/range/{hashPrefix}") + suspend fun fetchBreachedPasswords( + @Path("hashPrefix") + hashPrefix: String, + ): Result +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/NetworkModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/NetworkModule.kt index 664e08d23..4deb278d4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/NetworkModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/NetworkModule.kt @@ -2,6 +2,8 @@ package com.x8bit.bitwarden.data.auth.datasource.network.di import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsServiceImpl +import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService +import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedServiceImpl import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityServiceImpl import com.x8bit.bitwarden.data.platform.datasource.network.di.NetworkModule @@ -35,4 +37,15 @@ object NetworkModule { @Named(NetworkModule.UNAUTHORIZED) retrofit: Retrofit, json: Json, ): IdentityService = IdentityServiceImpl(retrofit.create(), json) + + @Provides + @Singleton + fun providesHaveIBeenPwnedService( + @Named(NetworkModule.UNAUTHORIZED) retrofit: Retrofit, + ): HaveIBeenPwnedService = HaveIBeenPwnedServiceImpl( + retrofit.newBuilder() + .baseUrl("https://api.pwnedpasswords.com") + .build() + .create(), + ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedService.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedService.kt new file mode 100644 index 000000000..75fdf3f0b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedService.kt @@ -0,0 +1,14 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.service + +/** + * Defines methods for interacting with the have I been pwned API. + */ +interface HaveIBeenPwnedService { + + /** + * Check to see if the given password has been breached. Returns true if breached. + */ + suspend fun hasPasswordBeenBreached( + password: String, + ): Result +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedServiceImpl.kt new file mode 100644 index 000000000..f6e2e9d6c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedServiceImpl.kt @@ -0,0 +1,36 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.service + +import com.x8bit.bitwarden.data.auth.datasource.network.api.HaveIBeenPwnedApi +import java.security.MessageDigest + +class HaveIBeenPwnedServiceImpl(private val api: HaveIBeenPwnedApi) : HaveIBeenPwnedService { + + @Suppress("MagicNumber") + override suspend fun hasPasswordBeenBreached(password: String): Result { + // Hash the password: + val hashedPassword = MessageDigest + .getInstance("SHA-1") + .digest(password.toByteArray()) + .joinToString(separator = "", transform = { "%02x".format(it) }) + // Take just the prefix to send to the API: + val hashPrefix = hashedPassword.substring(0, 5) + + return api + .fetchBreachedPasswords(hashPrefix = hashPrefix) + .mapCatching { responseBody -> + val allPwnedPasswords = responseBody.string() + // First split the response by newline: each hashed password is on a new line. + .split("\r\n") + .map { pwnedSuffix -> + // Then remove everything after the ":", since we only want the pwned hash: + // Before: 20d61603aba324bf08799896110561f05e1ad3be:12 + // After: 20d61603aba324bf08799896110561f05e1ad3be + pwnedSuffix.substring(0, endIndex = pwnedSuffix.indexOf(":")) + } + // Then see if any of those passwords match our full password hash: + allPwnedPasswords.any { pwnedSuffix -> + (hashPrefix + pwnedSuffix).equals(hashedPassword, ignoreCase = true) + } + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index db8396529..454357292 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -50,6 +50,7 @@ interface AuthRepository { masterPassword: String, masterPasswordHint: String?, captchaToken: String?, + shouldCheckDataBreaches: Boolean, ): RegisterResult /** diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index c50784e09..36596352d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -8,6 +8,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJs import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService +import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson @@ -35,9 +36,11 @@ private const val DEFAULT_KDF_ITERATIONS = 600000 /** * Default implementation of [AuthRepository]. */ +@Suppress("LongParameterList") @Singleton class AuthRepositoryImpl @Inject constructor( private val accountsService: AccountsService, + private val haveIBeenPwnedService: HaveIBeenPwnedService, private val identityService: IdentityService, private val authSdkSource: AuthSdkSource, private val authDiskSource: AuthDiskSource, @@ -143,12 +146,26 @@ class AuthRepositoryImpl @Inject constructor( } } + @Suppress("ReturnCount") override suspend fun register( email: String, masterPassword: String, masterPasswordHint: String?, captchaToken: String?, + shouldCheckDataBreaches: Boolean, ): RegisterResult { + if (shouldCheckDataBreaches) { + haveIBeenPwnedService + .hasPasswordBeenBreached(password = masterPassword) + .fold( + onFailure = { return RegisterResult.Error(null) }, + onSuccess = { foundDataBreaches -> + if (foundDataBreaches) { + return RegisterResult.DataBreachFound + } + }, + ) + } val kdf = Kdf.Pbkdf2(DEFAULT_KDF_ITERATIONS.toUInt()) return authSdkSource .makeRegisterKeys( diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/RepositoryModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/RepositoryModule.kt index fc47717cb..b759b3008 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/RepositoryModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/RepositoryModule.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository.di import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService +import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.repository.AuthRepository @@ -22,9 +23,11 @@ object RepositoryModule { @Provides @Singleton - fun bindsAuthRepository( + @Suppress("LongParameterList") + fun providesAuthRepository( accountsService: AccountsService, identityService: IdentityService, + haveIBeenPwnedService: HaveIBeenPwnedService, authSdkSource: AuthSdkSource, authDiskSource: AuthDiskSource, ): AuthRepository = AuthRepositoryImpl( @@ -32,6 +35,7 @@ object RepositoryModule { identityService = identityService, authSdkSource = authSdkSource, authDiskSource = authDiskSource, + haveIBeenPwnedService = haveIBeenPwnedService, dispatcher = Dispatchers.IO, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/RegisterResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/RegisterResult.kt index 251a5c6dc..71a2babbb 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/RegisterResult.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/RegisterResult.kt @@ -24,4 +24,9 @@ sealed class RegisterResult { * @param errorMessage a message describing the error. */ data class Error(val errorMessage: String?) : RegisterResult() + + /** + * Password hash was found in a data breach. + */ + data object DataBreachFound : RegisterResult() } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt index 3dfaa107d..388fae9d9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt @@ -45,6 +45,7 @@ import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.Acc import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange +import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ContinueWithBreachedPasswordClick import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.EmailInputChange import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ErrorDialogDismiss import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordHintChange @@ -56,12 +57,15 @@ import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountEvent.Navi import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountEvent.NavigateToTerms import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler +import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField import com.x8bit.bitwarden.ui.platform.components.BitwardenSwitch import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButtonTopAppBar import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField +import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog +import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState import com.x8bit.bitwarden.ui.platform.theme.clickableSpanStyle /** @@ -104,13 +108,49 @@ fun CreateAccountScreen( } } } - BitwardenBasicDialog( - visibilityState = state.errorDialogState, - onDismissRequest = remember(viewModel) { { viewModel.trySendAction(ErrorDialogDismiss) } }, - ) - BitwardenLoadingDialog( - visibilityState = state.loadingDialogState, - ) + + val haveIBeenPwnedMessage = remember { + R.string.weak_password_identified_and_found_in_a_data_breach_alert_description.asText() + } + + // Show dialog if needed: + when (val dialog = state.dialog) { + is CreateAccountDialog.Error -> { + BitwardenBasicDialog( + visibilityState = dialog.state, + onDismissRequest = remember(viewModel) { + { viewModel.trySendAction(ErrorDialogDismiss) } + }, + ) + } + + CreateAccountDialog.HaveIBeenPwned -> { + BitwardenTwoButtonDialog( + title = R.string.weak_and_exposed_master_password.asText(), + message = haveIBeenPwnedMessage, + confirmButtonText = R.string.yes.asText(), + dismissButtonText = R.string.no.asText(), + onConfirmClick = remember(viewModel) { + { viewModel.trySendAction(ContinueWithBreachedPasswordClick) } + }, + onDismissClick = remember(viewModel) { + { viewModel.trySendAction(ErrorDialogDismiss) } + }, + onDismissRequest = remember(viewModel) { + { viewModel.trySendAction(ErrorDialogDismiss) } + }, + ) + } + + CreateAccountDialog.Loading -> { + BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown(R.string.create_account.asText()), + ) + } + + null -> Unit + } + Column( modifier = Modifier .fillMaxSize() diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt index 92f299d6e..e73a970a9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt @@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha 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 +import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ContinueWithBreachedPasswordClick import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.EmailInputChange import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordHintChange import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordInputChange @@ -22,7 +23,6 @@ import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.isValidEmail import com.x8bit.bitwarden.ui.platform.components.BasicDialogState -import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -51,8 +51,7 @@ class CreateAccountViewModel @Inject constructor( passwordHintInput = "", isAcceptPoliciesToggled = false, isCheckDataBreachesToggled = false, - errorDialogState = BasicDialogState.Hidden, - loadingDialogState = LoadingDialogState.Hidden, + dialog = null, ), ) { @@ -93,6 +92,8 @@ class CreateAccountViewModel @Inject constructor( is CreateAccountAction.Internal.ReceiveCaptchaToken -> { handleReceiveCaptchaToken(action) } + + ContinueWithBreachedPasswordClick -> handleContinueWithBreachedPasswordClick() } } @@ -103,16 +104,21 @@ class CreateAccountViewModel @Inject constructor( is CaptchaCallbackTokenResult.MissingToken -> { mutableStateFlow.update { it.copy( - errorDialogState = BasicDialogState.Shown( - title = R.string.an_error_has_occurred.asText(), - message = R.string.captcha_failed.asText(), + dialog = CreateAccountDialog.Error( + BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.captcha_failed.asText(), + ), ), ) } } is CaptchaCallbackTokenResult.Success -> { - submitRegisterAccountRequest(captchaToken = result.token) + submitRegisterAccountRequest( + shouldCheckForDataBreaches = false, + captchaToken = result.token, + ) } } } @@ -122,7 +128,7 @@ class CreateAccountViewModel @Inject constructor( ) { when (val registerAccountResult = action.registerResult) { is RegisterResult.CaptchaRequired -> { - mutableStateFlow.update { it.copy(loadingDialogState = LoadingDialogState.Hidden) } + mutableStateFlow.update { it.copy(dialog = null) } sendEvent( CreateAccountEvent.NavigateToCaptcha( uri = generateUriForCaptcha(captchaId = registerAccountResult.captchaId), @@ -134,18 +140,19 @@ class CreateAccountViewModel @Inject constructor( // TODO parse and display server errors BIT-910 mutableStateFlow.update { it.copy( - loadingDialogState = LoadingDialogState.Hidden, - errorDialogState = BasicDialogState.Shown( - title = R.string.an_error_has_occurred.asText(), - message = registerAccountResult.errorMessage?.asText() - ?: R.string.generic_error_message.asText(), + dialog = CreateAccountDialog.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(loadingDialogState = LoadingDialogState.Hidden) } + mutableStateFlow.update { it.copy(dialog = null) } sendEvent( CreateAccountEvent.NavigateToLogin( email = mutableStateFlow.value.emailInput, @@ -153,6 +160,12 @@ class CreateAccountViewModel @Inject constructor( ), ) } + + RegisterResult.DataBreachFound -> { + mutableStateFlow.update { + it.copy(dialog = CreateAccountDialog.HaveIBeenPwned) + } + } } } @@ -174,7 +187,7 @@ class CreateAccountViewModel @Inject constructor( private fun handleDialogDismiss() { mutableStateFlow.update { - it.copy(errorDialogState = BasicDialogState.Hidden) + it.copy(dialog = null) } } @@ -205,7 +218,7 @@ class CreateAccountViewModel @Inject constructor( message = R.string.validation_field_required .asText(R.string.email_address.asText()), ) - mutableStateFlow.update { it.copy(errorDialogState = dialog) } + mutableStateFlow.update { it.copy(dialog = CreateAccountDialog.Error(dialog)) } } !mutableStateFlow.value.emailInput.isValidEmail() -> { @@ -213,7 +226,7 @@ class CreateAccountViewModel @Inject constructor( title = R.string.an_error_has_occurred.asText(), message = R.string.invalid_email.asText(), ) - mutableStateFlow.update { it.copy(errorDialogState = dialog) } + mutableStateFlow.update { it.copy(dialog = CreateAccountDialog.Error(dialog)) } } mutableStateFlow.value.passwordInput.length < MIN_PASSWORD_LENGTH -> { @@ -221,7 +234,7 @@ class CreateAccountViewModel @Inject constructor( 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(errorDialogState = dialog) } + mutableStateFlow.update { it.copy(dialog = CreateAccountDialog.Error(dialog)) } } mutableStateFlow.value.passwordInput != mutableStateFlow.value.confirmPasswordInput -> { @@ -229,7 +242,7 @@ class CreateAccountViewModel @Inject constructor( title = R.string.an_error_has_occurred.asText(), message = R.string.master_password_confirmation_val_message.asText(), ) - mutableStateFlow.update { it.copy(errorDialogState = dialog) } + mutableStateFlow.update { it.copy(dialog = CreateAccountDialog.Error(dialog)) } } !mutableStateFlow.value.isAcceptPoliciesToggled -> { @@ -237,24 +250,31 @@ class CreateAccountViewModel @Inject constructor( title = R.string.an_error_has_occurred.asText(), message = R.string.accept_policies_error.asText(), ) - mutableStateFlow.update { it.copy(errorDialogState = dialog) } + mutableStateFlow.update { it.copy(dialog = CreateAccountDialog.Error(dialog)) } } else -> { - submitRegisterAccountRequest(captchaToken = null) + submitRegisterAccountRequest( + shouldCheckForDataBreaches = mutableStateFlow.value.isCheckDataBreachesToggled, + captchaToken = null, + ) } } - private fun submitRegisterAccountRequest(captchaToken: String?) { + private fun handleContinueWithBreachedPasswordClick() { + submitRegisterAccountRequest(shouldCheckForDataBreaches = false, captchaToken = null) + } + + private fun submitRegisterAccountRequest( + shouldCheckForDataBreaches: Boolean, + captchaToken: String?, + ) { mutableStateFlow.update { - it.copy( - loadingDialogState = LoadingDialogState.Shown( - text = R.string.creating_account.asText(), - ), - ) + it.copy(dialog = CreateAccountDialog.Loading) } viewModelScope.launch { val result = authRepository.register( + shouldCheckDataBreaches = shouldCheckForDataBreaches, email = mutableStateFlow.value.emailInput, masterPassword = mutableStateFlow.value.passwordInput, masterPasswordHint = mutableStateFlow.value.passwordHintInput.ifBlank { null }, @@ -280,10 +300,32 @@ data class CreateAccountState( val passwordHintInput: String, val isCheckDataBreachesToggled: Boolean, val isAcceptPoliciesToggled: Boolean, - val errorDialogState: BasicDialogState, - val loadingDialogState: LoadingDialogState, + val dialog: CreateAccountDialog?, ) : Parcelable +/** + * Models dialogs that can be displayed on the create account screen. + */ +sealed class CreateAccountDialog : Parcelable { + /** + * Loading dialog. + */ + @Parcelize + data object Loading : CreateAccountDialog() + + /** + * Confirm the user wants to continue with potentially breached password. + */ + @Parcelize + data object HaveIBeenPwned : CreateAccountDialog() + + /** + * General error dialog with an OK button. + */ + @Parcelize + data class Error(val state: BasicDialogState.Shown) : CreateAccountDialog() +} + /** * Models events for the create account screen. */ @@ -337,6 +379,11 @@ sealed class CreateAccountAction { */ data object CloseClick : CreateAccountAction() + /** + * User clicked "Yes" when being asked if they are sure they want to use a breached password. + */ + data object ContinueWithBreachedPasswordClick : CreateAccountAction() + /** * Email input changed. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTwoButtonDialog.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTwoButtonDialog.kt new file mode 100644 index 000000000..007638f24 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTwoButtonDialog.kt @@ -0,0 +1,61 @@ +package com.x8bit.bitwarden.ui.platform.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.x8bit.bitwarden.ui.platform.base.util.Text + +/** + * Represents a Bitwarden-styled dialog that is hidden or shown based on [visibilityState]. + * + * @param title title to show. + * @param message message to show. + * @param confirmButtonText text to show on confirm button. + * @param dismissButtonText text to show on dismiss button. + * @param onConfirmClick called when the confirm button is clicked. + * @param onDismissClick called when the dismiss button is clicked. + * @param onDismissRequest called when the user attempts to dismiss the dialog (for example by + * tapping outside of it). + */ +@Composable +fun BitwardenTwoButtonDialog( + title: Text, + message: Text, + confirmButtonText: Text, + dismissButtonText: Text, + onConfirmClick: () -> Unit, + onDismissClick: () -> Unit, + onDismissRequest: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + dismissButton = { + BitwardenTextButton( + label = dismissButtonText(), + onClick = onDismissClick, + ) + }, + confirmButton = { + BitwardenTextButton( + label = confirmButtonText(), + onClick = onConfirmClick, + ) + }, + title = { + Text( + text = title(), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, + ) + }, + text = { + Text( + text = message(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedServiceTest.kt new file mode 100644 index 000000000..7f5a46bd2 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/HaveIBeenPwnedServiceTest.kt @@ -0,0 +1,48 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.service + +import com.x8bit.bitwarden.data.auth.datasource.network.api.HaveIBeenPwnedApi +import com.x8bit.bitwarden.data.platform.base.BaseServiceTest +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import retrofit2.create + +class HaveIBeenPwnedServiceTest : BaseServiceTest() { + + private val haveIBeenPwnedApi: HaveIBeenPwnedApi = retrofit.create() + private val service = HaveIBeenPwnedServiceImpl(haveIBeenPwnedApi) + + @Test + fun `when service returns failure should return failure`() = runTest { + val response = MockResponse().setResponseCode(400) + server.enqueue(response) + assertTrue(service.hasPasswordBeenBreached(PWNED_PASSWORD).isFailure) + } + + @Test + fun `when given password is in response returns true`() = runTest { + val response = MockResponse().setBody(HIBP_RESPONSE) + server.enqueue(response) + val result = service.hasPasswordBeenBreached(PWNED_PASSWORD) + assertTrue(result.getOrThrow()) + } + + @Test + fun `when given password is not in response returns false`() = runTest { + val response = MockResponse().setBody(HIBP_RESPONSE) + server.enqueue(response) + val result = service.hasPasswordBeenBreached("testpassword") + assertFalse(result.getOrThrow()) + } +} + +private const val PWNED_PASSWORD = "password1234" + +private val HIBP_RESPONSE = """ + FBD6D76BB5D2041542D7D2E3FAC5BB05593:36865 + F390F21EBEFEF07A1DA4E661AF830FD76A6:3 + F3CAEF537A4881A05E2A9A9A8A236FE7C14:1 + F44FD6981B10EC24A93989A0C61E71C767C:5 +""".trimIndent() diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index b6f00d479..ed6baa328 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJs import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService +import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.repository.model.AuthState @@ -22,6 +23,7 @@ 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.toUserState import com.x8bit.bitwarden.data.auth.util.toSdkParams +import com.x8bit.bitwarden.data.platform.util.asSuccess import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify @@ -45,6 +47,7 @@ class AuthRepositoryTest { private val accountsService: AccountsService = mockk() private val identityService: IdentityService = mockk() + private val haveIBeenPwnedService: HaveIBeenPwnedService = mockk() private val fakeAuthDiskSource = FakeAuthDiskSource() private val authSdkSource = mockk { coEvery { @@ -76,6 +79,7 @@ class AuthRepositoryTest { private val repository = AuthRepositoryImpl( accountsService = accountsService, identityService = identityService, + haveIBeenPwnedService = haveIBeenPwnedService, authSdkSource = authSdkSource, authDiskSource = fakeAuthDiskSource, dispatcher = UnconfinedTestDispatcher(), @@ -83,7 +87,7 @@ class AuthRepositoryTest { @BeforeEach fun beforeEach() { - clearMocks(identityService, accountsService) + clearMocks(identityService, accountsService, haveIBeenPwnedService) mockkStatic(GET_TOKEN_RESPONSE_EXTENSIONS_PATH) } @@ -230,6 +234,72 @@ class AuthRepositoryTest { } } + @Test + fun `register check data breaches error should return Error`() = runTest { + coEvery { + haveIBeenPwnedService.hasPasswordBeenBreached(PASSWORD) + } returns Result.failure(Throwable()) + + val result = repository.register( + email = EMAIL, + masterPassword = PASSWORD, + masterPasswordHint = null, + captchaToken = null, + shouldCheckDataBreaches = true, + ) + assertEquals(RegisterResult.Error(null), result) + } + + @Test + fun `register check data breaches found should return DataBreachFound`() = runTest { + coEvery { + haveIBeenPwnedService.hasPasswordBeenBreached(PASSWORD) + } returns true.asSuccess() + + val result = repository.register( + email = EMAIL, + masterPassword = PASSWORD, + masterPasswordHint = null, + captchaToken = null, + shouldCheckDataBreaches = true, + ) + assertEquals(RegisterResult.DataBreachFound, result) + } + + @Test + fun `register check data breaches Success should return Success`() = runTest { + coEvery { + haveIBeenPwnedService.hasPasswordBeenBreached(PASSWORD) + } returns false.asSuccess() + coEvery { + accountsService.register( + body = RegisterRequestJson( + email = EMAIL, + masterPasswordHash = PASSWORD_HASH, + masterPasswordHint = null, + captchaResponse = null, + key = ENCRYPTED_USER_KEY, + keys = RegisterRequestJson.Keys( + publicKey = PUBLIC_KEY, + encryptedPrivateKey = PRIVATE_KEY, + ), + kdfType = PBKDF2_SHA256, + kdfIterations = DEFAULT_KDF_ITERATIONS.toUInt(), + ), + ) + } returns Result.success(RegisterResponseJson.Success(captchaBypassToken = CAPTCHA_KEY)) + + val result = repository.register( + email = EMAIL, + masterPassword = PASSWORD, + masterPasswordHint = null, + captchaToken = null, + shouldCheckDataBreaches = true, + ) + assertEquals(RegisterResult.Success(CAPTCHA_KEY), result) + coVerify { haveIBeenPwnedService.hasPasswordBeenBreached(PASSWORD) } + } + @Test fun `register Success should return Success`() = runTest { coEvery { accountsService.preLogin(EMAIL) } returns Result.success(PRE_LOGIN_SUCCESS) @@ -256,6 +326,7 @@ class AuthRepositoryTest { masterPassword = PASSWORD, masterPasswordHint = null, captchaToken = null, + shouldCheckDataBreaches = false, ) assertEquals(RegisterResult.Success(CAPTCHA_KEY), result) } @@ -295,6 +366,7 @@ class AuthRepositoryTest { masterPassword = PASSWORD, masterPasswordHint = null, captchaToken = null, + shouldCheckDataBreaches = false, ) assertEquals(RegisterResult.Error(errorMessage = null), result) } @@ -334,6 +406,7 @@ class AuthRepositoryTest { masterPassword = PASSWORD, masterPasswordHint = null, captchaToken = null, + shouldCheckDataBreaches = false, ) assertEquals(RegisterResult.CaptchaRequired(captchaId = CAPTCHA_KEY), result) } @@ -364,6 +437,7 @@ class AuthRepositoryTest { masterPassword = PASSWORD, masterPasswordHint = null, captchaToken = null, + shouldCheckDataBreaches = false, ) assertEquals(RegisterResult.Error(errorMessage = null), result) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt index 14b65d06a..27177ba31 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt @@ -27,7 +27,6 @@ import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.BasicDialogState -import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -312,9 +311,11 @@ class CreateAccountScreenTest : BaseComposeTest() { val viewModel = mockk(relaxed = true) { every { stateFlow } returns MutableStateFlow( DEFAULT_STATE.copy( - errorDialogState = BasicDialogState.Shown( - title = "title".asText(), - message = "message".asText(), + dialog = CreateAccountDialog.Error( + BasicDialogState.Shown( + title = "title".asText(), + message = "message".asText(), + ), ), ), ) @@ -335,14 +336,62 @@ class CreateAccountScreenTest : BaseComposeTest() { verify { viewModel.trySendAction(CreateAccountAction.ErrorDialogDismiss) } } + @Test + fun `clicking No on the HIBP dialog should send ErrorDialogDismiss action`() { + val viewModel = mockk(relaxed = true) { + every { stateFlow } returns MutableStateFlow( + DEFAULT_STATE.copy(dialog = CreateAccountDialog.HaveIBeenPwned), + ) + every { eventFlow } returns emptyFlow() + every { trySendAction(CreateAccountAction.ErrorDialogDismiss) } returns Unit + } + composeTestRule.setContent { + CreateAccountScreen( + onNavigateBack = {}, + onNavigateToLogin = { _, _ -> }, + viewModel = viewModel, + ) + } + composeTestRule + .onAllNodesWithText("No") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(CreateAccountAction.ErrorDialogDismiss) } + } + + @Test + fun `clicking Yes on the HIBP dialog should send ContinueWithBreachedPasswordClick action`() { + val viewModel = mockk(relaxed = true) { + every { stateFlow } returns MutableStateFlow( + DEFAULT_STATE.copy(dialog = CreateAccountDialog.HaveIBeenPwned), + ) + every { eventFlow } returns emptyFlow() + every { trySendAction(CreateAccountAction.ErrorDialogDismiss) } returns Unit + } + composeTestRule.setContent { + CreateAccountScreen( + onNavigateBack = {}, + onNavigateToLogin = { _, _ -> }, + viewModel = viewModel, + ) + } + composeTestRule + .onAllNodesWithText("Yes") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(CreateAccountAction.ContinueWithBreachedPasswordClick) } + } + @Test fun `when BasicDialogState is Shown should show dialog`() { val viewModel = mockk(relaxed = true) { every { stateFlow } returns MutableStateFlow( DEFAULT_STATE.copy( - errorDialogState = BasicDialogState.Shown( - title = "title".asText(), - message = "message".asText(), + dialog = CreateAccountDialog.Error( + BasicDialogState.Shown( + title = "title".asText(), + message = "message".asText(), + ), ), ), ) @@ -407,8 +456,7 @@ class CreateAccountScreenTest : BaseComposeTest() { passwordHintInput = "", isCheckDataBreachesToggled = false, isAcceptPoliciesToggled = false, - errorDialogState = BasicDialogState.Hidden, - loadingDialogState = LoadingDialogState.Hidden, + dialog = null, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt index c99013d8c..33d9a8d0f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt @@ -17,8 +17,8 @@ import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.Pas import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.BasicDialogState -import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic @@ -70,8 +70,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() { passwordHintInput = "hint", isCheckDataBreachesToggled = false, isAcceptPoliciesToggled = false, - errorDialogState = BasicDialogState.Hidden, - loadingDialogState = LoadingDialogState.Hidden, + dialog = null, ) val handle = SavedStateHandle(mapOf("state" to savedState)) val viewModel = CreateAccountViewModel( @@ -91,9 +90,11 @@ class CreateAccountViewModelTest : BaseViewModelTest() { viewModel.trySendAction(EmailInputChange(input)) val expectedState = DEFAULT_STATE.copy( emailInput = input, - errorDialogState = BasicDialogState.Shown( - title = R.string.an_error_has_occurred.asText(), - message = R.string.invalid_email.asText(), + dialog = CreateAccountDialog.Error( + BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.invalid_email.asText(), + ), ), ) viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) @@ -112,10 +113,12 @@ class CreateAccountViewModelTest : BaseViewModelTest() { viewModel.trySendAction(EmailInputChange(input)) val expectedState = DEFAULT_STATE.copy( emailInput = input, - errorDialogState = BasicDialogState.Shown( - title = R.string.an_error_has_occurred.asText(), - message = R.string.validation_field_required - .asText(R.string.email_address.asText()), + dialog = CreateAccountDialog.Error( + BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.validation_field_required + .asText(R.string.email_address.asText()), + ), ), ) viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) @@ -136,9 +139,11 @@ class CreateAccountViewModelTest : BaseViewModelTest() { val expectedState = DEFAULT_STATE.copy( emailInput = EMAIL, passwordInput = input, - errorDialogState = BasicDialogState.Shown( - title = R.string.an_error_has_occurred.asText(), - message = R.string.master_password_length_val_message_x.asText(12), + dialog = CreateAccountDialog.Error( + BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.master_password_length_val_message_x.asText(12), + ), ), ) viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) @@ -159,9 +164,11 @@ class CreateAccountViewModelTest : BaseViewModelTest() { val expectedState = DEFAULT_STATE.copy( emailInput = "test@test.com", passwordInput = input, - errorDialogState = BasicDialogState.Shown( - title = R.string.an_error_has_occurred.asText(), - message = R.string.master_password_confirmation_val_message.asText(), + dialog = CreateAccountDialog.Error( + BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.master_password_confirmation_val_message.asText(), + ), ), ) viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) @@ -184,9 +191,11 @@ class CreateAccountViewModelTest : BaseViewModelTest() { emailInput = "test@test.com", passwordInput = password, confirmPasswordInput = password, - errorDialogState = BasicDialogState.Shown( - title = R.string.an_error_has_occurred.asText(), - message = R.string.accept_policies_error.asText(), + dialog = CreateAccountDialog.Error( + BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.accept_policies_error.asText(), + ), ), ) viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) @@ -205,6 +214,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() { masterPassword = PASSWORD, masterPasswordHint = null, captchaToken = null, + shouldCheckDataBreaches = false, ) } returns RegisterResult.Success(captchaToken = "mock_token") } @@ -218,11 +228,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() { assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem()) viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) assertEquals( - VALID_INPUT_STATE.copy( - loadingDialogState = LoadingDialogState.Shown( - text = R.string.creating_account.asText(), - ), - ), + VALID_INPUT_STATE.copy(dialog = CreateAccountDialog.Loading), stateFlow.awaitItem(), ) assertEquals( @@ -247,6 +253,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() { masterPassword = PASSWORD, masterPasswordHint = null, captchaToken = null, + shouldCheckDataBreaches = false, ) } returns RegisterResult.Error(errorMessage = "mock_error") } @@ -258,19 +265,16 @@ class CreateAccountViewModelTest : BaseViewModelTest() { assertEquals(VALID_INPUT_STATE, awaitItem()) viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) assertEquals( - VALID_INPUT_STATE.copy( - loadingDialogState = LoadingDialogState.Shown( - text = R.string.creating_account.asText(), - ), - ), + VALID_INPUT_STATE.copy(dialog = CreateAccountDialog.Loading), awaitItem(), ) assertEquals( VALID_INPUT_STATE.copy( - loadingDialogState = LoadingDialogState.Hidden, - errorDialogState = BasicDialogState.Shown( - title = R.string.an_error_has_occurred.asText(), - message = "mock_error".asText(), + dialog = CreateAccountDialog.Error( + BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = "mock_error".asText(), + ), ), ), awaitItem(), @@ -292,6 +296,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() { masterPassword = PASSWORD, masterPasswordHint = null, captchaToken = null, + shouldCheckDataBreaches = false, ) } returns RegisterResult.CaptchaRequired(captchaId = "mock_captcha_id") } @@ -322,6 +327,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() { masterPassword = PASSWORD, masterPasswordHint = null, captchaToken = null, + shouldCheckDataBreaches = false, ) } returns RegisterResult.Success(captchaToken = "mock_captcha_token") } @@ -341,6 +347,69 @@ class CreateAccountViewModelTest : BaseViewModelTest() { } } + @Test + @Suppress("MaxLineLength") + fun `ContinueWithBreachedPasswordClick should call repository with checkDataBreaches false`() { + val repo = mockk { + every { captchaTokenResultFlow } returns flowOf() + coEvery { + register( + email = EMAIL, + masterPassword = PASSWORD, + masterPasswordHint = null, + captchaToken = null, + shouldCheckDataBreaches = false, + ) + } returns RegisterResult.Error(null) + } + val viewModel = CreateAccountViewModel( + savedStateHandle = validInputHandle, + authRepository = repo, + ) + viewModel.trySendAction(CreateAccountAction.ContinueWithBreachedPasswordClick) + coVerify { + repo.register( + email = EMAIL, + masterPassword = PASSWORD, + masterPasswordHint = null, + captchaToken = null, + shouldCheckDataBreaches = false, + ) + } + } + + @Test + fun `SubmitClick register returns ShowDataBreaches should show HaveIBeenPwned dialog`() = + runTest { + val repo = mockk { + every { captchaTokenResultFlow } returns flowOf() + coEvery { + register( + email = EMAIL, + masterPassword = PASSWORD, + masterPasswordHint = null, + captchaToken = null, + shouldCheckDataBreaches = true, + ) + } returns RegisterResult.DataBreachFound + } + val viewModel = CreateAccountViewModel( + savedStateHandle = validInputHandle, + authRepository = repo, + ) + viewModel.actionChannel.trySend(CreateAccountAction.CheckDataBreachesToggle(true)) + viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) + viewModel.stateFlow.test { + assertEquals( + VALID_INPUT_STATE.copy( + isCheckDataBreachesToggled = true, + dialog = CreateAccountDialog.HaveIBeenPwned, + ), + awaitItem(), + ) + } + } + @Test fun `CloseClick should emit NavigateBack`() = runTest { val viewModel = CreateAccountViewModel( @@ -459,8 +528,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() { passwordHintInput = "", isCheckDataBreachesToggled = false, isAcceptPoliciesToggled = false, - errorDialogState = BasicDialogState.Hidden, - loadingDialogState = LoadingDialogState.Hidden, + dialog = null, ) private val VALID_INPUT_STATE = CreateAccountState( passwordInput = PASSWORD, @@ -469,8 +537,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() { passwordHintInput = "", isCheckDataBreachesToggled = false, isAcceptPoliciesToggled = true, - errorDialogState = BasicDialogState.Hidden, - loadingDialogState = LoadingDialogState.Hidden, + dialog = null, ) private const val LOGIN_RESULT_PATH = "com.x8bit.bitwarden.data.auth.repository.util.CaptchaUtilsKt"