From 33c64db85c7c1bcf228b8bcfd94de5566b9138d3 Mon Sep 17 00:00:00 2001 From: David Perez Date: Thu, 1 Feb 2024 02:16:07 -0600 Subject: [PATCH] Poll for auth request updates (#939) --- .../data/auth/repository/AuthRepository.kt | 10 +- .../auth/repository/AuthRepositoryImpl.kt | 118 +++- .../model/AuthRequestUpdatesResult.kt | 33 + .../loginapproval/LoginApprovalScreen.kt | 10 + .../loginapproval/LoginApprovalViewModel.kt | 83 ++- .../auth/repository/AuthRepositoryTest.kt | 570 +++++++++++++++--- .../loginapproval/LoginApprovalScreenTest.kt | 16 + .../LoginApprovalViewModelTest.kt | 125 +++- 8 files changed, 841 insertions(+), 124 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequestUpdatesResult.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index 18e1d257c..5844f7913 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -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.repository.model.AuthRequest 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.AuthState import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult @@ -227,9 +228,14 @@ interface AuthRepository : AuthenticatorProvider { fun createAuthRequestWithUpdates(email: String): Flow /** - * 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 + + /** + * Get an auth request by its request ID and emits updates for that request. + */ + fun getAuthRequestByIdFlow(requestId: String): Flow /** * Get a list of the current user's [AuthRequest]s. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index a39af567b..f60d6cb44 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -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.repository.model.AuthRequest 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.AuthState import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult @@ -98,9 +99,11 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.isActive import java.time.Clock 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_RETRY_INTERVAL_MILLIS: Long = 4L * 1_000L +private const val PASSWORDLESS_APPROVER_INTERVAL_MILLIS: Long = 5L * 60L * 1_000L /** * Default implementation of [AuthRepository]. @@ -897,20 +900,113 @@ class AuthRepositoryImpl( } } - override suspend fun getAuthRequest( - fingerprint: String, - ): AuthRequestResult = - when (val authRequestsResult = getAuthRequests()) { - AuthRequestsResult.Error -> AuthRequestResult.Error - is AuthRequestsResult.Success -> { - val request = authRequestsResult.authRequests - .firstOrNull { it.fingerprint == fingerprint } + private fun getAuthRequest( + initialRequest: suspend () -> AuthRequestUpdatesResult, + ): Flow = flow { + val result = initialRequest() + emit(result) + if (result is AuthRequestUpdatesResult.Error) return@flow + var isComplete = false + 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 - ?.let { AuthRequestResult.Success(it) } - ?: AuthRequestResult.Error + !updateAuthRequest.requestApproved && + updateAuthRequest.responseDate != null -> { + 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 = 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 = 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 = authRequestsService diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequestUpdatesResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequestUpdatesResult.kt new file mode 100644 index 000000000..4ef03bd0b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequestUpdatesResult.kt @@ -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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalScreen.kt index 546b14d3e..5b673c020 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalScreen.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer 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.BitwardenScaffold 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.LocalNonMaterialTypography @@ -53,6 +56,7 @@ import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography @Composable fun LoginApprovalScreen( viewModel: LoginApprovalViewModel = hiltViewModel(), + exitManager: ExitManager = LocalExitManager.current, onNavigateBack: () -> Unit, ) { val state by viewModel.stateFlow.collectAsState() @@ -60,6 +64,7 @@ fun LoginApprovalScreen( val resources = context.resources EventsEffect(viewModel = viewModel) { event -> when (event) { + LoginApprovalEvent.ExitApp -> exitManager.exitApplication() LoginApprovalEvent.NavigateBack -> onNavigateBack() is LoginApprovalEvent.ShowToast -> { @@ -82,6 +87,11 @@ fun LoginApprovalScreen( }, ) + BackHandler( + onBack = remember(viewModel) { + { viewModel.trySendAction(LoginApprovalAction.CloseClick) } + }, + ) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) BitwardenScaffold( modifier = Modifier diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModel.kt index 9a73bc221..f02794662 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModel.kt @@ -6,10 +6,16 @@ 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.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.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText 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.launch import kotlinx.parcelize.Parcelize @@ -25,17 +31,25 @@ private const val KEY_STATE = "state" @HiltViewModel class LoginApprovalViewModel @Inject constructor( private val authRepository: AuthRepository, + private val specialCircumstanceManager: SpecialCircumstanceManager, savedStateHandle: SavedStateHandle, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] - ?: LoginApprovalState( - fingerprint = requireNotNull(LoginApprovalArgs(savedStateHandle).fingerprint), - masterPasswordHash = null, - publicKey = "", - requestId = "", - shouldShowErrorDialog = false, - viewState = LoginApprovalState.ViewState.Loading, - ), + ?: run { + val specialCircumstance = specialCircumstanceManager.specialCircumstance + as? SpecialCircumstance.PasswordlessRequest + LoginApprovalState( + specialCircumstance = specialCircumstance, + fingerprint = specialCircumstance + ?.let { "" } + ?: requireNotNull(LoginApprovalArgs(savedStateHandle).fingerprint), + masterPasswordHash = null, + publicKey = "", + requestId = "", + shouldShowErrorDialog = false, + viewState = LoginApprovalState.ViewState.Loading, + ) + }, ) { private val dateTimeFormatter get() = DateTimeFormatter @@ -43,13 +57,22 @@ class LoginApprovalViewModel @Inject constructor( .withZone(TimeZone.getDefault().toZoneId()) init { - viewModelScope.launch { - trySendAction( - LoginApprovalAction.Internal.AuthRequestResultReceive( - authRequestResult = authRepository.getAuthRequest(state.fingerprint), - ), - ) - } + state + .specialCircumstance + ?.let { + 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) { @@ -89,7 +112,7 @@ class LoginApprovalViewModel @Inject constructor( } private fun handleCloseClicked() { - sendEvent(LoginApprovalEvent.NavigateBack) + closeScreen() } private fun handleDeclineRequestClicked() { @@ -135,8 +158,9 @@ class LoginApprovalViewModel @Inject constructor( ) { val email = authRepository.userStateFlow.value?.activeAccount?.email ?: return when (val result = action.authRequestResult) { - is AuthRequestResult.Success -> mutableStateFlow.update { + is AuthRequestUpdatesResult.Update -> mutableStateFlow.update { it.copy( + fingerprint = result.authRequest.fingerprint, masterPasswordHash = result.authRequest.masterPasswordHash, publicKey = result.authRequest.publicKey, requestId = result.authRequest.id, @@ -151,11 +175,18 @@ class LoginApprovalViewModel @Inject constructor( ) } - is AuthRequestResult.Error -> mutableStateFlow.update { + is AuthRequestUpdatesResult.Error -> mutableStateFlow.update { it.copy( 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 shouldShowErrorDialog: Boolean, // Internal + val specialCircumstance: SpecialCircumstance.PasswordlessRequest?, val fingerprint: String, val masterPasswordHash: String?, val publicKey: String, @@ -227,6 +267,11 @@ data class LoginApprovalState( * Models events for the Login Approval screen. */ sealed class LoginApprovalEvent { + /** + * Closes the app. + */ + data object ExitApp : LoginApprovalEvent() + /** * Navigates back. */ @@ -279,7 +324,7 @@ sealed class LoginApprovalAction { * An auth request result has been received to populate the data on the screen. */ data class AuthRequestResultReceive( - val authRequestResult: AuthRequestResult, + val authRequestResult: AuthRequestUpdatesResult, ) : Internal() /** diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index cf61bdc6c..95dbcd825 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -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.repository.model.AuthRequest 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.AuthState import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult @@ -3011,93 +3012,489 @@ class AuthRepositoryTest { } } + @Suppress("MaxLineLength") @Test - fun `getAuthRequest should return failure when getAuthRequests returns failure`() = runTest { - val fingerprint = "fingerprint" - coEvery { - authRequestsService.getAuthRequests() - } returns Throwable("Fail").asFailure() + fun `getAuthRequestByFingerprintFlow should emit failure and cancel flow when getAuthRequests fails`() = + runTest { + val fingerprint = "fingerprint" + coEvery { + authRequestsService.getAuthRequests() + } returns Throwable("Fail").asFailure() - val result = repository.getAuthRequest(fingerprint) + repository + .getAuthRequestByFingerprintFlow(fingerprint) + .test { + assertEquals(AuthRequestUpdatesResult.Error, awaitItem()) + awaitComplete() + } - coVerify(exactly = 1) { - authRequestsService.getAuthRequests() + coVerify(exactly = 1) { + authRequestsService.getAuthRequests() + } } - assertEquals(AuthRequestResult.Error, result) - } + @Suppress("MaxLineLength") @Test - fun `getAuthRequest should return success when service returns success`() = runTest { - val fingerprint = "fingerprint" - val responseJson = AuthRequestsResponseJson( - authRequests = listOf( - 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, + fun `getAuthRequestByFingerprintFlow should emit update then not cancel on failure when initial request succeeds and second fails`() = + runTest { + val authRequestsResponseJson = AuthRequestsResponseJson( + authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE), ) - } returns Result.success(fingerprint) - coEvery { - authRequestsService.getAuthRequests() - } returns responseJson.asSuccess() - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + val authRequest = AUTH_REQUEST + 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.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) { - authRequestsService.getAuthRequests() - authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) + coVerify(exactly = 1) { + authRequestsService.getAuthRequests() + authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) + authRequestsService.getAuthRequest(requestId = REQUEST_ID) + } } - assertEquals(expected, result) - } + @Suppress("MaxLineLength") @Test - fun `getAuthRequest should return error when no matching fingerprint exists`() = runTest { - val fingerprint = "fingerprint" - val responseJson = AuthRequestsResponseJson( - authRequests = listOf(), - ) - val expected = AuthRequestResult.Error - coEvery { - authRequestsService.getAuthRequests() - } returns responseJson.asSuccess() + fun `getAuthRequestByFingerprintFlow should emit update then approved and cancel when initial request succeeds and second succeeds with requestApproved`() = + runTest { + val responseJsonOne = AuthRequestsResponseJson( + authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE), + ) + val authRequestsResponse = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy( + requestApproved = true, + ) + 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) { - authRequestsService.getAuthRequests() + coVerify(exactly = 1) { + 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( + 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 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 PASSWORD = "password" private const val PASSWORD_HASH = "passwordHash" - private const val PASSWORD_HASH_LOCAL = "passwordHashLocal" private const val ACCESS_TOKEN = "accessToken" private const val ACCESS_TOKEN_2 = "accessToken2" private const val REFRESH_TOKEN = "refreshToken" @@ -3796,5 +4192,33 @@ class AuthRepositoryTest { 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, + ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalScreenTest.kt index 6c5a3329f..918219b6d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalScreenTest.kt @@ -9,8 +9,11 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest @@ -22,6 +25,9 @@ class LoginApprovalScreenTest : BaseComposeTest() { private var onNavigateBackCalled = false + private val exitManager: ExitManager = mockk { + every { exitApplication() } just runs + } private val mutableEventFlow = bufferedMutableSharedFlow() private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) private val viewModel = mockk(relaxed = true) { @@ -35,6 +41,7 @@ class LoginApprovalScreenTest : BaseComposeTest() { LoginApprovalScreen( onNavigateBack = { onNavigateBackCalled = true }, viewModel = viewModel, + exitManager = exitManager, ) } } @@ -45,6 +52,14 @@ class LoginApprovalScreenTest : BaseComposeTest() { assertTrue(onNavigateBackCalled) } + @Test + fun `on ExitApp should call exit appliction`() { + mutableEventFlow.tryEmit(LoginApprovalEvent.ExitApp) + verify(exactly = 1) { + exitManager.exitApplication() + } + } + @Test fun `on Confirm login should send ApproveRequestClick`() = runTest { composeTestRule @@ -93,6 +108,7 @@ class LoginApprovalScreenTest : BaseComposeTest() { private const val FINGERPRINT = "fingerprint" private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState( + specialCircumstance = null, fingerprint = FINGERPRINT, masterPasswordHash = null, publicKey = "publicKey", diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModelTest.kt index 58e6e8477..c2ae2182b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModelTest.kt @@ -5,14 +5,18 @@ import app.cash.turbine.test 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.AuthRequestResult +import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestUpdatesResult 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.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach @@ -24,11 +28,16 @@ import java.util.TimeZone class LoginApprovalViewModelTest : BaseViewModelTest() { + private val mockSpecialCircumstanceManager: SpecialCircumstanceManager = mockk { + every { specialCircumstance } returns null + } private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE) + private val mutableAuthRequestSharedFlow = bufferedMutableSharedFlow() private val mockAuthRepository = mockk { coEvery { - getAuthRequest(FINGERPRINT) - } returns AuthRequestResult.Success(AUTH_REQUEST) + getAuthRequestByFingerprintFlow(FINGERPRINT) + } returns mutableAuthRequestSharedFlow + coEvery { getAuthRequestByIdFlow(REQUEST_ID) } returns mutableAuthRequestSharedFlow every { userStateFlow } returns mutableUserStateFlow } @@ -45,38 +54,113 @@ class LoginApprovalViewModelTest : BaseViewModelTest() { } @Test - fun `initial state should be correct and trigger a getAuthRequest call`() { - val viewModel = createViewModel(state = null) - assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + fun `init should call getAuthRequestById when special circumstance is absent`() { + createViewModel(state = null) 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 fun `getAuthRequest failure should update state`() { - val authRepository = mockk { - coEvery { - getAuthRequest(FINGERPRINT) - } returns AuthRequestResult.Error - every { userStateFlow } returns mutableUserStateFlow - } val expected = DEFAULT_STATE.copy( viewState = LoginApprovalState.ViewState.Error, ) - val viewModel = createViewModel(authRepository = authRepository) + val viewModel = createViewModel() + mutableAuthRequestSharedFlow.tryEmit(AuthRequestUpdatesResult.Error) assertEquals(expected, viewModel.stateFlow.value) } @Test - fun `on CloseClick should emit NavigateBack`() = runTest { - val viewModel = createViewModel() + fun `on CloseClick should emit NavigateBack when shouldFinishWhenComplete is false`() = + 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.trySendAction(LoginApprovalAction.CloseClick) - assertEquals(LoginApprovalEvent.NavigateBack, awaitItem()) + assertEquals(LoginApprovalEvent.ExitApp, awaitItem()) } } @@ -149,9 +233,11 @@ class LoginApprovalViewModelTest : BaseViewModelTest() { private fun createViewModel( authRepository: AuthRepository = mockAuthRepository, + specialCircumstanceManager: SpecialCircumstanceManager = mockSpecialCircumstanceManager, state: LoginApprovalState? = DEFAULT_STATE, ): LoginApprovalViewModel = LoginApprovalViewModel( authRepository = authRepository, + specialCircumstanceManager = specialCircumstanceManager, savedStateHandle = SavedStateHandle() .also { it["fingerprint"] = FINGERPRINT } .apply { set("state", state) }, @@ -165,6 +251,7 @@ private const val PUBLIC_KEY = "publicKey" private const val REQUEST_ID = "requestId" private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState( fingerprint = FINGERPRINT, + specialCircumstance = null, masterPasswordHash = PASSWORD_HASH, publicKey = PUBLIC_KEY, requestId = REQUEST_ID,