mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +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.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(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
masterPassword: String,
|
||||||
masterPasswordHint: String?,
|
masterPasswordHint: String?,
|
||||||
captchaToken: String?,
|
captchaToken: String?,
|
||||||
|
shouldCheckDataBreaches: Boolean,
|
||||||
): RegisterResult
|
): 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.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(
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
BitwardenBasicDialog(
|
||||||
visibilityState = state.errorDialogState,
|
visibilityState = dialog.state,
|
||||||
onDismissRequest = remember(viewModel) { { viewModel.trySendAction(ErrorDialogDismiss) } },
|
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(
|
BitwardenLoadingDialog(
|
||||||
visibilityState = state.loadingDialogState,
|
visibilityState = LoadingDialogState.Shown(R.string.create_account.asText()),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.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.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(
|
||||||
|
BasicDialogState.Shown(
|
||||||
title = R.string.an_error_has_occurred.asText(),
|
title = R.string.an_error_has_occurred.asText(),
|
||||||
message = R.string.captcha_failed.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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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.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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,11 +311,13 @@ 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(
|
||||||
|
BasicDialogState.Shown(
|
||||||
title = "title".asText(),
|
title = "title".asText(),
|
||||||
message = "message".asText(),
|
message = "message".asText(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
every { eventFlow } returns emptyFlow()
|
every { eventFlow } returns emptyFlow()
|
||||||
every { trySendAction(CreateAccountAction.ErrorDialogDismiss) } returns Unit
|
every { trySendAction(CreateAccountAction.ErrorDialogDismiss) } returns Unit
|
||||||
|
@ -335,16 +336,64 @@ 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(
|
||||||
|
BasicDialogState.Shown(
|
||||||
title = "title".asText(),
|
title = "title".asText(),
|
||||||
message = "message".asText(),
|
message = "message".asText(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
every { eventFlow } returns emptyFlow()
|
every { eventFlow } returns emptyFlow()
|
||||||
every { trySendAction(CreateAccountAction.ErrorDialogDismiss) } returns Unit
|
every { trySendAction(CreateAccountAction.ErrorDialogDismiss) } returns Unit
|
||||||
|
@ -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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,10 +90,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(
|
||||||
|
BasicDialogState.Shown(
|
||||||
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(),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
|
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
|
||||||
viewModel.stateFlow.test {
|
viewModel.stateFlow.test {
|
||||||
|
@ -112,11 +113,13 @@ 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(
|
||||||
|
BasicDialogState.Shown(
|
||||||
title = R.string.an_error_has_occurred.asText(),
|
title = R.string.an_error_has_occurred.asText(),
|
||||||
message = R.string.validation_field_required
|
message = R.string.validation_field_required
|
||||||
.asText(R.string.email_address.asText()),
|
.asText(R.string.email_address.asText()),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
|
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
|
||||||
viewModel.stateFlow.test {
|
viewModel.stateFlow.test {
|
||||||
|
@ -136,10 +139,12 @@ 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(
|
||||||
|
BasicDialogState.Shown(
|
||||||
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(12),
|
message = R.string.master_password_length_val_message_x.asText(12),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
|
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
|
||||||
viewModel.stateFlow.test {
|
viewModel.stateFlow.test {
|
||||||
|
@ -159,10 +164,12 @@ 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(
|
||||||
|
BasicDialogState.Shown(
|
||||||
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(),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
|
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
|
||||||
viewModel.stateFlow.test {
|
viewModel.stateFlow.test {
|
||||||
|
@ -184,10 +191,12 @@ 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(
|
||||||
|
BasicDialogState.Shown(
|
||||||
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(),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
|
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
|
||||||
viewModel.stateFlow.test {
|
viewModel.stateFlow.test {
|
||||||
|
@ -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,21 +265,18 @@ 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"
|
||||||
|
|
Loading…
Reference in a new issue