From c6ce9923429a125ad4f477e9f3f11d20bad67a2b Mon Sep 17 00:00:00 2001
From: Ramsey Smith <142836716+ramsey-livefront@users.noreply.github.com>
Date: Tue, 3 Oct 2023 13:34:51 -0600
Subject: [PATCH] BIT-398: Launch ACTION_VIEW Intent with captcha URL and
handle callback (#88)
---
app/src/main/AndroidManifest.xml | 9 ++
.../java/com/x8bit/bitwarden/MainActivity.kt | 14 +++
.../java/com/x8bit/bitwarden/MainViewModel.kt | 48 ++++++++++
.../datasource/network/api/IdentityApi.kt | 1 +
.../network/service/IdentityService.kt | 2 +
.../network/service/IdentityServiceImpl.kt | 2 +
.../network/util/LoginResultExtensions.kt | 47 +++++++++-
.../data/auth/repository/AuthRepository.kt | 14 +++
.../auth/repository/AuthRepositoryImpl.kt | 18 +++-
.../ui/auth/feature/login/LoginScreen.kt | 6 ++
.../ui/auth/feature/login/LoginViewModel.kt | 47 ++++++++++
.../com/x8bit/bitwarden/MainViewModelTest.kt | 64 +++++++++++++
.../network/service/IdentityServiceTest.kt | 18 +++-
.../network/util/LoginResultExtensionsTest.kt | 44 +++++++++
.../auth/repository/AuthRepositoryTest.kt | 89 +++++++++++++++----
.../auth/feature/login/LoginViewModelTest.kt | 66 +++++++++++---
16 files changed, 453 insertions(+), 36 deletions(-)
create mode 100644 app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt
create mode 100644 app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt
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",