diff --git a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrm.kt b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrm.kt index bf44fff9f548..c26bb0ca4df0 100644 --- a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrm.kt +++ b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrm.kt @@ -17,8 +17,8 @@ package com.duckduckgo.privacy.config.impl.features.drm import androidx.core.net.toUri +import com.duckduckgo.app.browser.UriString.Companion.sameOrSubdomain import com.duckduckgo.app.privacy.db.UserAllowListRepository -import com.duckduckgo.common.utils.baseHost import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.feature.toggles.api.FeatureToggle import com.duckduckgo.privacy.config.api.Drm @@ -40,13 +40,16 @@ class RealDrm @Inject constructor( override fun isDrmAllowedForUrl(url: String): Boolean { val uri = url.toUri() - val isFeatureEnabled = featureToggle.isFeatureEnabled(PrivacyFeatureName.DrmFeatureName.value, defaultValue = true) - return (isFeatureEnabled && domainsThatAllowDrm(uri.baseHost)) || + // Defaults to false: the persister clears every privacy config toggle before applying a new config and + // only reinserts the features it contained, so an unset value means eme was dropped from the config. The + // stored exceptions outlive it, and they must not keep granting DRM once the feature is gone. + val isFeatureEnabled = featureToggle.isFeatureEnabled(PrivacyFeatureName.DrmFeatureName.value, defaultValue = false) + return (isFeatureEnabled && domainsThatAllowDrm(url)) || userAllowListRepository.isUriInUserAllowList(uri) || unprotectedTemporary.isAnException(uri.toString()) } - private fun domainsThatAllowDrm(host: String?): Boolean { - return drmRepository.exceptions.firstOrNull { it.domain == host } != null + private fun domainsThatAllowDrm(url: String): Boolean { + return drmRepository.exceptions.any { exception -> sameOrSubdomain(url, exception.domain) } } } diff --git a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrmTest.kt b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrmTest.kt index f35f83711efc..812f44180bd4 100644 --- a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrmTest.kt +++ b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrmTest.kt @@ -29,6 +29,7 @@ import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import java.util.concurrent.CopyOnWriteArrayList @@ -51,6 +52,38 @@ class RealDrmTest { assertTrue(testee.isDrmAllowedForUrl(url)) } + @Test + fun whenIsDrmAllowedForUrlIfSubdomainOfExceptionThenTrueIsReturned() { + giveFeatureIsEnabled() + givenUrlIsInExceptionList("foxnews.com") + + assertTrue(testee.isDrmAllowedForUrl("https://static.foxnews.com")) + } + + @Test + fun whenIsDrmAllowedForUrlIfNestedSubdomainOfExceptionThenTrueIsReturned() { + giveFeatureIsEnabled() + givenUrlIsInExceptionList("foxnews.com") + + assertTrue(testee.isDrmAllowedForUrl("https://a.b.foxnews.com")) + } + + @Test + fun whenIsDrmAllowedForUrlIfParentDomainOfExceptionThenFalseIsReturned() { + giveFeatureIsEnabled() + givenUrlIsInExceptionList("open.spotify.com") + + assertFalse(testee.isDrmAllowedForUrl("https://spotify.com")) + } + + @Test + fun whenIsDrmAllowedForUrlIfUnrelatedDomainSharesSuffixThenFalseIsReturned() { + giveFeatureIsEnabled() + givenUrlIsInExceptionList("foxnews.com") + + assertFalse(testee.isDrmAllowedForUrl("https://notfoxnews.com")) + } + @Test fun whenIsDrmAllowedForUrlIfFeatureIsEnabledAndDomainIsNotInExceptionsListThenFalseIsReturned() { giveFeatureIsEnabled() @@ -87,12 +120,22 @@ class RealDrmTest { assertTrue(testee.isDrmAllowedForUrl(url)) } + @Test + fun whenIsDrmAllowedForUrlThenEmeFeatureDefaultsToDisabled() { + givenUrlIsInExceptionList() + + testee.isDrmAllowedForUrl("https://open.spotify.com") + + // An unset toggle means eme was dropped from the config, so the stored exceptions must stop granting + verify(mockFeatureToggle).isFeatureEnabled(PrivacyFeatureName.DrmFeatureName.value, false) + } + private fun giveFeatureIsEnabled() { whenever(mockFeatureToggle.isFeatureEnabled(eq(PrivacyFeatureName.DrmFeatureName.value), any())).thenReturn(true) } - private fun givenUrlIsInExceptionList() { - val exceptions = CopyOnWriteArrayList().apply { add(FeatureException("open.spotify.com", "my reason here")) } + private fun givenUrlIsInExceptionList(domain: String = "open.spotify.com") { + val exceptions = CopyOnWriteArrayList().apply { add(FeatureException(domain, "my reason here")) } whenever(mockDrmRepository.exceptions).thenReturn(exceptions) } diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncher.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncher.kt index 4917d22c543b..091d159c6a0f 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncher.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncher.kt @@ -44,6 +44,8 @@ import com.duckduckgo.site.permissions.api.SitePermissionsDialogLauncher import com.duckduckgo.site.permissions.api.SitePermissionsGrantedListener import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermissionRequest import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissions +import com.duckduckgo.site.permissions.impl.feature.DrmPolicyFeature +import com.duckduckgo.site.permissions.impl.feature.isCentralPolicyEnabled import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType.ALLOW_ALWAYS import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType.DENY_ALWAYS @@ -68,6 +70,7 @@ class SitePermissionsDialogActivityLauncher @Inject constructor( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val duckAiHostProvider: DuckAiHostProvider, private val browserMode: BrowserMode, + private val drmPolicyFeature: DrmPolicyFeature, ) : SitePermissionsDialogLauncher { private lateinit var sitePermissionRequest: PermissionRequest @@ -252,21 +255,26 @@ class SitePermissionsDialogActivityLauncher @Inject constructor( ) { val domain = url.extractDomain() ?: url - // Check if user allowed or denied per session - val sessionSetting = sitePermissionsRepository.getDrmForSession(domain) - if (sessionSetting != null) { - if (sessionSetting) { - grantPermissions() - } else { - denyPermissions() + // Session state and the block list are rules in the central policy, which has already decided PROMPT + // by the time we get here. Re-checking them would be a second evaluator, and its outcome would not + // appear in the policy decision or the auto-grant pixel. + if (!drmPolicyFeature.isCentralPolicyEnabled()) { + // Check if user allowed or denied per session + val sessionSetting = sitePermissionsRepository.getDrmForSession(tabId, domain) + if (sessionSetting != null) { + if (sessionSetting) { + grantPermissions() + } else { + denyPermissions() + } + return } - return - } - // No session-based setting --> check if DRM blocked by config - if (sitePermissionsRepository.isDrmBlockedForUrlByConfig(url)) { - denyPermissions() - return + // No session-based setting --> check if DRM blocked by config + if (sitePermissionsRepository.isDrmBlockedForUrlByConfig(url)) { + denyPermissions() + return + } } // No session-based setting and no config --> proceed to show dialog @@ -305,7 +313,7 @@ class SitePermissionsDialogActivityLauncher @Inject constructor( // Fire mode grants the in-session WebView permission below but must not write the // choice into the shared (app-wide, in-memory) DRM session map that regular tabs read. if (browserMode != BrowserMode.FIRE) { - sitePermissionsRepository.saveDrmForSession(domain, true) + sitePermissionsRepository.saveDrmForSession(tabId, domain, true) } grantPermissions() } @@ -320,7 +328,7 @@ class SitePermissionsDialogActivityLauncher @Inject constructor( } else if (browserMode != BrowserMode.FIRE) { // Fire mode denied the in-session permission above but must not write the // choice into the shared (app-wide, in-memory) DRM session map that regular tabs read. - sitePermissionsRepository.saveDrmForSession(domain, false) + sitePermissionsRepository.saveDrmForSession(tabId, domain, false) } sendNegativeDialogClickPixel(SitePermissionsPixelValues.DRM, rememberChoice) } diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt index c0246cab23eb..a8d27af566c9 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt @@ -31,7 +31,12 @@ import com.duckduckgo.site.permissions.api.SitePermissionsManager import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermissionRequest import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissionQueryResponse import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissions +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyManager +import com.duckduckgo.site.permissions.impl.drm.DrmSessionStore +import com.duckduckgo.site.permissions.impl.feature.DrmPolicyFeature import com.duckduckgo.site.permissions.impl.feature.MicrophoneSitePermissionsDomainRecoveryFeature +import com.duckduckgo.site.permissions.impl.feature.isCentralPolicyEnabled import com.squareup.anvil.annotations.ContributesBinding import kotlinx.coroutines.withContext import logcat.logcat @@ -46,6 +51,9 @@ class SitePermissionsManagerImpl @Inject constructor( private val dispatcherProvider: DispatcherProvider, private val context: Context, private val microphoneSitePermissionsDomainRecoveryFeature: MicrophoneSitePermissionsDomainRecoveryFeature, + private val drmPolicyFeature: DrmPolicyFeature, + private val drmPolicyManager: DrmPolicyManager, + private val drmSessionStore: DrmSessionStore, duckAiHostProvider: DuckAiHostProvider, ) : SitePermissionsManager { @@ -65,14 +73,26 @@ class SitePermissionsManagerImpl @Inject constructor( val autoAccept = mutableListOf() val url = request.origin.toString() + val drmDecision = if (!request.resources.contains(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID)) { + null + } else { + withContext(dispatcherProvider.io()) { + drmPolicyManager + .takeIf { drmPolicyFeature.isCentralPolicyEnabled() } + ?.decide(url, tabId) + ?.also { logcat { "Permissions: drm policy decision for $url is $it" } } + } + } + val sitePermissionsAllowedToAsk = request.resources + .filter { drmDecision == null || it != PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID } .filter { isPermissionSupported(it) && isHardwareSupported(it) } .filter { sitePermissionsRepository.isDomainAllowedToAsk(url, it) } .toTypedArray() logcat { "Permissions: sitePermissionsAllowedToAsk in $url ${sitePermissionsAllowedToAsk.asList()}" } - val sitePermissionsGranted = if (microphoneSitePermissionsDomainRecoveryFeature.self().isEnabled()) { + val filteredPermissionsGranted = if (microphoneSitePermissionsDomainRecoveryFeature.self().isEnabled()) { getSitePermissionsGranted(url, tabId, sitePermissionsAllowedToAsk).filter { permission -> if (permission == PermissionRequest.RESOURCE_AUDIO_CAPTURE && audioCapturePermissionDomains.contains(url.extractDomain())) { ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED && @@ -85,6 +105,12 @@ class SitePermissionsManagerImpl @Inject constructor( getSitePermissionsGranted(url, tabId, sitePermissionsAllowedToAsk) } + val sitePermissionsGranted = if (drmDecision?.action == DrmPolicyAction.GRANT) { + filteredPermissionsGranted + PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID + } else { + filteredPermissionsGranted + } + if (sitePermissionsGranted.isNotEmpty()) { withContext(dispatcherProvider.main()) { logcat { "Permissions: site permission granted" } @@ -94,7 +120,8 @@ class SitePermissionsManagerImpl @Inject constructor( logcat { "Permissions: sitePermissionsGranted for $url are ${sitePermissionsGranted.asList()}" } - val userList = sitePermissionsAllowedToAsk.filter { !sitePermissionsGranted.contains(it) } + val userList = sitePermissionsAllowedToAsk.filter { !sitePermissionsGranted.contains(it) } + + listOfNotNull(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID.takeIf { drmDecision?.action == DrmPolicyAction.PROMPT }) if (userList.isEmpty() && sitePermissionsGranted.isEmpty()) { withContext(dispatcherProvider.main()) { logcat { "Permissions: site permission not granted, deny" } @@ -115,6 +142,7 @@ class SitePermissionsManagerImpl @Inject constructor( } override suspend fun clearAllButFireproof(fireproofDomains: List) { + drmSessionStore.clear() sitePermissionsRepository.sitePermissionsForAllWebsites().forEach { permission -> if (!fireproofDomains.contains(permission.domain)) { sitePermissionsRepository.deletePermissionsForSite(permission.domain) diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepository.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepository.kt index 4341694615b1..603657d9517b 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepository.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepository.kt @@ -22,7 +22,12 @@ import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.extractDomain import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermissionRequest +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyManager +import com.duckduckgo.site.permissions.impl.drm.DrmSessionStore import com.duckduckgo.site.permissions.impl.drmblock.DrmBlock +import com.duckduckgo.site.permissions.impl.feature.DrmPolicyFeature +import com.duckduckgo.site.permissions.impl.feature.isCentralPolicyEnabled import com.duckduckgo.site.permissions.store.SitePermissionsPreferences import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionsDao @@ -30,6 +35,7 @@ import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionsEnti import com.duckduckgo.site.permissions.store.sitepermissionsallowed.SitePermissionAllowedEntity import com.duckduckgo.site.permissions.store.sitepermissionsallowed.SitePermissionsAllowedDao import com.squareup.anvil.annotations.ContributesBinding +import dagger.Lazy import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch @@ -50,8 +56,8 @@ interface SitePermissionsRepository { fun sitePermissionsWebsitesFlow(): Flow> fun sitePermissionsForAllWebsites(): List fun sitePermissionsAllowedFlow(): Flow> - fun getDrmForSession(domain: String): Boolean? - fun saveDrmForSession(domain: String, allowed: Boolean) + fun getDrmForSession(tabId: String, domain: String): Boolean? + fun saveDrmForSession(tabId: String, domain: String, allowed: Boolean) fun isDrmBlockedForUrlByConfig(url: String): Boolean suspend fun undoDeleteAll(sitePermissions: List, allowedSites: List) suspend fun deleteAll() @@ -69,6 +75,9 @@ class SitePermissionsRepositoryImpl @Inject constructor( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, private val drmBlock: DrmBlock, + private val drmSessionStore: DrmSessionStore, + private val drmPolicyFeature: DrmPolicyFeature, + private val drmPolicyManager: Lazy, ) : SitePermissionsRepository { override var askCameraEnabled: Boolean @@ -94,11 +103,16 @@ class SitePermissionsRepositoryImpl @Inject constructor( sitePermissionsPreferences.askLocationEnabled = value } + // Kept for the flag-off path only; the shared DrmSessionStore replaces it once drmPolicy is fully rolled out. private val drmSessions = mutableMapOf() override suspend fun isDrmEnabledForSite(url: String): Boolean { - val domain = url.extractDomain() ?: url + if (withContext(dispatcherProvider.io()) { drmPolicyFeature.isCentralPolicyEnabled() }) { + // "Permitted or promptable" rather than "granted" — this feeds the breakage report's drmEnabled field. + return drmPolicyManager.get().decide(url).action != DrmPolicyAction.DENY + } + val domain = url.extractDomain() ?: url drmSessions[domain]?.let { return it } if (isDrmBlockedForUrlByConfig(url)) return false @@ -201,12 +215,20 @@ class SitePermissionsRepositoryImpl @Inject constructor( return sitePermissionsAllowedDao.getAllSitesPermissionsAllowedAsFlow() } - override fun getDrmForSession(domain: String): Boolean? { - return drmSessions[domain] + override fun getDrmForSession(tabId: String, domain: String): Boolean? { + return if (drmPolicyFeature.isCentralPolicyEnabled()) { + drmSessionStore.get(tabId, domain) + } else { + drmSessions[domain] + } } - override fun saveDrmForSession(domain: String, allowed: Boolean) { - drmSessions[domain] = allowed + override fun saveDrmForSession(tabId: String, domain: String, allowed: Boolean) { + if (drmPolicyFeature.isCentralPolicyEnabled()) { + drmSessionStore.save(tabId, domain, allowed) + } else { + drmSessions[domain] = allowed + } } override fun isDrmBlockedForUrlByConfig(url: String): Boolean { diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/UserProtections.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/UserProtections.kt new file mode 100644 index 000000000000..ce1ff8152588 --- /dev/null +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/UserProtections.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.site.permissions.impl + +import android.net.Uri +import com.duckduckgo.app.privacy.db.UserAllowListRepository +import com.duckduckgo.common.utils.extensions.toTldPlusOneOrSelf + +/** + * Whether the user has turned protections off for this URI. + * + * Protections are stored for the host of the page the user was looking at, while a DRM request usually + * arrives from a subresource origin, so the registrable domain is checked too. Shared by the block list + * and the policy so both answer the question the same way. + */ +internal fun UserAllowListRepository.isSiteUnprotectedByUser(uri: Uri): Boolean { + if (isUriInUserAllowList(uri)) return true + val host = uri.host ?: return false + return host.hostAndParentCandidates().any { isDomainInUserAllowList(it) } +} + +/** + * This host and every parent up to its registrable domain, each with its `www.` spelling. + * + * The eme and emeBlock lists match subdomains, so a value the user stored against any parent of the + * request origin has to be found as well, or remote config would override an explicit choice. + */ +internal fun String.hostAndParentCandidates(): List { + val registrableDomain = toTldPlusOneOrSelf() + val chain = mutableListOf() + var current = this + while (true) { + chain.add(current) + if (current == registrableDomain) break + val parent = current.substringAfter('.', "") + if (parent.isEmpty() || parent.length < registrableDomain.length) break + current = parent + } + return chain.flatMap { listOf(it, "www.$it") }.distinct() +} diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyEvaluator.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyEvaluator.kt new file mode 100644 index 000000000000..047a7cc8421d --- /dev/null +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyEvaluator.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.site.permissions.impl.drm + +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction.DENY +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction.GRANT +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction.PROMPT +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.ALLOW_LIST +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.BLOCK_LIST +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.GLOBAL_OFF +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.NO_RULE +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.PROTECTIONS_OFF +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.SESSION +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.USER_ALLOW_ALWAYS +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.USER_DENY_ALWAYS +import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType.ALLOW_ALWAYS +import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType.DENY_ALWAYS + +/* + * First match wins. isBlockedByBlockList comes from a composite that is already false for + * protections-off sites, so a block-listed domain with protections disabled grants at PROTECTIONS_OFF. + */ +internal fun DrmPolicyContext.evaluate(): DrmPolicyDecision = when { + !isGlobalAskEnabled -> DrmPolicyDecision(DENY, GLOBAL_OFF) + siteSetting == DENY_ALWAYS -> DrmPolicyDecision(DENY, USER_DENY_ALWAYS) + siteSetting == ALLOW_ALWAYS -> DrmPolicyDecision(GRANT, USER_ALLOW_ALWAYS) + sessionChoice != null -> DrmPolicyDecision(if (sessionChoice) GRANT else DENY, SESSION) + isBlockedByBlockList -> DrmPolicyDecision(DENY, BLOCK_LIST) + isSiteUnprotected -> DrmPolicyDecision(GRANT, PROTECTIONS_OFF) + isAllowedByAllowList -> DrmPolicyDecision(GRANT, ALLOW_LIST) + else -> DrmPolicyDecision(PROMPT, NO_RULE) +} diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyManager.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyManager.kt new file mode 100644 index 000000000000..034930e937b3 --- /dev/null +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyManager.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.site.permissions.impl.drm + +import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType + +enum class DrmPolicyAction { + GRANT, + DENY, + PROMPT, +} + +enum class DrmPolicyReason { + GLOBAL_OFF, + USER_DENY_ALWAYS, + USER_ALLOW_ALWAYS, + SESSION, + BLOCK_LIST, + PROTECTIONS_OFF, + ALLOW_LIST, + NO_RULE, +} + +data class DrmPolicyDecision( + val action: DrmPolicyAction, + val reason: DrmPolicyReason, +) + +data class DrmPolicyContext( + val isGlobalAskEnabled: Boolean, + val siteSetting: SitePermissionAskSettingType?, + val sessionChoice: Boolean?, + val isBlockedByBlockList: Boolean, + val isSiteUnprotected: Boolean, + val isAllowedByAllowList: Boolean, +) + +interface DrmPolicyManager { + suspend fun decide(url: String, tabId: String? = null): DrmPolicyDecision +} diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmSessionStore.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmSessionStore.kt new file mode 100644 index 000000000000..26102453eaa2 --- /dev/null +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmSessionStore.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.site.permissions.impl.drm + +import com.duckduckgo.di.scopes.AppScope +import dagger.SingleInstanceIn +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +@SingleInstanceIn(AppScope::class) +class DrmSessionStore @Inject constructor() { + private val sessions = ConcurrentHashMap() + + fun get(tabId: String, domain: String): Boolean? = sessions[key(tabId, domain)] + + fun save(tabId: String, domain: String, allowed: Boolean) { + sessions[key(tabId, domain)] = allowed + } + + fun clear() { + sessions.clear() + } + + private fun key(tabId: String, domain: String) = "$tabId/$domain" +} diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt new file mode 100644 index 000000000000..e7b561428d3e --- /dev/null +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.site.permissions.impl.drm + +import androidx.core.net.toUri +import com.duckduckgo.app.privacy.db.UserAllowListRepository +import com.duckduckgo.common.utils.extractDomain +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.privacy.config.api.Drm +import com.duckduckgo.privacy.config.api.UnprotectedTemporary +import com.duckduckgo.site.permissions.impl.SitePermissionsRepository +import com.duckduckgo.site.permissions.impl.drmblock.DrmBlock +import com.duckduckgo.site.permissions.impl.hostAndParentCandidates +import com.duckduckgo.site.permissions.impl.isSiteUnprotectedByUser +import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class RealDrmPolicyManager @Inject constructor( + private val sitePermissionsRepository: SitePermissionsRepository, + private val drmSessionStore: DrmSessionStore, + private val drmBlock: DrmBlock, + private val drm: Drm, + private val userAllowListRepository: UserAllowListRepository, + private val unprotectedTemporary: UnprotectedTemporary, +) : DrmPolicyManager { + + override suspend fun decide(url: String, tabId: String?): DrmPolicyDecision { + val domain = url.extractDomain() ?: url + val uri = url.toUri() + // Settings are keyed on the host as typed, while both config lists match subdomains, so a choice + // stored against any parent of the request origin has to be found too. Most specific wins. + val siteSetting = domain.hostAndParentCandidates() + .firstNotNullOfOrNull { host -> sitePermissionsRepository.getSitePermissionsForWebsite(host)?.askDrmSetting?.toDrmSetting() } + + return DrmPolicyContext( + isGlobalAskEnabled = sitePermissionsRepository.askDrmEnabled, + siteSetting = siteSetting, + sessionChoice = tabId?.let { drmSessionStore.get(it, domain) }, + isBlockedByBlockList = drmBlock.isDrmBlockedForUrl(url), + isSiteUnprotected = userAllowListRepository.isSiteUnprotectedByUser(uri) || unprotectedTemporary.isAnException(url), + isAllowedByAllowList = drm.isDrmAllowedForUrl(url), + ).evaluate() + } + + // Every row carries a DRM value defaulting to ASK_EVERY_TIME, so a row saved for another permission + // must not stop the scan before an explicit choice on one of the other spellings. + private fun String.toDrmSetting(): SitePermissionAskSettingType? = + runCatching { SitePermissionAskSettingType.valueOf(this) }.getOrNull() + ?.takeIf { it != SitePermissionAskSettingType.ASK_EVERY_TIME } +} diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlock.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlock.kt index 5589f32770a1..cccd53337f2e 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlock.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlock.kt @@ -17,10 +17,14 @@ package com.duckduckgo.site.permissions.impl.drmblock import androidx.core.net.toUri +import com.duckduckgo.app.browser.UriString.Companion.sameOrSubdomain import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.common.utils.baseHost import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.privacy.config.api.UnprotectedTemporary +import com.duckduckgo.site.permissions.impl.feature.DrmPolicyFeature +import com.duckduckgo.site.permissions.impl.feature.isCentralPolicyEnabled +import com.duckduckgo.site.permissions.impl.isSiteUnprotectedByUser import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject @@ -30,17 +34,23 @@ class RealDrmBlock @Inject constructor( private val drmBlockRepository: DrmBlockRepository, private val userAllowListRepository: UserAllowListRepository, private val unprotectedTemporary: UnprotectedTemporary, + private val drmPolicyFeature: DrmPolicyFeature, ) : DrmBlock { override fun isDrmBlockedForUrl(url: String): Boolean { val uri = url.toUri() - return drmBlockFeature.self().isEnabled() && - domainsThatBlockDrm(uri.baseHost) && - !userAllowListRepository.isUriInUserAllowList(uri) && - !unprotectedTemporary.isAnException(uri.toString()) - } + if (!drmBlockFeature.self().isEnabled()) return false - private fun domainsThatBlockDrm(host: String?): Boolean { - return drmBlockRepository.exceptions.firstOrNull { it.domain == host } != null + // The central policy widens both the list match and the protections-off check. The legacy path has to + // stay as it is on develop, so that turning the flag off during the rollout restores today's behaviour. + return if (drmPolicyFeature.isCentralPolicyEnabled()) { + drmBlockRepository.exceptions.any { sameOrSubdomain(url, it.domain) } && + !userAllowListRepository.isSiteUnprotectedByUser(uri) && + !unprotectedTemporary.isAnException(uri.toString()) + } else { + drmBlockRepository.exceptions.firstOrNull { it.domain == uri.baseHost } != null && + !userAllowListRepository.isUriInUserAllowList(uri) && + !unprotectedTemporary.isAnException(uri.toString()) + } } } diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/feature/DrmPolicyFeature.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/feature/DrmPolicyFeature.kt new file mode 100644 index 000000000000..82fbf54a91ce --- /dev/null +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/feature/DrmPolicyFeature.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.site.permissions.impl.feature + +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "drmPolicy", +) +interface DrmPolicyFeature { + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun self(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun centralPolicy(): Toggle +} + +fun DrmPolicyFeature.isCentralPolicyEnabled(): Boolean = self().isEnabled() && centralPolicy().isEnabled() diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncherTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncherTest.kt index 16238e6cf1b8..b87019f98dc3 100644 --- a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncherTest.kt +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncherTest.kt @@ -25,8 +25,11 @@ import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.browsermode.api.BrowserMode import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.duckchat.api.DuckAiHostProvider +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.site.permissions.api.SitePermissionsGrantedListener import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissions +import com.duckduckgo.site.permissions.impl.feature.DrmPolicyFeature import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.never @@ -53,6 +56,8 @@ class SitePermissionsDialogActivityLauncherTest { whenever(it.getHost()).thenReturn("duck.ai") } + private val drmPolicyFeature = FakeFeatureToggleFactory.create(DrmPolicyFeature::class.java) + private val testee = createLauncher(BrowserMode.REGULAR) private fun createLauncher(browserMode: BrowserMode) = SitePermissionsDialogActivityLauncher( @@ -64,11 +69,44 @@ class SitePermissionsDialogActivityLauncherTest { appCoroutineScope = coroutineRule.testScope, duckAiHostProvider = duckAiHostProvider, browserMode = browserMode, + drmPolicyFeature = drmPolicyFeature, ) + @Test + fun whenCentralPolicyEnabledThenLauncherDoesNotRecheckSessionOrBlockList() { + drmPolicyFeature.self().setRawStoredState(Toggle.State(true)) + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(true)) + val activity: Activity = mock() + val request: PermissionRequest = mock() + whenever(request.resources).thenReturn(arrayOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID)) + whenever(sitePermissionsRepository.getDrmForSession(any(), any())).thenReturn(false) + whenever(sitePermissionsRepository.isDrmBlockedForUrlByConfig(any())).thenReturn(true) + + // Showing the dialog needs a themed Activity, which a mock cannot provide. Only the skipped + // pre-checks are under test here: with the flag on neither early return may fire. + runCatching { + testee.askForSitePermission( + activity = activity, + url = "https://example.com", + tabId = "tabId", + permissionsRequested = SitePermissions( + autoAccept = emptyList(), + userHandled = listOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID), + ), + request = request, + permissionsGrantedListener = permissionsGrantedListener, + ) + } + + verify(request, never()).grant(any()) + verify(request, never()).deny() + verify(sitePermissionsRepository, never()).getDrmForSession(any(), any()) + verify(sitePermissionsRepository, never()).isDrmBlockedForUrlByConfig(any()) + } + @Test fun whenDrmAlreadyAllowedForSessionThenDialogNotShownAndNoImpressionPixelFired() { - whenever(sitePermissionsRepository.getDrmForSession("example.com")).thenReturn(true) + whenever(sitePermissionsRepository.getDrmForSession("tabId", "example.com")).thenReturn(true) val activity: Activity = mock() val request: PermissionRequest = mock() @@ -91,7 +129,7 @@ class SitePermissionsDialogActivityLauncherTest { @Test fun whenDrmBlockedByConfigThenDialogNotShownAndNoImpressionPixelFired() { - whenever(sitePermissionsRepository.getDrmForSession("example.com")).thenReturn(null) + whenever(sitePermissionsRepository.getDrmForSession("tabId", "example.com")).thenReturn(null) whenever(sitePermissionsRepository.isDrmBlockedForUrlByConfig("https://example.com")).thenReturn(true) val activity: Activity = mock() diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerTest.kt index 12c3e501073a..0b52bf2f5956 100644 --- a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerTest.kt +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerTest.kt @@ -28,15 +28,23 @@ import com.duckduckgo.duckchat.api.DuckAiHostProvider import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissionQueryResponse +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyDecision +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyManager +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason +import com.duckduckgo.site.permissions.impl.drm.DrmSessionStore +import com.duckduckgo.site.permissions.impl.feature.DrmPolicyFeature import com.duckduckgo.site.permissions.impl.feature.MicrophoneSitePermissionsDomainRecoveryFeature import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionsEntity import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.never import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyZeroInteractions import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Rule import org.junit.Test @@ -57,6 +65,9 @@ class SitePermissionsManagerTest { private val fakeMicrophoneSitePermissionsDomainRecoveryFeature = FakeFeatureToggleFactory.create( MicrophoneSitePermissionsDomainRecoveryFeature::class.java, ) + private val drmPolicyFeature = FakeFeatureToggleFactory.create(DrmPolicyFeature::class.java) + private val mockDrmPolicyManager: DrmPolicyManager = mock() + private val drmSessionStore = DrmSessionStore() private val testee by lazy { SitePermissionsManagerImpl( @@ -66,6 +77,9 @@ class SitePermissionsManagerTest { coroutineRule.testDispatcherProvider, mockContext, fakeMicrophoneSitePermissionsDomainRecoveryFeature, + drmPolicyFeature, + mockDrmPolicyManager, + drmSessionStore, mockDuckAiHostProvider, ) } @@ -78,6 +92,8 @@ class SitePermissionsManagerTest { whenever(mockDuckAiHostProvider.getHost()).thenReturn("duck.ai") whenever(mockPackageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)).thenReturn(true) fakeMicrophoneSitePermissionsDomainRecoveryFeature.self().setRawStoredState(Toggle.State(false)) + drmPolicyFeature.self().setRawStoredState(Toggle.State(true)) + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(false)) } @Test @@ -117,6 +133,81 @@ class SitePermissionsManagerTest { verify(permissionRequest).grant(arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE, PermissionRequest.RESOURCE_VIDEO_CAPTURE)) } + @Test + fun whenCentralPolicyEnabledAndPolicyGrantsThenDrmAutoAccepted() = runTest { + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(true)) + val resources = arrayOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) + whenever(mockDrmPolicyManager.decide(url, tabId)) + .thenReturn(DrmPolicyDecision(DrmPolicyAction.GRANT, DrmPolicyReason.ALLOW_LIST)) + + val permissionRequest: PermissionRequest = mock() + whenever(permissionRequest.origin).thenReturn(url.toUri()) + whenever(permissionRequest.resources).thenReturn(resources) + + val permissions = testee.getSitePermissions(tabId, permissionRequest) + + assertEquals(0, permissions.userHandled.size) + verify(permissionRequest).grant(resources) + verify(permissionRequest, never()).deny() + verify(mockSitePermissionsRepository, never()).isDomainAllowedToAsk(url, PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) + verify(mockSitePermissionsRepository, never()).isDomainGranted(url, tabId, PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) + } + + @Test + fun whenCentralPolicyEnabledAndPolicyDeniesThenRequestDenied() = runTest { + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(true)) + val resources = arrayOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) + whenever(mockDrmPolicyManager.decide(url, tabId)) + .thenReturn(DrmPolicyDecision(DrmPolicyAction.DENY, DrmPolicyReason.BLOCK_LIST)) + + val permissionRequest: PermissionRequest = mock() + whenever(permissionRequest.origin).thenReturn(url.toUri()) + whenever(permissionRequest.resources).thenReturn(resources) + + val permissions = testee.getSitePermissions(tabId, permissionRequest) + + assertEquals(0, permissions.autoAccept.size) + assertEquals(0, permissions.userHandled.size) + verify(permissionRequest).deny() + verify(permissionRequest, never()).grant(any()) + } + + @Test + fun whenCentralPolicyEnabledAndPolicyPromptsThenDrmUserHandled() = runTest { + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(true)) + val resources = arrayOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) + whenever(mockDrmPolicyManager.decide(url, tabId)) + .thenReturn(DrmPolicyDecision(DrmPolicyAction.PROMPT, DrmPolicyReason.NO_RULE)) + + val permissionRequest: PermissionRequest = mock() + whenever(permissionRequest.origin).thenReturn(url.toUri()) + whenever(permissionRequest.resources).thenReturn(resources) + + val permissions = testee.getSitePermissions(tabId, permissionRequest) + + assertEquals(0, permissions.autoAccept.size) + assertEquals(listOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID), permissions.userHandled) + verify(permissionRequest, never()).grant(any()) + verify(permissionRequest, never()).deny() + } + + @Test + fun whenCentralPolicyDisabledThenDrmFollowsExistingPath() = runTest { + val resources = arrayOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) + whenever(mockSitePermissionsRepository.isDomainAllowedToAsk(url, PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID)).thenReturn(true) + whenever(mockSitePermissionsRepository.isDomainGranted(url, tabId, PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID)).thenReturn(false) + + val permissionRequest: PermissionRequest = mock() + whenever(permissionRequest.origin).thenReturn(url.toUri()) + whenever(permissionRequest.resources).thenReturn(resources) + + val permissions = testee.getSitePermissions(tabId, permissionRequest) + + assertEquals(0, permissions.autoAccept.size) + assertEquals(listOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID), permissions.userHandled) + verifyZeroInteractions(mockDrmPolicyManager) + } + @Test fun givenListOfPermissionsThenFilterNotSupportedAndReturnOnlyPermissionsAllowedToAsk() = runTest { val resources = @@ -193,6 +284,16 @@ class SitePermissionsManagerTest { verify(mockSitePermissionsRepository).deletePermissionsForSite(domain) } + @Test + fun whenClearAllButFireproofThenDrmSessionChoicesAreCleared() = runTest { + drmSessionStore.save(tabId, "domain.com", true) + whenever(mockSitePermissionsRepository.sitePermissionsForAllWebsites()).thenReturn(emptyList()) + + testee.clearAllButFireproof(listOf("domain.com")) + + assertNull(drmSessionStore.get(tabId, "domain.com")) + } + @Test fun whenDomainGrantedThenGetPermissionsQueryResponseReturnsGranted() = runTest { whenever(mockSitePermissionsRepository.isDomainGranted(url, tabId, PermissionRequest.RESOURCE_VIDEO_CAPTURE)).thenReturn(true) diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepositoryTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepositoryTest.kt index b5802d8b1db1..1de8cd243f0f 100644 --- a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepositoryTest.kt +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepositoryTest.kt @@ -19,8 +19,16 @@ package com.duckduckgo.site.permissions.impl import android.webkit.PermissionRequest import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermissionRequest +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyDecision +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyManager +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason +import com.duckduckgo.site.permissions.impl.drm.DrmSessionStore import com.duckduckgo.site.permissions.impl.drmblock.DrmBlock +import com.duckduckgo.site.permissions.impl.feature.DrmPolicyFeature import com.duckduckgo.site.permissions.store.SitePermissionsPreferences import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType.ALLOW_ALWAYS @@ -35,7 +43,9 @@ import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.test.runTest import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -51,6 +61,8 @@ class SitePermissionsRepositoryTest { private val mockSitePermissionsAllowedDao: SitePermissionsAllowedDao = mock() private val mockSitePermissionsPreferences: SitePermissionsPreferences = mock() private val mockDrmBlock: DrmBlock = mock() + private val mockDrmPolicyManager: DrmPolicyManager = mock() + private val drmPolicyFeature = FakeFeatureToggleFactory.create(DrmPolicyFeature::class.java) private val repository = SitePermissionsRepositoryImpl( mockSitePermissionsDao, @@ -59,10 +71,20 @@ class SitePermissionsRepositoryTest { coroutineRule.testScope, coroutineRule.testDispatcherProvider, mockDrmBlock, + DrmSessionStore(), + drmPolicyFeature, + { mockDrmPolicyManager }, ) private val url = "https://domain.com/whatever" private val domain = "domain.com" + private val tabId = "tabId" + + @Before + fun before() { + drmPolicyFeature.self().setRawStoredState(Toggle.State(true)) + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(false)) + } @Test fun givenPermissionNotSupportedThenDomainIsNotAllowedToAsk() = runTest { @@ -127,15 +149,33 @@ class SitePermissionsRepositoryTest { } @Test - fun whenDrmSessionAllowsThenDrmIsEnabledForSite() = runTest { - repository.saveDrmForSession(domain, true) + fun whenDrmSessionSavedThenGetDrmForSessionReturnsIt() { + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(true)) - assertTrue(repository.isDrmEnabledForSite(url)) + repository.saveDrmForSession(tabId, domain, true) + + assertTrue(repository.getDrmForSession(tabId, domain) == true) + } + + @Test + fun whenDrmSessionSavedForOneTabThenAnotherTabOnSameDomainHasNoSessionChoice() { + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(true)) + + repository.saveDrmForSession(tabId, domain, true) + + assertNull(repository.getDrmForSession("anotherTabId", domain)) } @Test - fun whenDrmSessionDeniesThenDrmIsDisabledForSite() = runTest { - repository.saveDrmForSession(domain, false) + fun whenPolicyDisabledAndDrmSessionSavedThenGetDrmForSessionReturnsItForAnyTab() { + repository.saveDrmForSession(tabId, domain, true) + + assertTrue(repository.getDrmForSession("anotherTabId", domain) == true) + } + + @Test + fun whenPolicyDisabledAndDrmSessionSavedThenDrmEnabledForSiteFollowsIt() = runTest { + repository.saveDrmForSession(tabId, domain, false) assertFalse(repository.isDrmEnabledForSite(url)) } @@ -147,6 +187,37 @@ class SitePermissionsRepositoryTest { assertFalse(repository.isDrmEnabledForSite(url)) } + @Test + fun whenDomainAllowedToAskThenDrmIsEnabledForSite() = runTest { + setInitialSettings() + + assertTrue(repository.isDrmEnabledForSite(url)) + } + + @Test + fun whenCentralPolicyEnabledAndPolicyDeniesThenDrmIsDisabledForSite() = runTest { + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(true)) + whenever(mockDrmPolicyManager.decide(url)).thenReturn(DrmPolicyDecision(DrmPolicyAction.DENY, DrmPolicyReason.BLOCK_LIST)) + + assertFalse(repository.isDrmEnabledForSite(url)) + } + + @Test + fun whenCentralPolicyEnabledAndPolicyPromptsThenDrmIsEnabledForSite() = runTest { + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(true)) + whenever(mockDrmPolicyManager.decide(url)).thenReturn(DrmPolicyDecision(DrmPolicyAction.PROMPT, DrmPolicyReason.NO_RULE)) + + assertTrue(repository.isDrmEnabledForSite(url)) + } + + @Test + fun whenCentralPolicyEnabledAndPolicyGrantsThenDrmIsEnabledForSite() = runTest { + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(true)) + whenever(mockDrmPolicyManager.decide(url)).thenReturn(DrmPolicyDecision(DrmPolicyAction.GRANT, DrmPolicyReason.ALLOW_LIST)) + + assertTrue(repository.isDrmEnabledForSite(url)) + } + @Test fun whenSitePermissionsWasGrantedWithin24hThenReturnPermissionGranted() = runTest { setInitialSettings() diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyEvaluatorTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyEvaluatorTest.kt new file mode 100644 index 000000000000..e736e9ecbe5e --- /dev/null +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyEvaluatorTest.kt @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.site.permissions.impl.drm + +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction.DENY +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction.GRANT +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction.PROMPT +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.ALLOW_LIST +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.BLOCK_LIST +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.GLOBAL_OFF +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.NO_RULE +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.PROTECTIONS_OFF +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.SESSION +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.USER_ALLOW_ALWAYS +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.USER_DENY_ALWAYS +import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType.ALLOW_ALWAYS +import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType.ASK_EVERY_TIME +import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType.DENY_ALWAYS +import org.junit.Assert.assertEquals +import org.junit.Test + +class DrmPolicyEvaluatorTest { + + private val noRuleContext = DrmPolicyContext( + isGlobalAskEnabled = true, + siteSetting = null, + sessionChoice = null, + isBlockedByBlockList = false, + isSiteUnprotected = false, + isAllowedByAllowList = false, + ) + + @Test + fun whenGlobalToggleOffThenDenyWithGlobalOff() { + val decision = noRuleContext.copy(isGlobalAskEnabled = false).evaluate() + + assertEquals(DrmPolicyDecision(DENY, GLOBAL_OFF), decision) + } + + @Test + fun whenSiteSettingDenyAlwaysThenDenyWithUserDenyAlways() { + val decision = noRuleContext.copy(siteSetting = DENY_ALWAYS).evaluate() + + assertEquals(DrmPolicyDecision(DENY, USER_DENY_ALWAYS), decision) + } + + @Test + fun whenSiteSettingAllowAlwaysThenGrantWithUserAllowAlways() { + val decision = noRuleContext.copy(siteSetting = ALLOW_ALWAYS).evaluate() + + assertEquals(DrmPolicyDecision(GRANT, USER_ALLOW_ALWAYS), decision) + } + + @Test + fun whenSiteSettingAskEveryTimeThenPromptWithNoRule() { + val decision = noRuleContext.copy(siteSetting = ASK_EVERY_TIME).evaluate() + + assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), decision) + } + + @Test + fun whenSessionChoiceAllowsThenGrantWithSession() { + val decision = noRuleContext.copy(sessionChoice = true).evaluate() + + assertEquals(DrmPolicyDecision(GRANT, SESSION), decision) + } + + @Test + fun whenSessionChoiceDeniesThenDenyWithSession() { + val decision = noRuleContext.copy(sessionChoice = false).evaluate() + + assertEquals(DrmPolicyDecision(DENY, SESSION), decision) + } + + @Test + fun whenBlockListMatchesThenDenyWithBlockList() { + val decision = noRuleContext.copy(isBlockedByBlockList = true).evaluate() + + assertEquals(DrmPolicyDecision(DENY, BLOCK_LIST), decision) + } + + @Test + fun whenSiteUnprotectedThenGrantWithProtectionsOff() { + val decision = noRuleContext.copy(isSiteUnprotected = true).evaluate() + + assertEquals(DrmPolicyDecision(GRANT, PROTECTIONS_OFF), decision) + } + + @Test + fun whenAllowListMatchesThenGrantWithAllowList() { + val decision = noRuleContext.copy(isAllowedByAllowList = true).evaluate() + + assertEquals(DrmPolicyDecision(GRANT, ALLOW_LIST), decision) + } + + @Test + fun whenNoRuleMatchesThenPromptWithNoRule() { + val decision = noRuleContext.evaluate() + + assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), decision) + } + + @Test + fun whenDenyAlwaysAndAllowListMatchThenDenyWithUserDenyAlways() { + val decision = noRuleContext.copy(siteSetting = DENY_ALWAYS, isAllowedByAllowList = true).evaluate() + + assertEquals(DrmPolicyDecision(DENY, USER_DENY_ALWAYS), decision) + } + + @Test + fun whenAllowAlwaysAndBlockListMatchThenGrantWithUserAllowAlways() { + val decision = noRuleContext.copy(siteSetting = ALLOW_ALWAYS, isBlockedByBlockList = true).evaluate() + + assertEquals(DrmPolicyDecision(GRANT, USER_ALLOW_ALWAYS), decision) + } + + @Test + fun whenGlobalToggleOffAndAllowAlwaysThenDenyWithGlobalOff() { + val decision = noRuleContext.copy(isGlobalAskEnabled = false, siteSetting = ALLOW_ALWAYS).evaluate() + + assertEquals(DrmPolicyDecision(DENY, GLOBAL_OFF), decision) + } + + @Test + fun whenSessionDenyAndAllowListMatchThenDenyWithSession() { + val decision = noRuleContext.copy(sessionChoice = false, isAllowedByAllowList = true).evaluate() + + assertEquals(DrmPolicyDecision(DENY, SESSION), decision) + } + + @Test + fun whenSessionAllowAndBlockListMatchThenGrantWithSession() { + val decision = noRuleContext.copy(sessionChoice = true, isBlockedByBlockList = true).evaluate() + + assertEquals(DrmPolicyDecision(GRANT, SESSION), decision) + } + + @Test + fun whenBlockListAndAllowListMatchThenDenyWithBlockList() { + val decision = noRuleContext.copy(isBlockedByBlockList = true, isAllowedByAllowList = true).evaluate() + + assertEquals(DrmPolicyDecision(DENY, BLOCK_LIST), decision) + } + + @Test + fun whenSiteUnprotectedAndAllowListMatchThenGrantWithProtectionsOff() { + val decision = noRuleContext.copy(isSiteUnprotected = true, isAllowedByAllowList = true).evaluate() + + assertEquals(DrmPolicyDecision(GRANT, PROTECTIONS_OFF), decision) + } +} diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt new file mode 100644 index 000000000000..316e23d281c9 --- /dev/null +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.site.permissions.impl.drm + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.privacy.db.UserAllowListRepository +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.privacy.config.api.Drm +import com.duckduckgo.privacy.config.api.UnprotectedTemporary +import com.duckduckgo.site.permissions.impl.SitePermissionsRepository +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction.DENY +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction.GRANT +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction.PROMPT +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.ALLOW_LIST +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.BLOCK_LIST +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.GLOBAL_OFF +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.NO_RULE +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.PROTECTIONS_OFF +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.SESSION +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.USER_ALLOW_ALWAYS +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.USER_DENY_ALWAYS +import com.duckduckgo.site.permissions.impl.drmblock.DrmBlock +import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType +import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionsEntity +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RealDrmPolicyManagerTest { + + @get:Rule + var coroutineRule = CoroutineTestRule() + + private val mockSitePermissionsRepository: SitePermissionsRepository = mock() + private val drmSessionStore = DrmSessionStore() + private val mockDrmBlock: DrmBlock = mock() + private val mockDrm: Drm = mock() + private val mockUserAllowListRepository: UserAllowListRepository = mock() + private val mockUnprotectedTemporary: UnprotectedTemporary = mock() + + private val testee = RealDrmPolicyManager( + mockSitePermissionsRepository, + drmSessionStore, + mockDrmBlock, + mockDrm, + mockUserAllowListRepository, + mockUnprotectedTemporary, + ) + + private val url = "https://www.netflix.com/watch" + private val domain = "www.netflix.com" + private val tabId = "tabId" + + @Before + fun before() { + whenever(mockSitePermissionsRepository.askDrmEnabled).thenReturn(true) + } + + @Test + fun whenGlobalDrmAskDisabledThenDenyWithGlobalOff() = runTest { + whenever(mockSitePermissionsRepository.askDrmEnabled).thenReturn(false) + + assertEquals(DrmPolicyDecision(DENY, GLOBAL_OFF), testee.decide(url, tabId)) + } + + @Test + fun whenSiteSettingIsDenyAlwaysThenDenyWithUserDenyAlways() = runTest { + givenSiteSetting(SitePermissionAskSettingType.DENY_ALWAYS) + + assertEquals(DrmPolicyDecision(DENY, USER_DENY_ALWAYS), testee.decide(url, tabId)) + } + + @Test + fun whenSiteSettingIsAllowAlwaysThenGrantWithUserAllowAlways() = runTest { + givenSiteSetting(SitePermissionAskSettingType.ALLOW_ALWAYS) + + assertEquals(DrmPolicyDecision(GRANT, USER_ALLOW_ALWAYS), testee.decide(url, tabId)) + } + + @Test + fun whenSessionChoiceExistsForTabThenSessionDecides() = runTest { + drmSessionStore.save(tabId, domain, false) + + assertEquals(DrmPolicyDecision(DENY, SESSION), testee.decide(url, tabId)) + } + + @Test + fun whenTabIdIsNullThenSessionChoiceIsNotConsulted() = runTest { + drmSessionStore.save(tabId, domain, false) + + assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide(url, tabId = null)) + } + + @Test + fun whenOtherTabHasSessionChoiceThenSessionIsNotConsulted() = runTest { + drmSessionStore.save("otherTabId", domain, false) + + assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide(url, tabId)) + } + + @Test + fun whenDrmBlockedByBlockListThenDenyWithBlockList() = runTest { + whenever(mockDrmBlock.isDrmBlockedForUrl(url)).thenReturn(true) + + assertEquals(DrmPolicyDecision(DENY, BLOCK_LIST), testee.decide(url, tabId)) + } + + @Test + fun whenUriInUserAllowListThenGrantWithProtectionsOff() = runTest { + whenever(mockUserAllowListRepository.isUriInUserAllowList(any())).thenReturn(true) + + assertEquals(DrmPolicyDecision(GRANT, PROTECTIONS_OFF), testee.decide(url, tabId)) + } + + @Test + fun whenUrlInUnprotectedTemporaryThenGrantWithProtectionsOff() = runTest { + whenever(mockUnprotectedTemporary.isAnException(url)).thenReturn(true) + + assertEquals(DrmPolicyDecision(GRANT, PROTECTIONS_OFF), testee.decide(url, tabId)) + } + + @Test + fun whenBlockListDomainIsAlsoUnprotectedThenGrantWithProtectionsOff() = runTest { + // DrmBlock's composite already returns false for unprotected sites, so the block rule never fires here. + whenever(mockDrmBlock.isDrmBlockedForUrl(url)).thenReturn(false) + whenever(mockUserAllowListRepository.isUriInUserAllowList(any())).thenReturn(true) + whenever(mockDrm.isDrmAllowedForUrl(url)).thenReturn(true) + + assertEquals(DrmPolicyDecision(GRANT, PROTECTIONS_OFF), testee.decide(url, tabId)) + } + + @Test + fun whenUrlInAllowListThenGrantWithAllowList() = runTest { + whenever(mockDrm.isDrmAllowedForUrl(url)).thenReturn(true) + + assertEquals(DrmPolicyDecision(GRANT, ALLOW_LIST), testee.decide(url, tabId)) + } + + @Test + fun whenNoRuleMatchesThenPromptWithNoRule() = runTest { + assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide(url, tabId)) + } + + @Test + fun whenDenyAlwaysStoredOnWwwDomainAndRequestIsForBareDomainThenDenyWithUserDenyAlways() = runTest { + val entity = SitePermissionsEntity(domain = domain, askDrmSetting = SitePermissionAskSettingType.DENY_ALWAYS.name) + whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite(domain)).thenReturn(entity) + whenever(mockDrm.isDrmAllowedForUrl("https://netflix.com")).thenReturn(true) + + assertEquals(DrmPolicyDecision(DENY, USER_DENY_ALWAYS), testee.decide("https://netflix.com", tabId)) + } + + @Test + fun whenDenyAlwaysStoredOnBareDomainAndRequestIsForWwwDomainThenDenyWithUserDenyAlways() = runTest { + val entity = SitePermissionsEntity(domain = "netflix.com", askDrmSetting = SitePermissionAskSettingType.DENY_ALWAYS.name) + whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite("netflix.com")).thenReturn(entity) + whenever(mockDrm.isDrmAllowedForUrl(url)).thenReturn(true) + + assertEquals(DrmPolicyDecision(DENY, USER_DENY_ALWAYS), testee.decide(url, tabId)) + } + + @Test + fun whenDenyAlwaysStoredOnParentDomainAndRequestIsFromSubdomainThenDenyWithUserDenyAlways() = runTest { + val entity = SitePermissionsEntity(domain = "foxnews.com", askDrmSetting = SitePermissionAskSettingType.DENY_ALWAYS.name) + whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite("foxnews.com")).thenReturn(entity) + whenever(mockDrm.isDrmAllowedForUrl("https://static.foxnews.com/")).thenReturn(true) + + assertEquals(DrmPolicyDecision(DENY, USER_DENY_ALWAYS), testee.decide("https://static.foxnews.com/", tabId)) + } + + @Test + fun whenSettingStoredOnSubdomainThenParentDomainRequestIsUnaffected() = runTest { + val entity = SitePermissionsEntity(domain = "static.foxnews.com", askDrmSetting = SitePermissionAskSettingType.DENY_ALWAYS.name) + whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite("static.foxnews.com")).thenReturn(entity) + + assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide("https://foxnews.com/", tabId)) + } + + @Test + fun whenAnotherPermissionIsSavedOnOneSpellingThenDrmChoiceOnTheOtherStillApplies() = runTest { + val cameraOnly = SitePermissionsEntity(domain = domain, askCameraSetting = SitePermissionAskSettingType.ALLOW_ALWAYS.name) + val drmDenied = SitePermissionsEntity(domain = "netflix.com", askDrmSetting = SitePermissionAskSettingType.DENY_ALWAYS.name) + whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite(domain)).thenReturn(cameraOnly) + whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite("netflix.com")).thenReturn(drmDenied) + whenever(mockDrm.isDrmAllowedForUrl(url)).thenReturn(true) + + assertEquals(DrmPolicyDecision(DENY, USER_DENY_ALWAYS), testee.decide(url, tabId)) + } + + @Test + fun whenOnlyAskEveryTimeIsStoredThenNoUserRuleApplies() = runTest { + val askEveryTime = SitePermissionsEntity(domain = domain) + whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite(domain)).thenReturn(askEveryTime) + + assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide(url, tabId)) + } + + @Test + fun whenDenyAlwaysStoredOnWwwParentAndRequestIsFromSubdomainThenDenyWithUserDenyAlways() = runTest { + val entity = SitePermissionsEntity(domain = "www.foxnews.com", askDrmSetting = SitePermissionAskSettingType.DENY_ALWAYS.name) + whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite("www.foxnews.com")).thenReturn(entity) + whenever(mockDrm.isDrmAllowedForUrl("https://static.foxnews.com/")).thenReturn(true) + + assertEquals(DrmPolicyDecision(DENY, USER_DENY_ALWAYS), testee.decide("https://static.foxnews.com/", tabId)) + } + + @Test + fun whenDenyAlwaysStoredOnIntermediateParentThenSubdomainRequestIsDenied() = runTest { + val entity = SitePermissionsEntity(domain = "open.spotify.com", askDrmSetting = SitePermissionAskSettingType.DENY_ALWAYS.name) + whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite("open.spotify.com")).thenReturn(entity) + whenever(mockDrm.isDrmAllowedForUrl("https://static.open.spotify.com/")).thenReturn(true) + + assertEquals(DrmPolicyDecision(DENY, USER_DENY_ALWAYS), testee.decide("https://static.open.spotify.com/", tabId)) + } + + @Test + fun whenUrlIsMalformedThenPromptWithNoRule() = runTest { + assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide("not a url", tabId)) + } + + @Test + fun whenUrlIsIpAddressThenPromptWithNoRule() = runTest { + assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide("https://192.168.0.1", tabId)) + } + + @Test + fun whenUrlHostIsPublicSuffixThenPromptWithNoRule() = runTest { + assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide("https://co.uk", tabId)) + } + + private suspend fun givenSiteSetting(setting: SitePermissionAskSettingType) { + val entity = SitePermissionsEntity(domain = domain, askDrmSetting = setting.name) + whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite(domain)).thenReturn(entity) + } +} diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlockTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlockTest.kt index 2c41a8a3e54f..354bf2d6c829 100644 --- a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlockTest.kt +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlockTest.kt @@ -18,9 +18,11 @@ package com.duckduckgo.site.permissions.impl.drmblock import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.app.privacy.db.UserAllowListRepository +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.FeatureException import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.privacy.config.api.UnprotectedTemporary +import com.duckduckgo.site.permissions.impl.feature.DrmPolicyFeature import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.whenever @@ -39,14 +41,23 @@ class RealDrmBlockTest { private val mockDrmBlockRepository: DrmBlockRepository = mock() private val mockUserAllowListRepository: UserAllowListRepository = mock() private val mockUnprotectedTemporary: UnprotectedTemporary = mock() + private val drmPolicyFeature = FakeFeatureToggleFactory.create(DrmPolicyFeature::class.java) - val testee: RealDrmBlock = RealDrmBlock(mockDrmBlockFeature, mockDrmBlockRepository, mockUserAllowListRepository, mockUnprotectedTemporary) + val testee: RealDrmBlock = RealDrmBlock( + mockDrmBlockFeature, + mockDrmBlockRepository, + mockUserAllowListRepository, + mockUnprotectedTemporary, + drmPolicyFeature, + ) val url = "https://open.spotify.com" @Before fun before() { whenever(mockDrmBlockFeature.self()).thenReturn(mockToggle) + drmPolicyFeature.self().setRawStoredState(Toggle.State(true)) + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(false)) } @Test @@ -88,6 +99,85 @@ class RealDrmBlockTest { assertTrue(testee.isDrmBlockedForUrl(url)) } + @Test + fun whenSubdomainOfBlockedDomainThenTrueIsReturned() { + givenCentralPolicyIsEnabled() + givenFeatureIsEnabled() + givenUrlIsInExceptionList() + + assertTrue(testee.isDrmBlockedForUrl("https://static.open.spotify.com/player")) + } + + @Test + fun whenParentOfBlockedDomainThenFalseIsReturned() { + givenCentralPolicyIsEnabled() + givenFeatureIsEnabled() + givenUrlIsInExceptionList() + + assertFalse(testee.isDrmBlockedForUrl("https://spotify.com")) + } + + @Test + fun whenProtectionsAreOffForThePageHostThenSubresourceOriginIsNotBlocked() { + givenCentralPolicyIsEnabled() + givenFeatureIsEnabled() + givenUrlIsInExceptionList() + whenever(mockUserAllowListRepository.isDomainInUserAllowList("www.spotify.com")).thenReturn(true) + + assertFalse(testee.isDrmBlockedForUrl("https://static.open.spotify.com/player")) + } + + @Test + fun whenProtectionsAreOffForTheRegistrableDomainThenSubresourceOriginIsNotBlocked() { + givenCentralPolicyIsEnabled() + givenFeatureIsEnabled() + givenUrlIsInExceptionList() + whenever(mockUserAllowListRepository.isDomainInUserAllowList("spotify.com")).thenReturn(true) + + assertFalse(testee.isDrmBlockedForUrl("https://static.open.spotify.com/player")) + } + + @Test + fun whenProtectionsAreOffForAnUnrelatedDomainThenBlockStands() { + givenCentralPolicyIsEnabled() + givenFeatureIsEnabled() + givenUrlIsInExceptionList() + whenever(mockUserAllowListRepository.isDomainInUserAllowList("example.com")).thenReturn(true) + + assertTrue(testee.isDrmBlockedForUrl("https://static.open.spotify.com/player")) + } + + @Test + fun whenProtectionsAreOffForAnIntermediateParentThenSubdomainIsNotBlocked() { + givenCentralPolicyIsEnabled() + givenFeatureIsEnabled() + givenUrlIsInExceptionList() + whenever(mockUserAllowListRepository.isDomainInUserAllowList("open.spotify.com")).thenReturn(true) + + assertFalse(testee.isDrmBlockedForUrl("https://static.open.spotify.com/player")) + } + + @Test + fun whenCentralPolicyDisabledThenSubdomainOfBlockedDomainIsNotBlocked() { + givenFeatureIsEnabled() + givenUrlIsInExceptionList() + + assertFalse(testee.isDrmBlockedForUrl("https://static.open.spotify.com/player")) + } + + @Test + fun whenCentralPolicyDisabledThenProtectionsOffForAParentDoesNotUnblock() { + givenFeatureIsEnabled() + givenUrlIsInExceptionList() + whenever(mockUserAllowListRepository.isDomainInUserAllowList("spotify.com")).thenReturn(true) + + assertTrue(testee.isDrmBlockedForUrl(url)) + } + + private fun givenCentralPolicyIsEnabled() { + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(true)) + } + private fun givenFeatureIsEnabled() { whenever(mockDrmBlockFeature.self().isEnabled()).thenReturn(true) }