mirror of
https://github.com/bitwarden/android.git
synced 2026-08-25 17:09:55 -05:00
[PM-28468] Updated validation and navigation for MigrateToMyItems (#6279)
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
This commit is contained in:
co-authored by
Patrick Honkonen
Patrick Honkonen
parent
577e3c04e3
commit
eff4ce7abb
+279
@@ -0,0 +1,279 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 9,
|
||||
"identityHash": "61353072161e3101ade140e2c4b65495",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "ciphers",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `has_totp` INTEGER NOT NULL DEFAULT 1, `cipher_type` TEXT NOT NULL, `cipher_json` TEXT NOT NULL, `organization_id` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasTotp",
|
||||
"columnName": "has_totp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "cipherType",
|
||||
"columnName": "cipher_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cipherJson",
|
||||
"columnName": "cipher_json",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "organizationId",
|
||||
"columnName": "organization_id",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_ciphers_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_ciphers_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
},
|
||||
{
|
||||
"name": "index_ciphers_user_id_organization_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id",
|
||||
"organization_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_ciphers_user_id_organization_id` ON `${TABLE_NAME}` (`user_id`, `organization_id`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "collections",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `organization_id` TEXT NOT NULL, `should_hide_passwords` INTEGER NOT NULL, `name` TEXT NOT NULL, `external_id` TEXT, `read_only` INTEGER NOT NULL, `manage` INTEGER, `default_user_collection_email` TEXT, `type` TEXT NOT NULL DEFAULT '0', PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "organizationId",
|
||||
"columnName": "organization_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "shouldHidePasswords",
|
||||
"columnName": "should_hide_passwords",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "externalId",
|
||||
"columnName": "external_id",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isReadOnly",
|
||||
"columnName": "read_only",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "canManage",
|
||||
"columnName": "manage",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "defaultUserCollectionEmail",
|
||||
"columnName": "default_user_collection_email",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'0'"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_collections_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "domains",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `domains_json` TEXT, PRIMARY KEY(`user_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "domainsJson",
|
||||
"columnName": "domains_json",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `name` TEXT, `revision_date` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "revisionDate",
|
||||
"columnName": "revision_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_folders_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_folders_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "sends",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `send_type` TEXT NOT NULL, `send_json` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sendType",
|
||||
"columnName": "send_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sendJson",
|
||||
"columnName": "send_json",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_sends_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_sends_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '61353072161e3101ade140e2c4b65495')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,16 @@ interface VaultDiskSource {
|
||||
*/
|
||||
suspend fun getCiphers(userId: String): List<SyncResponseJson.Cipher>
|
||||
|
||||
/**
|
||||
* Checks if the user has any personal ciphers (ciphers not belonging to an organization).
|
||||
*
|
||||
* This is an optimized query that checks only the indexed organizationId column
|
||||
* without loading full cipher JSON data. Intended for vault migration state checks.
|
||||
*
|
||||
* @return Flow that emits true if user has personal ciphers, false otherwise
|
||||
*/
|
||||
fun hasPersonalCiphersFlow(userId: String): Flow<Boolean>
|
||||
|
||||
/**
|
||||
* Retrieves all ciphers with the given [cipherIds] from the data source for a given [userId].
|
||||
*/
|
||||
|
||||
+5
@@ -55,6 +55,7 @@ class VaultDiskSourceImpl(
|
||||
hasTotp = cipher.login?.totp != null,
|
||||
cipherType = json.encodeToString(cipher.type),
|
||||
cipherJson = json.encodeToString(cipher),
|
||||
organizationId = cipher.organizationId,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -97,6 +98,9 @@ class VaultDiskSourceImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override fun hasPersonalCiphersFlow(userId: String): Flow<Boolean> =
|
||||
ciphersDao.hasPersonalCiphersFlow(userId = userId)
|
||||
|
||||
override suspend fun getSelectedCiphers(
|
||||
userId: String,
|
||||
cipherIds: List<String>,
|
||||
@@ -295,6 +299,7 @@ class VaultDiskSourceImpl(
|
||||
hasTotp = cipher.login?.totp != null,
|
||||
cipherType = json.encodeToString(cipher.type),
|
||||
cipherJson = json.encodeToString(cipher),
|
||||
organizationId = cipher.organizationId,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -88,4 +88,21 @@ interface CiphersDao {
|
||||
insertCiphers(ciphers)
|
||||
return deletedCiphersCount > 0 || ciphers.isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user has any personal ciphers (ciphers with null organizationId).
|
||||
* Returns a Flow that emits true if personal ciphers exist, false otherwise.
|
||||
*
|
||||
* This query is optimized for vault migration checks and uses the indexed
|
||||
* organization_id column to avoid loading full cipher JSON.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM ciphers
|
||||
WHERE user_id = :userId
|
||||
AND organization_id IS NULL
|
||||
LIMIT 1
|
||||
)
|
||||
""")
|
||||
fun hasPersonalCiphersFlow(userId: String): Flow<Boolean>
|
||||
}
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.entity.SendEntity
|
||||
FolderEntity::class,
|
||||
SendEntity::class,
|
||||
],
|
||||
version = 8,
|
||||
version = 9,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 6, to = 7),
|
||||
|
||||
+14
-2
@@ -2,18 +2,25 @@ package com.x8bit.bitwarden.data.vault.datasource.disk.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Entity representing a cipher in the database.
|
||||
*/
|
||||
@Entity(tableName = "ciphers")
|
||||
@Entity(
|
||||
tableName = "ciphers",
|
||||
indices = [
|
||||
Index(value = ["user_id"]),
|
||||
Index(value = ["user_id", "organization_id"]),
|
||||
],
|
||||
)
|
||||
data class CipherEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@ColumnInfo(name = "id")
|
||||
val id: String,
|
||||
|
||||
@ColumnInfo(name = "user_id", index = true)
|
||||
@ColumnInfo(name = "user_id")
|
||||
val userId: String,
|
||||
|
||||
// Default to true for initial migration.
|
||||
@@ -26,4 +33,9 @@ data class CipherEntity(
|
||||
|
||||
@ColumnInfo(name = "cipher_json")
|
||||
val cipherJson: String,
|
||||
|
||||
// Extracted organizationId for query optimization to avoid loading full cipher JSON.
|
||||
// Enables lightweight queries for vault migration checks and organization filtering.
|
||||
@ColumnInfo(name = "organization_id")
|
||||
val organizationId: String?,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.x8bit.bitwarden.data.vault.manager
|
||||
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Manages the migration of personal vault items to organization collections.
|
||||
* This interface provides a way to check if migration is needed and track migration state.
|
||||
*
|
||||
* The manager reactively observes vault cipher data and automatically updates the migration state
|
||||
* when conditions change (e.g., after sync, after vault unlock, policy changes).
|
||||
*/
|
||||
interface VaultMigrationManager {
|
||||
/**
|
||||
* Flow that emits when conditions are met for the user to migrate their personal vault.
|
||||
* Automatically updated when cipher data, policies, or feature flags change.
|
||||
*/
|
||||
val vaultMigrationDataStateFlow: StateFlow<VaultMigrationData>
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package com.x8bit.bitwarden.data.vault.manager
|
||||
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.core.data.manager.model.FlagKey
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndUnlocked
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* Default implementation of [VaultMigrationManager].
|
||||
*
|
||||
* Reactively observes vault cipher data and automatically updates migration state when:
|
||||
* - Vault is unlocked
|
||||
* - Sync has occurred at least once
|
||||
* - Cipher data changes
|
||||
* - Network connectivity changes
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
class VaultMigrationManagerImpl(
|
||||
private val authDiskSource: AuthDiskSource,
|
||||
private val vaultDiskSource: VaultDiskSource,
|
||||
private val settingsDiskSource: SettingsDiskSource,
|
||||
private val policyManager: PolicyManager,
|
||||
private val featureFlagManager: FeatureFlagManager,
|
||||
private val connectionManager: NetworkConnectionManager,
|
||||
vaultLockManager: VaultLockManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
) : VaultMigrationManager {
|
||||
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
|
||||
|
||||
private val mutableVaultMigrationDataStateFlow =
|
||||
MutableStateFlow<VaultMigrationData>(value = VaultMigrationData.NoMigrationRequired)
|
||||
|
||||
override val vaultMigrationDataStateFlow: StateFlow<VaultMigrationData>
|
||||
get() = mutableVaultMigrationDataStateFlow.asStateFlow()
|
||||
|
||||
init {
|
||||
// Observe cipher data changes and automatically verify migration state
|
||||
mutableVaultMigrationDataStateFlow
|
||||
.observeWhenSubscribedAndUnlocked(
|
||||
userStateFlow = authDiskSource.userStateFlow,
|
||||
vaultUnlockFlow = vaultLockManager.vaultUnlockDataStateFlow,
|
||||
) { activeUserId ->
|
||||
observeCipherDataAndUpdateMigrationState(userId = activeUserId)
|
||||
}
|
||||
.launchIn(unconfinedScope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes cipher data, sync state, and network connectivity for the given user and updates
|
||||
* migration state when changes occur. Only emits updates after the user has synced at least
|
||||
* once to ensure data freshness.
|
||||
*
|
||||
* Uses optimized [VaultDiskSource.hasPersonalCiphersFlow] query that checks only the
|
||||
* indexed organizationId column without loading full cipher JSON data.
|
||||
*
|
||||
* Combines cipher data with [SettingsDiskSource.getLastSyncTimeFlow] to handle multi-account
|
||||
* scenarios where lastSyncTime may be cleared without clearing cipher data. This ensures
|
||||
* migration state updates when sync completes, not just when cipher data changes.
|
||||
*
|
||||
* Also combines with [NetworkConnectionManager.isNetworkConnectedFlow] to ensure migration
|
||||
* state updates reactively when network connectivity changes.
|
||||
*/
|
||||
private fun observeCipherDataAndUpdateMigrationState(userId: String) =
|
||||
combine(
|
||||
vaultDiskSource.hasPersonalCiphersFlow(userId = userId),
|
||||
settingsDiskSource.getLastSyncTimeFlow(userId = userId),
|
||||
connectionManager.isNetworkConnectedFlow,
|
||||
) { hasPersonalCiphers, lastSyncTime, isNetworkConnected ->
|
||||
// Only process after sync has occurred at least once
|
||||
lastSyncTime ?: return@combine null
|
||||
hasPersonalCiphers to isNetworkConnected
|
||||
}
|
||||
.filterNotNull()
|
||||
.onEach { (hasPersonalCiphers, isNetworkConnected) ->
|
||||
verifyAndUpdateMigrationState(
|
||||
userId = userId,
|
||||
hasPersonalCiphers = hasPersonalCiphers,
|
||||
isNetworkConnected = isNetworkConnected,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies if the user should migrate their personal vault to organization collections
|
||||
* based on active policies, feature flags, network connectivity, and whether they have
|
||||
* personal ciphers.
|
||||
*
|
||||
* @param userId The ID of the user to check for migration.
|
||||
* @param hasPersonalCiphers Boolean indicating if the user has any personal ciphers.
|
||||
* @param isNetworkConnected Boolean indicating if the device has network connectivity.
|
||||
*/
|
||||
private fun verifyAndUpdateMigrationState(
|
||||
userId: String,
|
||||
hasPersonalCiphers: Boolean,
|
||||
isNetworkConnected: Boolean,
|
||||
) {
|
||||
mutableVaultMigrationDataStateFlow.update {
|
||||
if (!shouldMigrateVault(
|
||||
hasPersonalCiphers = hasPersonalCiphers,
|
||||
isNetworkConnected = isNetworkConnected,
|
||||
)
|
||||
) {
|
||||
return@update VaultMigrationData.NoMigrationRequired
|
||||
}
|
||||
|
||||
val orgId = policyManager.getPersonalOwnershipPolicyOrganizationId()
|
||||
?: return@update VaultMigrationData.NoMigrationRequired
|
||||
|
||||
val orgName = authDiskSource
|
||||
.getOrganizations(userId = userId)
|
||||
?.firstOrNull { it.id == orgId }
|
||||
?.name
|
||||
?: return@update VaultMigrationData.NoMigrationRequired
|
||||
|
||||
VaultMigrationData.MigrationRequired(
|
||||
organizationId = orgId,
|
||||
organizationName = orgName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user should migrate their vault based on policies, feature flags,
|
||||
* network connectivity, and whether they have personal items.
|
||||
*
|
||||
* @param hasPersonalCiphers Boolean indicating if the user has any personal ciphers.
|
||||
* @param isNetworkConnected Boolean indicating if the device has network connectivity.
|
||||
* @return true if migration conditions are met, false otherwise.
|
||||
*/
|
||||
private fun shouldMigrateVault(
|
||||
hasPersonalCiphers: Boolean,
|
||||
isNetworkConnected: Boolean,
|
||||
): Boolean =
|
||||
policyManager
|
||||
.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
.any() &&
|
||||
featureFlagManager.getFeatureFlag(FlagKey.MigrateMyVaultToMyItems) &&
|
||||
isNetworkConnected &&
|
||||
hasPersonalCiphers
|
||||
}
|
||||
@@ -17,9 +17,11 @@ import com.x8bit.bitwarden.data.auth.manager.UserStateManager
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
|
||||
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.ReviewPromptManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||
@@ -37,6 +39,8 @@ import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.TotpCodeManagerImpl
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultLockManagerImpl
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManagerImpl
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManagerImpl
|
||||
import dagger.Module
|
||||
@@ -55,6 +59,28 @@ import javax.inject.Singleton
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object VaultManagerModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideVaultMigrationManager(
|
||||
authDiskSource: AuthDiskSource,
|
||||
vaultDiskSource: VaultDiskSource,
|
||||
settingsDiskSource: SettingsDiskSource,
|
||||
vaultLockManager: VaultLockManager,
|
||||
policyManager: PolicyManager,
|
||||
featureFlagManager: FeatureFlagManager,
|
||||
connectionManager: NetworkConnectionManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
): VaultMigrationManager = VaultMigrationManagerImpl(
|
||||
authDiskSource = authDiskSource,
|
||||
vaultDiskSource = vaultDiskSource,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
vaultLockManager = vaultLockManager,
|
||||
policyManager = policyManager,
|
||||
featureFlagManager = featureFlagManager,
|
||||
connectionManager = connectionManager,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCipherManager(
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.x8bit.bitwarden.data.vault.manager.model
|
||||
|
||||
/**
|
||||
* Represents vault migration state with organization metadata.
|
||||
*/
|
||||
sealed class VaultMigrationData {
|
||||
/**
|
||||
* User should migrate personal vault items to the specified organization.
|
||||
*/
|
||||
data class MigrationRequired(
|
||||
val organizationId: String,
|
||||
val organizationName: String,
|
||||
) : VaultMigrationData()
|
||||
|
||||
/**
|
||||
* No migration required.
|
||||
*/
|
||||
data object NoMigrationRequired : VaultMigrationData()
|
||||
}
|
||||
@@ -167,11 +167,4 @@ interface VaultRepository :
|
||||
* `null` if the item cannot be found.
|
||||
*/
|
||||
fun getVaultListItemStateFlow(itemId: String): StateFlow<DataState<CipherListView?>>
|
||||
|
||||
/**
|
||||
* Checks if there are any personal vault items (items without an organization ID) in the vault.
|
||||
*
|
||||
* @return `true` if there are personal vault items, `false` otherwise.
|
||||
*/
|
||||
fun hasPersonalVaultItems(): Boolean
|
||||
}
|
||||
|
||||
@@ -557,9 +557,4 @@ class VaultRepositoryImpl(
|
||||
organizationKeys = organizationKeys,
|
||||
)
|
||||
}
|
||||
|
||||
override fun hasPersonalVaultItems(): Boolean {
|
||||
val vaultData = vaultSyncManager.vaultDataStateFlow.value.data ?: return false
|
||||
return vaultData.decryptCipherListResult.successes.any { it.organizationId.isNullOrEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ import com.x8bit.bitwarden.ui.vault.feature.exportitems.exportItemsGraph
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.navigateToExportItemsGraph
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.navigateToVerifyPassword
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.navigateToVaultItemListingAsRoot
|
||||
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.MigrateToMyItemsRoute
|
||||
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.navigateToMigrateToMyItems
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
|
||||
@@ -153,6 +155,13 @@ fun RootNavScreen(
|
||||
RootNavState.OnboardingAutoFillSetup -> SetupAutofillRoute.AsRoot
|
||||
RootNavState.OnboardingBrowserAutofillSetup -> SetupBrowserAutofillRoute.AsRoot
|
||||
RootNavState.OnboardingStepsComplete -> SetupCompleteRoute
|
||||
is RootNavState.MigrateToMyItems -> {
|
||||
val migrateState = state as RootNavState.MigrateToMyItems
|
||||
MigrateToMyItemsRoute(
|
||||
organizationId = migrateState.organizationId,
|
||||
organizationName = migrateState.organizationName,
|
||||
)
|
||||
}
|
||||
}
|
||||
val currentRoute = navController.currentDestination?.rootLevelRoute()
|
||||
|
||||
@@ -204,6 +213,14 @@ fun RootNavScreen(
|
||||
navController.navigateToExpiredRegistrationLinkScreen()
|
||||
}
|
||||
|
||||
is RootNavState.MigrateToMyItems -> {
|
||||
navController.navigateToMigrateToMyItems(
|
||||
organizationName = currentState.organizationName,
|
||||
organizationId = currentState.organizationId,
|
||||
navOptions = rootNavOptions,
|
||||
)
|
||||
}
|
||||
|
||||
RootNavState.RemovePassword -> navController.navigateToRemovePassword(rootNavOptions)
|
||||
RootNavState.ResetPassword -> {
|
||||
navController.navigateToResetPasswordScreen(rootNavOptions)
|
||||
|
||||
+43
-14
@@ -19,6 +19,8 @@ import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
|
||||
import com.x8bit.bitwarden.data.credentials.model.ProviderGetPasswordCredentialRequest
|
||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -35,6 +37,7 @@ import javax.inject.Inject
|
||||
class RootNavViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
specialCircumstanceManager: SpecialCircumstanceManager,
|
||||
vaultMigrationManager: VaultMigrationManager,
|
||||
) : BaseViewModel<RootNavState, Unit, RootNavAction>(
|
||||
initialState = RootNavState.Splash,
|
||||
) {
|
||||
@@ -43,11 +46,13 @@ class RootNavViewModel @Inject constructor(
|
||||
authRepository.authStateFlow,
|
||||
authRepository.userStateFlow,
|
||||
specialCircumstanceManager.specialCircumstanceStateFlow,
|
||||
) { authState, userState, specialCircumstance ->
|
||||
vaultMigrationManager.vaultMigrationDataStateFlow,
|
||||
) { authState, userState, specialCircumstance, vaultMigrationData ->
|
||||
RootNavAction.Internal.UserStateUpdateReceive(
|
||||
authState = authState,
|
||||
userState = userState,
|
||||
specialCircumstance = specialCircumstance,
|
||||
vaultMigrationData = vaultMigrationData,
|
||||
)
|
||||
}
|
||||
.onEach(::handleAction)
|
||||
@@ -66,6 +71,7 @@ class RootNavViewModel @Inject constructor(
|
||||
) {
|
||||
val userState = action.userState
|
||||
val specialCircumstance = action.specialCircumstance
|
||||
|
||||
val updatedRootNavState = when {
|
||||
userState?.activeAccount?.trustedDevice?.isDeviceTrusted == false &&
|
||||
authRepository.tdeLoginComplete != true &&
|
||||
@@ -110,21 +116,32 @@ class RootNavViewModel @Inject constructor(
|
||||
getOnboardingNavState(onboardingStatus = userState.activeAccount.onboardingStatus)
|
||||
}
|
||||
|
||||
userState.activeAccount.isVaultUnlocked &&
|
||||
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 &&
|
||||
specialCircumstance == null &&
|
||||
action.vaultMigrationData is VaultMigrationData.MigrationRequired -> {
|
||||
RootNavState.MigrateToMyItems(
|
||||
organizationId = action.vaultMigrationData.organizationId,
|
||||
organizationName = action.vaultMigrationData.organizationName,
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -207,6 +224,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.",
|
||||
@@ -317,6 +336,15 @@ sealed class RootNavState : Parcelable {
|
||||
@Parcelize
|
||||
data object VaultLocked : RootNavState()
|
||||
|
||||
/**
|
||||
* App should show MigrateToMyItems screen.
|
||||
*/
|
||||
@Parcelize
|
||||
data class MigrateToMyItems(
|
||||
val organizationId: String,
|
||||
val organizationName: String,
|
||||
) : RootNavState()
|
||||
|
||||
/**
|
||||
* App should show vault unlocked nav graph for the given [activeUserId].
|
||||
*/
|
||||
@@ -494,6 +522,7 @@ sealed class RootNavAction {
|
||||
val authState: AuthState,
|
||||
val userState: UserState?,
|
||||
val specialCircumstance: SpecialCircumstance?,
|
||||
val vaultMigrationData: VaultMigrationData,
|
||||
) : RootNavAction()
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -59,6 +59,7 @@ import com.x8bit.bitwarden.ui.vault.feature.item.vaultItemDestination
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.vaultItemListingDestinationAsRoot
|
||||
import com.x8bit.bitwarden.ui.vault.feature.manualcodeentry.navigateToManualCodeEntryScreen
|
||||
import com.x8bit.bitwarden.ui.vault.feature.manualcodeentry.vaultManualCodeEntryDestination
|
||||
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.migrateToMyItemsDestination
|
||||
import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.navigateToVaultMoveToOrganization
|
||||
import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.vaultMoveToOrganizationDestination
|
||||
import com.x8bit.bitwarden.ui.vault.feature.qrcodescan.navigateToQrCodeScanScreen
|
||||
@@ -263,6 +264,11 @@ fun NavGraphBuilder.vaultUnlockedGraph(
|
||||
importLoginsScreenDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
|
||||
migrateToMyItemsDestination(
|
||||
onNavigateToVault = { navController.popBackStack() },
|
||||
onNavigateToLeaveOrganization = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -9,6 +9,7 @@ import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
@@ -26,6 +27,7 @@ private const val KEY_STATE = "state"
|
||||
@HiltViewModel
|
||||
class MigrateToMyItemsViewModel @Inject constructor(
|
||||
private val organizationEventManager: OrganizationEventManager,
|
||||
vaultSyncManager: VaultSyncManager,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<MigrateToMyItemsState, MigrateToMyItemsEvent, MigrateToMyItemsAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: run {
|
||||
@@ -42,6 +44,9 @@ class MigrateToMyItemsViewModel @Inject constructor(
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
// We need to ensure we have the most recent data. No need to do anything
|
||||
// RootNavViewModel will take care of navigating if needed
|
||||
vaultSyncManager.sync(forced = true)
|
||||
}
|
||||
|
||||
override fun handleAction(action: MigrateToMyItemsAction) {
|
||||
|
||||
+27
@@ -336,6 +336,32 @@ class VaultDiskSourceTest {
|
||||
assertTrue(foldersDao.deleteFoldersCalled)
|
||||
assertTrue(sendsDao.deleteSendsCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasPersonalCiphersFlow should emit true when personal ciphers exist`() = runTest {
|
||||
val personalCipher = CIPHER_ENTITY.copy(organizationId = null)
|
||||
|
||||
vaultDiskSource
|
||||
.hasPersonalCiphersFlow(USER_ID)
|
||||
.test {
|
||||
assertEquals(false, awaitItem())
|
||||
ciphersDao.insertCiphers(listOf(personalCipher))
|
||||
assertEquals(true, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasPersonalCiphersFlow should emit false when only org ciphers exist`() = runTest {
|
||||
val orgCipher = CIPHER_ENTITY.copy(id = "orgCipherId", organizationId = "org-123")
|
||||
|
||||
vaultDiskSource
|
||||
.hasPersonalCiphersFlow(USER_ID)
|
||||
.test {
|
||||
assertEquals(false, awaitItem())
|
||||
ciphersDao.insertCiphers(listOf(orgCipher))
|
||||
assertEquals(false, awaitItem())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val USER_ID: String = "test_user_id"
|
||||
@@ -486,6 +512,7 @@ private val CIPHER_ENTITY = CipherEntity(
|
||||
hasTotp = true,
|
||||
cipherType = "1",
|
||||
cipherJson = CIPHER_JSON,
|
||||
organizationId = "mockOrganizationId-1",
|
||||
)
|
||||
|
||||
private val COLLECTION_ENTITY = CollectionEntity(
|
||||
|
||||
+5
@@ -65,4 +65,9 @@ class FakeCiphersDao : CiphersDao {
|
||||
ciphersFlow.tryEmit(ciphers.toList())
|
||||
return removed || ciphers.isNotEmpty()
|
||||
}
|
||||
|
||||
override fun hasPersonalCiphersFlow(userId: String): Flow<Boolean> =
|
||||
ciphersFlow.map { ciphers ->
|
||||
ciphers.any { it.userId == userId && it.organizationId == null }
|
||||
}
|
||||
}
|
||||
|
||||
+657
@@ -0,0 +1,657 @@
|
||||
package com.x8bit.bitwarden.data.vault.manager
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
|
||||
import com.bitwarden.core.data.manager.model.FlagKey
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.network.model.createMockPolicy
|
||||
import com.bitwarden.network.model.createMockSyncResponse
|
||||
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.UserStateJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.NetworkConnection
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.NetworkSignalStrength
|
||||
import com.x8bit.bitwarden.data.platform.manager.util.FakeNetworkConnectionManager
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
|
||||
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
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
class VaultMigrationManagerTest {
|
||||
|
||||
private val fakeAuthDiskSource = FakeAuthDiskSource()
|
||||
private val fakeDispatcherManager = FakeDispatcherManager()
|
||||
|
||||
private val mutableHasPersonalCiphersFlow = MutableStateFlow(false)
|
||||
private val vaultDiskSource: VaultDiskSource = mockk {
|
||||
every { hasPersonalCiphersFlow(any()) } returns mutableHasPersonalCiphersFlow
|
||||
}
|
||||
|
||||
private val mutableVaultUnlockDataStateFlow =
|
||||
MutableStateFlow<List<VaultUnlockData>>(emptyList())
|
||||
private val vaultLockManager: VaultLockManager = mockk {
|
||||
every { vaultUnlockDataStateFlow } returns mutableVaultUnlockDataStateFlow
|
||||
}
|
||||
|
||||
private val mutableLastSyncTimeFlow =
|
||||
MutableStateFlow<Instant?>(Instant.parse("2023-10-27T12:00:00Z"))
|
||||
private val settingsDiskSource: SettingsDiskSource = mockk {
|
||||
every { getLastSyncTimeFlow(any()) } returns mutableLastSyncTimeFlow
|
||||
}
|
||||
|
||||
private val policyManager: PolicyManager = mockk {
|
||||
every { getActivePolicies(any()) } returns emptyList()
|
||||
every { getPersonalOwnershipPolicyOrganizationId() } returns null
|
||||
}
|
||||
|
||||
private val mockFeatureFlagManager = mockk<FeatureFlagManager>(relaxed = true) {
|
||||
every { getFeatureFlag(FlagKey.MigrateMyVaultToMyItems) } returns true
|
||||
}
|
||||
|
||||
private val fakeNetworkConnectionManager = FakeNetworkConnectionManager(
|
||||
isNetworkConnected = true,
|
||||
networkConnection = NetworkConnection.Wifi(strength = NetworkSignalStrength.GOOD),
|
||||
)
|
||||
|
||||
private fun createVaultMigrationManager(): VaultMigrationManager =
|
||||
VaultMigrationManagerImpl(
|
||||
authDiskSource = fakeAuthDiskSource,
|
||||
vaultDiskSource = vaultDiskSource,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
vaultLockManager = vaultLockManager,
|
||||
policyManager = policyManager,
|
||||
featureFlagManager = mockFeatureFlagManager,
|
||||
connectionManager = fakeNetworkConnectionManager,
|
||||
dispatcherManager = fakeDispatcherManager,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `vaultMigrationDataStateFlow should initially emit NoMigrationRequired`() = runTest {
|
||||
val vaultMigrationManager = createVaultMigrationManager()
|
||||
assertEquals(
|
||||
VaultMigrationData.NoMigrationRequired,
|
||||
vaultMigrationManager.vaultMigrationDataStateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should emit MigrationRequired when vault unlocks with all conditions met`() = runTest {
|
||||
val userId = "mockId-1"
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
|
||||
// Setup conditions for migration
|
||||
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
every {
|
||||
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
} returns listOf(mockPolicy)
|
||||
every {
|
||||
policyManager.getPersonalOwnershipPolicyOrganizationId()
|
||||
} returns "mockId-1"
|
||||
|
||||
// Store organizations
|
||||
val syncResponse = createMockSyncResponse(number = 1)
|
||||
fakeAuthDiskSource.storeOrganizations(
|
||||
userId = userId,
|
||||
organizations = syncResponse.profile.organizations,
|
||||
)
|
||||
|
||||
val vaultMigrationManager = createVaultMigrationManager()
|
||||
|
||||
vaultMigrationManager.vaultMigrationDataStateFlow.test {
|
||||
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
|
||||
|
||||
// Simulate vault unlock
|
||||
mutableVaultUnlockDataStateFlow.value = listOf(
|
||||
VaultUnlockData(
|
||||
userId = userId,
|
||||
status = VaultUnlockData.Status.UNLOCKED,
|
||||
),
|
||||
)
|
||||
|
||||
// Emit personal ciphers exist
|
||||
mutableHasPersonalCiphersFlow.value = true
|
||||
|
||||
assertEquals(
|
||||
VaultMigrationData.MigrationRequired(
|
||||
organizationId = "mockId-1",
|
||||
organizationName = "mockName-1",
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should emit NoMigrationRequired when no personal ownership policy`() = runTest {
|
||||
val userId = "mockId-1"
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
|
||||
every {
|
||||
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
} returns emptyList()
|
||||
|
||||
val vaultMigrationManager = createVaultMigrationManager()
|
||||
|
||||
vaultMigrationManager.vaultMigrationDataStateFlow.test {
|
||||
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
|
||||
|
||||
// Simulate vault unlock
|
||||
mutableVaultUnlockDataStateFlow.value = listOf(
|
||||
VaultUnlockData(
|
||||
userId = userId,
|
||||
status = VaultUnlockData.Status.UNLOCKED,
|
||||
),
|
||||
)
|
||||
|
||||
// Emit personal ciphers exist
|
||||
mutableHasPersonalCiphersFlow.value = true
|
||||
|
||||
// Should remain NoMigrationRequired since no policy
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should emit NoMigrationRequired when feature flag is disabled`() = runTest {
|
||||
val userId = "mockId-1"
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
|
||||
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 vaultMigrationManager = createVaultMigrationManager()
|
||||
|
||||
vaultMigrationManager.vaultMigrationDataStateFlow.test {
|
||||
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
|
||||
|
||||
// Simulate vault unlock
|
||||
mutableVaultUnlockDataStateFlow.value = listOf(
|
||||
VaultUnlockData(
|
||||
userId = userId,
|
||||
status = VaultUnlockData.Status.UNLOCKED,
|
||||
),
|
||||
)
|
||||
|
||||
// Emit personal ciphers exist
|
||||
mutableHasPersonalCiphersFlow.value = true
|
||||
|
||||
// Should remain NoMigrationRequired since feature flag is disabled
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should emit NoMigrationRequired when no network connection`() = runTest {
|
||||
val userId = "mockId-1"
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
fakeNetworkConnectionManager.fakeIsNetworkConnected = false
|
||||
|
||||
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
every {
|
||||
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
} returns listOf(mockPolicy)
|
||||
every {
|
||||
policyManager.getPersonalOwnershipPolicyOrganizationId()
|
||||
} returns "mockId-1"
|
||||
|
||||
val syncResponse = createMockSyncResponse(number = 1)
|
||||
fakeAuthDiskSource.storeOrganizations(
|
||||
userId = userId,
|
||||
organizations = syncResponse.profile.organizations,
|
||||
)
|
||||
|
||||
val vaultMigrationManager = createVaultMigrationManager()
|
||||
|
||||
vaultMigrationManager.vaultMigrationDataStateFlow.test {
|
||||
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
|
||||
|
||||
// Simulate vault unlock
|
||||
mutableVaultUnlockDataStateFlow.value = listOf(
|
||||
VaultUnlockData(
|
||||
userId = userId,
|
||||
status = VaultUnlockData.Status.UNLOCKED,
|
||||
),
|
||||
)
|
||||
|
||||
// Emit personal ciphers exist
|
||||
mutableHasPersonalCiphersFlow.value = true
|
||||
|
||||
// Should remain NoMigrationRequired since no network
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should emit NoMigrationRequired when no personal ciphers exist`() = runTest {
|
||||
val userId = "mockId-1"
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
|
||||
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
every {
|
||||
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
} returns listOf(mockPolicy)
|
||||
|
||||
val vaultMigrationManager = createVaultMigrationManager()
|
||||
|
||||
vaultMigrationManager.vaultMigrationDataStateFlow.test {
|
||||
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
|
||||
|
||||
// Simulate vault unlock
|
||||
mutableVaultUnlockDataStateFlow.value = listOf(
|
||||
VaultUnlockData(
|
||||
userId = userId,
|
||||
status = VaultUnlockData.Status.UNLOCKED,
|
||||
),
|
||||
)
|
||||
|
||||
// Emit no personal ciphers
|
||||
mutableHasPersonalCiphersFlow.value = false
|
||||
|
||||
// Should remain NoMigrationRequired since no personal ciphers
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should emit NoMigrationRequired when organization ID is null`() = runTest {
|
||||
val userId = "mockId-1"
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
|
||||
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
every {
|
||||
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
} returns listOf(mockPolicy)
|
||||
every {
|
||||
policyManager.getPersonalOwnershipPolicyOrganizationId()
|
||||
} returns null
|
||||
|
||||
val vaultMigrationManager = createVaultMigrationManager()
|
||||
|
||||
vaultMigrationManager.vaultMigrationDataStateFlow.test {
|
||||
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
|
||||
|
||||
// Simulate vault unlock
|
||||
mutableVaultUnlockDataStateFlow.value = listOf(
|
||||
VaultUnlockData(
|
||||
userId = userId,
|
||||
status = VaultUnlockData.Status.UNLOCKED,
|
||||
),
|
||||
)
|
||||
|
||||
// Emit personal ciphers exist
|
||||
mutableHasPersonalCiphersFlow.value = true
|
||||
|
||||
// Should remain NoMigrationRequired since organization ID is null
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should emit NoMigrationRequired when organizations list is null`() = runTest {
|
||||
val userId = "mockId-1"
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
|
||||
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
every {
|
||||
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
} returns listOf(mockPolicy)
|
||||
every {
|
||||
policyManager.getPersonalOwnershipPolicyOrganizationId()
|
||||
} returns "mockId-1"
|
||||
|
||||
// Store organizations as null in authDiskSource
|
||||
fakeAuthDiskSource.storeOrganizations(userId = userId, organizations = null)
|
||||
|
||||
val vaultMigrationManager = createVaultMigrationManager()
|
||||
|
||||
vaultMigrationManager.vaultMigrationDataStateFlow.test {
|
||||
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
|
||||
|
||||
// Simulate vault unlock
|
||||
mutableVaultUnlockDataStateFlow.value = listOf(
|
||||
VaultUnlockData(
|
||||
userId = userId,
|
||||
status = VaultUnlockData.Status.UNLOCKED,
|
||||
),
|
||||
)
|
||||
|
||||
// Emit personal ciphers exist
|
||||
mutableHasPersonalCiphersFlow.value = true
|
||||
|
||||
// Should remain NoMigrationRequired since organizations list is null
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should emit NoMigrationRequired when organization not found`() = runTest {
|
||||
val userId = "mockId-1"
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
|
||||
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
every {
|
||||
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
} returns listOf(mockPolicy)
|
||||
// Return a different org ID that doesn't match the one in the sync response
|
||||
every {
|
||||
policyManager.getPersonalOwnershipPolicyOrganizationId()
|
||||
} returns "non-existent-org-id"
|
||||
|
||||
// Store organizations with a different ID
|
||||
val syncResponse = createMockSyncResponse(number = 1)
|
||||
fakeAuthDiskSource.storeOrganizations(
|
||||
userId = userId,
|
||||
organizations = syncResponse.profile.organizations,
|
||||
)
|
||||
|
||||
val vaultMigrationManager = createVaultMigrationManager()
|
||||
|
||||
vaultMigrationManager.vaultMigrationDataStateFlow.test {
|
||||
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
|
||||
|
||||
// Simulate vault unlock
|
||||
mutableVaultUnlockDataStateFlow.value = listOf(
|
||||
VaultUnlockData(
|
||||
userId = userId,
|
||||
status = VaultUnlockData.Status.UNLOCKED,
|
||||
),
|
||||
)
|
||||
|
||||
// Emit personal ciphers exist
|
||||
mutableHasPersonalCiphersFlow.value = true
|
||||
|
||||
// Should remain NoMigrationRequired since organization is not found
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should not emit when vault is locked`() = runTest {
|
||||
val userId = "mockId-1"
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
|
||||
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
every {
|
||||
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
} returns listOf(mockPolicy)
|
||||
every {
|
||||
policyManager.getPersonalOwnershipPolicyOrganizationId()
|
||||
} returns "mockId-1"
|
||||
|
||||
val syncResponse = createMockSyncResponse(number = 1)
|
||||
fakeAuthDiskSource.storeOrganizations(
|
||||
userId = userId,
|
||||
organizations = syncResponse.profile.organizations,
|
||||
)
|
||||
|
||||
val vaultMigrationManager = createVaultMigrationManager()
|
||||
|
||||
vaultMigrationManager.vaultMigrationDataStateFlow.test {
|
||||
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
|
||||
|
||||
// Vault is locked - no unlock data
|
||||
mutableVaultUnlockDataStateFlow.value = emptyList()
|
||||
|
||||
// Emit personal ciphers exist - should not trigger migration check since
|
||||
// vault is locked
|
||||
mutableHasPersonalCiphersFlow.value = true
|
||||
|
||||
// Should remain NoMigrationRequired since vault is locked
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should not emit when sync has not occurred yet`() = runTest {
|
||||
val userId = "mockId-1"
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
|
||||
// Set sync time as null (never synced)
|
||||
mutableLastSyncTimeFlow.value = null
|
||||
|
||||
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
every {
|
||||
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
} returns listOf(mockPolicy)
|
||||
every {
|
||||
policyManager.getPersonalOwnershipPolicyOrganizationId()
|
||||
} returns "mockId-1"
|
||||
|
||||
val syncResponse = createMockSyncResponse(number = 1)
|
||||
fakeAuthDiskSource.storeOrganizations(
|
||||
userId = userId,
|
||||
organizations = syncResponse.profile.organizations,
|
||||
)
|
||||
|
||||
val vaultMigrationManager = createVaultMigrationManager()
|
||||
|
||||
vaultMigrationManager.vaultMigrationDataStateFlow.test {
|
||||
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
|
||||
|
||||
// Simulate vault unlock
|
||||
mutableVaultUnlockDataStateFlow.value = listOf(
|
||||
VaultUnlockData(
|
||||
userId = userId,
|
||||
status = VaultUnlockData.Status.UNLOCKED,
|
||||
),
|
||||
)
|
||||
|
||||
// Emit personal ciphers exist - should not trigger since no sync has occurred
|
||||
mutableHasPersonalCiphersFlow.value = true
|
||||
|
||||
// Should remain NoMigrationRequired since sync hasn't happened
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should emit MigrationRequired when sync completes after vault unlock`() = runTest {
|
||||
val userId = "mockId-1"
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
|
||||
// Start with sync time as null (never synced) - simulates multi-account scenario
|
||||
// where lastSyncTime was cleared without clearing cipher data
|
||||
mutableLastSyncTimeFlow.value = null
|
||||
|
||||
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
every {
|
||||
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
} returns listOf(mockPolicy)
|
||||
every {
|
||||
policyManager.getPersonalOwnershipPolicyOrganizationId()
|
||||
} returns "mockId-1"
|
||||
|
||||
val syncResponse = createMockSyncResponse(number = 1)
|
||||
fakeAuthDiskSource.storeOrganizations(
|
||||
userId = userId,
|
||||
organizations = syncResponse.profile.organizations,
|
||||
)
|
||||
|
||||
val vaultMigrationManager = createVaultMigrationManager()
|
||||
|
||||
vaultMigrationManager.vaultMigrationDataStateFlow.test {
|
||||
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
|
||||
|
||||
// Simulate vault unlock
|
||||
mutableVaultUnlockDataStateFlow.value = listOf(
|
||||
VaultUnlockData(
|
||||
userId = userId,
|
||||
status = VaultUnlockData.Status.UNLOCKED,
|
||||
),
|
||||
)
|
||||
|
||||
// Emit personal ciphers exist - should not trigger yet since no sync
|
||||
mutableHasPersonalCiphersFlow.value = true
|
||||
|
||||
// Should remain NoMigrationRequired since sync hasn't happened
|
||||
expectNoEvents()
|
||||
|
||||
// Sync completes - should now trigger migration check
|
||||
mutableLastSyncTimeFlow.value = Instant.parse("2023-10-27T12:00:00Z")
|
||||
|
||||
assertEquals(
|
||||
VaultMigrationData.MigrationRequired(
|
||||
organizationId = "mockId-1",
|
||||
organizationName = "mockName-1",
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should emit MigrationRequired when network connectivity changes from offline to online`() =
|
||||
runTest {
|
||||
val userId = "mockId-1"
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
fakeNetworkConnectionManager.fakeIsNetworkConnected = false
|
||||
|
||||
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
every {
|
||||
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
} returns listOf(mockPolicy)
|
||||
every {
|
||||
policyManager.getPersonalOwnershipPolicyOrganizationId()
|
||||
} returns "mockId-1"
|
||||
|
||||
val syncResponse = createMockSyncResponse(number = 1)
|
||||
fakeAuthDiskSource.storeOrganizations(
|
||||
userId = userId,
|
||||
organizations = syncResponse.profile.organizations,
|
||||
)
|
||||
|
||||
val vaultMigrationManager = createVaultMigrationManager()
|
||||
|
||||
vaultMigrationManager.vaultMigrationDataStateFlow.test {
|
||||
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
|
||||
|
||||
// Simulate vault unlock while offline
|
||||
mutableVaultUnlockDataStateFlow.value = listOf(
|
||||
VaultUnlockData(
|
||||
userId = userId,
|
||||
status = VaultUnlockData.Status.UNLOCKED,
|
||||
),
|
||||
)
|
||||
|
||||
// Emit personal ciphers exist - still offline
|
||||
mutableHasPersonalCiphersFlow.value = true
|
||||
|
||||
// Should remain NoMigrationRequired since no network
|
||||
expectNoEvents()
|
||||
|
||||
// Network comes online - should now emit MigrationRequired
|
||||
fakeNetworkConnectionManager.fakeIsNetworkConnected = true
|
||||
|
||||
assertEquals(
|
||||
VaultMigrationData.MigrationRequired(
|
||||
organizationId = "mockId-1",
|
||||
organizationName = "mockName-1",
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `should emit NoMigrationRequired when network connectivity changes from online to offline`() =
|
||||
runTest {
|
||||
val userId = "mockId-1"
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
|
||||
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
every {
|
||||
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
} returns listOf(mockPolicy)
|
||||
every {
|
||||
policyManager.getPersonalOwnershipPolicyOrganizationId()
|
||||
} returns "mockId-1"
|
||||
|
||||
val syncResponse = createMockSyncResponse(number = 1)
|
||||
fakeAuthDiskSource.storeOrganizations(
|
||||
userId = userId,
|
||||
organizations = syncResponse.profile.organizations,
|
||||
)
|
||||
|
||||
val vaultMigrationManager = createVaultMigrationManager()
|
||||
|
||||
vaultMigrationManager.vaultMigrationDataStateFlow.test {
|
||||
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
|
||||
|
||||
// Simulate vault unlock while online
|
||||
mutableVaultUnlockDataStateFlow.value = listOf(
|
||||
VaultUnlockData(
|
||||
userId = userId,
|
||||
status = VaultUnlockData.Status.UNLOCKED,
|
||||
),
|
||||
)
|
||||
|
||||
// Emit personal ciphers exist
|
||||
mutableHasPersonalCiphersFlow.value = true
|
||||
|
||||
// Should emit MigrationRequired since all conditions met
|
||||
assertEquals(
|
||||
VaultMigrationData.MigrationRequired(
|
||||
organizationId = "mockId-1",
|
||||
organizationName = "mockName-1",
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
// Network goes offline - should revert to NoMigrationRequired
|
||||
fakeNetworkConnectionManager.fakeIsNetworkConnected = false
|
||||
|
||||
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val MOCK_USER_STATE = UserStateJson(
|
||||
activeUserId = "mockId-1",
|
||||
accounts = mapOf(
|
||||
"mockId-1" to AccountJson(
|
||||
profile = AccountJson.Profile(
|
||||
userId = "mockId-1",
|
||||
email = "test@bitwarden.com",
|
||||
isEmailVerified = true,
|
||||
name = "Bitwarden Tester",
|
||||
stamp = null,
|
||||
organizationId = null,
|
||||
avatarColorHex = null,
|
||||
hasPremium = false,
|
||||
forcePasswordResetReason = null,
|
||||
kdfType = null,
|
||||
kdfIterations = null,
|
||||
kdfMemory = null,
|
||||
kdfParallelism = null,
|
||||
userDecryptionOptions = null,
|
||||
isTwoFactorEnabled = false,
|
||||
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
|
||||
),
|
||||
tokens = AccountTokensJson(
|
||||
accessToken = "accessToken",
|
||||
refreshToken = "refreshToken",
|
||||
),
|
||||
settings = AccountJson.Settings(
|
||||
environmentUrlData = null,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -1440,100 +1440,6 @@ class VaultRepositoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasPersonalVaultItems returns false when vault data is loading`() {
|
||||
mutableVaultDataStateFlow.value = DataState.Loading
|
||||
|
||||
val result = vaultRepository.hasPersonalVaultItems()
|
||||
|
||||
assertEquals(false, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasPersonalVaultItems returns false when all items belong to organizations`() {
|
||||
mutableVaultDataStateFlow.value = DataState.Loaded(
|
||||
data = VaultData(
|
||||
decryptCipherListResult = DecryptCipherListResult(
|
||||
successes = listOf(
|
||||
createMockCipherListView(number = 1, organizationId = "org-1"),
|
||||
createMockCipherListView(number = 2, organizationId = "org-2"),
|
||||
),
|
||||
failures = emptyList(),
|
||||
),
|
||||
collectionViewList = emptyList(),
|
||||
folderViewList = emptyList(),
|
||||
sendViewList = emptyList(),
|
||||
),
|
||||
)
|
||||
|
||||
val result = vaultRepository.hasPersonalVaultItems()
|
||||
|
||||
assertEquals(false, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasPersonalVaultItems returns true when there are items without organization ID`() {
|
||||
mutableVaultDataStateFlow.value = DataState.Loaded(
|
||||
data = VaultData(
|
||||
decryptCipherListResult = DecryptCipherListResult(
|
||||
successes = listOf(
|
||||
createMockCipherListView(number = 1, organizationId = null),
|
||||
createMockCipherListView(number = 2, organizationId = "org-2"),
|
||||
),
|
||||
failures = emptyList(),
|
||||
),
|
||||
collectionViewList = emptyList(),
|
||||
folderViewList = emptyList(),
|
||||
sendViewList = emptyList(),
|
||||
),
|
||||
)
|
||||
|
||||
val result = vaultRepository.hasPersonalVaultItems()
|
||||
|
||||
assertEquals(true, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasPersonalVaultItems returns true when there are items with empty organization ID`() {
|
||||
mutableVaultDataStateFlow.value = DataState.Loaded(
|
||||
data = VaultData(
|
||||
decryptCipherListResult = DecryptCipherListResult(
|
||||
successes = listOf(
|
||||
createMockCipherListView(number = 1, organizationId = ""),
|
||||
createMockCipherListView(number = 2, organizationId = "org-2"),
|
||||
),
|
||||
failures = emptyList(),
|
||||
),
|
||||
collectionViewList = emptyList(),
|
||||
folderViewList = emptyList(),
|
||||
sendViewList = emptyList(),
|
||||
),
|
||||
)
|
||||
|
||||
val result = vaultRepository.hasPersonalVaultItems()
|
||||
|
||||
assertEquals(true, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasPersonalVaultItems returns false when successes list is empty`() {
|
||||
mutableVaultDataStateFlow.value = DataState.Loaded(
|
||||
data = VaultData(
|
||||
decryptCipherListResult = DecryptCipherListResult(
|
||||
successes = emptyList(),
|
||||
failures = emptyList(),
|
||||
),
|
||||
collectionViewList = emptyList(),
|
||||
folderViewList = emptyList(),
|
||||
sendViewList = emptyList(),
|
||||
),
|
||||
)
|
||||
|
||||
val result = vaultRepository.hasPersonalVaultItems()
|
||||
|
||||
assertEquals(false, result)
|
||||
}
|
||||
|
||||
//region Helper functions
|
||||
|
||||
/**
|
||||
|
||||
+62
@@ -28,6 +28,8 @@ 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.manager.VaultMigrationManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
@@ -61,6 +63,12 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
||||
dispatcherManager = FakeDispatcherManager(),
|
||||
)
|
||||
|
||||
private val mutableVaultMigrationDataStateFlow =
|
||||
MutableStateFlow<VaultMigrationData>(VaultMigrationData.NoMigrationRequired)
|
||||
private val vaultMigrationManager = mockk<VaultMigrationManager> {
|
||||
every { vaultMigrationDataStateFlow } returns mutableVaultMigrationDataStateFlow
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkStatic(::parseJwtTokenDataOrNull)
|
||||
@@ -1579,10 +1587,59 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `when vaultMigrationDataStateFlow emits true the nav state should be MigrateToMyItems`() {
|
||||
mutableVaultMigrationDataStateFlow.value = MOCK_VAULT_MIGRATION_DATA
|
||||
mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_STATE)
|
||||
val viewModel = createViewModel()
|
||||
|
||||
assertEquals(
|
||||
RootNavState.MigrateToMyItems(
|
||||
organizationId = "mockOrganizationId-1",
|
||||
organizationName = "organizationName",
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when vault is locked MigrateToMyItems should not show even if flow emits true`() {
|
||||
mutableVaultMigrationDataStateFlow.value = MOCK_VAULT_MIGRATION_DATA
|
||||
|
||||
// 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 vaultMigrationDataStateFlow emits false MigrateToMyItems should not show`() {
|
||||
mutableVaultMigrationDataStateFlow.value = VaultMigrationData.NoMigrationRequired
|
||||
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,
|
||||
vaultMigrationManager = vaultMigrationManager,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1665,3 +1722,8 @@ private val MOCK_VAULT_UNLOCKED_USER_MULTIPLE_ACCOUNTS_STATE = UserState(
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private val MOCK_VAULT_MIGRATION_DATA = VaultMigrationData.MigrationRequired(
|
||||
organizationName = "organizationName",
|
||||
organizationId = "mockOrganizationId-1",
|
||||
)
|
||||
|
||||
+3
@@ -7,6 +7,7 @@ import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
@@ -26,6 +27,7 @@ class MigrateToMyItemsViewModelTest : BaseViewModelTest() {
|
||||
private val mockOrganizationEventManager: OrganizationEventManager = mockk {
|
||||
every { trackEvent(any()) } just runs
|
||||
}
|
||||
private val mockVaultSyncManager: VaultSyncManager = mockk(relaxed = true)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
@@ -176,6 +178,7 @@ class MigrateToMyItemsViewModelTest : BaseViewModelTest() {
|
||||
)
|
||||
return MigrateToMyItemsViewModel(
|
||||
organizationEventManager = mockOrganizationEventManager,
|
||||
vaultSyncManager = mockVaultSyncManager,
|
||||
savedStateHandle = savedStateHandle,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user