BIT-102: Create account functionality (#132)

This commit is contained in:
Ramsey Smith 2023-10-19 09:00:39 -06:00 committed by Álison Fernandes
parent 6f212066e3
commit 79c953b605
35 changed files with 1134 additions and 114 deletions

View file

@ -2,7 +2,7 @@ package com.x8bit.bitwarden
import android.content.Intent
import androidx.lifecycle.ViewModel
import com.x8bit.bitwarden.data.auth.datasource.network.util.getCaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

View file

@ -2,6 +2,8 @@ package com.x8bit.bitwarden.data.auth.datasource.network.api
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import retrofit2.http.Body
import retrofit2.http.POST
@ -12,4 +14,7 @@ interface AccountsApi {
@POST("/accounts/prelogin")
suspend fun preLogin(@Body body: PreLoginRequestJson): Result<PreLoginResponseJson>
@POST("/accounts/register")
suspend fun register(@Body body: RegisterRequestJson): Result<RegisterResponseJson.Success>
}

View file

@ -26,7 +26,8 @@ object NetworkModule {
@Singleton
fun providesAccountService(
@Named(NetworkModule.UNAUTHORIZED) retrofit: Retrofit,
): AccountsService = AccountsServiceImpl(retrofit.create())
json: Json,
): AccountsService = AccountsServiceImpl(retrofit.create(), json)
@Provides
@Singleton

View file

@ -0,0 +1,60 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson.Keys
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body for register.
*
* @param email the email to be registered.
* @param masterPasswordHash the master password (encrypted).
* @param masterPasswordHint the hint for the master password (nullable).
* @param captchaResponse the captcha bypass token.
* @param key the user key for the request (encrypted).
* @param keys a [Keys] object containing public and private keys.
* @param kdfType the kdf type represented as an [Int].
* @param kdfIterations the number of kdf iterations.
*/
@Serializable
data class RegisterRequestJson(
@SerialName("email")
val email: String,
@SerialName("masterPasswordHash")
val masterPasswordHash: String,
@SerialName("masterPasswordHint")
val masterPasswordHint: String?,
@SerialName("captchaResponse")
val captchaResponse: String?,
@SerialName("key")
val key: String,
@SerialName("keys")
val keys: Keys,
@SerialName("kdf")
val kdfType: KdfTypeJson,
@SerialName("kdfIterations")
val kdfIterations: UInt,
) {
/**
* A keys object containing public and private keys.
*
* @param publicKey the public key (encrypted).
* @param encryptedPrivateKey the private key (encrypted).
*/
@Serializable
data class Keys(
@SerialName("publicKey")
val publicKey: String,
@SerialName("encryptedPrivateKey")
val encryptedPrivateKey: String,
)
}

View file

@ -0,0 +1,45 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models response bodies for the register request.
*/
@Serializable
sealed class RegisterResponseJson {
/**
* Models a successful json response of the register request.
*
* @param captchaBypassToken the bypass token.
*/
@Serializable
data class Success(
@SerialName("captchaBypassToken")
val captchaBypassToken: String,
) : RegisterResponseJson()
/**
* Models a json body of a captcha error.
*
* @param validationErrors object containing error validations of the response.
*/
@Serializable
data class CaptchaRequired(
@SerialName("validationErrors")
val validationErrors: ValidationErrors,
) : RegisterResponseJson() {
/**
* Error validations containing a HCaptcha Site Key.
*
* @param captchaKeys keys for attempting captcha verification.
*/
@Serializable
data class ValidationErrors(
@SerialName("HCaptcha_SiteKey")
val captchaKeys: List<String>,
)
}
}

View file

@ -1,6 +1,8 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
/**
* Provides an API for querying accounts endpoints.
@ -11,4 +13,9 @@ interface AccountsService {
* Make pre login request to get KDF params.
*/
suspend fun preLogin(email: String): Result<PreLoginResponseJson>
/**
* Register a new account to Bitwarden.
*/
suspend fun register(body: RegisterRequestJson): Result<RegisterResponseJson>
}

View file

@ -3,11 +3,31 @@ package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
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.platform.datasource.network.model.toBitwardenError
import com.x8bit.bitwarden.data.platform.datasource.network.util.parseErrorBodyOrNull
import kotlinx.serialization.json.Json
import java.net.HttpURLConnection
class AccountsServiceImpl constructor(
private val accountsApi: AccountsApi,
private val json: Json,
) : AccountsService {
override suspend fun preLogin(email: String): Result<PreLoginResponseJson> =
accountsApi.preLogin(PreLoginRequestJson(email = email))
// TODO add error parsing and pass along error message for validations BIT-763
override suspend fun register(body: RegisterRequestJson): Result<RegisterResponseJson> =
accountsApi
.register(body)
.recoverCatching { throwable ->
throwable
.toBitwardenError()
.parseErrorBodyOrNull<RegisterResponseJson.CaptchaRequired>(
code = HttpURLConnection.HTTP_BAD_REQUEST,
json = json,
) ?: throw throwable
}
}

View file

@ -0,0 +1,15 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk.util
import com.bitwarden.core.Kdf
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson.ARGON2_ID
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson.PBKDF2_SHA256
/**
* Convert a [Kdf] to a [KdfTypeJson].
*/
fun Kdf.toKdfTypeJson(): KdfTypeJson =
when (this) {
is Kdf.Argon2id -> ARGON2_ID
is Kdf.Pbkdf2 -> PBKDF2_SHA256
}

View file

