BIT-1082: Implement vault unlock functionality (#263)

This commit is contained in:
Brian Yencho 2023-11-21 12:06:15 -06:00 committed by Álison Fernandes
parent 17cd6c3cb0
commit 95b38605ee
21 changed files with 865 additions and 154 deletions

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.repository
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
@ -17,6 +18,11 @@ interface AuthRepository {
*/
val authStateFlow: StateFlow<AuthState>
/**
* Emits updates for changes to the [UserState].
*/
val userStateFlow: StateFlow<UserState?>
/**
* Flow of the current [CaptchaCallbackTokenResult]. Subscribers should listen to the flow
* in order to receive updates whenever [setCaptchaCallbackTokenResult] is called.

View file

@ -16,6 +16,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
@ -33,6 +34,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Singleton
@ -75,6 +77,22 @@ class AuthRepositoryImpl constructor(
initialValue = AuthState.Uninitialized,
)
override val userStateFlow: StateFlow<UserState?> = combine(
authDiskSource.userStateFlow,
vaultRepository.vaultStateFlow,
) { userStateJson, vaultState ->
userStateJson?.toUserState(vaultState = vaultState)
}
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = authDiskSource
.userState
?.toUserState(
vaultState = vaultRepository.vaultStateFlow.value,
),
)
private val mutableCaptchaTokenFlow =
MutableSharedFlow<CaptchaCallbackTokenResult>(extraBufferCapacity = Int.MAX_VALUE)
override val captchaTokenResultFlow: Flow<CaptchaCallbackTokenResult> =
@ -132,6 +150,7 @@ class AuthRepositoryImpl constructor(
.environmentUrlData,
)
vaultRepository.unlockVault(
userId = userStateJson.activeUserId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
userKey = loginResponse.key,

View file

@ -0,0 +1,43 @@
package com.x8bit.bitwarden.data.auth.repository.model
import com.x8bit.bitwarden.data.auth.repository.model.UserState.Account
/**
* Represents the overall "user state" of the current active user as well as any users that may be
* switched to.
*
* @property activeUserId The ID of the current active user.
* @property accounts A mapping between user IDs and the [Account] information associated with
* that user.
*/
data class UserState(
val activeUserId: String,
val accounts: List<Account>,
) {
init {
require(accounts.any { it.userId == activeUserId })
}
/**
* The [Account] associated with the current [activeUserId].
*/
val activeAccount: Account
get() = accounts.first { it.userId == activeUserId }
/**
* Basic account information about a given user.
*
* @property userId The ID of the user.
* @property email The user's email address.
* @property name The user's name (if applicable).
* @property avatarColorHex Hex color value for a user's avatar in the "#AARRGGBB" format.
* @property isVaultUnlocked Whether or not the user's vault is currently unlocked.
*/
data class Account(
val userId: String,
val name: String?,
val email: String,
val avatarColorHex: String,
val isVaultUnlocked: Boolean,
)
}

View file

@ -1,7 +1,9 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.repository.model.VaultState
/**
* Updates the given [UserStateJson] with the data from the [syncResponse] to return a new
@ -31,3 +33,27 @@ fun UserStateJson.toUpdatedUserStateJson(
},
)
}
/**
* Converts the given [UserStateJson] to a [UserState] using the given [vaultState].
*/
fun UserStateJson.toUserState(
vaultState: VaultState,
): UserState =
UserState(
activeUserId = this.activeUserId,
accounts = this
.accounts
.values
.map { accountJson ->
val userId = accountJson.profile.userId
UserState.Account(
userId = accountJson.profile.userId,
name = accountJson.profile.name,
email = accountJson.profile.email,
// TODO Calculate default color (BIT-1191)
avatarColorHex = accountJson.profile.avatarColorHex ?: "#00aaaa",
isVaultUnlocked = userId in vaultState.unlockedVaultUserIds,
)
},
)

View file

@ -6,6 +6,7 @@ import com.bitwarden.core.Kdf
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.model.VaultState
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import kotlinx.coroutines.flow.StateFlow
@ -19,6 +20,11 @@ interface VaultRepository {
*/
val vaultDataStateFlow: StateFlow<DataState<VaultData>>
/**
* Flow that represents the current vault state.
*/
val vaultStateFlow: StateFlow<VaultState>
/**
* Flow that represents the current send data.
*/
@ -56,6 +62,7 @@ interface VaultRepository {
*/
@Suppress("LongParameterList")
suspend fun unlockVault(
userId: String,
masterPassword: String,
email: String,
kdf: Kdf,

View file

@ -17,6 +17,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.service.SyncService
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.model.VaultState
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipherList
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolderList
@ -39,6 +40,7 @@ import kotlinx.coroutines.launch
/**
* Default implementation of [VaultRepository].
*/
@Suppress("TooManyFunctions")
class VaultRepositoryImpl constructor(
private val syncService: SyncService,
private val vaultSdkSource: VaultSdkSource,
@ -55,12 +57,18 @@ class VaultRepositoryImpl constructor(
private val vaultDataMutableStateFlow =
MutableStateFlow<DataState<VaultData>>(DataState.Loading)
private val vaultMutableStateFlow =
MutableStateFlow(VaultState(unlockedVaultUserIds = emptySet()))
private val sendDataMutableStateFlow =
MutableStateFlow<DataState<SendData>>(DataState.Loading)
override val vaultDataStateFlow: StateFlow<DataState<VaultData>>
get() = vaultDataMutableStateFlow.asStateFlow()
override val vaultStateFlow: StateFlow<VaultState>
get() = vaultMutableStateFlow.asStateFlow()
override val sendDataStateFlow: StateFlow<DataState<SendData>>
get() = sendDataMutableStateFlow.asStateFlow()
@ -157,6 +165,7 @@ class VaultRepositoryImpl constructor(
val privateKey = authDiskSource.getPrivateKey(userId = userState.activeUserId)
?: return VaultUnlockResult.InvalidStateError
return unlockVault(
userId = userState.activeUserId,
masterPassword = masterPassword,
email = userState.activeAccount.profile.email,
kdf = userState.activeAccount.profile.toSdkParams(),
@ -173,6 +182,7 @@ class VaultRepositoryImpl constructor(
}
override suspend fun unlockVault(
userId: String,
masterPassword: String,
email: String,
kdf: Kdf,
@ -196,13 +206,31 @@ class VaultRepositoryImpl constructor(
)
.fold(
onFailure = { VaultUnlockResult.GenericError },
onSuccess = { it.toVaultUnlockResult() },
onSuccess = { initializeCryptoResult ->
initializeCryptoResult
.toVaultUnlockResult()
.also {
if (it is VaultUnlockResult.Success) {
setVaultToUnlocked(userId = userId)
}
}
},
),
)
}
.onCompletion { willSyncAfterUnlock = false }
.first()
// TODO: This is temporary. Eventually this needs to be based on the presence of various
// user keys but this will likely require SDK updates to support this (BIT-1190).
private fun setVaultToUnlocked(userId: String) {
vaultMutableStateFlow.update {
it.copy(
unlockedVaultUserIds = it.unlockedVaultUserIds + userId,
)
}
}
private fun storeUserKeyAndPrivateKey(
userKey: String?,
privateKey: String?,

View file

@ -0,0 +1,10 @@
package com.x8bit.bitwarden.data.vault.repository.model
/**
* General description of the vault across multiple users.
*
* @property unlockedVaultUserIds The user IDs for all users that currently have unlocked vaults.
*/
data class VaultState(
val unlockedVaultUserIds: Set<String>,
)

View file

@ -6,7 +6,7 @@ import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import com.x8bit.bitwarden.ui.platform.theme.TransitionProviders
private const val VAULT_UNLOCK: String = "vault_unlock"
const val VAULT_UNLOCK_ROUTE: String = "vault_unlock"
/**
* Navigate to the Vault Unlock screen.
@ -14,7 +14,7 @@ private const val VAULT_UNLOCK: String = "vault_unlock"
fun NavController.navigateToVaultUnlock(
navOptions: NavOptions? = null,
) {
navigate(VAULT_UNLOCK, navOptions)
navigate(VAULT_UNLOCK_ROUTE, navOptions)
}
/**
@ -22,7 +22,7 @@ fun NavController.navigateToVaultUnlock(
*/
fun NavGraphBuilder.vaultUnlockDestinations() {
composable(
route = VAULT_UNLOCK,
route = VAULT_UNLOCK_ROUTE,
enterTransition = TransitionProviders.Enter.slideUp,
exitTransition = TransitionProviders.Exit.slideDown,
popEnterTransition = TransitionProviders.Enter.slideUp,

View file

@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.Color
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
@ -14,6 +15,9 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.hexToColor
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.platform.util.labelOrBaseUrlHost
import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toActiveAccountSummary
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -30,18 +34,24 @@ private const val KEY_STATE = "state"
@HiltViewModel
class VaultUnlockViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val authRepository: AuthRepository,
private val vaultRepo: VaultRepository,
environmentRepo: EnvironmentRepository,
) : BaseViewModel<VaultUnlockState, VaultUnlockEvent, VaultUnlockAction>(
initialState = savedStateHandle[KEY_STATE] ?: VaultUnlockState(
accountSummaries = emptyList(),
avatarColorString = "0000FF",
initials = "BW",
email = "bit@bitwarden.com",
dialog = null,
environmentUrl = environmentRepo.environment.labelOrBaseUrlHost,
passwordInput = "",
),
initialState = savedStateHandle[KEY_STATE] ?: run {
val userState = requireNotNull(authRepository.userStateFlow.value)
val accountSummaries = userState.toAccountSummaries()
val activeAccountSummary = userState.toActiveAccountSummary()
VaultUnlockState(
accountSummaries = accountSummaries,
avatarColorString = activeAccountSummary.avatarColorHex,
initials = activeAccountSummary.initials,
email = activeAccountSummary.email,
dialog = null,
environmentUrl = environmentRepo.environment.labelOrBaseUrlHost,
passwordInput = "",
)
},
) {
init {
stateFlow

View file

@ -13,6 +13,9 @@ import androidx.navigation.navOptions
import com.x8bit.bitwarden.ui.auth.feature.auth.AUTH_GRAPH_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.auth.authGraph
import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuthGraph
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.VAULT_UNLOCK_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.navigateToVaultUnlock
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.vaultUnlockDestinations
import com.x8bit.bitwarden.ui.platform.feature.splash.SPLASH_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.splash.navigateToSplash
import com.x8bit.bitwarden.ui.platform.feature.splash.splashDestination
@ -47,12 +50,14 @@ fun RootNavScreen(
) {
splashDestination()
authGraph(navController)
vaultUnlockDestinations()
vaultUnlockedGraph(navController)
}
val targetRoute = when (state) {
RootNavState.Auth -> AUTH_GRAPH_ROUTE
RootNavState.Splash -> SPLASH_ROUTE
RootNavState.VaultLocked -> VAULT_UNLOCK_ROUTE
RootNavState.VaultUnlocked -> VAULT_UNLOCKED_GRAPH_ROUTE
}
val currentRoute = navController.currentDestination?.rootLevelRoute()
@ -77,6 +82,7 @@ fun RootNavScreen(
when (state) {
RootNavState.Auth -> navController.navigateToAuthGraph(rootNavOptions)
RootNavState.Splash -> navController.navigateToSplash(rootNavOptions)
RootNavState.VaultLocked -> navController.navigateToVaultUnlock(rootNavOptions)
RootNavState.VaultUnlocked -> navController.navigateToVaultUnlockedGraph(rootNavOptions)
}
}

View file

@ -1,10 +1,9 @@
package com.x8bit.bitwarden.ui.platform.feature.rootnav
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
@ -20,44 +19,33 @@ private const val KEY_NAV_DESTINATION = "nav_state"
*/
@HiltViewModel
class RootNavViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val savedStateHandle: SavedStateHandle,
authRepository: AuthRepository,
) : BaseViewModel<RootNavState, Unit, RootNavAction>(
initialState = RootNavState.Splash,
) {
private var savedRootNavState: RootNavState?
get() = savedStateHandle[KEY_NAV_DESTINATION]
set(value) {
savedStateHandle[KEY_NAV_DESTINATION] = value
}
init {
savedRootNavState?.let { savedState: RootNavState ->
mutableStateFlow.update { savedState }
}
// Every time the nav state changes, update saved state handle:
stateFlow
.onEach { savedRootNavState = it }
.launchIn(viewModelScope)
authRepository
.authStateFlow
.onEach { trySendAction(RootNavAction.AuthStateUpdated(it)) }
.userStateFlow
.onEach { sendAction(RootNavAction.Internal.UserStateUpdateReceive(it)) }
.launchIn(viewModelScope)
}
override fun handleAction(action: RootNavAction) {
when (action) {
is RootNavAction.AuthStateUpdated -> handleAuthStateUpdated(action)
is RootNavAction.Internal.UserStateUpdateReceive -> handleUserStateUpdateReceive(action)
}
}
private fun handleAuthStateUpdated(action: RootNavAction.AuthStateUpdated) {
when (action.newState) {
is AuthState.Authenticated -> mutableStateFlow.update { RootNavState.VaultUnlocked }
is AuthState.Unauthenticated -> mutableStateFlow.update { RootNavState.Auth }
is AuthState.Uninitialized -> mutableStateFlow.update { RootNavState.Splash }
private fun handleUserStateUpdateReceive(
action: RootNavAction.Internal.UserStateUpdateReceive,
) {
val userState = action.userState
val updatedRootNavState = when {
userState == null -> RootNavState.Auth
userState.activeAccount.isVaultUnlocked -> RootNavState.VaultUnlocked
else -> RootNavState.VaultLocked
}
mutableStateFlow.update { updatedRootNavState }
}
}
@ -77,6 +65,12 @@ sealed class RootNavState : Parcelable {
@Parcelize
data object Splash : RootNavState()
/**
* App should show vault locked nav graph.
*/
@Parcelize
data object VaultLocked : RootNavState()
/**
* App should show vault unlocked nav graph.
*/
@ -90,7 +84,13 @@ sealed class RootNavState : Parcelable {
sealed class RootNavAction {
/**
* Auth state in the repository layer changed.
* Internal ViewModel actions.
*/
data class AuthStateUpdated(val newState: AuthState) : RootNavAction()
sealed class Internal {
/**
* User state in the repository layer changed.
*/
data class UserStateUpdateReceive(val userState: UserState?) : RootNavAction()
}
}

View file

@ -3,9 +3,10 @@ package com.x8bit.bitwarden.ui.vault.feature.vault
import android.os.Parcelable
import androidx.annotation.DrawableRes
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
@ -16,9 +17,10 @@ import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.platform.base.util.hexToColor
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toActiveAccountSummary
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toViewState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -27,29 +29,29 @@ import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* Manages [VaultState], handles [VaultAction], and launches [VaultEvent] for the [VaultScreen].
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class VaultViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
authRepository: AuthRepository,
vaultRepository: VaultRepository,
) : BaseViewModel<VaultState, VaultEvent, VaultAction>(
initialState = savedStateHandle[KEY_STATE] ?: VaultState(
initials = activeAccountSummary.initials,
avatarColorString = activeAccountSummary.avatarColorHex,
accountSummaries = accountSummaries,
viewState = VaultState.ViewState.Loading,
),
initialState = run {
val userState = requireNotNull(authRepository.userStateFlow.value)
val accountSummaries = userState.toAccountSummaries()
val activeAccountSummary = userState.toActiveAccountSummary()
VaultState(
initials = activeAccountSummary.initials,
avatarColorString = activeAccountSummary.avatarColorHex,
accountSummaries = accountSummaries,
viewState = VaultState.ViewState.Loading,
)
},
) {
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
vaultRepository
.vaultDataStateFlow
.onEach { sendAction(VaultAction.Internal.VaultDataReceive(vaultData = it)) }
@ -66,6 +68,13 @@ class VaultViewModel @Inject constructor(
)
}
}
authRepository
.userStateFlow
.onEach {
sendAction(VaultAction.Internal.UserStateUpdateReceive(userState = it))
}
.launchIn(viewModelScope)
}
override fun handleAction(action: VaultAction) {
@ -81,6 +90,7 @@ class VaultViewModel @Inject constructor(
is VaultAction.SecureNoteGroupClick -> handleSecureNoteClick()
is VaultAction.TrashClick -> handleTrashClick()
is VaultAction.VaultItemClick -> handleVaultItemClick(action)
is VaultAction.Internal.UserStateUpdateReceive -> handleUserStateUpdateReceive(action)
is VaultAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
}
}
@ -144,6 +154,22 @@ class VaultViewModel @Inject constructor(
sendEvent(VaultEvent.NavigateToVaultItem(action.vaultItem.id))
}
private fun handleUserStateUpdateReceive(action: VaultAction.Internal.UserStateUpdateReceive) {
// Leave the current data alone if there is no UserState; we are in the process of logging
// out.
val userState = action.userState ?: return
mutableStateFlow.update {
val accountSummaries = userState.toAccountSummaries()
val activeAccountSummary = userState.toActiveAccountSummary()
it.copy(
initials = activeAccountSummary.initials,
avatarColorString = activeAccountSummary.avatarColorHex,
accountSummaries = accountSummaries,
)
}
}
private fun handleVaultDataReceive(action: VaultAction.Internal.VaultDataReceive) {
when (val vaultData = action.vaultData) {
is DataState.Error -> vaultErrorReceive(vaultData = vaultData)
@ -182,34 +208,6 @@ class VaultViewModel @Inject constructor(
//endregion VaultAction Handlers
}
// TODO: Get data from repository (BIT-205)
private val accountSummaries = persistentListOf(
AccountSummary(
userId = "lockedUserId",
name = "Locked User",
email = "locked@bitwarden.com",
avatarColorHex = "#00aaaa",
status = AccountSummary.Status.LOCKED,
),
AccountSummary(
userId = "activeUserId",
name = "Active User",
email = "active@bitwarden.com",
avatarColorHex = "#aa00aa",
status = AccountSummary.Status.ACTIVE,
),
AccountSummary(
userId = "unlockedUserId",
name = "Unlocked User",
email = "unlocked@bitwarden.com",
avatarColorHex = "#aaaa00",
status = AccountSummary.Status.UNLOCKED,
),
)
private val activeAccountSummary = accountSummaries
.first { it.status == AccountSummary.Status.ACTIVE }
/**
* Represents the overall state for the [VaultScreen].
*
@ -541,6 +539,13 @@ sealed class VaultAction {
*/
sealed class Internal : VaultAction() {
/**
* Indicates a change in user state has been received.
*/
data class UserStateUpdateReceive(
val userState: UserState?,
) : Internal()
/**
* Indicates a vault data was received.
*/

View file

@ -0,0 +1,43 @@
package com.x8bit.bitwarden.ui.vault.feature.vault.util
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
/**
* Converts the given [UserState] to a list of [AccountSummary].
*/
fun UserState.toAccountSummaries(): List<AccountSummary> =
accounts.map { account ->
account.toAccountSummary(
isActive = this.activeUserId == account.userId,
)
}
/**
* Converts the given [UserState] to an [AccountSummary] with a [AccountSummary.status] of
* [AccountSummary.Status.ACTIVE].
*/
fun UserState.toActiveAccountSummary(): AccountSummary =
this
.activeAccount
.toAccountSummary(isActive = true)
/**
* Converts the given [UserState.Account] to an [AccountSummary] with the correct
* [AccountSummary.Status]. The status will take into account whether or not the given account
* [isActive].
*/
fun UserState.Account.toAccountSummary(
isActive: Boolean,
): AccountSummary =
AccountSummary(
userId = this.userId,
name = this.name,
email = this.email,
avatarColorHex = this.avatarColorHex,
status = when {
isActive -> AccountSummary.Status.ACTIVE
this.isVaultUnlocked -> AccountSummary.Status.UNLOCKED
else -> AccountSummary.Status.LOCKED
},
)

View file

@ -37,6 +37,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentReposito
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultState
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import io.mockk.clearMocks
import io.mockk.coEvery
@ -47,6 +48,7 @@ import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
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
@ -62,7 +64,10 @@ class AuthRepositoryTest {
private val accountsService: AccountsService = mockk()
private val identityService: IdentityService = mockk()
private val haveIBeenPwnedService: HaveIBeenPwnedService = mockk()
private val vaultRepository: VaultRepository = mockk()
private val mutableVaultStateFlow = MutableStateFlow(VAULT_STATE)
private val vaultRepository: VaultRepository = mockk() {
every { vaultStateFlow } returns mutableVaultStateFlow
}
private val fakeAuthDiskSource = FakeAuthDiskSource()
private val fakeEnvironmentRepository =
FakeEnvironmentRepository()
@ -117,6 +122,41 @@ class AuthRepositoryTest {
unmockkStatic(GET_TOKEN_RESPONSE_EXTENSIONS_PATH)
}
@Test
fun `userStateFlow should update with changes to the UserStateJson and VaultState data`() {
fakeAuthDiskSource.userState = null
assertEquals(
null,
repository.userStateFlow.value,
)
mutableVaultStateFlow.value = VAULT_STATE
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
assertEquals(
SINGLE_USER_STATE_1.toUserState(
vaultState = VAULT_STATE,
),
repository.userStateFlow.value,
)
fakeAuthDiskSource.userState = MULTI_USER_STATE
assertEquals(
MULTI_USER_STATE.toUserState(
vaultState = VAULT_STATE,
),
repository.userStateFlow.value,
)
val emptyVaultState = VaultState(unlockedVaultUserIds = emptySet())
mutableVaultStateFlow.value = emptyVaultState
assertEquals(
MULTI_USER_STATE.toUserState(
vaultState = emptyVaultState,
),
repository.userStateFlow.value,
)
}
@Test
fun `rememberedEmailAddress should pull from and update AuthDiskSource`() {
// AuthDiskSource and the repository start with the same value.
@ -287,6 +327,7 @@ class AuthRepositoryTest {
.returns(Result.success(successResponse))
coEvery {
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key,
@ -321,6 +362,7 @@ class AuthRepositoryTest {
captchaToken = null,
)
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key,
@ -710,6 +752,7 @@ class AuthRepositoryTest {
} returns Result.success(successResponse)
coEvery {
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key,
@ -770,6 +813,7 @@ class AuthRepositoryTest {
} returns Result.success(successResponse)
coEvery {
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key,
@ -934,5 +978,8 @@ class AuthRepositoryTest {
USER_ID_2 to ACCOUNT_2,
),
)
private val VAULT_STATE = VaultState(
unlockedVaultUserIds = setOf(USER_ID_1),
)
}
}

View file

@ -3,6 +3,8 @@ package com.x8bit.bitwarden.data.auth.repository.util
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.network.model.KdfTypeJson
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.vault.repository.model.VaultState
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
@ -82,4 +84,81 @@ class UserStateJsonExtensionsTest {
),
)
}
@Test
fun `toUserState should return the correct UserState for an unlocked vault`() {
assertEquals(
UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "activeName",
email = "activeEmail",
avatarColorHex = "activeAvatarColorHex",
isVaultUnlocked = true,
),
),
),
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to AccountJson(
profile = mockk() {
every { userId } returns "activeUserId"
every { name } returns "activeName"
every { email } returns "activeEmail"
every { avatarColorHex } returns "activeAvatarColorHex"
},
tokens = mockk(),
settings = mockk(),
),
),
)
.toUserState(
vaultState = VaultState(
unlockedVaultUserIds = setOf("activeUserId"),
),
),
)
}
@Test
fun `toUserState return the correct UserState for a locked vault`() {
assertEquals(
UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "activeName",
email = "activeEmail",
avatarColorHex = "activeAvatarColorHex",
isVaultUnlocked = false,
),
),
),
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to AccountJson(
profile = mockk() {
every { userId } returns "activeUserId"
every { name } returns "activeName"
every { email } returns "activeEmail"
every { avatarColorHex } returns "activeAvatarColorHex"
},
tokens = mockk(),
settings = mockk(),
),
),
)
.toUserState(
vaultState = VaultState(
unlockedVaultUserIds = emptySet(),
),
),
)
}
}

View file

@ -27,6 +27,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkSend
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.model.VaultState
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import io.mockk.awaits
import io.mockk.coEvery
@ -498,6 +499,12 @@ class VaultRepositoryTest {
),
)
} returns Result.success(InitializeCryptoResult.Success)
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
),
vaultRepository.vaultStateFlow.value,
)
val result = vaultRepository.unlockVaultAndSyncForCurrentUser(
masterPassword = "mockPassword-1",
@ -507,6 +514,12 @@ class VaultRepositoryTest {
VaultUnlockResult.Success,
result,
)
assertEquals(
VaultState(
unlockedVaultUserIds = setOf("mockId-1"),
),
vaultRepository.vaultStateFlow.value,
)
coVerify { syncService.sync() }
}
@ -638,6 +651,12 @@ class VaultRepositoryTest {
),
)
} returns Result.failure(IllegalStateException())
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
),
vaultRepository.vaultStateFlow.value,
)
val result = vaultRepository.unlockVaultAndSyncForCurrentUser(
masterPassword = "mockPassword-1",
@ -647,6 +666,12 @@ class VaultRepositoryTest {
VaultUnlockResult.GenericError,
result,
)
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
),
vaultRepository.vaultStateFlow.value,
)
}
@Suppress("MaxLineLength")
@ -681,12 +706,24 @@ class VaultRepositoryTest {
),
)
} returns Result.success(InitializeCryptoResult.AuthenticationError)
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
),
vaultRepository.vaultStateFlow.value,
)
val result = vaultRepository.unlockVaultAndSyncForCurrentUser(masterPassword = "")
assertEquals(
VaultUnlockResult.AuthenticationError,
result,
)
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
),
vaultRepository.vaultStateFlow.value,
)
}
@Suppress("MaxLineLength")
@ -694,6 +731,12 @@ class VaultRepositoryTest {
fun `unlockVaultAndSyncForCurrentUser with missing user state should return InvalidStateError `() =
runTest {
fakeAuthDiskSource.userState = null
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
),
vaultRepository.vaultStateFlow.value,
)
val result = vaultRepository.unlockVaultAndSyncForCurrentUser(masterPassword = "")
@ -701,12 +744,25 @@ class VaultRepositoryTest {
VaultUnlockResult.InvalidStateError,
result,
)
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
),
vaultRepository.vaultStateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `unlockVaultAndSyncForCurrentUser with missing user key should return InvalidStateError `() =
runTest {
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
),
vaultRepository.vaultStateFlow.value,
)
val result = vaultRepository.unlockVaultAndSyncForCurrentUser(masterPassword = "")
fakeAuthDiskSource.storeUserKey(
userId = "mockId-1",
@ -721,12 +777,24 @@ class VaultRepositoryTest {
VaultUnlockResult.InvalidStateError,
result,
)
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
),
vaultRepository.vaultStateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `unlockVaultAndSyncForCurrentUser with missing private key should return InvalidStateError `() =
runTest {
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
),
vaultRepository.vaultStateFlow.value,
)
val result = vaultRepository.unlockVaultAndSyncForCurrentUser(masterPassword = "")
fakeAuthDiskSource.storeUserKey(
userId = "mockId-1",
@ -741,10 +809,17 @@ class VaultRepositoryTest {
VaultUnlockResult.InvalidStateError,
result,
)
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
),
vaultRepository.vaultStateFlow.value,
)
}
@Test
fun `unlockVault with initializeCrypto success should return Success`() = runTest {
val userId = "userId"
val kdf = MOCK_PROFILE.toSdkParams()
val email = MOCK_PROFILE.email
val masterPassword = "drowssap"
@ -763,7 +838,15 @@ class VaultRepositoryTest {
),
)
} returns InitializeCryptoResult.Success.asSuccess()
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
),
vaultRepository.vaultStateFlow.value,
)
val result = vaultRepository.unlockVault(
userId = userId,
masterPassword = masterPassword,
kdf = kdf,
email = email,
@ -771,7 +854,14 @@ class VaultRepositoryTest {
privateKey = privateKey,
organizationalKeys = organizationalKeys,
)
assertEquals(VaultUnlockResult.Success, result)
assertEquals(
VaultState(
unlockedVaultUserIds = setOf(userId),
),
vaultRepository.vaultStateFlow.value,
)
coVerify(exactly = 1) {
vaultSdkSource.initializeCrypto(
request = InitCryptoRequest(
@ -790,6 +880,7 @@ class VaultRepositoryTest {
@Test
fun `unlockVault with initializeCrypto authentication failure should return AuthenticationError`() =
runTest {
val userId = "userId"
val kdf = MOCK_PROFILE.toSdkParams()
val email = MOCK_PROFILE.email
val masterPassword = "drowssap"
@ -808,7 +899,15 @@ class VaultRepositoryTest {
),
)
} returns InitializeCryptoResult.AuthenticationError.asSuccess()
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
),
vaultRepository.vaultStateFlow.value,
)
val result = vaultRepository.unlockVault(
userId = userId,
masterPassword = masterPassword,
kdf = kdf,
email = email,
@ -816,7 +915,14 @@ class VaultRepositoryTest {
privateKey = privateKey,
organizationalKeys = organizationalKeys,
)
assertEquals(VaultUnlockResult.AuthenticationError, result)
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
),
vaultRepository.vaultStateFlow.value,
)
coVerify(exactly = 1) {
vaultSdkSource.initializeCrypto(
request = InitCryptoRequest(
@ -833,6 +939,7 @@ class VaultRepositoryTest {
@Test
fun `unlockVault with initializeCrypto failure should return GenericError`() = runTest {
val userId = "userId"
val kdf = MOCK_PROFILE.toSdkParams()
val email = MOCK_PROFILE.email
val masterPassword = "drowssap"
@ -851,7 +958,15 @@ class VaultRepositoryTest {
),
)
} returns Throwable("Fail").asFailure()
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
),
vaultRepository.vaultStateFlow.value,
)
val result = vaultRepository.unlockVault(
userId = userId,
masterPassword = masterPassword,
kdf = kdf,
email = email,
@ -859,7 +974,14 @@ class VaultRepositoryTest {
privateKey = privateKey,
organizationalKeys = organizationalKeys,
)
assertEquals(VaultUnlockResult.GenericError, result)
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
),
vaultRepository.vaultStateFlow.value,
)
coVerify(exactly = 1) {
vaultSdkSource.initializeCrypto(
request = InitCryptoRequest(
@ -876,6 +998,7 @@ class VaultRepositoryTest {
@Test
fun `unlockVault with initializeCrypto awaiting should block calls to sync`() = runTest {
val userId = "userId"
val kdf = MOCK_PROFILE.toSdkParams()
val email = MOCK_PROFILE.email
val masterPassword = "drowssap"
@ -898,6 +1021,7 @@ class VaultRepositoryTest {
val scope = CoroutineScope(Dispatchers.Unconfined)
scope.launch {
vaultRepository.unlockVault(
userId = userId,
masterPassword = masterPassword,
kdf = kdf,
email = email,

View file

@ -4,6 +4,8 @@ import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
@ -16,6 +18,7 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@ -23,6 +26,9 @@ import org.junit.jupiter.api.Test
class VaultUnlockViewModelTest : BaseViewModelTest() {
private val environmentRepository = FakeEnvironmentRepository()
private val authRepository = mockk<AuthRepository>() {
every { userStateFlow } returns MutableStateFlow(DEFAULT_USER_STATE)
}
private val vaultRepository = mockk<VaultRepository>()
@Test
@ -191,17 +197,39 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
vaultRepo: VaultRepository = vaultRepository,
): VaultUnlockViewModel = VaultUnlockViewModel(
savedStateHandle = SavedStateHandle().apply { set("state", state) },
authRepository = authRepository,
vaultRepo = vaultRepo,
environmentRepo = environmentRepo,
)
}
private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState(
accountSummaries = emptyList(),
avatarColorString = "0000FF",
email = "bit@bitwarden.com",
initials = "BW",
accountSummaries = listOf(
AccountSummary(
userId = "activeUserId",
name = "Active User",
email = "active@bitwarden.com",
avatarColorHex = "#aa00aa",
status = AccountSummary.Status.ACTIVE,
),
),
avatarColorString = "#aa00aa",
email = "active@bitwarden.com",
initials = "AU",
dialog = null,
environmentUrl = Environment.Us.label,
passwordInput = "",
)
private val DEFAULT_USER_STATE = UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "Active User",
email = "active@bitwarden.com",
avatarColorHex = "#aa00aa",
isVaultUnlocked = true,
),
),
)

View file

@ -72,6 +72,15 @@ class RootNavScreenTest : BaseComposeTest() {
}
assertTrue(isSplashScreenRemoved)
// Make sure navigating to vault locked works as expected:
rootNavStateFlow.value = RootNavState.VaultLocked
composeTestRule.runOnIdle {
fakeNavHostController.assertLastNavigation(
route = "vault_unlock",
navOptions = expectedNavOptions,
)
}
// Make sure navigating to vault unlocked works as expected:
rootNavStateFlow.value = RootNavState.VaultUnlocked
composeTestRule.runOnIdle {

View file

@ -1,64 +1,69 @@
package com.x8bit.bitwarden.ui.platform.feature.rootnav
import androidx.lifecycle.SavedStateHandle
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.AuthState.Authenticated
import com.x8bit.bitwarden.data.auth.repository.model.AuthState.Unauthenticated
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class RootNavViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should be the state in savedStateHandle`() {
val authRepository = mockk<AuthRepository> {
every { this@mockk.authStateFlow } returns MutableStateFlow(mockk<Authenticated>())
}
val handle = SavedStateHandle(mapOf(("nav_state" to RootNavState.VaultUnlocked)))
val viewModel = RootNavViewModel(
authRepository = authRepository,
savedStateHandle = handle,
)
assertEquals(RootNavState.VaultUnlocked, viewModel.stateFlow.value)
private val mutableUserStateFlow = MutableStateFlow<UserState?>(null)
private val authRepository = mockk<AuthRepository>() {
every { userStateFlow } returns mutableUserStateFlow
}
@Test
fun `when auth state is Uninitialized nav state should be Splash`() {
val viewModel = RootNavViewModel(
authRepository = mockk {
every { this@mockk.authStateFlow } returns MutableStateFlow(AuthState.Uninitialized)
},
savedStateHandle = SavedStateHandle(),
)
assertEquals(RootNavState.Splash, viewModel.stateFlow.value)
}
@Test
fun `when auth state is Authenticated nav state should be VaultUnlocked`() {
val authRepository = mockk<AuthRepository> {
every { this@mockk.authStateFlow } returns MutableStateFlow(mockk<Authenticated>())
}
val viewModel = RootNavViewModel(
authRepository = authRepository,
savedStateHandle = SavedStateHandle(),
)
assertEquals(RootNavState.VaultUnlocked, viewModel.stateFlow.value)
}
@Test
fun `when auth state is Unauthenticated nav state should be Auth`() = runTest {
val viewModel = RootNavViewModel(
authRepository = mockk {
every { this@mockk.authStateFlow } returns MutableStateFlow(Unauthenticated)
},
savedStateHandle = SavedStateHandle(),
)
fun `when there are no accounts the nav state should be Auth`() {
mutableUserStateFlow.tryEmit(null)
val viewModel = createViewModel()
assertEquals(RootNavState.Auth, viewModel.stateFlow.value)
}
@Test
fun `when the active user has an unlocked vault the nav state should be VaultUnlocked`() {
mutableUserStateFlow.tryEmit(
UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "name",
email = "email",
avatarColorHex = "avatarColorHex",
isVaultUnlocked = true,
),
),
),
)
val viewModel = createViewModel()
assertEquals(RootNavState.VaultUnlocked, viewModel.stateFlow.value)
}
@Test
fun `when the active user has a locked vault the nav state should be VaultLocked`() {
mutableUserStateFlow.tryEmit(
UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "name",
email = "email",
avatarColorHex = "avatarColorHex",
isVaultUnlocked = false,
),
),
),
)
val viewModel = createViewModel()
assertEquals(RootNavState.VaultLocked, viewModel.stateFlow.value)
}
private fun createViewModel(): RootNavViewModel =
RootNavViewModel(
authRepository = authRepository,
)
}

View file

@ -1,7 +1,8 @@
package com.x8bit.bitwarden.ui.vault.feature.vault
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView
@ -19,9 +20,17 @@ import org.junit.jupiter.api.Test
class VaultViewModelTest : BaseViewModelTest() {
private val mutableUserStateFlow =
MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
private val mutableVaultDataStateFlow =
MutableStateFlow<DataState<VaultData>>(DataState.Loading)
private val authRepository: AuthRepository =
mockk {
every { userStateFlow } returns mutableUserStateFlow
}
private val vaultRepository: VaultRepository =
mockk {
every { vaultDataStateFlow } returns mutableVaultDataStateFlow
@ -29,19 +38,54 @@ class VaultViewModelTest : BaseViewModelTest() {
}
@Test
fun `initial state should be correct when not set`() {
fun `initial state should be correct`() {
val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `initial state should be correct when set`() {
val state = DEFAULT_STATE.copy(
initials = "WB",
avatarColorString = "00FF00",
fun `UserState updates with a null value should do nothing`() {
val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
mutableUserStateFlow.value = null
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `UserState updates with a non-null value update the account information in the state`() {
val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "Other User",
email = "active@bitwarden.com",
avatarColorHex = "#00aaaa",
isVaultUnlocked = true,
),
),
)
assertEquals(
DEFAULT_STATE.copy(
avatarColorString = "#00aaaa",
initials = "OU",
accountSummaries = listOf(
AccountSummary(
userId = "activeUserId",
name = "Other User",
email = "active@bitwarden.com",
avatarColorHex = "#00aaaa",
status = AccountSummary.Status.ACTIVE,
),
),
),
viewModel.stateFlow.value,
)
val viewModel = createViewModel(state = state)
assertEquals(state, viewModel.stateFlow.value)
}
@Test
@ -294,23 +338,41 @@ class VaultViewModelTest : BaseViewModelTest() {
}
}
private fun createViewModel(
state: VaultState? = DEFAULT_STATE,
): VaultViewModel = VaultViewModel(
savedStateHandle = SavedStateHandle().apply { set("state", state) },
vaultRepository = vaultRepository,
)
private fun createViewModel(): VaultViewModel =
VaultViewModel(
authRepository = authRepository,
vaultRepository = vaultRepository,
)
}
private const val DEFAULT_COLOR_STRING: String = "FF0000FF"
private const val DEFAULE_INITIALS: String = "BW"
private val DEFAULT_STATE: VaultState =
createMockVaultState(viewState = VaultState.ViewState.Loading)
private val DEFAULT_USER_STATE = UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "Active User",
email = "active@bitwarden.com",
avatarColorHex = "#aa00aa",
isVaultUnlocked = true,
),
),
)
private fun createMockVaultState(viewState: VaultState.ViewState): VaultState =
VaultState(
avatarColorString = DEFAULT_COLOR_STRING,
initials = DEFAULE_INITIALS,
accountSummaries = emptyList(),
avatarColorString = "#aa00aa",
initials = "AU",
accountSummaries = listOf(
AccountSummary(
userId = "activeUserId",
name = "Active User",
email = "active@bitwarden.com",
avatarColorHex = "#aa00aa",
status = AccountSummary.Status.ACTIVE,
),
),
viewState = viewState,
)

View file

@ -0,0 +1,154 @@
package com.x8bit.bitwarden.ui.vault.feature.vault.util
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class UserStateExtensionsTest {
@Test
fun `toAccountSummaries should return the correct list`() {
assertEquals(
listOf(
AccountSummary(
userId = "activeUserId",
name = "activeName",
email = "activeEmail",
avatarColorHex = "activeAvatarColorHex",
status = AccountSummary.Status.ACTIVE,
),
AccountSummary(
userId = "lockedUserId",
name = "lockedName",
email = "lockedEmail",
avatarColorHex = "lockedAvatarColorHex",
status = AccountSummary.Status.LOCKED,
),
AccountSummary(
userId = "unlockedUserId",
name = "unlockedName",
email = "unlockedEmail",
avatarColorHex = "unlockedAvatarColorHex",
status = AccountSummary.Status.UNLOCKED,
),
),
UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "activeName",
email = "activeEmail",
avatarColorHex = "activeAvatarColorHex",
isVaultUnlocked = true,
),
UserState.Account(
userId = "lockedUserId",
name = "lockedName",
email = "lockedEmail",
avatarColorHex = "lockedAvatarColorHex",
isVaultUnlocked = false,
),
UserState.Account(
userId = "unlockedUserId",
name = "unlockedName",
email = "unlockedEmail",
avatarColorHex = "unlockedAvatarColorHex",
isVaultUnlocked = true,
),
),
)
.toAccountSummaries(),
)
}
@Test
fun `toAccountSummary for an active account should return an active AccountSummary`() {
assertEquals(
AccountSummary(
userId = "userId",
name = "name",
email = "email",
avatarColorHex = "avatarColorHex",
status = AccountSummary.Status.ACTIVE,
),
UserState.Account(
userId = "userId",
name = "name",
email = "email",
avatarColorHex = "avatarColorHex",
isVaultUnlocked = true,
)
.toAccountSummary(isActive = true),
)
}
@Test
fun `toAccountSummary for an locked account should return a locked AccountSummary`() {
assertEquals(
AccountSummary(
userId = "userId",
name = "name",
email = "email",
avatarColorHex = "avatarColorHex",
status = AccountSummary.Status.LOCKED,
),
UserState.Account(
userId = "userId",
name = "name",
email = "email",
avatarColorHex = "avatarColorHex",
isVaultUnlocked = false,
)
.toAccountSummary(isActive = false),
)
}
@Test
fun `toAccountSummary for a unlocked account should return a locked AccountSummary`() {
assertEquals(
AccountSummary(
userId = "userId",
name = "name",
email = "email",
avatarColorHex = "avatarColorHex",
status = AccountSummary.Status.UNLOCKED,
),
UserState.Account(
userId = "userId",
name = "name",
email = "email",
avatarColorHex = "avatarColorHex",
isVaultUnlocked = true,
)
.toAccountSummary(isActive = false),
)
}
@Suppress("MaxLineLength")
@Test
fun `toActiveAccountSummary should return an active AccountSummary`() {
assertEquals(
AccountSummary(
userId = "activeUserId",
name = "name",
email = "email",
avatarColorHex = "avatarColorHex",
status = AccountSummary.Status.ACTIVE,
),
UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "name",
email = "email",
avatarColorHex = "avatarColorHex",
isVaultUnlocked = true,
),
),
)
.toActiveAccountSummary(),
)
}
}