mirror of
https://github.com/bitwarden/android.git
synced 2026-10-22 02:53:09 -05:00
[PM-41929] fix: Update the manage devices screen when a passwordless request push is received (#7280)
This commit is contained in:
1 parent
b9296e9e16
commit
1d80697c2b
13 files changed
+482
-231
No files matched your search
@@ -32,7 +32,8 @@ interface AuthRequestManager {
|
||||
fun getAuthRequestByIdFlow(requestId: String): Flow<AuthRequestUpdatesResult>
|
||||
|
||||
/**
|
||||
* Get all auth request and emits updates over time.
|
||||
* Get all auth requests and emits updates over time, including when a passwordless push for
|
||||
* the active user indicates the list changed.
|
||||
*/
|
||||
fun getAuthRequestsWithUpdates(): Flow<AuthRequestsUpdatesResult>
|
||||
|
||||
|
||||
+60
-107
@@ -18,13 +18,18 @@ import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsResult
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsUpdatesResult
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.CreateAuthRequestResult
|
||||
import com.x8bit.bitwarden.data.auth.manager.util.isSso
|
||||
import com.x8bit.bitwarden.data.auth.manager.util.toAuthRequest
|
||||
import com.x8bit.bitwarden.data.auth.manager.util.toAuthRequestTypeJson
|
||||
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
|
||||
import com.x8bit.bitwarden.data.platform.manager.PushManager
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.isActive
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
@@ -38,7 +43,7 @@ private const val PASSWORDLESS_APPROVER_INTERVAL_MILLIS: Long = 5L * 60L * 1_000
|
||||
/**
|
||||
* Default implementation of [AuthRequestManager].
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
@Singleton
|
||||
class AuthRequestManagerImpl(
|
||||
private val clock: Clock,
|
||||
@@ -47,23 +52,31 @@ class AuthRequestManagerImpl(
|
||||
private val authDiskSource: AuthDiskSource,
|
||||
private val authSdkSource: AuthSdkSource,
|
||||
private val vaultSdkSource: VaultSdkSource,
|
||||
private val pushManager: PushManager,
|
||||
) : AuthRequestManager {
|
||||
private val activeUserId: String? get() = authDiskSource.userState?.activeUserId
|
||||
|
||||
override fun getAuthRequestsWithUpdates(): Flow<AuthRequestsUpdatesResult> = flow {
|
||||
while (currentCoroutineContext().isActive) {
|
||||
override fun getAuthRequestsWithUpdates(): Flow<AuthRequestsUpdatesResult> = merge(
|
||||
// Reads immediately, then on the polling interval.
|
||||
flow {
|
||||
while (currentCoroutineContext().isActive) {
|
||||
emit(Unit)
|
||||
delay(timeMillis = PASSWORDLESS_APPROVER_INTERVAL_MILLIS)
|
||||
}
|
||||
},
|
||||
pushManager
|
||||
.passwordlessRequestFlow
|
||||
.filter { it.userId == activeUserId }
|
||||
.map { },
|
||||
)
|
||||
.map {
|
||||
when (val result = getAuthRequests()) {
|
||||
is AuthRequestsResult.Error -> {
|
||||
emit(AuthRequestsUpdatesResult.Error(error = result.error))
|
||||
}
|
||||
|
||||
is AuthRequestsResult.Error -> AuthRequestsUpdatesResult.Error(error = result.error)
|
||||
is AuthRequestsResult.Success -> {
|
||||
emit(AuthRequestsUpdatesResult.Update(authRequests = result.authRequests))
|
||||
AuthRequestsUpdatesResult.Update(authRequests = result.authRequests)
|
||||
}
|
||||
}
|
||||
delay(timeMillis = PASSWORDLESS_APPROVER_INTERVAL_MILLIS)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
override fun createAuthRequestWithUpdates(
|
||||
@@ -91,18 +104,11 @@ class AuthRequestManagerImpl(
|
||||
isSso = authRequestType.isSso,
|
||||
)
|
||||
.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,
|
||||
request.toAuthRequest(
|
||||
fingerprint = authRequest.fingerprint,
|
||||
publicKey = request.publicKey,
|
||||
responseDate = request.responseDate,
|
||||
isRequestApproved = request.requestApproved ?: false,
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
@@ -183,20 +189,13 @@ class AuthRequestManagerImpl(
|
||||
isRequestApproved = false
|
||||
responseDate = clock.instant()
|
||||
}
|
||||
AuthRequest(
|
||||
id = request.id,
|
||||
platform = request.platform,
|
||||
ipAddress = request.ipAddress,
|
||||
key = request.key,
|
||||
masterPasswordHash = request.masterPasswordHash,
|
||||
creationDate = request.creationDate,
|
||||
originUrl = request.originUrl,
|
||||
responseDate = responseDate,
|
||||
requestApproved = isRequestApproved,
|
||||
// The PublicKey and Fingerprint should be frozen in place to
|
||||
// ensure no funny-business happens between multiple requests.
|
||||
publicKey = initialAuthRequest.publicKey,
|
||||
// The PublicKey and Fingerprint should be frozen in place to ensure no
|
||||
// funny-business happens between multiple requests.
|
||||
request.toAuthRequest(
|
||||
fingerprint = initialAuthRequest.fingerprint,
|
||||
publicKey = initialAuthRequest.publicKey,
|
||||
responseDate = responseDate,
|
||||
isRequestApproved = isRequestApproved,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -258,23 +257,12 @@ class AuthRequestManagerImpl(
|
||||
authRequestsService
|
||||
.getAuthRequest(requestId)
|
||||
.mapCatching { response ->
|
||||
getFingerprintPhrase(response.publicKey)
|
||||
.getOrThrow()
|
||||
.let { fingerprint ->
|
||||
AuthRequest(
|
||||
id = response.id,
|
||||
publicKey = response.publicKey,
|
||||
platform = response.platform,
|
||||
ipAddress = response.ipAddress,
|
||||
key = response.key,
|
||||
masterPasswordHash = response.masterPasswordHash,
|
||||
creationDate = response.creationDate,
|
||||
responseDate = response.responseDate,
|
||||
requestApproved = response.requestApproved ?: false,
|
||||
originUrl = response.originUrl,
|
||||
fingerprint = fingerprint,
|
||||
)
|
||||
}
|
||||
response.toAuthRequest(
|
||||
fingerprint = getFingerprintPhrase(response.publicKey).getOrThrow(),
|
||||
publicKey = response.publicKey,
|
||||
responseDate = response.responseDate,
|
||||
isRequestApproved = response.requestApproved ?: false,
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
onFailure = { AuthRequestUpdatesResult.Error(error = it) },
|
||||
@@ -288,18 +276,11 @@ class AuthRequestManagerImpl(
|
||||
.flatMap { request ->
|
||||
if (request.requestApproved == true) {
|
||||
getFingerprintPhrase(request.publicKey).map { fingerprint ->
|
||||
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 = true,
|
||||
originUrl = request.originUrl,
|
||||
request.toAuthRequest(
|
||||
fingerprint = fingerprint,
|
||||
publicKey = request.publicKey,
|
||||
responseDate = request.responseDate,
|
||||
isRequestApproved = true,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -313,18 +294,11 @@ class AuthRequestManagerImpl(
|
||||
.map { response ->
|
||||
response.authRequests.mapNotNull { request ->
|
||||
getFingerprintPhrase(request.publicKey).getOrNull()?.let { fingerprint ->
|
||||
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,
|
||||
request.toAuthRequest(
|
||||
fingerprint = fingerprint,
|
||||
publicKey = request.publicKey,
|
||||
responseDate = request.responseDate,
|
||||
isRequestApproved = request.requestApproved ?: false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -356,18 +330,11 @@ class AuthRequestManagerImpl(
|
||||
)
|
||||
}
|
||||
.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,
|
||||
request.toAuthRequest(
|
||||
fingerprint = "",
|
||||
publicKey = request.publicKey,
|
||||
responseDate = request.responseDate,
|
||||
isRequestApproved = request.requestApproved ?: false,
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
@@ -391,20 +358,13 @@ class AuthRequestManagerImpl(
|
||||
?.let { pendingAuthRequest ->
|
||||
authRequestsService
|
||||
.getAuthRequest(pendingAuthRequest.requestId)
|
||||
.map {
|
||||
.map { request ->
|
||||
NewAuthRequestData(
|
||||
authRequest = AuthRequest(
|
||||
id = it.id,
|
||||
publicKey = it.publicKey,
|
||||
platform = it.platform,
|
||||
ipAddress = it.ipAddress,
|
||||
key = it.key,
|
||||
masterPasswordHash = it.masterPasswordHash,
|
||||
creationDate = it.creationDate,
|
||||
responseDate = it.responseDate,
|
||||
requestApproved = it.requestApproved ?: false,
|
||||
originUrl = it.originUrl,
|
||||
authRequest = request.toAuthRequest(
|
||||
fingerprint = pendingAuthRequest.requestFingerprint,
|
||||
publicKey = request.publicKey,
|
||||
responseDate = request.responseDate,
|
||||
isRequestApproved = request.requestApproved ?: false,
|
||||
),
|
||||
privateKey = pendingAuthRequest.requestPrivateKey,
|
||||
accessCode = pendingAuthRequest.requestAccessCode,
|
||||
@@ -456,18 +416,11 @@ class AuthRequestManagerImpl(
|
||||
}
|
||||
}
|
||||
.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,
|
||||
request.toAuthRequest(
|
||||
fingerprint = authRequestResponse.fingerprint,
|
||||
publicKey = request.publicKey,
|
||||
responseDate = request.responseDate,
|
||||
isRequestApproved = request.requestApproved ?: false,
|
||||
)
|
||||
}
|
||||
.map {
|
||||
|
||||
@@ -84,6 +84,7 @@ object AuthManagerModule {
|
||||
authSdkSource: AuthSdkSource,
|
||||
vaultSdkSource: VaultSdkSource,
|
||||
authDiskSource: AuthDiskSource,
|
||||
pushManager: PushManager,
|
||||
): AuthRequestManager =
|
||||
AuthRequestManagerImpl(
|
||||
clock = clock,
|
||||
@@ -92,6 +93,7 @@ object AuthManagerModule {
|
||||
authSdkSource = authSdkSource,
|
||||
vaultSdkSource = vaultSdkSource,
|
||||
authDiskSource = authDiskSource,
|
||||
pushManager = pushManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.x8bit.bitwarden.data.auth.manager.util
|
||||
|
||||
import com.bitwarden.core.util.isOverFiveMinutesOld
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest
|
||||
import java.time.Clock
|
||||
|
||||
/**
|
||||
* Whether this request may still be approved or declined
|
||||
* and has not expired (it is under 5 minutes old).
|
||||
*/
|
||||
fun AuthRequest.isActionable(clock: Clock): Boolean =
|
||||
!requestApproved &&
|
||||
responseDate == null &&
|
||||
!creationDate.isOverFiveMinutesOld(clock)
|
||||
|
||||
/**
|
||||
* Filters out [AuthRequest]s that match one of the following criteria:
|
||||
* * The request has been approved.
|
||||
* * The request has been declined (indicated by it not being approved & having a responseDate).
|
||||
* * The request has expired (it is at least 5 minutes old).
|
||||
*/
|
||||
fun List<AuthRequest>.filterRespondedAndExpired(clock: Clock): List<AuthRequest> =
|
||||
filter { it.isActionable(clock = clock) }
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.x8bit.bitwarden.data.auth.manager.util
|
||||
|
||||
import com.bitwarden.network.model.AuthRequestsResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Converts the given [AuthRequestsResponseJson.AuthRequest] to an [AuthRequest], given the
|
||||
* [fingerprint] that the response itself does not carry.
|
||||
*
|
||||
* The [publicKey], [responseDate], and [isRequestApproved] are supplied by the caller.
|
||||
*/
|
||||
fun AuthRequestsResponseJson.AuthRequest.toAuthRequest(
|
||||
fingerprint: String,
|
||||
publicKey: String,
|
||||
responseDate: Instant?,
|
||||
isRequestApproved: Boolean,
|
||||
): AuthRequest = AuthRequest(
|
||||
id = this.id,
|
||||
publicKey = publicKey,
|
||||
platform = this.platform,
|
||||
ipAddress = this.ipAddress,
|
||||
key = this.key,
|
||||
masterPasswordHash = this.masterPasswordHash,
|
||||
creationDate = this.creationDate,
|
||||
responseDate = responseDate,
|
||||
requestApproved = isRequestApproved,
|
||||
originUrl = this.originUrl,
|
||||
fingerprint = fingerprint,
|
||||
)
|
||||
+17
-46
@@ -10,7 +10,6 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.core.data.manager.BuildInfoManager
|
||||
import com.bitwarden.core.data.util.toFormattedDateTimeStyle
|
||||
import com.bitwarden.core.util.isBuildVersionAtLeast
|
||||
import com.bitwarden.core.util.isOverFiveMinutesOld
|
||||
import com.bitwarden.ui.platform.base.BackgroundEvent
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
|
||||
@@ -18,6 +17,7 @@ import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsUpdatesResult
|
||||
import com.x8bit.bitwarden.data.auth.manager.util.filterRespondedAndExpired
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.DeviceInfo
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.GetDevicesResult
|
||||
@@ -63,15 +63,13 @@ class ManageDevicesViewModel @Inject constructor(
|
||||
isRefreshing = false,
|
||||
internalHideBottomSheet = false,
|
||||
isFdroid = buildInfoManager.isFdroid,
|
||||
devicesLoaded = false,
|
||||
authRequestsLoaded = false,
|
||||
),
|
||||
) {
|
||||
private var authJob: Job = Job().apply { complete() }
|
||||
private var devicesJob: Job = Job().apply { complete() }
|
||||
|
||||
init {
|
||||
updateAuthRequestList()
|
||||
fetchAllDevices()
|
||||
settingsRepository
|
||||
.getPullToRefreshEnabledFlow()
|
||||
.map { ManageDevicesAction.Internal.PullToRefreshEnableReceive(it) }
|
||||
@@ -111,17 +109,8 @@ class ManageDevicesViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun handleRefreshPull() {
|
||||
val shouldRefetchDevices = !state.devicesLoaded
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isRefreshing = true,
|
||||
authRequestsLoaded = false,
|
||||
)
|
||||
}
|
||||
mutableStateFlow.update { it.copy(isRefreshing = true) }
|
||||
updateAuthRequestList()
|
||||
if (shouldRefetchDevices) {
|
||||
fetchAllDevices()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePendingRequestRowClicked(
|
||||
@@ -174,7 +163,10 @@ class ManageDevicesViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun fetchAllDevices() {
|
||||
viewModelScope.launch {
|
||||
// Canceled first so a slower earlier read cannot land after a newer one and render a
|
||||
// stale list or dismiss the pull-to-refresh indicator early.
|
||||
devicesJob.cancel()
|
||||
devicesJob = viewModelScope.launch {
|
||||
sendAction(
|
||||
ManageDevicesAction.Internal.GetDevicesResultReceive(
|
||||
devicesResult = authRepository.getDevices(),
|
||||
@@ -193,16 +185,10 @@ class ManageDevicesViewModel @Inject constructor(
|
||||
|
||||
is AuthRequestsUpdatesResult.Error -> emptyList()
|
||||
}
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
authRequests = filteredRequests.toImmutableList(),
|
||||
authRequestsLoaded = true,
|
||||
isRefreshing = if (state.devicesLoaded) false else it.isRefreshing,
|
||||
)
|
||||
}
|
||||
if (state.devicesLoaded) {
|
||||
updateContentWithCurrentData()
|
||||
}
|
||||
mutableStateFlow.update { it.copy(authRequests = filteredRequests.toImmutableList()) }
|
||||
// The device list is the only source that reports which device owns a pending request, so
|
||||
// it is re-read before the new list can be rendered against it.
|
||||
fetchAllDevices()
|
||||
}
|
||||
|
||||
private fun handleGetDevicesResultReceived(
|
||||
@@ -211,7 +197,10 @@ class ManageDevicesViewModel @Inject constructor(
|
||||
val devicesResult = action.devicesResult as? GetDevicesResult.Success
|
||||
?: run {
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = ManageDevicesState.ViewState.Error, isRefreshing = false)
|
||||
it.copy(
|
||||
viewState = ManageDevicesState.ViewState.Error,
|
||||
isRefreshing = false,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -219,13 +208,10 @@ class ManageDevicesViewModel @Inject constructor(
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
devices = devicesResult.devices.toImmutableList(),
|
||||
devicesLoaded = true,
|
||||
isRefreshing = if (state.authRequestsLoaded) false else it.isRefreshing,
|
||||
isRefreshing = false,
|
||||
)
|
||||
}
|
||||
if (state.authRequestsLoaded) {
|
||||
updateContentWithCurrentData()
|
||||
}
|
||||
updateContentWithCurrentData()
|
||||
}
|
||||
|
||||
private fun updateContentWithCurrentData() {
|
||||
@@ -285,8 +271,6 @@ data class ManageDevicesState(
|
||||
val isRefreshing: Boolean,
|
||||
private val internalHideBottomSheet: Boolean,
|
||||
private val isFdroid: Boolean,
|
||||
val devicesLoaded: Boolean,
|
||||
val authRequestsLoaded: Boolean,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
@@ -457,16 +441,3 @@ enum class DeviceSessionStatus {
|
||||
Pending,
|
||||
None,
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out [AuthRequest]s that match one of the following criteria:
|
||||
* * The request has been approved.
|
||||
* * The request has been declined (indicated by it not being approved & having a responseDate).
|
||||
* * The request has expired (it is at least 5 minutes old).
|
||||
*/
|
||||
private fun List<AuthRequest>.filterRespondedAndExpired(clock: Clock) =
|
||||
filterNot { request ->
|
||||
request.requestApproved ||
|
||||
request.responseDate != null ||
|
||||
request.creationDate.isOverFiveMinutesOld(clock)
|
||||
}
|
||||
+1
-14
@@ -8,13 +8,13 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.core.data.manager.BuildInfoManager
|
||||
import com.bitwarden.core.data.util.toFormattedDateTimeStyle
|
||||
import com.bitwarden.core.util.isBuildVersionAtLeast
|
||||
import com.bitwarden.core.util.isOverFiveMinutesOld
|
||||
import com.bitwarden.ui.platform.base.BackgroundEvent
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
|
||||
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsUpdatesResult
|
||||
import com.x8bit.bitwarden.data.auth.manager.util.filterRespondedAndExpired
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
|
||||
@@ -391,16 +391,3 @@ sealed class PendingRequestsAction {
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out [AuthRequest]s that match one of the following criteria:
|
||||
* * The request has been approved.
|
||||
* * The request has been declined (indicated by it not being approved & having a responseDate).
|
||||
* * The request has expired (it is at least 5 minutes old).
|
||||
*/
|
||||
private fun List<AuthRequest>.filterRespondedAndExpired(clock: Clock) =
|
||||
filterNot { request ->
|
||||
request.requestApproved ||
|
||||
request.responseDate != null ||
|
||||
request.creationDate.isOverFiveMinutesOld(clock)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.manager
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.core.AuthRequestResponse
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.core.data.util.asFailure
|
||||
import com.bitwarden.core.data.util.asSuccess
|
||||
import com.bitwarden.network.model.AuthRequestTypeJson
|
||||
@@ -22,9 +23,12 @@ import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestUpdatesResult
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsResult
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsUpdatesResult
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.CreateAuthRequestResult
|
||||
import com.x8bit.bitwarden.data.platform.manager.PushManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
@@ -52,6 +56,11 @@ class AuthRequestManagerTest {
|
||||
} returns "AsymmetricEncString".asSuccess()
|
||||
}
|
||||
private val fakeAuthDiskSource = FakeAuthDiskSource()
|
||||
private val mutablePasswordlessRequestFlow =
|
||||
bufferedMutableSharedFlow<PasswordlessRequestData>()
|
||||
private val pushManager: PushManager = mockk {
|
||||
every { passwordlessRequestFlow } returns mutablePasswordlessRequestFlow
|
||||
}
|
||||
|
||||
private val repository: AuthRequestManager = AuthRequestManagerImpl(
|
||||
clock = fixedClock,
|
||||
@@ -60,6 +69,7 @@ class AuthRequestManagerTest {
|
||||
authSdkSource = authSdkSource,
|
||||
vaultSdkSource = vaultSdkSource,
|
||||
authDiskSource = fakeAuthDiskSource,
|
||||
pushManager = pushManager,
|
||||
)
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@@ -1281,6 +1291,38 @@ class AuthRequestManagerTest {
|
||||
}
|
||||
assertEquals(expected, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getAuthRequestsWithUpdates should re-read on a push and ignore a non-active user push`() =
|
||||
runTest {
|
||||
val expected = AuthRequestsUpdatesResult.Update(authRequests = listOf(AUTH_REQUEST))
|
||||
coEvery { authRequestsService.getAuthRequests() } returns AuthRequestsResponseJson(
|
||||
authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE),
|
||||
)
|
||||
.asSuccess()
|
||||
coEvery {
|
||||
authSdkSource.getUserFingerprint(email = EMAIL, publicKey = PUBLIC_KEY)
|
||||
} returns FINGER_PRINT.asSuccess()
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE
|
||||
|
||||
repository
|
||||
.getAuthRequestsWithUpdates()
|
||||
.test {
|
||||
assertEquals(expected, awaitItem())
|
||||
|
||||
mutablePasswordlessRequestFlow.emit(
|
||||
PASSWORDLESS_REQUEST_DATA.copy(userId = "otherUserId"),
|
||||
)
|
||||
mutablePasswordlessRequestFlow.emit(PASSWORDLESS_REQUEST_DATA)
|
||||
|
||||
// Only the active user's push produces a re-read, and it arrives without
|
||||
// waiting for the polling interval.
|
||||
assertEquals(expected, awaitItem())
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
|
||||
coVerify(exactly = 2) { authRequestsService.getAuthRequests() }
|
||||
}
|
||||
}
|
||||
|
||||
private const val EMAIL: String = "test@bitwarden.com"
|
||||
@@ -1363,3 +1405,8 @@ private val AUTH_REQUEST_RESPONSE: AuthRequestResponse = AuthRequestResponse(
|
||||
accessCode = "accessCode",
|
||||
fingerprint = "fingerprint",
|
||||
)
|
||||
|
||||
private val PASSWORDLESS_REQUEST_DATA: PasswordlessRequestData = PasswordlessRequestData(
|
||||
loginRequestId = REQUEST_ID,
|
||||
userId = USER_ID,
|
||||
)
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.x8bit.bitwarden.data.auth.manager.util
|
||||
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class AuthRequestExtensionsTest {
|
||||
private val clock: Clock = Clock.fixed(
|
||||
Instant.parse("2024-09-13T00:04:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `isActionable should return true when unanswered and under five minutes old`() {
|
||||
assertTrue(AUTH_REQUEST.isActionable(clock = clock))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isActionable should return false when the request has been approved`() {
|
||||
assertFalse(AUTH_REQUEST.copy(requestApproved = true).isActionable(clock = clock))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isActionable should return false when the request has a response date`() {
|
||||
assertFalse(
|
||||
AUTH_REQUEST
|
||||
.copy(responseDate = Instant.parse("2024-09-13T00:03:00Z"))
|
||||
.isActionable(clock = clock),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isActionable should return false when the request is over five minutes old`() {
|
||||
assertFalse(
|
||||
AUTH_REQUEST
|
||||
.copy(creationDate = Instant.parse("2024-09-12T23:58:00Z"))
|
||||
.isActionable(clock = clock),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filterRespondedAndExpired should retain only the actionable requests`() {
|
||||
val actionable = AUTH_REQUEST.copy(id = "actionable")
|
||||
val approved = AUTH_REQUEST.copy(id = "approved", requestApproved = true)
|
||||
val declined = AUTH_REQUEST.copy(
|
||||
id = "declined",
|
||||
responseDate = Instant.parse("2024-09-13T00:03:00Z"),
|
||||
)
|
||||
val expired = AUTH_REQUEST.copy(
|
||||
id = "expired",
|
||||
creationDate = Instant.parse("2024-09-12T23:58:00Z"),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(actionable),
|
||||
listOf(actionable, approved, declined, expired)
|
||||
.filterRespondedAndExpired(clock = clock),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filterRespondedAndExpired should return an empty list when given an empty list`() {
|
||||
assertEquals(
|
||||
emptyList<AuthRequest>(),
|
||||
emptyList<AuthRequest>().filterRespondedAndExpired(clock = clock),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val AUTH_REQUEST: AuthRequest = AuthRequest(
|
||||
id = "1",
|
||||
publicKey = "publicKey",
|
||||
platform = "Android",
|
||||
ipAddress = "192.168.0.1",
|
||||
key = "key",
|
||||
masterPasswordHash = "verySecureHash",
|
||||
creationDate = Instant.parse("2024-09-13T00:00:00Z"),
|
||||
responseDate = null,
|
||||
requestApproved = false,
|
||||
originUrl = "www.bitwarden.com",
|
||||
fingerprint = "fingerprint",
|
||||
)
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.x8bit.bitwarden.data.auth.manager.util
|
||||
|
||||
import com.bitwarden.network.model.AuthRequestsResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Instant
|
||||
|
||||
class AuthRequestsResponseJsonExtensionsTest {
|
||||
@Test
|
||||
fun `toAuthRequest should map each property and apply the given values`() {
|
||||
val fingerprint = "fingerprint"
|
||||
val responseDate = Instant.parse("2024-09-13T00:10:00Z")
|
||||
|
||||
val result = AUTH_REQUEST_RESPONSE_JSON.toAuthRequest(
|
||||
fingerprint = fingerprint,
|
||||
publicKey = "givenPublicKey",
|
||||
responseDate = responseDate,
|
||||
isRequestApproved = false,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
AuthRequest(
|
||||
id = "1",
|
||||
publicKey = "givenPublicKey",
|
||||
platform = "Android",
|
||||
ipAddress = "192.168.0.1",
|
||||
key = "key",
|
||||
masterPasswordHash = "verySecureHash",
|
||||
creationDate = Instant.parse("2024-09-13T00:00:00Z"),
|
||||
responseDate = responseDate,
|
||||
requestApproved = false,
|
||||
originUrl = "www.bitwarden.com",
|
||||
fingerprint = fingerprint,
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val AUTH_REQUEST_RESPONSE_JSON: AuthRequestsResponseJson.AuthRequest =
|
||||
AuthRequestsResponseJson.AuthRequest(
|
||||
id = "1",
|
||||
publicKey = "publicKey",
|
||||
platform = "Android",
|
||||
ipAddress = "192.168.0.1",
|
||||
key = "key",
|
||||
masterPasswordHash = "verySecureHash",
|
||||
creationDate = Instant.parse("2024-09-13T00:00:00Z"),
|
||||
responseDate = Instant.parse("2024-09-13T00:05:00Z"),
|
||||
requestApproved = true,
|
||||
originUrl = "www.bitwarden.com",
|
||||
)
|
||||
+3
-3
@@ -199,7 +199,9 @@ class ManageDevicesScreenTest : BitwardenComposeTest() {
|
||||
@Test
|
||||
fun `bottom sheet should not show when permission already granted`() {
|
||||
permissionsManager.checkPermissionResult = true
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(devicesLoaded = true)
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
viewState = ManageDevicesState.ViewState.Content(items = emptyList()),
|
||||
)
|
||||
composeTestRule.onNodeWithText("Skip for now").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@@ -242,6 +244,4 @@ private val DEFAULT_STATE = ManageDevicesState(
|
||||
isRefreshing = false,
|
||||
internalHideBottomSheet = false,
|
||||
isFdroid = false,
|
||||
devicesLoaded = false,
|
||||
authRequestsLoaded = false,
|
||||
)
|
||||
+133
-60
@@ -83,19 +83,16 @@ class ManageDevicesViewModelTest : BaseViewModelTest() {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `init should make necessary network calls`() {
|
||||
createViewModel()
|
||||
coVerify {
|
||||
authRepository.getAuthRequestsWithUpdates()
|
||||
authRepository.getDevices()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `init should set devicesLoaded true after device fetch success`() {
|
||||
fun `auth request update should trigger a device fetch`() {
|
||||
val viewModel = createViewModel()
|
||||
// After init with unconfined dispatcher, devices coroutine runs immediately
|
||||
assertEquals(true, viewModel.stateFlow.value.devicesLoaded)
|
||||
coVerify(exactly = 0) { authRepository.getDevices() }
|
||||
|
||||
mutableAuthRequestsWithUpdatesFlow.tryEmit(
|
||||
AuthRequestsUpdatesResult.Update(authRequests = emptyList()),
|
||||
)
|
||||
|
||||
coVerify(exactly = 1) { authRepository.getDevices() }
|
||||
assertEquals(EMPTY_CONTENT_STATE, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -116,42 +113,36 @@ class ManageDevicesViewModelTest : BaseViewModelTest() {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LifecycleResume should re-fetch auth requests only`() = runTest {
|
||||
fun `LifecycleResume should re-subscribe to auth request updates`() {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(ManageDevicesAction.LifecycleResume)
|
||||
|
||||
// getAuthRequestsWithUpdates called twice: once on init, once on resume
|
||||
verify(exactly = 2) { authRepository.getAuthRequestsWithUpdates() }
|
||||
coVerify(exactly = 1) { authRepository.getDevices() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RefreshPull when devices loaded should re-fetch auth requests only`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
skipItems(1)
|
||||
fun `RefreshPull should re-subscribe to auth request updates and clear isRefreshing`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
mutableAuthRequestsWithUpdatesFlow.tryEmit(
|
||||
AuthRequestsUpdatesResult.Update(authRequests = emptyList()),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(ManageDevicesAction.RefreshPull)
|
||||
assertEquals(
|
||||
EMPTY_CONTENT_STATE.copy(isRefreshing = true),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
mutableAuthRequestsWithUpdatesFlow.tryEmit(
|
||||
AuthRequestsUpdatesResult.Update(authRequests = emptyList()),
|
||||
)
|
||||
|
||||
coVerify(exactly = 1) { authRepository.getDevices() }
|
||||
verify(exactly = 2) { authRepository.getAuthRequestsWithUpdates() }
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RefreshPull when devices failed should re-fetch both devices and auth requests`() =
|
||||
runTest {
|
||||
coEvery { authRepository.getDevices() } returns GetDevicesResult.Error
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
skipItems(1)
|
||||
|
||||
viewModel.trySendAction(ManageDevicesAction.RefreshPull)
|
||||
|
||||
coVerify(exactly = 2) { authRepository.getDevices() }
|
||||
verify(exactly = 2) { authRepository.getAuthRequestsWithUpdates() }
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
coVerify(exactly = 2) { authRepository.getDevices() }
|
||||
assertEquals(EMPTY_CONTENT_STATE, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -171,32 +162,43 @@ class ManageDevicesViewModelTest : BaseViewModelTest() {
|
||||
fun `when getDevices returns error should show error state`() {
|
||||
coEvery { authRepository.getDevices() } returns GetDevicesResult.Error
|
||||
val viewModel = createViewModel()
|
||||
mutableAuthRequestsWithUpdatesFlow.tryEmit(
|
||||
AuthRequestsUpdatesResult.Update(authRequests = emptyList()),
|
||||
)
|
||||
assertEquals(
|
||||
ManageDevicesState(
|
||||
authRequests = persistentListOf(),
|
||||
devices = persistentListOf(),
|
||||
viewState = ManageDevicesState.ViewState.Error,
|
||||
isPullToRefreshSettingEnabled = false,
|
||||
isRefreshing = false,
|
||||
internalHideBottomSheet = false,
|
||||
isFdroid = false,
|
||||
devicesLoaded = false,
|
||||
authRequestsLoaded = false,
|
||||
),
|
||||
DEFAULT_STATE.copy(viewState = ManageDevicesState.ViewState.Error),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AuthRequestsResultReceive with error should use empty auth request list`() {
|
||||
fun `when getDevices returns error after content should show error state`() {
|
||||
val viewModel = createViewModel()
|
||||
mutableAuthRequestsWithUpdatesFlow.tryEmit(
|
||||
AuthRequestsUpdatesResult.Update(authRequests = emptyList()),
|
||||
)
|
||||
assertEquals(EMPTY_CONTENT_STATE, viewModel.stateFlow.value)
|
||||
|
||||
// A failed read means the rendered list is stale, so the error state is shown.
|
||||
coEvery { authRepository.getDevices() } returns GetDevicesResult.Error
|
||||
mutableAuthRequestsWithUpdatesFlow.tryEmit(
|
||||
AuthRequestsUpdatesResult.Update(authRequests = emptyList()),
|
||||
)
|
||||
|
||||
coVerify(exactly = 2) { authRepository.getDevices() }
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(viewState = ManageDevicesState.ViewState.Error),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AuthRequestsResultReceive with error should still render with an empty request list`() {
|
||||
val viewModel = createViewModel()
|
||||
mutableAuthRequestsWithUpdatesFlow.tryEmit(
|
||||
AuthRequestsUpdatesResult.Error(error = Throwable()),
|
||||
)
|
||||
assertEquals(
|
||||
emptyList<AuthRequest>(),
|
||||
viewModel.stateFlow.value.authRequests,
|
||||
)
|
||||
assertEquals(EMPTY_CONTENT_STATE, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -267,7 +269,7 @@ class ManageDevicesViewModelTest : BaseViewModelTest() {
|
||||
)
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(
|
||||
ManageDevicesState(
|
||||
DEFAULT_STATE.copy(
|
||||
authRequests = listOf(validAuthRequest).toImmutableList(),
|
||||
devices = listOf(
|
||||
otherDevice,
|
||||
@@ -311,18 +313,61 @@ class ManageDevicesViewModelTest : BaseViewModelTest() {
|
||||
),
|
||||
),
|
||||
),
|
||||
isPullToRefreshSettingEnabled = false,
|
||||
isRefreshing = false,
|
||||
internalHideBottomSheet = false,
|
||||
isFdroid = false,
|
||||
devicesLoaded = true,
|
||||
authRequestsLoaded = true,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `push-driven auth request update should add a pending row for its device`() = runTest {
|
||||
val pendingDevice = DEFAULT_DEVICE.copy(
|
||||
id = "device-pending",
|
||||
pendingAuthRequest = DevicePendingAuthRequest(
|
||||
id = PASSWORDLESS_AUTH_REQUEST.id,
|
||||
creationDate = fixedClock.instant(),
|
||||
),
|
||||
)
|
||||
val viewModel = createViewModel()
|
||||
mutableAuthRequestsWithUpdatesFlow.tryEmit(
|
||||
AuthRequestsUpdatesResult.Update(authRequests = emptyList()),
|
||||
)
|
||||
|
||||
// The device only reports its pending association once the request exists server-side.
|
||||
coEvery { authRepository.getDevices() } returns GetDevicesResult.Success(
|
||||
devices = listOf(pendingDevice),
|
||||
)
|
||||
// A push makes the manager re-read the list, which surfaces here as another update.
|
||||
mutableAuthRequestsWithUpdatesFlow.tryEmit(
|
||||
AuthRequestsUpdatesResult.Update(authRequests = listOf(PASSWORDLESS_AUTH_REQUEST)),
|
||||
)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
authRequests = listOf(PASSWORDLESS_AUTH_REQUEST).toImmutableList(),
|
||||
devices = listOf(pendingDevice).toImmutableList(),
|
||||
viewState = ManageDevicesState.ViewState.Content(
|
||||
items = listOf(
|
||||
ManageDevicesState.ViewState.Content.DeviceItem(
|
||||
id = pendingDevice.id,
|
||||
name = pendingDevice.name,
|
||||
typeName = pendingDevice.type.readableDeviceTypeName,
|
||||
isTrusted = pendingDevice.isTrusted,
|
||||
firstLoginDate = "Oct 27, 2023, 12:00:00 PM",
|
||||
lastActivityLabel = pendingDevice.lastActivityDate
|
||||
?.toLastActivityLabel(clock = fixedClock),
|
||||
status = DeviceSessionStatus.Pending,
|
||||
fingerprintPhrase = PASSWORDLESS_AUTH_REQUEST.fingerprint,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(state: ManageDevicesState? = null) = ManageDevicesViewModel(
|
||||
clock = fixedClock,
|
||||
authRepository = authRepository,
|
||||
@@ -333,6 +378,34 @@ class ManageDevicesViewModelTest : BaseViewModelTest() {
|
||||
)
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE = ManageDevicesState(
|
||||
authRequests = persistentListOf(),
|
||||
devices = persistentListOf(),
|
||||
viewState = ManageDevicesState.ViewState.Loading,
|
||||
isPullToRefreshSettingEnabled = false,
|
||||
isRefreshing = false,
|
||||
internalHideBottomSheet = false,
|
||||
isFdroid = false,
|
||||
)
|
||||
|
||||
private val EMPTY_CONTENT_STATE = DEFAULT_STATE.copy(
|
||||
viewState = ManageDevicesState.ViewState.Content(items = emptyList()),
|
||||
)
|
||||
|
||||
private val PASSWORDLESS_AUTH_REQUEST = AuthRequest(
|
||||
id = "auth-req-push",
|
||||
publicKey = "publicKey",
|
||||
platform = "Android",
|
||||
ipAddress = "192.168.0.1",
|
||||
key = null,
|
||||
masterPasswordHash = null,
|
||||
creationDate = Instant.parse("2023-10-27T12:00:00Z"),
|
||||
responseDate = null,
|
||||
requestApproved = false,
|
||||
originUrl = "www.bitwarden.com",
|
||||
fingerprint = "fingerprint-phrase",
|
||||
)
|
||||
|
||||
private val DEFAULT_DEVICE = DeviceInfo(
|
||||
id = "device-current",
|
||||
name = "Test Device",
|
||||
|
||||
+24
@@ -202,6 +202,30 @@ class PendingRequestsViewModelTest : BaseViewModelTest() {
|
||||
assertEquals(expected, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPendingResults failure after a successful load should show the error state`() {
|
||||
val viewModel = createViewModel(
|
||||
state = DEFAULT_STATE.copy(viewState = PendingRequestsState.ViewState.Loading),
|
||||
)
|
||||
mutableAuthRequestsWithUpdatesFlow.tryEmit(
|
||||
value = AuthRequestsUpdatesResult.Update(authRequests = emptyList()),
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(viewState = PendingRequestsState.ViewState.Empty),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
// A failed update means the rendered list is stale, so the error state is shown.
|
||||
mutableAuthRequestsWithUpdatesFlow.tryEmit(
|
||||
value = AuthRequestsUpdatesResult.Error(error = Throwable()),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(viewState = PendingRequestsState.ViewState.Error),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on CloseClick should emit NavigateBack`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
Reference in new issue
Block a user