BIT-332: Launch web view for hcaptchca (#73)

This commit is contained in:
Ramsey Smith 2023-09-28 14:19:14 -06:00 committed by Álison Fernandes
parent 5fc78afa0a
commit 02a1445f39
7 changed files with 164 additions and 2 deletions

View file

@ -0,0 +1,32 @@
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 kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.util.Base64
import java.util.Locale
/**
* Generates an [Intent] to display a CAPTCHA challenge for Bitwarden authentication.
*/
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 = "captchaRequiredText", value = "Captcha required")
}
val base64Data = Base64
.getEncoder()
.encodeToString(
json
.toString()
.toByteArray(Charsets.UTF_8),
)
val parentParam = "bitwarden%3A%2F%2Fcaptcha-callback"
val url = "https://vault.bitwarden.com/captcha-mobile-connector.html" +
"?data=$base64Data&parent=$parentParam&v=1"
return Intent(Intent.ACTION_VIEW, Uri.parse(url))
}

View file

@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@ -21,6 +22,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
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.BitwardenTextField
/**
@ -31,11 +33,13 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
fun LoginScreen(
onNavigateToLanding: () -> Unit,
viewModel: LoginViewModel = hiltViewModel(),
intentHandler: IntentHandler = IntentHandler(context = LocalContext.current),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
LoginEvent.NavigateToLanding -> onNavigateToLanding()
is LoginEvent.NavigateToCaptcha -> intentHandler.startActivity(intent = event.intent)
}
}

View file

@ -1,9 +1,11 @@
package com.x8bit.bitwarden.ui.auth.feature.login
import android.content.Intent
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
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
import dagger.hilt.android.lifecycle.HiltViewModel
@ -60,8 +62,13 @@ class LoginViewModel @Inject constructor(
LoginResult.Error -> Unit
// No action required on success, root nav will navigate to logged in state
LoginResult.Success -> Unit
// TODO: launch intent with captcha URL BIT-399
is LoginResult.CaptchaRequired -> Unit
is LoginResult.CaptchaRequired -> {
sendEvent(
event = LoginEvent.NavigateToCaptcha(
intent = result.generateIntentForCaptcha(),
),
)
}
}
}
}
@ -97,6 +104,11 @@ sealed class LoginEvent {
* Navigates to the Landing screen.
*/
data object NavigateToLanding : LoginEvent()
/**
* Navigates to the captcha verification screen.
*/
data class NavigateToCaptcha(val intent: Intent) : LoginEvent()
}
/**

View file

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.ui.platform.base.util
import android.content.Context
import android.content.Intent
/**
* A utility class for simplifying the handling of Android Intents within a given context.
*/
class IntentHandler(private val context: Context) {
/**
* Start an activity using the provided [Intent].
*/
fun startActivity(intent: Intent) {
context.startActivity(intent)
}
}

View file

@ -0,0 +1,22 @@
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 org.junit.Assert.assertEquals
import org.junit.Test
class LoginResultExtensionsTest {
@Test
fun `generateIntentForCaptcha should return valid Intent`() {
val captchaRequired = LoginResult.CaptchaRequired("testCaptchaId")
val intent = captchaRequired.generateIntentForCaptcha()
val expectedUrl = "https://vault.bitwarden.com/captcha-mobile-connector.html" +
"?data=eyJzaXRlS2V5IjoidGVzdENhcHRjaGkxZGQiLCJsb2NhbGUiOiJlbl9VUyJ9" +
"&parent=bitwarden%3A%2F%2Fcaptcha-callback&v=1"
val expectedIntent = Intent(Intent.ACTION_VIEW, Uri.parse(expectedUrl))
assertEquals(expectedIntent.action, intent.action)
assertEquals(expectedIntent.data, intent.data)
}
}

View file

@ -1,9 +1,11 @@
package com.x8bit.bitwarden.ui.auth.feature.login
import android.content.Intent
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
@ -85,4 +87,30 @@ class LoginScreenTest : BaseComposeTest() {
}
assertTrue(onNavigateToLandingCalled)
}
@Test
fun `NavigateToCaptcha should call intentHandler startActivity`() {
val intentHandler = mockk<IntentHandler>(relaxed = true) {
every { startActivity(any()) } returns Unit
}
val mockIntent = mockk<Intent>()
val viewModel = mockk<LoginViewModel>(relaxed = true) {
every { eventFlow } returns flowOf(LoginEvent.NavigateToCaptcha(mockIntent))
every { stateFlow } returns MutableStateFlow(
LoginState(
emailAddress = "",
isLoginButtonEnabled = false,
passwordInput = "",
),
)
}
composeTestRule.setContent {
LoginScreen(
onNavigateToLanding = {},
intentHandler = intentHandler,
viewModel = viewModel,
)
}
verify { intentHandler.startActivity(mockIntent) }
}
}

View file

@ -1,15 +1,22 @@
package com.x8bit.bitwarden.ui.auth.feature.login
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.generateIntentForCaptcha
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
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 LoginViewModelTest : BaseViewModelTest() {
@ -18,6 +25,16 @@ class LoginViewModelTest : BaseViewModelTest() {
it["email_address"] = "test@gmail.com"
}
@BeforeEach
fun setUp() {
mockkStatic(LOGIN_RESULT_PATH)
}
@AfterEach
fun tearDown() {
unmockkStatic(LOGIN_RESULT_PATH)
}
@Test
fun `initial state should be correct`() = runTest {
val viewModel = LoginViewModel(
@ -87,6 +104,33 @@ class LoginViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `LoginButtonClick login returns CaptchaRequired should emit NavigateToCaptcha`() =
runTest {
val mockkIntent = mockk<Intent>()
every {
LoginResult
.CaptchaRequired(captchaId = "mock_captcha_id")
.generateIntentForCaptcha()
} returns mockkIntent
val authRepository = mockk<AuthRepository> {
coEvery { login("test@gmail.com", "") } returns
LoginResult.CaptchaRequired(captchaId = "mock_captcha_id")
}
val viewModel = LoginViewModel(
authRepository = authRepository,
savedStateHandle = savedStateHandle,
)
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LoginAction.LoginButtonClick)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
assertEquals(LoginEvent.NavigateToCaptcha(intent = mockkIntent), awaitItem())
}
coVerify {
authRepository.login(email = "test@gmail.com", password = "")
}
}
@Test
fun `SingleSignOnClick should do nothing`() = runTest {
val viewModel = LoginViewModel(
@ -136,5 +180,8 @@ class LoginViewModelTest : BaseViewModelTest() {
passwordInput = "",
isLoginButtonEnabled = true,
)
private const val LOGIN_RESULT_PATH =
"com.x8bit.bitwarden.data.auth.datasource.network.util.LoginResultExtensionsKt"
}
}