PM-25125: Refactor user state managment into UserStateManager (#5774)

This commit is contained in:
David Perez 2025-08-25 13:45:43 -05:00 committed by GitHub
parent ff23dc3ab2
commit dc198eaf72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 689 additions and 483 deletions

View File

@ -0,0 +1,43 @@
package com.x8bit.bitwarden.data.auth.manager
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import kotlinx.coroutines.flow.StateFlow
/**
* Manages the global state of all users.
*/
interface UserStateManager {
/**
* Emits updates for changes to the [UserState].
*/
val userStateFlow: StateFlow<UserState?>
/**
* Tracks whether there is an additional account that is pending login/registration in order to
* have multiple accounts available.
*
* This allows a direct view into and modification of [UserState.hasPendingAccountAddition].
* Note that this call has no effect when there is no [UserState] information available.
*/
var hasPendingAccountAddition: Boolean
/**
* Emits updates for changes to the [UserState.hasPendingAccountAddition] flag.
*/
val hasPendingAccountAdditionStateFlow: StateFlow<Boolean>
/**
* Tracks whether there is an account that is pending deletion in order to allow the account to
* remain active until the deletion is finalized.
*/
var hasPendingAccountDeletion: Boolean
/**
* Run the given [block] while preventing any updates to [UserState]. This is useful in cases
* where many individual changes might occur that would normally affect the [UserState] but we
* only want a single final emission. In the rare case that multiple threads are running
* transactions simultaneously, there will be no [UserState] updates until the last
* transaction completes.
*/
suspend fun <T> userStateTransaction(block: suspend () -> T): T
}

View File

@ -0,0 +1,162 @@
package com.x8bit.bitwarden.data.auth.manager
import com.bitwarden.data.manager.DispatcherManager
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.repository.model.UserAccountTokens
import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.util.currentOnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.util.onboardingStatusChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokens
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokensFlow
import com.x8bit.bitwarden.data.auth.repository.util.userKeyConnectorStateFlow
import com.x8bit.bitwarden.data.auth.repository.util.userKeyConnectorStateList
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsList
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsListFlow
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
/**
* The default implementation of the [UserStateManager].
*/
class UserStateManagerImpl(
private val authDiskSource: AuthDiskSource,
firstTimeActionManager: FirstTimeActionManager,
vaultLockManager: VaultLockManager,
dispatcherManager: DispatcherManager,
) : UserStateManager {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
//region Pending Account Addition
private val mutableHasPendingAccountAdditionStateFlow = MutableStateFlow(value = false)
override val hasPendingAccountAdditionStateFlow: StateFlow<Boolean>
get() = mutableHasPendingAccountAdditionStateFlow
override var hasPendingAccountAddition: Boolean
by mutableHasPendingAccountAdditionStateFlow::value
//endregion Pending Account Addition
//region Pending Account Deletion
/**
* If there is a pending account deletion, continue showing the original UserState until it
* is confirmed. This is accomplished by blocking the emissions of the [userStateFlow]
* whenever set to `true`.
*/
private val mutableHasPendingAccountDeletionStateFlow = MutableStateFlow(value = false)
override var hasPendingAccountDeletion: Boolean
by mutableHasPendingAccountDeletionStateFlow::value
//endregion Pending Account Deletion
/**
* Whenever a function needs to update multiple underlying data-points that contribute to the
* [UserState], we update this [MutableStateFlow] and continue to show the original `UserState`
* until the transaction is complete. This is accomplished by blocking the emissions of the
* [userStateFlow] whenever this is set to a value above 0 (a count is used if more than one
* process is updating data simultaneously).
*/
private val mutableUserStateTransactionCountStateFlow = MutableStateFlow(0)
@Suppress("UNCHECKED_CAST", "MagicNumber")
override val userStateFlow: StateFlow<UserState?> = combine(
authDiskSource.userStateFlow,
authDiskSource.userAccountTokensFlow,
authDiskSource.userOrganizationsListFlow,
authDiskSource.userKeyConnectorStateFlow,
authDiskSource.onboardingStatusChangesFlow,
firstTimeActionManager.firstTimeStateFlow,
vaultLockManager.vaultUnlockDataStateFlow,
hasPendingAccountAdditionStateFlow,
// Ignore the data in the merge, but trigger an update when they emit.
merge(
mutableHasPendingAccountDeletionStateFlow,
mutableUserStateTransactionCountStateFlow,
vaultLockManager.isActiveUserUnlockingFlow,
),
) { array ->
val userStateJson = array[0] as UserStateJson?
val userAccountTokens = array[1] as List<UserAccountTokens>
val userOrganizationsList = array[2] as List<UserOrganizations>
val userIsUsingKeyConnectorList = array[3] as List<UserKeyConnectorState>
val onboardingStatus = array[4] as OnboardingStatus?
val firstTimeState = array[5] as FirstTimeState
val vaultState = array[6] as List<VaultUnlockData>
val hasPendingAccountAddition = array[7] as Boolean
userStateJson?.toUserState(
vaultState = vaultState,
userAccountTokens = userAccountTokens,
userOrganizationsList = userOrganizationsList,
userIsUsingKeyConnectorList = userIsUsingKeyConnectorList,
hasPendingAccountAddition = hasPendingAccountAddition,
onboardingStatus = onboardingStatus,
isBiometricsEnabledProvider = ::isBiometricsEnabled,
vaultUnlockTypeProvider = ::getVaultUnlockType,
isDeviceTrustedProvider = ::isDeviceTrusted,
firstTimeState = firstTimeState,
)
}
.filterNot {
mutableHasPendingAccountDeletionStateFlow.value ||
mutableUserStateTransactionCountStateFlow.value > 0 ||
vaultLockManager.isActiveUserUnlockingFlow.value
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = authDiskSource
.userState
?.toUserState(
vaultState = vaultLockManager.vaultUnlockDataStateFlow.value,
userAccountTokens = authDiskSource.userAccountTokens,
userOrganizationsList = authDiskSource.userOrganizationsList,
userIsUsingKeyConnectorList = authDiskSource.userKeyConnectorStateList,
hasPendingAccountAddition = mutableHasPendingAccountAdditionStateFlow.value,
onboardingStatus = authDiskSource.currentOnboardingStatus,
isBiometricsEnabledProvider = ::isBiometricsEnabled,
vaultUnlockTypeProvider = ::getVaultUnlockType,
isDeviceTrustedProvider = ::isDeviceTrusted,
firstTimeState = firstTimeActionManager.currentOrDefaultUserFirstTimeState,
),
)
override suspend fun <T> userStateTransaction(block: suspend () -> T): T {
mutableUserStateTransactionCountStateFlow.update { it.inc() }
return try {
block()
} finally {
mutableUserStateTransactionCountStateFlow.update { it.dec() }
}
}
private fun isBiometricsEnabled(
userId: String,
): Boolean = authDiskSource.getUserBiometricUnlockKey(userId = userId) != null
private fun isDeviceTrusted(
userId: String,
): Boolean = authDiskSource.getDeviceKey(userId = userId) != null
private fun getVaultUnlockType(
userId: String,
): VaultUnlockType = authDiskSource
.getPinProtectedUserKey(userId = userId)
?.let { VaultUnlockType.PIN }
?: VaultUnlockType.MASTER_PASSWORD
}

View File

@ -6,6 +6,7 @@ import com.bitwarden.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
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.DeleteAccountResult
@ -27,7 +28,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
@ -44,17 +44,12 @@ import kotlinx.coroutines.flow.StateFlow
* Provides an API for observing an modifying authentication state.
*/
@Suppress("TooManyFunctions")
interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
interface AuthRepository : AuthenticatorProvider, AuthRequestManager, UserStateManager {
/**
* Models the current auth state.
*/
val authStateFlow: StateFlow<AuthState>
/**
* Emits updates for changes to the [UserState].
*/
val userStateFlow: StateFlow<UserState?>
/**
* Flow of the current [DuoCallbackTokenResult]. Subscribers should listen to the flow
* in order to receive updates whenever [setDuoCallbackTokenResult] is called.
@ -110,15 +105,6 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
*/
var shouldTrustDevice: Boolean
/**
* Tracks whether there is an additional account that is pending login/registration in order to
* have multiple accounts available.
*
* This allows a direct view into and modification of [UserState.hasPendingAccountAddition].
* Note that this call has no effect when there is no [UserState] information available.
*/
var hasPendingAccountAddition: Boolean
/**
* Return the cached password policies for the current user.
*/
@ -140,11 +126,6 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
*/
val showWelcomeCarousel: Boolean
/**
* Clears the pending deletion state that occurs when the an account is successfully deleted.
*/
fun clearPendingAccountDeletion()
/**
* Attempt to delete the current account using the [masterPassword] and log them out
* upon success.

View File

@ -46,7 +46,6 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeviceDataModel
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toInt
@ -55,6 +54,7 @@ import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
import com.x8bit.bitwarden.data.auth.manager.model.MigrateExistingUserToKeyConnectorResult
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
@ -77,13 +77,8 @@ import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserAccountTokens
import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.toLoginErrorResult
@ -91,36 +86,25 @@ import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.currentOnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.util.onboardingStatusChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.auth.repository.util.toRemovedPasswordUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
import com.x8bit.bitwarden.data.auth.repository.util.toUserStateJsonWithPassword
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokens
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokensFlow
import com.x8bit.bitwarden.data.auth.repository.util.userKeyConnectorStateFlow
import com.x8bit.bitwarden.data.auth.repository.util.userKeyConnectorStateList
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsList
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsListFlow
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockError
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import kotlinx.coroutines.CoroutineScope
@ -128,13 +112,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
@ -144,7 +126,6 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import java.time.Clock
import javax.inject.Singleton
@ -172,12 +153,13 @@ class AuthRepositoryImpl(
private val trustedDeviceManager: TrustedDeviceManager,
private val userLogoutManager: UserLogoutManager,
private val policyManager: PolicyManager,
firstTimeActionManager: FirstTimeActionManager,
private val userStateManager: UserStateManager,
logsManager: LogsManager,
pushManager: PushManager,
dispatcherManager: DispatcherManager,
) : AuthRepository,
AuthRequestManager by authRequestManager {
AuthRequestManager by authRequestManager,
UserStateManager by userStateManager {
/**
* A scope intended for use when simply collecting multiple flows in order to combine them. The
* use of [Dispatchers.Unconfined] allows for this to happen synchronously whenever any of
@ -190,24 +172,6 @@ class AuthRepositoryImpl(
*/
private val ioScope = CoroutineScope(dispatcherManager.io)
private val mutableHasPendingAccountAdditionStateFlow = MutableStateFlow(false)
/**
* If there is a pending account deletion, continue showing the original UserState until it
* is confirmed. This is accomplished by blocking the emissions of the [userStateFlow]
* whenever set to `true`.
*/
private val mutableHasPendingAccountDeletionStateFlow = MutableStateFlow(false)
/**
* Whenever a function needs to update multiple underlying data-points that contribute to the
* [UserState], we update this [MutableStateFlow] and continue to show the original `UserState`
* until the transaction is complete. This is accomplished by blocking the emissions of the
* [userStateFlow] whenever this is set to a value above 0 (a count is used if more than one
* process is updating data simultaneously).
*/
private val mutableUserStateTransactionCountStateFlow = MutableStateFlow(0)
/**
* The auth information to make the identity token request will need to be
* cached to make the request again in the case of two-factor authentication.
@ -268,68 +232,6 @@ class AuthRepositoryImpl(
initialValue = AuthState.Uninitialized,
)
@Suppress("UNCHECKED_CAST", "MagicNumber")
override val userStateFlow: StateFlow<UserState?> = combine(
authDiskSource.userStateFlow,
authDiskSource.userAccountTokensFlow,
authDiskSource.userOrganizationsListFlow,
authDiskSource.userKeyConnectorStateFlow,
authDiskSource.onboardingStatusChangesFlow,
firstTimeActionManager.firstTimeStateFlow,
vaultRepository.vaultUnlockDataStateFlow,
mutableHasPendingAccountAdditionStateFlow,
// Ignore the data in the merge, but trigger an update when they emit.
merge(
mutableHasPendingAccountDeletionStateFlow,
mutableUserStateTransactionCountStateFlow,
vaultRepository.isActiveUserUnlockingFlow,
),
) { array ->
val userStateJson = array[0] as UserStateJson?
val userAccountTokens = array[1] as List<UserAccountTokens>
val userOrganizationsList = array[2] as List<UserOrganizations>
val userIsUsingKeyConnectorList = array[3] as List<UserKeyConnectorState>
val onboardingStatus = array[4] as OnboardingStatus?
val firstTimeState = array[5] as FirstTimeState
val vaultState = array[6] as List<VaultUnlockData>
val hasPendingAccountAddition = array[7] as Boolean
userStateJson?.toUserState(
vaultState = vaultState,
userAccountTokens = userAccountTokens,
userOrganizationsList = userOrganizationsList,
userIsUsingKeyConnectorList = userIsUsingKeyConnectorList,
hasPendingAccountAddition = hasPendingAccountAddition,
onboardingStatus = onboardingStatus,
isBiometricsEnabledProvider = ::isBiometricsEnabled,
vaultUnlockTypeProvider = ::getVaultUnlockType,
isDeviceTrustedProvider = ::isDeviceTrusted,
firstTimeState = firstTimeState,
)
}
.filterNot {
mutableHasPendingAccountDeletionStateFlow.value ||
mutableUserStateTransactionCountStateFlow.value > 0 ||
vaultRepository.isActiveUserUnlockingFlow.value
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = authDiskSource
.userState
?.toUserState(
vaultState = vaultRepository.vaultUnlockDataStateFlow.value,
userAccountTokens = authDiskSource.userAccountTokens,
userOrganizationsList = authDiskSource.userOrganizationsList,
userIsUsingKeyConnectorList = authDiskSource.userKeyConnectorStateList,
hasPendingAccountAddition = mutableHasPendingAccountAdditionStateFlow.value,
onboardingStatus = authDiskSource.currentOnboardingStatus,
isBiometricsEnabledProvider = ::isBiometricsEnabled,
vaultUnlockTypeProvider = ::getVaultUnlockType,
isDeviceTrustedProvider = ::isDeviceTrusted,
firstTimeState = firstTimeActionManager.currentOrDefaultUserFirstTimeState,
),
)
private val duoTokenChannel = Channel<DuoCallbackTokenResult>(capacity = Int.MAX_VALUE)
override val duoTokenResultFlow: Flow<DuoCallbackTokenResult> = duoTokenChannel.receiveAsFlow()
@ -358,9 +260,6 @@ class AuthRepositoryImpl(
}
}
override var hasPendingAccountAddition: Boolean
by mutableHasPendingAccountAdditionStateFlow::value
override val passwordPolicies: List<PolicyInformation.MasterPassword>
get() = policyManager.getActivePolicies()
@ -379,7 +278,7 @@ class AuthRepositoryImpl(
init {
combine(
mutableHasPendingAccountAdditionStateFlow,
userStateManager.hasPendingAccountAdditionStateFlow,
authDiskSource.userStateFlow,
environmentRepository.environmentStateFlow,
) { hasPendingAddition, userState, environment ->
@ -460,16 +359,12 @@ class AuthRepositoryImpl(
.launchIn(unconfinedScope)
}
override fun clearPendingAccountDeletion() {
mutableHasPendingAccountDeletionStateFlow.value = false
}
override suspend fun deleteAccountWithMasterPassword(
masterPassword: String,
): DeleteAccountResult {
val profile = authDiskSource.userState?.activeAccount?.profile
?: return DeleteAccountResult.Error(message = null, error = NoActiveUserException())
mutableHasPendingAccountDeletionStateFlow.value = true
userStateManager.hasPendingAccountDeletion = true
return authSdkSource
.hashPassword(
email = profile.email,
@ -489,7 +384,7 @@ class AuthRepositoryImpl(
override suspend fun deleteAccountWithOneTimePassword(
oneTimePassword: String,
): DeleteAccountResult {
mutableHasPendingAccountDeletionStateFlow.value = true
userStateManager.hasPendingAccountDeletion = true
return accountsService
.deleteAccount(
masterPasswordHash = null,
@ -501,13 +396,13 @@ class AuthRepositoryImpl(
private fun Result<DeleteAccountResponseJson>.finalizeDeleteAccount(): DeleteAccountResult =
fold(
onFailure = {
clearPendingAccountDeletion()
userStateManager.hasPendingAccountDeletion = false
DeleteAccountResult.Error(error = it, message = null)
},
onSuccess = { response ->
when (response) {
is DeleteAccountResponseJson.Invalid -> {
clearPendingAccountDeletion()
userStateManager.hasPendingAccountDeletion = false
DeleteAccountResult.Error(message = response.message, error = null)
}
@ -874,7 +769,7 @@ class AuthRepositoryImpl(
// We need to make sure that the environment is set back to the correct spot.
updateEnvironment()
// No switching to do but clear any pending account additions
hasPendingAccountAddition = false
userStateManager.hasPendingAccountAddition = false
return SwitchAccountResult.NoChange
}
@ -889,7 +784,7 @@ class AuthRepositoryImpl(
authDiskSource.userState = currentUserState.copy(activeUserId = userId)
// Clear any pending account additions
hasPendingAccountAddition = false
userStateManager.hasPendingAccountAddition = false
return SwitchAccountResult.AccountSwitched
}
@ -1552,27 +1447,6 @@ class AuthRepositoryImpl(
)
}
private fun isBiometricsEnabled(
userId: String,
): Boolean = authDiskSource.getUserBiometricUnlockKey(userId = userId) != null
private fun isDeviceTrusted(
userId: String,
): Boolean = authDiskSource.getDeviceKey(userId = userId) != null
private fun getVaultUnlockType(
userId: String,
): VaultUnlockType =
when {
authDiskSource.getPinProtectedUserKey(userId = userId) != null -> {
VaultUnlockType.PIN
}
else -> {
VaultUnlockType.MASTER_PASSWORD
}
}
/**
* Update the saved state with the force password reset reason.
*/
@ -1683,7 +1557,7 @@ class AuthRepositoryImpl(
deviceData: DeviceDataModel?,
orgIdentifier: String?,
userConfirmedKeyConnector: Boolean,
): LoginResult = userStateTransaction {
): LoginResult = userStateManager.userStateTransaction {
val userStateJson = loginResponse.toUserState(
previousUserState = authDiskSource.userState,
environmentUrlData = environmentRepository.environment.environmentUrlData,
@ -1722,7 +1596,7 @@ class AuthRepositoryImpl(
// we should ask him to confirm the domain
if (isNewKeyConnectorUser && isNotConfirmed) {
keyConnectorResponse = loginResponse
return LoginResult.ConfirmKeyConnectorDomain(
return@userStateTransaction LoginResult.ConfirmKeyConnectorDomain(
domain = keyConnectorUrl,
)
}
@ -2156,22 +2030,6 @@ class AuthRepositoryImpl(
}
//endregion LoginCommon
/**
* Run the given [block] while preventing any updates to [UserState]. This is useful in cases
* where many individual changes might occur that would normally affect the [UserState] but we
* only want a single final emission. In the rare case that multiple threads are running
* transactions simultaneously, there will be no [UserState] updates until the last
* transaction completes.
*/
private inline fun <T> userStateTransaction(block: () -> T): T {
mutableUserStateTransactionCountStateFlow.update { it.inc() }
return try {
block()
} finally {
mutableUserStateTransactionCountStateFlow.update { it.dec() }
}
}
}
/**

View File

@ -13,6 +13,8 @@ import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
import com.x8bit.bitwarden.data.auth.manager.UserStateManagerImpl
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
@ -22,6 +24,7 @@ import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.Module
import dagger.Provides
@ -60,8 +63,8 @@ object AuthRepositoryModule {
userLogoutManager: UserLogoutManager,
pushManager: PushManager,
policyManager: PolicyManager,
firstTimeActionManager: FirstTimeActionManager,
logsManager: LogsManager,
userStateManager: UserStateManager,
): AuthRepository = AuthRepositoryImpl(
clock = clock,
accountsService = accountsService,
@ -83,7 +86,21 @@ object AuthRepositoryModule {
userLogoutManager = userLogoutManager,
pushManager = pushManager,
policyManager = policyManager,
firstTimeActionManager = firstTimeActionManager,
logsManager = logsManager,
userStateManager = userStateManager,
)
@Provides
@Singleton
fun providesUserStateManager(
authDiskSource: AuthDiskSource,
firstTimeActionManager: FirstTimeActionManager,
vaultLockManager: VaultLockManager,
dispatcherManager: DispatcherManager,
): UserStateManager = UserStateManagerImpl(
authDiskSource = authDiskSource,
firstTimeActionManager = firstTimeActionManager,
vaultLockManager = vaultLockManager,
dispatcherManager = dispatcherManager,
)
}

View File

@ -109,7 +109,7 @@ class DeleteAccountViewModel @Inject constructor(
}
private fun handleAccountDeletionConfirm() {
authRepository.clearPendingAccountDeletion()
authRepository.hasPendingAccountDeletion = false
dismissDialog()
}

View File

@ -69,7 +69,7 @@ class DeleteAccountConfirmationViewModel @Inject constructor(
}
private fun handleDeleteAccountAcknowledge() {
authRepository.clearPendingAccountDeletion()
authRepository.hasPendingAccountDeletion = false
mutableStateFlow.update { it.copy(dialog = null) }
}

View File

@ -0,0 +1,342 @@
package com.x8bit.bitwarden.data.auth.manager
import app.cash.turbine.test
import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager
import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.network.model.GetTokenResponseJson
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.createMockOrganization
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
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.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertNull
import java.time.ZonedDateTime
class UserStateManagerTest {
private val fakeAuthDiskSource = FakeAuthDiskSource()
private val firstTimeActionManager = mockk<FirstTimeActionManager> {
every { currentOrDefaultUserFirstTimeState } returns FIRST_TIME_STATE
every { firstTimeStateFlow } returns MutableStateFlow(FIRST_TIME_STATE)
}
private val mutableVaultUnlockDataStateFlow = MutableStateFlow(VAULT_UNLOCK_DATA)
private val mutableIsActiveUserUnlockingFlow = MutableStateFlow(false)
private val vaultLockManager: VaultLockManager = mockk {
every { vaultUnlockDataStateFlow } returns mutableVaultUnlockDataStateFlow
every { isActiveUserUnlockingFlow } returns mutableIsActiveUserUnlockingFlow
}
private val dispatcherManager: DispatcherManager = FakeDispatcherManager()
private val userStateManager: UserStateManager = UserStateManagerImpl(
authDiskSource = fakeAuthDiskSource,
firstTimeActionManager = firstTimeActionManager,
vaultLockManager = vaultLockManager,
dispatcherManager = dispatcherManager,
)
@BeforeEach
fun setup() {
mockkStatic(GetTokenResponseJson.Success::toUserState)
}
@AfterEach
fun tearDown() {
unmockkStatic(GetTokenResponseJson.Success::toUserState)
}
@Test
fun `userStateFlow should update according to changes in its underlying data sources`() =
runTest {
fakeAuthDiskSource.userState = null
userStateManager.userStateFlow.test {
assertNull(awaitItem())
mutableVaultUnlockDataStateFlow.value = VAULT_UNLOCK_DATA
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
assertEquals(
SINGLE_USER_STATE_1.toUserState(
vaultState = VAULT_UNLOCK_DATA,
userAccountTokens = emptyList(),
userOrganizationsList = emptyList(),
userIsUsingKeyConnectorList = emptyList(),
hasPendingAccountAddition = false,
onboardingStatus = null,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isDeviceTrustedProvider = { false },
firstTimeState = FIRST_TIME_STATE,
),
awaitItem(),
)
fakeAuthDiskSource.apply {
storePinProtectedUserKey(
userId = USER_ID_1,
pinProtectedUserKey = "pinProtectedUseKey",
)
storePinProtectedUserKey(
userId = USER_ID_2,
pinProtectedUserKey = "pinProtectedUseKey",
)
userState = MULTI_USER_STATE
}
assertEquals(
MULTI_USER_STATE.toUserState(
vaultState = VAULT_UNLOCK_DATA,
userAccountTokens = emptyList(),
userOrganizationsList = emptyList(),
userIsUsingKeyConnectorList = emptyList(),
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.PIN },
isDeviceTrustedProvider = { false },
onboardingStatus = null,
firstTimeState = FIRST_TIME_STATE,
),
awaitItem(),
)
val emptyVaultState = emptyList<VaultUnlockData>()
mutableVaultUnlockDataStateFlow.value = emptyVaultState
assertEquals(
MULTI_USER_STATE.toUserState(
vaultState = emptyVaultState,
userAccountTokens = emptyList(),
userOrganizationsList = emptyList(),
userIsUsingKeyConnectorList = emptyList(),
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.PIN },
isDeviceTrustedProvider = { false },
onboardingStatus = null,
firstTimeState = FIRST_TIME_STATE,
),
awaitItem(),
)
fakeAuthDiskSource.apply {
storePinProtectedUserKey(
userId = USER_ID_1,
pinProtectedUserKey = null,
)
storePinProtectedUserKey(
userId = USER_ID_2,
pinProtectedUserKey = null,
)
storeOrganizations(
userId = USER_ID_1,
organizations = ORGANIZATIONS,
)
}
assertEquals(
MULTI_USER_STATE.toUserState(
vaultState = emptyVaultState,
userAccountTokens = emptyList(),
userOrganizationsList = USER_ORGANIZATIONS,
userIsUsingKeyConnectorList = USER_SHOULD_USER_KEY_CONNECTOR,
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isDeviceTrustedProvider = { false },
onboardingStatus = null,
firstTimeState = FIRST_TIME_STATE,
),
awaitItem(),
)
}
}
@Test
fun `clear Pending Account Deletion should unblock userState updates`() = runTest {
val originalUserState = SINGLE_USER_STATE_1.toUserState(
vaultState = VAULT_UNLOCK_DATA,
userAccountTokens = emptyList(),
userOrganizationsList = emptyList(),
userIsUsingKeyConnectorList = emptyList(),
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isDeviceTrustedProvider = { false },
onboardingStatus = null,
firstTimeState = FIRST_TIME_STATE,
)
val finalUserState = SINGLE_USER_STATE_2.toUserState(
vaultState = VAULT_UNLOCK_DATA,
userAccountTokens = emptyList(),
userOrganizationsList = emptyList(),
userIsUsingKeyConnectorList = emptyList(),
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isDeviceTrustedProvider = { false },
onboardingStatus = null,
firstTimeState = FIRST_TIME_STATE,
)
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
userStateManager.userStateFlow.test {
assertEquals(originalUserState, awaitItem())
// Set the pending deletion flag
userStateManager.hasPendingAccountDeletion = true
// Update the account. No changes are emitted because
// the pending deletion blocks the update.
fakeAuthDiskSource.userState = SINGLE_USER_STATE_2
expectNoEvents()
// Clearing the pending deletion allows the change to go through
userStateManager.hasPendingAccountDeletion = false
assertEquals(finalUserState, awaitItem())
}
}
@Test
fun `hasPendingAccountAdditionStateFlow updates when hasPendingAccountAddition changes`() =
runTest {
userStateManager.hasPendingAccountAdditionStateFlow.test {
assertFalse(awaitItem())
userStateManager.hasPendingAccountAddition = true
assertTrue(awaitItem())
userStateManager.hasPendingAccountAddition = false
assertFalse(awaitItem())
}
}
@Test
fun `hasPendingAccountAddition updates when hasPendingAccountAddition changes`() {
assertFalse(userStateManager.hasPendingAccountAddition)
userStateManager.hasPendingAccountAddition = true
assertTrue(userStateManager.hasPendingAccountAddition)
userStateManager.hasPendingAccountAddition = false
assertFalse(userStateManager.hasPendingAccountAddition)
}
@Test
fun `hasPendingAccountDeletion updates when hasPendingAccountDeletion changes`() {
assertFalse(userStateManager.hasPendingAccountDeletion)
userStateManager.hasPendingAccountDeletion = true
assertTrue(userStateManager.hasPendingAccountDeletion)
userStateManager.hasPendingAccountDeletion = false
assertFalse(userStateManager.hasPendingAccountDeletion)
}
}
private const val EMAIL_1 = "test@bitwarden.com"
private const val EMAIL_2 = "test2@bitwarden.com"
private const val USER_ID_1 = "2a135b23-e1fb-42c9-bec3-573857bc8181"
private const val USER_ID_2 = "b9d32ec0-6497-4582-9798-b350f53bfa02"
private val FIRST_TIME_STATE = FirstTimeState(
showImportLoginsCard = true,
)
private val ORGANIZATIONS = listOf(createMockOrganization(number = 0))
private val USER_ORGANIZATIONS = listOf(
UserOrganizations(
userId = USER_ID_1,
organizations = ORGANIZATIONS.toOrganizations(),
),
)
private val USER_SHOULD_USER_KEY_CONNECTOR = listOf(
UserKeyConnectorState(
userId = USER_ID_1,
isUsingKeyConnector = null,
),
)
private val VAULT_UNLOCK_DATA = listOf(
VaultUnlockData(
userId = USER_ID_1,
status = VaultUnlockData.Status.UNLOCKED,
),
)
private val PROFILE_1 = AccountJson.Profile(
userId = USER_ID_1,
email = EMAIL_1,
isEmailVerified = true,
name = "Bitwarden Tester",
hasPremium = false,
stamp = null,
organizationId = null,
avatarColorHex = null,
forcePasswordResetReason = null,
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = null,
isTwoFactorEnabled = false,
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
)
private val ACCOUNT_1 = AccountJson(
profile = PROFILE_1,
settings = AccountJson.Settings(
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
),
)
private val ACCOUNT_2 = AccountJson(
profile = AccountJson.Profile(
userId = USER_ID_2,
email = EMAIL_2,
isEmailVerified = true,
name = "Bitwarden Tester 2",
hasPremium = false,
stamp = null,
organizationId = null,
avatarColorHex = null,
forcePasswordResetReason = null,
kdfType = KdfTypeJson.PBKDF2_SHA256,
kdfIterations = 400000,
kdfMemory = null,
kdfParallelism = null,
userDecryptionOptions = null,
isTwoFactorEnabled = true,
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
),
settings = AccountJson.Settings(
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_EU,
),
)
private val SINGLE_USER_STATE_1 = UserStateJson(
activeUserId = USER_ID_1,
accounts = mapOf(
USER_ID_1 to ACCOUNT_1,
),
)
private val SINGLE_USER_STATE_2 = UserStateJson(
activeUserId = USER_ID_2,
accounts = mapOf(
USER_ID_2 to ACCOUNT_2,
),
)
private val MULTI_USER_STATE = UserStateJson(
activeUserId = USER_ID_1,
accounts = mapOf(
USER_ID_1 to ACCOUNT_1,
USER_ID_2 to ACCOUNT_2,
),
)

View File

@ -76,6 +76,7 @@ import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest
import com.x8bit.bitwarden.data.auth.manager.model.MigrateExistingUserToKeyConnectorResult
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
@ -98,17 +99,13 @@ import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations
import com.x8bit.bitwarden.data.auth.repository.util.toRemovedPasswordUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
@ -116,11 +113,9 @@ import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.platform.manager.model.NotificationLogoutData
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
@ -136,6 +131,7 @@ import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.slot
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
@ -253,14 +249,21 @@ class AuthRepositoryTest {
getActivePoliciesFlow(type = PolicyTypeJson.MASTER_PASSWORD)
} returns mutableActivePolicyFlow
}
private val firstTimeActionManager = mockk<FirstTimeActionManager> {
every { currentOrDefaultUserFirstTimeState } returns FIRST_TIME_STATE
every { firstTimeStateFlow } returns MutableStateFlow(FIRST_TIME_STATE)
}
private val logsManager: LogsManager = mockk {
every { setUserData(userId = any(), environmentType = any()) } just runs
}
private val mutableHasPendingAccountAdditionStateFlow = MutableStateFlow(false)
private val userStateManager: UserStateManager = mockk {
every {
hasPendingAccountAdditionStateFlow
} returns mutableHasPendingAccountAdditionStateFlow
every { hasPendingAccountAddition = any() } just runs
every { hasPendingAccountAddition } returns mutableHasPendingAccountAdditionStateFlow.value
every { hasPendingAccountDeletion = any() } just runs
val blockSlot = slot<suspend () -> LoginResult>()
coEvery { userStateTransaction(capture(blockSlot)) } coAnswers { blockSlot.captured() }
}
private val repository: AuthRepository = AuthRepositoryImpl(
clock = FIXED_CLOCK,
accountsService = accountsService,
@ -282,8 +285,8 @@ class AuthRepositoryTest {
dispatcherManager = dispatcherManager,
pushManager = pushManager,
policyManager = policyManager,
firstTimeActionManager = firstTimeActionManager,
logsManager = logsManager,
userStateManager = userStateManager,
)
@BeforeEach
@ -362,108 +365,6 @@ class AuthRepositoryTest {
}
}
@Test
fun `userStateFlow should update according to changes in its underlying data sources`() {
fakeAuthDiskSource.userState = null
assertEquals(
null,
repository.userStateFlow.value,
)
mutableVaultUnlockDataStateFlow.value = VAULT_UNLOCK_DATA
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
assertEquals(
SINGLE_USER_STATE_1.toUserState(
vaultState = VAULT_UNLOCK_DATA,
userAccountTokens = emptyList(),
userOrganizationsList = emptyList(),
userIsUsingKeyConnectorList = emptyList(),
hasPendingAccountAddition = false,
onboardingStatus = null,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isDeviceTrustedProvider = { false },
firstTimeState = FIRST_TIME_STATE,
),
repository.userStateFlow.value,
)
fakeAuthDiskSource.apply {
storePinProtectedUserKey(
userId = USER_ID_1,
pinProtectedUserKey = "pinProtectedUseKey",
)
storePinProtectedUserKey(
userId = USER_ID_2,
pinProtectedUserKey = "pinProtectedUseKey",
)
userState = MULTI_USER_STATE
}
assertEquals(
MULTI_USER_STATE.toUserState(
vaultState = VAULT_UNLOCK_DATA,
userAccountTokens = emptyList(),
userOrganizationsList = emptyList(),
userIsUsingKeyConnectorList = emptyList(),
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.PIN },
isDeviceTrustedProvider = { false },
onboardingStatus = null,
firstTimeState = FIRST_TIME_STATE,
),
repository.userStateFlow.value,
)
val emptyVaultState = emptyList<VaultUnlockData>()
mutableVaultUnlockDataStateFlow.value = emptyVaultState
assertEquals(
MULTI_USER_STATE.toUserState(
vaultState = emptyVaultState,
userAccountTokens = emptyList(),
userOrganizationsList = emptyList(),
userIsUsingKeyConnectorList = emptyList(),
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.PIN },
isDeviceTrustedProvider = { false },
onboardingStatus = null,
firstTimeState = FIRST_TIME_STATE,
),
repository.userStateFlow.value,
)
fakeAuthDiskSource.apply {
storePinProtectedUserKey(
userId = USER_ID_1,
pinProtectedUserKey = null,
)
storePinProtectedUserKey(
userId = USER_ID_2,
pinProtectedUserKey = null,
)
storeOrganizations(
userId = USER_ID_1,
organizations = ORGANIZATIONS,
)
}
assertEquals(
MULTI_USER_STATE.toUserState(
vaultState = emptyVaultState,
userAccountTokens = emptyList(),
userOrganizationsList = USER_ORGANIZATIONS,
userIsUsingKeyConnectorList = USER_SHOULD_USER_KEY_CONNECTOR,
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isDeviceTrustedProvider = { false },
onboardingStatus = null,
firstTimeState = FIRST_TIME_STATE,
),
repository.userStateFlow.value,
)
}
@Test
@OptIn(ExperimentalSerializationApi::class)
@Suppress("MaxLineLength")
@ -593,7 +494,10 @@ class AuthRepositoryTest {
),
fakeAuthDiskSource.userState,
)
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
}
@Test
@ -688,63 +592,6 @@ class AuthRepositoryTest {
assertEquals(ORGANIZATIONS, repository.organizations)
}
@Test
fun `clear Pending Account Deletion should unblock userState updates`() = runTest {
val masterPassword = "hello world"
val hashedMasterPassword = "dlrow olleh"
val originalUserState = SINGLE_USER_STATE_1.toUserState(
vaultState = VAULT_UNLOCK_DATA,
userAccountTokens = emptyList(),
userOrganizationsList = emptyList(),
userIsUsingKeyConnectorList = emptyList(),
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isDeviceTrustedProvider = { false },
onboardingStatus = null,
firstTimeState = FIRST_TIME_STATE,
)
val finalUserState = SINGLE_USER_STATE_2.toUserState(
vaultState = VAULT_UNLOCK_DATA,
userAccountTokens = emptyList(),
userOrganizationsList = emptyList(),
userIsUsingKeyConnectorList = emptyList(),
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isDeviceTrustedProvider = { false },
onboardingStatus = null,
firstTimeState = FIRST_TIME_STATE,
)
val kdf = SINGLE_USER_STATE_1.activeAccount.profile.toSdkParams()
coEvery {
authSdkSource.hashPassword(EMAIL, masterPassword, kdf, HashPurpose.SERVER_AUTHORIZATION)
} returns hashedMasterPassword.asSuccess()
coEvery {
accountsService.deleteAccount(
masterPasswordHash = hashedMasterPassword,
oneTimePassword = null,
)
} returns DeleteAccountResponseJson.Success.asSuccess()
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
repository.userStateFlow.test {
assertEquals(originalUserState, awaitItem())
// Deleting the account sets the pending deletion flag
repository.deleteAccountWithMasterPassword(masterPassword = masterPassword)
// Update the account. No changes are emitted because
// the pending deletion blocks the update.
fakeAuthDiskSource.userState = SINGLE_USER_STATE_2
expectNoEvents()
// Clearing the pending deletion allows the change to go through
repository.clearPendingAccountDeletion()
assertEquals(finalUserState, awaitItem())
}
}
@Test
fun `delete account fails if not logged in`() = runTest {
val masterPassword = "hello world"
@ -768,6 +615,9 @@ class AuthRepositoryTest {
val result = repository.deleteAccountWithMasterPassword(masterPassword = masterPassword)
assertEquals(DeleteAccountResult.Error(message = null, error = error), result)
verify(exactly = 1) {
userStateManager.hasPendingAccountDeletion = true
}
coVerify {
authSdkSource.hashPassword(EMAIL, masterPassword, kdf, HashPurpose.SERVER_AUTHORIZATION)
}
@ -793,6 +643,9 @@ class AuthRepositoryTest {
val result = repository.deleteAccountWithMasterPassword(masterPassword = masterPassword)
assertEquals(DeleteAccountResult.Error(message = null, error = error), result)
verify(exactly = 1) {
userStateManager.hasPendingAccountDeletion = true
}
coVerify {
authSdkSource.hashPassword(EMAIL, masterPassword, kdf, HashPurpose.SERVER_AUTHORIZATION)
accountsService.deleteAccount(
@ -824,6 +677,9 @@ class AuthRepositoryTest {
val result = repository.deleteAccountWithMasterPassword(masterPassword = masterPassword)
assertEquals(DeleteAccountResult.Error(message = "Fail", error = null), result)
verify(exactly = 1) {
userStateManager.hasPendingAccountDeletion = true
}
coVerify {
authSdkSource.hashPassword(EMAIL, masterPassword, kdf, HashPurpose.SERVER_AUTHORIZATION)
accountsService.deleteAccount(
@ -852,6 +708,9 @@ class AuthRepositoryTest {
val result = repository.deleteAccountWithMasterPassword(masterPassword = masterPassword)
assertEquals(DeleteAccountResult.Success, result)
verify(exactly = 1) {
userStateManager.hasPendingAccountDeletion = true
}
coVerify {
authSdkSource.hashPassword(EMAIL, masterPassword, kdf, HashPurpose.SERVER_AUTHORIZATION)
accountsService.deleteAccount(
@ -877,6 +736,9 @@ class AuthRepositoryTest {
)
assertEquals(DeleteAccountResult.Success, result)
verify(exactly = 1) {
userStateManager.hasPendingAccountDeletion = true
}
coVerify {
accountsService.deleteAccount(
masterPasswordHash = null,
@ -2008,7 +1870,10 @@ class AuthRepositoryTest {
SINGLE_USER_STATE_1,
fakeAuthDiskSource.userState,
)
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
}
@Test
@ -2188,7 +2053,10 @@ class AuthRepositoryTest {
SINGLE_USER_STATE_1,
fakeAuthDiskSource.userState,
)
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
coVerify(exactly = 0) {
vaultRepository.unlockVault(
userId = USER_ID_1,
@ -2314,7 +2182,11 @@ class AuthRepositoryTest {
fakeAuthDiskSource.userState,
)
assertFalse(repository.hasPendingAccountAddition)
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition
userStateManager.hasPendingAccountAddition = true
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
}
@Test
@ -2450,6 +2322,9 @@ class AuthRepositoryTest {
email = EMAIL,
twoFactorToken = "twoFactorTokenToStore",
)
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
}
}
@Test
@ -2657,7 +2532,8 @@ class AuthRepositoryTest {
SINGLE_USER_STATE_1,
fakeAuthDiskSource.userState,
)
verify {
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
settingsRepository.storeUserHasLoggedInValue(userId = USER_ID_1)
}
@ -2912,6 +2788,9 @@ class AuthRepositoryTest {
SINGLE_USER_STATE_1,
fakeAuthDiskSource.userState,
)
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
}
}
@Test
@ -3021,7 +2900,10 @@ class AuthRepositoryTest {
SINGLE_USER_STATE_1,
fakeAuthDiskSource.userState,
)
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
}
@Test
@ -3176,6 +3058,9 @@ class AuthRepositoryTest {
email = EMAIL,
twoFactorToken = "twoFactorTokenToStore",
)
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
}
}
@Test
@ -3317,7 +3202,10 @@ class AuthRepositoryTest {
SINGLE_USER_STATE_1,
fakeAuthDiskSource.userState,
)
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
}
@Test
@ -3376,6 +3264,7 @@ class AuthRepositoryTest {
}
assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState)
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
}
@ -3544,6 +3433,7 @@ class AuthRepositoryTest {
}
assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState)
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
}
@ -3749,6 +3639,7 @@ class AuthRepositoryTest {
}
assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState)
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
}
@ -3896,6 +3787,7 @@ class AuthRepositoryTest {
}
assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState)
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
}
@ -4005,7 +3897,10 @@ class AuthRepositoryTest {
SINGLE_USER_STATE_1,
fakeAuthDiskSource.userState,
)
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
}
@Test
@ -4065,6 +3960,7 @@ class AuthRepositoryTest {
vaultRepository.syncIfNecessary()
}
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
}
@ -4175,6 +4071,7 @@ class AuthRepositoryTest {
vaultRepository.syncIfNecessary()
}
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
}
@ -4287,6 +4184,7 @@ class AuthRepositoryTest {
vaultRepository.syncIfNecessary()
}
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
}
@ -4359,7 +4257,11 @@ class AuthRepositoryTest {
fakeAuthDiskSource.userState,
)
assertFalse(repository.hasPendingAccountAddition)
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition
userStateManager.hasPendingAccountAddition = false
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
}
@Test
@ -4489,6 +4391,9 @@ class AuthRepositoryTest {
email = EMAIL,
twoFactorToken = "twoFactorTokenToStore",
)
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
}
}
@Test
@ -4557,7 +4462,10 @@ class AuthRepositoryTest {
SINGLE_USER_STATE_1,
fakeAuthDiskSource.userState,
)
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
}
@Test
@ -6008,38 +5916,19 @@ class AuthRepositoryTest {
val updatedUserId = USER_ID_2
fakeAuthDiskSource.userState = null
assertNull(repository.userStateFlow.value)
assertEquals(
SwitchAccountResult.NoChange,
repository.switchAccount(userId = updatedUserId),
)
assertNull(repository.userStateFlow.value)
}
@Suppress("MaxLineLength")
@Test
fun `switchAccount when the given userId is the same as the current activeUserId should reset any pending account additions`() {
val originalUserId = USER_ID_1
val originalUserState = SINGLE_USER_STATE_1.toUserState(
vaultState = VAULT_UNLOCK_DATA,
userAccountTokens = emptyList(),
userOrganizationsList = emptyList(),
userIsUsingKeyConnectorList = emptyList(),
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isDeviceTrustedProvider = { false },
onboardingStatus = null,
firstTimeState = FIRST_TIME_STATE,
)
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
fakeEnvironmentRepository.environment = Environment.Eu
assertEquals(
originalUserState,
repository.userStateFlow.value,
)
repository.hasPendingAccountAddition = true
assertEquals(
@ -6047,46 +5936,24 @@ class AuthRepositoryTest {
repository.switchAccount(userId = originalUserId),
)
assertEquals(
originalUserState,
repository.userStateFlow.value,
)
assertFalse(repository.hasPendingAccountAddition)
assertEquals(Environment.Us, fakeEnvironmentRepository.environment)
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
}
}
@Suppress("MaxLineLength")
@Test
fun `switchAccount when the given userId does not correspond to a saved account should do nothing`() {
val invalidId = "invalidId"
val originalUserState = SINGLE_USER_STATE_1.toUserState(
vaultState = VAULT_UNLOCK_DATA,
userAccountTokens = emptyList(),
userOrganizationsList = emptyList(),
userIsUsingKeyConnectorList = emptyList(),
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isDeviceTrustedProvider = { false },
onboardingStatus = null,
firstTimeState = FIRST_TIME_STATE,
)
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
fakeEnvironmentRepository.environment = Environment.Eu
assertEquals(
originalUserState,
repository.userStateFlow.value,
)
assertEquals(
SwitchAccountResult.NoChange,
repository.switchAccount(userId = invalidId),
)
assertEquals(
originalUserState,
repository.userStateFlow.value,
)
assertEquals(Environment.Us, fakeEnvironmentRepository.environment)
}
@ -6094,24 +5961,8 @@ class AuthRepositoryTest {
@Test
fun `switchAccount when the userId is valid should update the current UserState and reset any pending account additions`() {
val updatedUserId = USER_ID_2
val originalUserState = MULTI_USER_STATE.toUserState(
vaultState = VAULT_UNLOCK_DATA,
userAccountTokens = emptyList(),
userOrganizationsList = emptyList(),
userIsUsingKeyConnectorList = emptyList(),
hasPendingAccountAddition = false,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isDeviceTrustedProvider = { false },
onboardingStatus = null,
firstTimeState = FIRST_TIME_STATE,
)
fakeAuthDiskSource.userState = MULTI_USER_STATE
fakeEnvironmentRepository.environment = Environment.Eu
assertEquals(
originalUserState,
repository.userStateFlow.value,
)
repository.hasPendingAccountAddition = true
assertEquals(
@ -6119,12 +5970,10 @@ class AuthRepositoryTest {
repository.switchAccount(userId = updatedUserId),
)
assertEquals(
originalUserState.copy(activeUserId = updatedUserId),
repository.userStateFlow.value,
)
assertFalse(repository.hasPendingAccountAddition)
assertEquals(Environment.Eu, fakeEnvironmentRepository.environment)
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
}
}
@Test
@ -6888,6 +6737,9 @@ class AuthRepositoryTest {
)
// This should only be set after they complete a registration and not based on login.
assertNull(fakeAuthDiskSource.getOnboardingStatus(USER_ID_1))
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
}
}
@Test
@ -6956,7 +6808,10 @@ class AuthRepositoryTest {
userId = USER_ID_1,
passwordHash = PASSWORD_HASH,
)
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
assertNull(fakeAuthDiskSource.getOnboardingStatus(USER_ID_1))
}
@ -7014,42 +6869,6 @@ class AuthRepositoryTest {
)
}
@Suppress("MaxLineLength")
@Test
fun `isUserManagedByOrganization should return true if any org userIsClaimedByOrganization is true`() =
runTest {
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
fakeAuthDiskSource.storeUserKey(userId = USER_ID_1, userKey = ENCRYPTED_USER_KEY)
val organizations = listOf(
createMockOrganization(number = 0)
.copy(
userIsClaimedByOrganization = true,
),
createMockOrganization(number = 1),
)
fakeAuthDiskSource.storeOrganizations(userId = USER_ID_1, organizations = organizations)
assertEquals(
SINGLE_USER_STATE_1.toUserState(
vaultState = VAULT_UNLOCK_DATA,
userAccountTokens = emptyList(),
userOrganizationsList = listOf(
UserOrganizations(
userId = USER_ID_1,
organizations = organizations.toOrganizations(),
),
),
userIsUsingKeyConnectorList = emptyList(),
hasPendingAccountAddition = false,
onboardingStatus = null,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isDeviceTrustedProvider = { false },
firstTimeState = FIRST_TIME_STATE,
),
repository.userStateFlow.value,
)
}
companion object {
private val FIXED_CLOCK: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
@ -7259,18 +7078,6 @@ class AuthRepositoryTest {
accessToken = ACCESS_TOKEN_2,
refreshToken = "refreshToken",
)
private val USER_ORGANIZATIONS = listOf(
UserOrganizations(
userId = USER_ID_1,
organizations = ORGANIZATIONS.toOrganizations(),
),
)
private val USER_SHOULD_USER_KEY_CONNECTOR = listOf(
UserKeyConnectorState(
userId = USER_ID_1,
isUsingKeyConnector = null,
),
)
private val VAULT_UNLOCK_DATA = listOf(
VaultUnlockData(
userId = USER_ID_1,
@ -7278,10 +7085,6 @@ class AuthRepositoryTest {
),
)
private val FIRST_TIME_STATE = FirstTimeState(
showImportLoginsCard = true,
)
private val SERVER_CONFIG_DEFAULT = ServerConfig(
lastSync = 0L,
serverData = ConfigResponseJson(

View File

@ -186,7 +186,7 @@ class DeleteAccountViewModelTest : BaseViewModelTest() {
@Test
fun `AccountDeletionConfirm should clear dialog state and call clearPendingAccountDeletion`() =
runTest {
every { authRepo.clearPendingAccountDeletion() } just runs
every { authRepo.hasPendingAccountDeletion = false } just runs
val state = DEFAULT_STATE.copy(
dialog = DeleteAccountState.DeleteAccountDialog.DeleteSuccess,
)
@ -198,7 +198,7 @@ class DeleteAccountViewModelTest : BaseViewModelTest() {
viewModel.stateFlow.value,
)
verify {
authRepo.clearPendingAccountDeletion()
authRepo.hasPendingAccountDeletion = false
}
}

View File

@ -55,7 +55,7 @@ class DeleteAccountConfirmationViewModelTest : BaseViewModelTest() {
@Test
fun `DeleteAccountAcknowledge should clear dialog and call clearPendingAccountDeletion`() =
runTest {
every { authRepo.clearPendingAccountDeletion() } just runs
every { authRepo.hasPendingAccountDeletion = false } just runs
val state = DEFAULT_STATE.copy(
dialog =
DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.DeleteSuccess(),
@ -68,7 +68,7 @@ class DeleteAccountConfirmationViewModelTest : BaseViewModelTest() {
viewModel.stateFlow.value,
)
verify {
authRepo.clearPendingAccountDeletion()
authRepo.hasPendingAccountDeletion = false
}
}