From da59d056fce9092ae423ac4471a482a3cc21a7cd Mon Sep 17 00:00:00 2001 From: Patrick Honkonen Date: Wed, 23 Oct 2024 13:47:55 -0400 Subject: [PATCH] [PM-12922] Disable delete if user can't manage collection Disables the delete button for items in collections where the user does not have "manage" permission. This change ensures that users cannot delete items from collections they are not authorized to manage. It updates the UI to reflect the user's permissions and prevents accidental or unauthorized deletions. --- .../feature/addedit/VaultAddEditScreen.kt | 2 +- .../feature/addedit/VaultAddEditViewModel.kt | 26 +- .../addedit/util/CipherViewExtensions.kt | 4 +- .../ui/vault/feature/item/VaultItemScreen.kt | 3 +- .../vault/feature/item/VaultItemViewModel.kt | 31 ++- .../feature/item/model/VaultItemStateData.kt | 1 + .../feature/item/util/CipherViewExtensions.kt | 4 +- .../sdk/model/CollectionViewUtil.kt | 8 +- .../addedit/VaultAddEditViewModelTest.kt | 140 ++++++++++ .../addedit/util/CipherViewExtensionsTest.kt | 7 + .../addedit/util/TotpDataExtensionsTest.kt | 4 +- .../vault/feature/item/VaultItemScreenTest.kt | 28 +- .../feature/item/VaultItemViewModelTest.kt | 243 ++++++++++++++++++ .../item/util/CipherViewExtensionsTest.kt | 13 + .../feature/item/util/VaultItemTestUtil.kt | 2 + 15 files changed, 502 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt index 2bf3d550c..23a778d30 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt @@ -314,7 +314,7 @@ fun VaultAddEditScreen( text = stringResource(id = R.string.delete), onClick = { pendingDeleteCipher = true }, ) - .takeUnless { state.isAddItemMode }, + .takeUnless { state.isAddItemMode || !state.canDelete }, ), ) }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt index 3c2e0b6e3..f6ddefd23 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt @@ -149,7 +149,9 @@ class VaultAddEditViewModel @Inject constructor( attestationOptions = fido2AttestationOptions, isIndividualVaultDisabled = isIndividualVaultDisabled, ) - ?: totpData?.toDefaultAddTypeContent(isIndividualVaultDisabled) + ?: totpData?.toDefaultAddTypeContent( + isIndividualVaultDisabled = isIndividualVaultDisabled, + ) ?: VaultAddEditState.ViewState.Content( common = VaultAddEditState.ViewState.Content.Common(), isIndividualVaultDisabled = isIndividualVaultDisabled, @@ -1619,6 +1621,16 @@ class VaultAddEditViewModel @Inject constructor( currentAccount = userData?.activeAccount, vaultAddEditType = vaultAddEditType, ) { currentAccount, cipherView -> + val canDelete = vaultData.collectionViewList + .none { + val itemIsInCollection = cipherView + ?.collectionIds + ?.contains(it.id) == true + + val canManageCollection = it.manage + + itemIsInCollection && !canManageCollection + } // Derive the view state from the current Cipher for Edit mode // or use the current state for Add (cipherView @@ -1628,6 +1640,7 @@ class VaultAddEditViewModel @Inject constructor( totpData = totpData, resourceManager = resourceManager, clock = clock, + canDelete = canDelete, ) ?: viewState) .appendFolderAndOwnerData( @@ -2056,6 +2069,15 @@ data class VaultAddEditState( */ val isCloneMode: Boolean get() = vaultAddEditType is VaultAddEditType.CloneItem + /** + * Helper to determine if the UI should allow deletion of this item. + */ + val canDelete: Boolean + get() = (viewState as? ViewState.Content) + ?.common + ?.canDelete + ?: false + /** * Enum representing the main type options for the vault, such as LOGIN, CARD, etc. * @@ -2114,6 +2136,7 @@ data class VaultAddEditState( * @property availableFolders The list of folders that this item could be added too. * @property selectedOwnerId The ID of the owner associated with the item. * @property availableOwners A list of available owners. + * @property canDelete Indicates whether the current user can delete the item. */ @Parcelize data class Common( @@ -2129,6 +2152,7 @@ data class VaultAddEditState( val availableFolders: List = emptyList(), val selectedOwnerId: String? = null, val availableOwners: List = emptyList(), + val canDelete: Boolean = true, ) : Parcelable { /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt index 749e8e051..e9587a53c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt @@ -35,13 +35,14 @@ private const val PASSKEY_CREATION_TIME_PATTERN: String = "hh:mm a" /** * Transforms [CipherView] into [VaultAddEditState.ViewState]. */ -@Suppress("LongMethod") +@Suppress("LongMethod", "LongParameterList") fun CipherView.toViewState( isClone: Boolean, isIndividualVaultDisabled: Boolean, totpData: TotpData?, resourceManager: ResourceManager, clock: Clock, + canDelete: Boolean, ): VaultAddEditState.ViewState = VaultAddEditState.ViewState.Content( type = when (type) { @@ -107,6 +108,7 @@ fun CipherView.toViewState( notes = this.notes.orEmpty(), availableOwners = emptyList(), customFieldData = this.fields.orEmpty().map { it.toCustomField() }, + canDelete = canDelete, ), isIndividualVaultDisabled = isIndividualVaultDisabled, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt index 63011234f..c16a9a3de 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt @@ -226,7 +226,8 @@ fun VaultItemScreen( ) } }, - ), + ) + .takeIf { state.canDelete }, ), ) }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt index 0168b289d..91208b1b1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt @@ -82,7 +82,8 @@ class VaultItemViewModel @Inject constructor( vaultRepository.getVaultItemStateFlow(state.vaultItemId), authRepository.userStateFlow, vaultRepository.getAuthCodeFlow(state.vaultItemId), - ) { cipherViewState, userState, authCodeState -> + vaultRepository.collectionsStateFlow, + ) { cipherViewState, userState, authCodeState, collectionsState -> val totpCodeData = authCodeState.data?.let { TotpCodeItemData( periodSeconds = it.periodSeconds, @@ -96,14 +97,30 @@ class VaultItemViewModel @Inject constructor( vaultDataState = combineDataStates( cipherViewState, authCodeState, - ) { _, _ -> + collectionsState, + ) { _, _, _ -> // We are only combining the DataStates to know the overall state, // we map it to the appropriate value below. } .mapNullable { + // Deletion is not allowed when the item is in a collection that the user + // does not have "manage" permission for. + val canDelete = collectionsState.data + ?.none { + val itemIsInCollection = cipherViewState.data + ?.collectionIds + ?.contains(it.id) == true + + val canManageCollection = it.manage + + itemIsInCollection && !canManageCollection + } + ?: true + VaultItemStateData( cipher = cipherViewState.data, totpCodeItemData = totpCodeData, + canDelete = canDelete, ) }, ) @@ -899,6 +916,7 @@ class VaultItemViewModel @Inject constructor( isPremiumUser = account.isPremium, hasMasterPassword = account.hasMasterPassword, totpCodeItemData = this.data?.totpCodeItemData, + canDelete = this.data?.canDelete == true, ) ?: VaultItemState.ViewState.Error(message = errorText) @@ -1137,6 +1155,14 @@ data class VaultItemState( ?.isNotEmpty() ?: false + /** + * Whether or not the cipher can be deleted. + */ + val canDelete: Boolean + get() = viewState.asContentOrNull() + ?.common + ?.canDelete == true + /** * The text to display on the deletion confirmation dialog. */ @@ -1200,6 +1226,7 @@ data class VaultItemState( @IgnoredOnParcel val currentCipher: CipherView? = null, val attachments: List?, + val canDelete: Boolean, ) : Parcelable { /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/model/VaultItemStateData.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/model/VaultItemStateData.kt index 91d3ba2a5..1f7c9d43b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/model/VaultItemStateData.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/model/VaultItemStateData.kt @@ -11,4 +11,5 @@ import com.bitwarden.vault.CipherView data class VaultItemStateData( val cipher: CipherView?, val totpCodeItemData: TotpCodeItemData?, + val canDelete: Boolean, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt index e4c46f592..565f600a9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt @@ -32,13 +32,14 @@ private const val FIDO2_CREDENTIAL_CREATION_TIME_PATTERN: String = "h:mm a" /** * Transforms [VaultData] into [VaultState.ViewState]. */ -@Suppress("CyclomaticComplexMethod", "LongMethod") +@Suppress("CyclomaticComplexMethod", "LongMethod", "LongParameterList") fun CipherView.toViewState( previousState: VaultItemState.ViewState.Content?, isPremiumUser: Boolean, hasMasterPassword: Boolean, totpCodeItemData: TotpCodeItemData?, clock: Clock = Clock.systemDefaultZone(), + canDelete: Boolean, ): VaultItemState.ViewState = VaultItemState.ViewState.Content( common = VaultItemState.ViewState.Content.Common( @@ -79,6 +80,7 @@ fun CipherView.toViewState( } } .orEmpty(), + canDelete = canDelete, ), type = when (type) { CipherType.LOGIN -> { diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CollectionViewUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CollectionViewUtil.kt index 379ad075f..5d3e216f4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CollectionViewUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CollectionViewUtil.kt @@ -5,7 +5,11 @@ import com.bitwarden.vault.CollectionView /** * Create a mock [CollectionView] with a given [number]. */ -fun createMockCollectionView(number: Int, name: String? = null): CollectionView = +fun createMockCollectionView( + number: Int, + name: String? = null, + manage: Boolean = true, +): CollectionView = CollectionView( id = "mockId-$number", organizationId = "mockOrganizationId-$number", @@ -13,5 +17,5 @@ fun createMockCollectionView(number: Int, name: String? = null): CollectionView name = name ?: "mockName-$number", externalId = "mockExternalId-$number", readOnly = false, - manage = true, + manage = manage, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt index 4bd49bbd4..7300ee238 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt @@ -45,6 +45,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFido2CredentialList import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult @@ -1195,6 +1196,136 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `in edit mode, canDelete should be false when cipher is in a collection the user cannot manage`() = + runTest { + val cipherView = createMockCipherView(1) + val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID) + val stateWithName = createVaultAddItemState( + vaultAddEditType = vaultAddEditType, + commonContentViewState = createCommonContentViewState( + name = "mockName-1", + originalCipher = cipherView, + customFieldData = listOf( + VaultAddEditState.Custom.HiddenField( + itemId = "testId", + name = "mockName-1", + value = "mockValue-1", + ), + ), + notes = "mockNotes-1", + canDelete = false, + ), + ) + + every { + cipherView.toViewState( + isClone = false, + isIndividualVaultDisabled = false, + totpData = null, + resourceManager = resourceManager, + clock = fixedClock, + canDelete = false, + ) + } returns stateWithName.viewState + + mutableVaultDataFlow.value = DataState.Loaded( + data = createVaultData( + cipherView = cipherView, + collectionViewList = listOf( + createMockCollectionView( + number = 1, + manage = false, + ), + ), + ), + ) + + createAddVaultItemViewModel( + createSavedStateHandleWithState( + state = stateWithName, + vaultAddEditType = vaultAddEditType, + ), + ) + + verify { + cipherView.toViewState( + isClone = false, + isIndividualVaultDisabled = false, + totpData = null, + resourceManager = resourceManager, + clock = fixedClock, + canDelete = false, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `in edit mode, canDelete should be true when cipher is in a collection the user can manage`() = + runTest { + val cipherView = createMockCipherView(1) + val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID) + val stateWithName = createVaultAddItemState( + vaultAddEditType = vaultAddEditType, + commonContentViewState = createCommonContentViewState( + name = "mockName-1", + originalCipher = cipherView, + customFieldData = listOf( + VaultAddEditState.Custom.HiddenField( + itemId = "testId", + name = "mockName-1", + value = "mockValue-1", + ), + ), + notes = "mockNotes-1", + canDelete = true, + ), + ) + + every { + cipherView.toViewState( + isClone = false, + isIndividualVaultDisabled = false, + totpData = null, + resourceManager = resourceManager, + clock = fixedClock, + canDelete = true, + ) + } returns stateWithName.viewState + + mutableVaultDataFlow.value = DataState.Loaded( + data = createVaultData( + cipherView = cipherView, + collectionViewList = listOf( + createMockCollectionView( + number = 1, + manage = true, + ), + ), + ), + ) + + createAddVaultItemViewModel( + createSavedStateHandleWithState( + state = stateWithName, + vaultAddEditType = vaultAddEditType, + ), + ) + + verify { + cipherView.toViewState( + isClone = false, + isIndividualVaultDisabled = false, + totpData = null, + resourceManager = resourceManager, + clock = fixedClock, + canDelete = true, + ) + } + } + @Test fun `in edit mode, updateCipher success should ShowToast and NavigateBack`() = runTest { @@ -1310,6 +1441,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { totpData = null, resourceManager = resourceManager, clock = fixedClock, + canDelete = true, ) } returns stateWithName.viewState mutableVaultDataFlow.value = DataState.Loaded( @@ -1341,6 +1473,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { totpData = null, resourceManager = resourceManager, clock = fixedClock, + canDelete = true, ) vaultRepository.updateCipher(DEFAULT_EDIT_ITEM_ID, any()) } @@ -1375,6 +1508,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { totpData = null, resourceManager = resourceManager, clock = fixedClock, + canDelete = true, ) } returns stateWithName.viewState coEvery { @@ -1437,6 +1571,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { totpData = null, resourceManager = resourceManager, clock = fixedClock, + canDelete = true, ) } returns stateWithName.viewState coEvery { @@ -1502,6 +1637,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { totpData = null, resourceManager = resourceManager, clock = fixedClock, + canDelete = true, ) } returns stateWithName.viewState mutableVaultDataFlow.value = DataState.Loaded( @@ -1558,6 +1694,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { totpData = null, resourceManager = resourceManager, clock = fixedClock, + canDelete = true, ) } returns stateWithName.viewState every { fido2CredentialManager.isUserVerified } returns true @@ -1619,6 +1756,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { totpData = null, resourceManager = resourceManager, clock = fixedClock, + canDelete = true, ) } returns stateWithName.viewState every { fido2CredentialManager.isUserVerified } returns false @@ -4026,6 +4164,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { ), availableOwners: List = createOwnerList(), selectedOwnerId: String? = null, + canDelete: Boolean = true, ): VaultAddEditState.ViewState.Content.Common = VaultAddEditState.ViewState.Content.Common( name = name, @@ -4038,6 +4177,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { originalCipher = originalCipher, availableFolders = availableFolders, availableOwners = availableOwners, + canDelete = canDelete, ) @Suppress("LongParameterList") diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt index 02c551f71..074d32c2a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt @@ -75,6 +75,7 @@ class CipherViewExtensionsTest { totpData = null, resourceManager = resourceManager, clock = FIXED_CLOCK, + canDelete = true, ) assertEquals( @@ -121,6 +122,7 @@ class CipherViewExtensionsTest { totpData = null, resourceManager = resourceManager, clock = FIXED_CLOCK, + canDelete = true, ) assertEquals( @@ -172,6 +174,7 @@ class CipherViewExtensionsTest { totpData = null, resourceManager = resourceManager, clock = FIXED_CLOCK, + canDelete = true, ) assertEquals( @@ -232,6 +235,7 @@ class CipherViewExtensionsTest { totpData = mockk { every { uri } returns totp }, resourceManager = resourceManager, clock = FIXED_CLOCK, + canDelete = true, ) assertEquals( @@ -289,6 +293,7 @@ class CipherViewExtensionsTest { totpData = null, resourceManager = resourceManager, clock = FIXED_CLOCK, + canDelete = true, ) assertEquals( @@ -324,6 +329,7 @@ class CipherViewExtensionsTest { totpData = null, resourceManager = resourceManager, clock = FIXED_CLOCK, + canDelete = true, ) assertEquals( @@ -368,6 +374,7 @@ class CipherViewExtensionsTest { totpData = null, resourceManager = resourceManager, clock = FIXED_CLOCK, + canDelete = true, ) assertEquals( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/TotpDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/TotpDataExtensionsTest.kt index e17177fb3..d68bbcf29 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/TotpDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/TotpDataExtensionsTest.kt @@ -42,7 +42,9 @@ class TotpDataExtensionsTest { isIndividualVaultDisabled = false, type = VaultAddEditState.ViewState.Content.ItemType.Login(totp = uri), ), - totpData.toDefaultAddTypeContent(isIndividualVaultDisabled = false), + totpData.toDefaultAddTypeContent( + isIndividualVaultDisabled = false, + ), ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index 07f9dd129..3abfa91db 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -780,22 +780,39 @@ class VaultItemScreenTest : BaseComposeTest() { } @Test - fun `menu Delete option click should be displayed`() { - - // Confirm dropdown version of item is absent + fun `menu Delete option should be displayed based on state`() { + // Confirm overflow is closed on initial load composeTestRule .onAllNodesWithText("Delete") .filter(hasAnyAncestor(isPopup())) .assertCountEquals(0) + // Open the overflow menu composeTestRule .onNodeWithContentDescription("More") .performClick() - // Click on the delete item in the dropdown + + // Confirm Delete option is present + mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) } composeTestRule .onAllNodesWithText("Delete") .filterToOne(hasAnyAncestor(isPopup())) .assertIsDisplayed() + + // Confirm Delete option is not present when canDelete is false + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_LOGIN_VIEW_STATE + .copy( + common = DEFAULT_COMMON + .copy(canDelete = false), + ), + ) + } + composeTestRule + .onAllNodesWithText("Delete") + .filter(hasAnyAncestor(isPopup())) + .assertCountEquals(0) } @Test @@ -1166,6 +1183,7 @@ class VaultItemScreenTest : BaseComposeTest() { @Test fun `Menu should display correct items when cipher is not in a collection`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) } composeTestRule .onNodeWithContentDescription("More") .performClick() @@ -2350,6 +2368,7 @@ private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common = title = "test.mp4", ), ), + canDelete = true, ) private val DEFAULT_PASSKEY = R.string.created_xy.asText( @@ -2431,6 +2450,7 @@ private val EMPTY_COMMON: VaultItemState.ViewState.Content.Common = requiresReprompt = true, requiresCloneConfirmation = false, attachments = emptyList(), + canDelete = true, ) private val EMPTY_LOGIN_TYPE: VaultItemState.ViewState.Content.ItemType.Login = diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index be2bc9512..911f80edc 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -4,6 +4,7 @@ import android.net.Uri import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.vault.CipherView +import com.bitwarden.vault.CollectionView import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.repository.AuthRepository @@ -17,6 +18,7 @@ import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView import com.x8bit.bitwarden.data.vault.manager.FileManager import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem import com.x8bit.bitwarden.data.vault.repository.VaultRepository @@ -59,6 +61,8 @@ class VaultItemViewModelTest : BaseViewModelTest() { private val mutableAuthCodeItemFlow = MutableStateFlow>(DataState.Loading) private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE) + private val mutableCollectionsStateFlow = + MutableStateFlow>>(DataState.Loading) private val clipboardManager: BitwardenClipboardManager = mockk() private val authRepo: AuthRepository = mockk { @@ -67,6 +71,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { private val vaultRepo: VaultRepository = mockk { every { getAuthCodeFlow(VAULT_ITEM_ID) } returns mutableAuthCodeItemFlow every { getVaultItemStateFlow(VAULT_ITEM_ID) } returns mutableVaultItemFlow + every { collectionsStateFlow } returns mutableCollectionsStateFlow } private val mockFileManager: FileManager = mockk() @@ -151,6 +156,105 @@ class VaultItemViewModelTest : BaseViewModelTest() { assertEquals(initialState.copy(dialog = null), viewModel.stateFlow.value) } + @Test + fun `canDelete should be true when collections are empty`() = runTest { + val mockCipherView = mockk { + every { + toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = null, + canDelete = true, + ) + } returns DEFAULT_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + verify { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = null, + canDelete = true, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `canDelete should be false when cipher is in a collection that the user cannot manage`() = + runTest { + val mockCipherView = mockk { + every { collectionIds } returns listOf("mockId-1", "mockId-2") + every { + toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = null, + canDelete = false, + ) + } returns DEFAULT_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded( + data = listOf( + createMockCollectionView(number = 1) + .copy(manage = false), + createMockCollectionView(number = 2) + .copy(manage = true), + ), + ) + verify { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = null, + canDelete = false, + ) + } + } + + @Test + fun `canDelete should be true when cipher is not in collections`() { + val mockCipherView = mockk { + every { collectionIds } returns listOf("mockId-3") + every { + toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = null, + canDelete = true, + ) + } returns DEFAULT_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded( + data = listOf( + createMockCollectionView(number = 1) + .copy(manage = false), + createMockCollectionView(number = 2) + .copy(manage = false), + ), + ) + verify { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = null, + canDelete = true, + ) + } + } + @Test fun `DeleteClick should show password dialog when re-prompt is required`() = runTest { @@ -162,11 +266,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.Common.DeleteClick) @@ -185,6 +291,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } } @@ -204,6 +311,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns loginState } @@ -221,6 +329,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) viewModel.trySendAction(VaultItemAction.Common.DeleteClick) assertEquals(expected, viewModel.stateFlow.value) @@ -247,6 +356,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns loginState } @@ -260,6 +370,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) viewModel.trySendAction(VaultItemAction.Common.DeleteClick) assertEquals(expected, viewModel.stateFlow.value) @@ -279,12 +390,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val viewModel = createViewModel(state = DEFAULT_STATE) coEvery { @@ -322,11 +435,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val viewModel = createViewModel(state = DEFAULT_STATE) coEvery { @@ -368,12 +483,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val viewModel = createViewModel(state = DEFAULT_STATE) coEvery { @@ -406,11 +523,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) val viewModel = createViewModel(state = loginState) assertEquals(loginState, viewModel.stateFlow.value) @@ -439,12 +558,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } returns viewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = viewState) val viewModel = createViewModel(state = loginState) assertEquals(loginState, viewModel.stateFlow.value) @@ -476,6 +597,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } returns DEFAULT_VIEW_STATE } @@ -483,6 +605,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableAuthCodeItemFlow.value = DataState.Loaded( data = createVerificationCodeItem(), ) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val viewModel = createViewModel(state = DEFAULT_STATE) coEvery { @@ -516,11 +639,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val viewModel = createViewModel(state = DEFAULT_STATE) coEvery { @@ -565,11 +690,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) val viewModel = createViewModel(state = loginState) assertEquals(loginState, viewModel.stateFlow.value) @@ -597,11 +724,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = loginViewState) val viewModel = createViewModel(state = loginState) @@ -613,6 +742,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } assertEquals( @@ -638,6 +768,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = any(), isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState @@ -648,6 +779,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { } returns ValidatePasswordResult.Success(isValid = true) mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = loginViewState) val viewModel = createViewModel(state = loginState) @@ -699,6 +831,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = any(), isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState @@ -709,6 +842,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { } returns ValidatePasswordResult.Success(isValid = false) mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = loginViewState) val viewModel = createViewModel(state = loginState) @@ -752,6 +886,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = any(), isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState @@ -762,6 +897,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { } returns ValidatePasswordResult.Error mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = loginViewState) val viewModel = createViewModel(state = loginState) @@ -816,12 +952,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.Common.CopyCustomHiddenFieldClick("field")) @@ -839,6 +977,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } @@ -854,6 +993,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns createViewState(common = DEFAULT_COMMON.copy(requiresReprompt = false)) @@ -862,6 +1002,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) viewModel.trySendAction(VaultItemAction.Common.CopyCustomHiddenFieldClick(field)) @@ -871,6 +1012,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) organizationEventManager.trackEvent( @@ -910,12 +1052,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction( @@ -941,6 +1085,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } @@ -974,11 +1119,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction( @@ -1004,6 +1151,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) organizationEventManager.trackEvent( @@ -1024,12 +1172,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.Common.AttachmentsClick) @@ -1047,6 +1197,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } @@ -1066,12 +1217,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) @@ -1100,12 +1253,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) @@ -1125,6 +1280,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } @@ -1148,12 +1304,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.Common.CloneClick) @@ -1196,12 +1354,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.Common.CloneClick) @@ -1219,6 +1379,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } @@ -1238,12 +1399,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) @@ -1269,12 +1432,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.Common.MoveToOrganizationClick) @@ -1292,6 +1457,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } @@ -1311,12 +1477,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) @@ -1353,12 +1521,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = any(), isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = loginViewState) val viewModel = createViewModel(state = loginState) @@ -1409,12 +1579,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = any(), isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = loginViewState) val viewModel = createViewModel(state = loginState) @@ -1474,12 +1646,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = any(), isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = null, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = loginViewState) val viewModel = createViewModel(state = loginState) @@ -1652,6 +1826,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = createTotpCodeData(), ) } returns DEFAULT_VIEW_STATE @@ -1659,6 +1834,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) val breachCount = 5 @@ -1692,6 +1868,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = createTotpCodeData(), ) } @@ -1710,6 +1887,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = createTotpCodeData(), ) } returns DEFAULT_VIEW_STATE @@ -1717,6 +1895,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyPasswordClick) @@ -1736,6 +1915,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = createTotpCodeData(), ) } @@ -1750,6 +1930,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = createTotpCodeData(), ) } returns createViewState(common = DEFAULT_COMMON.copy(requiresReprompt = false)) @@ -1757,6 +1938,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) every { clipboardManager.setText(text = DEFAULT_LOGIN_PASSWORD) } just runs @@ -1768,6 +1950,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = createTotpCodeData(), ) } @@ -1782,6 +1965,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableAuthCodeItemFlow.value = DataState.Loaded( data = createVerificationCodeItem(), ) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyTotpClick) @@ -1808,6 +1992,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { previousState = null, isPremiumUser = true, hasMasterPassword = true, + canDelete = true, totpCodeItemData = createTotpCodeData(), ) } returns createViewState(common = DEFAULT_COMMON.copy(requiresReprompt = false)) @@ -1815,6 +2000,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { every { clipboardManager.setText(text = DEFAULT_LOGIN_USERNAME) } just runs mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyUsernameClick) @@ -1825,6 +2011,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } } @@ -1849,12 +2036,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.ItemType.Login.PasswordHistoryClick) @@ -1873,6 +2062,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } } @@ -1888,6 +2078,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } .returns( @@ -1899,6 +2090,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) viewModel.eventFlow.test { viewModel.trySendAction(VaultItemAction.ItemType.Login.PasswordHistoryClick) @@ -1914,6 +2106,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } } @@ -1930,12 +2123,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } returns DEFAULT_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction( @@ -1958,6 +2153,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } } @@ -1977,12 +2173,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction( @@ -2008,6 +2206,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), + canDelete = true, ) organizationEventManager.trackEvent( event = OrganizationEvent.CipherClientToggledPasswordVisible( @@ -2042,11 +2241,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns CARD_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(cardState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick) @@ -2067,6 +2268,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } } @@ -2081,6 +2283,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns createViewState( common = DEFAULT_COMMON.copy(requiresReprompt = false), @@ -2090,6 +2293,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { every { clipboardManager.setText(text = "12345436") } just runs mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick) @@ -2100,6 +2304,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } } @@ -2115,11 +2320,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns CARD_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(cardState, viewModel.stateFlow.value) viewModel.trySendAction( @@ -2140,6 +2347,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } } @@ -2154,6 +2362,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns createViewState( common = DEFAULT_COMMON.copy(requiresReprompt = false), @@ -2163,6 +2372,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { every { clipboardManager.setText(text = "12345436") } just runs mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) viewModel.trySendAction( VaultItemAction.ItemType.Card.NumberVisibilityClick(isVisible = true), @@ -2179,6 +2389,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } } @@ -2194,11 +2405,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns CARD_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(cardState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick) @@ -2219,6 +2432,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } } @@ -2233,6 +2447,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns createViewState( common = DEFAULT_COMMON.copy(requiresReprompt = false), @@ -2242,6 +2457,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { every { clipboardManager.setText(text = "987") } just runs mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick) @@ -2252,6 +2468,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } } @@ -2267,11 +2484,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns CARD_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(cardState, viewModel.stateFlow.value) viewModel.trySendAction( @@ -2292,6 +2511,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } } @@ -2306,6 +2526,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns createViewState( common = DEFAULT_COMMON.copy(requiresReprompt = false), @@ -2314,6 +2535,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { } every { clipboardManager.setText(text = "987") } just runs mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) viewModel.trySendAction( @@ -2331,6 +2553,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } } @@ -2364,11 +2587,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns SSH_KEY_VIEW_STATE } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(sshKeyState, viewModel.stateFlow.value) viewModel.trySendAction( @@ -2420,12 +2645,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns viewState } val viewModel = createViewModel(state = null) mutableVaultItemFlow.value = DataState.Loaded(data = cipherView) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(DEFAULT_STATE.copy(viewState = viewState), viewModel.stateFlow.value) } @@ -2435,6 +2662,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { val viewModel = createViewModel(state = null) mutableVaultItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals( DEFAULT_STATE.copy( @@ -2456,12 +2684,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns viewState } val viewModel = createViewModel(state = null) mutableVaultItemFlow.value = DataState.Pending(data = cipherView) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals(DEFAULT_STATE.copy(viewState = viewState), viewModel.stateFlow.value) } @@ -2472,6 +2702,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { val viewModel = createViewModel(state = null) mutableVaultItemFlow.value = DataState.Pending(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) assertEquals( DEFAULT_STATE.copy( @@ -2493,6 +2724,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns viewState } @@ -2529,6 +2761,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, hasMasterPassword = true, totpCodeItemData = null, + canDelete = true, ) } returns viewState } @@ -2562,6 +2795,15 @@ class VaultItemViewModelTest : BaseViewModelTest() { } } + @Nested + inner class CollectionsFlow { + @BeforeEach + fun setup() { + mutableUserStateFlow.value = DEFAULT_USER_STATE + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + } + } + @Suppress("LongParameterList") private fun createViewModel( state: VaultItemState?, @@ -2734,6 +2976,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { title = "test.mp4", ), ), + canDelete = true, ) private val DEFAULT_VIEW_STATE: VaultItemState.ViewState.Content = diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt index b2ab66549..05d86866a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt @@ -45,6 +45,7 @@ class CipherViewExtensionsTest { totpCode = "testCode", ), clock = fixedClock, + canDelete = true, ) assertEquals( @@ -80,6 +81,7 @@ class CipherViewExtensionsTest { totpCode = "testCode", ), clock = fixedClock, + canDelete = true, ) assertEquals( @@ -108,6 +110,7 @@ class CipherViewExtensionsTest { totpCode = "testCode", ), clock = fixedClock, + canDelete = true, ) assertEquals( @@ -136,6 +139,7 @@ class CipherViewExtensionsTest { totpCode = "testCode", ), clock = fixedClock, + canDelete = true, ) assertEquals( @@ -170,6 +174,7 @@ class CipherViewExtensionsTest { totpCode = "testCode", ), clock = fixedClock, + canDelete = true, ) assertEquals( @@ -194,6 +199,7 @@ class CipherViewExtensionsTest { hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, + canDelete = true, ) assertEquals( @@ -216,6 +222,7 @@ class CipherViewExtensionsTest { hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, + canDelete = true, ) assertEquals( @@ -237,6 +244,7 @@ class CipherViewExtensionsTest { hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, + canDelete = true, ) assertEquals( @@ -268,6 +276,7 @@ class CipherViewExtensionsTest { hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, + canDelete = true, ) assertEquals( @@ -304,6 +313,7 @@ class CipherViewExtensionsTest { hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, + canDelete = true, ) assertEquals( @@ -342,6 +352,7 @@ class CipherViewExtensionsTest { hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, + canDelete = true, ) assertEquals( @@ -364,6 +375,7 @@ class CipherViewExtensionsTest { hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, + canDelete = true, ) val expectedState = VaultItemState.ViewState.Content( @@ -384,6 +396,7 @@ class CipherViewExtensionsTest { hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, + canDelete = true, ) assertEquals( VaultItemState.ViewState.Content( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt index 6b0d0c0cb..5fb2db4fe 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt @@ -170,6 +170,7 @@ fun createCommonContent( requiresReprompt = true, requiresCloneConfirmation = false, attachments = emptyList(), + canDelete = true, ) } else { VaultItemState.ViewState.Content.Common( @@ -213,6 +214,7 @@ fun createCommonContent( title = "test.mp4", ), ), + canDelete = true, ) }