mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 07:05:35 +03:00
BIT-189 Check for data breaches during create account (#154)
This commit is contained in:
parent
2472648434
commit
8864315342
15 changed files with 577 additions and 84 deletions
|
@ -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>
|
||||
}
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -50,6 +50,7 @@ interface AuthRepository {
|
|||
masterPassword: String,
|
||||
masterPasswordHint: String?,
|
||||
captchaToken: String?,
|
||||
shouldCheckDataBreaches: Boolean,
|
||||
): RegisterResult
|
||||
|
||||
/**
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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()
|
|
@ -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<AuthSdkSource> {
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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<CreateAccountViewModel>(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<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
|
||||
fun `when BasicDialogState is Shown should show dialog`() {
|
||||
val viewModel = mockk<CreateAccountViewModel>(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<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
|
||||
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"
|
||||
|
|
Loading…
Reference in a new issue