[PM-40528] feat: Wire SendControls precedence into DisableSend/SendOptions enforcement points (#7239)

This commit is contained in:
André Bispo
2026-08-11 13:22:34 +00:00
committed by GitHub
parent 9d7979a501
commit f92ae63c2c
13 changed files with 685 additions and 68 deletions
@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.manager
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.x8bit.bitwarden.data.platform.manager.model.EffectiveSendPolicy
import kotlinx.coroutines.flow.Flow
/**
@@ -18,6 +19,19 @@ interface PolicyManager {
*/
fun getActivePolicies(type: PolicyType): List<PolicyView>
/**
* Returns the current, precedence-resolved [EffectiveSendPolicy] for the active user. When
* the `pm-31885-send-controls` feature flag is disabled, or no organization has an active
* SendControls policy, this is equivalent to today's DisableSend/SendOptions logic.
*/
fun getEffectiveSendPolicy(): EffectiveSendPolicy
/**
* Returns a flow that emits the current [EffectiveSendPolicy] for the active user whenever
* the underlying policies or the `pm-31885-send-controls` feature flag change.
*/
fun getEffectiveSendPolicyFlow(): Flow<EffectiveSendPolicy>
/**
* Get all the policies of the given [type] that are enabled and applicable to the [userId].
*/
@@ -8,7 +8,10 @@ import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.platform.manager.model.EffectiveSendPolicy
import com.x8bit.bitwarden.data.vault.repository.util.toSdkOrganizationPolicyContext
import com.x8bit.bitwarden.data.vault.repository.util.toSdkPolicyViews
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -47,6 +50,30 @@ class PolicyManagerImpl(
?.let { userId -> getUserPolicies(userId = userId, type = type) }
.orEmpty()
override fun getEffectiveSendPolicy(): EffectiveSendPolicy =
resolveEffectiveSendPolicy(
disableSendPolicies = getActivePolicies(type = PolicyType.DISABLE_SEND),
isSendControlsEnabled = featureFlagManager.getFeatureFlag(key = FlagKey.SendControls),
sendControlsPolicies = getActivePolicies(type = PolicyType.SEND_CONTROLS),
sendOptionsPolicies = getActivePolicies(type = PolicyType.SEND_OPTIONS),
)
override fun getEffectiveSendPolicyFlow(): Flow<EffectiveSendPolicy> =
combine(
featureFlagManager.getFeatureFlagFlow(key = FlagKey.SendControls),
getActivePoliciesFlow(type = PolicyType.SEND_CONTROLS),
getActivePoliciesFlow(type = PolicyType.DISABLE_SEND),
getActivePoliciesFlow(type = PolicyType.SEND_OPTIONS),
) { isSendControlsEnabled, sendControlsPolicies, disableSendPolicies, sendOptionsPolicies ->
resolveEffectiveSendPolicy(
disableSendPolicies = disableSendPolicies,
isSendControlsEnabled = isSendControlsEnabled,
sendControlsPolicies = sendControlsPolicies,
sendOptionsPolicies = sendOptionsPolicies,
)
}
.distinctUntilChanged()
override fun getUserPolicies(
userId: String,
type: PolicyType,
@@ -170,6 +197,62 @@ class PolicyManagerImpl(
this.organizationShouldUsePolicies
}
}
/**
* Resolves the [EffectiveSendPolicy] from the raw, already-filtered active policies of each
* relevant type. When [isSendControlsEnabled] is `false`, this is equivalent to the legacy
* DisableSend/SendOptions logic. When `true`, an organization's active SendControls policy
* (if any) takes precedence over that same organization's legacy DisableSend/SendOptions
* policies, while other organizations' legacy policies remain in effect.
*/
private fun resolveEffectiveSendPolicy(
disableSendPolicies: List<PolicyView>,
isSendControlsEnabled: Boolean,
sendControlsPolicies: List<PolicyView>,
sendOptionsPolicies: List<PolicyView>,
): EffectiveSendPolicy {
if (!isSendControlsEnabled) {
return EffectiveSendPolicy(
allowedDomains = null,
allowedSendTypes = null,
deletionHours = null,
disableHideEmail = sendOptionsPolicies
.mapNotNull { it.policyInformation as? PolicyInformation.SendOptions }
.any { it.shouldDisableHideEmail ?: false },
disableSend = disableSendPolicies.any(),
whoCanAccess = null,
)
}
val decodedSendControls = sendControlsPolicies
// Sorted so that the passthrough fields below are sourced deterministically when
// multiple organizations have an active SendControls policy.
.sortedBy { it.revisionDate }
.mapNotNull { policy ->
(policy.policyInformation as? PolicyInformation.SendControls)
?.let { policy.organizationId to it }
}
val organizationIdsWithSendControls = decodedSendControls.map { it.first }.toSet()
val remainingDisableSendPolicies = disableSendPolicies
.filterNot { organizationIdsWithSendControls.contains(it.organizationId) }
val remainingSendOptions = sendOptionsPolicies
.filterNot { organizationIdsWithSendControls.contains(it.organizationId) }
.mapNotNull { it.policyInformation as? PolicyInformation.SendOptions }
val firstSendControls = decodedSendControls.firstOrNull()?.second
return EffectiveSendPolicy(
allowedDomains = firstSendControls?.allowedDomains,
allowedSendTypes = firstSendControls?.allowedSendTypes,
deletionHours = firstSendControls?.deletionHours,
disableHideEmail = decodedSendControls.any { it.second.disableHideEmail == true } ||
remainingSendOptions.any { it.shouldDisableHideEmail ?: false },
disableSend = decodedSendControls.any { it.second.disableSend == true } ||
remainingDisableSendPolicies.any(),
whoCanAccess = firstSendControls?.whoCanAccess,
)
}
}
private data class OrganizationPolicyData(
@@ -0,0 +1,35 @@
package com.x8bit.bitwarden.data.platform.manager.model
import com.bitwarden.network.model.SendAccessTypeJson
import com.bitwarden.network.model.SendTypeJson
/**
* The effective, precedence-resolved Send policy for the active user, combining the legacy
* DisableSend/SendOptions policies with the newer SendControls policy (type 21) per the
* `pm-31885-send-controls` feature flag.
*
* When an organization has an active SendControls policy, that organization's legacy
* DisableSend/SendOptions policies are ignored in favor of the SendControls values; other
* organizations' legacy policies remain in effect.
*
* @property allowedDomains The allowed recipient email domains, sourced from whichever
* organization's active SendControls policy has the earliest revision date, if any. Currently
* unused by the UI.
* @property allowedSendTypes The types of Sends that are allowed to be created, sourced the same
* way as [allowedDomains]. Currently unused by the UI.
* @property deletionHours The enforced number of hours until a Send is deleted, sourced the same
* way as [allowedDomains]. Currently unused by the UI.
* @property disableHideEmail Whether the ability to hide one's email address on a Send should be
* disabled.
* @property disableSend Whether the ability to create and edit Sends should be disabled.
* @property whoCanAccess The access type Sends are restricted to, sourced the same way as
* [allowedDomains]. Currently unused by the UI.
*/
data class EffectiveSendPolicy(
val allowedDomains: String?,
val allowedSendTypes: List<SendTypeJson>?,
val deletionHours: Int?,
val disableHideEmail: Boolean,
val disableSend: Boolean,
val whoCanAccess: SendAccessTypeJson?,
)
@@ -2,7 +2,6 @@ package com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar
import androidx.annotation.StringRes
import androidx.lifecycle.viewModelScope
import com.bitwarden.policies.PolicyType
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.base.DeferredBackgroundEvent
import com.bitwarden.ui.platform.resource.BitwardenString
@@ -35,9 +34,7 @@ class VaultUnlockedNavBarViewModel @Inject constructor(
notificationState = VaultUnlockedNavBarNotificationState(
settingsTabNotificationCount = firstTimeActionManager.allSettingsBadgeCountFlow.value,
),
areSendsDisabled = policyManager
.getActivePolicies(type = PolicyType.DISABLE_SEND)
.any(),
areSendsDisabled = policyManager.getEffectiveSendPolicy().disableSend,
),
) {
init {
@@ -56,8 +53,8 @@ class VaultUnlockedNavBarViewModel @Inject constructor(
.launchIn(viewModelScope)
policyManager
.getActivePoliciesFlow(type = PolicyType.DISABLE_SEND)
.map { VaultUnlockedNavBarAction.Internal.SendPolicyUpdateReceive(it.any()) }
.getEffectiveSendPolicyFlow()
.map { VaultUnlockedNavBarAction.Internal.SendPolicyUpdateReceive(it.disableSend) }
.onEach(::sendAction)
.launchIn(viewModelScope)
@@ -6,7 +6,6 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.data.repository.util.baseWebSendUrl
import com.bitwarden.policies.PolicyType
import com.bitwarden.ui.platform.base.BackgroundEvent
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.components.icon.model.IconData
@@ -69,9 +68,7 @@ class SendViewModel @Inject constructor(
viewState = SendState.ViewState.Loading,
dialogState = null,
isPullToRefreshSettingEnabled = settingsRepo.getPullToRefreshEnabledFlow().value,
policyDisablesSend = policyManager
.getActivePolicies(type = PolicyType.DISABLE_SEND)
.any(),
policyDisablesSend = policyManager.getEffectiveSendPolicy().disableSend,
isRefreshing = false,
isPremiumUser = authRepo.userStateFlow.value?.activeAccount?.isPremium == true,
isUpgradedToPremiumCardEligible = false,
@@ -85,8 +82,8 @@ class SendViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
policyManager
.getActivePoliciesFlow(type = PolicyType.DISABLE_SEND)
.map { SendAction.Internal.PolicyUpdateReceive(it.any()) }
.getEffectiveSendPolicyFlow()
.map { SendAction.Internal.PolicyUpdateReceive(it.disableSend) }
.onEach(::sendAction)
.launchIn(viewModelScope)
vaultRepo
@@ -8,7 +8,8 @@ import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.repository.util.takeUntilLoaded
import com.bitwarden.data.repository.util.baseWebSendUrl
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.policies.PolicyType
import com.bitwarden.network.model.SendAccessTypeJson
import com.bitwarden.network.model.SendTypeJson
import com.bitwarden.send.SendView
import com.bitwarden.ui.platform.base.BackgroundEvent
import com.bitwarden.ui.platform.base.BaseViewModel
@@ -21,13 +22,12 @@ import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.bitwarden.ui.util.concat
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager
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.EffectiveSendPolicy
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult
@@ -98,6 +98,7 @@ class AddEditSendViewModel @Inject constructor(
val args = savedStateHandle.toAddEditSendArgs()
val sendType = args.sendType
val addEditSendType = args.addEditSendType
val effectiveSendPolicy = policyManager.getEffectiveSendPolicy()
AddEditSendState(
sendType = sendType,
@@ -114,9 +115,7 @@ class AddEditSendViewModel @Inject constructor(
noteInput = "",
isHideEmailChecked = false,
isDeactivateChecked = false,
isHideEmailAddressEnabled = !policyManager
.getActivePolicies<PolicyInformation.SendOptions>()
.any { it.shouldDisableHideEmail ?: false },
isHideEmailAddressEnabled = !effectiveSendPolicy.disableHideEmail,
deletionDate = clock
.instant()
.plus(@Suppress("MagicNumber") 7, ChronoUnit.DAYS),
@@ -148,9 +147,11 @@ class AddEditSendViewModel @Inject constructor(
},
dialogState = null,
baseWebSendUrl = environmentRepo.environment.baseWebSendUrl,
policyDisablesSend = policyManager
.getActivePolicies(type = PolicyType.DISABLE_SEND)
.any(),
policyDisablesSend = effectiveSendPolicy.disableSend,
allowedDomains = effectiveSendPolicy.allowedDomains,
allowedSendTypes = effectiveSendPolicy.allowedSendTypes,
deletionHours = effectiveSendPolicy.deletionHours,
whoCanAccess = effectiveSendPolicy.whoCanAccess,
isPremium = authRepo.userStateFlow.value?.activeAccount?.isPremium == true,
)
},
@@ -825,9 +826,7 @@ class AddEditSendViewModel @Inject constructor(
}
private val isHideEmailAddressEnabled: Boolean
get() = !policyManager
.getActivePolicies<PolicyInformation.SendOptions>()
.any { it.shouldDisableHideEmail ?: false }
get() = !policyManager.getEffectiveSendPolicy().disableHideEmail
private inline fun onContent(
crossinline block: (AddEditSendState.ViewState.Content) -> Unit,
@@ -886,6 +885,15 @@ class AddEditSendViewModel @Inject constructor(
/**
* Models state for the add/edit send screen.
*
* @property allowedDomains The allowed recipient email domains, sourced from
* [EffectiveSendPolicy.allowedDomains]. Currently unused by the UI.
* @property allowedSendTypes The types of Sends that are allowed to be created, sourced from
* [EffectiveSendPolicy.allowedSendTypes]. Currently unused by the UI.
* @property deletionHours The enforced Send deletion window in hours, sourced from
* [EffectiveSendPolicy.deletionHours]. Currently unused by the UI.
* @property whoCanAccess The access type Sends are restricted to, sourced from
* [EffectiveSendPolicy.whoCanAccess]. Currently unused by the UI.
*/
@Parcelize
data class AddEditSendState(
@@ -897,6 +905,10 @@ data class AddEditSendState(
val isShared: Boolean,
val baseWebSendUrl: String,
val policyDisablesSend: Boolean,
val allowedDomains: String?,
val allowedSendTypes: List<SendTypeJson>?,
val deletionHours: Int?,
val whoCanAccess: SendAccessTypeJson?,
val isPremium: Boolean,
) : Parcelable {
@@ -184,9 +184,7 @@ class VaultItemListingViewModel @Inject constructor(
?.let {
VaultItemListingState.DialogState.Loading(BitwardenString.loading.asText())
},
policyDisablesSend = policyManager
.getActivePolicies(type = PolicyType.DISABLE_SEND)
.any(),
policyDisablesSend = policyManager.getEffectiveSendPolicy().disableSend,
restrictItemTypesPolicyOrgIds = persistentListOf(),
autofillSelectionData = specialCircumstance?.toAutofillSelectionDataOrNull(),
hasMasterPassword = userState.activeAccount.hasMasterPassword,
@@ -214,8 +212,8 @@ class VaultItemListingViewModel @Inject constructor(
.launchIn(viewModelScope)
policyManager
.getActivePoliciesFlow(type = PolicyType.DISABLE_SEND)
.map { VaultItemListingsAction.Internal.PolicyUpdateReceive(it.any()) }
.getEffectiveSendPolicyFlow()
.map { VaultItemListingsAction.Internal.PolicyUpdateReceive(it.disableSend) }
.onEach(::sendAction)
.launchIn(viewModelScope)
@@ -6,6 +6,7 @@ import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.network.model.OrganizationStatusType
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SendAccessTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.createMockOrganizationNetwork
import com.bitwarden.network.model.createMockPolicy
@@ -14,6 +15,7 @@ import com.bitwarden.policies.PolicyView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.platform.manager.model.EffectiveSendPolicy
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPolicyView
import io.mockk.every
import io.mockk.mockk
@@ -38,6 +40,7 @@ class PolicyManagerTest {
}
private val authSdkSource: AuthSdkSource = mockk()
private val mutablePoliciesInAcceptedStateFlagFlow = MutableStateFlow(true)
private val mutableSendControlsFlagFlow = MutableStateFlow(false)
private val featureFlagManager: FeatureFlagManager = mockk {
every {
getFeatureFlagFlow(key = FlagKey.PoliciesInAcceptedState)
@@ -45,6 +48,12 @@ class PolicyManagerTest {
every { getFeatureFlag(key = FlagKey.PoliciesInAcceptedState) } answers {
mutablePoliciesInAcceptedStateFlagFlow.value
}
every {
getFeatureFlagFlow(key = FlagKey.SendControls)
} returns mutableSendControlsFlagFlow
every { getFeatureFlag(key = FlagKey.SendControls) } answers {
mutableSendControlsFlagFlow.value
}
}
private val policyManager: PolicyManager = PolicyManagerImpl(
@@ -724,6 +733,454 @@ class PolicyManagerTest {
policyManager.getPersonalOwnershipPolicyOrganizationId(),
)
}
@Test
fun `getEffectiveSendPolicy should mirror legacy DisableSend policy when flag is off`() {
setUpSendPolicies(
disableSendPolicies = listOf(
createMockPolicyView(organizationId = "mockId-1", enabled = true),
),
)
assertEquals(
EffectiveSendPolicy(
allowedDomains = null,
allowedSendTypes = null,
deletionHours = null,
disableHideEmail = false,
disableSend = true,
whoCanAccess = null,
),
policyManager.getEffectiveSendPolicy(),
)
}
@Test
fun `getEffectiveSendPolicy should mirror legacy SendOptions policy when flag is off`() {
setUpSendPolicies(
sendOptionsPolicies = listOf(
createMockPolicyView(
organizationId = "mockId-1",
enabled = true,
type = PolicyType.SEND_OPTIONS,
data = """{"disableHideEmail":true}""",
),
),
)
assertEquals(
EffectiveSendPolicy(
allowedDomains = null,
allowedSendTypes = null,
deletionHours = null,
disableHideEmail = true,
disableSend = false,
whoCanAccess = null,
),
policyManager.getEffectiveSendPolicy(),
)
}
@Test
fun `getEffectiveSendPolicy should ignore an active SendControls policy when flag is off`() {
setUpSendPolicies(
sendControlsPolicies = listOf(
createMockPolicyView(
organizationId = "mockId-1",
enabled = true,
type = PolicyType.SEND_CONTROLS,
data = """{"disableSend":false}""",
),
),
disableSendPolicies = listOf(
createMockPolicyView(organizationId = "mockId-1", enabled = true),
),
)
assertEquals(
EffectiveSendPolicy(
allowedDomains = null,
allowedSendTypes = null,
deletionHours = null,
disableHideEmail = false,
disableSend = true,
whoCanAccess = null,
),
policyManager.getEffectiveSendPolicy(),
)
}
@Suppress("MaxLineLength")
@Test
fun `getEffectiveSendPolicy should use SendControls for an org that has both policy types when flag is on`() {
mutableSendControlsFlagFlow.value = true
setUpSendPolicies(
sendControlsPolicies = listOf(
createMockPolicyView(
organizationId = "mockId-1",
enabled = true,
type = PolicyType.SEND_CONTROLS,
data = """{"disableSend":false}""",
),
),
disableSendPolicies = listOf(
createMockPolicyView(organizationId = "mockId-1", enabled = true),
),
)
assertEquals(
EffectiveSendPolicy(
allowedDomains = null,
allowedSendTypes = null,
deletionHours = null,
disableHideEmail = false,
disableSend = false,
whoCanAccess = null,
),
policyManager.getEffectiveSendPolicy(),
)
}
@Suppress("MaxLineLength")
@Test
fun `getEffectiveSendPolicy should still apply legacy DisableSend for an org without SendControls when flag is on`() {
mutableSendControlsFlagFlow.value = true
setUpSendPolicies(
sendControlsPolicies = listOf(
createMockPolicyView(
organizationId = "mockId-1",
enabled = true,
type = PolicyType.SEND_CONTROLS,
data = """{"disableSend":false}""",
),
),
disableSendPolicies = listOf(
// mockId-1 has SendControls (exclusion applies), mockId-2 does not.
createMockPolicyView(organizationId = "mockId-1", enabled = true),
createMockPolicyView(organizationId = "mockId-2", enabled = true),
),
)
assertEquals(
EffectiveSendPolicy(
allowedDomains = null,
allowedSendTypes = null,
deletionHours = null,
disableHideEmail = false,
disableSend = true,
whoCanAccess = null,
),
policyManager.getEffectiveSendPolicy(),
)
}
@Suppress("MaxLineLength")
@Test
fun `getEffectiveSendPolicy should OR disableHideEmail across SendControls and remaining legacy orgs`() {
mutableSendControlsFlagFlow.value = true
setUpSendPolicies(
sendControlsPolicies = listOf(
createMockPolicyView(
organizationId = "mockId-1",
enabled = true,
type = PolicyType.SEND_CONTROLS,
data = """{"disableHideEmail":false}""",
),
),
sendOptionsPolicies = listOf(
createMockPolicyView(
organizationId = "mockId-2",
enabled = true,
type = PolicyType.SEND_OPTIONS,
data = """{"disableHideEmail":true}""",
),
),
)
assertEquals(
EffectiveSendPolicy(
allowedDomains = null,
allowedSendTypes = null,
deletionHours = null,
disableHideEmail = true,
disableSend = false,
whoCanAccess = null,
),
policyManager.getEffectiveSendPolicy(),
)
}
@Suppress("MaxLineLength")
@Test
fun `getEffectiveSendPolicy should source new fields from the earliest active SendControls policy`() {
mutableSendControlsFlagFlow.value = true
setUpSendPolicies(
sendControlsPolicies = listOf(
createMockPolicyView(
organizationId = "mockId-1",
enabled = true,
type = PolicyType.SEND_CONTROLS,
data = """
{
"whoCanAccess":2,
"allowedDomains":"bitwarden.com",
"deletionHours":24
}
""".trimIndent(),
),
),
)
assertEquals(
EffectiveSendPolicy(
allowedDomains = "bitwarden.com",
allowedSendTypes = null,
deletionHours = 24,
disableHideEmail = false,
disableSend = false,
whoCanAccess = SendAccessTypeJson.SPECIFIC_PEOPLE,
),
policyManager.getEffectiveSendPolicy(),
)
}
@Suppress("MaxLineLength")
@Test
fun `getEffectiveSendPolicy should source new fields by revision date rather than list order`() {
mutableSendControlsFlagFlow.value = true
setUpSendPolicies(
sendControlsPolicies = listOf(
// Listed first, but revised later, so it should lose to the policy below.
createMockPolicyView(
number = 2,
organizationId = "mockId-2",
enabled = true,
type = PolicyType.SEND_CONTROLS,
data = """{"allowedDomains":"later.example.com"}""",
revisionDate = Instant.parse("2026-06-01T00:00:00Z"),
),
createMockPolicyView(
number = 1,
organizationId = "mockId-1",
enabled = true,
type = PolicyType.SEND_CONTROLS,
data = """{"allowedDomains":"earlier.example.com"}""",
revisionDate = Instant.parse("2026-01-01T00:00:00Z"),
),
),
)
assertEquals(
"earlier.example.com",
policyManager.getEffectiveSendPolicy().allowedDomains,
)
}
@Suppress("MaxLineLength")
@Test
fun `getEffectiveSendPolicy should return safe defaults when no org has any Send-related policy`() {
mutableSendControlsFlagFlow.value = true
setUpSendPolicies()
assertEquals(
EffectiveSendPolicy(
allowedDomains = null,
allowedSendTypes = null,
deletionHours = null,
disableHideEmail = false,
disableSend = false,
whoCanAccess = null,
),
policyManager.getEffectiveSendPolicy(),
)
}
@Suppress("MaxLineLength")
@Test
fun `getEffectiveSendPolicyFlow should re-emit when the underlying policies change`() = runTest {
val userStateJson = mockk<UserStateJson> {
every { activeUserId } returns USER_ID
}
val organizations = createMockOrganizationNetwork(
number = 1,
isEnabled = true,
shouldUsePolicies = true,
)
every {
authSdkSource.filterPolicies(
policies = any(),
organizations = any(),
policyType = PolicyType.SEND_CONTROLS,
)
} returns emptyList<PolicyView>().asSuccess()
every {
authSdkSource.filterPolicies(
policies = any(),
organizations = any(),
policyType = PolicyType.SEND_OPTIONS,
)
} returns emptyList<PolicyView>().asSuccess()
every {
authSdkSource.filterPolicies(
policies = any(),
organizations = any(),
policyType = PolicyType.DISABLE_SEND,
)
} returnsMany listOf(
emptyList<PolicyView>().asSuccess(),
listOf(createMockPolicyView(organizationId = organizations.id, enabled = true))
.asSuccess(),
)
mutableUserStateFlow.value = userStateJson
mutableOrganizationsFlow.value = listOf(organizations)
mutablePolicyFlow.value = listOf(
createMockPolicy(organizationId = organizations.id, isEnabled = true),
)
val expectedPolicy = EffectiveSendPolicy(
allowedDomains = null,
allowedSendTypes = null,
deletionHours = null,
disableHideEmail = false,
disableSend = false,
whoCanAccess = null,
)
policyManager
.getEffectiveSendPolicyFlow()
.test {
assertEquals(expectedPolicy, awaitItem())
mutablePolicyFlow.value = listOf(
createMockPolicy(
number = 2,
organizationId = organizations.id,
isEnabled = true,
),
)
assertEquals(expectedPolicy.copy(disableSend = true), awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `getEffectiveSendPolicyFlow should re-emit when the SendControls feature flag changes`() =
runTest {
val userStateJson = mockk<UserStateJson> {
every { activeUserId } returns USER_ID
}
val organizations = createMockOrganizationNetwork(
number = 1,
isEnabled = true,
shouldUsePolicies = true,
)
every {
authSdkSource.filterPolicies(
policies = any(),
organizations = any(),
policyType = PolicyType.SEND_CONTROLS,
)
} returns listOf(
createMockPolicyView(
organizationId = organizations.id,
enabled = true,
type = PolicyType.SEND_CONTROLS,
data = """{"disableSend":false}""",
),
)
.asSuccess()
every {
authSdkSource.filterPolicies(
policies = any(),
organizations = any(),
policyType = PolicyType.DISABLE_SEND,
)
} returns listOf(
createMockPolicyView(organizationId = organizations.id, enabled = true),
)
.asSuccess()
every {
authSdkSource.filterPolicies(
policies = any(),
organizations = any(),
policyType = PolicyType.SEND_OPTIONS,
)
} returns emptyList<PolicyView>().asSuccess()
mutableUserStateFlow.value = userStateJson
mutableOrganizationsFlow.value = listOf(organizations)
mutablePolicyFlow.value = listOf(
createMockPolicy(organizationId = organizations.id, isEnabled = true),
)
val expectedPolicy = EffectiveSendPolicy(
allowedDomains = null,
allowedSendTypes = null,
deletionHours = null,
disableHideEmail = false,
disableSend = true,
whoCanAccess = null,
)
policyManager
.getEffectiveSendPolicyFlow()
.test {
// Flag off, so the organization's legacy DisableSend policy applies.
assertEquals(expectedPolicy, awaitItem())
mutableSendControlsFlagFlow.value = true
// Flag on, so the organization's SendControls policy supersedes its legacy one.
assertEquals(expectedPolicy.copy(disableSend = false), awaitItem())
}
}
/**
* Sets up the mocks required for [PolicyManagerImpl.getEffectiveSendPolicy] /
* [PolicyManagerImpl.getEffectiveSendPolicyFlow] to return, per policy type, the given lists
* of already-decoded [PolicyView]s.
*/
private fun setUpSendPolicies(
disableSendPolicies: List<PolicyView> = emptyList(),
sendControlsPolicies: List<PolicyView> = emptyList(),
sendOptionsPolicies: List<PolicyView> = emptyList(),
) {
val userState: UserStateJson = mockk {
every { activeUserId } returns USER_ID
}
every { authDiskSource.userState } returns userState
every {
authDiskSource.getOrganizations(USER_ID)
} returns listOf(
createMockOrganizationNetwork(number = 1, isEnabled = true, shouldUsePolicies = true),
)
every {
authDiskSource.getPolicies(USER_ID)
} returns listOf(createMockPolicy(organizationId = "mockId-1", isEnabled = true))
every {
authSdkSource.filterPolicies(
policies = any(),
organizations = any(),
policyType = PolicyType.SEND_CONTROLS,
)
} returns sendControlsPolicies.asSuccess()
every {
authSdkSource.filterPolicies(
policies = any(),
organizations = any(),
policyType = PolicyType.DISABLE_SEND,
)
} returns disableSendPolicies.asSuccess()
every {
authSdkSource.filterPolicies(
policies = any(),
organizations = any(),
policyType = PolicyType.SEND_OPTIONS,
)
} returns sendOptionsPolicies.asSuccess()
}
}
private const val USER_ID = "userId"
@@ -1,8 +1,6 @@
package com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar
import app.cash.turbine.test
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
@@ -10,8 +8,8 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.EffectiveSendPolicy
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPolicyView
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@@ -36,14 +34,10 @@ class VaultUnlockedNavBarViewModelTest : BaseViewModelTest() {
private val firstTimeActionManager: FirstTimeActionManager = mockk {
every { allSettingsBadgeCountFlow } returns mutableSettingsBadgeCountFlow
}
private val mutableDisableSendsPolicyFlow = MutableStateFlow<List<PolicyView>>(emptyList())
private val mutableEffectiveSendPolicyFlow = MutableStateFlow(DEFAULT_EFFECTIVE_SEND_POLICY)
private val policyManager: PolicyManager = mockk {
every {
getActivePoliciesFlow(PolicyType.DISABLE_SEND)
} returns mutableDisableSendsPolicyFlow
every {
getActivePolicies(PolicyType.DISABLE_SEND)
} answers { mutableDisableSendsPolicyFlow.value }
every { getEffectiveSendPolicyFlow() } returns mutableEffectiveSendPolicyFlow
every { getEffectiveSendPolicy() } answers { mutableEffectiveSendPolicyFlow.value }
}
@Suppress("MaxLineLength")
@@ -326,20 +320,19 @@ class VaultUnlockedNavBarViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `DISABLE_SEND policy flow update with disabled policy should set areSendsDisabled to false`() =
fun `effective send policy flow update should set areSendsDisabled to match disableSend`() =
runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(areSendsDisabled = false), awaitItem())
mutableDisableSendsPolicyFlow.emit(
listOf(createMockPolicyView(type = PolicyType.DISABLE_SEND)),
mutableEffectiveSendPolicyFlow.emit(
DEFAULT_EFFECTIVE_SEND_POLICY.copy(disableSend = true),
)
assertEquals(DEFAULT_STATE.copy(areSendsDisabled = true), awaitItem())
mutableDisableSendsPolicyFlow.emit(emptyList())
mutableEffectiveSendPolicyFlow.emit(DEFAULT_EFFECTIVE_SEND_POLICY)
assertEquals(DEFAULT_STATE.copy(areSendsDisabled = false), awaitItem())
}
}
@@ -362,3 +355,12 @@ private val DEFAULT_STATE = VaultUnlockedNavBarState(
notificationState = DEFAULT_NOTIFICATION_STATE,
areSendsDisabled = false,
)
private val DEFAULT_EFFECTIVE_SEND_POLICY = EffectiveSendPolicy(
allowedDomains = null,
allowedSendTypes = null,
deletionHours = null,
disableHideEmail = false,
disableSend = false,
whoCanAccess = null,
)
@@ -6,7 +6,6 @@ import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.data.repository.util.baseWebSendUrl
import com.bitwarden.policies.PolicyType
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
@@ -19,6 +18,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.model.EffectiveSendPolicy
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
@@ -73,8 +73,8 @@ class SendViewModelTest : BaseViewModelTest() {
every { sendDataStateFlow } returns mutableSendDataFlow
}
private val policyManager: PolicyManager = mockk {
every { getActivePolicies(type = PolicyType.DISABLE_SEND) } returns emptyList()
every { getActivePoliciesFlow(type = PolicyType.DISABLE_SEND) } returns emptyFlow()
every { getEffectiveSendPolicy() } returns DEFAULT_EFFECTIVE_SEND_POLICY
every { getEffectiveSendPolicyFlow() } returns emptyFlow()
}
private val networkConnectionManager: NetworkConnectionManager = mockk {
@@ -823,6 +823,15 @@ private val DEFAULT_STATE: SendState = SendState(
isPremiumUser = false,
)
private val DEFAULT_EFFECTIVE_SEND_POLICY = EffectiveSendPolicy(
allowedDomains = null,
allowedSendTypes = null,
deletionHours = null,
disableHideEmail = false,
disableSend = false,
whoCanAccess = null,
)
private val DEFAULT_USER_ACCOUNT_STATE = UserState.Account(
userId = "user_id_1",
name = "Bit",
@@ -1578,6 +1578,10 @@ private val DEFAULT_STATE = AddEditSendState(
isShared = false,
baseWebSendUrl = "https://vault.bitwarden.com/#/send/",
policyDisablesSend = false,
allowedDomains = null,
allowedSendTypes = null,
deletionHours = null,
whoCanAccess = null,
sendType = SendItemType.TEXT,
isPremium = true,
)
@@ -6,7 +6,6 @@ import app.cash.turbine.test
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.policies.PolicyType
import com.bitwarden.send.SendView
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
@@ -17,18 +16,17 @@ import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager
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.EffectiveSendPolicy
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.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPolicyView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
@@ -56,7 +54,6 @@ import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
@@ -96,8 +93,7 @@ class AddEditSendViewModelTest : BaseViewModelTest() {
every { getSendStateFlow(any()) } returns mutableSendDataStateFlow
}
private val policyManager: PolicyManager = mockk {
every { getActivePolicies(type = PolicyType.DISABLE_SEND) } returns emptyList()
every { getActivePolicies(type = PolicyType.SEND_OPTIONS) } returns emptyList()
every { getEffectiveSendPolicy() } returns DEFAULT_EFFECTIVE_SEND_POLICY
}
private val networkConnectionManager = mockk<NetworkConnectionManager> {
every { isNetworkConnected } returns true
@@ -139,20 +135,10 @@ class AddEditSendViewModelTest : BaseViewModelTest() {
}
@Test
fun `initial state should be correct when a sendOption includes shouldDisableHideEmail`() {
fun `initial state should be correct when the effective policy disables hide email`() {
every {
policyManager.getActivePolicies(type = PolicyType.SEND_OPTIONS)
} returns listOf(
createMockPolicyView(
id = "123",
type = PolicyType.SEND_OPTIONS,
enabled = true,
data = Json.encodeToString(
PolicyInformation.SendOptions(shouldDisableHideEmail = true),
),
organizationId = "id2",
),
)
policyManager.getEffectiveSendPolicy()
} returns DEFAULT_EFFECTIVE_SEND_POLICY.copy(disableHideEmail = true)
val viewModel = createViewModel()
val viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
@@ -1520,10 +1506,23 @@ private val DEFAULT_STATE = AddEditSendState(
isShared = false,
baseWebSendUrl = DEFAULT_ENVIRONMENT_URL,
policyDisablesSend = false,
allowedDomains = null,
allowedSendTypes = null,
deletionHours = null,
whoCanAccess = null,
sendType = SendItemType.TEXT,
isPremium = true,
)
private val DEFAULT_EFFECTIVE_SEND_POLICY = EffectiveSendPolicy(
allowedDomains = null,
allowedSendTypes = null,
deletionHours = null,
disableHideEmail = false,
disableSend = false,
whoCanAccess = null,
)
private val DEFAULT_ACCOUNT = UserState.Account(
userId = "activeUserId",
name = "Active User",
@@ -78,6 +78,7 @@ import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.EffectiveSendPolicy
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
@@ -221,8 +222,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
private val mutableActivePoliciesFlow: MutableStateFlow<List<PolicyView>> =
MutableStateFlow(emptyList())
private val policyManager: PolicyManager = mockk {
every { getActivePolicies(type = PolicyType.DISABLE_SEND) } returns emptyList()
every { getActivePoliciesFlow(type = PolicyType.DISABLE_SEND) } returns emptyFlow()
every { getEffectiveSendPolicy() } returns DEFAULT_EFFECTIVE_SEND_POLICY
every { getEffectiveSendPolicyFlow() } returns emptyFlow()
every {
getActivePoliciesFlow(type = PolicyType.RESTRICTED_ITEM_TYPES)
} returns mutableActivePoliciesFlow
@@ -6779,4 +6780,13 @@ private val DEFAULT_USER_STATE = UserState(
accounts = listOf(DEFAULT_ACCOUNT),
)
private val DEFAULT_EFFECTIVE_SEND_POLICY = EffectiveSendPolicy(
allowedDomains = null,
allowedSendTypes = null,
deletionHours = null,
disableHideEmail = false,
disableSend = false,
whoCanAccess = null,
)
private const val DEFAULT_RELYING_PARTY_ID = "www.bitwarden.com"