Poll for auth request updates (#939)

This commit is contained in:
David Perez 2024-02-01 02:16:07 -06:00 committed by Álison Fernandes
parent 624e60fd71
commit 33c64db85c
8 changed files with 841 additions and 124 deletions

View file

@ -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.

View file

@ -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

View file

@ -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()
}

View file

@ -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

View file

@ -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()
/** /**

View file

@ -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,
)
} }
} }

View file

@ -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",

View file

@ -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,