BIT-189 Check for data breaches during create account (#154)

This commit is contained in:
Andrew Haisting 2023-10-24 11:36:11 -05:00 committed by Álison Fernandes
parent 2472648434
commit 8864315342
15 changed files with 577 additions and 84 deletions

View file

@ -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<ResponseBody>
}

View file

@ -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.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsServiceImpl 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.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityServiceImpl import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityServiceImpl
import com.x8bit.bitwarden.data.platform.datasource.network.di.NetworkModule import com.x8bit.bitwarden.data.platform.datasource.network.di.NetworkModule
@ -35,4 +37,15 @@ object NetworkModule {
@Named(NetworkModule.UNAUTHORIZED) retrofit: Retrofit, @Named(NetworkModule.UNAUTHORIZED) retrofit: Retrofit,
json: Json, json: Json,
): IdentityService = IdentityServiceImpl(retrofit.create(), 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(),
)
} }

View file

@ -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<Boolean>
}

View file

@ -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<Boolean> {
// 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)
}
}
}
}

View file

@ -50,6 +50,7 @@ interface AuthRepository {
masterPassword: String, masterPassword: String,
masterPasswordHint: String?, masterPasswordHint: String?,
captchaToken: String?, captchaToken: String?,
shouldCheckDataBreaches: Boolean,
): RegisterResult ): RegisterResult
/** /**

View file

@ -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.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson 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.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.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson 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]. * Default implementation of [AuthRepository].
*/ */
@Suppress("LongParameterList")
@Singleton @Singleton
class AuthRepositoryImpl @Inject constructor( class AuthRepositoryImpl @Inject constructor(
private val accountsService: AccountsService, private val accountsService: AccountsService,
private val haveIBeenPwnedService: HaveIBeenPwnedService,
private val identityService: IdentityService, private val identityService: IdentityService,
private val authSdkSource: AuthSdkSource, private val authSdkSource: AuthSdkSource,
private val authDiskSource: AuthDiskSource, private val authDiskSource: AuthDiskSource,
@ -143,12 +146,26 @@ class AuthRepositoryImpl @Inject constructor(
} }
} }
@Suppress("ReturnCount")
override suspend fun register( override suspend fun register(
email: String, email: String,
masterPassword: String, masterPassword: String,
masterPasswordHint: String?, masterPasswordHint: String?,
captchaToken: String?, captchaToken: String?,
shouldCheckDataBreaches: Boolean,
): RegisterResult { ): 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()) val kdf = Kdf.Pbkdf2(DEFAULT_KDF_ITERATIONS.toUInt())
return authSdkSource return authSdkSource
.makeRegisterKeys( .makeRegisterKeys(

View file

@ -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.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService 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.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepository
@ -22,9 +23,11 @@ object RepositoryModule {
@Provides @Provides
@Singleton @Singleton
fun bindsAuthRepository( @Suppress("LongParameterList")
fun providesAuthRepository(
accountsService: AccountsService, accountsService: AccountsService,
identityService: IdentityService, identityService: IdentityService,
haveIBeenPwnedService: HaveIBeenPwnedService,
authSdkSource: AuthSdkSource, authSdkSource: AuthSdkSource,
authDiskSource: AuthDiskSource, authDiskSource: AuthDiskSource,
): AuthRepository = AuthRepositoryImpl( ): AuthRepository = AuthRepositoryImpl(
@ -32,6 +35,7 @@ object RepositoryModule {
identityService = identityService, identityService = identityService,
authSdkSource = authSdkSource, authSdkSource = authSdkSource,
authDiskSource = authDiskSource, authDiskSource = authDiskSource,
haveIBeenPwnedService = haveIBeenPwnedService,
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
) )
} }

View file

@ -24,4 +24,9 @@ sealed class RegisterResult {
* @param errorMessage a message describing the error. * @param errorMessage a message describing the error.
*/ */
data class Error(val errorMessage: String?) : RegisterResult() data class Error(val errorMessage: String?) : RegisterResult()
/**
* Password hash was found in a data breach.
*/
data object DataBreachFound : RegisterResult()
} }

View file

@ -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.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick 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.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.EmailInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ErrorDialogDismiss import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ErrorDialogDismiss
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordHintChange 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.auth.feature.createaccount.CreateAccountEvent.NavigateToTerms
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect 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.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.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.BitwardenSwitch import com.x8bit.bitwarden.ui.platform.components.BitwardenSwitch
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButtonTopAppBar import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButtonTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField 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 import com.x8bit.bitwarden.ui.platform.theme.clickableSpanStyle
/** /**
@ -104,13 +108,49 @@ fun CreateAccountScreen(
} }
} }
} }
BitwardenBasicDialog(
visibilityState = state.errorDialogState, val haveIBeenPwnedMessage = remember {
onDismissRequest = remember(viewModel) { { viewModel.trySendAction(ErrorDialogDismiss) } }, R.string.weak_password_identified_and_found_in_a_data_breach_alert_description.asText()
) }
BitwardenLoadingDialog(
visibilityState = state.loadingDialogState, // 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()

View file

@ -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.AcceptPoliciesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle 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.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.EmailInputChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordHintChange import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordHintChange
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordInputChange 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.asText
import com.x8bit.bitwarden.ui.platform.base.util.isValidEmail import com.x8bit.bitwarden.ui.platform.base.util.isValidEmail
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -51,8 +51,7 @@ class CreateAccountViewModel @Inject constructor(
passwordHintInput = "", passwordHintInput = "",
isAcceptPoliciesToggled = false, isAcceptPoliciesToggled = false,
isCheckDataBreachesToggled = false, isCheckDataBreachesToggled = false,
errorDialogState = BasicDialogState.Hidden, dialog = null,
loadingDialogState = LoadingDialogState.Hidden,
), ),
) { ) {
@ -93,6 +92,8 @@ class CreateAccountViewModel @Inject constructor(
is CreateAccountAction.Internal.ReceiveCaptchaToken -> { is CreateAccountAction.Internal.ReceiveCaptchaToken -> {
handleReceiveCaptchaToken(action) handleReceiveCaptchaToken(action)
} }
ContinueWithBreachedPasswordClick -> handleContinueWithBreachedPasswordClick()
} }
} }
@ -103,16 +104,21 @@ class CreateAccountViewModel @Inject constructor(
is CaptchaCallbackTokenResult.MissingToken -> { is CaptchaCallbackTokenResult.MissingToken -> {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
errorDialogState = BasicDialogState.Shown( dialog = CreateAccountDialog.Error(
title = R.string.an_error_has_occurred.asText(), BasicDialogState.Shown(
message = R.string.captcha_failed.asText(), title = R.string.an_error_has_occurred.asText(),
message = R.string.captcha_failed.asText(),
),
), ),
) )
} }
} }
is CaptchaCallbackTokenResult.Success -> { 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) { when (val registerAccountResult = action.registerResult) {
is RegisterResult.CaptchaRequired -> { is RegisterResult.CaptchaRequired -> {
mutableStateFlow.update { it.copy(loadingDialogState = LoadingDialogState.Hidden) } mutableStateFlow.update { it.copy(dialog = null) }
sendEvent( sendEvent(
CreateAccountEvent.NavigateToCaptcha( CreateAccountEvent.NavigateToCaptcha(
uri = generateUriForCaptcha(captchaId = registerAccountResult.captchaId), uri = generateUriForCaptcha(captchaId = registerAccountResult.captchaId),
@ -134,18 +140,19 @@ class CreateAccountViewModel @Inject constructor(
// TODO parse and display server errors BIT-910 // TODO parse and display server errors BIT-910
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
loadingDialogState = LoadingDialogState.Hidden, dialog = CreateAccountDialog.Error(
errorDialogState = BasicDialogState.Shown( BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(), title = R.string.an_error_has_occurred.asText(),
message = registerAccountResult.errorMessage?.asText() message = registerAccountResult.errorMessage?.asText()
?: R.string.generic_error_message.asText(), ?: R.string.generic_error_message.asText(),
),
), ),
) )
} }
} }
is RegisterResult.Success -> { is RegisterResult.Success -> {
mutableStateFlow.update { it.copy(loadingDialogState = LoadingDialogState.Hidden) } mutableStateFlow.update { it.copy(dialog = null) }
sendEvent( sendEvent(
CreateAccountEvent.NavigateToLogin( CreateAccountEvent.NavigateToLogin(
email = mutableStateFlow.value.emailInput, 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() { private fun handleDialogDismiss() {
mutableStateFlow.update { 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 message = R.string.validation_field_required
.asText(R.string.email_address.asText()), .asText(R.string.email_address.asText()),
) )
mutableStateFlow.update { it.copy(errorDialogState = dialog) } mutableStateFlow.update { it.copy(dialog = CreateAccountDialog.Error(dialog)) }
} }
!mutableStateFlow.value.emailInput.isValidEmail() -> { !mutableStateFlow.value.emailInput.isValidEmail() -> {
@ -213,7 +226,7 @@ class CreateAccountViewModel @Inject constructor(
title = R.string.an_error_has_occurred.asText(), title = R.string.an_error_has_occurred.asText(),
message = R.string.invalid_email.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 -> { mutableStateFlow.value.passwordInput.length < MIN_PASSWORD_LENGTH -> {
@ -221,7 +234,7 @@ class CreateAccountViewModel @Inject constructor(
title = R.string.an_error_has_occurred.asText(), title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_length_val_message_x.asText(MIN_PASSWORD_LENGTH), 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 -> { mutableStateFlow.value.passwordInput != mutableStateFlow.value.confirmPasswordInput -> {
@ -229,7 +242,7 @@ class CreateAccountViewModel @Inject constructor(
title = R.string.an_error_has_occurred.asText(), title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_confirmation_val_message.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 -> { !mutableStateFlow.value.isAcceptPoliciesToggled -> {
@ -237,24 +250,31 @@ class CreateAccountViewModel @Inject constructor(
title = R.string.an_error_has_occurred.asText(), title = R.string.an_error_has_occurred.asText(),
message = R.string.accept_policies_error.asText(), message = R.string.accept_policies_error.asText(),
) )
mutableStateFlow.update { it.copy(errorDialogState = dialog) } mutableStateFlow.update { it.copy(dialog = CreateAccountDialog.Error(dialog)) }
} }
else -> { 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 { mutableStateFlow.update {
it.copy( it.copy(dialog = CreateAccountDialog.Loading)
loadingDialogState = LoadingDialogState.Shown(
text = R.string.creating_account.asText(),
),
)
} }
viewModelScope.launch { viewModelScope.launch {
val result = authRepository.register( val result = authRepository.register(
shouldCheckDataBreaches = shouldCheckForDataBreaches,
email = mutableStateFlow.value.emailInput, email = mutableStateFlow.value.emailInput,
masterPassword = mutableStateFlow.value.passwordInput, masterPassword = mutableStateFlow.value.passwordInput,
masterPasswordHint = mutableStateFlow.value.passwordHintInput.ifBlank { null }, masterPasswordHint = mutableStateFlow.value.passwordHintInput.ifBlank { null },
@ -280,10 +300,32 @@ data class CreateAccountState(
val passwordHintInput: String, val passwordHintInput: String,
val isCheckDataBreachesToggled: Boolean, val isCheckDataBreachesToggled: Boolean,
val isAcceptPoliciesToggled: Boolean, val isAcceptPoliciesToggled: Boolean,
val errorDialogState: BasicDialogState, val dialog: CreateAccountDialog?,
val loadingDialogState: LoadingDialogState,
) : Parcelable ) : 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. * Models events for the create account screen.
*/ */
@ -337,6 +379,11 @@ sealed class CreateAccountAction {
*/ */
data object CloseClick : 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. * Email input changed.
*/ */

View file

@ -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,
)
}

