mirror of
https://github.com/bitwarden/android.git
synced 2024-11-26 19:36:18 +03:00
Poll for auth request updates (#939)
This commit is contained in:
parent
624e60fd71
commit
33c64db85c
8 changed files with 841 additions and 124 deletions
|
@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJs
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
|
||||||
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestUpdatesResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
|
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
|
||||||
|
@ -227,9 +228,14 @@ interface AuthRepository : AuthenticatorProvider {
|
||||||
fun createAuthRequestWithUpdates(email: String): Flow<CreateAuthRequestResult>
|
fun createAuthRequestWithUpdates(email: String): Flow<CreateAuthRequestResult>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get an auth request by its [fingerprint].
|
* Get an auth request by its [fingerprint] and emits updates for that request.
|
||||||
*/
|
*/
|
||||||
suspend fun getAuthRequest(fingerprint: String): AuthRequestResult
|
fun getAuthRequestByFingerprintFlow(fingerprint: String): Flow<AuthRequestUpdatesResult>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an auth request by its request ID and emits updates for that request.
|
||||||
|
*/
|
||||||
|
fun getAuthRequestByIdFlow(requestId: String): Flow<AuthRequestUpdatesResult>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a list of the current user's [AuthRequest]s.
|
* Get a list of the current user's [AuthRequest]s.
|
||||||
|
|
|
@ -34,6 +34,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson
|
||||||
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
|
||||||
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestUpdatesResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
|
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
|
||||||
|
@ -98,9 +99,11 @@ import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import java.time.Clock
|
import java.time.Clock
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
private const val PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS: Long = 15L * 60L * 1_000L
|
private const val PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS: Long = 15L * 60L * 1_000L
|
||||||
private const val PASSWORDLESS_NOTIFICATION_RETRY_INTERVAL_MILLIS: Long = 4L * 1_000L
|
private const val PASSWORDLESS_NOTIFICATION_RETRY_INTERVAL_MILLIS: Long = 4L * 1_000L
|
||||||
|
private const val PASSWORDLESS_APPROVER_INTERVAL_MILLIS: Long = 5L * 60L * 1_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default implementation of [AuthRepository].
|
* Default implementation of [AuthRepository].
|
||||||
|
@ -897,20 +900,113 @@ class AuthRepositoryImpl(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getAuthRequest(
|
private fun getAuthRequest(
|
||||||
fingerprint: String,
|
initialRequest: suspend () -> AuthRequestUpdatesResult,
|
||||||
): AuthRequestResult =
|
): Flow<AuthRequestUpdatesResult> = flow {
|
||||||
when (val authRequestsResult = getAuthRequests()) {
|
val result = initialRequest()
|
||||||
AuthRequestsResult.Error -> AuthRequestResult.Error
|
emit(result)
|
||||||
is AuthRequestsResult.Success -> {
|
if (result is AuthRequestUpdatesResult.Error) return@flow
|
||||||
val request = authRequestsResult.authRequests
|
var isComplete = false
|
||||||
.firstOrNull { it.fingerprint == fingerprint }
|
while (coroutineContext.isActive && !isComplete) {
|
||||||
|
delay(PASSWORDLESS_APPROVER_INTERVAL_MILLIS)
|
||||||
|
val updateResult = result as AuthRequestUpdatesResult.Update
|
||||||
|
authRequestsService
|
||||||
|
.getAuthRequest(result.authRequest.id)
|
||||||
|
.map { request ->
|
||||||
|
AuthRequest(
|
||||||
|
id = request.id,
|
||||||
|
publicKey = request.publicKey,
|
||||||
|
platform = request.platform,
|
||||||
|
ipAddress = request.ipAddress,
|
||||||
|
key = request.key,
|
||||||
|
masterPasswordHash = request.masterPasswordHash,
|
||||||
|
creationDate = request.creationDate,
|
||||||
|
responseDate = request.responseDate,
|
||||||
|
requestApproved = request.requestApproved ?: false,
|
||||||
|
originUrl = request.originUrl,
|
||||||
|
fingerprint = updateResult.authRequest.fingerprint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.fold(
|
||||||
|
onFailure = { emit(AuthRequestUpdatesResult.Error) },
|
||||||
|
onSuccess = { updateAuthRequest ->
|
||||||
|
when {
|
||||||
|
updateAuthRequest.requestApproved -> {
|
||||||
|
isComplete = true
|
||||||
|
emit(AuthRequestUpdatesResult.Approved)
|
||||||
|
}
|
||||||
|
|
||||||
request
|
!updateAuthRequest.requestApproved &&
|
||||||
?.let { AuthRequestResult.Success(it) }
|
updateAuthRequest.responseDate != null -> {
|
||||||
?: AuthRequestResult.Error
|
isComplete = true
|
||||||
|
emit(AuthRequestUpdatesResult.Declined)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAuthRequest
|
||||||
|
.creationDate
|
||||||
|
.toInstant()
|
||||||
|
.plusMillis(PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS)
|
||||||
|
.isBefore(clock.instant()) -> {
|
||||||
|
isComplete = true
|
||||||
|
emit(AuthRequestUpdatesResult.Expired)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
emit(AuthRequestUpdatesResult.Update(updateAuthRequest))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAuthRequestByFingerprintFlow(
|
||||||
|
fingerprint: String,
|
||||||
|
): Flow<AuthRequestUpdatesResult> = getAuthRequest {
|
||||||
|
when (val authRequestsResult = getAuthRequests()) {
|
||||||
|
AuthRequestsResult.Error -> AuthRequestUpdatesResult.Error
|
||||||
|
is AuthRequestsResult.Success -> {
|
||||||
|
authRequestsResult
|
||||||
|
.authRequests
|
||||||
|
.firstOrNull { it.fingerprint == fingerprint }
|
||||||
|
?.let { AuthRequestUpdatesResult.Update(it) }
|
||||||
|
?: AuthRequestUpdatesResult.Error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAuthRequestByIdFlow(
|
||||||
|
requestId: String,
|
||||||
|
): Flow<AuthRequestUpdatesResult> = getAuthRequest {
|
||||||
|
authRequestsService
|
||||||
|
.getAuthRequest(requestId)
|
||||||
|
.map { request ->
|
||||||
|
when (val result = getFingerprintPhrase(request.publicKey)) {
|
||||||
|
is UserFingerprintResult.Error -> null
|
||||||
|
is UserFingerprintResult.Success -> AuthRequest(
|
||||||
|
id = request.id,
|
||||||
|
publicKey = request.publicKey,
|
||||||
|
platform = request.platform,
|
||||||
|
ipAddress = request.ipAddress,
|
||||||
|
key = request.key,
|
||||||
|
masterPasswordHash = request.masterPasswordHash,
|
||||||
|
creationDate = request.creationDate,
|
||||||
|
responseDate = request.responseDate,
|
||||||
|
requestApproved = request.requestApproved ?: false,
|
||||||
|
originUrl = request.originUrl,
|
||||||
|
fingerprint = result.fingerprint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fold(
|
||||||
|
onFailure = { AuthRequestUpdatesResult.Error },
|
||||||
|
onSuccess = { authRequest ->
|
||||||
|
authRequest
|
||||||
|
?.let { AuthRequestUpdatesResult.Update(it) }
|
||||||
|
?: AuthRequestUpdatesResult.Error
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getAuthRequests(): AuthRequestsResult =
|
override suspend fun getAuthRequests(): AuthRequestsResult =
|
||||||
authRequestsService
|
authRequestsService
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
package com.x8bit.bitwarden.data.auth.repository.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Models result of an authorization approval request.
|
||||||
|
*/
|
||||||
|
sealed class AuthRequestUpdatesResult {
|
||||||
|
/**
|
||||||
|
* Models the data returned when creating an auth request.
|
||||||
|
*/
|
||||||
|
data class Update(
|
||||||
|
val authRequest: AuthRequest,
|
||||||
|
) : AuthRequestUpdatesResult()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The auth request has been approved.
|
||||||
|
*/
|
||||||
|
data object Approved : AuthRequestUpdatesResult()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* There was an error getting the user's auth requests.
|
||||||
|
*/
|
||||||
|
data object Error : AuthRequestUpdatesResult()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The auth request has been declined.
|
||||||
|
*/
|
||||||
|
data object Declined : AuthRequestUpdatesResult()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The auth request has expired.
|
||||||
|
*/
|
||||||
|
data object Expired : AuthRequestUpdatesResult()
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval
|
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval
|
||||||
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
@ -42,6 +43,8 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenOutlinedButton
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenOutlinedButton
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
|
||||||
|
import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager
|
||||||
|
import com.x8bit.bitwarden.ui.platform.theme.LocalExitManager
|
||||||
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors
|
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors
|
||||||
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
|
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
|
||||||
|
|
||||||
|
@ -53,6 +56,7 @@ import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginApprovalScreen(
|
fun LoginApprovalScreen(
|
||||||
viewModel: LoginApprovalViewModel = hiltViewModel(),
|
viewModel: LoginApprovalViewModel = hiltViewModel(),
|
||||||
|
exitManager: ExitManager = LocalExitManager.current,
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val state by viewModel.stateFlow.collectAsState()
|
val state by viewModel.stateFlow.collectAsState()
|
||||||
|
@ -60,6 +64,7 @@ fun LoginApprovalScreen(
|
||||||
val resources = context.resources
|
val resources = context.resources
|
||||||
EventsEffect(viewModel = viewModel) { event ->
|
EventsEffect(viewModel = viewModel) { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
|
LoginApprovalEvent.ExitApp -> exitManager.exitApplication()
|
||||||
LoginApprovalEvent.NavigateBack -> onNavigateBack()
|
LoginApprovalEvent.NavigateBack -> onNavigateBack()
|
||||||
|
|
||||||
is LoginApprovalEvent.ShowToast -> {
|
is LoginApprovalEvent.ShowToast -> {
|
||||||
|
@ -82,6 +87,11 @@ fun LoginApprovalScreen(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
BackHandler(
|
||||||
|
onBack = remember(viewModel) {
|
||||||
|
{ viewModel.trySendAction(LoginApprovalAction.CloseClick) }
|
||||||
|
},
|
||||||
|
)
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||||
BitwardenScaffold(
|
BitwardenScaffold(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
|
@ -6,10 +6,16 @@ import androidx.lifecycle.viewModelScope
|
||||||
import com.x8bit.bitwarden.R
|
import com.x8bit.bitwarden.R
|
||||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
|
||||||
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestUpdatesResult
|
||||||
|
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
|
||||||
|
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
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.Text
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
@ -25,17 +31,25 @@ private const val KEY_STATE = "state"
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class LoginApprovalViewModel @Inject constructor(
|
class LoginApprovalViewModel @Inject constructor(
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
|
private val specialCircumstanceManager: SpecialCircumstanceManager,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : BaseViewModel<LoginApprovalState, LoginApprovalEvent, LoginApprovalAction>(
|
) : BaseViewModel<LoginApprovalState, LoginApprovalEvent, LoginApprovalAction>(
|
||||||
initialState = savedStateHandle[KEY_STATE]
|
initialState = savedStateHandle[KEY_STATE]
|
||||||
?: LoginApprovalState(
|
?: run {
|
||||||
fingerprint = requireNotNull(LoginApprovalArgs(savedStateHandle).fingerprint),
|
val specialCircumstance = specialCircumstanceManager.specialCircumstance
|
||||||
masterPasswordHash = null,
|
as? SpecialCircumstance.PasswordlessRequest
|
||||||
publicKey = "",
|
LoginApprovalState(
|
||||||
requestId = "",
|
specialCircumstance = specialCircumstance,
|
||||||
shouldShowErrorDialog = false,
|
fingerprint = specialCircumstance
|
||||||
viewState = LoginApprovalState.ViewState.Loading,
|
?.let { "" }
|
||||||
),
|
?: requireNotNull(LoginApprovalArgs(savedStateHandle).fingerprint),
|
||||||
|
masterPasswordHash = null,
|
||||||
|
publicKey = "",
|
||||||
|
requestId = "",
|
||||||
|
shouldShowErrorDialog = false,
|
||||||
|
viewState = LoginApprovalState.ViewState.Loading,
|
||||||
|
)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
private val dateTimeFormatter
|
private val dateTimeFormatter
|
||||||
get() = DateTimeFormatter
|
get() = DateTimeFormatter
|
||||||
|
@ -43,13 +57,22 @@ class LoginApprovalViewModel @Inject constructor(
|
||||||
.withZone(TimeZone.getDefault().toZoneId())
|
.withZone(TimeZone.getDefault().toZoneId())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
state
|
||||||
trySendAction(
|
.specialCircumstance
|
||||||
LoginApprovalAction.Internal.AuthRequestResultReceive(
|
?.let {
|
||||||
authRequestResult = authRepository.getAuthRequest(state.fingerprint),
|
authRepository
|
||||||
),
|
.getAuthRequestByIdFlow(it.passwordlessRequestData.loginRequestId)
|
||||||
)
|
.map { LoginApprovalAction.Internal.AuthRequestResultReceive(it) }
|
||||||
}
|
.onEach(::sendAction)
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
?: run {
|
||||||
|
authRepository
|
||||||
|
.getAuthRequestByFingerprintFlow(state.fingerprint)
|
||||||
|
.map { LoginApprovalAction.Internal.AuthRequestResultReceive(it) }
|
||||||
|
.onEach(::sendAction)
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleAction(action: LoginApprovalAction) {
|
override fun handleAction(action: LoginApprovalAction) {
|
||||||
|
@ -89,7 +112,7 @@ class LoginApprovalViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleCloseClicked() {
|
private fun handleCloseClicked() {
|
||||||
sendEvent(LoginApprovalEvent.NavigateBack)
|
closeScreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleDeclineRequestClicked() {
|
private fun handleDeclineRequestClicked() {
|
||||||
|
@ -135,8 +158,9 @@ class LoginApprovalViewModel @Inject constructor(
|
||||||
) {
|
) {
|
||||||
val email = authRepository.userStateFlow.value?.activeAccount?.email ?: return
|
val email = authRepository.userStateFlow.value?.activeAccount?.email ?: return
|
||||||
when (val result = action.authRequestResult) {
|
when (val result = action.authRequestResult) {
|
||||||
is AuthRequestResult.Success -> mutableStateFlow.update {
|
is AuthRequestUpdatesResult.Update -> mutableStateFlow.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
fingerprint = result.authRequest.fingerprint,
|
||||||
masterPasswordHash = result.authRequest.masterPasswordHash,
|
masterPasswordHash = result.authRequest.masterPasswordHash,
|
||||||
publicKey = result.authRequest.publicKey,
|
publicKey = result.authRequest.publicKey,
|
||||||
requestId = result.authRequest.id,
|
requestId = result.authRequest.id,
|
||||||
|
@ -151,11 +175,18 @@ class LoginApprovalViewModel @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is AuthRequestResult.Error -> mutableStateFlow.update {
|
is AuthRequestUpdatesResult.Error -> mutableStateFlow.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
viewState = LoginApprovalState.ViewState.Error,
|
viewState = LoginApprovalState.ViewState.Error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AuthRequestUpdatesResult.Approved,
|
||||||
|
AuthRequestUpdatesResult.Declined,
|
||||||
|
AuthRequestUpdatesResult.Expired,
|
||||||
|
-> {
|
||||||
|
closeScreen()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -174,6 +205,14 @@ class LoginApprovalViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun closeScreen() {
|
||||||
|
if (state.specialCircumstance?.shouldFinishWhenComplete == true) {
|
||||||
|
sendEvent(LoginApprovalEvent.ExitApp)
|
||||||
|
} else {
|
||||||
|
sendEvent(LoginApprovalEvent.NavigateBack)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -184,6 +223,7 @@ data class LoginApprovalState(
|
||||||
val viewState: ViewState,
|
val viewState: ViewState,
|
||||||
val shouldShowErrorDialog: Boolean,
|
val shouldShowErrorDialog: Boolean,
|
||||||
// Internal
|
// Internal
|
||||||
|
val specialCircumstance: SpecialCircumstance.PasswordlessRequest?,
|
||||||
val fingerprint: String,
|
val fingerprint: String,
|
||||||
val masterPasswordHash: String?,
|
val masterPasswordHash: String?,
|
||||||
val publicKey: String,
|
val publicKey: String,
|
||||||
|
@ -227,6 +267,11 @@ data class LoginApprovalState(
|
||||||
* Models events for the Login Approval screen.
|
* Models events for the Login Approval screen.
|
||||||
*/
|
*/
|
||||||
sealed class LoginApprovalEvent {
|
sealed class LoginApprovalEvent {
|
||||||
|
/**
|
||||||
|
* Closes the app.
|
||||||
|
*/
|
||||||
|
data object ExitApp : LoginApprovalEvent()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigates back.
|
* Navigates back.
|
||||||
*/
|
*/
|
||||||
|
@ -279,7 +324,7 @@ sealed class LoginApprovalAction {
|
||||||
* An auth request result has been received to populate the data on the screen.
|
* An auth request result has been received to populate the data on the screen.
|
||||||
*/
|
*/
|
||||||
data class AuthRequestResultReceive(
|
data class AuthRequestResultReceive(
|
||||||
val authRequestResult: AuthRequestResult,
|
val authRequestResult: AuthRequestUpdatesResult,
|
||||||
) : Internal()
|
) : Internal()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -44,6 +44,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL
|
||||||
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
|
||||||
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestUpdatesResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
|
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
|
||||||
|
@ -3011,93 +3012,489 @@ class AuthRepositoryTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
@Test
|
@Test
|
||||||
fun `getAuthRequest should return failure when getAuthRequests returns failure`() = runTest {
|
fun `getAuthRequestByFingerprintFlow should emit failure and cancel flow when getAuthRequests fails`() =
|
||||||
val fingerprint = "fingerprint"
|
runTest {
|
||||||
coEvery {
|
val fingerprint = "fingerprint"
|
||||||
authRequestsService.getAuthRequests()
|
coEvery {
|
||||||
} returns Throwable("Fail").asFailure()
|
authRequestsService.getAuthRequests()
|
||||||
|
} returns Throwable("Fail").asFailure()
|
||||||
|
|
||||||
val result = repository.getAuthRequest(fingerprint)
|
repository
|
||||||
|
.getAuthRequestByFingerprintFlow(fingerprint)
|
||||||
|
.test {
|
||||||
|
assertEquals(AuthRequestUpdatesResult.Error, awaitItem())
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
|
||||||
coVerify(exactly = 1) {
|
coVerify(exactly = 1) {
|
||||||
authRequestsService.getAuthRequests()
|
authRequestsService.getAuthRequests()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
assertEquals(AuthRequestResult.Error, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
@Test
|
@Test
|
||||||
fun `getAuthRequest should return success when service returns success`() = runTest {
|
fun `getAuthRequestByFingerprintFlow should emit update then not cancel on failure when initial request succeeds and second fails`() =
|
||||||
val fingerprint = "fingerprint"
|
runTest {
|
||||||
val responseJson = AuthRequestsResponseJson(
|
val authRequestsResponseJson = AuthRequestsResponseJson(
|
||||||
authRequests = listOf(
|
authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE),
|
||||||
AuthRequestsResponseJson.AuthRequest(
|
|
||||||
id = "1",
|
|
||||||
publicKey = PUBLIC_KEY,
|
|
||||||
platform = "Android",
|
|
||||||
ipAddress = "192.168.0.1",
|
|
||||||
key = "public",
|
|
||||||
masterPasswordHash = "verySecureHash",
|
|
||||||
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
|
|
||||||
responseDate = null,
|
|
||||||
requestApproved = true,
|
|
||||||
originUrl = "www.bitwarden.com",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val expected = AuthRequestResult.Success(
|
|
||||||
authRequest = AuthRequest(
|
|
||||||
id = "1",
|
|
||||||
publicKey = PUBLIC_KEY,
|
|
||||||
platform = "Android",
|
|
||||||
ipAddress = "192.168.0.1",
|
|
||||||
key = "public",
|
|
||||||
masterPasswordHash = "verySecureHash",
|
|
||||||
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
|
|
||||||
responseDate = null,
|
|
||||||
requestApproved = true,
|
|
||||||
originUrl = "www.bitwarden.com",
|
|
||||||
fingerprint = fingerprint,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
coEvery {
|
|
||||||
authSdkSource.getUserFingerprint(
|
|
||||||
email = EMAIL,
|
|
||||||
publicKey = PUBLIC_KEY,
|
|
||||||
)
|
)
|
||||||
} returns Result.success(fingerprint)
|
val authRequest = AUTH_REQUEST
|
||||||
coEvery {
|
val expectedOne = AuthRequestUpdatesResult.Update(
|
||||||
authRequestsService.getAuthRequests()
|
authRequest = authRequest,
|
||||||
} returns responseJson.asSuccess()
|
)
|
||||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
val expectedTwo = AuthRequestUpdatesResult.Error
|
||||||
|
coEvery {
|
||||||
|
authSdkSource.getUserFingerprint(
|
||||||
|
email = EMAIL,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
)
|
||||||
|
} returns Result.success(FINGER_PRINT)
|
||||||
|
coEvery {
|
||||||
|
authRequestsService.getAuthRequests()
|
||||||
|
} returns authRequestsResponseJson.asSuccess()
|
||||||
|
coEvery {
|
||||||
|
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||||
|
} returns Result.failure(mockk())
|
||||||
|
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||||
|
|
||||||
val result = repository.getAuthRequest(fingerprint)
|
repository
|
||||||
|
.getAuthRequestByFingerprintFlow(FINGER_PRINT)
|
||||||
|
.test {
|
||||||
|
assertEquals(expectedOne, awaitItem())
|
||||||
|
assertEquals(expectedTwo, awaitItem())
|
||||||
|
cancelAndConsumeRemainingEvents()
|
||||||
|
}
|
||||||
|
|
||||||
coVerify(exactly = 1) {
|
coVerify(exactly = 1) {
|
||||||
authRequestsService.getAuthRequests()
|
authRequestsService.getAuthRequests()
|
||||||
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||||
|
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
assertEquals(expected, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
@Test
|
@Test
|
||||||
fun `getAuthRequest should return error when no matching fingerprint exists`() = runTest {
|
fun `getAuthRequestByFingerprintFlow should emit update then approved and cancel when initial request succeeds and second succeeds with requestApproved`() =
|
||||||
val fingerprint = "fingerprint"
|
runTest {
|
||||||
val responseJson = AuthRequestsResponseJson(
|
val responseJsonOne = AuthRequestsResponseJson(
|
||||||
authRequests = listOf(),
|
authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE),
|
||||||
)
|
)
|
||||||
val expected = AuthRequestResult.Error
|
val authRequestsResponse = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy(
|
||||||
coEvery {
|
requestApproved = true,
|
||||||
authRequestsService.getAuthRequests()
|
)
|
||||||
} returns responseJson.asSuccess()
|
val expectedOne = AuthRequestUpdatesResult.Update(
|
||||||
|
authRequest = AUTH_REQUEST,
|
||||||
|
)
|
||||||
|
val expectedTwo = AuthRequestUpdatesResult.Approved
|
||||||
|
coEvery {
|
||||||
|
authSdkSource.getUserFingerprint(
|
||||||
|
email = EMAIL,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
)
|
||||||
|
} returns Result.success(FINGER_PRINT)
|
||||||
|
coEvery {
|
||||||
|
authRequestsService.getAuthRequests()
|
||||||
|
} returns responseJsonOne.asSuccess()
|
||||||
|
coEvery {
|
||||||
|
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||||
|
} returns Result.success(authRequestsResponse)
|
||||||
|
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||||
|
|
||||||
val result = repository.getAuthRequest(fingerprint)
|
repository
|
||||||
|
.getAuthRequestByFingerprintFlow(FINGER_PRINT)
|
||||||
|
.test {
|
||||||
|
assertEquals(expectedOne, awaitItem())
|
||||||
|
assertEquals(expectedTwo, awaitItem())
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
|
||||||
coVerify(exactly = 1) {
|
coVerify(exactly = 1) {
|
||||||
authRequestsService.getAuthRequests()
|
authRequestsService.getAuthRequests()
|
||||||
|
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||||
|
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
@Test
|
||||||
|
fun `getAuthRequestByFingerprintFlow should emit update then declined and cancel when initial request succeeds and second succeeds with valid response data`() =
|
||||||
|
runTest {
|
||||||
|
val responseJsonOne = AuthRequestsResponseJson(
|
||||||
|
authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE),
|
||||||
|
)
|
||||||
|
val authRequestsResponse = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy(
|
||||||
|
responseDate = mockk(),
|
||||||
|
requestApproved = false,
|
||||||
|
)
|
||||||
|
val expectedOne = AuthRequestUpdatesResult.Update(
|
||||||
|
authRequest = AUTH_REQUEST,
|
||||||
|
)
|
||||||
|
val expectedTwo = AuthRequestUpdatesResult.Declined
|
||||||
|
coEvery {
|
||||||
|
authSdkSource.getUserFingerprint(
|
||||||
|
email = EMAIL,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
)
|
||||||
|
} returns Result.success(FINGER_PRINT)
|
||||||
|
coEvery {
|
||||||
|
authRequestsService.getAuthRequests()
|
||||||
|
} returns responseJsonOne.asSuccess()
|
||||||
|
coEvery {
|
||||||
|
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||||
|
} returns Result.success(authRequestsResponse)
|
||||||
|
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||||
|
|
||||||
|
repository
|
||||||
|
.getAuthRequestByFingerprintFlow(FINGER_PRINT)
|
||||||
|
.test {
|
||||||
|
assertEquals(expectedOne, awaitItem())
|
||||||
|
assertEquals(expectedTwo, awaitItem())
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
authRequestsService.getAuthRequests()
|
||||||
|
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||||
|
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
@Test
|
||||||
|
fun `getAuthRequestByFingerprintFlow should emit update then expired and cancel when initial request succeeds and second succeeds after 15 mins have passed`() =
|
||||||
|
runTest {
|
||||||
|
val responseJsonOne = AuthRequestsResponseJson(
|
||||||
|
authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE),
|
||||||
|
)
|
||||||
|
val fixedClock: Clock = Clock.fixed(
|
||||||
|
Instant.parse("2022-11-12T00:00:00Z"),
|
||||||
|
ZoneOffset.UTC,
|
||||||
|
)
|
||||||
|
val authRequestsResponse = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy(
|
||||||
|
creationDate = ZonedDateTime.ofInstant(fixedClock.instant(), ZoneOffset.UTC),
|
||||||
|
requestApproved = false,
|
||||||
|
)
|
||||||
|
val expectedOne = AuthRequestUpdatesResult.Update(
|
||||||
|
authRequest = AUTH_REQUEST,
|
||||||
|
)
|
||||||
|
val expectedTwo = AuthRequestUpdatesResult.Expired
|
||||||
|
coEvery {
|
||||||
|
authSdkSource.getUserFingerprint(
|
||||||
|
email = EMAIL,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
)
|
||||||
|
} returns Result.success(FINGER_PRINT)
|
||||||
|
coEvery {
|
||||||
|
authRequestsService.getAuthRequests()
|
||||||
|
} returns responseJsonOne.asSuccess()
|
||||||
|
coEvery {
|
||||||
|
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||||
|
} returns Result.success(authRequestsResponse)
|
||||||
|
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||||
|
|
||||||
|
repository
|
||||||
|
.getAuthRequestByFingerprintFlow(FINGER_PRINT)
|
||||||
|
.test {
|
||||||
|
assertEquals(expectedOne, awaitItem())
|
||||||
|
assertEquals(expectedTwo, awaitItem())
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
authRequestsService.getAuthRequests()
|
||||||
|
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||||
|
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
@Test
|
||||||
|
fun `getAuthRequestByFingerprintFlow should emit update then update and not cancel when initial request succeeds and second succeeds before 15 mins passes`() =
|
||||||
|
runTest {
|
||||||
|
val responseJsonOne = AuthRequestsResponseJson(
|
||||||
|
authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE),
|
||||||
|
)
|
||||||
|
val newHash = "evenMoreSecureHash"
|
||||||
|
val authRequestsResponse = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy(
|
||||||
|
masterPasswordHash = newHash,
|
||||||
|
requestApproved = false,
|
||||||
|
)
|
||||||
|
val authRequest = AUTH_REQUEST.copy(
|
||||||
|
masterPasswordHash = newHash,
|
||||||
|
requestApproved = false,
|
||||||
|
)
|
||||||
|
val expectedOne = AuthRequestUpdatesResult.Update(
|
||||||
|
authRequest = AUTH_REQUEST,
|
||||||
|
)
|
||||||
|
val expectedTwo = AuthRequestUpdatesResult.Update(
|
||||||
|
authRequest = authRequest,
|
||||||
|
)
|
||||||
|
coEvery {
|
||||||
|
authSdkSource.getUserFingerprint(
|
||||||
|
email = EMAIL,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
)
|
||||||
|
} returns Result.success(FINGER_PRINT)
|
||||||
|
coEvery {
|
||||||
|
authRequestsService.getAuthRequests()
|
||||||
|
} returns responseJsonOne.asSuccess()
|
||||||
|
coEvery {
|
||||||
|
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||||
|
} returns Result.success(authRequestsResponse)
|
||||||
|
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||||
|
|
||||||
|
repository
|
||||||
|
.getAuthRequestByFingerprintFlow(FINGER_PRINT)
|
||||||
|
.test {
|
||||||
|
assertEquals(expectedOne, awaitItem())
|
||||||
|
assertEquals(expectedTwo, awaitItem())
|
||||||
|
cancelAndConsumeRemainingEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
authRequestsService.getAuthRequests()
|
||||||
|
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||||
|
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
@Test
|
||||||
|
fun `getAuthRequestByIdFlow should emit failure and cancel flow when getAuthRequests fails`() =
|
||||||
|
runTest {
|
||||||
|
coEvery {
|
||||||
|
authRequestsService.getAuthRequest(REQUEST_ID)
|
||||||
|
} returns Throwable("Fail").asFailure()
|
||||||
|
|
||||||
|
repository
|
||||||
|
.getAuthRequestByIdFlow(REQUEST_ID)
|
||||||
|
.test {
|
||||||
|
assertEquals(AuthRequestUpdatesResult.Error, awaitItem())
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
authRequestsService.getAuthRequest(REQUEST_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
@Test
|
||||||
|
fun `getAuthRequestByIdFlow should emit update then not cancel on failure when initial request succeeds and second fails`() =
|
||||||
|
runTest {
|
||||||
|
val authRequestResponseOne = Result.success(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE)
|
||||||
|
val authRequestResponseTwo = Result.failure<AuthRequestsResponseJson.AuthRequest>(
|
||||||
|
exception = mockk(),
|
||||||
|
)
|
||||||
|
val authRequest = AUTH_REQUEST.copy(
|
||||||
|
id = REQUEST_ID,
|
||||||
|
)
|
||||||
|
val expectedOne = AuthRequestUpdatesResult.Update(
|
||||||
|
authRequest = authRequest,
|
||||||
|
)
|
||||||
|
val expectedTwo = AuthRequestUpdatesResult.Error
|
||||||
|
coEvery {
|
||||||
|
authSdkSource.getUserFingerprint(
|
||||||
|
email = EMAIL,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
)
|
||||||
|
} returns Result.success(FINGER_PRINT)
|
||||||
|
coEvery {
|
||||||
|
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||||
|
} returns authRequestResponseOne andThen authRequestResponseTwo
|
||||||
|
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||||
|
|
||||||
|
repository
|
||||||
|
.getAuthRequestByIdFlow(REQUEST_ID)
|
||||||
|
.test {
|
||||||
|
assertEquals(expectedOne, awaitItem())
|
||||||
|
assertEquals(expectedTwo, awaitItem())
|
||||||
|
cancelAndConsumeRemainingEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||||
|
}
|
||||||
|
coVerify(exactly = 2) {
|
||||||
|
authRequestsService.getAuthRequest(REQUEST_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
@Test
|
||||||
|
fun `getAuthRequestByIdFlow should emit update then approved and cancel when initial request succeeds and second succeeds with requestApproved`() =
|
||||||
|
runTest {
|
||||||
|
val authRequestResponseOne = Result.success(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE)
|
||||||
|
val authRequestResponseJson = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy(
|
||||||
|
requestApproved = true,
|
||||||
|
)
|
||||||
|
val authRequestResponseTwo = Result.success(authRequestResponseJson)
|
||||||
|
val expectedOne = AuthRequestUpdatesResult.Update(
|
||||||
|
authRequest = AUTH_REQUEST,
|
||||||
|
)
|
||||||
|
val expectedTwo = AuthRequestUpdatesResult.Approved
|
||||||
|
coEvery {
|
||||||
|
authSdkSource.getUserFingerprint(
|
||||||
|
email = EMAIL,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
)
|
||||||
|
} returns Result.success(FINGER_PRINT)
|
||||||
|
coEvery {
|
||||||
|
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||||
|
} returns authRequestResponseOne andThen authRequestResponseTwo
|
||||||
|
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||||
|
|
||||||
|
repository
|
||||||
|
.getAuthRequestByIdFlow(REQUEST_ID)
|
||||||
|
.test {
|
||||||
|
assertEquals(expectedOne, awaitItem())
|
||||||
|
assertEquals(expectedTwo, awaitItem())
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||||
|
}
|
||||||
|
coVerify(exactly = 2) {
|
||||||
|
authRequestsService.getAuthRequest(REQUEST_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
@Test
|
||||||
|
fun `getAuthRequestByIdFlow should emit update then declined and cancel when initial request succeeds and second succeeds with valid response data`() =
|
||||||
|
runTest {
|
||||||
|
val authRequestResponseOne = Result.success(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE)
|
||||||
|
val authRequestResponseJson = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy(
|
||||||
|
responseDate = mockk(),
|
||||||
|
requestApproved = false,
|
||||||
|
)
|
||||||
|
val authRequestResponseTwo = Result.success(authRequestResponseJson)
|
||||||
|
val expectedOne = AuthRequestUpdatesResult.Update(
|
||||||
|
authRequest = AUTH_REQUEST,
|
||||||
|
)
|
||||||
|
val expectedTwo = AuthRequestUpdatesResult.Declined
|
||||||
|
coEvery {
|
||||||
|
authSdkSource.getUserFingerprint(
|
||||||
|
email = EMAIL,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
)
|
||||||
|
} returns Result.success(FINGER_PRINT)
|
||||||
|
coEvery {
|
||||||
|
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||||
|
} returns authRequestResponseOne andThen authRequestResponseTwo
|
||||||
|
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||||
|
|
||||||
|
repository
|
||||||
|
.getAuthRequestByIdFlow(REQUEST_ID)
|
||||||
|
.test {
|
||||||
|
assertEquals(expectedOne, awaitItem())
|
||||||
|
assertEquals(expectedTwo, awaitItem())
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||||
|
}
|
||||||
|
coVerify(exactly = 2) {
|
||||||
|
authRequestsService.getAuthRequest(REQUEST_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
@Test
|
||||||
|
fun `getAuthRequestByIdFlow should emit update then expired and cancel when initial request succeeds and second succeeds after 15 mins have passed`() =
|
||||||
|
runTest {
|
||||||
|
val fixedClock: Clock = Clock.fixed(
|
||||||
|
Instant.parse("2022-11-12T00:00:00Z"),
|
||||||
|
ZoneOffset.UTC,
|
||||||
|
)
|
||||||
|
val authRequestResponseOne = Result.success(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE)
|
||||||
|
val authRequestResponseJson = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy(
|
||||||
|
creationDate = ZonedDateTime.ofInstant(fixedClock.instant(), ZoneOffset.UTC),
|
||||||
|
requestApproved = false,
|
||||||
|
)
|
||||||
|
val authRequestResponseTwo = Result.success(authRequestResponseJson)
|
||||||
|
val expectedOne = AuthRequestUpdatesResult.Update(
|
||||||
|
authRequest = AUTH_REQUEST,
|
||||||
|
)
|
||||||
|
val expectedTwo = AuthRequestUpdatesResult.Expired
|
||||||
|
coEvery {
|
||||||
|
authSdkSource.getUserFingerprint(
|
||||||
|
email = EMAIL,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
)
|
||||||
|
} returns Result.success(FINGER_PRINT)
|
||||||
|
coEvery {
|
||||||
|
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||||
|
} returns authRequestResponseOne andThen authRequestResponseTwo
|
||||||
|
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||||
|
|
||||||
|
repository
|
||||||
|
.getAuthRequestByIdFlow(REQUEST_ID)
|
||||||
|
.test {
|
||||||
|
assertEquals(expectedOne, awaitItem())
|
||||||
|
assertEquals(expectedTwo, awaitItem())
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||||
|
}
|
||||||
|
coVerify(exactly = 2) {
|
||||||
|
authRequestsService.getAuthRequest(REQUEST_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
@Test
|
||||||
|
fun `getAuthRequestByIdFlow should emit update then update and not cancel when initial request succeeds and second succeeds before 15 mins passes`() =
|
||||||
|
runTest {
|
||||||
|
val newHash = "evenMoreSecureHash"
|
||||||
|
val authRequestResponseOne = Result.success(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE)
|
||||||
|
val authRequestResponseJson = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy(
|
||||||
|
masterPasswordHash = newHash,
|
||||||
|
requestApproved = false,
|
||||||
|
)
|
||||||
|
val authRequestResponseTwo = Result.success(authRequestResponseJson)
|
||||||
|
val authRequest = AUTH_REQUEST.copy(
|
||||||
|
masterPasswordHash = newHash,
|
||||||
|
requestApproved = false,
|
||||||
|
)
|
||||||
|
val expectedOne = AuthRequestUpdatesResult.Update(
|
||||||
|
authRequest = AUTH_REQUEST,
|
||||||
|
)
|
||||||
|
val expectedTwo = AuthRequestUpdatesResult.Update(
|
||||||
|
authRequest = authRequest,
|
||||||
|
)
|
||||||
|
coEvery {
|
||||||
|
authSdkSource.getUserFingerprint(
|
||||||
|
email = EMAIL,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
)
|
||||||
|
} returns Result.success(FINGER_PRINT)
|
||||||
|
coEvery {
|
||||||
|
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||||
|
} returns authRequestResponseOne andThen authRequestResponseTwo
|
||||||
|
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||||
|
|
||||||
|
repository
|
||||||
|
.getAuthRequestByIdFlow(REQUEST_ID)
|
||||||
|
.test {
|
||||||
|
assertEquals(expectedOne, awaitItem())
|
||||||
|
assertEquals(expectedTwo, awaitItem())
|
||||||
|
cancelAndConsumeRemainingEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||||
|
}
|
||||||
|
coVerify(exactly = 2) {
|
||||||
|
authRequestsService.getAuthRequest(REQUEST_ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
assertEquals(expected, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `getAuthRequests should return failure when service returns failure`() = runTest {
|
fun `getAuthRequests should return failure when service returns failure`() = runTest {
|
||||||
|
@ -3650,7 +4047,6 @@ class AuthRepositoryTest {
|
||||||
private const val EMAIL_2 = "test2@bitwarden.com"
|
private const val EMAIL_2 = "test2@bitwarden.com"
|
||||||
private const val PASSWORD = "password"
|
private const val PASSWORD = "password"
|
||||||
private const val PASSWORD_HASH = "passwordHash"
|
private const val PASSWORD_HASH = "passwordHash"
|
||||||
private const val PASSWORD_HASH_LOCAL = "passwordHashLocal"
|
|
||||||
private const val ACCESS_TOKEN = "accessToken"
|
private const val ACCESS_TOKEN = "accessToken"
|
||||||
private const val ACCESS_TOKEN_2 = "accessToken2"
|
private const val ACCESS_TOKEN_2 = "accessToken2"
|
||||||
private const val REFRESH_TOKEN = "refreshToken"
|
private const val REFRESH_TOKEN = "refreshToken"
|
||||||
|
@ -3796,5 +4192,33 @@ class AuthRepositoryTest {
|
||||||
status = VaultUnlockData.Status.UNLOCKED,
|
status = VaultUnlockData.Status.UNLOCKED,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
private const val FINGER_PRINT = "FINGER_PRINT"
|
||||||
|
private const val REQUEST_ID: String = "REQUEST_ID"
|
||||||
|
private val AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE =
|
||||||
|
AuthRequestsResponseJson.AuthRequest(
|
||||||
|
id = REQUEST_ID,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
platform = "Android",
|
||||||
|
ipAddress = "192.168.0.1",
|
||||||
|
key = "public",
|
||||||
|
masterPasswordHash = "verySecureHash",
|
||||||
|
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
|
||||||
|
responseDate = null,
|
||||||
|
requestApproved = true,
|
||||||
|
originUrl = "www.bitwarden.com",
|
||||||
|
)
|
||||||
|
private val AUTH_REQUEST = AuthRequest(
|
||||||
|
id = REQUEST_ID,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
platform = "Android",
|
||||||
|
ipAddress = "192.168.0.1",
|
||||||
|
key = "public",
|
||||||
|
masterPasswordHash = "verySecureHash",
|
||||||
|
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
|
||||||
|
responseDate = null,
|
||||||
|
requestApproved = true,
|
||||||
|
originUrl = "www.bitwarden.com",
|
||||||
|
fingerprint = FINGER_PRINT,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,8 +9,11 @@ import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performScrollTo
|
import androidx.compose.ui.test.performScrollTo
|
||||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||||
|
import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
|
import io.mockk.just
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.runs
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
@ -22,6 +25,9 @@ class LoginApprovalScreenTest : BaseComposeTest() {
|
||||||
|
|
||||||
private var onNavigateBackCalled = false
|
private var onNavigateBackCalled = false
|
||||||
|
|
||||||
|
private val exitManager: ExitManager = mockk {
|
||||||
|
every { exitApplication() } just runs
|
||||||
|
}
|
||||||
private val mutableEventFlow = bufferedMutableSharedFlow<LoginApprovalEvent>()
|
private val mutableEventFlow = bufferedMutableSharedFlow<LoginApprovalEvent>()
|
||||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||||
private val viewModel = mockk<LoginApprovalViewModel>(relaxed = true) {
|
private val viewModel = mockk<LoginApprovalViewModel>(relaxed = true) {
|
||||||
|
@ -35,6 +41,7 @@ class LoginApprovalScreenTest : BaseComposeTest() {
|
||||||
LoginApprovalScreen(
|
LoginApprovalScreen(
|
||||||
onNavigateBack = { onNavigateBackCalled = true },
|
onNavigateBack = { onNavigateBackCalled = true },
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
|
exitManager = exitManager,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -45,6 +52,14 @@ class LoginApprovalScreenTest : BaseComposeTest() {
|
||||||
assertTrue(onNavigateBackCalled)
|
assertTrue(onNavigateBackCalled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on ExitApp should call exit appliction`() {
|
||||||
|
mutableEventFlow.tryEmit(LoginApprovalEvent.ExitApp)
|
||||||
|
verify(exactly = 1) {
|
||||||
|
exitManager.exitApplication()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `on Confirm login should send ApproveRequestClick`() = runTest {
|
fun `on Confirm login should send ApproveRequestClick`() = runTest {
|
||||||
composeTestRule
|
composeTestRule
|
||||||
|
@ -93,6 +108,7 @@ class LoginApprovalScreenTest : BaseComposeTest() {
|
||||||
|
|
||||||
private const val FINGERPRINT = "fingerprint"
|
private const val FINGERPRINT = "fingerprint"
|
||||||
private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState(
|
private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState(
|
||||||
|
specialCircumstance = null,
|
||||||
fingerprint = FINGERPRINT,
|
fingerprint = FINGERPRINT,
|
||||||
masterPasswordHash = null,
|
masterPasswordHash = null,
|
||||||
publicKey = "publicKey",
|
publicKey = "publicKey",
|
||||||
|
|
|
@ -5,14 +5,18 @@ import app.cash.turbine.test
|
||||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
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.AuthRequest
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
|
||||||
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestUpdatesResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||||
|
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
|
||||||
|
import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData
|
||||||
|
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
|
||||||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
||||||
|
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.verify
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.jupiter.api.AfterEach
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
@ -24,11 +28,16 @@ import java.util.TimeZone
|
||||||
|
|
||||||
class LoginApprovalViewModelTest : BaseViewModelTest() {
|
class LoginApprovalViewModelTest : BaseViewModelTest() {
|
||||||
|
|
||||||
|
private val mockSpecialCircumstanceManager: SpecialCircumstanceManager = mockk {
|
||||||
|
every { specialCircumstance } returns null
|
||||||
|
}
|
||||||
private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
|
private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
|
||||||
|
private val mutableAuthRequestSharedFlow = bufferedMutableSharedFlow<AuthRequestUpdatesResult>()
|
||||||
private val mockAuthRepository = mockk<AuthRepository> {
|
private val mockAuthRepository = mockk<AuthRepository> {
|
||||||
coEvery {
|
coEvery {
|
||||||
getAuthRequest(FINGERPRINT)
|
getAuthRequestByFingerprintFlow(FINGERPRINT)
|
||||||
} returns AuthRequestResult.Success(AUTH_REQUEST)
|
} returns mutableAuthRequestSharedFlow
|
||||||
|
coEvery { getAuthRequestByIdFlow(REQUEST_ID) } returns mutableAuthRequestSharedFlow
|
||||||
every { userStateFlow } returns mutableUserStateFlow
|
every { userStateFlow } returns mutableUserStateFlow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,38 +54,113 @@ class LoginApprovalViewModelTest : BaseViewModelTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `initial state should be correct and trigger a getAuthRequest call`() {
|
fun `init should call getAuthRequestById when special circumstance is absent`() {
|
||||||
val viewModel = createViewModel(state = null)
|
createViewModel(state = null)
|
||||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
|
||||||
coVerify {
|
coVerify {
|
||||||
mockAuthRepository.getAuthRequest(FINGERPRINT)
|
mockAuthRepository.getAuthRequestByFingerprintFlow(FINGERPRINT)
|
||||||
}
|
}
|
||||||
verify {
|
}
|
||||||
mockAuthRepository.userStateFlow
|
|
||||||
|
@Test
|
||||||
|
fun `init should call getAuthRequest when special circumstance is present`() {
|
||||||
|
every {
|
||||||
|
mockSpecialCircumstanceManager.specialCircumstance
|
||||||
|
} returns SpecialCircumstance.PasswordlessRequest(
|
||||||
|
passwordlessRequestData = PasswordlessRequestData(
|
||||||
|
loginRequestId = REQUEST_ID,
|
||||||
|
userId = USER_ID,
|
||||||
|
),
|
||||||
|
shouldFinishWhenComplete = false,
|
||||||
|
)
|
||||||
|
createViewModel(state = null)
|
||||||
|
coVerify {
|
||||||
|
mockAuthRepository.getAuthRequestByIdFlow(REQUEST_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getAuthRequest update should update state`() {
|
||||||
|
val expected = DEFAULT_STATE.copy(
|
||||||
|
fingerprint = FINGERPRINT,
|
||||||
|
masterPasswordHash = AUTH_REQUEST.masterPasswordHash,
|
||||||
|
publicKey = AUTH_REQUEST.publicKey,
|
||||||
|
requestId = AUTH_REQUEST.id,
|
||||||
|
viewState = LoginApprovalState.ViewState.Content(
|
||||||
|
deviceType = AUTH_REQUEST.platform,
|
||||||
|
domainUrl = AUTH_REQUEST.originUrl,
|
||||||
|
email = EMAIL,
|
||||||
|
fingerprint = AUTH_REQUEST.fingerprint,
|
||||||
|
ipAddress = AUTH_REQUEST.ipAddress,
|
||||||
|
time = "9/13/24 12:00 AM",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
mutableAuthRequestSharedFlow.tryEmit(AuthRequestUpdatesResult.Update(AUTH_REQUEST))
|
||||||
|
assertEquals(expected, viewModel.stateFlow.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getAuthRequest approved should emit NavigateBack`() = runTest {
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
viewModel.eventFlow.test {
|
||||||
|
mutableAuthRequestSharedFlow.tryEmit(AuthRequestUpdatesResult.Approved)
|
||||||
|
assertEquals(LoginApprovalEvent.NavigateBack, awaitItem())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getAuthRequest declined should emit NavigateBack`() = runTest {
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
viewModel.eventFlow.test {
|
||||||
|
mutableAuthRequestSharedFlow.tryEmit(AuthRequestUpdatesResult.Declined)
|
||||||
|
assertEquals(LoginApprovalEvent.NavigateBack, awaitItem())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getAuthRequest expired should emit NavigateBack`() = runTest {
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
viewModel.eventFlow.test {
|
||||||
|
mutableAuthRequestSharedFlow.tryEmit(AuthRequestUpdatesResult.Expired)
|
||||||
|
assertEquals(LoginApprovalEvent.NavigateBack, awaitItem())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `getAuthRequest failure should update state`() {
|
fun `getAuthRequest failure should update state`() {
|
||||||
val authRepository = mockk<AuthRepository> {
|
|
||||||
coEvery {
|
|
||||||
getAuthRequest(FINGERPRINT)
|
|
||||||
} returns AuthRequestResult.Error
|
|
||||||
every { userStateFlow } returns mutableUserStateFlow
|
|
||||||
}
|
|
||||||
val expected = DEFAULT_STATE.copy(
|
val expected = DEFAULT_STATE.copy(
|
||||||
viewState = LoginApprovalState.ViewState.Error,
|
viewState = LoginApprovalState.ViewState.Error,
|
||||||
)
|
)
|
||||||
val viewModel = createViewModel(authRepository = authRepository)
|
val viewModel = createViewModel()
|
||||||
|
mutableAuthRequestSharedFlow.tryEmit(AuthRequestUpdatesResult.Error)
|
||||||
assertEquals(expected, viewModel.stateFlow.value)
|
assertEquals(expected, viewModel.stateFlow.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `on CloseClick should emit NavigateBack`() = runTest {
|
fun `on CloseClick should emit NavigateBack when shouldFinishWhenComplete is false`() =
|
||||||
val viewModel = createViewModel()
|
runTest {
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
viewModel.eventFlow.test {
|
||||||
|
viewModel.trySendAction(LoginApprovalAction.CloseClick)
|
||||||
|
assertEquals(LoginApprovalEvent.NavigateBack, awaitItem())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on CloseClick should emit ExitApp when shouldFinishWhenComplete is true`() = runTest {
|
||||||
|
every {
|
||||||
|
mockSpecialCircumstanceManager.specialCircumstance
|
||||||
|
} returns SpecialCircumstance.PasswordlessRequest(
|
||||||
|
passwordlessRequestData = PasswordlessRequestData(
|
||||||
|
loginRequestId = REQUEST_ID,
|
||||||
|
userId = USER_ID,
|
||||||
|
),
|
||||||
|
shouldFinishWhenComplete = true,
|
||||||
|
)
|
||||||
|
val viewModel = createViewModel(state = null)
|
||||||
viewModel.eventFlow.test {
|
viewModel.eventFlow.test {
|
||||||
viewModel.trySendAction(LoginApprovalAction.CloseClick)
|
viewModel.trySendAction(LoginApprovalAction.CloseClick)
|
||||||
assertEquals(LoginApprovalEvent.NavigateBack, awaitItem())
|
assertEquals(LoginApprovalEvent.ExitApp, awaitItem())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -149,9 +233,11 @@ class LoginApprovalViewModelTest : BaseViewModelTest() {
|
||||||
|
|
||||||
private fun createViewModel(
|
private fun createViewModel(
|
||||||
authRepository: AuthRepository = mockAuthRepository,
|
authRepository: AuthRepository = mockAuthRepository,
|
||||||
|
specialCircumstanceManager: SpecialCircumstanceManager = mockSpecialCircumstanceManager,
|
||||||
state: LoginApprovalState? = DEFAULT_STATE,
|
state: LoginApprovalState? = DEFAULT_STATE,
|
||||||
): LoginApprovalViewModel = LoginApprovalViewModel(
|
): LoginApprovalViewModel = LoginApprovalViewModel(
|
||||||
authRepository = authRepository,
|
authRepository = authRepository,
|
||||||
|
specialCircumstanceManager = specialCircumstanceManager,
|
||||||
savedStateHandle = SavedStateHandle()
|
savedStateHandle = SavedStateHandle()
|
||||||
.also { it["fingerprint"] = FINGERPRINT }
|
.also { it["fingerprint"] = FINGERPRINT }
|
||||||
.apply { set("state", state) },
|
.apply { set("state", state) },
|
||||||
|
@ -165,6 +251,7 @@ private const val PUBLIC_KEY = "publicKey"
|
||||||
private const val REQUEST_ID = "requestId"
|
private const val REQUEST_ID = "requestId"
|
||||||
private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState(
|
private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState(
|
||||||
fingerprint = FINGERPRINT,
|
fingerprint = FINGERPRINT,
|
||||||
|
specialCircumstance = null,
|
||||||
masterPasswordHash = PASSWORD_HASH,
|
masterPasswordHash = PASSWORD_HASH,
|
||||||
publicKey = PUBLIC_KEY,
|
publicKey = PUBLIC_KEY,
|
||||||
requestId = REQUEST_ID,
|
requestId = REQUEST_ID,
|
||||||
|
|
Loading…
Reference in a new issue