From e75d7844de7480e5def8db1debca5b678e5b274f Mon Sep 17 00:00:00 2001 From: David Perez Date: Fri, 18 Jul 2025 15:44:52 -0500 Subject: [PATCH] PM-23910: Disallow file sends for non-premium users (#5544) --- .../send/addedit/AddEditSendViewModel.kt | 17 +++++ .../itemlisting/VaultItemListingViewModel.kt | 25 +++++-- .../send/addedit/AddEditSendScreenTest.kt | 1 + .../send/addedit/AddEditSendViewModelTest.kt | 67 +++++++++++++++++++ .../VaultItemListingViewModelTest.kt | 46 ++++++++++++- 5 files changed, 149 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt index a094028f60..0a53d49c66 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt @@ -15,6 +15,7 @@ import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText import com.bitwarden.ui.util.concat import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager @@ -65,6 +66,7 @@ private const val MAX_FILE_SIZE_BYTES: Long = 100 * 1024 * 1024 @HiltViewModel class AddEditSendViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + authRepo: AuthRepository, private val clock: Clock, private val clipboardManager: BitwardenClipboardManager, private val environmentRepo: EnvironmentRepository, @@ -131,6 +133,7 @@ class AddEditSendViewModel @Inject constructor( policyDisablesSend = policyManager .getActivePolicies(type = PolicyTypeJson.DISABLE_SEND) .any(), + isPremium = authRepo.userStateFlow.value?.activeAccount?.isPremium == true, ) }, ) { @@ -499,6 +502,19 @@ class AddEditSendViewModel @Inject constructor( } (content.selectedType as? AddEditSendState.ViewState.Content.SendType.File) ?.let { fileType -> + if (!state.isPremium) { + // We should never get here without a premium account, but we do one last + // check just in case. + mutableStateFlow.update { + it.copy( + dialogState = AddEditSendState.DialogState.Error( + title = R.string.send.asText(), + message = R.string.send_file_premium_required.asText(), + ), + ) + } + return@onContent + } if (fileType.name.isNullOrBlank()) { mutableStateFlow.update { it.copy( @@ -686,6 +702,7 @@ data class AddEditSendState( val isShared: Boolean, val baseWebSendUrl: String, val policyDisablesSend: Boolean, + val isPremium: Boolean, ) : Parcelable { /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index b6cf668628..a9d70c040f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -838,11 +838,26 @@ class VaultItemListingViewModel @Inject constructor( } is VaultItemListingState.ItemListingType.Send -> { - sendEvent( - VaultItemListingEvent.NavigateToAddSendItem( - sendType = itemListingType.toSendItemType(), - ), - ) + when (val sendType = itemListingType.toSendItemType()) { + SendItemType.FILE -> { + if (state.isPremium) { + sendEvent(VaultItemListingEvent.NavigateToAddSendItem(sendType)) + } else { + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.Error( + title = R.string.send.asText(), + message = R.string.send_file_premium_required.asText(), + ), + ) + } + } + } + + SendItemType.TEXT -> { + sendEvent(VaultItemListingEvent.NavigateToAddSendItem(sendType)) + } + } } } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt index b63e539323..106b55f07d 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt @@ -881,4 +881,5 @@ private val DEFAULT_STATE = AddEditSendState( baseWebSendUrl = "https://vault.bitwarden.com/#/send/", policyDisablesSend = false, sendType = SendItemType.TEXT, + isPremium = true, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt index 62fa94dd17..7e43ebeb71 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt @@ -12,10 +12,14 @@ import com.bitwarden.ui.platform.base.BaseViewModelTest import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus +import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation +import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager +import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView @@ -66,6 +70,10 @@ class AddEditSendViewModelTest : BaseViewModelTest() { private val clipboardManager: BitwardenClipboardManager = mockk { every { setText(any(), toastDescriptorOverride = any()) } just runs } + private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE) + private val authRepository = mockk { + every { userStateFlow } returns mutableUserStateFlow + } private val environmentRepository: EnvironmentRepository = mockk { every { environment } returns Environment.Us } @@ -386,6 +394,38 @@ class AddEditSendViewModelTest : BaseViewModelTest() { ) } + @Test + fun `SaveClick for file without premium show error dialog`() { + mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( + accounts = listOf(DEFAULT_ACCOUNT.copy(isPremium = false)), + ) + val initialState = DEFAULT_STATE.copy( + isPremium = false, + viewState = DEFAULT_VIEW_STATE.copy( + common = DEFAULT_COMMON_STATE.copy(name = "test"), + selectedType = AddEditSendState.ViewState.Content.SendType.File( + uri = null, + name = null, + displaySize = null, + sizeBytes = null, + ), + ), + ) + val viewModel = createViewModel(initialState) + + viewModel.trySendAction(AddEditSendAction.SaveClick) + + assertEquals( + initialState.copy( + dialogState = AddEditSendState.DialogState.Error( + title = R.string.send.asText(), + message = R.string.send_file_premium_required.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + @Test fun `SaveClick with file missing should show error dialog`() { val initialState = DEFAULT_STATE.copy( @@ -966,6 +1006,7 @@ class AddEditSendViewModelTest : BaseViewModelTest() { toAddEditSendArgs() } returns AddEditSendArgs(sendType = sendType, addEditSendType = addEditSendType) }, + authRepo = authRepository, environmentRepo = environmentRepository, specialCircumstanceManager = specialCircumstanceManager, clock = clock, @@ -1013,4 +1054,30 @@ private val DEFAULT_STATE = AddEditSendState( baseWebSendUrl = DEFAULT_ENVIRONMENT_URL, policyDisablesSend = false, sendType = SendItemType.TEXT, + isPremium = true, +) + +private val DEFAULT_ACCOUNT = UserState.Account( + userId = "activeUserId", + name = "Active User", + email = "active@bitwarden.com", + environment = Environment.Us, + avatarColorHex = "#aa00aa", + isPremium = true, + isLoggedIn = true, + isVaultUnlocked = true, + needsPasswordReset = false, + isBiometricsEnabled = false, + organizations = emptyList(), + needsMasterPassword = false, + trustedDevice = null, + hasMasterPassword = true, + isUsingKeyConnector = false, + onboardingStatus = OnboardingStatus.COMPLETE, + firstTimeState = FirstTimeState(showImportLoginsCard = true), +) + +private val DEFAULT_USER_STATE = UserState( + activeUserId = "activeUserId", + accounts = listOf(DEFAULT_ACCOUNT), ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index a371715e35..d0da8a56bd 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -1422,7 +1422,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } @Test - fun `AddVaultItemClick for send item should emit NavigateToAddVaultItem`() = runTest { + fun `AddVaultItemClick for text send item should emit NavigateToAddVaultItem`() = runTest { val viewModel = createVaultItemListingViewModel( createSavedStateHandleWithVaultItemListingType(VaultItemListingType.SendText), ) @@ -1435,6 +1435,47 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } + @Test + fun `AddVaultItemClick for file send item with premium should emit NavigateToAddVaultItem`() = + runTest { + val viewModel = createVaultItemListingViewModel( + createSavedStateHandleWithVaultItemListingType(VaultItemListingType.SendFile), + ) + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemListingsAction.AddVaultItemClick) + assertEquals( + VaultItemListingEvent.NavigateToAddSendItem(sendType = SendItemType.FILE), + awaitItem(), + ) + } + } + + @Test + fun `AddVaultItemClick for file send item without premium should display error dialog`() = + runTest { + mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( + accounts = listOf(DEFAULT_ACCOUNT.copy(isPremium = false)), + ) + val viewModel = createVaultItemListingViewModel( + createSavedStateHandleWithVaultItemListingType(VaultItemListingType.SendFile), + ) + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemListingsAction.AddVaultItemClick) + expectNoEvents() + } + assertEquals( + createVaultItemListingState( + itemListingType = VaultItemListingState.ItemListingType.Send.SendFile, + dialogState = VaultItemListingState.DialogState.Error( + title = R.string.send.asText(), + message = R.string.send_file_premium_required.asText(), + ), + isPremium = false, + ), + viewModel.stateFlow.value, + ) + } + @Test fun `ItemTypeToAddSelected sends NavigateToAddFolder for folder selection`() = runTest { val viewModel = createVaultItemListingViewModel( @@ -5331,6 +5372,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { itemListingType: VaultItemListingState.ItemListingType = VaultItemListingState.ItemListingType.Vault.Login, viewState: VaultItemListingState.ViewState = VaultItemListingState.ViewState.Loading, dialogState: VaultItemListingState.DialogState? = null, + isPremium: Boolean = true, ): VaultItemListingState = VaultItemListingState( itemListingType = itemListingType, @@ -5348,7 +5390,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { policyDisablesSend = false, hasMasterPassword = true, createCredentialRequest = null, - isPremium = true, + isPremium = isPremium, isRefreshing = false, restrictItemTypesPolicyOrgIds = persistentListOf(), )