mirror of
https://github.com/bitwarden/android.git
synced 2025-03-16 19:28:44 +03:00
BIT-398: Launch ACTION_VIEW Intent with captcha URL and handle callback (#88)
This commit is contained in:
parent
9d9ee38070
commit
c6ce992342
16 changed files with 453 additions and 36 deletions
|
@ -15,6 +15,7 @@
|
|||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true"
|
||||
android:theme="@style/LaunchTheme">
|
||||
<intent-filter>
|
||||
|
@ -22,6 +23,14 @@
|
|||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="bitwarden"
|
||||
android:host="captcha-callback" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
48
app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt
Normal file
48
app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt
Normal file
|
@ -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()
|
||||
}
|
|
@ -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<GetTokenResponseJson.Success>
|
||||
}
|
||||
|
|
|
@ -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<GetTokenResponseJson>
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ class IdentityServiceImpl constructor(
|
|||
override suspend fun getToken(
|
||||
email: String,
|
||||
passwordHash: String,
|
||||
captchaToken: String?,
|
||||
): Result<GetTokenResponseJson> = 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) },
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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<AuthState>
|
||||
|
||||
/**
|
||||
* Flow of the current [CaptchaCallbackTokenResult]. Subscribers should listen to the flow
|
||||
* in order to receive updates whenever [setCaptchaCallbackTokenResult] is called.
|
||||
*/
|
||||
val captchaTokenResultFlow: Flow<CaptchaCallbackTokenResult>
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
|
|
@ -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>(AuthState.Unauthenticated)
|
||||
override val authStateFlow: StateFlow<AuthState> = mutableAuthStateFlow.asStateFlow()
|
||||
|
||||
/**
|
||||
* Attempt to login with the given email.
|
||||
*/
|
||||
private val mutableCaptchaTokenFlow =
|
||||
MutableSharedFlow<CaptchaCallbackTokenResult>(extraBufferCapacity = Int.MAX_VALUE)
|
||||
override val captchaTokenResultFlow: Flow<CaptchaCallbackTokenResult> =
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
64
app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt
Normal file
64
app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt
Normal file
|
@ -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<AuthRepository> {
|
||||
every {
|
||||
setCaptchaCallbackTokenResult(
|
||||
tokenResult = CaptchaCallbackTokenResult.Success(
|
||||
token = "mockk_token",
|
||||
),
|
||||
)
|
||||
} returns Unit
|
||||
}
|
||||
val mockIntent = mockk<Intent> {
|
||||
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"
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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<Intent> {
|
||||
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<Intent> {
|
||||
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<Intent> {
|
||||
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<Intent> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<AuthRepository> {
|
||||
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<AuthRepository> {
|
||||
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<AuthRepository> {
|
||||
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<AuthRepository> {
|
||||
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",
|
||||
|
|
Loading…
Add table
Reference in a new issue