From 4a874668f26c00d228544e60917a6e767dbece85 Mon Sep 17 00:00:00 2001 From: aj-rosado <109146700+aj-rosado@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:58:23 +0000 Subject: [PATCH] [PM-28468] Added service methods to migration to MyItems validation (#6248) --- .../data/platform/manager/PolicyManager.kt | 6 + .../platform/manager/PolicyManagerImpl.kt | 7 + .../data/vault/repository/VaultRepository.kt | 7 + .../vault/repository/VaultRepositoryImpl.kt | 5 + .../platform/manager/PolicyManagerTest.kt | 203 ++++++++++++++++++ .../vault/repository/VaultRepositoryTest.kt | 94 ++++++++ 6 files changed, 322 insertions(+) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/PolicyManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/PolicyManager.kt index 7e61d5dbed..0331c462ef 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/PolicyManager.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/PolicyManager.kt @@ -25,4 +25,10 @@ interface PolicyManager { userId: String, type: PolicyTypeJson, ): List + + /** + * Get the organization id of the personal ownership policy. + * If multiple organizations enforce the policy, return the first to set it. + */ + fun getPersonalOwnershipPolicyOrganizationId(): String? } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/PolicyManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/PolicyManagerImpl.kt index 3197b81516..b2c2f8861c 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/PolicyManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/PolicyManagerImpl.kt @@ -66,6 +66,13 @@ class PolicyManagerImpl( ) .orEmpty() + override fun getPersonalOwnershipPolicyOrganizationId(): String? = + this + .getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP) + .sortedBy { it.revisionDate } + .firstOrNull() + ?.organizationId + /** * A helper method to filter policies. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt index bef061f6c0..a525e84aae 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt @@ -166,4 +166,11 @@ interface VaultRepository : * `null` if the item cannot be found. */ fun getVaultListItemStateFlow(itemId: String): StateFlow> + + /** + * 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 } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index 9c3a6dabc8..15d6819fd7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -550,4 +550,9 @@ class VaultRepositoryImpl( organizationKeys = organizationKeys, ) } + + override fun hasPersonalVaultItems(): Boolean { + val vaultData = vaultSyncManager.vaultDataStateFlow.value.data ?: return false + return vaultData.decryptCipherListResult.successes.any { it.organizationId.isNullOrEmpty() } + } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/PolicyManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/PolicyManagerTest.kt index e5581c0404..46eaa767cd 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/PolicyManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/PolicyManagerTest.kt @@ -13,9 +13,11 @@ 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.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import java.time.ZonedDateTime class PolicyManagerTest { private val mutableUserStateFlow = MutableStateFlow(null) @@ -277,6 +279,207 @@ class PolicyManagerTest { ), ) } + + @Test + fun `getPersonalOwnershipPolicyOrganizationId returns null when no active user`() { + every { authDiskSource.userState } returns null + + assertNull(policyManager.getPersonalOwnershipPolicyOrganizationId()) + } + + @Test + fun `getPersonalOwnershipPolicyOrganizationId returns null when no policies exist`() { + val userState: UserStateJson = mockk { + every { activeUserId } returns USER_ID + } + every { authDiskSource.userState } returns userState + every { authDiskSource.getOrganizations(USER_ID) } returns emptyList() + every { authDiskSource.getPolicies(USER_ID) } returns emptyList() + + assertNull(policyManager.getPersonalOwnershipPolicyOrganizationId()) + } + + @Test + fun `getPersonalOwnershipPolicyOrganizationId returns null when policy is disabled`() { + val userState: UserStateJson = mockk { + every { activeUserId } returns USER_ID + } + every { authDiskSource.userState } returns userState + every { + authDiskSource.getOrganizations(USER_ID) + } returns listOf( + createMockOrganization( + number = 1, + isEnabled = true, + shouldUsePolicies = true, + type = OrganizationType.USER, + ), + ) + every { + authDiskSource.getPolicies(USER_ID) + } returns listOf( + createMockPolicy( + organizationId = "mockId-1", + isEnabled = false, + type = PolicyTypeJson.PERSONAL_OWNERSHIP, + ), + ) + + assertNull(policyManager.getPersonalOwnershipPolicyOrganizationId()) + } + + @Test + fun `getPersonalOwnershipPolicyOrganizationId returns organization id for single policy`() { + val userState: UserStateJson = mockk { + every { activeUserId } returns USER_ID + } + val expectedOrganizationId = "mockId-1" + every { authDiskSource.userState } returns userState + every { + authDiskSource.getOrganizations(USER_ID) + } returns listOf( + createMockOrganization( + number = 1, + isEnabled = true, + shouldUsePolicies = true, + type = OrganizationType.USER, + ), + ) + every { + authDiskSource.getPolicies(USER_ID) + } returns listOf( + createMockPolicy( + organizationId = expectedOrganizationId, + isEnabled = true, + type = PolicyTypeJson.PERSONAL_OWNERSHIP, + ), + ) + + assertEquals( + expectedOrganizationId, + policyManager.getPersonalOwnershipPolicyOrganizationId(), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `getPersonalOwnershipPolicyOrganizationId returns earliest policy when multiple orgs have policy`() { + val userState: UserStateJson = mockk { + every { activeUserId } returns USER_ID + } + val earliestRevisionDate = ZonedDateTime.parse("2024-01-01T00:00:00Z") + val middleRevisionDate = ZonedDateTime.parse("2024-06-01T00:00:00Z") + val latestRevisionDate = ZonedDateTime.parse("2024-12-01T00:00:00Z") + + val expectedOrganizationId = "mockId-1" + + every { authDiskSource.userState } returns userState + every { + authDiskSource.getOrganizations(USER_ID) + } returns listOf( + createMockOrganization( + number = 1, + isEnabled = true, + shouldUsePolicies = true, + type = OrganizationType.USER, + ), + createMockOrganization( + number = 2, + isEnabled = true, + shouldUsePolicies = true, + type = OrganizationType.USER, + ), + createMockOrganization( + number = 3, + isEnabled = true, + shouldUsePolicies = true, + type = OrganizationType.USER, + ), + ) + every { + authDiskSource.getPolicies(USER_ID) + } returns listOf( + createMockPolicy( + number = 3, + organizationId = "mockId-3", + isEnabled = true, + type = PolicyTypeJson.PERSONAL_OWNERSHIP, + revisionDate = latestRevisionDate, + ), + createMockPolicy( + number = 1, + organizationId = expectedOrganizationId, + isEnabled = true, + type = PolicyTypeJson.PERSONAL_OWNERSHIP, + revisionDate = earliestRevisionDate, + ), + createMockPolicy( + number = 2, + organizationId = "mockId-2", + isEnabled = true, + type = PolicyTypeJson.PERSONAL_OWNERSHIP, + revisionDate = middleRevisionDate, + ), + ) + + assertEquals( + expectedOrganizationId, + policyManager.getPersonalOwnershipPolicyOrganizationId(), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `getPersonalOwnershipPolicyOrganizationId filters out policies from organizations not using policies`() { + val userState: UserStateJson = mockk { + every { activeUserId } returns USER_ID + } + val earlierRevisionDate = ZonedDateTime.parse("2024-01-01T00:00:00Z") + val laterRevisionDate = ZonedDateTime.parse("2024-06-01T00:00:00Z") + val expectedOrganizationId = "mockId-2" + + every { authDiskSource.userState } returns userState + every { + authDiskSource.getOrganizations(USER_ID) + } returns listOf( + createMockOrganization( + number = 1, + isEnabled = true, + shouldUsePolicies = false, // This org does NOT use policies + type = OrganizationType.USER, + ), + createMockOrganization( + number = 2, + isEnabled = true, + shouldUsePolicies = true, // This org uses policies + type = OrganizationType.USER, + ), + ) + every { + authDiskSource.getPolicies(USER_ID) + } returns listOf( + createMockPolicy( + number = 1, + organizationId = "mockId-1", + isEnabled = true, + type = PolicyTypeJson.PERSONAL_OWNERSHIP, + revisionDate = earlierRevisionDate, // Earlier but org doesn't enforce + ), + createMockPolicy( + number = 2, + organizationId = expectedOrganizationId, + isEnabled = true, + type = PolicyTypeJson.PERSONAL_OWNERSHIP, + revisionDate = laterRevisionDate, + ), + ) + + // Should return mockId-2 because mockId-1's organization doesn't enforce policies + assertEquals( + expectedOrganizationId, + policyManager.getPersonalOwnershipPolicyOrganizationId(), + ) + } } private const val USER_ID = "userId" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt index d8ea30e139..964c51d09e 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt @@ -1390,6 +1390,100 @@ 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 /**