View file

@ -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()

View file

@ -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.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson 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.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.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.repository.model.AuthState 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.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.toUserState import com.x8bit.bitwarden.data.auth.repository.util.toUserState
import com.x8bit.bitwarden.data.auth.util.toSdkParams import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.util.asSuccess
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
@ -45,6 +47,7 @@ class AuthRepositoryTest {
private val accountsService: AccountsService = mockk() private val accountsService: AccountsService = mockk()
private val identityService: IdentityService = mockk() private val identityService: IdentityService = mockk()
private val haveIBeenPwnedService: HaveIBeenPwnedService = mockk()
private val fakeAuthDiskSource = FakeAuthDiskSource() private val fakeAuthDiskSource = FakeAuthDiskSource()
private val authSdkSource = mockk<AuthSdkSource> { private val authSdkSource = mockk<AuthSdkSource> {
coEvery { coEvery {
@ -76,6 +79,7 @@ class AuthRepositoryTest {
private val repository = AuthRepositoryImpl( private val repository = AuthRepositoryImpl(
accountsService = accountsService, accountsService = accountsService,
identityService = identityService, identityService = identityService,
haveIBeenPwnedService = haveIBeenPwnedService,
authSdkSource = authSdkSource, authSdkSource = authSdkSource,
authDiskSource = fakeAuthDiskSource, authDiskSource = fakeAuthDiskSource,
dispatcher = UnconfinedTestDispatcher(), dispatcher = UnconfinedTestDispatcher(),
@ -83,7 +87,7 @@ class AuthRepositoryTest {
@BeforeEach @BeforeEach
fun beforeEach() { fun beforeEach() {
clearMocks(identityService, accountsService) clearMocks(identityService, accountsService, haveIBeenPwnedService)
mockkStatic(GET_TOKEN_RESPONSE_EXTENSIONS_PATH) 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 @Test
fun `register Success should return Success`() = runTest { fun `register Success should return Success`() = runTest {
coEvery { accountsService.preLogin(EMAIL) } returns Result.success(PRE_LOGIN_SUCCESS) coEvery { accountsService.preLogin(EMAIL) } returns Result.success(PRE_LOGIN_SUCCESS)
@ -256,6 +326,7 @@ class AuthRepositoryTest {
masterPassword = PASSWORD, masterPassword = PASSWORD,
masterPasswordHint = null, masterPasswordHint = null,
captchaToken = null, captchaToken = null,
shouldCheckDataBreaches = false,
) )
assertEquals(RegisterResult.Success(CAPTCHA_KEY), result) assertEquals(RegisterResult.Success(CAPTCHA_KEY), result)
} }
@ -295,6 +366,7 @@ class AuthRepositoryTest {
masterPassword = PASSWORD, masterPassword = PASSWORD,
masterPasswordHint = null, masterPasswordHint = null,
captchaToken = null, captchaToken = null,
shouldCheckDataBreaches = false,
) )
assertEquals(RegisterResult.Error(errorMessage = null), result) assertEquals(RegisterResult.Error(errorMessage = null), result)
} }
@ -334,6 +406,7 @@ class AuthRepositoryTest {
masterPassword = PASSWORD, masterPassword = PASSWORD,
masterPasswordHint = null, masterPasswordHint = null,
captchaToken = null, captchaToken = null,
shouldCheckDataBreaches = false,
) )
assertEquals(RegisterResult.CaptchaRequired(captchaId = CAPTCHA_KEY), result) assertEquals(RegisterResult.CaptchaRequired(captchaId = CAPTCHA_KEY), result)
} }
@ -364,6 +437,7 @@ class AuthRepositoryTest {
masterPassword = PASSWORD, masterPassword = PASSWORD,
masterPasswordHint = null, masterPasswordHint = null,
captchaToken = null, captchaToken = null,
shouldCheckDataBreaches = false,
) )
assertEquals(RegisterResult.Error(errorMessage = null), result) assertEquals(RegisterResult.Error(errorMessage = null), result)
} }

View file

@ -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.IntentHandler
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
@ -312,9 +311,11 @@ class CreateAccountScreenTest : BaseComposeTest() {
val viewModel = mockk<CreateAccountViewModel>(relaxed = true) { val viewModel = mockk<CreateAccountViewModel>(relaxed = true) {
every { stateFlow } returns MutableStateFlow( every { stateFlow } returns MutableStateFlow(
DEFAULT_STATE.copy( DEFAULT_STATE.copy(
errorDialogState = BasicDialogState.Shown( dialog = CreateAccountDialog.Error(
title = "title".asText(), BasicDialogState.Shown(
message = "message".asText(), title = "title".asText(),
message = "message".asText(),
),
), ),
), ),
) )
@ -335,14 +336,62 @@ class CreateAccountScreenTest : BaseComposeTest() {
verify { viewModel.trySendAction(CreateAccountAction.ErrorDialogDismiss) } verify { viewModel.trySendAction(CreateAccountAction.ErrorDialogDismiss) }
} }
@Test
fun `clicking No on the HIBP dialog should send ErrorDialogDismiss action`() {
val viewModel = mockk<CreateAccountViewModel>(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<CreateAccountViewModel>(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 @Test
fun `when BasicDialogState is Shown should show dialog`() { fun `when BasicDialogState is Shown should show dialog`() {
val viewModel = mockk<CreateAccountViewModel>(relaxed = true) { val viewModel = mockk<CreateAccountViewModel>(relaxed = true) {
every { stateFlow } returns MutableStateFlow( every { stateFlow } returns MutableStateFlow(
DEFAULT_STATE.copy( DEFAULT_STATE.copy(
errorDialogState = BasicDialogState.Shown( dialog = CreateAccountDialog.Error(
title = "title".asText(), BasicDialogState.Shown(
message = "message".asText(), title = "title".asText(),
message = "message".asText(),
),
), ),
), ),
) )
@ -407,8 +456,7 @@ class CreateAccountScreenTest : BaseComposeTest() {
passwordHintInput = "", passwordHintInput = "",
isCheckDataBreachesToggled = false, isCheckDataBreachesToggled = false,
isAcceptPoliciesToggled = false, isAcceptPoliciesToggled = false,
errorDialogState = BasicDialogState.Hidden, dialog = null,
loadingDialogState = LoadingDialogState.Hidden,
) )
} }
} }

View file

@ -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.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
@ -70,8 +70,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
passwordHintInput = "hint", passwordHintInput = "hint",
isCheckDataBreachesToggled = false, isCheckDataBreachesToggled = false,
isAcceptPoliciesToggled = false, isAcceptPoliciesToggled = false,
errorDialogState = BasicDialogState.Hidden, dialog = null,
loadingDialogState = LoadingDialogState.Hidden,
) )
val handle = SavedStateHandle(mapOf("state" to savedState)) val handle = SavedStateHandle(mapOf("state" to savedState))
val viewModel = CreateAccountViewModel( val viewModel = CreateAccountViewModel(
@ -91,9 +90,11 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
viewModel.trySendAction(EmailInputChange(input)) viewModel.trySendAction(EmailInputChange(input))
val expectedState = DEFAULT_STATE.copy( val expectedState = DEFAULT_STATE.copy(
emailInput = input, emailInput = input,
errorDialogState = BasicDialogState.Shown( dialog = CreateAccountDialog.Error(
title = R.string.an_error_has_occurred.asText(), BasicDialogState.Shown(
message = R.string.invalid_email.asText(), title = R.string.an_error_has_occurred.asText(),
message = R.string.invalid_email.asText(),
),
), ),
) )
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
@ -112,10 +113,12 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
viewModel.trySendAction(EmailInputChange(input)) viewModel.trySendAction(EmailInputChange(input))
val expectedState = DEFAULT_STATE.copy( val expectedState = DEFAULT_STATE.copy(
emailInput = input, emailInput = input,
errorDialogState = BasicDialogState.Shown( dialog = CreateAccountDialog.Error(
title = R.string.an_error_has_occurred.asText(), BasicDialogState.Shown(
message = R.string.validation_field_required title = R.string.an_error_has_occurred.asText(),
.asText(R.string.email_address.asText()), message = R.string.validation_field_required
.asText(R.string.email_address.asText()),
),
), ),
) )
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
@ -136,9 +139,11 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
val expectedState = DEFAULT_STATE.copy( val expectedState = DEFAULT_STATE.copy(
emailInput = EMAIL, emailInput = EMAIL,
passwordInput = input, passwordInput = input,
errorDialogState = BasicDialogState.Shown( dialog = CreateAccountDialog.Error(
title = R.string.an_error_has_occurred.asText(), BasicDialogState.Shown(
message = R.string.master_password_length_val_message_x.asText(12), title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_length_val_message_x.asText(12),
),
), ),
) )
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
@ -159,9 +164,11 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
val expectedState = DEFAULT_STATE.copy( val expectedState = DEFAULT_STATE.copy(
emailInput = "test@test.com", emailInput = "test@test.com",
passwordInput = input, passwordInput = input,
errorDialogState = BasicDialogState.Shown( dialog = CreateAccountDialog.Error(
title = R.string.an_error_has_occurred.asText(), BasicDialogState.Shown(
message = R.string.master_password_confirmation_val_message.asText(), title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_confirmation_val_message.asText(),
),
), ),
) )
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
@ -184,9 +191,11 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
emailInput = "test@test.com", emailInput = "test@test.com",
passwordInput = password, passwordInput = password,
confirmPasswordInput = password, confirmPasswordInput = password,
errorDialogState = BasicDialogState.Shown( dialog = CreateAccountDialog.Error(
title = R.string.an_error_has_occurred.asText(), BasicDialogState.Shown(
message = R.string.accept_policies_error.asText(), title = R.string.an_error_has_occurred.asText(),
message = R.string.accept_policies_error.asText(),
),
), ),
) )
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
@ -205,6 +214,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
masterPassword = PASSWORD, masterPassword = PASSWORD,
masterPasswordHint = null, masterPasswordHint = null,
captchaToken = null, captchaToken = null,
shouldCheckDataBreaches = false,
) )
} returns RegisterResult.Success(captchaToken = "mock_token") } returns RegisterResult.Success(captchaToken = "mock_token")
} }
@ -218,11 +228,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem()) assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem())
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
assertEquals( assertEquals(
VALID_INPUT_STATE.copy( VALID_INPUT_STATE.copy(dialog = CreateAccountDialog.Loading),
loadingDialogState = LoadingDialogState.Shown(
text = R.string.creating_account.asText(),
),
),
stateFlow.awaitItem(), stateFlow.awaitItem(),
) )
assertEquals( assertEquals(
@ -247,6 +253,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
masterPassword = PASSWORD, masterPassword = PASSWORD,
masterPasswordHint = null, masterPasswordHint = null,
captchaToken = null, captchaToken = null,
shouldCheckDataBreaches = false,
) )
} returns RegisterResult.Error(errorMessage = "mock_error") } returns RegisterResult.Error(errorMessage = "mock_error")
} }
@ -258,19 +265,16 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
assertEquals(VALID_INPUT_STATE, awaitItem()) assertEquals(VALID_INPUT_STATE, awaitItem())
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick) viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
assertEquals( assertEquals(
VALID_INPUT_STATE.copy( VALID_INPUT_STATE.copy(dialog = CreateAccountDialog.Loading),
loadingDialogState = LoadingDialogState.Shown(
text = R.string.creating_account.asText(),
),
),
awaitItem(), awaitItem(),
) )
assertEquals( assertEquals(
VALID_INPUT_STATE.copy( VALID_INPUT_STATE.copy(
loadingDialogState = LoadingDialogState.Hidden, dialog = CreateAccountDialog.Error(
errorDialogState = BasicDialogState.Shown( BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(), title = R.string.an_error_has_occurred.asText(),
message = "mock_error".asText(), message = "mock_error".asText(),
),
), ),
), ),
awaitItem(), awaitItem(),
@ -292,6 +296,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
masterPassword = PASSWORD, masterPassword = PASSWORD,
masterPasswordHint = null, masterPasswordHint = null,
captchaToken = null, captchaToken = null,
shouldCheckDataBreaches = false,
) )
} returns RegisterResult.CaptchaRequired(captchaId = "mock_captcha_id") } returns RegisterResult.CaptchaRequired(captchaId = "mock_captcha_id")
} }
@ -322,6 +327,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
masterPassword = PASSWORD, masterPassword = PASSWORD,
masterPasswordHint = null, masterPasswordHint = null,
captchaToken = null, captchaToken = null,
shouldCheckDataBreaches = false,
) )
} returns RegisterResult.Success(captchaToken = "mock_captcha_token") } 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<AuthRepository> {
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<AuthRepository> {
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 @Test
fun `CloseClick should emit NavigateBack`() = runTest { fun `CloseClick should emit NavigateBack`() = runTest {
val viewModel = CreateAccountViewModel( val viewModel = CreateAccountViewModel(
@ -459,8 +528,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
passwordHintInput = "", passwordHintInput = "",
isCheckDataBreachesToggled = false, isCheckDataBreachesToggled = false,
isAcceptPoliciesToggled = false, isAcceptPoliciesToggled = false,
errorDialogState = BasicDialogState.Hidden, dialog = null,
loadingDialogState = LoadingDialogState.Hidden,
) )
private val VALID_INPUT_STATE = CreateAccountState( private val VALID_INPUT_STATE = CreateAccountState(
passwordInput = PASSWORD, passwordInput = PASSWORD,
@ -469,8 +537,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
passwordHintInput = "", passwordHintInput = "",
isCheckDataBreachesToggled = false, isCheckDataBreachesToggled = false,
isAcceptPoliciesToggled = true, isAcceptPoliciesToggled = true,
errorDialogState = BasicDialogState.Hidden, dialog = null,
loadingDialogState = LoadingDialogState.Hidden,
) )
private const val LOGIN_RESULT_PATH = private const val LOGIN_RESULT_PATH =
"com.x8bit.bitwarden.data.auth.repository.util.CaptchaUtilsKt" "com.x8bit.bitwarden.data.auth.repository.util.CaptchaUtilsKt"