diff --git a/dd-sdk-android-core/api/apiSurface b/dd-sdk-android-core/api/apiSurface index bb4bcf0e2f..a300ee0bea 100644 --- a/dd-sdk-android-core/api/apiSurface +++ b/dd-sdk-android-core/api/apiSurface @@ -284,6 +284,8 @@ data class com.datadog.android.core.configuration.Configuration fun setUploadSchedulerStrategy(UploadSchedulerStrategy?): Builder fun setVersion(String): Builder companion object +class com.datadog.android.core.configuration.HostPatternValidator + fun validate(List, String): List class com.datadog.android.core.configuration.HostsSanitizer fun sanitizeHosts(List, String): List enum com.datadog.android.core.configuration.UploadFrequency diff --git a/dd-sdk-android-core/api/dd-sdk-android-core.api b/dd-sdk-android-core/api/dd-sdk-android-core.api index 8c8ed66f27..945d8f58d2 100644 --- a/dd-sdk-android-core/api/dd-sdk-android-core.api +++ b/dd-sdk-android-core/api/dd-sdk-android-core.api @@ -773,6 +773,11 @@ public final class com/datadog/android/core/configuration/Configuration$Builder public final class com/datadog/android/core/configuration/Configuration$Companion { } +public final class com/datadog/android/core/configuration/HostPatternValidator { + public fun ()V + public final fun validate (Ljava/util/List;Ljava/lang/String;)Ljava/util/List; +} + public final class com/datadog/android/core/configuration/HostsSanitizer { public fun ()V public final fun sanitizeHosts (Ljava/util/List;Ljava/lang/String;)Ljava/util/List; diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/configuration/HostPatternValidator.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/configuration/HostPatternValidator.kt new file mode 100644 index 0000000000..1ba5f2297a --- /dev/null +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/configuration/HostPatternValidator.kt @@ -0,0 +1,75 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.core.configuration + +import com.datadog.android.api.InternalLogger +import com.datadog.android.core.internal.utils.unboundInternalLogger +import com.datadog.android.lint.InternalApi +import java.util.Locale + +/** + * Utility class to validate wildcard host patterns. + * + * A host pattern may contain at most one `*` wildcard and may only use the characters `a-z`, `0-9`, + * `.`, `-` and `*`. Plain host names (without a wildcard) are valid patterns too. This is the + * counterpart of [HostsSanitizer] for entries that are allowed to carry a wildcard. + */ +class HostPatternValidator { + + /** + * Validates the given host patterns, returning the valid ones lowercased and in their original + * order. Entries containing characters outside `[a-z0-9.*-]` or more than one `*` wildcard are + * dropped and a warning is logged. + * + * @param patterns Host patterns to validate. + * @param feature SDK feature requesting the validation. + */ + @InternalApi + fun validate( + patterns: List, + feature: String + ): List { + return patterns.mapNotNull { validatePattern(it, feature) } + } + + private fun validatePattern(pattern: String, feature: String): String? { + val lowercased = pattern.lowercase(Locale.US) + return when { + lowercased.contains(INVALID_CHARACTER) -> { + unboundInternalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + { ERROR_INVALID_CHARACTERS.format(Locale.US, pattern, feature) } + ) + null + } + lowercased.count { it == WILDCARD } > 1 -> { + unboundInternalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + { ERROR_MULTIPLE_WILDCARDS.format(Locale.US, pattern, feature) } + ) + null + } + else -> lowercased + } + } + + internal companion object { + private const val WILDCARD = '*' + + private val INVALID_CHARACTER = Regex("[^a-z0-9.*-]") + + internal const val ERROR_INVALID_CHARACTERS: String = + "You are using a malformed host pattern \"%s\" to setup %s tracking. It will be dropped. " + + "A host pattern may only contain lowercase letters, digits, '.', '-' and a single '*' wildcard." + + internal const val ERROR_MULTIPLE_WILDCARDS: String = + "You are using a host pattern \"%s\" with more than one wildcard to setup %s tracking. " + + "It will be dropped. A host pattern may contain at most one '*' wildcard." + } +} diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/configuration/HostPatternValidatorTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/configuration/HostPatternValidatorTest.kt new file mode 100644 index 0000000000..b4a0eb0e2c --- /dev/null +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/configuration/HostPatternValidatorTest.kt @@ -0,0 +1,209 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.core.configuration + +import com.datadog.android.api.InternalLogger +import com.datadog.android.utils.config.InternalLoggerTestConfiguration +import com.datadog.tools.unit.annotations.TestConfigurationsProvider +import com.datadog.tools.unit.extensions.TestConfigurationExtension +import com.datadog.tools.unit.extensions.config.TestConfiguration +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.annotation.StringForgeryType +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import java.util.Locale + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class), + ExtendWith(TestConfigurationExtension::class) +) +internal class HostPatternValidatorTest { + + lateinit var testedValidator: HostPatternValidator + + @StringForgery(type = StringForgeryType.ALPHABETICAL) + lateinit var fakeFeature: String + + @BeforeEach + fun `set up`() { + testedValidator = HostPatternValidator() + } + + @Test + fun `M keep valid wildcard patterns W validate()`() { + // Given + val patterns = listOf("*.example.com", "preview-*.shopist.io", "example.net") + + // When + val result = testedValidator.validate(patterns, fakeFeature) + + // Then + assertThat(result).isEqualTo(patterns) + } + + @Test + fun `M keep plain hosts W validate { no wildcard }`( + @StringForgery( + regex = "(([a-z0-9]|[a-z0-9][a-z0-9-]*[a-z0-9])\\.)+([a-z]|[a-z][a-z0-9-]*[a-z0-9])" + ) hosts: List + ) { + // When + val result = testedValidator.validate(hosts, fakeFeature) + + // Then + assertThat(result).isEqualTo(hosts) + } + + @Test + fun `M keep single wildcard W validate { star }`() { + // When + val result = testedValidator.validate(listOf("*"), fakeFeature) + + // Then + assertThat(result).containsExactly("*") + } + + @Test + fun `M keep empty string W validate { blank }`() { + // When + val result = testedValidator.validate(listOf(""), fakeFeature) + + // Then + assertThat(result).containsExactly("") + } + + @Test + fun `M lowercase patterns W validate { uppercase }`() { + // When + val result = testedValidator.validate( + listOf("*.SHOPIST.IO", "Preview-*.Example.COM"), + fakeFeature + ) + + // Then + assertThat(result).containsExactly("*.shopist.io", "preview-*.example.com") + } + + @Test + fun `M drop and warn W validate { more than one wildcard }`() { + // Given + val pattern = "*.foo.*.bar" + + // When + val result = testedValidator.validate(listOf(pattern), fakeFeature) + + // Then + assertThat(result).isEmpty() + val expectedMessage = HostPatternValidator.ERROR_MULTIPLE_WILDCARDS.format( + Locale.US, + pattern, + fakeFeature + ) + argumentCaptor<() -> String> { + verify(logger.mockInternalLogger).log( + eq(InternalLogger.Level.WARN), + eq(InternalLogger.Target.USER), + capture(), + isNull(), + eq(false), + eq(null) + ) + assertThat(firstValue()).isEqualTo(expectedMessage) + } + } + + @Test + fun `M drop and warn W validate { invalid characters }`() { + // Given + val patterns = listOf("foo'bar.com", "back\\slash.com", "https://foo.com") + + // When + val result = testedValidator.validate(patterns, fakeFeature) + + // Then + assertThat(result).isEmpty() + val expectedMessages = patterns.map { + HostPatternValidator.ERROR_INVALID_CHARACTERS.format(Locale.US, it, fakeFeature) + } + argumentCaptor<() -> String> { + verify(logger.mockInternalLogger, times(patterns.size)).log( + eq(InternalLogger.Level.WARN), + eq(InternalLogger.Target.USER), + capture(), + isNull(), + eq(false), + eq(null) + ) + assertThat(allValues.map { it() }) + .containsExactlyInAnyOrderElementsOf(expectedMessages) + } + } + + @Test + fun `M warn with original pattern W validate { uppercase invalid characters }`() { + // Given + val pattern = "FOO'BAR.com" + + // When + testedValidator.validate(listOf(pattern), fakeFeature) + + // Then + val expectedMessage = HostPatternValidator.ERROR_INVALID_CHARACTERS.format( + Locale.US, + pattern, + fakeFeature + ) + argumentCaptor<() -> String> { + verify(logger.mockInternalLogger).log( + eq(InternalLogger.Level.WARN), + eq(InternalLogger.Target.USER), + capture(), + isNull(), + eq(false), + eq(null) + ) + assertThat(firstValue()).isEqualTo(expectedMessage) + } + } + + @Test + fun `M keep valid and drop invalid W validate { mixed }`() { + // Given + val validWildcard = "*.shopist.io" + val plainHost = "example.com" + val multiWildcard = "*.foo.*.bar" + val invalidChars = "foo'bar.com" + val patterns = listOf(validWildcard, multiWildcard, plainHost, invalidChars) + + // When + val result = testedValidator.validate(patterns, fakeFeature) + + // Then + assertThat(result).containsExactly(validWildcard, plainHost) + } + + companion object { + val logger = InternalLoggerTestConfiguration() + + @TestConfigurationsProvider + @JvmStatic + fun getTestConfigurations(): List { + return listOf(logger) + } + } +} diff --git a/detekt_custom_safe_calls.yml b/detekt_custom_safe_calls.yml index 7b7c8805a6..aca87a7ee5 100644 --- a/detekt_custom_safe_calls.yml +++ b/detekt_custom_safe_calls.yml @@ -853,6 +853,7 @@ datadog: - "kotlin.collections.List.mapNotNull(kotlin.Function1)" - "kotlin.collections.List.maxOrNull()" - "kotlin.collections.List.orEmpty()" + - "kotlin.collections.List.partition(kotlin.Function1)" - "kotlin.collections.List.reduceOrNull(kotlin.Function2)" - "kotlin.collections.List.reversed()" - "kotlin.collections.List.shuffled()" @@ -1223,10 +1224,12 @@ datadog: - "kotlin.Any?.hashCode()" - "kotlin.CharSequence.isNullOrEmpty()" - "kotlin.String.all(kotlin.Function1)" + - "kotlin.String.any(kotlin.Function1)" - "kotlin.String.codePoints()" - "kotlin.String.constructor()" - "kotlin.String.contains(kotlin.Char, kotlin.Boolean)" - "kotlin.String.contains(kotlin.CharSequence, kotlin.Boolean)" + - "kotlin.String.contains(kotlin.text.Regex)" - "kotlin.String.count(kotlin.Function1)" - "kotlin.String.endsWith(kotlin.Char, kotlin.Boolean)" - "kotlin.String.endsWith(kotlin.String, kotlin.Boolean)" diff --git a/features/dd-sdk-android-webview/api/apiSurface b/features/dd-sdk-android-webview/api/apiSurface index ac4c7d8f9f..ee3653f44c 100644 --- a/features/dd-sdk-android-webview/api/apiSurface +++ b/features/dd-sdk-android-webview/api/apiSurface @@ -1,5 +1,6 @@ object com.datadog.android.webview.WebViewTracking fun enable(android.webkit.WebView, List, Float = 100f, com.datadog.android.api.SdkCore = Datadog.getInstance()) + fun enableWithPatterns(android.webkit.WebView, List, Float = 100f, com.datadog.android.api.SdkCore = Datadog.getInstance()) class _InternalWebViewProxy constructor(com.datadog.android.api.SdkCore, String? = null) fun consumeWebviewEvent(String) diff --git a/features/dd-sdk-android-webview/api/dd-sdk-android-webview.api b/features/dd-sdk-android-webview/api/dd-sdk-android-webview.api index 5f73cdf17d..a8d2b06743 100644 --- a/features/dd-sdk-android-webview/api/dd-sdk-android-webview.api +++ b/features/dd-sdk-android-webview/api/dd-sdk-android-webview.api @@ -4,6 +4,10 @@ public final class com/datadog/android/webview/WebViewTracking { public static final fun enable (Landroid/webkit/WebView;Ljava/util/List;F)V public static final fun enable (Landroid/webkit/WebView;Ljava/util/List;FLcom/datadog/android/api/SdkCore;)V public static synthetic fun enable$default (Landroid/webkit/WebView;Ljava/util/List;FLcom/datadog/android/api/SdkCore;ILjava/lang/Object;)V + public static final fun enableWithPatterns (Landroid/webkit/WebView;Ljava/util/List;)V + public static final fun enableWithPatterns (Landroid/webkit/WebView;Ljava/util/List;F)V + public static final fun enableWithPatterns (Landroid/webkit/WebView;Ljava/util/List;FLcom/datadog/android/api/SdkCore;)V + public static synthetic fun enableWithPatterns$default (Landroid/webkit/WebView;Ljava/util/List;FLcom/datadog/android/api/SdkCore;ILjava/lang/Object;)V } public final class com/datadog/android/webview/WebViewTracking$_InternalWebViewProxy { diff --git a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/WebViewTracking.kt b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/WebViewTracking.kt index 366812f56d..98004f981a 100644 --- a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/WebViewTracking.kt +++ b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/WebViewTracking.kt @@ -74,6 +74,81 @@ object WebViewTracking { allowedHosts: List, @FloatRange(from = 0.0, to = 100.0) logsSampleRate: Float = 100f, sdkCore: SdkCore = Datadog.getInstance() + ) { + enableInternal( + webView = webView, + allowedHosts = allowedHosts, + allowedHostPatterns = emptyList(), + logsSampleRate = logsSampleRate, + sdkCore = sdkCore + ) + } + + /** + * Attach the bridge to track events from the WebView as part of the same session, matching the + * web page's URL host against a list of wildcard host patterns. + * + * This is the pattern-matching counterpart of [enable]. Use it when the hosts you want to track + * cannot be enumerated up front (e.g. ephemeral preview URLs or multi-tenant subdomains). + * + * This method must be called from the Main Thread. + * Please note that: + * - you need to enable the JavaScript support in the WebView settings for this feature + * to be functional: + * ``` + * webView.settings.javaScriptEnabled = true + * ``` + * - by default, navigation will happen outside of your application (in a browser or a different app). To prevent + * that and ensure Datadog can track the full WebView user journey, attach a [android.webkit.WebViewClient] to your + * WebView, as following: + * ``` + * webView.webViewClient = WebViewClient() + * ``` + * The WebView events will not be tracked unless the web page's URL Host matches one of the + * provided patterns. + * + * Each wildcard pattern may contain at most a single `*` wildcard (matching any sequence of + * characters) and may only use the characters `a-z`, `0-9`, `.`, `-` and `*`. Such patterns are + * lowercased before matching, and entries containing other characters or more than one wildcard + * are dropped with a warning. + * + * Plain host names (without a wildcard) are accepted too: they are validated and sanitized exactly + * like the hosts passed to [enable] (and matched exactly or as a subdomain suffix), so plain hosts + * behave identically across both APIs. + * + * @param webView the webView on which to attach the bridge. + * @param hostPatterns a list of wildcard host patterns that you want to track when loaded in the + * WebView (e.g.: `listOf("*.example.com", "preview-*.shopist.io", "example.net")`). + * @param logsSampleRate the sample rate for logs coming from the WebView, in percent. A value of `30` means we'll + * send 30% of the logs. If value is `0`, no logs will be sent to Datadog. Default is 100.0 (ie: all logs are sent). + * @param sdkCore SDK instance on which to attach the bridge. + * [More here](https://developer.android.com/guide/webapps/webview#HandlingNavigation). + */ + @MainThread + @JvmOverloads + @JvmStatic + fun enableWithPatterns( + webView: WebView, + hostPatterns: List, + @FloatRange(from = 0.0, to = 100.0) logsSampleRate: Float = 100f, + sdkCore: SdkCore = Datadog.getInstance() + ) { + enableInternal( + webView = webView, + allowedHosts = emptyList(), + allowedHostPatterns = hostPatterns, + logsSampleRate = logsSampleRate, + sdkCore = sdkCore + ) + } + + @MainThread + private fun enableInternal( + webView: WebView, + allowedHosts: List, + allowedHostPatterns: List, + logsSampleRate: Float, + sdkCore: SdkCore ) { val featureSdkCore = sdkCore as FeatureSdkCore if (!webView.settings.javaScriptEnabled) { @@ -98,7 +173,13 @@ object WebViewTracking { .getFeature(WebViewRumFeature.WEB_RUM_FEATURE_NAME) ?.unwrap() as? WebViewRumFeature webView.addJavascriptInterface( - DatadogEventBridge(webViewEventConsumer, allowedHosts, privacyLevel, webViewRumFeature), + DatadogEventBridge( + webViewEventConsumer, + allowedHosts, + allowedHostPatterns, + privacyLevel, + webViewRumFeature + ), DATADOG_EVENT_BRIDGE_NAME ) featureSdkCore.internalLogger.logApiUsage { diff --git a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/DatadogEventBridge.kt b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/DatadogEventBridge.kt index e9084400b0..17454dcc8e 100644 --- a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/DatadogEventBridge.kt +++ b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/DatadogEventBridge.kt @@ -7,6 +7,7 @@ package com.datadog.android.webview.internal import android.webkit.JavascriptInterface +import com.datadog.android.core.configuration.HostPatternValidator import com.datadog.android.core.configuration.HostsSanitizer import com.datadog.android.core.sampling.DeterministicSampler import com.datadog.android.internal.sampling.DeterministicSampling @@ -24,6 +25,7 @@ import com.google.gson.JsonArray internal class DatadogEventBridge( internal val webViewEventConsumer: WebViewEventConsumer, private val allowedHosts: List, + private val allowedHostPatterns: List, private val privacyLevel: String, private val webViewRumFeature: WebViewRumFeature? ) { @@ -33,7 +35,7 @@ internal class DatadogEventBridge( /** * Called from the browser-sdk side whenever there is a new RUM/LOG event * available related with the tracked WebView. - * @param event as the bundled web event as a Json string + * @param event as the bundled web event as a JSON string */ @JavascriptInterface fun send(event: String) { @@ -49,8 +51,19 @@ internal class DatadogEventBridge( fun getAllowedWebViewHosts(): String { // We need to use a JsonArray here otherwise it cannot be parsed on the JS side val origins = JsonArray() + // Plain (wildcard-free) entries are validated and sanitized exactly like the hosts passed to + // WebViewTracking.enable(...), so plain hosts behave identically across both APIs. Only entries + // carrying a wildcard go through the pattern validator. + val (wildcardPatterns, plainHostPatterns) = allowedHostPatterns.partition { pattern -> + pattern.any { it == WILDCARD } + } HostsSanitizer() - .sanitizeHosts(allowedHosts, WEB_VIEW_TRACKING_FEATURE_NAME) + .sanitizeHosts(allowedHosts + plainHostPatterns, WEB_VIEW_TRACKING_FEATURE_NAME) + .forEach { + origins.add(it) + } + HostPatternValidator() + .validate(wildcardPatterns, WEB_VIEW_TRACKING_FEATURE_NAME) .forEach { origins.add(it) } @@ -97,7 +110,7 @@ internal class DatadogEventBridge( } val combinedRate = DeterministicSampling.combinedSampleRate(sessionSampleRate, traceSampleRate) - val sampler = DeterministicSampler( + val sampler = DeterministicSampler( SessionSamplingIdProvider::provideId, combinedRate ) @@ -110,6 +123,8 @@ internal class DatadogEventBridge( companion object { internal const val WEB_VIEW_TRACKING_FEATURE_NAME = "WebView" + private const val WILDCARD = '*' + private const val SESSION_ID_KEY = "session_id" private const val SESSION_STATE_KEY = "session_state" private const val SESSION_SAMPLE_RATE_KEY = "session_sample_rate" diff --git a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/WebViewTrackingTest.kt b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/WebViewTrackingTest.kt index efbfe632a5..ed37db7b61 100644 --- a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/WebViewTrackingTest.kt +++ b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/WebViewTrackingTest.kt @@ -351,6 +351,99 @@ internal class WebViewTrackingTest { ) } + @Test + fun `M attach the bridge W enableWithPatterns`(@Forgery fakeUrls: List) { + // Given + val fakeHostPatterns = fakeUrls.map { it.host } + val mockSettings: WebSettings = mock { + whenever(it.javaScriptEnabled).thenReturn(true) + } + val mockWebView: WebView = mock { + whenever(it.settings).thenReturn(mockSettings) + } + + // When + WebViewTracking.enableWithPatterns(mockWebView, fakeHostPatterns, sdkCore = mockCore) + + // Then + verify(mockWebView).addJavascriptInterface( + argThat { this is DatadogEventBridge }, + eq(WebViewTracking.DATADOG_EVENT_BRIDGE_NAME) + ) + } + + @Test + fun `M pass valid patterns to the bridge W enableWithPatterns`() { + // Given + val fakeHostPatterns = listOf("*.example.com", "preview-*.shopist.io", "example.net") + val mockSettings: WebSettings = mock { + whenever(it.javaScriptEnabled).thenReturn(true) + } + val mockWebView: WebView = mock { + whenever(it.settings).thenReturn(mockSettings) + } + + // When + WebViewTracking.enableWithPatterns(mockWebView, fakeHostPatterns, sdkCore = mockCore) + + // Then + argumentCaptor { + verify(mockWebView).addJavascriptInterface( + capture(), + eq(WebViewTracking.DATADOG_EVENT_BRIDGE_NAME) + ) + assertThat(firstValue.getAllowedWebViewHosts()) + .isEqualTo("[\"example.net\",\"*.example.com\",\"preview-*.shopist.io\"]") + } + } + + @Test + fun `M attach the bridge and send a warn log W enableWithPatterns { javascript not enabled }`() { + // Given + val fakeHostPatterns = listOf("*.example.com") + val mockSettings: WebSettings = mock { + whenever(it.javaScriptEnabled).thenReturn(false) + } + val mockWebView: WebView = mock { + whenever(it.settings).thenReturn(mockSettings) + } + + // When + WebViewTracking.enableWithPatterns(mockWebView, fakeHostPatterns, sdkCore = mockCore) + + // Then + verify(mockWebView).addJavascriptInterface( + argThat { this is DatadogEventBridge }, + eq(WebViewTracking.DATADOG_EVENT_BRIDGE_NAME) + ) + mockInternalLogger.verifyLog( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + WebViewTracking.JAVA_SCRIPT_NOT_ENABLED_WARNING_MESSAGE + ) + } + + @Test + fun `M send telemetry W enableWithPatterns`() { + // Given + val fakeHostPatterns = listOf("*.example.com") + val mockSettings: WebSettings = mock { + whenever(it.javaScriptEnabled).thenReturn(true) + } + val mockWebView: WebView = mock { + whenever(it.settings).thenReturn(mockSettings) + } + + // When + WebViewTracking.enableWithPatterns(mockWebView, fakeHostPatterns, sdkCore = mockCore) + + // Then + verify(mockInternalLogger).logApiUsage( + any(), + argThat { this() is InternalTelemetryEvent.ApiUsage.TrackWebView } + ) + } + @Test fun `M create a default WebEventConsumer W enable()`( @Forgery fakeUrls: List @@ -648,7 +741,7 @@ internal class WebViewTrackingTest { val feature = it.getArgument(0) feature.onInitialize(mock()) } - val fakeFeaturesContext = mapOf>( + val fakeFeaturesContext = mapOf( "rum" to mapOf( "application_id" to fakeApplicationId, "session_id" to fakeSessionId, diff --git a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/DatadogEventBridgeTest.kt b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/DatadogEventBridgeTest.kt index 37202bcd0d..8553062c83 100644 --- a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/DatadogEventBridgeTest.kt +++ b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/DatadogEventBridgeTest.kt @@ -49,6 +49,7 @@ internal class DatadogEventBridgeTest { testedDatadogEventBridge = DatadogEventBridge( mockWebViewEventConsumer, emptyList(), + emptyList(), fakePrivacyLevel, mockWebViewRumFeature ) @@ -72,7 +73,13 @@ internal class DatadogEventBridgeTest { ) { // Given val expectedHosts = hosts.joinToString(",", prefix = "[", postfix = "]") { "\"$it\"" } - testedDatadogEventBridge = DatadogEventBridge(mock(), hosts, fakePrivacyLevel, mockWebViewRumFeature) + testedDatadogEventBridge = DatadogEventBridge( + mock(), + hosts, + emptyList(), + fakePrivacyLevel, + mockWebViewRumFeature + ) // When val allowedWebViewHosts = testedDatadogEventBridge.getAllowedWebViewHosts() @@ -93,6 +100,7 @@ internal class DatadogEventBridgeTest { testedDatadogEventBridge = DatadogEventBridge( mockWebViewEventConsumer, hosts, + emptyList(), fakePrivacyLevel, mockWebViewRumFeature ) @@ -116,6 +124,7 @@ internal class DatadogEventBridgeTest { testedDatadogEventBridge = DatadogEventBridge( mockWebViewEventConsumer, hosts, + emptyList(), fakePrivacyLevel, mockWebViewRumFeature ) @@ -127,6 +136,110 @@ internal class DatadogEventBridgeTest { assertThat(allowedWebViewHosts).isEqualTo(expectedHosts) } + @Test + fun `M return sanitized plain hosts and wildcard patterns W getAllowedWebViewHosts() { wildcard patterns }`() { + // Given + val patterns = listOf("*.example.com", "preview-*.shopist.io", "example.net") + testedDatadogEventBridge = DatadogEventBridge( + mockWebViewEventConsumer, + emptyList(), + patterns, + fakePrivacyLevel, + mockWebViewRumFeature + ) + + // When + val allowedWebViewHosts = testedDatadogEventBridge.getAllowedWebViewHosts() + + // Then + // Plain hosts are sanitized first (exactly like enable), then wildcard patterns are appended. + assertThat(allowedWebViewHosts) + .isEqualTo("[\"example.net\",\"*.example.com\",\"preview-*.shopist.io\"]") + } + + @Test + fun `M drop invalid patterns W getAllowedWebViewHosts() { invalid patterns }`() { + // Given + val patterns = listOf("*.example.com", "*.foo.*.bar", "in valid", "EXAMPLE.NET") + testedDatadogEventBridge = DatadogEventBridge( + mockWebViewEventConsumer, + emptyList(), + patterns, + fakePrivacyLevel, + mockWebViewRumFeature + ) + + // When + val allowedWebViewHosts = testedDatadogEventBridge.getAllowedWebViewHosts() + + // Then + // "in valid" is dropped by the host sanitizer, "*.foo.*.bar" by the pattern validator. + // "EXAMPLE.NET" is a plain host so it keeps the sanitizer's behavior (no lowercasing), like enable. + assertThat(allowedWebViewHosts).isEqualTo("[\"EXAMPLE.NET\",\"*.example.com\"]") + } + + @Test + fun `M drop bare TLD and empty entries W getAllowedWebViewHosts() { edge cases }`() { + // Given + val patterns = listOf("com", "", "*", "*.example.com") + testedDatadogEventBridge = DatadogEventBridge( + mockWebViewEventConsumer, + emptyList(), + patterns, + fakePrivacyLevel, + mockWebViewRumFeature + ) + + // When + val allowedWebViewHosts = testedDatadogEventBridge.getAllowedWebViewHosts() + + // Then + // "com" (bare TLD) and "" are wildcard-free, so HostsSanitizer drops them, like enable(). + // "*" is a valid match-all pattern and is kept. + assertThat(allowedWebViewHosts).isEqualTo("[\"*\",\"*.example.com\"]") + } + + @Test + fun `M sanitize plain hosts like enable W getAllowedWebViewHosts() { url in patterns }`() { + // Given + val patterns = listOf("https://foo.com", "*.shopist.io") + testedDatadogEventBridge = DatadogEventBridge( + mockWebViewEventConsumer, + emptyList(), + patterns, + fakePrivacyLevel, + mockWebViewRumFeature + ) + + // When + val allowedWebViewHosts = testedDatadogEventBridge.getAllowedWebViewHosts() + + // Then + // "https://foo.com" is a wildcard-free entry, so it is URL-stripped to its host exactly like + // enable(...) rather than being dropped for containing invalid characters. + assertThat(allowedWebViewHosts).isEqualTo("[\"foo.com\",\"*.shopist.io\"]") + } + + @Test + fun `M merge sanitized hosts and patterns W getAllowedWebViewHosts() { mixed }`() { + // Given + val hosts = listOf("example.com") + val patterns = listOf("*.shopist.io") + testedDatadogEventBridge = DatadogEventBridge( + mockWebViewEventConsumer, + hosts, + patterns, + fakePrivacyLevel, + mockWebViewRumFeature + ) + + // When + val allowedWebViewHosts = testedDatadogEventBridge.getAllowedWebViewHosts() + + // Then + assertThat(allowedWebViewHosts).isEqualTo("[\"example.com\",\"*.shopist.io\"]") + } + @Test fun `M return the provided privacy level W getPrivacyLevel()`() { // When @@ -197,6 +310,7 @@ internal class DatadogEventBridgeTest { testedDatadogEventBridge = DatadogEventBridge( mockWebViewEventConsumer, emptyList(), + emptyList(), fakePrivacyLevel, null )