diff --git a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt index 286e08228..abd63484b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt @@ -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 diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AccountsApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AccountsApi.kt index f96a9fab0..f033a3e6c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AccountsApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AccountsApi.kt @@ -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 + + @POST("/accounts/register") + suspend fun register(@Body body: RegisterRequestJson): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/NetworkModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/NetworkModule.kt index 5cde71498..664e08d23 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/NetworkModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/NetworkModule.kt @@ -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 diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/RegisterRequestJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/RegisterRequestJson.kt new file mode 100644 index 000000000..6d125dd79 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/RegisterRequestJson.kt @@ -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, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/RegisterResponseJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/RegisterResponseJson.kt new file mode 100644 index 000000000..305c0f2bb --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/RegisterResponseJson.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt index 487aab25d..d65ba484d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt @@ -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 + + /** + * Register a new account to Bitwarden. + */ + suspend fun register(body: RegisterRequestJson): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt index 8883164ae..7d2fa4637 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt @@ -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 = 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 = + accountsApi + .register(body) + .recoverCatching { throwable -> + throwable + .toBitwardenError() + .parseErrorBodyOrNull( + code = HttpURLConnection.HTTP_BAD_REQUEST, + json = json, + ) ?: throw throwable + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/sdk/util/KdfExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/sdk/util/KdfExtensions.kt new file mode 100644 index 000000000..e212ce516 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/sdk/util/KdfExtensions.kt @@ -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 + } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index dbcfb990b..2ba4ca7da 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -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]. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index 1318339e7..04379e70d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -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) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/AuthState.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthState.kt similarity index 88% rename from app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/AuthState.kt rename to app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthState.kt index e8f11eafa..3d1830197 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/AuthState.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthState.kt @@ -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. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/LoginResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/LoginResult.kt similarity index 86% rename from app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/LoginResult.kt rename to app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/LoginResult.kt index 3179108f1..37c510268 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/LoginResult.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/LoginResult.kt @@ -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. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/RegisterResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/RegisterResult.kt new file mode 100644 index 000000000..251a5c6dc --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/RegisterResult.kt @@ -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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/util/LoginResultExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/CaptchaUtils.kt similarity index 92% rename from app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/util/LoginResultExtensions.kt rename to app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/CaptchaUtils.kt index fcb1cb053..c7b1d7797 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/util/LoginResultExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/CaptchaUtils.kt @@ -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()) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt index 63124919e..09e02b71c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt @@ -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( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountNavigation.kt index 9ed804b72..364e655e4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountNavigation.kt @@ -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, + ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt index 63196b6cf..3dfaa107d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt @@ -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() diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt index 3d26cd6cb..580284124 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt @@ -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( 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() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingNavigation.kt index d37b34f0d..63a1b534f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingNavigation.kt @@ -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( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt index fd500f71c..19c7774b7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt @@ -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, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt index 7e5dc8a3c..c1dd73077 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt @@ -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() } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt index 053b48fc0..ff47d13e9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt @@ -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) } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt index 88dba9be1..ec2973a81 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt @@ -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, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index ffce750b9..b72270760 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -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 diff --git a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt index 35913cbb0..b997fa2a7 100644 --- a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt @@ -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 diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt index 04eff473c..aba9f54ac 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt @@ -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, + ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 9d1fdc737..19b1d8286 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -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), ) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/util/LoginResultExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/CaptchaUtilsTest.kt similarity index 88% rename from app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/util/LoginResultExtensionsTest.kt rename to app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/CaptchaUtilsTest.kt index aaf186972..3822c02a4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/util/LoginResultExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/CaptchaUtilsTest.kt @@ -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" + diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt index 70b3cece8..14b65d06a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreenTest.kt @@ -27,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(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() + val intentHandler = mockk(relaxed = true) { + every { startCustomTabsActivity(mockUri) } returns Unit + } + val viewModel = mockk(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, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt index 0aed9296a..b55a9f1a6 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModelTest.kt @@ -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 { + 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 { + 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 { + 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() + every { + generateUriForCaptcha(captchaId = "mock_captcha_id") + } returns mockkUri + val repo = mockk { + 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() + every { + generateUriForCaptcha(captchaId = "mock_captcha_id") + } returns mockkUri + val repo = mockk { + 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" } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt index 338ea6f15..74d3aff74 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt @@ -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(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, ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt index 4d8fc0898..2abfd1113 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt @@ -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(), ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt index a1b44b90b..60562a669 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt @@ -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 = "", diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt index dc1c2b2d0..59f749eec 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt @@ -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() every { - LoginResult - .CaptchaRequired(captchaId = "mock_captcha_id") - .generateUriForCaptcha() + generateUriForCaptcha(captchaId = "mock_captcha_id") } returns mockkUri val authRepository = mockk { 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" } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index 9d6cb166e..4c35830ff 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -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