diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3c02b7e9bd..de3400d130 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ tools:targetApi="31"> @@ -22,6 +23,14 @@ + + + + + + diff --git a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt index ee579f97ae..4882fa9fd5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt @@ -1,8 +1,10 @@ package com.x8bit.bitwarden +import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavScreen import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme @@ -13,6 +15,9 @@ import dagger.hilt.android.AndroidEntryPoint */ @AndroidEntryPoint class MainActivity : ComponentActivity() { + + private val mainViewModel: MainViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { var shouldShowSplashScreen = true installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen } @@ -25,4 +30,13 @@ class MainActivity : ComponentActivity() { } } } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + mainViewModel.sendAction( + action = MainAction.ReceiveNewIntent( + intent = intent, + ), + ) + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt new file mode 100644 index 0000000000..286e082283 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt @@ -0,0 +1,48 @@ +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.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** + * A view model that helps launch actions for the [MainActivity]. + */ +@HiltViewModel +class MainViewModel @Inject constructor( + private val authRepository: AuthRepository, +) : ViewModel() { + /** + * Send a [MainAction]. + */ + fun sendAction(action: MainAction) { + when (action) { + is MainAction.ReceiveNewIntent -> handleNewIntentReceived(intent = action.intent) + } + } + + private fun handleNewIntentReceived(intent: Intent) { + val captchaCallbackTokenResult = intent.getCaptchaCallbackTokenResult() + when { + captchaCallbackTokenResult != null -> { + authRepository.setCaptchaCallbackTokenResult( + tokenResult = captchaCallbackTokenResult, + ) + } + + else -> Unit + } + } +} + +/** + * Models actions for the [MainActivity]. + */ +sealed class MainAction { + /** + * Receive Intent by the application. + */ + data class ReceiveNewIntent(val intent: Intent) : MainAction() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/IdentityApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/IdentityApi.kt index 4d809b6766..c07a13f7dd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/IdentityApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/IdentityApi.kt @@ -26,5 +26,6 @@ interface IdentityApi { @Field(value = "deviceName") deviceName: String, @Field(value = "deviceType") deviceType: String, @Field(value = "grant_type") grantType: String, + @Field(value = "captchaResponse") captchaResponse: String?, ): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt index 1bdefb3abb..26af2559b3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt @@ -12,9 +12,11 @@ interface IdentityService { * * @param email user's email address. * @param passwordHash password hashed with the Bitwarden SDK. + * @param captchaToken captcha token to be passed to the API (nullable). */ suspend fun getToken( email: String, passwordHash: String, + captchaToken: String?, ): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt index 06eb8b728d..953b788474 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt @@ -18,6 +18,7 @@ class IdentityServiceImpl constructor( override suspend fun getToken( email: String, passwordHash: String, + captchaToken: String?, ): Result = api .getToken( // TODO: use correct base URL here BIT-328 @@ -33,6 +34,7 @@ class IdentityServiceImpl constructor( grantType = "password", passwordHash = passwordHash, email = email, + captchaResponse = captchaToken, ) .fold( onSuccess = { Result.success(it) }, 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/datasource/network/util/LoginResultExtensions.kt index 03df5bd2fb..90aa871e66 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/datasource/network/util/LoginResultExtensions.kt @@ -5,9 +5,13 @@ 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 import java.util.Base64 import java.util.Locale +private const val CAPTCHA_HOST: String = "captcha-callback" +private const val CALLBACK_URI = "bitwarden://$CAPTCHA_HOST" + /** * Generates an [Intent] to display a CAPTCHA challenge for Bitwarden authentication. */ @@ -15,7 +19,7 @@ fun LoginResult.CaptchaRequired.generateIntentForCaptcha(): Intent { val json = buildJsonObject { put(key = "siteKey", value = captchaId) put(key = "locale", value = Locale.getDefault().toString()) - put(key = "callbackUri", value = "bitwarden://captcha-callback") + put(key = "callbackUri", value = CALLBACK_URI) put(key = "captchaRequiredText", value = "Captcha required") } val base64Data = Base64 @@ -25,8 +29,47 @@ fun LoginResult.CaptchaRequired.generateIntentForCaptcha(): Intent { .toString() .toByteArray(Charsets.UTF_8), ) - val parentParam = "bitwarden%3A%2F%2Fcaptcha-callback" + val parentParam = URLEncoder.encode(CALLBACK_URI, "UTF-8") val url = "https://vault.bitwarden.com/captcha-mobile-connector.html" + "?data=$base64Data&parent=$parentParam&v=1" return Intent(Intent.ACTION_VIEW, Uri.parse(url)) } + +/** + * Retrieves a [CaptchaCallbackTokenResult] from an Intent. There are three possible cases. + * + * - [null]: Intent is not a captcha callback, or data is null. + * + * - [CaptchaCallbackTokenResult.MissingToken]: + * Intent is the captcha callback, but its missing a token value. + * + * - [CaptchaCallbackTokenResult.Success]: + * Intent is the captcha callback, and it has a token. + */ +fun Intent.getCaptchaCallbackTokenResult(): CaptchaCallbackTokenResult? { + val localData = data + return if ( + action == Intent.ACTION_VIEW && localData != null && localData.host == CAPTCHA_HOST + ) { + localData.getQueryParameter("token")?.let { + CaptchaCallbackTokenResult.Success(token = it) + } ?: CaptchaCallbackTokenResult.MissingToken + } else { + null + } +} + +/** + * Sealed class representing the result of captcha callback token extraction. + */ +sealed class CaptchaCallbackTokenResult { + /** + * Represents a missing token in the captcha callback. + */ + data object MissingToken : CaptchaCallbackTokenResult() + + /** + * Represents a token present in the captcha callback. + */ + data class Success(val token: String) : CaptchaCallbackTokenResult() +} 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 c7ef5263bc..992bd68e99 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 @@ -2,6 +2,8 @@ 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 kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow /** @@ -13,6 +15,12 @@ interface AuthRepository { */ val authStateFlow: StateFlow + /** + * Flow of the current [CaptchaCallbackTokenResult]. Subscribers should listen to the flow + * in order to receive updates whenever [setCaptchaCallbackTokenResult] is called. + */ + val captchaTokenResultFlow: Flow + /** * Attempt to login with the given email and password. Updated access token will be reflected * in [authStateFlow]. @@ -20,5 +28,11 @@ interface AuthRepository { suspend fun login( email: String, password: String, + captchaToken: String?, ): LoginResult + + /** + * Set the value of [captchaTokenResultFlow]. + */ + fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult) } 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 313c6689f5..48a7fa5157 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 @@ -8,10 +8,14 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJs import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult 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.platform.datasource.network.interceptor.AuthTokenInterceptor import com.x8bit.bitwarden.data.platform.util.flatMap +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject import javax.inject.Singleton @@ -30,12 +34,15 @@ class AuthRepositoryImpl @Inject constructor( private val mutableAuthStateFlow = MutableStateFlow(AuthState.Unauthenticated) override val authStateFlow: StateFlow = mutableAuthStateFlow.asStateFlow() - /** - * Attempt to login with the given email. - */ + private val mutableCaptchaTokenFlow = + MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) + override val captchaTokenResultFlow: Flow = + mutableCaptchaTokenFlow.asSharedFlow() + override suspend fun login( email: String, password: String, + captchaToken: String?, ): LoginResult = accountsService .preLogin(email = email) .flatMap { @@ -50,6 +57,7 @@ class AuthRepositoryImpl @Inject constructor( identityService.getToken( email = email, passwordHash = passwordHash, + captchaToken = captchaToken, ) } .fold( @@ -70,4 +78,8 @@ class AuthRepositoryImpl @Inject constructor( } }, ) + + override fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult) { + mutableCaptchaTokenFlow.tryEmit(tokenResult) + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt index 891aac4e8d..5e100fa13c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.auth.feature.login +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -36,10 +37,15 @@ fun LoginScreen( intentHandler: IntentHandler = IntentHandler(context = LocalContext.current), ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val context = LocalContext.current EventsEffect(viewModel = viewModel) { event -> when (event) { LoginEvent.NavigateToLanding -> onNavigateToLanding() is LoginEvent.NavigateToCaptcha -> intentHandler.startActivity(intent = event.intent) + is LoginEvent.ShowErrorDialog -> { + // TODO Show proper error Dialog + Toast.makeText(context, event.messageRes, Toast.LENGTH_SHORT).show() + } } } 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 19f79e69b3..9cdbd6f0a0 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 @@ -2,9 +2,12 @@ package com.x8bit.bitwarden.ui.auth.feature.login import android.content.Intent import android.os.Parcelable +import androidx.annotation.StringRes 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.generateIntentForCaptcha import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModel @@ -39,6 +42,15 @@ class LoginViewModel @Inject constructor( stateFlow .onEach { savedStateHandle[KEY_STATE] = it } .launchIn(viewModelScope) + authRepository.captchaTokenResultFlow + .onEach { + sendAction( + LoginAction.Internal.ReceiveCaptchaToken( + tokenResult = it, + ), + ) + } + .launchIn(viewModelScope) } override fun handleAction(action: LoginAction) { @@ -47,15 +59,33 @@ class LoginViewModel @Inject constructor( LoginAction.NotYouButtonClick -> handleNotYouButtonClicked() LoginAction.SingleSignOnClick -> handleSingleSignOnClicked() is LoginAction.PasswordInputChanged -> handlePasswordInputChanged(action) + is LoginAction.Internal.ReceiveCaptchaToken -> { + handleCaptchaTokenReceived(action.tokenResult) + } + } + } + + private fun handleCaptchaTokenReceived(tokenResult: CaptchaCallbackTokenResult) { + when (tokenResult) { + CaptchaCallbackTokenResult.MissingToken -> { + sendEvent(LoginEvent.ShowErrorDialog(messageRes = R.string.captcha_failed)) + } + + is CaptchaCallbackTokenResult.Success -> attemptLogin(captchaToken = tokenResult.token) } } private fun handleLoginButtonClicked() { + attemptLogin(captchaToken = null) + } + + private fun attemptLogin(captchaToken: String?) { viewModelScope.launch { // TODO: show progress here BIT-320 val result = authRepository.login( email = mutableStateFlow.value.emailAddress, password = mutableStateFlow.value.passwordInput, + captchaToken = captchaToken, ) when (result) { // TODO: show an error here BIT-320 @@ -109,6 +139,11 @@ sealed class LoginEvent { * Navigates to the captcha verification screen. */ data class NavigateToCaptcha(val intent: Intent) : LoginEvent() + + /** + * Shows an error pop up with a given message + */ + data class ShowErrorDialog(@StringRes val messageRes: Int) : LoginEvent() } /** @@ -134,4 +169,16 @@ sealed class LoginAction { * Indicates that the password input has changed. */ data class PasswordInputChanged(val input: String) : LoginAction() + + /** + * Models actions that the [LoginViewModel] itself might send. + */ + sealed class Internal : LoginAction() { + /** + * Indicates a captcha callback token has been received. + */ + data class ReceiveCaptchaToken( + val tokenResult: CaptchaCallbackTokenResult, + ) : Internal() + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt new file mode 100644 index 0000000000..35913cbb08 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt @@ -0,0 +1,64 @@ +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.AuthRepository +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.Test +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach + +class MainViewModelTest { + + @BeforeEach + fun setUp() { + mockkStatic(LOGIN_RESULT_PATH) + } + + @AfterEach + fun tearDown() { + unmockkStatic(LOGIN_RESULT_PATH) + } + + @Test + fun `ReceiveNewIntent with captcha host should call setCaptchaCallbackToken`() { + val authRepository = mockk { + every { + setCaptchaCallbackTokenResult( + tokenResult = CaptchaCallbackTokenResult.Success( + token = "mockk_token", + ), + ) + } returns Unit + } + val mockIntent = mockk { + every { data?.host } returns "captcha-callback" + every { data?.getQueryParameter("token") } returns "mockk_token" + every { action } returns Intent.ACTION_VIEW + } + val viewModel = MainViewModel( + authRepository = authRepository, + ) + viewModel.sendAction( + MainAction.ReceiveNewIntent( + intent = mockIntent, + ), + ) + verify { + authRepository.setCaptchaCallbackTokenResult( + tokenResult = CaptchaCallbackTokenResult.Success( + token = "mockk_token", + ), + ) + } + } + + companion object { + private const val LOGIN_RESULT_PATH = + "com.x8bit.bitwarden.data.auth.datasource.network.util.LoginResultExtensionsKt" + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt index 2973e37de6..46ef09db66 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt @@ -24,21 +24,33 @@ class IdentityServiceTest : BaseServiceTest() { @Test fun `getToken when request response is Success should return Success`() = runTest { server.enqueue(MockResponse().setBody(LOGIN_SUCCESS_JSON)) - val result = identityService.getToken(email = EMAIL, passwordHash = PASSWORD_HASH) + val result = identityService.getToken( + email = EMAIL, + passwordHash = PASSWORD_HASH, + captchaToken = null, + ) assertEquals(Result.success(LOGIN_SUCCESS), result) } @Test fun `getToken when request is error should return error`() = runTest { server.enqueue(MockResponse().setResponseCode(500)) - val result = identityService.getToken(email = EMAIL, passwordHash = PASSWORD_HASH) + val result = identityService.getToken( + email = EMAIL, + passwordHash = PASSWORD_HASH, + captchaToken = null, + ) assertTrue(result.isFailure) } @Test fun `getToken when response is CaptchaRequired should return CaptchaRequired`() = runTest { server.enqueue(MockResponse().setResponseCode(400).setBody(CAPTCHA_BODY_JSON)) - val result = identityService.getToken(email = EMAIL, passwordHash = PASSWORD_HASH) + val result = identityService.getToken( + email = EMAIL, + passwordHash = PASSWORD_HASH, + captchaToken = null, + ) assertEquals(Result.success(CAPTCHA_BODY), result) } 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/datasource/network/util/LoginResultExtensionsTest.kt index 724a148af0..3f3da136b0 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/datasource/network/util/LoginResultExtensionsTest.kt @@ -3,6 +3,8 @@ package com.x8bit.bitwarden.data.auth.datasource.network.util import android.content.Intent import android.net.Uri import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult +import io.mockk.every +import io.mockk.mockk import org.junit.Assert.assertEquals import org.junit.Test @@ -19,4 +21,46 @@ class LoginResultExtensionsTest { assertEquals(expectedIntent.action, intent.action) assertEquals(expectedIntent.data, intent.data) } + + @Test + fun `getCaptchaCallbackToken should return null when data is null`() { + val intent = mockk { + every { data } returns null + every { action } returns Intent.ACTION_VIEW + } + val result = intent.getCaptchaCallbackTokenResult() + assertEquals(null, result) + } + + @Test + fun `getCaptchaCallbackToken should return null when action is not Intent ACTION_VIEW`() { + val intent = mockk { + every { data } returns null + every { action } returns Intent.ACTION_ANSWER + } + val result = intent.getCaptchaCallbackTokenResult() + assertEquals(null, result) + } + + @Test + fun `getCaptchaCallbackToken should return MissingToken with missing token parameter`() { + val intent = mockk { + every { data?.getQueryParameter("token") } returns null + every { action } returns Intent.ACTION_VIEW + every { data?.host } returns "captcha-callback" + } + val result = intent.getCaptchaCallbackTokenResult() + assertEquals(CaptchaCallbackTokenResult.MissingToken, result) + } + + @Test + fun `getCaptchaCallbackToken should return Success when token query parameter is present`() { + val intent = mockk { + every { data?.getQueryParameter("token") } returns "myToken" + every { action } returns Intent.ACTION_VIEW + every { data?.host } returns "captcha-callback" + } + val result = intent.getCaptchaCallbackTokenResult() + assertEquals(CaptchaCallbackTokenResult.Success("myToken"), result) + } } 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 98748fa4fc..860bc01d5b 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,5 +1,6 @@ package com.x8bit.bitwarden.data.auth.repository +import app.cash.turbine.test import com.bitwarden.core.Kdf import com.bitwarden.sdk.Client import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState @@ -8,6 +9,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson 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.platform.datasource.network.interceptor.AuthTokenInterceptor import io.mockk.clearMocks import io.mockk.coEvery @@ -49,49 +51,102 @@ class AuthRepositoryTest { @Test fun `login when pre login fails should return Error`() = runTest { - coEvery { accountsService.preLogin(EMAIL) } returns (Result.failure(RuntimeException())) - val result = repository.login(EMAIL, PASSWORD) + coEvery { + accountsService.preLogin(email = EMAIL) + } returns (Result.failure(RuntimeException())) + val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null) assertEquals(LoginResult.Error, result) assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value) - coVerify { accountsService.preLogin(EMAIL) } + coVerify { accountsService.preLogin(email = EMAIL) } } @Test fun `login get token fails should return Error`() = runTest { - coEvery { accountsService.preLogin(EMAIL) } returns Result.success(PRE_LOGIN_SUCCESS) - coEvery { identityService.getToken(EMAIL, PASSWORD_HASH) } + coEvery { + accountsService.preLogin(email = EMAIL) + } returns Result.success(PRE_LOGIN_SUCCESS) + coEvery { + identityService.getToken( + email = EMAIL, + passwordHash = PASSWORD_HASH, + captchaToken = null, + ) + } .returns(Result.failure(RuntimeException())) - val result = repository.login(EMAIL, PASSWORD) + val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null) assertEquals(LoginResult.Error, result) assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value) - coVerify { accountsService.preLogin(EMAIL) } - coVerify { identityService.getToken(EMAIL, PASSWORD_HASH) } + coVerify { accountsService.preLogin(email = EMAIL) } + coVerify { + identityService.getToken( + email = EMAIL, + passwordHash = PASSWORD_HASH, + captchaToken = null, + ) + } } @Test fun `login get token succeeds should return Success and update AuthState`() = runTest { - coEvery { accountsService.preLogin(EMAIL) } returns Result.success(PRE_LOGIN_SUCCESS) - coEvery { identityService.getToken(email = EMAIL, passwordHash = PASSWORD_HASH) } + coEvery { + accountsService.preLogin(email = EMAIL) + } returns Result.success(PRE_LOGIN_SUCCESS) + coEvery { + identityService.getToken( + email = EMAIL, + passwordHash = PASSWORD_HASH, + captchaToken = null, + ) + } .returns(Result.success(GetTokenResponseJson.Success(accessToken = ACCESS_TOKEN))) every { authInterceptor.authToken = ACCESS_TOKEN } returns Unit - val result = repository.login(EMAIL, PASSWORD) + val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null) assertEquals(LoginResult.Success, result) assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value) verify { authInterceptor.authToken = ACCESS_TOKEN } - coVerify { accountsService.preLogin(EMAIL) } - coVerify { identityService.getToken(EMAIL, PASSWORD_HASH) } + coVerify { accountsService.preLogin(email = EMAIL) } + coVerify { + identityService.getToken( + email = EMAIL, + passwordHash = PASSWORD_HASH, + captchaToken = null, + ) + } } @Test fun `login get token returns captcha request should return CaptchaRequired`() = runTest { coEvery { accountsService.preLogin(EMAIL) } returns Result.success(PRE_LOGIN_SUCCESS) - coEvery { identityService.getToken(email = EMAIL, passwordHash = PASSWORD_HASH) } + coEvery { + identityService.getToken( + email = EMAIL, + passwordHash = PASSWORD_HASH, + captchaToken = null, + ) + } .returns(Result.success(GetTokenResponseJson.CaptchaRequired(CAPTCHA_KEY))) - val result = repository.login(EMAIL, PASSWORD) + val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null) assertEquals(LoginResult.CaptchaRequired(CAPTCHA_KEY), result) assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value) - coVerify { accountsService.preLogin(EMAIL) } - coVerify { identityService.getToken(EMAIL, PASSWORD_HASH) } + coVerify { accountsService.preLogin(email = EMAIL) } + coVerify { + identityService.getToken( + email = EMAIL, + passwordHash = PASSWORD_HASH, + captchaToken = null, + ) + } + } + + @Test + fun `setCaptchaCallbackToken should change the value of captchaTokenFlow`() = runTest { + repository.captchaTokenResultFlow.test { + repository.setCaptchaCallbackTokenResult(CaptchaCallbackTokenResult.Success("mockk")) + assertEquals( + CaptchaCallbackTokenResult.Success("mockk"), + awaitItem(), + ) + } } companion object { 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 9ff2ef260f..57862c09ba 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,6 +4,7 @@ import android.content.Intent import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test 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.generateIntentForCaptcha import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest @@ -13,6 +14,7 @@ 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 @@ -38,7 +40,9 @@ class LoginViewModelTest : BaseViewModelTest() { @Test fun `initial state should be correct`() = runTest { val viewModel = LoginViewModel( - authRepository = mockk(), + authRepository = mockk { + every { captchaTokenResultFlow } returns flowOf() + }, savedStateHandle = savedStateHandle, ) viewModel.stateFlow.test { @@ -59,7 +63,9 @@ class LoginViewModelTest : BaseViewModelTest() { ), ) val viewModel = LoginViewModel( - authRepository = mockk(), + authRepository = mockk { + every { captchaTokenResultFlow } returns flowOf() + }, savedStateHandle = handle, ) viewModel.stateFlow.test { @@ -71,7 +77,14 @@ class LoginViewModelTest : BaseViewModelTest() { fun `LoginButtonClick login returns error should do nothing`() = runTest { // TODO: handle and display errors (BIT-320) val authRepository = mockk { - coEvery { login(email = "test@gmail.com", password = "") } returns LoginResult.Error + coEvery { + login( + email = "test@gmail.com", + password = "", + captchaToken = null, + ) + } returns LoginResult.Error + every { captchaTokenResultFlow } returns flowOf() } val viewModel = LoginViewModel( authRepository = authRepository, @@ -82,14 +95,15 @@ class LoginViewModelTest : BaseViewModelTest() { assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) } coVerify { - authRepository.login(email = "test@gmail.com", password = "") + authRepository.login(email = "test@gmail.com", password = "", captchaToken = null) } } @Test fun `LoginButtonClick login returns success should do nothing`() = runTest { val authRepository = mockk { - coEvery { login("test@gmail.com", "") } returns LoginResult.Success + coEvery { login("test@gmail.com", "", captchaToken = null) } returns LoginResult.Success + every { captchaTokenResultFlow } returns flowOf() } val viewModel = LoginViewModel( authRepository = authRepository, @@ -100,7 +114,7 @@ class LoginViewModelTest : BaseViewModelTest() { assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) } coVerify { - authRepository.login(email = "test@gmail.com", password = "") + authRepository.login(email = "test@gmail.com", password = "", captchaToken = null) } } @@ -114,8 +128,9 @@ class LoginViewModelTest : BaseViewModelTest() { .generateIntentForCaptcha() } returns mockkIntent val authRepository = mockk { - coEvery { login("test@gmail.com", "") } returns + coEvery { login("test@gmail.com", "", captchaToken = null) } returns LoginResult.CaptchaRequired(captchaId = "mock_captcha_id") + every { captchaTokenResultFlow } returns flowOf() } val viewModel = LoginViewModel( authRepository = authRepository, @@ -127,14 +142,16 @@ class LoginViewModelTest : BaseViewModelTest() { assertEquals(LoginEvent.NavigateToCaptcha(intent = mockkIntent), awaitItem()) } coVerify { - authRepository.login(email = "test@gmail.com", password = "") + authRepository.login(email = "test@gmail.com", password = "", captchaToken = null) } } @Test fun `SingleSignOnClick should do nothing`() = runTest { val viewModel = LoginViewModel( - authRepository = mockk(), + authRepository = mockk { + every { captchaTokenResultFlow } returns flowOf() + }, savedStateHandle = savedStateHandle, ) viewModel.eventFlow.test { @@ -146,7 +163,9 @@ class LoginViewModelTest : BaseViewModelTest() { @Test fun `NotYouButtonClick should emit NavigateToLanding`() = runTest { val viewModel = LoginViewModel( - authRepository = mockk(), + authRepository = mockk { + every { captchaTokenResultFlow } returns flowOf() + }, savedStateHandle = savedStateHandle, ) viewModel.eventFlow.test { @@ -162,7 +181,9 @@ class LoginViewModelTest : BaseViewModelTest() { fun `PasswordInputChanged should update password input`() = runTest { val input = "input" val viewModel = LoginViewModel( - authRepository = mockk(), + authRepository = mockk { + every { captchaTokenResultFlow } returns flowOf() + }, savedStateHandle = savedStateHandle, ) viewModel.eventFlow.test { @@ -174,6 +195,29 @@ class LoginViewModelTest : BaseViewModelTest() { } } + @Test + fun `captchaTokenFlow success update should trigger a login`() = runTest { + val authRepository = mockk { + every { captchaTokenResultFlow } returns flowOf( + CaptchaCallbackTokenResult.Success("token"), + ) + coEvery { + login( + "test@gmail.com", + "", + captchaToken = "token", + ) + } returns LoginResult.Success + } + LoginViewModel( + authRepository = authRepository, + savedStateHandle = savedStateHandle, + ) + coVerify { + authRepository.login(email = "test@gmail.com", password = "", captchaToken = "token") + } + } + companion object { private val DEFAULT_STATE = LoginState( emailAddress = "test@gmail.com",