@ -1,8 +1,9 @@
package com.x8bit.bitwarden.data.auth.repository
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@ -26,6 +27,12 @@ interface AuthRepository {
*/
var rememberedEmailAddress: String?
/**
* The currently selected region label (`null` if not set).
*/
// TODO replace this with a more robust selected region object BIT-725
var selectedRegionLabel: String
/**
* Attempt to login with the given email and password. Updated access token will be reflected
* in [authStateFlow].
@ -41,6 +48,16 @@ interface AuthRepository {
*/
fun logout()
/**
* Attempt to register a new account with the given parameters.
*/
suspend fun register(
email: String,
masterPassword: String,
masterPasswordHint: String?,
captchaToken: String?,
): RegisterResult
/**
* Set the value of [captchaTokenResultFlow].
*/

View file

@ -1,15 +1,20 @@
package com.x8bit.bitwarden.data.auth.repository
import com.bitwarden.core.Kdf
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.CaptchaRequired
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.Success
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
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.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackTokenResult
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.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
import com.x8bit.bitwarden.data.platform.util.flatMap
@ -23,6 +28,8 @@ import kotlinx.coroutines.flow.update
import javax.inject.Inject
import javax.inject.Singleton
private const val DEFAULT_KDF_ITERATIONS = 600000
/**
* Default implementation of [AuthRepository].
*/
@ -49,6 +56,9 @@ class AuthRepositoryImpl @Inject constructor(
authDiskSource.rememberedEmailAddress = value
}
// TODO Handle selected region functionality BIT-725
override var selectedRegionLabel: String = "bitwarden.us"
override suspend fun login(
email: String,
password: String,
@ -93,6 +103,56 @@ class AuthRepositoryImpl @Inject constructor(
mutableAuthStateFlow.update { AuthState.Unauthenticated }
}
override suspend fun register(
email: String,
masterPassword: String,
masterPasswordHint: String?,
captchaToken: String?,
): RegisterResult {
val kdf = Kdf.Pbkdf2(DEFAULT_KDF_ITERATIONS.toUInt())
return authSdkSource
.makeRegisterKeys(
email = email,
password = masterPassword,
kdf = kdf,
)
.flatMap { registerKeyResponse ->
accountsService.register(
body = RegisterRequestJson(
email = email,
masterPasswordHash = registerKeyResponse.masterPasswordHash,
masterPasswordHint = masterPasswordHint,
captchaResponse = captchaToken,
key = registerKeyResponse.encryptedUserKey,
keys = RegisterRequestJson.Keys(
publicKey = registerKeyResponse.keys.public,
encryptedPrivateKey = registerKeyResponse.keys.private,
),
kdfType = kdf.toKdfTypeJson(),
kdfIterations = kdf.iterations,
),
)
}
.fold(
onSuccess = {
when (it) {
is RegisterResponseJson.CaptchaRequired -> {
it.validationErrors.captchaKeys.firstOrNull()?.let { key ->
RegisterResult.CaptchaRequired(captchaId = key)
} ?: RegisterResult.Error(errorMessage = null)
}
is RegisterResponseJson.Success -> {
RegisterResult.Success(captchaToken = it.captchaBypassToken)
}
}
},
onFailure = {
RegisterResult.Error(errorMessage = null)
},
)
}
override fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult) {
mutableCaptchaTokenFlow.tryEmit(tokenResult)
}

View file

@ -1,4 +1,4 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models high level auth state for the application.

View file

@ -1,4 +1,4 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of logging in.

View file

@ -0,0 +1,27 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of registering a new account.
*/
sealed class RegisterResult {
/**
* Register succeeded.
*
* @param captchaToken the captcha bypass token to bypass future captcha verifications.
*/
data class Success(val captchaToken: String) : RegisterResult()
/**
* Captcha verification is required.
*
* @param captchaId the captcha id for performing the captcha verification.
*/
data class CaptchaRequired(val captchaId: String) : RegisterResult()
/**
* There was an error logging in.
*
* @param errorMessage a message describing the error.
*/
data class Error(val errorMessage: String?) : RegisterResult()
}

View file

@ -1,8 +1,7 @@
package com.x8bit.bitwarden.data.auth.datasource.network.util
package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
import android.net.Uri
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.net.URLEncoder
@ -15,7 +14,7 @@ private const val CALLBACK_URI = "bitwarden://$CAPTCHA_HOST"
/**
* Generates a [Uri] to display a CAPTCHA challenge for Bitwarden authentication.
*/
fun LoginResult.CaptchaRequired.generateUriForCaptcha(): Uri {
fun generateUriForCaptcha(captchaId: String): Uri {
val json = buildJsonObject {
put(key = "siteKey", value = captchaId)
put(key = "locale", value = Locale.getDefault().toString())

View file

@ -4,6 +4,7 @@ import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavOptions
import androidx.navigation.navOptions
import androidx.navigation.navigation
import com.x8bit.bitwarden.ui.auth.feature.createaccount.createAccountDestinations
import com.x8bit.bitwarden.ui.auth.feature.createaccount.navigateToCreateAccount
@ -22,11 +23,25 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
startDestination = LANDING_ROUTE,
route = AUTH_GRAPH_ROUTE,
) {
createAccountDestinations(onNavigateBack = { navController.popBackStack() })
createAccountDestinations(
onNavigateBack = { navController.popBackStack() },
onNavigateToLogin = { emailAddress, captchaToken ->
navController.navigateToLogin(
emailAddress = emailAddress,
captchaToken = captchaToken,
navOptions = navOptions {
popUpTo(LANDING_ROUTE)
},
)
},
)
landingDestinations(
onNavigateToCreateAccount = { navController.navigateToCreateAccount() },
onNavigateToLogin = { emailAddress, regionLabel ->
navController.navigateToLogin(emailAddress, regionLabel)
onNavigateToLogin = { emailAddress ->
navController.navigateToLogin(
emailAddress = emailAddress,
captchaToken = null,
)
},
)
loginDestinations(

View file

@ -19,8 +19,12 @@ fun NavController.navigateToCreateAccount(navOptions: NavOptions? = null) {
*/
fun NavGraphBuilder.createAccountDestinations(
onNavigateBack: () -> Unit,
onNavigateToLogin: (emailAddress: String, captchaToken: String) -> Unit,
) {
composable(route = CREATE_ACCOUNT_ROUTE) {
CreateAccountScreen(onNavigateBack)
CreateAccountScreen(
onNavigateBack = onNavigateBack,
onNavigateToLogin = onNavigateToLogin,
)
}
}

View file

@ -57,6 +57,7 @@ import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountEvent.Navi
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
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
@ -70,6 +71,7 @@ import com.x8bit.bitwarden.ui.platform.theme.clickableSpanStyle
@Composable
fun CreateAccountScreen(
onNavigateBack: () -> Unit,
onNavigateToLogin: (emailAddress: String, captchaToken: String) -> Unit,
intentHandler: IntentHandler = IntentHandler(context = LocalContext.current),
viewModel: CreateAccountViewModel = hiltViewModel(),
) {
@ -89,13 +91,26 @@ fun CreateAccountScreen(
is CreateAccountEvent.ShowToast -> {
Toast.makeText(context, event.text, Toast.LENGTH_SHORT).show()
}
is CreateAccountEvent.NavigateToCaptcha -> {
intentHandler.startCustomTabsActivity(uri = event.uri)
}
is CreateAccountEvent.NavigateToLogin -> {
onNavigateToLogin(
event.email,
event.captchaToken,
)
}
}
}
BitwardenBasicDialog(
visibilityState = state.errorDialogState,
onDismissRequest = remember(viewModel) { { viewModel.trySendAction(ErrorDialogDismiss) } },
)
BitwardenLoadingDialog(
visibilityState = state.loadingDialogState,
)
Column(
modifier = Modifier
.fillMaxSize()

View file

@ -1,9 +1,14 @@
package com.x8bit.bitwarden.ui.auth.feature.createaccount
import android.net.Uri
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
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.generateUriForCaptcha
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
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
@ -16,10 +21,12 @@ import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.Ter
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@ -33,6 +40,7 @@ private const val MIN_PASSWORD_LENGTH = 12
@HiltViewModel
class CreateAccountViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val authRepository: AuthRepository,
) : BaseViewModel<CreateAccountState, CreateAccountEvent, CreateAccountAction>(
initialState = savedStateHandle[KEY_STATE]
?: CreateAccountState(
@ -43,6 +51,7 @@ class CreateAccountViewModel @Inject constructor(
isAcceptPoliciesToggled = false,
isCheckDataBreachesToggled = false,
errorDialogState = BasicDialogState.Hidden,
loadingDialogState = LoadingDialogState.Hidden,
),
) {
@ -51,6 +60,16 @@ class CreateAccountViewModel @Inject constructor(
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
authRepository
.captchaTokenResultFlow
.onEach {
sendAction(
CreateAccountAction.Internal.ReceiveCaptchaToken(
tokenResult = it,
),
)
}
.launchIn(viewModelScope)
}
override fun handleAction(action: CreateAccountAction) {
@ -66,6 +85,73 @@ class CreateAccountViewModel @Inject constructor(
is CheckDataBreachesToggle -> handleCheckDataBreachesToggle(action)
is PrivacyPolicyClick -> handlePrivacyPolicyClick()
is TermsClick -> handleTermsClick()
is CreateAccountAction.Internal.ReceiveRegisterResult -> {
handleReceiveRegisterAccountResult(action)
}
is CreateAccountAction.Internal.ReceiveCaptchaToken -> {
handleReceiveCaptchaToken(action)
}
}
}
private fun handleReceiveCaptchaToken(
action: CreateAccountAction.Internal.ReceiveCaptchaToken,
) {
when (val result = action.tokenResult) {
is CaptchaCallbackTokenResult.MissingToken -> {
mutableStateFlow.update {
it.copy(
errorDialogState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.captcha_failed.asText(),
),
)
}
}
is CaptchaCallbackTokenResult.Success -> {
submitRegisterAccountRequest(captchaToken = result.token)
}
}
}
private fun handleReceiveRegisterAccountResult(
action: CreateAccountAction.Internal.ReceiveRegisterResult,
) {
when (val registerAccountResult = action.registerResult) {
is RegisterResult.CaptchaRequired -> {
mutableStateFlow.update { it.copy(loadingDialogState = LoadingDialogState.Hidden) }
sendEvent(
CreateAccountEvent.NavigateToCaptcha(
uri = generateUriForCaptcha(captchaId = registerAccountResult.captchaId),
),
)
}
is RegisterResult.Error -> {
// TODO show more robust error messages BIT-763
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(),
),
)
}
}
is RegisterResult.Success -> {
mutableStateFlow.update { it.copy(loadingDialogState = LoadingDialogState.Hidden) }
sendEvent(
CreateAccountEvent.NavigateToLogin(
email = mutableStateFlow.value.emailInput,
captchaToken = registerAccountResult.captchaToken,
),
)
}
}
}
@ -121,7 +207,30 @@ class CreateAccountViewModel @Inject constructor(
}
else -> {
sendEvent(CreateAccountEvent.ShowToast("TODO: Handle Submit Click"))
submitRegisterAccountRequest(captchaToken = null)
}
}
private fun submitRegisterAccountRequest(captchaToken: String?) {
mutableStateFlow.update {
it.copy(
loadingDialogState = LoadingDialogState.Shown(
text = R.string.creating_account.asText(),
),
)
}
viewModelScope.launch {
val result = authRepository.register(
email = mutableStateFlow.value.emailInput,
masterPassword = mutableStateFlow.value.passwordInput,
masterPasswordHint = mutableStateFlow.value.passwordHintInput.ifBlank { null },
captchaToken = captchaToken,
)
sendAction(
CreateAccountAction.Internal.ReceiveRegisterResult(
registerResult = result,
),
)
}
}
}
@ -138,6 +247,7 @@ data class CreateAccountState(
val isCheckDataBreachesToggled: Boolean,
val isAcceptPoliciesToggled: Boolean,
val errorDialogState: BasicDialogState,
val loadingDialogState: LoadingDialogState,
) : Parcelable
/**
@ -155,6 +265,19 @@ sealed class CreateAccountEvent {
*/
data class ShowToast(val text: String) : CreateAccountEvent()
/**
* Navigates to the captcha verification screen.
*/
data class NavigateToCaptcha(val uri: Uri) : CreateAccountEvent()
/**
* Navigates to the captcha verification screen.
*/
data class NavigateToLogin(
val email: String,
val captchaToken: String,
) : CreateAccountEvent()
/**
* Navigate to terms and conditions.
*/
@ -224,4 +347,23 @@ sealed class CreateAccountAction {
* User tapped terms link.
*/
data object TermsClick : CreateAccountAction()
/**
* Models actions that the [CreateAccountViewModel] itself might send.
*/
sealed class Internal : CreateAccountAction() {
/**
* Indicates a captcha callback token has been received.
*/
data class ReceiveCaptchaToken(
val tokenResult: CaptchaCallbackTokenResult,
) : Internal()
/**
* Indicates a [RegisterResult] has been received.
*/
data class ReceiveRegisterResult(
val registerResult: RegisterResult,
) : Internal()
}
}

View file

@ -19,7 +19,7 @@ fun NavController.navigateToLanding(navOptions: NavOptions? = null) {
*/
fun NavGraphBuilder.landingDestinations(
onNavigateToCreateAccount: () -> Unit,
onNavigateToLogin: (emailAddress: String, regionLabel: String) -> Unit,
onNavigateToLogin: (emailAddress: String) -> Unit,
) {
composable(route = LANDING_ROUTE) {
LandingScreen(

View file

@ -54,7 +54,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
@Suppress("LongMethod")
fun LandingScreen(
onNavigateToCreateAccount: () -> Unit,
onNavigateToLogin: (emailAddress: String, regionLabel: String) -> Unit,
onNavigateToLogin: (emailAddress: String) -> Unit,
viewModel: LandingViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
@ -63,7 +63,6 @@ fun LandingScreen(
LandingEvent.NavigateToCreateAccount -> onNavigateToCreateAccount()
is LandingEvent.NavigateToLogin -> onNavigateToLogin(
event.emailAddress,
event.regionLabel,
)
}
}

View file

@ -66,12 +66,13 @@ class LandingViewModel @Inject constructor(
val email = mutableStateFlow.value.emailInput
val isRememberMeEnabled = mutableStateFlow.value.isRememberMeEnabled
val selectedRegionLabel = mutableStateFlow.value.selectedRegion.label
// Update the remembered email address
authRepository.rememberedEmailAddress = email.takeUnless { !isRememberMeEnabled }
// Update the selected region selectedRegionLabel
authRepository.selectedRegionLabel = mutableStateFlow.value.selectedRegion.label
sendEvent(LandingEvent.NavigateToLogin(email, selectedRegionLabel))
sendEvent(LandingEvent.NavigateToLogin(email))
}
private fun handleCreateAccountClicked() {
@ -125,7 +126,6 @@ sealed class LandingEvent {
*/
data class NavigateToLogin(
val emailAddress: String,
val regionLabel: String,
) : LandingEvent()
}

View file

@ -9,16 +9,16 @@ import androidx.navigation.compose.composable
import androidx.navigation.navArgument
private const val EMAIL_ADDRESS: String = "email_address"
private const val REGION_LABEL: String = "region_label"
private const val LOGIN_ROUTE: String = "login/{$EMAIL_ADDRESS}/{$REGION_LABEL}"
private const val CAPTCHA_TOKEN = "captcha_token"
private const val LOGIN_ROUTE: String = "login/{$EMAIL_ADDRESS}/{$CAPTCHA_TOKEN}"
/**
* Class to retrieve login arguments from the [SavedStateHandle].
*/
class LoginArgs(val emailAddress: String, val regionLabel: String) {
class LoginArgs(val emailAddress: String, val captchaToken: String?) {
constructor(savedStateHandle: SavedStateHandle) : this(
checkNotNull(savedStateHandle[EMAIL_ADDRESS]) as String,
checkNotNull(savedStateHandle[REGION_LABEL]) as String,
savedStateHandle[CAPTCHA_TOKEN],
)
}
@ -27,10 +27,10 @@ class LoginArgs(val emailAddress: String, val regionLabel: String) {
*/
fun NavController.navigateToLogin(
emailAddress: String,
regionLabel: String,
captchaToken: String?,
navOptions: NavOptions? = null,
) {
this.navigate("login/$emailAddress/$regionLabel", navOptions)
this.navigate("login/$emailAddress/$captchaToken", navOptions)
}
/**

View file

@ -7,9 +7,9 @@ import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.datasource.network.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -38,9 +38,10 @@ class LoginViewModel @Inject constructor(
emailAddress = LoginArgs(savedStateHandle).emailAddress,
isLoginButtonEnabled = true,
passwordInput = "",
region = LoginArgs(savedStateHandle).regionLabel,
region = authRepository.selectedRegionLabel,
loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Hidden,
captchaToken = LoginArgs(savedStateHandle).captchaToken,
),
) {
@ -84,7 +85,7 @@ class LoginViewModel @Inject constructor(
mutableStateFlow.update { it.copy(loadingDialogState = LoadingDialogState.Hidden) }
sendEvent(
event = LoginEvent.NavigateToCaptcha(
uri = loginResult.generateUriForCaptcha(),
uri = generateUriForCaptcha(captchaId = loginResult.captchaId),
),
)
}
@ -123,7 +124,12 @@ class LoginViewModel @Inject constructor(
}
}
is CaptchaCallbackTokenResult.Success -> attemptLogin(captchaToken = tokenResult.token)
is CaptchaCallbackTokenResult.Success -> {
mutableStateFlow.update {
it.copy(captchaToken = tokenResult.token)
}
attemptLogin()
}
}
}
@ -132,10 +138,10 @@ class LoginViewModel @Inject constructor(
}
private fun handleLoginButtonClicked() {
attemptLogin(captchaToken = null)
attemptLogin()
}
private fun attemptLogin(captchaToken: String?) {
private fun attemptLogin() {
mutableStateFlow.update {
it.copy(
loadingDialogState = LoadingDialogState.Shown(
@ -147,7 +153,7 @@ class LoginViewModel @Inject constructor(
val result = authRepository.login(
email = mutableStateFlow.value.emailAddress,
password = mutableStateFlow.value.passwordInput,
captchaToken = captchaToken,
captchaToken = mutableStateFlow.value.captchaToken,
)
sendAction(
LoginAction.Internal.ReceiveLoginResult(
@ -183,6 +189,7 @@ class LoginViewModel @Inject constructor(
data class LoginState(
val passwordInput: String,
val emailAddress: String,
val captchaToken: String?,
val region: String,
val isLoginButtonEnabled: Boolean,
val loadingDialogState: LoadingDialogState,

View file

@ -3,7 +3,7 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel

View file

@ -1,7 +1,7 @@
package com.x8bit.bitwarden
import android.content.Intent
import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import io.mockk.every
import io.mockk.mockk

View file

@ -1,18 +1,26 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson.PBKDF2_SHA256
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
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.platform.base.BaseServiceTest
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import retrofit2.create
class AccountsServiceTest : BaseServiceTest() {
private val accountsApi: AccountsApi = retrofit.create()
private val service = AccountsServiceImpl(accountsApi)
private val service = AccountsServiceImpl(
accountsApi = accountsApi,
json = Json,
)
@Test
fun `preLogin with unknown kdf type be failure`() = runTest {
@ -93,7 +101,77 @@ class AccountsServiceTest : BaseServiceTest() {
assertEquals(Result.success(expectedResponse), service.preLogin(EMAIL))
}
@Test
fun `register success json should be Success`() = runTest {
val json = """
{
"captchaBypassToken": "mock_token"
}
"""
val expectedResponse = RegisterResponseJson.Success(
captchaBypassToken = "mock_token",
)
val response = MockResponse().setBody(json)
server.enqueue(response)
assertEquals(Result.success(expectedResponse), service.register(registerRequestBody))
}
@Test
fun `register failure json should be failure`() = runTest {
val json = """
{
"message": "The model state is invalid.",
"validationErrors": {
"": [
"Email '' is already taken."
]
},
"exceptionMessage": null,
"exceptionStackTrace": null,
"innerExceptionMessage": null,
"object": "error"
}
"""
val response = MockResponse().setResponseCode(400).setBody(json)
server.enqueue(response)
assertTrue(service.register(registerRequestBody).isFailure)
}
@Test
fun `register captcha json should be CaptchaRequired`() = runTest {
val json = """
{
"validationErrors": {
"HCaptcha_SiteKey": [
"mock_token"
]
}
}
"""
val expectedResponse = RegisterResponseJson.CaptchaRequired(
validationErrors = RegisterResponseJson.CaptchaRequired.ValidationErrors(
captchaKeys = listOf("mock_token"),
),
)
val response = MockResponse().setResponseCode(400).setBody(json)
server.enqueue(response)
assertEquals(Result.success(expectedResponse), service.register(registerRequestBody))
}
companion object {
private const val EMAIL = "email"
private val registerRequestBody = RegisterRequestJson(
email = EMAIL,
masterPasswordHash = "mockk_masterPasswordHash",
masterPasswordHint = "mockk_masterPasswordHint",
captchaResponse = "mockk_captchaResponse",
key = "mockk_key",
keys = RegisterRequestJson.Keys(
publicKey = "mockk_publicKey",
encryptedPrivateKey = "mockk_encryptedPrivateKey",
),
kdfType = PBKDF2_SHA256,
kdfIterations = 600000U,
)
}
}

View file

@ -1,15 +1,22 @@
package com.x8bit.bitwarden.data.auth.repository
import app.cash.turbine.test
import com.bitwarden.core.Kdf
import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.core.RsaKeyPair
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson.PBKDF2_SHA256
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
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.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackTokenResult
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.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
import io.mockk.clearMocks
@ -38,6 +45,22 @@ class AuthRepositoryTest {
kdf = PRE_LOGIN_SUCCESS.kdfParams.toSdkParams(),
)
} returns Result.success(PASSWORD_HASH)
coEvery {
makeRegisterKeys(
email = EMAIL,
password = PASSWORD,
kdf = Kdf.Pbkdf2(DEFAULT_KDF_ITERATIONS.toUInt()),
)
} returns Result.success(
RegisterKeyResponse(
masterPasswordHash = PASSWORD_HASH,
encryptedUserKey = ENCRYPTED_USER_KEY,
keys = RsaKeyPair(
public = PUBLIC_KEY,
private = PRIVATE_KEY,
),
),
)
}
private val repository = AuthRepositoryImpl(
@ -116,16 +139,14 @@ class AuthRepositoryTest {
passwordHash = PASSWORD_HASH,
captchaToken = null,
)
}
.returns(
Result.success(
GetTokenResponseJson.Invalid(
errorModel = GetTokenResponseJson.Invalid.ErrorModel(
errorMessage = "mock_error_message",
),
),
} returns Result.success(
GetTokenResponseJson.Invalid(
errorModel = GetTokenResponseJson.Invalid.ErrorModel(
errorMessage = "mock_error_message",
),
)
),
)
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
assertEquals(LoginResult.Error(errorMessage = "mock_error_message"), result)
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
@ -194,6 +215,144 @@ class AuthRepositoryTest {
}
}
@Test
fun `register Success should return Success`() = runTest {
coEvery { accountsService.preLogin(EMAIL) } returns Result.success(PRE_LOGIN_SUCCESS)
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,
)
assertEquals(RegisterResult.Success(CAPTCHA_KEY), result)
}
@Test
fun `register returns CaptchaRequired captchaKeys empty should return Error no message`() =
runTest {
coEvery { accountsService.preLogin(EMAIL) } returns Result.success(PRE_LOGIN_SUCCESS)
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.CaptchaRequired(
validationErrors = RegisterResponseJson
.CaptchaRequired
.ValidationErrors(
captchaKeys = emptyList(),
),
),
)
val result = repository.register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
)
assertEquals(RegisterResult.Error(errorMessage = null), result)
}
@Test
fun `register returns CaptchaRequired captchaKeys should return CaptchaRequired`() =
runTest {
coEvery { accountsService.preLogin(EMAIL) } returns Result.success(PRE_LOGIN_SUCCESS)
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.CaptchaRequired(
validationErrors = RegisterResponseJson
.CaptchaRequired
.ValidationErrors(
captchaKeys = listOf(CAPTCHA_KEY),
),
),
)
val result = repository.register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
)
assertEquals(RegisterResult.CaptchaRequired(captchaId = CAPTCHA_KEY), result)
}
@Test
fun `register Failure should return Error with no message`() = runTest {
coEvery { accountsService.preLogin(EMAIL) } returns Result.success(PRE_LOGIN_SUCCESS)
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.failure(RuntimeException())
val result = repository.register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
)
assertEquals(RegisterResult.Error(errorMessage = null), result)
}
@Test
fun `setCaptchaCallbackToken should change the value of captchaTokenFlow`() = runTest {
repository.captchaTokenResultFlow.test {
@ -240,6 +399,10 @@ class AuthRepositoryTest {
private const val PASSWORD_HASH = "passwordHash"
private const val ACCESS_TOKEN = "accessToken"
private const val CAPTCHA_KEY = "captcha"
private const val DEFAULT_KDF_ITERATIONS = 600000
private const val ENCRYPTED_USER_KEY = "encryptedUserKey"
private const val PUBLIC_KEY = "PublicKey"
private const val PRIVATE_KEY = "privateKey"
private val PRE_LOGIN_SUCCESS = PreLoginResponseJson(
kdfParams = PreLoginResponseJson.KdfParams.Pbkdf2(iterations = 1u),
)

View file

@ -1,20 +1,18 @@
package com.x8bit.bitwarden.data.auth.datasource.network.util
package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
import android.net.Uri
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Test
class LoginResultExtensionsTest : BaseComposeTest() {
class CaptchaUtilsTest : BaseComposeTest() {
@Test
fun `generateIntentForCaptcha should return valid Uri`() {
val captchaRequired = LoginResult.CaptchaRequired("testCaptchaId")
val actualUri = captchaRequired.generateUriForCaptcha()
val actualUri = generateUriForCaptcha(captchaId = "testCaptchaId")
val expectedUrl = "https://vault.bitwarden.com/captcha-mobile-connector.html" +
"?data=eyJzaXRlS2V5IjoidGVzdENhcHRjaGFJZCIsImxvY2FsZSI6ImVuX1VTIiwiY2Fsb" +
"GJhY2tVcmkiOiJiaXR3YXJkZW46Ly9jYXB0Y2hhLWNhbGxiYWNrIiwiY2FwdGNoYVJlcXVp" +

View file

@ -27,12 +27,14 @@ 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
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import org.junit.Assert.assertTrue
import org.junit.Test
class CreateAccountScreenTest : BaseComposeTest() {
@ -45,7 +47,11 @@ class CreateAccountScreenTest : BaseComposeTest() {
every { trySendAction(SubmitClick) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel)
CreateAccountScreen(
onNavigateBack = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
composeTestRule.onNodeWithText("Submit").performClick()
verify { viewModel.trySendAction(SubmitClick) }
@ -59,7 +65,11 @@ class CreateAccountScreenTest : BaseComposeTest() {
every { trySendAction(CloseClick) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel)
CreateAccountScreen(
onNavigateBack = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
composeTestRule.onNodeWithContentDescription("Close").performClick()
verify { viewModel.trySendAction(CloseClick) }
@ -73,7 +83,11 @@ class CreateAccountScreenTest : BaseComposeTest() {
every { trySendAction(CheckDataBreachesToggle(true)) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel)
CreateAccountScreen(
onNavigateBack = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
composeTestRule
.onNodeWithText("Check known data breaches for this password")
@ -90,7 +104,11 @@ class CreateAccountScreenTest : BaseComposeTest() {
every { trySendAction(AcceptPoliciesToggle(true)) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel)
CreateAccountScreen(
onNavigateBack = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
composeTestRule
.onNodeWithText("By activating this switch you agree", substring = true)
@ -108,9 +126,63 @@ class CreateAccountScreenTest : BaseComposeTest() {
every { eventFlow } returns flowOf(CreateAccountEvent.NavigateBack)
}
composeTestRule.setContent {
CreateAccountScreen(onNavigateBack = onNavigateBack, viewModel = viewModel)
CreateAccountScreen(
onNavigateBack = onNavigateBack,
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
assertTrue(onNavigateBackCalled)
}
@Test
fun `NavigateToLogin event should invoke navigate login lambda`() {
var onNavigateToLoginCalled = false
val onNavigateToLogin = { _: String, _: String -> onNavigateToLoginCalled = true }
val viewModel = mockk<CreateAccountViewModel>(relaxed = true) {
every { stateFlow } returns MutableStateFlow(DEFAULT_STATE)
every { eventFlow } returns flowOf(
CreateAccountEvent.NavigateToLogin(
email = "",
captchaToken = "",
),
)
}
composeTestRule.setContent {
CreateAccountScreen(
onNavigateBack = {},
onNavigateToLogin = onNavigateToLogin,
viewModel = viewModel,
)
}
assertTrue(onNavigateToLoginCalled)
}
@Test
fun `NavigateToCaptcha event should invoke intent handler`() {
val mockUri = mockk<Uri>()
val intentHandler = mockk<IntentHandler>(relaxed = true) {
every { startCustomTabsActivity(mockUri) } returns Unit
}
val viewModel = mockk<CreateAccountViewModel>(relaxed = true) {
every { stateFlow } returns MutableStateFlow(DEFAULT_STATE)
every { eventFlow } returns flowOf(
CreateAccountEvent.NavigateToCaptcha(
uri = mockUri,
),
)
}
composeTestRule.setContent {
CreateAccountScreen(
onNavigateBack = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
intentHandler = intentHandler,
)
}
verify {
intentHandler.startCustomTabsActivity(mockUri)
}
assert(onNavigateBackCalled)
}
@Test
@ -127,6 +199,7 @@ class CreateAccountScreenTest : BaseComposeTest() {
composeTestRule.setContent {
CreateAccountScreen(
onNavigateBack = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
intentHandler = intentHandler,
)
@ -150,6 +223,7 @@ class CreateAccountScreenTest : BaseComposeTest() {
composeTestRule.setContent {
CreateAccountScreen(
onNavigateBack = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
intentHandler = intentHandler,
)
@ -167,7 +241,11 @@ class CreateAccountScreenTest : BaseComposeTest() {
every { trySendAction(EmailInputChange("input")) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel)
CreateAccountScreen(
onNavigateBack = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
composeTestRule.onNodeWithText("Email address").performTextInput(TEST_INPUT)
verify { viewModel.trySendAction(EmailInputChange(TEST_INPUT)) }
@ -181,7 +259,11 @@ class CreateAccountScreenTest : BaseComposeTest() {
every { trySendAction(PasswordInputChange("input")) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel)
CreateAccountScreen(
onNavigateBack = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
composeTestRule.onNodeWithText("Master password").performTextInput(TEST_INPUT)
verify { viewModel.trySendAction(PasswordInputChange(TEST_INPUT)) }
@ -195,7 +277,11 @@ class CreateAccountScreenTest : BaseComposeTest() {
every { trySendAction(ConfirmPasswordInputChange("input")) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel)
CreateAccountScreen(
onNavigateBack = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
composeTestRule.onNodeWithText("Re-type master password").performTextInput(TEST_INPUT)
verify { viewModel.trySendAction(ConfirmPasswordInputChange(TEST_INPUT)) }
@ -209,7 +295,11 @@ class CreateAccountScreenTest : BaseComposeTest() {
every { trySendAction(PasswordHintChange("input")) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel)
CreateAccountScreen(
onNavigateBack = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
composeTestRule
.onNodeWithText("Master password hint (optional)")
@ -232,7 +322,11 @@ class CreateAccountScreenTest : BaseComposeTest() {
every { trySendAction(CreateAccountAction.ErrorDialogDismiss) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel)
CreateAccountScreen(
onNavigateBack = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
composeTestRule
.onAllNodesWithText("Ok")
@ -256,7 +350,11 @@ class CreateAccountScreenTest : BaseComposeTest() {
every { trySendAction(CreateAccountAction.ErrorDialogDismiss) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel)
CreateAccountScreen(
onNavigateBack = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
composeTestRule.onNode(isDialog()).assertIsDisplayed()
}
@ -268,7 +366,11 @@ class CreateAccountScreenTest : BaseComposeTest() {
every { eventFlow } returns emptyFlow()
}
composeTestRule.setContent {
CreateAccountScreen(onNavigateBack = {}, viewModel = viewModel)
CreateAccountScreen(
onNavigateBack = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
// should start with 2 Show buttons:
@ -306,6 +408,7 @@ class CreateAccountScreenTest : BaseComposeTest() {
isCheckDataBreachesToggled = false,
isAcceptPoliciesToggled = false,
errorDialogState = BasicDialogState.Hidden,
loadingDialogState = LoadingDialogState.Hidden,
)
}
}

View file

@ -1,26 +1,57 @@
package com.x8bit.bitwarden.ui.auth.feature.createaccount
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import app.cash.turbine.testIn
import app.cash.turbine.turbineScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
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.EmailInputChange
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.CreateAccountEvent.ShowToast
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.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class CreateAccountViewModelTest : BaseViewModelTest() {
private val mockAuthRepository = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
}
@BeforeEach
fun setUp() {
mockkStatic(LOGIN_RESULT_PATH)
}
@AfterEach
fun tearDown() {
unmockkStatic(LOGIN_RESULT_PATH)
}
@Test
fun `initial state should be correct`() {
val viewModel = CreateAccountViewModel(SavedStateHandle())
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@ -34,15 +65,22 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
isCheckDataBreachesToggled = false,
isAcceptPoliciesToggled = false,
errorDialogState = BasicDialogState.Hidden,
loadingDialogState = LoadingDialogState.Hidden,
)
val handle = SavedStateHandle(mapOf("state" to savedState))
val viewModel = CreateAccountViewModel(handle)
val viewModel = CreateAccountViewModel(
savedStateHandle = handle,
authRepository = mockAuthRepository,
)
assertEquals(savedState, viewModel.stateFlow.value)
}
@Test
fun `SubmitClick with password below 12 chars should show password length dialog`() = runTest {
val viewModel = CreateAccountViewModel(SavedStateHandle())
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
val input = "abcdefghikl"
viewModel.trySendAction(PasswordInputChange("abcdefghikl"))
val expectedState = DEFAULT_STATE.copy(
@ -59,18 +97,181 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
}
@Test
fun `SubmitClick with long enough password emit ShowToast`() = runTest {
val viewModel = CreateAccountViewModel(SavedStateHandle())
fun `SubmitClick with long enough password should show and hide loading dialog`() = runTest {
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = "",
masterPassword = "longenoughpassword",
masterPasswordHint = null,
captchaToken = null,
)
} returns RegisterResult.Success(captchaToken = "mock_token")
}
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = repo,
)
turbineScope {
val stateFlow = viewModel.stateFlow.testIn(backgroundScope)
val eventFlow = viewModel.eventFlow.testIn(backgroundScope)
assertEquals(
DEFAULT_STATE,
stateFlow.awaitItem(),
)
viewModel.trySendAction(PasswordInputChange("longenoughpassword"))
assertEquals(
DEFAULT_STATE.copy(passwordInput = "longenoughpassword"),
stateFlow.awaitItem(),
)
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
assertEquals(
DEFAULT_STATE.copy(
passwordInput = "longenoughpassword",
loadingDialogState = LoadingDialogState.Shown(
text = R.string.creating_account.asText(),
),
),
stateFlow.awaitItem(),
)
assertEquals(
CreateAccountEvent.NavigateToLogin(
email = "",
captchaToken = "mock_token",
),
eventFlow.awaitItem(),
)
assertEquals(
DEFAULT_STATE.copy(
passwordInput = "longenoughpassword",
loadingDialogState = LoadingDialogState.Hidden,
),
stateFlow.awaitItem(),
)
}
}
@Test
fun `SubmitClick register returns error should update errorDialogState`() = runTest {
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = "",
masterPassword = "longenoughpassword",
masterPasswordHint = null,
captchaToken = null,
)
} returns RegisterResult.Error(errorMessage = "mock_error")
}
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = repo,
)
viewModel.trySendAction(PasswordInputChange("longenoughpassword"))
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(passwordInput = "longenoughpassword"),
awaitItem(),
)
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
assertEquals(
DEFAULT_STATE.copy(
passwordInput = "longenoughpassword",
loadingDialogState = LoadingDialogState.Shown(
text = R.string.creating_account.asText(),
),
),
awaitItem(),
)
assertEquals(
DEFAULT_STATE.copy(
passwordInput = "longenoughpassword",
loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = "mock_error".asText(),
),
),
awaitItem(),
)
}
}
@Test
fun `SubmitClick register returns CaptchaRequired should emit NavigateToCaptcha`() = runTest {
val mockkUri = mockk<Uri>()
every {
generateUriForCaptcha(captchaId = "mock_captcha_id")
} returns mockkUri
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = "",
masterPassword = "longenoughpassword",
masterPasswordHint = null,
captchaToken = null,
)
} returns RegisterResult.CaptchaRequired(captchaId = "mock_captcha_id")
}
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = repo,
)
viewModel.trySendAction(PasswordInputChange("longenoughpassword"))
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
assertEquals(ShowToast("TODO: Handle Submit Click"), awaitItem())
assertEquals(
CreateAccountEvent.NavigateToCaptcha(uri = mockkUri),
awaitItem(),
)
}
}
@Test
fun `SubmitClick register returns Success should emit NavigateToLogin`() = runTest {
val mockkUri = mockk<Uri>()
every {
generateUriForCaptcha(captchaId = "mock_captcha_id")
} returns mockkUri
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = "",
masterPassword = "longenoughpassword",
masterPasswordHint = null,
captchaToken = null,
)
} returns RegisterResult.Success(captchaToken = "mock_captcha_token")
}
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = repo,
)
viewModel.trySendAction(PasswordInputChange("longenoughpassword"))
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
assertEquals(
CreateAccountEvent.NavigateToLogin(
email = "",
captchaToken = "mock_captcha_token",
),
awaitItem(),
)
}
}
@Test
fun `CloseClick should emit NavigateBack`() = runTest {
val viewModel = CreateAccountViewModel(SavedStateHandle())
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(CloseClick)
assertEquals(CreateAccountEvent.NavigateBack, awaitItem())
@ -79,7 +280,10 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
@Test
fun `PrivacyPolicyClick should emit NavigatePrivacyPolicy`() = runTest {
val viewModel = CreateAccountViewModel(SavedStateHandle())
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(CreateAccountAction.PrivacyPolicyClick)
assertEquals(CreateAccountEvent.NavigateToPrivacyPolicy, awaitItem())
@ -88,7 +292,10 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
@Test
fun `TermsClick should emit NavigateToTerms`() = runTest {
val viewModel = CreateAccountViewModel(SavedStateHandle())
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(CreateAccountAction.TermsClick)
assertEquals(CreateAccountEvent.NavigateToTerms, awaitItem())
@ -97,7 +304,10 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
@Test
fun `ConfirmPasswordInputChange update passwordInput`() = runTest {
val viewModel = CreateAccountViewModel(SavedStateHandle())
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.actionChannel.trySend(ConfirmPasswordInputChange("input"))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(confirmPasswordInput = "input"), awaitItem())
@ -106,7 +316,10 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
@Test
fun `EmailInputChange update passwordInput`() = runTest {
val viewModel = CreateAccountViewModel(SavedStateHandle())
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.actionChannel.trySend(EmailInputChange("input"))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(emailInput = "input"), awaitItem())
@ -115,7 +328,10 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
@Test
fun `PasswordHintChange update passwordInput`() = runTest {
val viewModel = CreateAccountViewModel(SavedStateHandle())
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.actionChannel.trySend(PasswordHintChange("input"))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(passwordHintInput = "input"), awaitItem())
@ -124,7 +340,10 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
@Test
fun `PasswordInputChange update passwordInput`() = runTest {
val viewModel = CreateAccountViewModel(SavedStateHandle())
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.actionChannel.trySend(PasswordInputChange("input"))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(passwordInput = "input"), awaitItem())
@ -133,7 +352,10 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
@Test
fun `CheckDataBreachesToggle should change isCheckDataBreachesToggled`() = runTest {
val viewModel = CreateAccountViewModel(SavedStateHandle())
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.trySendAction(CreateAccountAction.CheckDataBreachesToggle(true))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(isCheckDataBreachesToggled = true), awaitItem())
@ -142,7 +364,10 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
@Test
fun `AcceptPoliciesToggle should change isAcceptPoliciesToggled`() = runTest {
val viewModel = CreateAccountViewModel(SavedStateHandle())
val viewModel = CreateAccountViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockAuthRepository,
)
viewModel.trySendAction(CreateAccountAction.AcceptPoliciesToggle(true))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(isAcceptPoliciesToggled = true), awaitItem())
@ -158,6 +383,9 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
isCheckDataBreachesToggled = false,
isAcceptPoliciesToggled = false,
errorDialogState = BasicDialogState.Hidden,
loadingDialogState = LoadingDialogState.Hidden,
)
private const val LOGIN_RESULT_PATH =
"com.x8bit.bitwarden.data.auth.repository.util.CaptchaUtilsKt"
}
}

View file

@ -35,7 +35,7 @@ class LandingScreenTest : BaseComposeTest() {
composeTestRule.setContent {
LandingScreen(
onNavigateToCreateAccount = {},
onNavigateToLogin = { _, _ -> },
onNavigateToLogin = { _ -> },
viewModel = viewModel,
)
}
@ -62,7 +62,7 @@ class LandingScreenTest : BaseComposeTest() {
composeTestRule.setContent {
LandingScreen(
onNavigateToCreateAccount = {},
onNavigateToLogin = { _, _ -> },
onNavigateToLogin = { _ -> },
viewModel = viewModel,
)
}
@ -88,7 +88,7 @@ class LandingScreenTest : BaseComposeTest() {
composeTestRule.setContent {
LandingScreen(
onNavigateToCreateAccount = {},
onNavigateToLogin = { _, _ -> },
onNavigateToLogin = { _ -> },
viewModel = viewModel,
)
}
@ -116,7 +116,7 @@ class LandingScreenTest : BaseComposeTest() {
composeTestRule.setContent {
LandingScreen(
onNavigateToCreateAccount = {},
onNavigateToLogin = { _, _ -> },
onNavigateToLogin = { _ -> },
viewModel = viewModel,
)
}
@ -143,7 +143,7 @@ class LandingScreenTest : BaseComposeTest() {
composeTestRule.setContent {
LandingScreen(
onNavigateToCreateAccount = {},
onNavigateToLogin = { _, _ -> },
onNavigateToLogin = { _ -> },
viewModel = viewModel,
)
}
@ -170,7 +170,7 @@ class LandingScreenTest : BaseComposeTest() {
composeTestRule.setContent {
LandingScreen(
onNavigateToCreateAccount = { onNavigateToCreateAccountCalled = true },
onNavigateToLogin = { _, _ -> },
onNavigateToLogin = { _ -> },
viewModel = viewModel,
)
}
@ -180,13 +180,11 @@ class LandingScreenTest : BaseComposeTest() {
@Test
fun `NavigateToLogin event should call onNavigateToLogin`() {
val testEmail = "test@test.com"
val testRegion = "bitwarden.com"
var capturedEmail: String? = null
var capturedRegion: String? = null
val viewModel = mockk<LandingViewModel>(relaxed = true) {
every { eventFlow } returns flowOf(LandingEvent.NavigateToLogin(testEmail, testRegion))
every { eventFlow } returns flowOf(LandingEvent.NavigateToLogin(testEmail))
every { stateFlow } returns MutableStateFlow(
LandingState(
emailInput = "",
@ -200,16 +198,14 @@ class LandingScreenTest : BaseComposeTest() {
composeTestRule.setContent {
LandingScreen(
onNavigateToCreateAccount = { },
onNavigateToLogin = { email, region ->
onNavigateToLogin = { email ->
capturedEmail = email
capturedRegion = region
},
viewModel = viewModel,
)
}
assertEquals(testEmail, capturedEmail)
assertEquals(testRegion, capturedRegion)
}
@Test
@ -230,7 +226,7 @@ class LandingScreenTest : BaseComposeTest() {
composeTestRule.setContent {
LandingScreen(
onNavigateToCreateAccount = {},
onNavigateToLogin = { _, _ -> },
onNavigateToLogin = { _ -> },
viewModel = viewModel,
)
}

View file

@ -55,7 +55,7 @@ class LandingViewModelTest : BaseViewModelTest() {
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LandingAction.ContinueButtonClick)
assertEquals(
LandingEvent.NavigateToLogin("input", "bitwarden.com"),
LandingEvent.NavigateToLogin("input"),
awaitItem(),
)
}

View file

@ -34,6 +34,7 @@ class LoginScreenTest : BaseComposeTest() {
every { stateFlow } returns MutableStateFlow(
LoginState(
emailAddress = "",
captchaToken = null,
isLoginButtonEnabled = false,
passwordInput = "",
region = "",
@ -61,6 +62,7 @@ class LoginScreenTest : BaseComposeTest() {
every { stateFlow } returns MutableStateFlow(
LoginState(
emailAddress = "",
captchaToken = null,
isLoginButtonEnabled = false,
passwordInput = "",
region = "",
@ -88,6 +90,7 @@ class LoginScreenTest : BaseComposeTest() {
every { stateFlow } returns MutableStateFlow(
LoginState(
emailAddress = "",
captchaToken = null,
isLoginButtonEnabled = false,
passwordInput = "",
region = "",
@ -115,6 +118,7 @@ class LoginScreenTest : BaseComposeTest() {
every { stateFlow } returns MutableStateFlow(
LoginState(
emailAddress = "",
captchaToken = null,
isLoginButtonEnabled = false,
passwordInput = "",
region = "",
@ -154,6 +158,7 @@ class LoginScreenTest : BaseComposeTest() {
every { stateFlow } returns MutableStateFlow(
LoginState(
emailAddress = "",
captchaToken = null,
isLoginButtonEnabled = false,
passwordInput = "",
region = "",
@ -182,6 +187,7 @@ class LoginScreenTest : BaseComposeTest() {
every { stateFlow } returns MutableStateFlow(
LoginState(
emailAddress = "",
captchaToken = null,
isLoginButtonEnabled = false,
passwordInput = "",
region = "",
@ -210,6 +216,7 @@ class LoginScreenTest : BaseComposeTest() {
every { stateFlow } returns MutableStateFlow(
LoginState(
emailAddress = "",
captchaToken = null,
isLoginButtonEnabled = false,
passwordInput = "",
region = "",

View file

@ -4,9 +4,9 @@ import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.datasource.network.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -47,6 +47,7 @@ class LoginViewModelTest : BaseViewModelTest() {
val viewModel = LoginViewModel(
authRepository = mockk {
every { captchaTokenResultFlow } returns flowOf()
every { selectedRegionLabel } returns "bitwarden.us"
},
savedStateHandle = savedStateHandle,
)
@ -83,6 +84,7 @@ class LoginViewModelTest : BaseViewModelTest() {
val viewModel = LoginViewModel(
authRepository = mockk {
every { captchaTokenResultFlow } returns flowOf()
every { selectedRegionLabel } returns "bitwarden.us"
},
savedStateHandle = savedStateHandle,
)
@ -106,6 +108,7 @@ class LoginViewModelTest : BaseViewModelTest() {
)
} returns LoginResult.Error(errorMessage = "mock_error")
every { captchaTokenResultFlow } returns flowOf()
every { selectedRegionLabel } returns "bitwarden.us"
}
val viewModel = LoginViewModel(
authRepository = authRepository,
@ -145,6 +148,7 @@ class LoginViewModelTest : BaseViewModelTest() {
login("test@gmail.com", "", captchaToken = null)
} returns LoginResult.Success
every { captchaTokenResultFlow } returns flowOf()
every { selectedRegionLabel } returns "bitwarden.us"
}
val viewModel = LoginViewModel(
authRepository = authRepository,
@ -176,14 +180,13 @@ class LoginViewModelTest : BaseViewModelTest() {
runTest {
val mockkUri = mockk<Uri>()
every {
LoginResult
.CaptchaRequired(captchaId = "mock_captcha_id")
.generateUriForCaptcha()
generateUriForCaptcha(captchaId = "mock_captcha_id")
} returns mockkUri
val authRepository = mockk<AuthRepository> {
coEvery { login("test@gmail.com", "", captchaToken = null) } returns
LoginResult.CaptchaRequired(captchaId = "mock_captcha_id")
every { captchaTokenResultFlow } returns flowOf()
every { selectedRegionLabel } returns "bitwarden.us"
}
val viewModel = LoginViewModel(
authRepository = authRepository,
@ -204,6 +207,7 @@ class LoginViewModelTest : BaseViewModelTest() {
val viewModel = LoginViewModel(
authRepository = mockk {
every { captchaTokenResultFlow } returns flowOf()
every { selectedRegionLabel } returns "bitwarden.us"
},
savedStateHandle = savedStateHandle,
)
@ -222,6 +226,7 @@ class LoginViewModelTest : BaseViewModelTest() {
val viewModel = LoginViewModel(
authRepository = mockk {
every { captchaTokenResultFlow } returns flowOf()
every { selectedRegionLabel } returns "bitwarden.us"
},
savedStateHandle = savedStateHandle,
)
@ -240,6 +245,7 @@ class LoginViewModelTest : BaseViewModelTest() {
val viewModel = LoginViewModel(
authRepository = mockk {
every { captchaTokenResultFlow } returns flowOf()
every { selectedRegionLabel } returns "bitwarden.us"
},
savedStateHandle = savedStateHandle,
)
@ -258,6 +264,7 @@ class LoginViewModelTest : BaseViewModelTest() {
val viewModel = LoginViewModel(
authRepository = mockk {
every { captchaTokenResultFlow } returns flowOf()
every { selectedRegionLabel } returns "bitwarden.us"
},
savedStateHandle = savedStateHandle,
)
@ -276,6 +283,7 @@ class LoginViewModelTest : BaseViewModelTest() {
every { captchaTokenResultFlow } returns flowOf(
CaptchaCallbackTokenResult.Success("token"),
)
every { selectedRegionLabel } returns "bitwarden.us"
coEvery {
login(
"test@gmail.com",
@ -298,12 +306,13 @@ class LoginViewModelTest : BaseViewModelTest() {
emailAddress = "test@gmail.com",
passwordInput = "",
isLoginButtonEnabled = true,
region = "",
region = "bitwarden.us",
loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Hidden,
captchaToken = null,
)
private const val LOGIN_RESULT_PATH =
"com.x8bit.bitwarden.data.auth.datasource.network.util.LoginResultExtensionsKt"
"com.x8bit.bitwarden.data.auth.repository.util.CaptchaUtilsKt"
}
}

View file

@ -1,9 +1,9 @@
package com.x8bit.bitwarden.ui.platform.feature.rootnav
import androidx.lifecycle.SavedStateHandle
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState.Authenticated
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState.Unauthenticated
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.AuthState.Authenticated
import com.x8bit.bitwarden.data.auth.repository.model.AuthState.Unauthenticated
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.every