mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 18:58:59 +03:00
BIT-1560: Successfully login with device (#892)
This commit is contained in:
parent
2127dcbb1d
commit
087018bd26
10 changed files with 819 additions and 14 deletions
|
@ -36,6 +36,7 @@ interface IdentityApi {
|
|||
@Field(value = "twoFactorToken") twoFactorCode: String?,
|
||||
@Field(value = "twoFactorProvider") twoFactorMethod: String?,
|
||||
@Field(value = "twoFactorRemember") twoFactorRemember: String?,
|
||||
@Field(value = "authRequest") authRequestId: String?,
|
||||
): Result<GetTokenResponseJson.Success>
|
||||
|
||||
@GET("/account/prevalidate")
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
package com.x8bit.bitwarden.data.auth.datasource.network.model
|
||||
|
||||
/**
|
||||
* Hold the information necessary to add authorization with device to a login request.
|
||||
*/
|
||||
data class DeviceDataModel(
|
||||
val accessCode: String,
|
||||
val masterPasswordHash: String?,
|
||||
val asymmetricalKey: String,
|
||||
val privateKey: String,
|
||||
)
|
|
@ -34,6 +34,26 @@ sealed class IdentityTokenAuthModel {
|
|||
*/
|
||||
abstract val ssoRedirectUri: String?
|
||||
|
||||
/**
|
||||
* The ID of the auth request that granted this login.
|
||||
*/
|
||||
abstract val authRequestId: String?
|
||||
|
||||
/**
|
||||
* The data for logging in with a username and password.
|
||||
*/
|
||||
data class AuthRequest(
|
||||
override val username: String,
|
||||
override val authRequestId: String,
|
||||
val accessCode: String,
|
||||
) : IdentityTokenAuthModel() {
|
||||
override val grantType: String get() = "password"
|
||||
override val password: String get() = accessCode
|
||||
override val ssoCode: String? get() = null
|
||||
override val ssoCodeVerifier: String? get() = null
|
||||
override val ssoRedirectUri: String? get() = null
|
||||
}
|
||||
|
||||
/**
|
||||
* The data for logging in with a username and password.
|
||||
*/
|
||||
|
@ -42,6 +62,7 @@ sealed class IdentityTokenAuthModel {
|
|||
override val password: String,
|
||||
) : IdentityTokenAuthModel() {
|
||||
override val grantType: String get() = "password"
|
||||
override val authRequestId: String? get() = null
|
||||
override val ssoCode: String? get() = null
|
||||
override val ssoCodeVerifier: String? get() = null
|
||||
override val ssoRedirectUri: String? get() = null
|
||||
|
@ -56,6 +77,7 @@ sealed class IdentityTokenAuthModel {
|
|||
override val ssoRedirectUri: String,
|
||||
) : IdentityTokenAuthModel() {
|
||||
override val grantType: String get() = "authorization_code"
|
||||
override val authRequestId: String? get() = null
|
||||
override val username: String? get() = null
|
||||
override val password: String? get() = null
|
||||
}
|
||||
|
|
|
@ -44,6 +44,7 @@ class IdentityServiceImpl constructor(
|
|||
twoFactorMethod = twoFactorData?.method,
|
||||
twoFactorRemember = twoFactorData?.remember?.let { if (it) "1" else "0 " },
|
||||
captchaResponse = captchaToken,
|
||||
authRequestId = authModel.authRequestId,
|
||||
)
|
||||
.recoverCatching { throwable ->
|
||||
val bitwardenError = throwable.toBitwardenError()
|
||||
|
|
|
@ -116,6 +116,21 @@ interface AuthRepository : AuthenticatorProvider {
|
|||
captchaToken: String?,
|
||||
): LoginResult
|
||||
|
||||
/**
|
||||
* Attempt to login with the given email and auth request ID and access code. The updated
|
||||
* access token will be reflected in [authStateFlow].
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
suspend fun login(
|
||||
email: String,
|
||||
requestId: String,
|
||||
accessCode: String,
|
||||
asymmetricalKey: String,
|
||||
requestPrivateKey: String,
|
||||
masterPasswordHash: String?,
|
||||
captchaToken: String?,
|
||||
): LoginResult
|
||||
|
||||
/**
|
||||
* Repeat the previous login attempt but this time with Two-Factor authentication
|
||||
* information. Password is included if available to unlock the vault after
|
||||
|
|
|
@ -2,10 +2,12 @@ package com.x8bit.bitwarden.data.auth.repository
|
|||
|
||||
import android.os.SystemClock
|
||||
import com.bitwarden.core.AuthRequestResponse
|
||||
import com.bitwarden.core.InitUserCryptoMethod
|
||||
import com.bitwarden.crypto.HashPurpose
|
||||
import com.bitwarden.crypto.Kdf
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeviceDataModel
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.CaptchaRequired
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.Success
|
||||
|
@ -76,6 +78,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
|||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
@ -89,6 +92,7 @@ import kotlinx.coroutines.flow.flow
|
|||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.isActive
|
||||
import java.time.Clock
|
||||
|
@ -214,10 +218,9 @@ class AuthRepositoryImpl(
|
|||
),
|
||||
)
|
||||
|
||||
private val mutableCaptchaTokenFlow =
|
||||
bufferedMutableSharedFlow<CaptchaCallbackTokenResult>()
|
||||
private val captchaTokenChannel = Channel<CaptchaCallbackTokenResult>(capacity = Int.MAX_VALUE)
|
||||
override val captchaTokenResultFlow: Flow<CaptchaCallbackTokenResult> =
|
||||
mutableCaptchaTokenFlow.asSharedFlow()
|
||||
captchaTokenChannel.receiveAsFlow()
|
||||
|
||||
private val mutableSsoCallbackResultFlow =
|
||||
bufferedMutableSharedFlow<SsoCallbackResult>()
|
||||
|
@ -346,6 +349,31 @@ class AuthRepositoryImpl(
|
|||
onSuccess = { it },
|
||||
)
|
||||
|
||||
override suspend fun login(
|
||||
email: String,
|
||||
requestId: String,
|
||||
accessCode: String,
|
||||
asymmetricalKey: String,
|
||||
requestPrivateKey: String,
|
||||
masterPasswordHash: String?,
|
||||
captchaToken: String?,
|
||||
): LoginResult =
|
||||
loginCommon(
|
||||
email = email,
|
||||
authModel = IdentityTokenAuthModel.AuthRequest(
|
||||
username = email,
|
||||
authRequestId = requestId,
|
||||
accessCode = accessCode,
|
||||
),
|
||||
deviceData = DeviceDataModel(
|
||||
accessCode = accessCode,
|
||||
masterPasswordHash = masterPasswordHash,
|
||||
asymmetricalKey = asymmetricalKey,
|
||||
privateKey = requestPrivateKey,
|
||||
),
|
||||
captchaToken = captchaToken,
|
||||
)
|
||||
|
||||
override suspend fun login(
|
||||
email: String,
|
||||
password: String?,
|
||||
|
@ -387,6 +415,7 @@ class AuthRepositoryImpl(
|
|||
password: String? = null,
|
||||
authModel: IdentityTokenAuthModel,
|
||||
twoFactorData: TwoFactorDataModel? = null,
|
||||
deviceData: DeviceDataModel? = null,
|
||||
captchaToken: String?,
|
||||
): LoginResult = identityService
|
||||
.getToken(
|
||||
|
@ -444,7 +473,7 @@ class AuthRepositoryImpl(
|
|||
twoFactorResponse = null
|
||||
resendEmailRequestJson = null
|
||||
|
||||
// Attempt to unlock the vault if possible.
|
||||
// Attempt to unlock the vault with password if possible.
|
||||
password?.let {
|
||||
vaultRepository.clearUnlockedData()
|
||||
vaultRepository.unlockVault(
|
||||
|
@ -476,7 +505,29 @@ class AuthRepositoryImpl(
|
|||
|
||||
// Cache the password to verify against any password policies
|
||||
// after the sync completes.
|
||||
passwordToCheck = password
|
||||
passwordToCheck = it
|
||||
}
|
||||
|
||||
// Attempt to unlock the vault with auth request if possible.
|
||||
deviceData?.let {
|
||||
vaultRepository.clearUnlockedData()
|
||||
vaultRepository.unlockVault(
|
||||
userId = userStateJson.activeUserId,
|
||||
email = userStateJson.activeAccount.profile.email,
|
||||
kdf = userStateJson.activeAccount.profile.toSdkParams(),
|
||||
privateKey = loginResponse.privateKey,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
||||
requestPrivateKey = it.privateKey,
|
||||
protectedUserKey = it.asymmetricalKey,
|
||||
),
|
||||
// We can separately unlock the vault for organization data after
|
||||
// receiving the sync response if this data is currently absent.
|
||||
organizationKeys = null,
|
||||
)
|
||||
authDiskSource.storeMasterPasswordHash(
|
||||
userId = userStateJson.activeUserId,
|
||||
passwordHash = it.masterPasswordHash,
|
||||
)
|
||||
}
|
||||
|
||||
authDiskSource.userState = userStateJson
|
||||
|
@ -741,7 +792,7 @@ class AuthRepositoryImpl(
|
|||
}
|
||||
|
||||
override fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult) {
|
||||
mutableCaptchaTokenFlow.tryEmit(tokenResult)
|
||||
captchaTokenChannel.trySend(tokenResult)
|
||||
}
|
||||
|
||||
override suspend fun getOrganizationDomainSsoDetails(
|
||||
|
|
|
@ -7,6 +7,9 @@ import androidx.lifecycle.viewModelScope
|
|||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.CreateAuthRequestResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
|
@ -16,6 +19,7 @@ import kotlinx.coroutines.flow.launchIn
|
|||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -24,6 +28,7 @@ private const val KEY_STATE = "state"
|
|||
/**
|
||||
* Manages application state for the Login with Device screen.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
@HiltViewModel
|
||||
class LoginWithDeviceViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
|
@ -34,12 +39,18 @@ class LoginWithDeviceViewModel @Inject constructor(
|
|||
emailAddress = LoginWithDeviceArgs(savedStateHandle).emailAddress,
|
||||
viewState = LoginWithDeviceState.ViewState.Loading,
|
||||
dialogState = null,
|
||||
loginData = null,
|
||||
),
|
||||
) {
|
||||
private var authJob: Job = Job().apply { complete() }
|
||||
|
||||
init {
|
||||
sendNewAuthRequest(isResend = false)
|
||||
authRepository
|
||||
.captchaTokenResultFlow
|
||||
.map { LoginWithDeviceAction.Internal.ReceiveCaptchaToken(tokenResult = it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: LoginWithDeviceAction) {
|
||||
|
@ -73,6 +84,14 @@ class LoginWithDeviceViewModel @Inject constructor(
|
|||
is LoginWithDeviceAction.Internal.NewAuthRequestResultReceive -> {
|
||||
handleNewAuthRequestResultReceived(action)
|
||||
}
|
||||
|
||||
is LoginWithDeviceAction.Internal.ReceiveCaptchaToken -> {
|
||||
handleReceiveCaptchaToken(action)
|
||||
}
|
||||
|
||||
is LoginWithDeviceAction.Internal.ReceiveLoginResult -> {
|
||||
handleReceiveLoginResult(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -89,9 +108,17 @@ class LoginWithDeviceViewModel @Inject constructor(
|
|||
isResendNotificationLoading = false,
|
||||
),
|
||||
dialogState = null,
|
||||
loginData = LoginWithDeviceState.LoginData(
|
||||
accessCode = result.authRequestResponse.accessCode,
|
||||
requestId = result.authRequest.id,
|
||||
masterPasswordHash = result.authRequest.masterPasswordHash,
|
||||
asymmetricalKey = requireNotNull(result.authRequest.key),
|
||||
privateKey = result.authRequestResponse.privateKey,
|
||||
captchaToken = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
// TODO: Unlock the vault (BIT-813)
|
||||
attemptLogin()
|
||||
}
|
||||
|
||||
is CreateAuthRequestResult.Update -> {
|
||||
|
@ -153,6 +180,95 @@ class LoginWithDeviceViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleReceiveCaptchaToken(
|
||||
action: LoginWithDeviceAction.Internal.ReceiveCaptchaToken,
|
||||
) {
|
||||
when (val tokenResult = action.tokenResult) {
|
||||
CaptchaCallbackTokenResult.MissingToken -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = LoginWithDeviceState.DialogState.Error(
|
||||
title = R.string.log_in_denied.asText(),
|
||||
message = R.string.captcha_failed.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is CaptchaCallbackTokenResult.Success -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(loginData = it.loginData?.copy(captchaToken = tokenResult.token))
|
||||
}
|
||||
attemptLogin()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleReceiveLoginResult(
|
||||
action: LoginWithDeviceAction.Internal.ReceiveLoginResult,
|
||||
) {
|
||||
when (val loginResult = action.loginResult) {
|
||||
is LoginResult.CaptchaRequired -> {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
sendEvent(
|
||||
event = LoginWithDeviceEvent.NavigateToCaptcha(
|
||||
uri = generateUriForCaptcha(captchaId = loginResult.captchaId),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
is LoginResult.TwoFactorRequired -> {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
sendEvent(
|
||||
LoginWithDeviceEvent.NavigateToTwoFactorLogin(
|
||||
emailAddress = state.emailAddress,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
is LoginResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = LoginWithDeviceState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = loginResult
|
||||
.errorMessage
|
||||
?.asText()
|
||||
?: R.string.generic_error_message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is LoginResult.Success -> {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun attemptLogin() {
|
||||
val loginData = state.loginData ?: return
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = LoginWithDeviceState.DialogState.Loading(
|
||||
message = R.string.logging_in.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val result = authRepository.login(
|
||||
email = state.emailAddress,
|
||||
requestId = loginData.requestId,
|
||||
accessCode = loginData.accessCode,
|
||||
asymmetricalKey = loginData.asymmetricalKey,
|
||||
requestPrivateKey = loginData.privateKey,
|
||||
masterPasswordHash = loginData.masterPasswordHash,
|
||||
captchaToken = loginData.captchaToken,
|
||||
)
|
||||
sendAction(LoginWithDeviceAction.Internal.ReceiveLoginResult(result))
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendNewAuthRequest(isResend: Boolean) {
|
||||
setIsResendNotificationLoading(isResend)
|
||||
authJob.cancel()
|
||||
|
@ -188,6 +304,7 @@ data class LoginWithDeviceState(
|
|||
val emailAddress: String,
|
||||
val viewState: ViewState,
|
||||
val dialogState: DialogState?,
|
||||
val loginData: LoginData?,
|
||||
) : Parcelable {
|
||||
/**
|
||||
* Represents the specific view states for the [LoginWithDeviceScreen].
|
||||
|
@ -234,6 +351,19 @@ data class LoginWithDeviceState(
|
|||
val message: Text,
|
||||
) : DialogState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper class containing all data needed to login.
|
||||
*/
|
||||
@Parcelize
|
||||
data class LoginData(
|
||||
val accessCode: String,
|
||||
val requestId: String,
|
||||
val captchaToken: String?,
|
||||
val masterPasswordHash: String?,
|
||||
val asymmetricalKey: String,
|
||||
val privateKey: String,
|
||||
) : Parcelable
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -299,5 +429,19 @@ sealed class LoginWithDeviceAction {
|
|||
data class NewAuthRequestResultReceive(
|
||||
val result: CreateAuthRequestResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates a captcha callback token has been received.
|
||||
*/
|
||||
data class ReceiveCaptchaToken(
|
||||
val tokenResult: CaptchaCallbackTokenResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates a login result has been received.
|
||||
*/
|
||||
data class ReceiveLoginResult(
|
||||
val loginResult: LoginResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository
|
|||
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.core.AuthRequestResponse
|
||||
import com.bitwarden.core.InitUserCryptoMethod
|
||||
import com.bitwarden.core.RegisterKeyResponse
|
||||
import com.bitwarden.core.UpdatePasswordResponse
|
||||
import com.bitwarden.crypto.HashPurpose
|
||||
|
@ -1226,6 +1227,344 @@ class AuthRepositoryTest {
|
|||
assertEquals(LoginResult.Error(errorMessage = null), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login with device get token fails should return Error with no message`() = runTest {
|
||||
coEvery {
|
||||
identityService.getToken(
|
||||
email = EMAIL,
|
||||
authModel = IdentityTokenAuthModel.AuthRequest(
|
||||
username = EMAIL,
|
||||
authRequestId = DEVICE_REQUEST_ID,
|
||||
accessCode = DEVICE_ACCESS_CODE,
|
||||
),
|
||||
captchaToken = null,
|
||||
uniqueAppId = UNIQUE_APP_ID,
|
||||
)
|
||||
} returns Throwable("Fail").asFailure()
|
||||
val result = repository.login(
|
||||
email = EMAIL,
|
||||
requestId = DEVICE_REQUEST_ID,
|
||||
accessCode = DEVICE_ACCESS_CODE,
|
||||
asymmetricalKey = DEVICE_ASYMMETRICAL_KEY,
|
||||
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
|
||||
masterPasswordHash = PASSWORD_HASH,
|
||||
captchaToken = null,
|
||||
)
|
||||
assertEquals(LoginResult.Error(errorMessage = null), result)
|
||||
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
|
||||
coVerify {
|
||||
identityService.getToken(
|
||||
email = EMAIL,
|
||||
authModel = IdentityTokenAuthModel.AuthRequest(
|
||||
username = EMAIL,
|
||||
authRequestId = DEVICE_REQUEST_ID,
|
||||
accessCode = DEVICE_ACCESS_CODE,
|
||||
),
|
||||
captchaToken = null,
|
||||
uniqueAppId = UNIQUE_APP_ID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login with device get token returns Invalid should return Error with correct message`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
identityService.getToken(
|
||||
email = EMAIL,
|
||||
authModel = IdentityTokenAuthModel.AuthRequest(
|
||||
username = EMAIL,
|
||||
authRequestId = DEVICE_REQUEST_ID,
|
||||
accessCode = DEVICE_ACCESS_CODE,
|
||||
),
|
||||
captchaToken = null,
|
||||
uniqueAppId = UNIQUE_APP_ID,
|
||||
)
|
||||
} returns GetTokenResponseJson
|
||||
.Invalid(
|
||||
errorModel = GetTokenResponseJson.Invalid.ErrorModel(
|
||||
errorMessage = "mock_error_message",
|
||||
),
|
||||
)
|
||||
.asSuccess()
|
||||
|
||||
val result = repository.login(
|
||||
email = EMAIL,
|
||||
requestId = DEVICE_REQUEST_ID,
|
||||
accessCode = DEVICE_ACCESS_CODE,
|
||||
asymmetricalKey = DEVICE_ASYMMETRICAL_KEY,
|
||||
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
|
||||
masterPasswordHash = PASSWORD_HASH,
|
||||
captchaToken = null,
|
||||
)
|
||||
assertEquals(LoginResult.Error(errorMessage = "mock_error_message"), result)
|
||||
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
|
||||
coVerify {
|
||||
identityService.getToken(
|
||||
email = EMAIL,
|
||||
authModel = IdentityTokenAuthModel.AuthRequest(
|
||||
username = EMAIL,
|
||||
authRequestId = DEVICE_REQUEST_ID,
|
||||
accessCode = DEVICE_ACCESS_CODE,
|
||||
),
|
||||
captchaToken = null,
|
||||
uniqueAppId = UNIQUE_APP_ID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `login with device get token succeeds should return Success, update AuthState, update stored keys, and sync`() =
|
||||
runTest {
|
||||
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
|
||||
coEvery {
|
||||
identityService.getToken(
|
||||
email = EMAIL,
|
||||
authModel = IdentityTokenAuthModel.AuthRequest(
|
||||
username = EMAIL,
|
||||
authRequestId = DEVICE_REQUEST_ID,
|
||||
accessCode = DEVICE_ACCESS_CODE,
|
||||
),
|
||||
captchaToken = null,
|
||||
uniqueAppId = UNIQUE_APP_ID,
|
||||
)
|
||||
} returns successResponse.asSuccess()
|
||||
coEvery { vaultRepository.syncIfNecessary() } just runs
|
||||
every {
|
||||
GET_TOKEN_RESPONSE_SUCCESS.toUserState(
|
||||
previousUserState = null,
|
||||
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
|
||||
)
|
||||
} returns SINGLE_USER_STATE_1
|
||||
coEvery {
|
||||
vaultRepository.unlockVault(
|
||||
userId = USER_ID_1,
|
||||
email = EMAIL,
|
||||
kdf = ACCOUNT_1.profile.toSdkParams(),
|
||||
privateKey = successResponse.privateKey,
|
||||
organizationKeys = null,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
||||
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
|
||||
protectedUserKey = DEVICE_ASYMMETRICAL_KEY,
|
||||
),
|
||||
)
|
||||
} returns VaultUnlockResult.Success
|
||||
val result = repository.login(
|
||||
email = EMAIL,
|
||||
requestId = DEVICE_REQUEST_ID,
|
||||
accessCode = DEVICE_ACCESS_CODE,
|
||||
asymmetricalKey = DEVICE_ASYMMETRICAL_KEY,
|
||||
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
|
||||
masterPasswordHash = PASSWORD_HASH,
|
||||
captchaToken = null,
|
||||
)
|
||||
assertEquals(LoginResult.Success, result)
|
||||
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
|
||||
fakeAuthDiskSource.assertPrivateKey(
|
||||
userId = USER_ID_1,
|
||||
privateKey = "privateKey",
|
||||
)
|
||||
fakeAuthDiskSource.assertUserKey(
|
||||
userId = USER_ID_1,
|
||||
userKey = "key",
|
||||
)
|
||||
coVerify {
|
||||
identityService.getToken(
|
||||
email = EMAIL,
|
||||
authModel = IdentityTokenAuthModel.AuthRequest(
|
||||
username = EMAIL,
|
||||
authRequestId = DEVICE_REQUEST_ID,
|
||||
accessCode = DEVICE_ACCESS_CODE,
|
||||
),
|
||||
captchaToken = null,
|
||||
uniqueAppId = UNIQUE_APP_ID,
|
||||
)
|
||||
vaultRepository.syncIfNecessary()
|
||||
vaultRepository.unlockVault(
|
||||
userId = USER_ID_1,
|
||||
email = EMAIL,
|
||||
kdf = ACCOUNT_1.profile.toSdkParams(),
|
||||
privateKey = successResponse.privateKey,
|
||||
organizationKeys = null,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
||||
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
|
||||
protectedUserKey = DEVICE_ASYMMETRICAL_KEY,
|
||||
),
|
||||
)
|
||||
}
|
||||
assertEquals(
|
||||
SINGLE_USER_STATE_1,
|
||||
fakeAuthDiskSource.userState,
|
||||
)
|
||||
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login with device get token returns captcha request should return CaptchaRequired`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
identityService.getToken(
|
||||
email = EMAIL,
|
||||
authModel = IdentityTokenAuthModel.AuthRequest(
|
||||
username = EMAIL,
|
||||
authRequestId = DEVICE_REQUEST_ID,
|
||||
accessCode = DEVICE_ACCESS_CODE,
|
||||
),
|
||||
captchaToken = null,
|
||||
uniqueAppId = UNIQUE_APP_ID,
|
||||
)
|
||||
} returns GetTokenResponseJson.CaptchaRequired(CAPTCHA_KEY).asSuccess()
|
||||
val result = repository.login(
|
||||
email = EMAIL,
|
||||
requestId = DEVICE_REQUEST_ID,
|
||||
accessCode = DEVICE_ACCESS_CODE,
|
||||
asymmetricalKey = DEVICE_ASYMMETRICAL_KEY,
|
||||
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
|
||||
masterPasswordHash = PASSWORD_HASH,
|
||||
captchaToken = null,
|
||||
)
|
||||
assertEquals(LoginResult.CaptchaRequired(CAPTCHA_KEY), result)
|
||||
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
|
||||
coVerify {
|
||||
identityService.getToken(
|
||||
email = EMAIL,
|
||||
authModel = IdentityTokenAuthModel.AuthRequest(
|
||||
username = EMAIL,
|
||||
authRequestId = DEVICE_REQUEST_ID,
|
||||
accessCode = DEVICE_ACCESS_CODE,
|
||||
),
|
||||
captchaToken = null,
|
||||
uniqueAppId = UNIQUE_APP_ID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login with device get token returns two factor request should return TwoFactorRequired`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
identityService.getToken(
|
||||
email = EMAIL,
|
||||
authModel = IdentityTokenAuthModel.AuthRequest(
|
||||
username = EMAIL,
|
||||
authRequestId = DEVICE_REQUEST_ID,
|
||||
accessCode = DEVICE_ACCESS_CODE,
|
||||
),
|
||||
captchaToken = null,
|
||||
uniqueAppId = UNIQUE_APP_ID,
|
||||
)
|
||||
} returns GetTokenResponseJson
|
||||
.TwoFactorRequired(TWO_FACTOR_AUTH_METHODS_DATA, null, null)
|
||||
.asSuccess()
|
||||
val result = repository.login(
|
||||
email = EMAIL,
|
||||
requestId = DEVICE_REQUEST_ID,
|
||||
accessCode = DEVICE_ACCESS_CODE,
|
||||
asymmetricalKey = DEVICE_ASYMMETRICAL_KEY,
|
||||
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
|
||||
masterPasswordHash = PASSWORD_HASH,
|
||||
captchaToken = null,
|
||||
)
|
||||
assertEquals(LoginResult.TwoFactorRequired, result)
|
||||
assertEquals(
|
||||
repository.twoFactorResponse,
|
||||
GetTokenResponseJson.TwoFactorRequired(TWO_FACTOR_AUTH_METHODS_DATA, null, null),
|
||||
)
|
||||
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
|
||||
coVerify {
|
||||
identityService.getToken(
|
||||
email = EMAIL,
|
||||
authModel = IdentityTokenAuthModel.AuthRequest(
|
||||
username = EMAIL,
|
||||
authRequestId = DEVICE_REQUEST_ID,
|
||||
accessCode = DEVICE_ACCESS_CODE,
|
||||
),
|
||||
captchaToken = null,
|
||||
uniqueAppId = UNIQUE_APP_ID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login with device two factor with remember saves two factor auth token`() = runTest {
|
||||
// Attempt a normal login with a two factor error first, so that the auth
|
||||
// data will be cached.
|
||||
coEvery {
|
||||
identityService.getToken(
|
||||
email = EMAIL,
|
||||
authModel = IdentityTokenAuthModel.AuthRequest(
|
||||
username = EMAIL,
|
||||
authRequestId = DEVICE_REQUEST_ID,
|
||||
accessCode = DEVICE_ACCESS_CODE,
|
||||
),
|
||||
captchaToken = null,
|
||||
uniqueAppId = UNIQUE_APP_ID,
|
||||
)
|
||||
} returns GetTokenResponseJson
|
||||
.TwoFactorRequired(TWO_FACTOR_AUTH_METHODS_DATA, null, null)
|
||||
.asSuccess()
|
||||
val firstResult = repository.login(
|
||||
email = EMAIL,
|
||||
requestId = DEVICE_REQUEST_ID,
|
||||
accessCode = DEVICE_ACCESS_CODE,
|
||||
asymmetricalKey = DEVICE_ASYMMETRICAL_KEY,
|
||||
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
|
||||
masterPasswordHash = PASSWORD_HASH,
|
||||
captchaToken = null,
|
||||
)
|
||||
assertEquals(LoginResult.TwoFactorRequired, firstResult)
|
||||
coVerify {
|
||||
identityService.getToken(
|
||||
email = EMAIL,
|
||||
authModel = IdentityTokenAuthModel.AuthRequest(
|
||||
username = EMAIL,
|
||||
authRequestId = DEVICE_REQUEST_ID,
|
||||
accessCode = DEVICE_ACCESS_CODE,
|
||||
),
|
||||
captchaToken = null,
|
||||
uniqueAppId = UNIQUE_APP_ID,
|
||||
)
|
||||
}
|
||||
|
||||
// Login with two factor data.
|
||||
val successResponse = GET_TOKEN_RESPONSE_SUCCESS.copy(
|
||||
twoFactorToken = "twoFactorTokenToStore",
|
||||
)
|
||||
coEvery {
|
||||
identityService.getToken(
|
||||
email = EMAIL,
|
||||
authModel = IdentityTokenAuthModel.AuthRequest(
|
||||
username = EMAIL,
|
||||
authRequestId = DEVICE_REQUEST_ID,
|
||||
accessCode = DEVICE_ACCESS_CODE,
|
||||
),
|
||||
captchaToken = null,
|
||||
uniqueAppId = UNIQUE_APP_ID,
|
||||
twoFactorData = TWO_FACTOR_DATA,
|
||||
)
|
||||
} returns successResponse.asSuccess()
|
||||
coEvery { vaultRepository.syncIfNecessary() } just runs
|
||||
every {
|
||||
successResponse.toUserState(
|
||||
previousUserState = null,
|
||||
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
|
||||
)
|
||||
} returns SINGLE_USER_STATE_1
|
||||
val finalResult = repository.login(
|
||||
email = EMAIL,
|
||||
password = null,
|
||||
twoFactorData = TWO_FACTOR_DATA,
|
||||
captchaToken = null,
|
||||
)
|
||||
assertEquals(LoginResult.Success, finalResult)
|
||||
assertNull(repository.twoFactorResponse)
|
||||
fakeAuthDiskSource.assertTwoFactorToken(
|
||||
email = EMAIL,
|
||||
twoFactorToken = "twoFactorTokenToStore",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SSO login get token fails should return Error with no message`() = runTest {
|
||||
coEvery {
|
||||
|
@ -3441,6 +3780,10 @@ class AuthRepositoryTest {
|
|||
private const val SSO_CODE = "ssoCode"
|
||||
private const val SSO_CODE_VERIFIER = "ssoCodeVerifier"
|
||||
private const val SSO_REDIRECT_URI = "bitwarden://sso-test"
|
||||
private const val DEVICE_ACCESS_CODE = "accessCode"
|
||||
private const val DEVICE_REQUEST_ID = "authRequestId"
|
||||
private const val DEVICE_ASYMMETRICAL_KEY = "asymmetricalKey"
|
||||
private const val DEVICE_REQUEST_PRIVATE_KEY = "requestPrivateKey"
|
||||
|
||||
private const val DEFAULT_KDF_ITERATIONS = 600000
|
||||
private const val ENCRYPTED_USER_KEY = "encryptedUserKey"
|
||||
|
|
|
@ -178,4 +178,5 @@ private val DEFAULT_STATE = LoginWithDeviceState(
|
|||
isResendNotificationLoading = false,
|
||||
),
|
||||
dialogState = null,
|
||||
loginData = null,
|
||||
)
|
||||
|
|
|
@ -7,11 +7,15 @@ import com.x8bit.bitwarden.R
|
|||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.CreateAuthRequestResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import io.mockk.awaits
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
@ -23,10 +27,13 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
|
|||
|
||||
private val mutableCreateAuthRequestWithUpdatesFlow =
|
||||
bufferedMutableSharedFlow<CreateAuthRequestResult>()
|
||||
private val mutableCaptchaTokenResultFlow =
|
||||
bufferedMutableSharedFlow<CaptchaCallbackTokenResult>()
|
||||
private val authRepository = mockk<AuthRepository> {
|
||||
coEvery {
|
||||
createAuthRequestWithUpdates(EMAIL)
|
||||
} returns mutableCreateAuthRequestWithUpdatesFlow
|
||||
coEvery { captchaTokenResultFlow } returns mutableCaptchaTokenResultFlow
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -128,22 +135,221 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `on createAuthRequestWithUpdates Success received should show content`() {
|
||||
fun `on createAuthRequestWithUpdates Success and login success should update the state`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
authRepository.login(
|
||||
email = EMAIL,
|
||||
requestId = DEFAULT_LOGIN_DATA.requestId,
|
||||
accessCode = DEFAULT_LOGIN_DATA.accessCode,
|
||||
asymmetricalKey = DEFAULT_LOGIN_DATA.asymmetricalKey,
|
||||
requestPrivateKey = DEFAULT_LOGIN_DATA.privateKey,
|
||||
masterPasswordHash = DEFAULT_LOGIN_DATA.masterPasswordHash,
|
||||
captchaToken = null,
|
||||
)
|
||||
} returns LoginResult.Success
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
mutableCreateAuthRequestWithUpdatesFlow.tryEmit(
|
||||
CreateAuthRequestResult.Success(AUTH_REQUEST, AUTH_REQUEST_RESPONSE),
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
|
||||
fingerprintPhrase = "",
|
||||
),
|
||||
loginData = DEFAULT_LOGIN_DATA,
|
||||
dialogState = LoginWithDeviceState.DialogState.Loading(
|
||||
message = R.string.logging_in.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
|
||||
fingerprintPhrase = "",
|
||||
),
|
||||
dialogState = null,
|
||||
loginData = DEFAULT_LOGIN_DATA,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
authRepository.login(
|
||||
email = EMAIL,
|
||||
requestId = AUTH_REQUEST.id,
|
||||
accessCode = AUTH_REQUEST_RESPONSE.accessCode,
|
||||
asymmetricalKey = requireNotNull(AUTH_REQUEST.key),
|
||||
requestPrivateKey = AUTH_REQUEST_RESPONSE.privateKey,
|
||||
masterPasswordHash = AUTH_REQUEST.masterPasswordHash,
|
||||
captchaToken = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `on createAuthRequestWithUpdates Success and login two factor required should emit NavigateToTwoFactorLogin`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
authRepository.login(
|
||||
email = EMAIL,
|
||||
requestId = DEFAULT_LOGIN_DATA.requestId,
|
||||
accessCode = DEFAULT_LOGIN_DATA.accessCode,
|
||||
asymmetricalKey = DEFAULT_LOGIN_DATA.asymmetricalKey,
|
||||
requestPrivateKey = DEFAULT_LOGIN_DATA.privateKey,
|
||||
masterPasswordHash = DEFAULT_LOGIN_DATA.masterPasswordHash,
|
||||
captchaToken = null,
|
||||
)
|
||||
} returns LoginResult.TwoFactorRequired
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
mutableCreateAuthRequestWithUpdatesFlow.tryEmit(
|
||||
CreateAuthRequestResult.Success(AUTH_REQUEST, AUTH_REQUEST_RESPONSE),
|
||||
)
|
||||
assertEquals(
|
||||
LoginWithDeviceEvent.NavigateToTwoFactorLogin(emailAddress = EMAIL),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
authRepository.login(
|
||||
email = EMAIL,
|
||||
requestId = AUTH_REQUEST.id,
|
||||
accessCode = AUTH_REQUEST_RESPONSE.accessCode,
|
||||
asymmetricalKey = requireNotNull(AUTH_REQUEST.key),
|
||||
requestPrivateKey = AUTH_REQUEST_RESPONSE.privateKey,
|
||||
masterPasswordHash = AUTH_REQUEST.masterPasswordHash,
|
||||
captchaToken = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on createAuthRequestWithUpdates Success and login error should should update the state`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
authRepository.login(
|
||||
email = EMAIL,
|
||||
requestId = DEFAULT_LOGIN_DATA.requestId,
|
||||
accessCode = DEFAULT_LOGIN_DATA.accessCode,
|
||||
asymmetricalKey = DEFAULT_LOGIN_DATA.asymmetricalKey,
|
||||
requestPrivateKey = DEFAULT_LOGIN_DATA.privateKey,
|
||||
masterPasswordHash = DEFAULT_LOGIN_DATA.masterPasswordHash,
|
||||
captchaToken = null,
|
||||
)
|
||||
} returns LoginResult.Error(null)
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
mutableCreateAuthRequestWithUpdatesFlow.tryEmit(
|
||||
CreateAuthRequestResult.Success(AUTH_REQUEST, AUTH_REQUEST_RESPONSE),
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
|
||||
fingerprintPhrase = "",
|
||||
),
|
||||
loginData = DEFAULT_LOGIN_DATA,
|
||||
dialogState = LoginWithDeviceState.DialogState.Loading(
|
||||
message = R.string.logging_in.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
|
||||
fingerprintPhrase = "",
|
||||
),
|
||||
dialogState = LoginWithDeviceState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.generic_error_message.asText(),
|
||||
),
|
||||
loginData = DEFAULT_LOGIN_DATA,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
authRepository.login(
|
||||
email = EMAIL,
|
||||
requestId = AUTH_REQUEST.id,
|
||||
accessCode = AUTH_REQUEST_RESPONSE.accessCode,
|
||||
asymmetricalKey = requireNotNull(AUTH_REQUEST.key),
|
||||
requestPrivateKey = AUTH_REQUEST_RESPONSE.privateKey,
|
||||
masterPasswordHash = AUTH_REQUEST.masterPasswordHash,
|
||||
captchaToken = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on captchaTokenResultFlow missing token should should display error dialog`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||
mutableCreateAuthRequestWithUpdatesFlow.tryEmit(
|
||||
CreateAuthRequestResult.Success(AUTH_REQUEST, AUTH_REQUEST_RESPONSE),
|
||||
)
|
||||
mutableCaptchaTokenResultFlow.tryEmit(CaptchaCallbackTokenResult.MissingToken)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
|
||||
fingerprintPhrase = "",
|
||||
dialogState = LoginWithDeviceState.DialogState.Error(
|
||||
title = R.string.log_in_denied.asText(),
|
||||
message = R.string.captcha_failed.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on captchaTokenResultFlow success should update the token`() = runTest {
|
||||
val captchaToken = "captchaToken"
|
||||
val initialState = DEFAULT_STATE.copy(loginData = DEFAULT_LOGIN_DATA)
|
||||
coEvery {
|
||||
authRepository.login(
|
||||
email = EMAIL,
|
||||
requestId = DEFAULT_LOGIN_DATA.requestId,
|
||||
accessCode = DEFAULT_LOGIN_DATA.accessCode,
|
||||
asymmetricalKey = DEFAULT_LOGIN_DATA.asymmetricalKey,
|
||||
requestPrivateKey = DEFAULT_LOGIN_DATA.privateKey,
|
||||
masterPasswordHash = DEFAULT_LOGIN_DATA.masterPasswordHash,
|
||||
captchaToken = captchaToken,
|
||||
)
|
||||
} just awaits
|
||||
val viewModel = createViewModel(initialState)
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(initialState, awaitItem())
|
||||
mutableCaptchaTokenResultFlow.tryEmit(CaptchaCallbackTokenResult.Success(captchaToken))
|
||||
assertEquals(
|
||||
initialState.copy(
|
||||
loginData = DEFAULT_LOGIN_DATA.copy(captchaToken = captchaToken),
|
||||
dialogState = LoginWithDeviceState.DialogState.Loading(
|
||||
message = R.string.logging_in.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
authRepository.login(
|
||||
email = EMAIL,
|
||||
requestId = AUTH_REQUEST.id,
|
||||
accessCode = AUTH_REQUEST_RESPONSE.accessCode,
|
||||
asymmetricalKey = requireNotNull(AUTH_REQUEST.key),
|
||||
requestPrivateKey = AUTH_REQUEST_RESPONSE.privateKey,
|
||||
masterPasswordHash = AUTH_REQUEST.masterPasswordHash,
|
||||
captchaToken = captchaToken,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on createAuthRequestWithUpdates Error received should show content with error dialog`() {
|
||||
val viewModel = createViewModel()
|
||||
|
@ -229,6 +435,7 @@ private val DEFAULT_STATE = LoginWithDeviceState(
|
|||
emailAddress = EMAIL,
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE,
|
||||
dialogState = null,
|
||||
loginData = null,
|
||||
)
|
||||
|
||||
private val AUTH_REQUEST = AuthRequest(
|
||||
|
@ -251,3 +458,12 @@ private val AUTH_REQUEST_RESPONSE = AuthRequestResponse(
|
|||
accessCode = "accessCode",
|
||||
fingerprint = "fingerprint",
|
||||
)
|
||||
|
||||
private val DEFAULT_LOGIN_DATA = LoginWithDeviceState.LoginData(
|
||||
accessCode = "accessCode",
|
||||
requestId = "1",
|
||||
masterPasswordHash = "verySecureHash",
|
||||
asymmetricalKey = "public",
|
||||
privateKey = "private_key",
|
||||
captchaToken = null,
|
||||
)
|
||||
|
|
Loading…
Add table
Reference in a new issue