Updated RootNavViewModel and added tests

This commit is contained in:
Andre Rosado committed 2025-12-18 11:23:25 +00:00
1 parent 65a63aae45
commit 2734ac4b3d
4 files changed
+413 -42

No files matched your search

@@ -2,7 +2,6 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav
import android.os.Parcelable
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.util.parseJwtTokenDataOrNull
import com.bitwarden.ui.platform.base.BaseViewModel
@@ -18,11 +17,8 @@ import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest
import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
import com.x8bit.bitwarden.data.credentials.model.ProviderGetPasswordCredentialRequest
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -40,10 +36,7 @@ import javax.inject.Inject
class RootNavViewModel @Inject constructor(
private val authRepository: AuthRepository,
specialCircumstanceManager: SpecialCircumstanceManager,
private val policyManager: PolicyManager,
private val connectionManager: NetworkConnectionManager,
private val featureFlagManager: FeatureFlagManager,
private val vaultRepository: VaultRepository,
vaultRepository: VaultRepository,
) : BaseViewModel<RootNavState, Unit, RootNavAction>(
initialState = RootNavState.Splash,
) {
@@ -123,28 +116,27 @@ class RootNavViewModel @Inject constructor(
}
userState.activeAccount.isVaultUnlocked &&
action.shouldMigratePersonalVault &&
featureFlagManager.getFeatureFlag(FlagKey.MigrateMyVaultToMyItems) &&
connectionManager.isNetworkConnected &&
specialCircumstance == null -> {
specialCircumstance is SpecialCircumstance.AutofillSave -> {
RootNavState.VaultUnlockedForAutofillSave(
autofillSaveItem = specialCircumstance.autofillSaveItem,
)
}
userState.activeAccount.isVaultUnlocked &&
specialCircumstance is SpecialCircumstance.AutofillSelection -> {
RootNavState.VaultUnlockedForAutofillSelection(
activeUserId = userState.activeAccount.userId,
type = specialCircumstance.autofillSelectionData.type,
)
}
userState.activeAccount.isVaultUnlocked &&
action.shouldMigratePersonalVault -> {
RootNavState.MigrateToMyItems
}
userState.activeAccount.isVaultUnlocked -> {
when (specialCircumstance) {
is SpecialCircumstance.AutofillSave -> {
RootNavState.VaultUnlockedForAutofillSave(
autofillSaveItem = specialCircumstance.autofillSaveItem,
)
}
is SpecialCircumstance.AutofillSelection -> {
RootNavState.VaultUnlockedForAutofillSelection(
activeUserId = userState.activeAccount.userId,
type = specialCircumstance.autofillSelectionData.type,
)
}
is SpecialCircumstance.AddTotpLoginItem -> {
RootNavState.VaultUnlockedForNewTotp(
activeUserId = userState.activeAccount.userId,
@@ -227,6 +219,8 @@ class RootNavViewModel @Inject constructor(
is SpecialCircumstance.CredentialExchangeExport,
is SpecialCircumstance.RegistrationEvent,
is SpecialCircumstance.AutofillSave,
is SpecialCircumstance.AutofillSelection,
-> {
throw IllegalStateException(
"Special circumstance should have been already handled.",
@@ -5,11 +5,13 @@ import app.cash.turbine.turbineScope
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.error.MissingPropertyException
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.createMockCipher
import com.bitwarden.network.model.createMockCollection
@@ -35,7 +37,10 @@ import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
@@ -140,6 +145,15 @@ class VaultSyncManagerTest {
private val databaseSchemeManager: DatabaseSchemeManager = mockk {
every { databaseSchemeChangeFlow } returns mutableDatabaseSchemeChangeFlow
}
private val policyManager: PolicyManager = mockk<PolicyManager> {
every { getActivePolicies(any()) } returns emptyList()
}
private val mockFeatureFlagManager = mockk<FeatureFlagManager>(relaxed = true) {
every { getFeatureFlag(FlagKey.MigrateMyVaultToMyItems) } returns true
}
private val connectionManager: NetworkConnectionManager = mockk<NetworkConnectionManager> {
every { isNetworkConnected } returns true
}
private val vaultSyncManager: VaultSyncManager = VaultSyncManagerImpl(
syncService = syncService,
@@ -150,6 +164,9 @@ class VaultSyncManagerTest {
userLogoutManager = userLogoutManager,
userStateManager = userStateManager,
vaultLockManager = vaultLockManager,
policyManager = policyManager,
featureFlagManager = mockFeatureFlagManager,
connectionManager = connectionManager,
clock = clock,
databaseSchemeManager = databaseSchemeManager,
pushManager = pushManager,
@@ -1249,6 +1266,250 @@ class VaultSyncManagerTest {
coVerify(exactly = 0) { syncService.sync() }
}
@Test
fun `shouldMigratePersonalVaultFlow should initially emit false`() = runTest {
assertEquals(false, vaultSyncManager.shouldMigratePersonalVaultFlow.value)
}
@Test
fun `shouldMigratePersonalVaultFlow should emit true after sync when all conditions are met`() =
runTest {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
setVaultToUnlocked(userId)
// Setup conditions for migration
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns listOf(mockPolicy)
coEvery {
vaultSdkSource.initializeOrganizationCrypto(
userId = userId,
request = InitOrgCryptoRequest(
organizationKeys = createMockOrganizationKeys(number = 1),
),
)
} returns InitializeCryptoResult.Success.asSuccess()
// Setup sync with personal ciphers (organizationId = null)
val personalCipher = createMockCipher(number = 1).copy(organizationId = null)
val syncResponse =
createMockSyncResponse(number = 1).copy(ciphers = listOf(personalCipher))
coEvery { syncService.sync() } returns syncResponse.asSuccess()
coEvery { vaultDiskSource.replaceVaultData(userId = userId, vault = any()) } just runs
setupEmptyDecryptionResults(userId)
setupVaultDiskSourceFlows(userId)
vaultSyncManager.shouldMigratePersonalVaultFlow.test {
assertEquals(false, awaitItem())
vaultSyncManager.sync()
assertEquals(true, awaitItem())
}
}
@Test
fun `shouldMigratePersonalVaultFlow should emit false when no personal ownership policy`() =
runTest {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
setVaultToUnlocked(userId)
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns emptyList()
val personalCipher = createMockCipher(number = 1).copy(organizationId = null)
val syncResponse =
createMockSyncResponse(number = 1).copy(ciphers = listOf(personalCipher))
coEvery { syncService.sync() } returns syncResponse.asSuccess()
coEvery { vaultDiskSource.replaceVaultData(userId = userId, vault = any()) } just runs
coEvery {
vaultSdkSource.initializeOrganizationCrypto(
userId = userId,
request = InitOrgCryptoRequest(
organizationKeys = createMockOrganizationKeys(number = 1),
),
)
} returns InitializeCryptoResult.Success.asSuccess()
setupEmptyDecryptionResults(userId)
setupVaultDiskSourceFlows(userId)
vaultSyncManager.shouldMigratePersonalVaultFlow.test {
assertEquals(false, awaitItem())
vaultSyncManager.sync()
// Should still be false since no policy
expectNoEvents()
}
}
@Test
fun `shouldMigratePersonalVaultFlow should emit false when feature flag is disabled`() =
runTest {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
setVaultToUnlocked(userId)
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns listOf(mockPolicy)
every {
mockFeatureFlagManager.getFeatureFlag(FlagKey.MigrateMyVaultToMyItems)
} returns false
val personalCipher = createMockCipher(number = 1).copy(organizationId = null)
val syncResponse =
createMockSyncResponse(number = 1).copy(ciphers = listOf(personalCipher))
coEvery { syncService.sync() } returns syncResponse.asSuccess()
coEvery { vaultDiskSource.replaceVaultData(userId = userId, vault = any()) } just runs
coEvery {
vaultSdkSource.initializeOrganizationCrypto(
userId = userId,
request = InitOrgCryptoRequest(
organizationKeys = createMockOrganizationKeys(number = 1),
),
)
} returns InitializeCryptoResult.Success.asSuccess()
setupEmptyDecryptionResults(userId)
setupVaultDiskSourceFlows(userId)
vaultSyncManager.shouldMigratePersonalVaultFlow.test {
assertEquals(false, awaitItem())
vaultSyncManager.sync()
// Should still be false since feature flag is disabled
expectNoEvents()
}
}
@Test
fun `shouldMigratePersonalVaultFlow should emit false when no network connection`() =
runTest {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
setVaultToUnlocked(userId)
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns listOf(mockPolicy)
every { connectionManager.isNetworkConnected } returns false
val personalCipher = createMockCipher(number = 1).copy(organizationId = null)
val syncResponse =
createMockSyncResponse(number = 1).copy(ciphers = listOf(personalCipher))
coEvery { syncService.sync() } returns syncResponse.asSuccess()
coEvery { vaultDiskSource.replaceVaultData(userId = userId, vault = any()) } just runs
coEvery {
vaultSdkSource.initializeOrganizationCrypto(
userId = userId,
request = InitOrgCryptoRequest(
organizationKeys = createMockOrganizationKeys(number = 1),
),
)
} returns InitializeCryptoResult.Success.asSuccess()
setupEmptyDecryptionResults(userId)
setupVaultDiskSourceFlows(userId)
vaultSyncManager.shouldMigratePersonalVaultFlow.test {
assertEquals(false, awaitItem())
vaultSyncManager.sync()
// Should still be false since no network
expectNoEvents()
}
}
@Test
fun `shouldMigratePersonalVaultFlow should emit false when no personal ciphers exist`() =
runTest {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
setVaultToUnlocked(userId)
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns listOf(mockPolicy)
// All ciphers belong to organizations
val orgCipher = createMockCipher(number = 1).copy(organizationId = "org-id")
val syncResponse = createMockSyncResponse(number = 1).copy(ciphers = listOf(orgCipher))
coEvery { syncService.sync() } returns syncResponse.asSuccess()
coEvery { vaultDiskSource.replaceVaultData(userId = userId, vault = any()) } just runs
coEvery {
vaultSdkSource.initializeOrganizationCrypto(
userId = userId,
request = InitOrgCryptoRequest(
organizationKeys = createMockOrganizationKeys(number = 1),
),
)
} returns InitializeCryptoResult.Success.asSuccess()
setupEmptyDecryptionResults(userId)
setupVaultDiskSourceFlows(userId)
vaultSyncManager.shouldMigratePersonalVaultFlow.test {
assertEquals(false, awaitItem())
vaultSyncManager.sync()
// Should still be false since no personal ciphers
expectNoEvents()
}
}
@Test
fun `shouldMigratePersonalVaultFlow should emit false when cipher list is null`() =
runTest {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
setVaultToUnlocked(userId)
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns listOf(mockPolicy)
val syncResponse = createMockSyncResponse(number = 1).copy(ciphers = null)
coEvery { syncService.sync() } returns syncResponse.asSuccess()
coEvery { vaultDiskSource.replaceVaultData(userId = userId, vault = any()) } just runs
coEvery {
vaultSdkSource.initializeOrganizationCrypto(
userId = userId,
request = InitOrgCryptoRequest(
organizationKeys = createMockOrganizationKeys(number = 1),
),
)
} returns InitializeCryptoResult.Success.asSuccess()
setupEmptyDecryptionResults(userId)
setupVaultDiskSourceFlows(userId)
vaultSyncManager.shouldMigratePersonalVaultFlow.test {
assertEquals(false, awaitItem())
vaultSyncManager.sync()
// Should still be false since ciphers is null
expectNoEvents()
}
}
//region Helper functions
/**
@@ -28,6 +28,7 @@ import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
import io.mockk.every
import io.mockk.mockk
@@ -61,6 +62,11 @@ class RootNavViewModelTest : BaseViewModelTest() {
dispatcherManager = FakeDispatcherManager(),
)
private val mutableShouldMigratePersonalVaultFlow = MutableStateFlow(false)
private val vaultRepository = mockk<VaultRepository> {
every { shouldMigratePersonalVaultFlow } returns mutableShouldMigratePersonalVaultFlow
}
@BeforeEach
fun setup() {
mockkStatic(::parseJwtTokenDataOrNull)
@@ -1579,10 +1585,56 @@ class RootNavViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `when shouldMigratePersonalVaultFlow emits true the nav state should be MigrateToMyItems`() {
mutableShouldMigratePersonalVaultFlow.value = true
mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_STATE)
val viewModel = createViewModel()
assertEquals(
RootNavState.MigrateToMyItems,
viewModel.stateFlow.value,
)
}
@Test
fun `when vault is locked MigrateToMyItems should not show even if flow emits true`() {
mutableShouldMigratePersonalVaultFlow.value = true
// Vault is locked
mutableUserStateFlow.tryEmit(
MOCK_VAULT_UNLOCKED_USER_STATE.copy(
accounts = listOf(
MOCK_VAULT_UNLOCKED_USER_STATE.activeAccount.copy(isVaultUnlocked = false),
),
),
)
val viewModel = createViewModel()
assertEquals(
RootNavState.VaultLocked,
viewModel.stateFlow.value,
)
}
@Test
fun `when shouldMigratePersonalVaultFlow emits false MigrateToMyItems should not show`() {
mutableShouldMigratePersonalVaultFlow.value = false
mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_STATE)
val viewModel = createViewModel()
assertEquals(
RootNavState.VaultUnlocked("activeUserId"),
viewModel.stateFlow.value,
)
}
private fun createViewModel(): RootNavViewModel =
RootNavViewModel(
authRepository = authRepository,
specialCircumstanceManager = specialCircumstanceManager,
vaultRepository = vaultRepository,
)
}
@@ -2,29 +2,35 @@ package com.x8bit.bitwarden.ui.vault.feature.migratetomyitems
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.mockk
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.assertNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class MigrateToMyItemsViewModelTest : BaseViewModelTest() {
@BeforeEach
fun setup() {
mockkStatic(SavedStateHandle::toMigrateToMyItemsArgs)
private val mutableUserStateFlow = MutableStateFlow<UserState?>(MOCK_USER_STATE)
private val authRepository = mockk<AuthRepository> {
every { userStateFlow } returns mutableUserStateFlow
}
@AfterEach
fun tearDown() {
unmockkStatic(SavedStateHandle::toMigrateToMyItemsArgs)
private val policyManager = mockk<PolicyManager> {
every { getPersonalOwnershipPolicyOrganizationId() } returns ORGANIZATION_ID
}
@Test
@@ -150,18 +156,76 @@ class MigrateToMyItemsViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `initial state should handle null organization ID`() {
every { policyManager.getPersonalOwnershipPolicyOrganizationId() } returns null
val viewModel = createViewModel()
assertEquals("", viewModel.stateFlow.value.organizationId)
assertEquals("", viewModel.stateFlow.value.organizationName)
assertNull(viewModel.stateFlow.value.dialog)
}
@Test
fun `initial state should handle null organization name`() {
mutableUserStateFlow.value = MOCK_USER_STATE.copy(
accounts = listOf(
MOCK_USER_STATE.activeAccount.copy(organizations = emptyList()),
),
)
val viewModel = createViewModel()
assertEquals(ORGANIZATION_ID, viewModel.stateFlow.value.organizationId)
assertEquals("", viewModel.stateFlow.value.organizationName)
assertNull(viewModel.stateFlow.value.dialog)
}
private fun createViewModel(
savedStateHandle: SavedStateHandle = SavedStateHandle(),
): MigrateToMyItemsViewModel {
every { savedStateHandle.toMigrateToMyItemsArgs() } returns MigrateToMyItemsArgs(
organizationId = ORGANIZATION_ID,
organizationName = ORGANIZATION_NAME,
)
return MigrateToMyItemsViewModel(
): MigrateToMyItemsViewModel =
MigrateToMyItemsViewModel(
authRepository = authRepository,
policyManager = policyManager,
savedStateHandle = savedStateHandle,
)
}
}
private const val ORGANIZATION_ID = "test-organization-id"
private const val ORGANIZATION_NAME = "Test Organization"
private val MOCK_USER_STATE = UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "Test User",
email = "test@example.com",
avatarColorHex = "#FF0000",
environment = Environment.Us,
isPremium = false,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
needsMasterPassword = false,
hasMasterPassword = true,
trustedDevice = null,
organizations = listOf(
Organization(
id = ORGANIZATION_ID,
name = ORGANIZATION_NAME,
shouldManageResetPassword = false,
shouldUseKeyConnector = false,
role = OrganizationType.USER,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
isBiometricsEnabled = false,
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
isUsingKeyConnector = false,
onboardingStatus = OnboardingStatus.COMPLETE,
firstTimeState = FirstTimeState(showImportLoginsCard = false),
isExportable = true,
),
),
)