From d6004738e6f98ca5fc3194c43acf25504f19d504 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 1 Oct 2026 07:28:00 -0400 Subject: [PATCH 01/17] Support obfuscated feature flag assignment keys --- .../api/dd-sdk-android-flags.api | 8 + .../dd-sdk-android-flags/build.gradle.kts | 10 + .../internal/FlagKeyObfuscationDeviceTest.kt | 60 ++++ .../internal/evaluation/EvaluationsManager.kt | 29 +- .../internal/model/FlagKeyObfuscation.kt | 82 ++++++ .../flags/internal/model/FlagsStateEntry.kt | 3 +- .../PrecomputedAssignmentsRequestFactory.kt | 16 +- .../persistence/FlagsPersistenceManager.kt | 7 +- .../persistence/FlagsStateDeserializer.kt | 8 +- .../persistence/FlagsStateSerializer.kt | 11 +- .../repository/DefaultFlagsRepository.kt | 47 ++- .../internal/repository/FlagsRepository.kt | 8 +- .../repository/net/PrecomputeMapper.kt | 10 +- .../flags/internal/FlagKeyObfuscationTest.kt | 275 ++++++++++++++++++ .../evaluation/EvaluationsManagerTest.kt | 128 +++++++- ...recomputedAssignmentsRequestFactoryTest.kt | 29 +- .../repository/net/PrecomputeMapperTest.kt | 52 ++-- 17 files changed, 711 insertions(+), 72 deletions(-) create mode 100644 features/dd-sdk-android-flags/src/androidTest/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationDeviceTest.kt create mode 100644 features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt create mode 100644 features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt diff --git a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api index 2c4f599dec..2140026c08 100644 --- a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api +++ b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api @@ -1,3 +1,11 @@ +public final class com/datadog/android/flags/BuildConfig { + public static final field BUILD_TYPE Ljava/lang/String; + public static final field DEBUG Z + public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public static final field SDK_VERSION_NAME Ljava/lang/String; + public fun ()V +} + public abstract interface class com/datadog/android/flags/EvaluationContextCallback { public abstract fun onFailure (Ljava/lang/Throwable;)V public abstract fun onSuccess ()V diff --git a/features/dd-sdk-android-flags/build.gradle.kts b/features/dd-sdk-android-flags/build.gradle.kts index 3a52437f0c..8857b70576 100644 --- a/features/dd-sdk-android-flags/build.gradle.kts +++ b/features/dd-sdk-android-flags/build.gradle.kts @@ -4,6 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ +import com.datadog.gradle.config.AndroidConfig import com.datadog.gradle.utils.createJsonModelsGenerationTask import org.jetbrains.kotlin.gradle.dsl.JvmTarget @@ -48,6 +49,13 @@ createJsonModelsGenerationTask("generateFlagsModelsFromJson") { @Suppress("DEPRECATION") android { namespace = "com.datadog.android.flags" + buildFeatures { + buildConfig = true + } + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + buildConfigField("String", "SDK_VERSION_NAME", "\"${AndroidConfig.VERSION.name}\"") + } } dependencies { @@ -75,6 +83,8 @@ dependencies { testImplementation(testFixtures(project(":dd-sdk-android-core"))) testImplementation(testFixtures(project(":features:dd-sdk-android-rum"))) testImplementation(libs.okHttpMock) + androidTestImplementation(libs.androidXTestRunner) + androidTestImplementation(libs.androidXTestJUnitExt) unmock(libs.robolectric) } diff --git a/features/dd-sdk-android-flags/src/androidTest/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationDeviceTest.kt b/features/dd-sdk-android-flags/src/androidTest/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationDeviceTest.kt new file mode 100644 index 0000000000..a69cea5147 --- /dev/null +++ b/features/dd-sdk-android-flags/src/androidTest/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationDeviceTest.kt @@ -0,0 +1,60 @@ +/* + * 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.flags.internal + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.datadog.android.api.InternalLogger +import com.datadog.android.flags.internal.model.FlagKeyObfuscation +import com.datadog.android.flags.internal.model.FlagsStateEntry +import com.datadog.android.flags.internal.persistence.FlagsStateDeserializer +import com.datadog.android.flags.internal.persistence.FlagsStateSerializer +import com.datadog.android.flags.internal.repository.net.PrecomputeMapper +import com.datadog.android.flags.model.EvaluationContext +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class FlagKeyObfuscationDeviceTest { + @Test + fun resolvesEncodedAssignmentAfterCacheRoundTrip() { + val fakeKey = "🚲/旗" + val fakeDigest = "94b611e0d3b26b52f6ad66013d1c72f8a92ea109390049759e75d3c8d3aa4dab" + val fakeAttributes = JSONObject( + """ + {"obfuscated":true, + "obfuscation":{"scheme":"flag-key-sha256-v1","salt":"000102030405060708090a0b0c0d0e0f"}, + "flags":{"$fakeDigest":{ + "variationType":"boolean","variationValue":true,"doLog":true, + "allocationKey":"allocation","variationKey":"variation", + "reason":"TARGETING_MATCH","serialId":123,"extraLogging":{} + }}} + """.trimIndent() + ) + val logger = InternalLogger.UNBOUND + val testedMapper = PrecomputeMapper(logger) + val response = JSONObject().put("data", JSONObject().put("attributes", fakeAttributes)).toString() + val assignments = checkNotNull(testedMapper.map(response)) + assertEquals(fakeDigest, assignments.obfuscation?.encode(fakeKey)) + val entry = + FlagsStateEntry(EvaluationContext("subject", emptyMap()), assignments.flags, 123L, assignments.obfuscation) + val serialized = FlagsStateSerializer(logger).serialize(entry) + assertFalse(JSONObject(serialized).has("flags")) + val restored = checkNotNull(FlagsStateDeserializer(logger).deserialize(serialized)) + val flag = checkNotNull(restored.flags[restored.obfuscation?.encode(fakeKey)]) + assertEquals( + true, + FlagValueConverter.convert(flag.variationValue, flag.variationType, Boolean::class).getOrThrow() + ) + assertEquals("variation", flag.variationKey) + assertEquals(123L, flag.serialId) + assertNull(checkNotNull(FlagKeyObfuscation.read(fakeAttributes)).encode("\ud800")) + } +} diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt index dddfde0c14..509be37b56 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt @@ -97,8 +97,8 @@ internal class EvaluationsManager( * Processes a new evaluation context by fetching flags and storing atomically. * * This method asynchronously fetches precomputed flag evaluations for the given context - * and atomically updates both the context and flag data in the repository. Network failures - * result in an empty flag set being stored with the context, allowing graceful degradation. + * and atomically updates the context, assignments, and encoding descriptor. Network and decode + * failures retain the last assignments and mark matching cached assignments as stale. * * The operation is performed on the configured executor service and will not block the * calling thread. Errors are logged but do not propagate to the caller. @@ -107,6 +107,7 @@ internal class EvaluationsManager( * a valid targeting key. * @param callback Optional callback invoked when the context is set and the flags have been fetched successfully or not. */ + @Suppress("LongMethod") // Keep response acceptance and timeout completion in the same operation. fun updateEvaluationsForContext(context: EvaluationContext, callback: EvaluationContextCallback? = null) { val matchingCachedAssignments = AtomicBoolean(false) val initializationCompletion = startInitializationTimeout(context, callback, matchingCachedAssignments) { @@ -126,14 +127,18 @@ internal class EvaluationsManager( { "Processing evaluation context: ${context.targetingKey}" } ) + val supportsObfuscation = datadogContext.source == "android" + flagsRepository.setObfuscationSupported(supportsObfuscation) val hadFlags = flagsRepository.hasFlags() matchingCachedAssignments.set( hadFlags && flagsRepository.getEvaluationContext() == context ) val response = assignmentsReader.readPrecomputedFlags(context, datadogContext) - if (response != null) { - val flagsMap = precomputeMapper.map(response) - flagsRepository.setFlagsAndContext(context, flagsMap) + val assignments = response?.let { precomputeMapper.map(it) } + ?.takeIf { it.obfuscation == null || supportsObfuscation } + if (assignments != null) { + val flagsMap = assignments.flags + flagsRepository.setFlagsAndContext(context, flagsMap, assignments.obfuscation) internalLogger.log( InternalLogger.Level.DEBUG, InternalLogger.Target.MAINTAINER, @@ -148,13 +153,22 @@ internal class EvaluationsManager( } completionCallback?.onSuccess() } else { + val failureMessage = if (response == null) { + NETWORK_REQUEST_FAILED_MESSAGE + } else { + DECODE_FAILED_MESSAGE + } internalLogger.log( InternalLogger.Level.WARN, InternalLogger.Target.USER, - { NETWORK_REQUEST_FAILED_MESSAGE } + { failureMessage } ) - val throwable = NetworkRequestFailedException(NETWORK_REQUEST_FAILED_MESSAGE) + val throwable = if (response == null) { + NetworkRequestFailedException(NETWORK_REQUEST_FAILED_MESSAGE) + } else { + IllegalArgumentException(DECODE_FAILED_MESSAGE) + } // Only use cached flags if they match the requested context to avoid // serving flags from a different user/context. val completionCallback = synchronized(initializationTerminalLock) { @@ -209,6 +223,7 @@ internal class EvaluationsManager( } companion object { + private const val DECODE_FAILED_MESSAGE = "Unable to decode feature flag assignments" private const val FETCH_AND_STORE_OPERATION_NAME = "Fetch and store flags for evaluation context" private const val NETWORK_REQUEST_FAILED_MESSAGE = "Unable to fetch feature flags. Please check your network connection." diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt new file mode 100644 index 0000000000..4ea3a1d786 --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt @@ -0,0 +1,82 @@ +/* + * 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.flags.internal.model + +import org.json.JSONException +import org.json.JSONObject +import java.security.MessageDigest + +/** The public encoding descriptor travels with its assignment map. */ +@ConsistentCopyVisibility +internal data class FlagKeyObfuscation private constructor(val salt: String) { + @Suppress("UnsafeThirdPartyFunctionCall") // SHA-256 is mandatory on Android; salt and Unicode are validated. + fun encode(key: String): String? { + // Reject unpaired surrogates instead of silently replacing their UTF-8 encoding. + if (!isWellFormedUnicode(key)) return null + val digest = MessageDigest.getInstance("SHA-256") + digest.update("datadog.feature-flags.flag-key.v1\u0000".toByteArray(Charsets.UTF_8)) + digest.update(ByteArray(SALT_BYTES) { salt.substring(it * 2, it * 2 + 2).toInt(HEX_RADIX).toByte() }) + digest.update(key.toByteArray(Charsets.UTF_8)) + return digest.digest().joinToString("") { "%02x".format(it) } + } + + @Suppress("UnsafeThirdPartyFunctionCall") // Both fields have validated, non-null names and values. + fun toJson(): JSONObject = JSONObject().put("scheme", SCHEME).put("salt", salt) + + companion object { + const val SCHEME = "flag-key-sha256-v1" + private const val SALT_BYTES = 16 + private const val HEX_RADIX = 16 + private const val DIGEST_BYTES = 32 + + /** Throws for unsupported or inconsistent descriptors, including explicit JSON null. */ + // JSON errors propagate to the response or cache parser, where they are caught. + @Suppress("UnsafeThirdPartyFunctionCall", "ThrowingInternalException", "ThrowsCount") + fun read(json: JSONObject): FlagKeyObfuscation? { + val obfuscated = json.opt("obfuscated") + if ((obfuscated == null || obfuscated == false) && !json.has("obfuscation")) return null + if (obfuscated != true) throw JSONException("Invalid flag-key obfuscation metadata") + val descriptor = json.getJSONObject("obfuscation") + if (descriptor.opt("scheme") != SCHEME) throw JSONException("Unsupported flag-key obfuscation scheme") + val salt = descriptor.opt("salt") + if (salt !is String || !isLowercaseHex(salt, SALT_BYTES)) { + throw JSONException("Flag-key salt must contain 32 lowercase hexadecimal characters") + } + return FlagKeyObfuscation(salt) + } + + // Caught by the response or cache parser. + @Suppress("UnsafeThirdPartyFunctionCall", "ThrowingInternalException") + fun validateKeys(keys: Iterator) { + keys.forEach { + if (!isLowercaseHex(it, DIGEST_BYTES)) throw JSONException("Invalid encoded flag key") + } + } + + private fun isLowercaseHex(value: String, bytes: Int): Boolean = + value.length == bytes * 2 && value.all { it in '0'..'9' || it in 'a'..'f' } + + @Suppress("UnsafeThirdPartyFunctionCall", "ReturnCount") // Character predicates cannot throw. + private fun isWellFormedUnicode(value: String): Boolean { + var index = 0 + while (index < value.length) { + val char = value[index++] + if (char.isHighSurrogate()) { + if (index >= value.length || !value[index++].isLowSurrogate()) return false + } else if (char.isLowSurrogate()) { + return false + } + } + return true + } + } +} + +internal data class PrecomputedAssignments( + val flags: Map, + val obfuscation: FlagKeyObfuscation? = null +) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagsStateEntry.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagsStateEntry.kt index d61d3f383f..b2de7aa2f0 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagsStateEntry.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagsStateEntry.kt @@ -11,5 +11,6 @@ import com.datadog.android.flags.model.EvaluationContext internal data class FlagsStateEntry( val evaluationContext: EvaluationContext, val flags: Map, - val lastUpdateTimestamp: Long + val lastUpdateTimestamp: Long, + val obfuscation: FlagKeyObfuscation? = null ) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt index e7414d824c..edf8fb902b 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt @@ -9,12 +9,15 @@ package com.datadog.android.flags.internal.net import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.feature.Feature +import com.datadog.android.flags.BuildConfig import com.datadog.android.flags.internal.getFlagsEndpoint +import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.model.EvaluationContext import okhttp3.Headers import okhttp3.Request import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray import org.json.JSONException import org.json.JSONObject @@ -89,11 +92,18 @@ internal class PrecomputedAssignmentsRequestFactory( .put("targeting_key", context.targetingKey) .put("targeting_attributes", attributeObj) val env = buildEnvPayload(datadogContext) - val source = buildSourcePayload(datadogContext) + val source = buildSourcePayload() val attributes = JSONObject() .put("env", env) .put("source", source) .put("subject", subject) + // Bridge SDKs that consume raw snapshots cannot resolve encoded keys yet. + if (datadogContext.source == "android") { + attributes.put( + "supported_capabilities", + JSONObject().put("assignment_encodings", JSONArray().put(FlagKeyObfuscation.SCHEME)) + ) + } val data = JSONObject() .put("type", "precompute-assignments-request") .put("attributes", attributes) @@ -129,10 +139,10 @@ internal class PrecomputedAssignmentsRequestFactory( .put("dd_env", datadogContext.env) @Suppress("UnsafeThirdPartyFunctionCall") // call wrapped in try/catch - private fun buildSourcePayload(datadogContext: DatadogContext): JSONObject = + private fun buildSourcePayload(): JSONObject = JSONObject() .put("sdk_name", SDK_NAME) - .put("sdk_version", datadogContext.sdkVersion) + .put("sdk_version", BuildConfig.SDK_VERSION_NAME) private val DatadogContext.rumApplicationId: String? get() = featuresContext.get(Feature.RUM_FEATURE_NAME) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsPersistenceManager.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsPersistenceManager.kt index 9563a6eb8d..de263f0990 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsPersistenceManager.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsPersistenceManager.kt @@ -11,6 +11,7 @@ import com.datadog.android.api.storage.datastore.DataStoreHandler import com.datadog.android.api.storage.datastore.DataStoreReadCallback import com.datadog.android.api.storage.datastore.DataStoreWriteCallback import com.datadog.android.core.persistence.datastore.DataStoreContent +import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.internal.model.FlagsStateEntry import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.model.EvaluationContext @@ -33,12 +34,14 @@ internal class FlagsPersistenceManager( context: EvaluationContext, flags: Map, currentTimestamp: Long, - callback: DataStoreWriteCallback? = null + callback: DataStoreWriteCallback? = null, + obfuscation: FlagKeyObfuscation? = null ) { val entry = FlagsStateEntry( evaluationContext = context, flags = flags, - lastUpdateTimestamp = currentTimestamp + lastUpdateTimestamp = currentTimestamp, + obfuscation = obfuscation ) dataStore.setValue( diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializer.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializer.kt index d890fc4303..4739d3f7af 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializer.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializer.kt @@ -8,6 +8,7 @@ package com.datadog.android.flags.internal.persistence import com.datadog.android.api.InternalLogger import com.datadog.android.core.internal.persistence.Deserializer +import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.internal.model.FlagsStateEntry import com.datadog.android.flags.internal.model.JsonKeys import com.datadog.android.flags.internal.model.PrecomputedFlag @@ -26,7 +27,9 @@ internal class FlagsStateDeserializer(private val internalLogger: InternalLogger val evaluationContext = deserializeEvaluationContext(contextJson) @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch - val flagsJson = json.getJSONObject(JsonKeys.FLAGS.value) + val obfuscation = FlagKeyObfuscation.read(json) + val flagsJson = json.getJSONObject(if (obfuscation == null) JsonKeys.FLAGS.value else "encodedFlags") + if (obfuscation != null) FlagKeyObfuscation.validateKeys(flagsJson.keys()) val flags = deserializeFlags(flagsJson) @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch @@ -35,7 +38,8 @@ internal class FlagsStateDeserializer(private val internalLogger: InternalLogger FlagsStateEntry( evaluationContext = evaluationContext, flags = flags, - lastUpdateTimestamp = timestamp + lastUpdateTimestamp = timestamp, + obfuscation = obfuscation ) } catch (e: JSONException) { internalLogger.log( diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializer.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializer.kt index e950b6bdda..8ac90101b8 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializer.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializer.kt @@ -19,7 +19,7 @@ import org.json.JSONObject */ internal class FlagsStateSerializer(private val internalLogger: InternalLogger) : Serializer { - @Suppress("TooGenericExceptionCaught") + @Suppress("TooGenericExceptionCaught", "UnsafeThirdPartyFunctionCall") // JSON operations are caught below. override fun serialize(model: FlagsStateEntry): String = try { val json = JSONObject() @@ -33,7 +33,14 @@ internal class FlagsStateSerializer(private val internalLogger: InternalLogger) model.flags.forEach { (key, flag) -> flagsJson.put(key, serializePrecomputedFlag(flag)) } - json.put(JsonKeys.FLAGS.value, flagsJson) + if (model.obfuscation == null) { + json.put(JsonKeys.FLAGS.value, flagsJson) + } else { + // Older SDKs must reject this cache instead of reading digests as original keys. + json.put("encodedFlags", flagsJson) + json.put("obfuscated", true) + json.put("obfuscation", model.obfuscation.toJson()) + } json.put(JsonKeys.LAST_UPDATE_TIMESTAMP.value, model.lastUpdateTimestamp) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt index 485ec3a58e..6cbc57cccc 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt @@ -10,11 +10,13 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.api.storage.datastore.DataStoreHandler import com.datadog.android.api.storage.datastore.DataStoreWriteCallback +import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.internal.persistence.FlagsPersistenceManager import com.datadog.android.flags.model.EvaluationContext import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference internal class DefaultFlagsRepository( @@ -24,8 +26,16 @@ internal class DefaultFlagsRepository( private val internalLogger: InternalLogger = featureSdkCore.internalLogger, private val persistenceLoadTimeoutMs: Long = PERSISTENCE_LOAD_TIMEOUT_MS ) : FlagsRepository { - private data class FlagsState(val context: EvaluationContext, val flags: Map) + private data class FlagsState( + val context: EvaluationContext, + val flags: Map, + val obfuscation: FlagKeyObfuscation? + ) { + fun get(key: String): PrecomputedFlag? = + if (obfuscation == null) flags[key] else obfuscation.encode(key)?.let { flags[it] } + } private val atomicState = AtomicReference(null) + private val obfuscationSupported = AtomicBoolean(true) @Suppress("UnsafeThirdPartyFunctionCall") // Safe: count is positive constant (1) private val persistenceLoadedLatch = CountDownLatch(1) @@ -37,16 +47,21 @@ internal class DefaultFlagsRepository( ) { persistedState -> try { persistedState?.let { - val loadedState = FlagsState(it.evaluationContext, it.flags) + val loadedState = FlagsState(it.evaluationContext, it.flags, it.obfuscation) atomicState.compareAndSet(null, loadedState) + discardUnsupportedAssignments() } } finally { persistenceLoadedLatch.countDown() } } - override fun setFlagsAndContext(context: EvaluationContext, flags: Map) { - val newState = FlagsState(context, flags) + override fun setFlagsAndContext( + context: EvaluationContext, + flags: Map, + obfuscation: FlagKeyObfuscation? + ) { + val newState = FlagsState(context, flags, obfuscation) atomicState.set(newState) persistenceLoadedLatch.countDown() @@ -54,7 +69,7 @@ internal class DefaultFlagsRepository( context = context, flags = flags, currentTimestamp = featureSdkCore.timeProvider.getDeviceTimestampMillis(), - object : DataStoreWriteCallback { + callback = object : DataStoreWriteCallback { override fun onSuccess() { } @@ -65,15 +80,28 @@ internal class DefaultFlagsRepository( messageBuilder = { ERROR_SAVING_FLAGS_STATE } ) } - } + }, + obfuscation = obfuscation ) } + override fun setObfuscationSupported(supported: Boolean) { + obfuscationSupported.set(supported) + discardUnsupportedAssignments() + } + + @Suppress("UnsafeThirdPartyFunctionCall") // The atomic update only filters an immutable state value. + private fun discardUnsupportedAssignments() { + if (!obfuscationSupported.get()) { + atomicState.updateAndGet { state -> state?.takeIf { it.obfuscation == null } } + } + } + override fun getPrecomputedFlag(key: String): PrecomputedFlag? { waitForPersistenceLoad() val state = atomicState.get() if (state != null) { - return state.flags[key] + return state.get(key) } internalLogger.log( InternalLogger.Level.WARN, @@ -87,7 +115,8 @@ internal class DefaultFlagsRepository( waitForPersistenceLoad() val state = atomicState.get() if (state != null) { - return state.flags + // The React Native bridge consumes original keys and cannot decode this representation. + return if (state.obfuscation == null) state.flags else emptyMap() } internalLogger.log( InternalLogger.Level.WARN, @@ -116,7 +145,7 @@ internal class DefaultFlagsRepository( override fun getPrecomputedFlagWithContext(key: String): Pair? { waitForPersistenceLoad() val state = atomicState.get() ?: return null - val flag = state.flags[key] ?: return null + val flag = state.get(key) ?: return null return flag to state.context } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/FlagsRepository.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/FlagsRepository.kt index 247b4d2b9c..09ecd8ee41 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/FlagsRepository.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/FlagsRepository.kt @@ -6,6 +6,7 @@ package com.datadog.android.flags.internal.repository +import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.model.EvaluationContext import com.datadog.tools.annotation.NoOpImplementation @@ -14,7 +15,12 @@ import com.datadog.tools.annotation.NoOpImplementation internal interface FlagsRepository { fun getPrecomputedFlag(key: String): PrecomputedFlag? fun getEvaluationContext(): EvaluationContext? - fun setFlagsAndContext(context: EvaluationContext, flags: Map) + fun setFlagsAndContext( + context: EvaluationContext, + flags: Map, + obfuscation: FlagKeyObfuscation? = null + ) + fun setObfuscationSupported(supported: Boolean) fun getPrecomputedFlagWithContext(key: String): Pair? fun hasFlags(): Boolean fun hasLoadedFlagsForContext(context: EvaluationContext): Boolean diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapper.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapper.kt index 6366ed0744..89c4a75d3d 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapper.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapper.kt @@ -7,7 +7,9 @@ package com.datadog.android.flags.internal.repository.net import com.datadog.android.api.InternalLogger +import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.internal.model.JsonKeys +import com.datadog.android.flags.internal.model.PrecomputedAssignments import com.datadog.android.flags.internal.model.PrecomputedFlag import org.json.JSONException import org.json.JSONObject @@ -19,11 +21,13 @@ internal class PrecomputeMapper(private val internalLogger: InternalLogger) { // JSONObject methods accept non-null String parameters despite Detekt's incorrect nullable interpretation // All getJsonObject calls are wrapped in try-catch for JSONException which is the actual exception thrown @Suppress("UnsafeThirdPartyFunctionCall") - internal fun map(rawJson: String): Map = try { + internal fun map(rawJson: String): PrecomputedAssignments? = try { val jsonResponse = JSONObject(rawJson) val data = jsonResponse.getJSONObject("data") val attributes = data.getJSONObject("attributes") val flags = attributes.getJSONObject("flags") + val obfuscation = FlagKeyObfuscation.read(attributes) + if (obfuscation != null) FlagKeyObfuscation.validateKeys(flags.keys()) val flagsMap = mutableMapOf() @@ -51,7 +55,7 @@ internal class PrecomputeMapper(private val internalLogger: InternalLogger) { flagsMap[flagKey] = precomputedFlag } - flagsMap + PrecomputedAssignments(flagsMap, obfuscation) } catch (e: JSONException) { internalLogger.log( level = InternalLogger.Level.WARN, @@ -68,7 +72,7 @@ internal class PrecomputeMapper(private val internalLogger: InternalLogger) { onlyOnce = true ) - emptyMap() + null } private companion object { diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt new file mode 100644 index 0000000000..21b0b6cfbc --- /dev/null +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt @@ -0,0 +1,275 @@ +/* + * 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.flags.internal + +import com.datadog.android.api.InternalLogger +import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.api.storage.datastore.DataStoreHandler +import com.datadog.android.api.storage.datastore.DataStoreReadCallback +import com.datadog.android.core.persistence.datastore.DataStoreContent +import com.datadog.android.flags.FlagsConfiguration +import com.datadog.android.flags.internal.evaluation.EvaluationsManager +import com.datadog.android.flags.internal.model.FlagKeyObfuscation +import com.datadog.android.flags.internal.model.FlagsStateEntry +import com.datadog.android.flags.internal.persistence.FlagsStateDeserializer +import com.datadog.android.flags.internal.persistence.FlagsStateSerializer +import com.datadog.android.flags.internal.repository.DefaultFlagsRepository +import com.datadog.android.flags.internal.repository.net.PrecomputeMapper +import com.datadog.android.flags.internal.storage.RecordWriter +import com.datadog.android.flags.model.ErrorCode +import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.ExposureEvent +import org.assertj.core.api.Assertions.assertThat +import org.json.JSONObject +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +internal class FlagKeyObfuscationTest { + private val mockInternalLogger: InternalLogger = mock() + private val mockSdkCore: FeatureSdkCore = mock() + private val mockDataStore: DataStoreHandler = mock() + private val mockWriter: RecordWriter = mock() + private val mockRumLogger: RumEvaluationLogger = mock() + private val mockEvaluationsFeature: EvaluationsFeature = mock() + private val fakeContext = EvaluationContext("athlete-123", mapOf("team" to "cycling")) + private val testedMapper = PrecomputeMapper(mockInternalLogger) + private lateinit var testedRepository: DefaultFlagsRepository + private lateinit var testedClient: DatadogFlagsClient + + @BeforeEach + fun setUp() { + whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger + whenever(mockSdkCore.timeProvider) doReturn mock() + doAnswer { + it.getArgument>(2).onFailure() + }.whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) + testedRepository = DefaultFlagsRepository(mockSdkCore, "obfuscation", mockDataStore) + testedClient = DatadogFlagsClient( + featureSdkCore = mockSdkCore, + evaluationsManager = mock(), + flagsRepository = testedRepository, + flagsConfiguration = FlagsConfiguration.Builder().build(), + rumEvaluationLogger = mockRumLogger, + exposureProcessor = ExposureEventsProcessor(mockWriter, mockSdkCore.timeProvider), + evaluationsFeature = mockEvaluationsFeature, + flagStateManager = mock() + ) + } + + @ParameterizedTest + @MethodSource("vectors") + fun `M resolve original key W evaluation { shared edge hash vectors }`(vector: Pair) { + val (key, digest) = vector + val encoding = checkNotNull(FlagKeyObfuscation.read(metadata())) + assertThat(encoding.encode(key)).isEqualTo(digest) + install(payload(key, encoded = false)) + val plain = testedClient.resolve(key, false) + + install(payload(digest)) + + assertThat(testedClient.resolve(key, false)).isEqualTo(plain) + assertThat(testedClient.resolveBooleanValue(key, false)).isTrue() + verify(mockRumLogger, times(3)).logEvaluation(key, "variation-456") + verify( + mockEvaluationsFeature, + times(3) + ).processEvaluation(key, fakeContext, "variation-456", "allocation-123", null, null) + val exposure = argumentCaptor() + verify(mockWriter).write(exposure.capture()) + assertThat(exposure.firstValue.flag.key).isEqualTo(key) + assertThat(exposure.firstValue.serialId).isEqualTo(123L) + } + + @Test + fun `M preserve results and defaults W evaluation { encoded types and errors }`() { + val encoding = checkNotNull(FlagKeyObfuscation.read(metadata())) + val flags = JSONObject() + listOf( + "string" to "visible", + "integer" to 42, + "number" to 12.5, + "object" to JSONObject("{\"nested\":[42]}") + ).forEach { + flags.put(checkNotNull(encoding.encode(it.first)), assignment(it.first, it.second)) + } + flags.put(checkNotNull(encoding.encode("bad")), assignment("boolean", "invalid")) + val assignments = metadata().put("flags", flags) + install(JSONObject().put("data", JSONObject().put("attributes", assignments)).toString()) + + assertThat(testedClient.resolveStringValue("string", "default")).isEqualTo("visible") + assertThat(testedClient.resolveIntValue("integer", -1)).isEqualTo(42) + assertThat(testedClient.resolveDoubleValue("number", -1.0)).isEqualTo(12.5) + assertThat(testedClient.resolveStructureValue("object", emptyMap())).containsKey("nested") + assertThat(testedClient.resolve("integer", "default").errorCode) + .isEqualTo(ErrorCode.TYPE_MISMATCH) + assertThat(testedClient.resolve("bad", false).errorCode).isEqualTo(ErrorCode.PARSE_ERROR) + assertThat(testedClient.resolve("missing", false).errorCode).isEqualTo(ErrorCode.FLAG_NOT_FOUND) + } + + @Test + fun `M retain lookup and deduplicate exposure W refresh { salt changes and cache round trip }`() { + for (salt in listOf(SALT, "f".repeat(32), SALT)) { + val descriptor = metadata(salt) + val encoding = checkNotNull(FlagKeyObfuscation.read(descriptor)) + val decoded = checkNotNull(testedMapper.map(payload(checkNotNull(encoding.encode("flag")), salt = salt))) + val entry = FlagsStateEntry(fakeContext, decoded.flags, 1234L, decoded.obfuscation) + val serialized = FlagsStateSerializer(mockInternalLogger).serialize(entry) + // The legacy deserializer requires flags, so it cannot ingest encoded keys. + assertThat(JSONObject(serialized).has("flags")).isFalse() + val restored = checkNotNull(FlagsStateDeserializer(mockInternalLogger).deserialize(serialized)) + assertThat(restored.obfuscation).isEqualTo(encoding) + assertThat(restored.evaluationContext).isEqualTo(fakeContext) + testedRepository.setFlagsAndContext(restored.evaluationContext, restored.flags, restored.obfuscation) + assertThat(testedClient.resolveBooleanValue("flag", false)).isTrue() + assertThat(testedRepository.getPrecomputedFlag("flag")).isNotNull() + assertThat(testedRepository.getPrecomputedFlag(checkNotNull(encoding.encode("flag")))).isNull() + } + verify(mockWriter, times(1)).write(any()) + verify(mockRumLogger, times(3)).logEvaluation("flag", "variation-456") + } + + @Test + fun `M hide encoded snapshots and discard cache W bridge reads { legacy consumer }`() { + install(payload(VECTORS[2].second)) + assertThat(testedRepository.getFlagsSnapshot()).isEmpty() + testedRepository.setObfuscationSupported(false) + assertThat(testedRepository.hasFlags()).isFalse() + assertThat(testedRepository.getEvaluationContext()).isNull() + install(payload("flag", encoded = false)) + assertThat(testedRepository.getFlagsSnapshot()).containsKey("flag") + } + + @Test + fun `M reject late encoded cache W setObfuscationSupported() { React Native consumer }`() { + var cacheCallback: DataStoreReadCallback? = null + doAnswer { cacheCallback = it.getArgument(2) } + .whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) + val testedRepository = DefaultFlagsRepository(mockSdkCore, "late-cache", mockDataStore) + testedRepository.setObfuscationSupported(false) + val decoded = checkNotNull(testedMapper.map(payload(VECTORS[2].second))) + val entry = FlagsStateEntry(fakeContext, decoded.flags, 1234L, decoded.obfuscation) + + checkNotNull(cacheCallback).onSuccess(DataStoreContent(0, entry)) + + assertThat(testedRepository.getFlagsSnapshot()).isEmpty() + assertThat(testedRepository.hasFlags()).isFalse() + assertThat(testedRepository.getEvaluationContext()).isNull() + } + + @Test + fun `M restore encoded cache W constructor { native consumer }`() { + val decoded = checkNotNull(testedMapper.map(payload(VECTORS[2].second))) + val entry = FlagsStateEntry(fakeContext, decoded.flags, 1234L, decoded.obfuscation) + doAnswer { it.getArgument>(2).onSuccess(DataStoreContent(0, entry)) } + .whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) + + val testedRepository = DefaultFlagsRepository(mockSdkCore, "restored-cache", mockDataStore) + + assertThat(testedRepository.getPrecomputedFlagWithContext("flag")?.second).isEqualTo(fakeContext) + assertThat(testedRepository.getPrecomputedFlag("flag")?.variationValue).isEqualTo("true") + assertThat(testedRepository.getPrecomputedFlag(VECTORS[2].second)).isNull() + } + + @Test + fun `M reject invalid Unicode W evaluation { no replacement alias }`() { + install(payload(VECTORS[2].second)) + for (key in listOf("\ud800", "\udc00", "a\ud800b")) { + assertThat(testedClient.resolve(key, false).errorCode).isEqualTo(ErrorCode.FLAG_NOT_FOUND) + } + } + + @ParameterizedTest + @MethodSource("invalidMetadata") + fun `M reject response and cache W decode { invalid descriptor }`(json: String) { + val attrs = JSONObject(json).put("flags", JSONObject()) + assertThat( + testedMapper.map(JSONObject().put("data", JSONObject().put("attributes", attrs)).toString()) + ).isNull() + val entry = FlagsStateEntry(fakeContext, emptyMap(), 1234L) + val cache = JSONObject(FlagsStateSerializer(mockInternalLogger).serialize(entry)) + JSONObject(json).keys().forEach { cache.put(it, JSONObject(json).get(it)) } + assertThat(FlagsStateDeserializer(mockInternalLogger).deserialize(cache.toString())).isNull() + } + + @Test + fun `M reject malformed map keys W decode { encoded payload }`() { + for (key in listOf("plaintext", "a".repeat(63), "a".repeat(65), "A".repeat(64), "a".repeat(63) + "\n")) { + assertThat(testedMapper.map(payload(key))).isNull() + } + } + + @Test + fun `M accept legacy descriptors W decode { absent or false }`() { + for (attrs in listOf(JSONObject(), JSONObject().put("obfuscated", false))) { + attrs.put("flags", JSONObject().put("flag", assignment())) + install(JSONObject().put("data", JSONObject().put("attributes", attrs)).toString()) + assertThat(testedClient.resolveBooleanValue("flag", false)).isTrue() + } + } + + private fun install(response: String) { + val assignments = checkNotNull(testedMapper.map(response)) + testedRepository.setFlagsAndContext(fakeContext, assignments.flags, assignments.obfuscation) + } + + companion object { + private const val SALT = "000102030405060708090a0b0c0d0e0f" + private val VECTORS = listOf( + "new-route-planner" to "a60479237ef2f69175bbe0bd581966d1583766941815dc1d414c883767795190", + "Flag" to "adca75d2141c51b0c0f084c1058edfb8e6e763f91aaf54ca06326d9847bd9586", + "flag" to "9817872c144b018abd77e3915bd77e2c27f4f534dccff8ffaca27361e3a5e1ee", + " flag " to "b1a5f851cc82a3fdf03a461d72a2241584dcf455df1d384e02a937901b3bc80b", + "café" to "3bfa8c3c17c1b61035b98ecf14007cdf5cba5ce61a48e8cfb65f8106541892d3", + "cafe\u0301" to "1e8b7ec5e8028a1ec96b38dd37f6ccea041c0a90358904af40ac5810c23c8765", + "🚲/旗" to "94b611e0d3b26b52f6ad66013d1c72f8a92ea109390049759e75d3c8d3aa4dab", + "a\u0000b" to "bac134d201be5e7f28fc7019248f0809c2a013b137e866446ee71add2bc344c7", + "" to "072d985b427f536ad0a11b2d4c5e0f7e6f0ff6d23e779e082f75599ce3fe3eba" + ) + + @JvmStatic + fun vectors(): List> = VECTORS + + @JvmStatic + fun invalidMetadata(): List = listOf( + "{\"obfuscated\":true}", + "{\"obfuscated\":null}", + "{\"obfuscated\":true,\"obfuscation\":null}", + "{\"obfuscated\":true,\"obfuscation\":[]}", + metadata().put("obfuscated", "true").toString(), + metadata().put("obfuscated", false).toString(), + metadata().apply { remove("obfuscated") }.toString(), + metadata().apply { getJSONObject("obfuscation").put("scheme", "unknown") }.toString() + ) + listOf("", "0".repeat(30), "0".repeat(34), "G".repeat(32), SALT.uppercase(), "0".repeat(31) + "\n", 42) + .map { metadata().apply { getJSONObject("obfuscation").put("salt", it) }.toString() } + + private fun metadata(salt: String = SALT): JSONObject = JSONObject() + .put("obfuscated", true) + .put("obfuscation", JSONObject().put("scheme", FlagKeyObfuscation.SCHEME).put("salt", salt)) + + private fun assignment(type: String = "boolean", value: Any = true): JSONObject = JSONObject() + .put("variationType", type).put("variationValue", value).put("allocationKey", "allocation-123") + .put("variationKey", "variation-456").put("doLog", true).put("serialId", 123L) + .put("reason", "TARGETING_MATCH").put("extraLogging", JSONObject().put("test", "metadata")) + + private fun payload(key: String, encoded: Boolean = true, salt: String = SALT): String { + val attrs = if (encoded) metadata(salt) else JSONObject() + attrs.put("flags", JSONObject().put(key, assignment())) + return JSONObject().put("data", JSONObject().put("attributes", attrs)).toString() + } + } +} diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt index 06fb5d1b69..031438f32c 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt @@ -19,8 +19,11 @@ import com.datadog.android.flags.FlagsInitializationTimeoutException import com.datadog.android.flags.FlagsStateListener import com.datadog.android.flags.internal.FlagsStateManager import com.datadog.android.flags.internal.model.FlagsStateEntry +import com.datadog.android.flags.internal.model.PrecomputedAssignments import com.datadog.android.flags.internal.model.PrecomputedFlag +import com.datadog.android.flags.internal.net.PrecomputedAssignmentsDownloader import com.datadog.android.flags.internal.net.PrecomputedAssignmentsReader +import com.datadog.android.flags.internal.net.PrecomputedAssignmentsRequestFactory import com.datadog.android.flags.internal.repository.DefaultFlagsRepository import com.datadog.android.flags.internal.repository.FlagsRepository import com.datadog.android.flags.internal.repository.net.PrecomputeMapper @@ -32,6 +35,8 @@ import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.assertj.core.api.Assertions.assertThat import org.json.JSONObject @@ -183,7 +188,7 @@ internal class EvaluationsManagerTest { whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)) .thenReturn(mockResponse) - whenever(mockPrecomputeMapper.map(mockResponse)).thenReturn(expectedFlags) + whenever(mockPrecomputeMapper.map(mockResponse)).thenReturn(PrecomputedAssignments(expectedFlags)) // When evaluationsManager.updateEvaluationsForContext(context) @@ -245,13 +250,14 @@ internal class EvaluationsManagerTest { fakeDatadogContext ) ).thenReturn(invalidResponse) - whenever(mockPrecomputeMapper.map(invalidResponse)).thenReturn(emptyMap()) + whenever(mockPrecomputeMapper.map(invalidResponse)).thenReturn(null) // When evaluationsManager.updateEvaluationsForContext(context) // Then - verify(mockFlagsRepository).setFlagsAndContext(context, emptyMap()) + verify(mockFlagsRepository, times(0)).setFlagsAndContext(any(), any(), anyOrNull()) + verify(mockFlagsStateManager).updateState(any()) } @Test @@ -291,7 +297,7 @@ internal class EvaluationsManagerTest { whenever(mockAssignmentsDownloader.readPrecomputedFlags(publicContext, fakeDatadogContext)) .thenReturn(mockResponse) - whenever(mockPrecomputeMapper.map(mockResponse)).thenReturn(expectedFlags) + whenever(mockPrecomputeMapper.map(mockResponse)).thenReturn(PrecomputedAssignments(expectedFlags)) // When evaluationsManager.updateEvaluationsForContext(publicContext) @@ -373,7 +379,7 @@ internal class EvaluationsManagerTest { whenever(mockAssignmentsDownloader.readPrecomputedFlags(publicContext, fakeDatadogContext)) .thenReturn(jsonResponse) - whenever(mockPrecomputeMapper.map(jsonResponse)).thenReturn(flagsMap) + whenever(mockPrecomputeMapper.map(jsonResponse)).thenReturn(PrecomputedAssignments(flagsMap)) // When evaluationsManager.updateEvaluationsForContext(publicContext, callback = mockCallback) @@ -431,7 +437,7 @@ internal class EvaluationsManagerTest { whenever(mockAssignmentsDownloader.readPrecomputedFlags(publicContext, fakeDatadogContext)) .thenReturn(jsonResponse) - whenever(mockPrecomputeMapper.map(jsonResponse)).thenReturn(flagsMap) + whenever(mockPrecomputeMapper.map(jsonResponse)).thenReturn(PrecomputedAssignments(flagsMap)) // When/Then - should not throw assertDoesNotThrow { evaluationsManager.updateEvaluationsForContext(publicContext, callback = null) } @@ -451,7 +457,7 @@ internal class EvaluationsManagerTest { } whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)) .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) - whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(emptyMap()) + whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val testedManager = createManager( initializationTimeoutMs = 2_500L, scheduler = InitializationTimeoutScheduler { timeoutMs, action -> @@ -585,7 +591,7 @@ internal class EvaluationsManagerTest { } whenever(mockAssignmentsDownloader.readPrecomputedFlags(any(), eq(fakeDatadogContext))) .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) - whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(emptyMap()) + whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val stateManager = FlagsStateManager( DDCoreStateHolder.create( initialState = FlagsClientState.NotReady, @@ -734,7 +740,7 @@ internal class EvaluationsManagerTest { }.whenever(mockFlagsStateManager).updateState(any()) whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)) .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) - whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(emptyMap()) + whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val testedManager = createManager( initializationTimeoutMs = 2_500L, scheduler = InitializationTimeoutScheduler { _, action -> @@ -777,7 +783,7 @@ internal class EvaluationsManagerTest { var cancellationCount = 0 whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)) .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) - whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(emptyMap()) + whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val testedManager = createManager( initializationTimeoutMs = 2_500L, scheduler = InitializationTimeoutScheduler { _, action -> @@ -806,7 +812,7 @@ internal class EvaluationsManagerTest { .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenAnswer { checkNotNull(timeoutAction).invoke() - emptyMap() + PrecomputedAssignments(emptyMap()) } val testedManager = createManager( initializationTimeoutMs = 2_500L, @@ -833,7 +839,7 @@ internal class EvaluationsManagerTest { var scheduleCount = 0 whenever(mockAssignmentsDownloader.readPrecomputedFlags(any(), eq(fakeDatadogContext))) .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) - whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(emptyMap()) + whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val testedManager = createManager( initializationTimeoutMs = 2_500L, scheduler = InitializationTimeoutScheduler { _, _ -> @@ -858,7 +864,7 @@ internal class EvaluationsManagerTest { var scheduleCount = 0 whenever(mockAssignmentsDownloader.readPrecomputedFlags(any(), eq(fakeDatadogContext))) .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) - whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(emptyMap()) + whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val testedManager = createManager( initializationTimeoutMs = null, scheduler = InitializationTimeoutScheduler { _, _ -> @@ -913,7 +919,7 @@ internal class EvaluationsManagerTest { var timeoutAction: (() -> Unit)? = null whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)) .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) - whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(emptyMap()) + whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val stateManager = FlagsStateManager( DDCoreStateHolder.create( initialState = FlagsClientState.NotReady, @@ -1037,7 +1043,7 @@ internal class EvaluationsManagerTest { whenever(mockAssignmentsDownloader.readPrecomputedFlags(publicContext, fakeDatadogContext)) .thenReturn(jsonResponse) - whenever(mockPrecomputeMapper.map(jsonResponse)).thenReturn(flagsMap) + whenever(mockPrecomputeMapper.map(jsonResponse)).thenReturn(PrecomputedAssignments(flagsMap)) // When evaluationsManagerWithRealState.updateEvaluationsForContext(publicContext, callback = callback) @@ -1048,6 +1054,87 @@ internal class EvaluationsManagerTest { // region Cold-start integration + @Test + fun `M retain matching assignments on invalid encoding W updateEvaluationsForContext() { HTTP integration }`() { + fakeDatadogContext = fakeDatadogContext.copy(source = "android") + val context = EvaluationContext("athlete-123", emptyMap()) + val testedRepository = createIntegrationRepository() + val testedManager = createIntegrationManager(testedRepository) + val mockCallback = mock() + mockWebServer.enqueue(MockResponse().setBody(ENCODED_RESPONSE_JSON)) + + testedManager.updateEvaluationsForContext(context, mockCallback) + + assertThat(testedRepository.getPrecomputedFlag("flag")?.variationValue).isEqualTo("true") + verify(mockCallback).onSuccess() + val request = checkNotNull(mockWebServer.takeRequest(1, TimeUnit.SECONDS)) + assertThat(request.path).isEqualTo("/precompute-assignments") + val attributes = JSONObject(request.body.readUtf8()).getJSONObject("data").getJSONObject("attributes") + assertThat(attributes.getJSONObject("supported_capabilities").getJSONArray("assignment_encodings").getString(0)) + .isEqualTo("flag-key-sha256-v1") + mockWebServer.enqueue(MockResponse().setBody(ENCODED_RESPONSE_JSON.replace("flag-key-sha256-v1", "unknown"))) + + testedManager.updateEvaluationsForContext(context, mockCallback) + + verify(mockFlagsStateManager).updateState(FlagsClientState.Stale) + verify(mockCallback).onFailure(any()) + assertThat(testedRepository.getPrecomputedFlag("flag")?.variationValue).isEqualTo("true") + mockWebServer.enqueue(MockResponse().setBody(ENCODED_RESPONSE_JSON.replace("flag-key-sha256-v1", "unknown"))) + + testedManager.updateEvaluationsForContext(EvaluationContext("other-user", emptyMap()), mockCallback) + + verify(mockFlagsStateManager).updateState(any()) + assertThat(testedRepository.getEvaluationContext()).isEqualTo(context) + } + + @Test + fun `M reject unsolicited encoding W updateEvaluationsForContext() { React Native HTTP integration }`() { + fakeDatadogContext = fakeDatadogContext.copy(source = "react-native") + val context = EvaluationContext("athlete-123", emptyMap()) + val testedRepository = createIntegrationRepository() + val testedManager = createIntegrationManager(testedRepository) + val mockCallback = mock() + mockWebServer.enqueue(MockResponse().setBody(ENCODED_RESPONSE_JSON)) + + testedManager.updateEvaluationsForContext(context, mockCallback) + + verify(mockCallback).onFailure(any()) + assertThat(testedRepository.hasFlags()).isFalse() + assertThat(testedRepository.getFlagsSnapshot()).isEmpty() + val request = checkNotNull(mockWebServer.takeRequest(1, TimeUnit.SECONDS)) + val attributes = JSONObject(request.body.readUtf8()).getJSONObject("data").getJSONObject("attributes") + assertThat(attributes.has("supported_capabilities")).isFalse() + } + + private fun createIntegrationRepository(): DefaultFlagsRepository { + val mockDataStore = mock() + whenever(mockSdkCore.timeProvider) doReturn mock() + whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger + doAnswer { + it.getArgument>(2).onFailure() + }.whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) + return DefaultFlagsRepository(mockSdkCore, "http-test", mockDataStore) + } + + private fun createIntegrationManager(repository: FlagsRepository): EvaluationsManager = EvaluationsManager( + sdkCore = mockSdkCore, + executorService = mockExecutorService, + internalLogger = mockInternalLogger, + flagsRepository = repository, + assignmentsReader = PrecomputedAssignmentsDownloader( + OkHttpClient(), + mockInternalLogger, + PrecomputedAssignmentsRequestFactory( + mockInternalLogger, + mockWebServer.url("/precompute-assignments").toString() + ) + ), + precomputeMapper = PrecomputeMapper(mockInternalLogger), + flagStateManager = mockFlagsStateManager, + initializationTimeoutMs = null, + initializationTimeoutScheduler = { _, _ -> {} } + ) + @Test fun `M notify STALE W updateEvaluationsForContext() { cold start network failure, cached flags match context }`() { // Given @@ -1135,6 +1222,17 @@ internal class EvaluationsManagerTest { ) companion object { + private val ENCODED_RESPONSE_JSON = """ + {"data":{"attributes":{ + "obfuscated":true, + "obfuscation":{"scheme":"flag-key-sha256-v1","salt":"000102030405060708090a0b0c0d0e0f"}, + "flags":{"9817872c144b018abd77e3915bd77e2c27f4f534dccff8ffaca27361e3a5e1ee":{ + "variationType":"boolean","variationValue":true,"doLog":true, + "allocationKey":"allocation-123","variationKey":"variation-456", + "reason":"TARGETING_MATCH","serialId":123,"extraLogging":{} + }} + }}} + """.trimIndent() private const val EMPTY_FLAGS_RESPONSE_JSON = "{\"data\": {\"attributes\": {\"flags\": {}}}}" } } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt index 3a427c85f7..9fe9c996ec 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt @@ -10,6 +10,7 @@ import com.datadog.android.DatadogSite import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.feature.Feature +import com.datadog.android.flags.BuildConfig import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.utils.forge.ForgeConfigurator import fr.xgouchet.elmyr.Forge @@ -64,6 +65,32 @@ internal class PrecomputedAssignmentsRequestFactoryTest { // region create() - Success cases + @Test + fun `M negotiate encoding with flags version W create() { native source }`() { + val context = fakeDatadogContext.copy(source = "android", sdkVersion = "overridden-core-version") + val request = checkNotNull(testedFactory.create(EvaluationContext("subject", emptyMap()), context)) + val buffer = Buffer() + checkNotNull(request.body).writeTo(buffer) + val attributes = JSONObject(buffer.readUtf8()).getJSONObject("data").getJSONObject("attributes") + + assertThat(attributes.getJSONObject("supported_capabilities").getJSONArray("assignment_encodings").toString()) + .isEqualTo("[\"flag-key-sha256-v1\"]") + assertThat(attributes.getJSONObject("source").getString("sdk_version")).isEqualTo(BuildConfig.SDK_VERSION_NAME) + } + + @Test + fun `M omit encoding capability W create() { bridge sources }`() { + for (source in listOf("react-native", "flutter", "unknown")) { + val context = fakeDatadogContext.copy(source = source) + val request = checkNotNull(testedFactory.create(EvaluationContext("subject", emptyMap()), context)) + val buffer = Buffer() + checkNotNull(request.body).writeTo(buffer) + val attributes = JSONObject(buffer.readUtf8()).getJSONObject("data").getJSONObject("attributes") + + assertThat(attributes.has("supported_capabilities")).isFalse() + } + } + @Test fun `M create valid request W create() { all fields present }`( @StringForgery fakeTargetingKey: String @@ -188,7 +215,7 @@ internal class PrecomputedAssignmentsRequestFactoryTest { // Validate source val source = attributes.getJSONObject("source") assertThat(source.getString("sdk_name")).isEqualTo("dd-sdk-android") - assertThat(source.getString("sdk_version")).isEqualTo(fakeDatadogContext.sdkVersion) + assertThat(source.getString("sdk_version")).isEqualTo(BuildConfig.SDK_VERSION_NAME) } @Test diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapperTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapperTest.kt index d1dfaf1031..95d740d0bd 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapperTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapperTest.kt @@ -84,7 +84,7 @@ internal class PrecomputeMapperTest { ) // When - val result = testedMapper.map(json) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -113,7 +113,7 @@ internal class PrecomputeMapperTest { ) // When - val result = testedMapper.map(json) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -137,7 +137,7 @@ internal class PrecomputeMapperTest { ) // When - val result = testedMapper.map(json) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -161,7 +161,7 @@ internal class PrecomputeMapperTest { ) // When - val result = testedMapper.map(json) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -185,7 +185,7 @@ internal class PrecomputeMapperTest { ) // When - val result = testedMapper.map(json) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -213,7 +213,7 @@ internal class PrecomputeMapperTest { ) // When - val result = testedMapper.map(json) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -242,7 +242,7 @@ internal class PrecomputeMapperTest { ) // When - val result = testedMapper.map(json) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -277,7 +277,7 @@ internal class PrecomputeMapperTest { ) // When - val result = testedMapper.map(json) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(3) @@ -304,7 +304,7 @@ internal class PrecomputeMapperTest { val json = buildValidJson(emptyMap()) // When - val result = testedMapper.map(json) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).isEmpty() @@ -325,7 +325,7 @@ internal class PrecomputeMapperTest { val json = buildValidJson(mapOf(fakeFlagKey to flagJson)) // When - val result = testedMapper.map(json) + val result = checkNotNull(testedMapper.map(json)).flags // Then val flag = result[fakeFlagKey] @@ -344,7 +344,7 @@ internal class PrecomputeMapperTest { val json = buildValidJson(mapOf(fakeFlagKey to flagJson)) // When - val result = testedMapper.map(json) + val result = checkNotNull(testedMapper.map(json)).flags // Then val flag = result[fakeFlagKey] @@ -363,7 +363,7 @@ internal class PrecomputeMapperTest { val json = buildValidJson(mapOf(fakeFlagKey to flagJson)) // When - val result = testedMapper.map(json) + val result = checkNotNull(testedMapper.map(json)).flags // Then val flag = result[fakeFlagKey] @@ -385,7 +385,7 @@ internal class PrecomputeMapperTest { ) // When - val result = testedMapper.map(json) + val result = checkNotNull(testedMapper.map(json)).flags // Then val flag = result[fakeFlagKey] @@ -399,7 +399,7 @@ internal class PrecomputeMapperTest { // region Error Handling @Test - fun `M return empty map and log error W map() { invalid JSON }`() { + fun `M return null and log error W map() { invalid JSON }`() { // Given val invalidJson = "{ invalid json" @@ -407,7 +407,7 @@ internal class PrecomputeMapperTest { val result = testedMapper.map(invalidJson) // Then - assertThat(result).isEmpty() + assertThat(result).isNull() val messageCaptor = argumentCaptor<() -> String>() verify(mockInternalLogger).log( @@ -422,7 +422,7 @@ internal class PrecomputeMapperTest { } @Test - fun `M return empty map and log error W map() { missing data field }`() { + fun `M return null and log error W map() { missing data field }`() { // Given val jsonWithoutData = JSONObject().apply { put("notData", JSONObject()) @@ -432,7 +432,7 @@ internal class PrecomputeMapperTest { val result = testedMapper.map(jsonWithoutData) // Then - assertThat(result).isEmpty() + assertThat(result).isNull() val messageCaptor = argumentCaptor<() -> String>() verify(mockInternalLogger).log( @@ -447,7 +447,7 @@ internal class PrecomputeMapperTest { } @Test - fun `M return empty map and log error W map() { missing attributes field }`() { + fun `M return null and log error W map() { missing attributes field }`() { // Given val jsonWithoutAttributes = JSONObject().data { put("notAttributes", JSONObject()) @@ -457,7 +457,7 @@ internal class PrecomputeMapperTest { val result = testedMapper.map(jsonWithoutAttributes) // Then - assertThat(result).isEmpty() + assertThat(result).isNull() val messageCaptor = argumentCaptor<() -> String>() verify(mockInternalLogger).log( @@ -472,7 +472,7 @@ internal class PrecomputeMapperTest { } @Test - fun `M return empty map and log error W map() { missing flags field }`() { + fun `M return null and log error W map() { missing flags field }`() { // Given val jsonWithoutFlags = JSONObject().data { attributes { @@ -484,7 +484,7 @@ internal class PrecomputeMapperTest { val result = testedMapper.map(jsonWithoutFlags) // Then - assertThat(result).isEmpty() + assertThat(result).isNull() val messageCaptor = argumentCaptor<() -> String>() verify(mockInternalLogger).log( @@ -499,7 +499,7 @@ internal class PrecomputeMapperTest { } @Test - fun `M return empty map and log error W map() { missing required flag fields }`() { + fun `M return null and log error W map() { missing required flag fields }`() { // Given val jsonWithIncompleteFlag = JSONObject().data { attributes { @@ -516,7 +516,7 @@ internal class PrecomputeMapperTest { val result = testedMapper.map(jsonWithIncompleteFlag) // Then - assertThat(result).isEmpty() + assertThat(result).isNull() val messageCaptor = argumentCaptor<() -> String>() verify(mockInternalLogger).log( @@ -531,7 +531,7 @@ internal class PrecomputeMapperTest { } @Test - fun `M return empty map and log error W map() { empty JSON string }`() { + fun `M return null and log error W map() { empty JSON string }`() { // Given val emptyJson = "" @@ -539,7 +539,7 @@ internal class PrecomputeMapperTest { val result = testedMapper.map(emptyJson) // Then - assertThat(result).isEmpty() + assertThat(result).isNull() val messageCaptor = argumentCaptor<() -> String>() verify(mockInternalLogger).log( @@ -573,7 +573,7 @@ internal class PrecomputeMapperTest { }.toString() // When - val result = testedMapper.map(json) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) From 185a1fa917e3a97fab3c9b810b371d2a25ecc086 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 2 Oct 2026 15:43:33 -0400 Subject: [PATCH 02/17] fix(flags): keep capability negotiation independent of versions --- .../dd-sdk-android-flags/api/dd-sdk-android-flags.api | 8 -------- features/dd-sdk-android-flags/build.gradle.kts | 5 ----- .../internal/net/PrecomputedAssignmentsRequestFactory.kt | 7 +++---- .../flags/internal/repository/DefaultFlagsRepository.kt | 6 +++--- .../net/PrecomputedAssignmentsRequestFactoryTest.kt | 7 +++---- 5 files changed, 9 insertions(+), 24 deletions(-) diff --git a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api index 2140026c08..2c4f599dec 100644 --- a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api +++ b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api @@ -1,11 +1,3 @@ -public final class com/datadog/android/flags/BuildConfig { - public static final field BUILD_TYPE Ljava/lang/String; - public static final field DEBUG Z - public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; - public static final field SDK_VERSION_NAME Ljava/lang/String; - public fun ()V -} - public abstract interface class com/datadog/android/flags/EvaluationContextCallback { public abstract fun onFailure (Ljava/lang/Throwable;)V public abstract fun onSuccess ()V diff --git a/features/dd-sdk-android-flags/build.gradle.kts b/features/dd-sdk-android-flags/build.gradle.kts index 8857b70576..37f8db6a1b 100644 --- a/features/dd-sdk-android-flags/build.gradle.kts +++ b/features/dd-sdk-android-flags/build.gradle.kts @@ -4,7 +4,6 @@ * Copyright 2016-Present Datadog, Inc. */ -import com.datadog.gradle.config.AndroidConfig import com.datadog.gradle.utils.createJsonModelsGenerationTask import org.jetbrains.kotlin.gradle.dsl.JvmTarget @@ -49,12 +48,8 @@ createJsonModelsGenerationTask("generateFlagsModelsFromJson") { @Suppress("DEPRECATION") android { namespace = "com.datadog.android.flags" - buildFeatures { - buildConfig = true - } defaultConfig { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - buildConfigField("String", "SDK_VERSION_NAME", "\"${AndroidConfig.VERSION.name}\"") } } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt index edf8fb902b..c2cd6af5ef 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt @@ -9,7 +9,6 @@ package com.datadog.android.flags.internal.net import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.feature.Feature -import com.datadog.android.flags.BuildConfig import com.datadog.android.flags.internal.getFlagsEndpoint import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.model.EvaluationContext @@ -92,7 +91,7 @@ internal class PrecomputedAssignmentsRequestFactory( .put("targeting_key", context.targetingKey) .put("targeting_attributes", attributeObj) val env = buildEnvPayload(datadogContext) - val source = buildSourcePayload() + val source = buildSourcePayload(datadogContext) val attributes = JSONObject() .put("env", env) .put("source", source) @@ -139,10 +138,10 @@ internal class PrecomputedAssignmentsRequestFactory( .put("dd_env", datadogContext.env) @Suppress("UnsafeThirdPartyFunctionCall") // call wrapped in try/catch - private fun buildSourcePayload(): JSONObject = + private fun buildSourcePayload(datadogContext: DatadogContext): JSONObject = JSONObject() .put("sdk_name", SDK_NAME) - .put("sdk_version", BuildConfig.SDK_VERSION_NAME) + .put("sdk_version", datadogContext.sdkVersion) private val DatadogContext.rumApplicationId: String? get() = featuresContext.get(Feature.RUM_FEATURE_NAME) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt index 6cbc57cccc..8cb5d7ba64 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt @@ -90,10 +90,10 @@ internal class DefaultFlagsRepository( discardUnsupportedAssignments() } - @Suppress("UnsafeThirdPartyFunctionCall") // The atomic update only filters an immutable state value. private fun discardUnsupportedAssignments() { - if (!obfuscationSupported.get()) { - atomicState.updateAndGet { state -> state?.takeIf { it.obfuscation == null } } + while (!obfuscationSupported.get()) { + val state = atomicState.get() ?: return + if (state.obfuscation == null || atomicState.compareAndSet(state, null)) return } } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt index 9fe9c996ec..d41c366e3d 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt @@ -10,7 +10,6 @@ import com.datadog.android.DatadogSite import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.feature.Feature -import com.datadog.android.flags.BuildConfig import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.utils.forge.ForgeConfigurator import fr.xgouchet.elmyr.Forge @@ -66,7 +65,7 @@ internal class PrecomputedAssignmentsRequestFactoryTest { // region create() - Success cases @Test - fun `M negotiate encoding with flags version W create() { native source }`() { + fun `M advertise encoding capability W create() { native source }`() { val context = fakeDatadogContext.copy(source = "android", sdkVersion = "overridden-core-version") val request = checkNotNull(testedFactory.create(EvaluationContext("subject", emptyMap()), context)) val buffer = Buffer() @@ -75,7 +74,7 @@ internal class PrecomputedAssignmentsRequestFactoryTest { assertThat(attributes.getJSONObject("supported_capabilities").getJSONArray("assignment_encodings").toString()) .isEqualTo("[\"flag-key-sha256-v1\"]") - assertThat(attributes.getJSONObject("source").getString("sdk_version")).isEqualTo(BuildConfig.SDK_VERSION_NAME) + assertThat(attributes.getJSONObject("source").getString("sdk_version")).isEqualTo(context.sdkVersion) } @Test @@ -215,7 +214,7 @@ internal class PrecomputedAssignmentsRequestFactoryTest { // Validate source val source = attributes.getJSONObject("source") assertThat(source.getString("sdk_name")).isEqualTo("dd-sdk-android") - assertThat(source.getString("sdk_version")).isEqualTo(BuildConfig.SDK_VERSION_NAME) + assertThat(source.getString("sdk_version")).isEqualTo(fakeDatadogContext.sdkVersion) } @Test From b3779f89163286fd8516a0a7db09f506cc6becda Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 6 Oct 2026 08:16:28 -0400 Subject: [PATCH 03/17] test(flags): keep obfuscation coverage in JVM suite --- .../dd-sdk-android-flags/build.gradle.kts | 5 -- .../internal/FlagKeyObfuscationDeviceTest.kt | 60 ------------------- .../flags/internal/FlagKeyObfuscationTest.kt | 2 + 3 files changed, 2 insertions(+), 65 deletions(-) delete mode 100644 features/dd-sdk-android-flags/src/androidTest/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationDeviceTest.kt diff --git a/features/dd-sdk-android-flags/build.gradle.kts b/features/dd-sdk-android-flags/build.gradle.kts index 37f8db6a1b..3a52437f0c 100644 --- a/features/dd-sdk-android-flags/build.gradle.kts +++ b/features/dd-sdk-android-flags/build.gradle.kts @@ -48,9 +48,6 @@ createJsonModelsGenerationTask("generateFlagsModelsFromJson") { @Suppress("DEPRECATION") android { namespace = "com.datadog.android.flags" - defaultConfig { - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } } dependencies { @@ -78,8 +75,6 @@ dependencies { testImplementation(testFixtures(project(":dd-sdk-android-core"))) testImplementation(testFixtures(project(":features:dd-sdk-android-rum"))) testImplementation(libs.okHttpMock) - androidTestImplementation(libs.androidXTestRunner) - androidTestImplementation(libs.androidXTestJUnitExt) unmock(libs.robolectric) } diff --git a/features/dd-sdk-android-flags/src/androidTest/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationDeviceTest.kt b/features/dd-sdk-android-flags/src/androidTest/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationDeviceTest.kt deleted file mode 100644 index a69cea5147..0000000000 --- a/features/dd-sdk-android-flags/src/androidTest/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationDeviceTest.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * 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.flags.internal - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.datadog.android.api.InternalLogger -import com.datadog.android.flags.internal.model.FlagKeyObfuscation -import com.datadog.android.flags.internal.model.FlagsStateEntry -import com.datadog.android.flags.internal.persistence.FlagsStateDeserializer -import com.datadog.android.flags.internal.persistence.FlagsStateSerializer -import com.datadog.android.flags.internal.repository.net.PrecomputeMapper -import com.datadog.android.flags.model.EvaluationContext -import org.json.JSONObject -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -internal class FlagKeyObfuscationDeviceTest { - @Test - fun resolvesEncodedAssignmentAfterCacheRoundTrip() { - val fakeKey = "🚲/旗" - val fakeDigest = "94b611e0d3b26b52f6ad66013d1c72f8a92ea109390049759e75d3c8d3aa4dab" - val fakeAttributes = JSONObject( - """ - {"obfuscated":true, - "obfuscation":{"scheme":"flag-key-sha256-v1","salt":"000102030405060708090a0b0c0d0e0f"}, - "flags":{"$fakeDigest":{ - "variationType":"boolean","variationValue":true,"doLog":true, - "allocationKey":"allocation","variationKey":"variation", - "reason":"TARGETING_MATCH","serialId":123,"extraLogging":{} - }}} - """.trimIndent() - ) - val logger = InternalLogger.UNBOUND - val testedMapper = PrecomputeMapper(logger) - val response = JSONObject().put("data", JSONObject().put("attributes", fakeAttributes)).toString() - val assignments = checkNotNull(testedMapper.map(response)) - assertEquals(fakeDigest, assignments.obfuscation?.encode(fakeKey)) - val entry = - FlagsStateEntry(EvaluationContext("subject", emptyMap()), assignments.flags, 123L, assignments.obfuscation) - val serialized = FlagsStateSerializer(logger).serialize(entry) - assertFalse(JSONObject(serialized).has("flags")) - val restored = checkNotNull(FlagsStateDeserializer(logger).deserialize(serialized)) - val flag = checkNotNull(restored.flags[restored.obfuscation?.encode(fakeKey)]) - assertEquals( - true, - FlagValueConverter.convert(flag.variationValue, flag.variationType, Boolean::class).getOrThrow() - ) - assertEquals("variation", flag.variationKey) - assertEquals(123L, flag.serialId) - assertNull(checkNotNull(FlagKeyObfuscation.read(fakeAttributes)).encode("\ud800")) - } -} diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt index 21b0b6cfbc..c074dd5c6e 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt @@ -137,6 +137,8 @@ internal class FlagKeyObfuscationTest { testedRepository.setFlagsAndContext(restored.evaluationContext, restored.flags, restored.obfuscation) assertThat(testedClient.resolveBooleanValue("flag", false)).isTrue() assertThat(testedRepository.getPrecomputedFlag("flag")).isNotNull() + assertThat(testedRepository.getPrecomputedFlag("flag")?.variationKey).isEqualTo("variation-456") + assertThat(testedRepository.getPrecomputedFlag("flag")?.serialId).isEqualTo(123L) assertThat(testedRepository.getPrecomputedFlag(checkNotNull(encoding.encode("flag")))).isNull() } verify(mockWriter, times(1)).write(any()) From c63e2d68bfc6e3e1a684fc25fce020c483dd8ae9 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 6 Oct 2026 12:11:01 -0400 Subject: [PATCH 04/17] fix(flags): gate encoded cache reads until source is known --- .../internal/evaluation/EvaluationsManager.kt | 4 +- .../repository/DefaultFlagsRepository.kt | 34 +++---- .../flags/internal/FlagKeyObfuscationTest.kt | 91 ++++++++++++++++--- .../evaluation/EvaluationsManagerTest.kt | 19 ++++ 4 files changed, 119 insertions(+), 29 deletions(-) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt index 509be37b56..73709e06cc 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt @@ -117,6 +117,8 @@ internal class EvaluationsManager( sdkCore.getFeature(Feature.FLAGS_FEATURE_NAME) ?.withContext(withFeatureContexts = setOf(Feature.RUM_FEATURE_NAME)) { datadogContext -> + val supportsObfuscation = datadogContext.source == "android" + flagsRepository.setObfuscationSupported(supportsObfuscation) executorService.executeSafe( operationName = FETCH_AND_STORE_OPERATION_NAME, internalLogger = internalLogger @@ -127,8 +129,6 @@ internal class EvaluationsManager( { "Processing evaluation context: ${context.targetingKey}" } ) - val supportsObfuscation = datadogContext.source == "android" - flagsRepository.setObfuscationSupported(supportsObfuscation) val hadFlags = flagsRepository.hasFlags() matchingCachedAssignments.set( hadFlags && flagsRepository.getEvaluationContext() == context diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt index 6669eebdb5..2d75e43988 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt @@ -7,6 +7,7 @@ package com.datadog.android.flags.internal.repository import com.datadog.android.api.InternalLogger +import com.datadog.android.api.feature.Feature import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.api.storage.datastore.DataStoreHandler import com.datadog.android.api.storage.datastore.DataStoreWriteCallback @@ -36,7 +37,11 @@ internal class DefaultFlagsRepository( if (obfuscation == null) flags[key] else obfuscation.encode(key)?.let { flags[it] } } private val atomicState = AtomicReference(null) - private val obfuscationSupported = AtomicBoolean(true) + private val obfuscationSupported = AtomicBoolean(false) + + // Preserve encoded cache data while source detection is pending, but do not expose it. + private val readableState: FlagsState? + get() = atomicState.get()?.takeIf { it.obfuscation == null || obfuscationSupported.get() } @Suppress("UnsafeThirdPartyFunctionCall") // Safe: count is positive constant (1) private val persistenceLoadedLatch = CountDownLatch(1) @@ -51,13 +56,18 @@ internal class DefaultFlagsRepository( val cachedFlags = it.flags.mapValues { (_, flag) -> flag.copy(reason = ResolutionReason.CACHED.name) } val loadedState = FlagsState(it.evaluationContext, cachedFlags, it.obfuscation) atomicState.compareAndSet(null, loadedState) - discardUnsupportedAssignments() } } finally { persistenceLoadedLatch.countDown() } } + init { + featureSdkCore.getFeature(Feature.FLAGS_FEATURE_NAME)?.withContext { context -> + setObfuscationSupported(context.source == "android") + } + } + override fun setFlagsAndContext( context: EvaluationContext, flags: Map, @@ -89,19 +99,11 @@ internal class DefaultFlagsRepository( override fun setObfuscationSupported(supported: Boolean) { obfuscationSupported.set(supported) - discardUnsupportedAssignments() - } - - private fun discardUnsupportedAssignments() { - while (!obfuscationSupported.get()) { - val state = atomicState.get() ?: return - if (state.obfuscation == null || atomicState.compareAndSet(state, null)) return - } } override fun getPrecomputedFlag(key: String): PrecomputedFlag? { waitForPersistenceLoad() - val state = atomicState.get() + val state = readableState if (state != null) { return state.get(key) } @@ -115,7 +117,7 @@ internal class DefaultFlagsRepository( override fun getFlagsSnapshot(): Map { waitForPersistenceLoad() - val state = atomicState.get() + val state = readableState if (state != null) { // The React Native bridge consumes original keys and cannot decode this representation. return if (state.obfuscation == null) state.flags else emptyMap() @@ -130,23 +132,23 @@ internal class DefaultFlagsRepository( override fun getEvaluationContext(): EvaluationContext? { waitForPersistenceLoad() - return atomicState.get()?.context + return readableState?.context } override fun hasFlags(): Boolean { waitForPersistenceLoad() - return atomicState.get()?.flags?.isNotEmpty() ?: false + return readableState?.flags?.isNotEmpty() ?: false } override fun hasLoadedFlagsForContext(context: EvaluationContext): Boolean { - val state = atomicState.get() + val state = readableState return state?.context == context && state.flags.isNotEmpty() } @Suppress("ReturnCount") override fun getPrecomputedFlagWithContext(key: String): Pair? { waitForPersistenceLoad() - val state = atomicState.get() ?: return null + val state = readableState ?: return null val flag = state.get(key) ?: return null return flag to state.context } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt index 02ae6587c5..abd58e0c86 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt @@ -7,6 +7,9 @@ package com.datadog.android.flags.internal import com.datadog.android.api.InternalLogger +import com.datadog.android.api.context.DatadogContext +import com.datadog.android.api.feature.Feature +import com.datadog.android.api.feature.FeatureScope import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.api.storage.datastore.DataStoreHandler import com.datadog.android.api.storage.datastore.DataStoreReadCallback @@ -29,6 +32,7 @@ import org.json.JSONObject import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -38,11 +42,15 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.times import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever internal class FlagKeyObfuscationTest { private val mockInternalLogger: InternalLogger = mock() private val mockSdkCore: FeatureSdkCore = mock() + private val mockFeatureScope: FeatureScope = mock() + private val mockDatadogContext: DatadogContext = mock() + private val mockEvaluationsManager: EvaluationsManager = mock() private val mockDataStore: DataStoreHandler = mock() private val mockWriter: RecordWriter = mock() private val mockRumLogger: RumEvaluationLogger = mock() @@ -56,20 +64,15 @@ internal class FlagKeyObfuscationTest { fun setUp() { whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger whenever(mockSdkCore.timeProvider) doReturn mock() + whenever(mockSdkCore.getFeature(Feature.FLAGS_FEATURE_NAME)) doReturn mockFeatureScope + whenever(mockDatadogContext.source) doReturn "android" + doAnswer { it.getArgument<(DatadogContext) -> Unit>(1).invoke(mockDatadogContext) } + .whenever(mockFeatureScope).withContext(any(), any()) doAnswer { it.getArgument>(2).onFailure() }.whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) testedRepository = DefaultFlagsRepository(mockSdkCore, "obfuscation", mockDataStore) - testedClient = DatadogFlagsClient( - featureSdkCore = mockSdkCore, - evaluationsManager = mock(), - flagsRepository = testedRepository, - flagsConfiguration = FlagsConfiguration.Builder().build(), - rumEvaluationLogger = mockRumLogger, - exposureProcessor = ExposureEventsProcessor(mockWriter, mockSdkCore.timeProvider), - evaluationsFeature = mockEvaluationsFeature, - flagStateManager = mock() - ) + testedClient = createClient() } @ParameterizedTest @@ -147,7 +150,7 @@ internal class FlagKeyObfuscationTest { } @Test - fun `M hide encoded snapshots and discard cache W bridge reads { legacy consumer }`() { + fun `M hide encoded assignments W setObfuscationSupported() { legacy consumer }`() { install(payload(VECTORS[2].second)) assertThat(testedRepository.getFlagsSnapshot()).isEmpty() testedRepository.setObfuscationSupported(false) @@ -189,6 +192,53 @@ internal class FlagKeyObfuscationTest { assertThat(testedRepository.getPrecomputedFlag(VECTORS[2].second)).isNull() } + @ParameterizedTest + @MethodSource("cacheInitializations") + fun `M gate cached assignments W resolveBooleanValue() { delayed source or disk without network }`( + source: String, + diskFirst: Boolean, + encoded: Boolean + ) { + // Given + var sourceCallback: ((DatadogContext) -> Unit)? = null + var diskCallback: DataStoreReadCallback? = null + doAnswer { sourceCallback = it.getArgument(1) } + .whenever(mockFeatureScope).withContext(any(), any()) + doAnswer { diskCallback = it.getArgument(2) } + .whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) + whenever(mockDatadogContext.source) doReturn source + val decoded = checkNotNull(testedMapper.map(payload(if (encoded) VECTORS[2].second else "flag", encoded))) + val entry = FlagsStateEntry(fakeContext, decoded.flags, 1234L, decoded.obfuscation) + testedRepository = DefaultFlagsRepository(mockSdkCore, "deferred-cache", mockDataStore) + testedClient = createClient() + + // When + if (diskFirst) { + checkNotNull(diskCallback).onSuccess(DataStoreContent(0, entry)) + assertThat(testedClient.resolveBooleanValue("flag", false)).isEqualTo(!encoded) + assertThat(testedRepository.hasFlags()).isEqualTo(!encoded) + assertThat(testedRepository.hasLoadedFlagsForContext(fakeContext)).isEqualTo(!encoded) + assertThat(testedRepository.getEvaluationContext()).isEqualTo(if (encoded) null else fakeContext) + assertThat(testedRepository.getPrecomputedFlag("flag") != null).isEqualTo(!encoded) + assertThat(testedRepository.getPrecomputedFlagWithContext("flag") != null).isEqualTo(!encoded) + } + checkNotNull(sourceCallback).invoke(mockDatadogContext) + if (!diskFirst) { + checkNotNull(diskCallback).onSuccess(DataStoreContent(0, entry)) + } + + // Then + val readable = !encoded || source == "android" + assertThat(testedClient.resolveBooleanValue("flag", false)).isEqualTo(readable) + assertThat(testedRepository.hasFlags()).isEqualTo(readable) + assertThat(testedRepository.hasLoadedFlagsForContext(fakeContext)).isEqualTo(readable) + assertThat(testedRepository.getEvaluationContext()).isEqualTo(if (readable) fakeContext else null) + assertThat(testedRepository.getPrecomputedFlag("flag") != null).isEqualTo(readable) + assertThat(testedRepository.getPrecomputedFlagWithContext("flag") != null).isEqualTo(readable) + assertThat(testedRepository.getFlagsSnapshot().isEmpty()).isEqualTo(encoded) + verifyNoInteractions(mockEvaluationsManager) + } + @Test fun `M reject invalid Unicode W evaluation { no replacement alias }`() { install(payload(VECTORS[2].second)) @@ -231,6 +281,17 @@ internal class FlagKeyObfuscationTest { testedRepository.setFlagsAndContext(fakeContext, assignments.flags, assignments.obfuscation) } + private fun createClient(): DatadogFlagsClient = DatadogFlagsClient( + featureSdkCore = mockSdkCore, + evaluationsManager = mockEvaluationsManager, + flagsRepository = testedRepository, + flagsConfiguration = FlagsConfiguration.Builder().build(), + rumEvaluationLogger = mockRumLogger, + exposureProcessor = ExposureEventsProcessor(mockWriter, mockSdkCore.timeProvider), + evaluationsFeature = mockEvaluationsFeature, + flagStateManager = mock() + ) + companion object { private const val SALT = "000102030405060708090a0b0c0d0e0f" private val VECTORS = listOf( @@ -248,6 +309,14 @@ internal class FlagKeyObfuscationTest { @JvmStatic fun vectors(): List> = VECTORS + @JvmStatic + fun cacheInitializations(): List = + listOf("android", "react-native", "future-bridge").flatMap { source -> + listOf(true, false).flatMap { diskFirst -> + listOf(true, false).map { encoded -> Arguments.of(source, diskFirst, encoded) } + } + } + @JvmStatic fun invalidMetadata(): List = listOf( "{\"obfuscated\":true}", diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt index 031438f32c..88a7d5b817 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt @@ -46,6 +46,8 @@ import org.junit.jupiter.api.Assertions.fail import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings @@ -60,6 +62,7 @@ import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.times import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness import java.util.concurrent.CountDownLatch @@ -148,6 +151,22 @@ internal class EvaluationsManagerTest { mockWebServer.shutdown() } + @ParameterizedTest + @ValueSource(strings = ["android", "react-native"]) + fun `M set encoding support W updateEvaluationsForContext() { network executor delayed }`(source: String) { + // Given + fakeDatadogContext = fakeDatadogContext.copy(source = source) + doAnswer { null }.whenever(mockExecutorService).execute(any()) + + // When + evaluationsManager.updateEvaluationsForContext(EvaluationContext(fakeTargetingKey, emptyMap())) + + // Then + verify(mockFlagsRepository).setObfuscationSupported(source == "android") + verify(mockExecutorService).execute(any()) + verifyNoInteractions(mockAssignmentsDownloader) + } + @Test fun `M process context successfully W updateEvaluationsForContext() { valid response }`() { // Given From 16292d0dca4b0164ecdd5961db2fe6c0039c2a93 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 6 Oct 2026 15:58:58 -0400 Subject: [PATCH 05/17] Use the feature flags capabilities header --- .../flags/internal/model/FlagKeyObfuscation.kt | 1 + .../net/PrecomputedAssignmentsRequestFactory.kt | 16 ++++++++-------- .../evaluation/EvaluationsManagerTest.kt | 6 ++++-- .../PrecomputedAssignmentsRequestFactoryTest.kt | 6 ++++-- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt index 4ea3a1d786..817bbeb83e 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt @@ -29,6 +29,7 @@ internal data class FlagKeyObfuscation private constructor(val salt: String) { companion object { const val SCHEME = "flag-key-sha256-v1" + const val CAPABILITY = "assignment-encoding-flag-key-256-v1" private const val SALT_BYTES = 16 private const val HEX_RADIX = 16 private const val DIGEST_BYTES = 32 diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt index c2cd6af5ef..8452d69d52 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt @@ -16,7 +16,6 @@ import okhttp3.Headers import okhttp3.Request import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody -import org.json.JSONArray import org.json.JSONException import org.json.JSONObject @@ -68,6 +67,14 @@ internal class PrecomputedAssignmentsRequestFactory( .add(HEADER_CLIENT_TOKEN, datadogContext.clientToken) .add(HEADER_CONTENT_TYPE, CONTENT_TYPE_VND_JSON) + // Bridge SDKs that consume raw snapshots cannot resolve encoded keys yet. + if (datadogContext.source == "android") { + headersBuilder.add( + "X-DD-FEATURE-FLAGS-CAPABILITIES", + listOf(FlagKeyObfuscation.CAPABILITY).sorted().joinToString(",") + ) + } + datadogContext.rumApplicationId?.let { headersBuilder.add(HEADER_APPLICATION_ID, it) } @@ -96,13 +103,6 @@ internal class PrecomputedAssignmentsRequestFactory( .put("env", env) .put("source", source) .put("subject", subject) - // Bridge SDKs that consume raw snapshots cannot resolve encoded keys yet. - if (datadogContext.source == "android") { - attributes.put( - "supported_capabilities", - JSONObject().put("assignment_encodings", JSONArray().put(FlagKeyObfuscation.SCHEME)) - ) - } val data = JSONObject() .put("type", "precompute-assignments-request") .put("attributes", attributes) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt index 88a7d5b817..6c351ca6f4 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt @@ -1089,8 +1089,9 @@ internal class EvaluationsManagerTest { val request = checkNotNull(mockWebServer.takeRequest(1, TimeUnit.SECONDS)) assertThat(request.path).isEqualTo("/precompute-assignments") val attributes = JSONObject(request.body.readUtf8()).getJSONObject("data").getJSONObject("attributes") - assertThat(attributes.getJSONObject("supported_capabilities").getJSONArray("assignment_encodings").getString(0)) - .isEqualTo("flag-key-sha256-v1") + assertThat(request.getHeader("X-DD-FEATURE-FLAGS-CAPABILITIES")) + .isEqualTo("assignment-encoding-flag-key-256-v1") + assertThat(attributes.has("supported_capabilities")).isFalse() mockWebServer.enqueue(MockResponse().setBody(ENCODED_RESPONSE_JSON.replace("flag-key-sha256-v1", "unknown"))) testedManager.updateEvaluationsForContext(context, mockCallback) @@ -1123,6 +1124,7 @@ internal class EvaluationsManagerTest { val request = checkNotNull(mockWebServer.takeRequest(1, TimeUnit.SECONDS)) val attributes = JSONObject(request.body.readUtf8()).getJSONObject("data").getJSONObject("attributes") assertThat(attributes.has("supported_capabilities")).isFalse() + assertThat(request.getHeader("X-DD-FEATURE-FLAGS-CAPABILITIES")).isNull() } private fun createIntegrationRepository(): DefaultFlagsRepository { diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt index d41c366e3d..f18b96a8bf 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt @@ -72,8 +72,9 @@ internal class PrecomputedAssignmentsRequestFactoryTest { checkNotNull(request.body).writeTo(buffer) val attributes = JSONObject(buffer.readUtf8()).getJSONObject("data").getJSONObject("attributes") - assertThat(attributes.getJSONObject("supported_capabilities").getJSONArray("assignment_encodings").toString()) - .isEqualTo("[\"flag-key-sha256-v1\"]") + assertThat(request.header("X-DD-FEATURE-FLAGS-CAPABILITIES")) + .isEqualTo("assignment-encoding-flag-key-256-v1") + assertThat(attributes.has("supported_capabilities")).isFalse() assertThat(attributes.getJSONObject("source").getString("sdk_version")).isEqualTo(context.sdkVersion) } @@ -87,6 +88,7 @@ internal class PrecomputedAssignmentsRequestFactoryTest { val attributes = JSONObject(buffer.readUtf8()).getJSONObject("data").getJSONObject("attributes") assertThat(attributes.has("supported_capabilities")).isFalse() + assertThat(request.header("X-DD-FEATURE-FLAGS-CAPABILITIES")).isNull() } } From 751cb7629dc85df0e7b674aa4ac4c7f39fb0a6c6 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 6 Oct 2026 16:33:45 -0400 Subject: [PATCH 06/17] fix(flags): cache lookup hashes and strengthen obfuscation regressions --- .../internal/model/FlagKeyObfuscation.kt | 17 +++-- .../flags/internal/FlagKeyObfuscationTest.kt | 67 ++++++++++++++++--- .../repository/DefaultFlagsRepositoryTest.kt | 10 +++ .../flags/utils/forge/ForgeConfigurator.kt | 1 + .../PrecomputedAssignmentsForgeryFactory.kt | 18 +++++ 5 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/utils/forge/PrecomputedAssignmentsForgeryFactory.kt diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt index 817bbeb83e..af11f3697b 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt @@ -6,6 +6,7 @@ package com.datadog.android.flags.internal.model +import androidx.collection.LruCache import org.json.JSONException import org.json.JSONObject import java.security.MessageDigest @@ -13,15 +14,20 @@ import java.security.MessageDigest /** The public encoding descriptor travels with its assignment map. */ @ConsistentCopyVisibility internal data class FlagKeyObfuscation private constructor(val salt: String) { + @Suppress("UnsafeThirdPartyFunctionCall") // The cache limit is a positive constant. + private val lookupKeys = LruCache(LOOKUP_CACHE_LIMIT) + @Suppress("UnsafeThirdPartyFunctionCall") // SHA-256 is mandatory on Android; salt and Unicode are validated. fun encode(key: String): String? { // Reject unpaired surrogates instead of silently replacing their UTF-8 encoding. if (!isWellFormedUnicode(key)) return null - val digest = MessageDigest.getInstance("SHA-256") - digest.update("datadog.feature-flags.flag-key.v1\u0000".toByteArray(Charsets.UTF_8)) - digest.update(ByteArray(SALT_BYTES) { salt.substring(it * 2, it * 2 + 2).toInt(HEX_RADIX).toByte() }) - digest.update(key.toByteArray(Charsets.UTF_8)) - return digest.digest().joinToString("") { "%02x".format(it) } + return lookupKeys.get(key) ?: run { + val digest = MessageDigest.getInstance("SHA-256") + digest.update("datadog.feature-flags.flag-key.v1\u0000".toByteArray(Charsets.UTF_8)) + digest.update(ByteArray(SALT_BYTES) { salt.substring(it * 2, it * 2 + 2).toInt(HEX_RADIX).toByte() }) + digest.update(key.toByteArray(Charsets.UTF_8)) + digest.digest().joinToString("") { "%02x".format(it) }.also { lookupKeys.put(key, it) } + } } @Suppress("UnsafeThirdPartyFunctionCall") // Both fields have validated, non-null names and values. @@ -33,6 +39,7 @@ internal data class FlagKeyObfuscation private constructor(val salt: String) { private const val SALT_BYTES = 16 private const val HEX_RADIX = 16 private const val DIGEST_BYTES = 32 + private const val LOOKUP_CACHE_LIMIT = 1024 /** Throws for unsupported or inconsistent descriptors, including explicit JSON null. */ // JSON errors propagate to the response or cache parser, where they are caught. diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt index abd58e0c86..4ae61c75eb 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt @@ -77,7 +77,7 @@ internal class FlagKeyObfuscationTest { @ParameterizedTest @MethodSource("vectors") - fun `M resolve original key W evaluation { shared edge hash vectors }`(vector: Pair) { + fun `M resolve original key W resolve() { shared edge hash vectors }`(vector: Pair) { val (key, digest) = vector val encoding = checkNotNull(FlagKeyObfuscation.read(metadata())) assertThat(encoding.encode(key)).isEqualTo(digest) @@ -100,7 +100,20 @@ internal class FlagKeyObfuscationTest { } @Test - fun `M preserve results and defaults W evaluation { encoded types and errors }`() { + fun `M cache bounded lookup hashes W encode() { descriptors keep separate caches }`() { + val encoding = checkNotNull(FlagKeyObfuscation.read(metadata())) + val other = checkNotNull(FlagKeyObfuscation.read(metadata("f".repeat(32)))) + val first = checkNotNull(encoding.encode("flag")) + assertThat(encoding.encode("flag")).isSameAs(first) + assertThat(other.encode("flag")).isNotEqualTo(first) + assertThat(encoding.encode("flag")).isSameAs(first) + assertThat(encoding.encode("café")).isNotEqualTo(encoding.encode("cafe\u0301")) + repeat(1024) { encoding.encode("flag-$it") } + assertThat(encoding.encode("flag")).isEqualTo(first).isNotSameAs(first) + } + + @Test + fun `M preserve results and defaults W resolve() { encoded types and errors }`() { val encoding = checkNotNull(FlagKeyObfuscation.read(metadata())) val flags = JSONObject() listOf( @@ -126,7 +139,7 @@ internal class FlagKeyObfuscationTest { } @Test - fun `M retain lookup and deduplicate exposure W refresh { salt changes and cache round trip }`() { + fun `M retain lookup and deduplicate exposure W setFlagsAndContext() { salt changes and cache round trip }`() { for (salt in listOf(SALT, "f".repeat(32), SALT)) { val descriptor = metadata(salt) val encoding = checkNotNull(FlagKeyObfuscation.read(descriptor)) @@ -149,6 +162,44 @@ internal class FlagKeyObfuscationTest { verify(mockRumLogger, times(3)).logEvaluation("flag", "variation-456") } + @Test + fun `M accept new flags and isolate unknown types W resolve() { forward compatible response }`() { + val encoding = checkNotNull(FlagKeyObfuscation.read(metadata())) + val flags = JSONObject() + .put(checkNotNull(encoding.encode("flag")), assignment()) + .put(checkNotNull(encoding.encode("new-flag")), assignment().put("future-field", true)) + .put(checkNotNull(encoding.encode("future")), assignment("future-type", true)) + val attributes = metadata().put("flags", flags).put("future-field", true) + install(JSONObject().put("data", JSONObject().put("attributes", attributes)).toString()) + + assertThat(testedClient.resolveBooleanValue("flag", false)).isTrue() + assertThat(testedClient.resolveBooleanValue("new-flag", false)).isTrue() + assertThat(testedClient.resolve("future", false).value).isFalse() + } + + @Test + fun `M restore latest salt and value W init { after multiple writes }`() { + for ((salt, value) in listOf(SALT to true, "f".repeat(32) to false)) { + val encoding = checkNotNull(FlagKeyObfuscation.read(metadata(salt))) + val flags = JSONObject().put(checkNotNull(encoding.encode("flag")), assignment("boolean", value)) + val attributes = metadata(salt).put("flags", flags) + install(JSONObject().put("data", JSONObject().put("attributes", attributes)).toString()) + assertThat(testedClient.resolveBooleanValue("flag", !value)).isEqualTo(value) + } + val written = argumentCaptor() + verify(mockDataStore, times(2)).setValue(any(), written.capture(), any(), anyOrNull(), any()) + val serialized = FlagsStateSerializer(mockInternalLogger).serialize(written.lastValue) + val restored = checkNotNull(FlagsStateDeserializer(mockInternalLogger).deserialize(serialized)) + assertThat(restored.obfuscation?.salt).isEqualTo("f".repeat(32)) + doAnswer { + it.getArgument>(2).onSuccess(DataStoreContent(0, restored)) + }.whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) + testedRepository = DefaultFlagsRepository(mockSdkCore, "obfuscation", mockDataStore) + testedClient = createClient() + assertThat(testedClient.resolveBooleanValue("flag", true)).isFalse() + verifyNoInteractions(mockEvaluationsManager) + } + @Test fun `M hide encoded assignments W setObfuscationSupported() { legacy consumer }`() { install(payload(VECTORS[2].second)) @@ -178,7 +229,7 @@ internal class FlagKeyObfuscationTest { } @Test - fun `M restore encoded cache W constructor { native consumer }`() { + fun `M restore encoded cache W init { native consumer }`() { val decoded = checkNotNull(testedMapper.map(payload(VECTORS[2].second))) val entry = FlagsStateEntry(fakeContext, decoded.flags, 1234L, decoded.obfuscation) doAnswer { it.getArgument>(2).onSuccess(DataStoreContent(0, entry)) } @@ -240,7 +291,7 @@ internal class FlagKeyObfuscationTest { } @Test - fun `M reject invalid Unicode W evaluation { no replacement alias }`() { + fun `M reject invalid Unicode W resolve() { no replacement alias }`() { install(payload(VECTORS[2].second)) for (key in listOf("\ud800", "\udc00", "a\ud800b")) { assertThat(testedClient.resolve(key, false).errorCode).isEqualTo(ErrorCode.FLAG_NOT_FOUND) @@ -249,7 +300,7 @@ internal class FlagKeyObfuscationTest { @ParameterizedTest @MethodSource("invalidMetadata") - fun `M reject response and cache W decode { invalid descriptor }`(json: String) { + fun `M reject response and cache W map() and deserialize() { invalid descriptor }`(json: String) { val attrs = JSONObject(json).put("flags", JSONObject()) assertThat( testedMapper.map(JSONObject().put("data", JSONObject().put("attributes", attrs)).toString()) @@ -261,14 +312,14 @@ internal class FlagKeyObfuscationTest { } @Test - fun `M reject malformed map keys W decode { encoded payload }`() { + fun `M reject malformed map keys W map() { encoded payload }`() { for (key in listOf("plaintext", "a".repeat(63), "a".repeat(65), "A".repeat(64), "a".repeat(63) + "\n")) { assertThat(testedMapper.map(payload(key))).isNull() } } @Test - fun `M accept legacy descriptors W decode { absent or false }`() { + fun `M accept legacy descriptors W map() { absent or false }`() { for (attrs in listOf(JSONObject(), JSONObject().put("obfuscated", false))) { attrs.put("flags", JSONObject().put("flag", assignment())) install(JSONObject().put("data", JSONObject().put("attributes", attrs)).toString()) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt index 9b23272bf8..ba06ee9dce 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt @@ -12,6 +12,7 @@ import com.datadog.android.api.storage.datastore.DataStoreHandler import com.datadog.android.api.storage.datastore.DataStoreReadCallback import com.datadog.android.core.persistence.datastore.DataStoreContent import com.datadog.android.flags.internal.model.FlagsStateEntry +import com.datadog.android.flags.internal.model.PrecomputedAssignments import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.model.ResolutionReason @@ -127,6 +128,15 @@ internal class DefaultFlagsRepositoryTest { ) } + @Test + fun `M store generated assignments W setFlagsAndContext()`(forge: Forge) { + val assignments = forge.getForgery() + + testedRepository.setFlagsAndContext(testContext, assignments.flags, assignments.obfuscation) + + assertThat(testedRepository.getFlagsSnapshot()).isEqualTo(assignments.flags) + } + @Test fun `M return stored context W getEvaluationContext() { after setting context }`(forge: Forge) { // Given diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/utils/forge/ForgeConfigurator.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/utils/forge/ForgeConfigurator.kt index 312c3ea65b..1029791a41 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/utils/forge/ForgeConfigurator.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/utils/forge/ForgeConfigurator.kt @@ -20,6 +20,7 @@ internal class ForgeConfigurator : BaseConfigurator() { forge.addFactory(FlagsConfigurationForgeryFactory()) forge.addFactory(PrecomputedFlagForgeryFactory()) + forge.addFactory(PrecomputedAssignmentsForgeryFactory()) forge.addFactory(FlagEvaluationForgeryFactory()) forge.addFactory(EvaluationContextForgeryFactory()) forge.addFactory(EvaluationAggregationStatsForgeryFactory()) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/utils/forge/PrecomputedAssignmentsForgeryFactory.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/utils/forge/PrecomputedAssignmentsForgeryFactory.kt new file mode 100644 index 0000000000..e54bdf06a9 --- /dev/null +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/utils/forge/PrecomputedAssignmentsForgeryFactory.kt @@ -0,0 +1,18 @@ +/* + * 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.flags.utils.forge + +import com.datadog.android.flags.internal.model.PrecomputedAssignments +import com.datadog.android.flags.internal.model.PrecomputedFlag +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +internal class PrecomputedAssignmentsForgeryFactory : ForgeryFactory { + override fun getForgery(forge: Forge): PrecomputedAssignments = PrecomputedAssignments( + flags = forge.aMap { anAlphabeticalString() to getForgery() } + ) +} From 65fc35cfcfafda108d17ac63f93c09846a642153 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 8 Oct 2026 07:39:37 -0400 Subject: [PATCH 07/17] FFLSDK-254: Mark obfuscation test setup, actions, and assertions --- .../flags/internal/FlagKeyObfuscationTest.kt | 96 +++++++++++++++++-- .../evaluation/EvaluationsManagerTest.kt | 14 +++ ...recomputedAssignmentsRequestFactoryTest.kt | 8 ++ 3 files changed, 112 insertions(+), 6 deletions(-) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt index 457092bdbb..eb6a7d5753 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt @@ -78,14 +78,17 @@ internal class FlagKeyObfuscationTest { @ParameterizedTest @MethodSource("vectors") fun `M resolve original key W resolve() { shared edge hash vectors }`(vector: Pair) { + // Given val (key, digest) = vector val encoding = checkNotNull(FlagKeyObfuscation.read(metadata())) assertThat(encoding.encode(key)).isEqualTo(digest) install(payload(key, encoded = false)) val plain = testedClient.resolve(key, false) + // When install(payload(digest)) + // Then assertThat(testedClient.resolve(key, false)).isEqualTo(plain) assertThat(testedClient.resolveBooleanValue(key, false)).isTrue() verify(mockRumLogger, times(3)).logEvaluation(key, "variation-456") @@ -101,19 +104,29 @@ internal class FlagKeyObfuscationTest { @Test fun `M cache bounded lookup hashes W encode() { descriptors keep separate caches }`() { + // Given val encoding = checkNotNull(FlagKeyObfuscation.read(metadata())) val other = checkNotNull(FlagKeyObfuscation.read(metadata("f".repeat(32)))) + + // When val first = checkNotNull(encoding.encode("flag")) + + // Then assertThat(encoding.encode("flag")).isSameAs(first) assertThat(other.encode("flag")).isNotEqualTo(first) assertThat(encoding.encode("flag")).isSameAs(first) assertThat(encoding.encode("café")).isNotEqualTo(encoding.encode("cafe\u0301")) + + // When repeat(1024) { encoding.encode("flag-$it") } + + // Then assertThat(encoding.encode("flag")).isEqualTo(first).isNotSameAs(first) } @Test fun `M preserve results and defaults W resolve() { encoded types and errors }`() { + // Given val encoding = checkNotNull(FlagKeyObfuscation.read(metadata())) val flags = JSONObject() listOf( @@ -126,8 +139,11 @@ internal class FlagKeyObfuscationTest { } flags.put(checkNotNull(encoding.encode("bad")), assignment("boolean", "invalid")) val assignments = metadata().put("flags", flags) + + // When install(JSONObject().put("data", JSONObject().put("attributes", assignments)).toString()) + // Then assertThat(testedClient.resolveStringValue("string", "default")).isEqualTo("visible") assertThat(testedClient.resolveIntValue("integer", -1)).isEqualTo(42) assertThat(testedClient.resolveDoubleValue("number", -1.0)).isEqualTo(12.5) @@ -141,37 +157,56 @@ internal class FlagKeyObfuscationTest { @Test fun `M retain lookup and deduplicate exposure W setFlagsAndContext() { salt changes and cache round trip }`() { for (salt in listOf(SALT, "f".repeat(32), SALT)) { + // Given val descriptor = metadata(salt) val encoding = checkNotNull(FlagKeyObfuscation.read(descriptor)) val decoded = checkNotNull(testedMapper.map(payload(checkNotNull(encoding.encode("flag")), salt = salt))) val entry = FlagsStateEntry(fakeContext, decoded.flags, 1234L, decoded.obfuscation) + + // When val serialized = FlagsStateSerializer(mockInternalLogger).serialize(entry) + + // Then // The legacy deserializer requires flags, so it cannot ingest encoded keys. assertThat(JSONObject(serialized).has("flags")).isFalse() + + // When val restored = checkNotNull(FlagsStateDeserializer(mockInternalLogger).deserialize(serialized)) + + // Then assertThat(restored.obfuscation).isEqualTo(encoding) assertThat(restored.evaluationContext).isEqualTo(fakeContext) + + // When testedRepository.setFlagsAndContext(restored.evaluationContext, restored.flags, restored.obfuscation) + + // Then assertThat(testedClient.resolveBooleanValue("flag", false)).isTrue() assertThat(testedRepository.getPrecomputedFlag("flag")).isNotNull() assertThat(testedRepository.getPrecomputedFlag("flag")?.variationKey).isEqualTo("variation-456") assertThat(testedRepository.getPrecomputedFlag("flag")?.serialId).isEqualTo(123L) assertThat(testedRepository.getPrecomputedFlag(checkNotNull(encoding.encode("flag")))).isNull() } + + // Then verify(mockWriter, times(1)).write(any()) verify(mockRumLogger, times(3)).logEvaluation("flag", "variation-456") } @Test fun `M accept new flags and isolate unknown types W resolve() { forward compatible response }`() { + // Given val encoding = checkNotNull(FlagKeyObfuscation.read(metadata())) val flags = JSONObject() .put(checkNotNull(encoding.encode("flag")), assignment()) .put(checkNotNull(encoding.encode("new-flag")), assignment().put("future-field", true)) .put(checkNotNull(encoding.encode("future")), assignment("future-type", true)) val attributes = metadata().put("flags", flags).put("future-field", true) + + // When install(JSONObject().put("data", JSONObject().put("attributes", attributes)).toString()) + // Then assertThat(testedClient.resolveBooleanValue("flag", false)).isTrue() assertThat(testedClient.resolveBooleanValue("new-flag", false)).isTrue() assertThat(testedClient.resolve("future", false).value).isFalse() @@ -179,6 +214,7 @@ internal class FlagKeyObfuscationTest { @Test fun `M restore latest salt and value W DefaultFlagsRepository() { after multiple writes }`() { + // Given for ((salt, value) in listOf(SALT to true, "f".repeat(32) to false)) { val encoding = checkNotNull(FlagKeyObfuscation.read(metadata(salt))) val flags = JSONObject().put(checkNotNull(encoding.encode("flag")), assignment("boolean", value)) @@ -194,25 +230,39 @@ internal class FlagKeyObfuscationTest { doAnswer { it.getArgument>(2).onSuccess(DataStoreContent(0, restored)) }.whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) + + // When testedRepository = DefaultFlagsRepository(mockSdkCore, "obfuscation", mockDataStore) testedClient = createClient() + + // Then assertThat(testedClient.resolveBooleanValue("flag", true)).isFalse() verifyNoInteractions(mockEvaluationsManager) } @Test fun `M hide encoded assignments W setObfuscationSupported() { legacy consumer }`() { + // Given install(payload(VECTORS[2].second)) assertThat(testedRepository.getFlagsSnapshot()).isEmpty() + + // When testedRepository.setObfuscationSupported(false) + + // Then assertThat(testedRepository.hasFlags()).isFalse() assertThat(testedRepository.getEvaluationContext()).isNull() + + // When install(payload("flag", encoded = false)) + + // Then assertThat(testedRepository.getFlagsSnapshot()).containsKey("flag") } @Test fun `M reject late encoded cache W setObfuscationSupported() { React Native consumer }`() { + // Given var cacheCallback: DataStoreReadCallback? = null doAnswer { cacheCallback = it.getArgument(2) } .whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) @@ -221,8 +271,10 @@ internal class FlagKeyObfuscationTest { val decoded = checkNotNull(testedMapper.map(payload(VECTORS[2].second))) val entry = FlagsStateEntry(fakeContext, decoded.flags, 1234L, decoded.obfuscation) + // When checkNotNull(cacheCallback).onSuccess(DataStoreContent(0, entry)) + // Then assertThat(testedRepository.getFlagsSnapshot()).isEmpty() assertThat(testedRepository.hasFlags()).isFalse() assertThat(testedRepository.getEvaluationContext()).isNull() @@ -230,13 +282,16 @@ internal class FlagKeyObfuscationTest { @Test fun `M restore encoded cache W DefaultFlagsRepository() { native consumer }`() { + // Given val decoded = checkNotNull(testedMapper.map(payload(VECTORS[2].second))) val entry = FlagsStateEntry(fakeContext, decoded.flags, 1234L, decoded.obfuscation) doAnswer { it.getArgument>(2).onSuccess(DataStoreContent(0, entry)) } .whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) + // When val testedRepository = DefaultFlagsRepository(mockSdkCore, "restored-cache", mockDataStore) + // Then assertThat(testedRepository.getPrecomputedFlagWithContext("flag")?.second).isEqualTo(fakeContext) assertThat(testedRepository.getPrecomputedFlag("flag")?.variationValue).isEqualTo("true") assertThat(testedRepository.getPrecomputedFlag("flag")?.reason).isEqualTo(ResolutionReason.CACHED.name) @@ -335,37 +390,66 @@ internal class FlagKeyObfuscationTest { @Test fun `M reject invalid Unicode W resolve() { no replacement alias }`() { + // Given install(payload(VECTORS[2].second)) + for (key in listOf("\ud800", "\udc00", "a\ud800b")) { - assertThat(testedClient.resolve(key, false).errorCode).isEqualTo(ErrorCode.FLAG_NOT_FOUND) + // When + val resolution = testedClient.resolve(key, false) + + // Then + assertThat(resolution.errorCode).isEqualTo(ErrorCode.FLAG_NOT_FOUND) } } @ParameterizedTest @MethodSource("invalidMetadata") fun `M reject response and cache W map() and deserialize() { invalid descriptor }`(json: String) { + // Given val attrs = JSONObject(json).put("flags", JSONObject()) - assertThat( - testedMapper.map(JSONObject().put("data", JSONObject().put("attributes", attrs)).toString()) - ).isNull() + + // When + val assignments = testedMapper.map(JSONObject().put("data", JSONObject().put("attributes", attrs)).toString()) + + // Then + assertThat(assignments).isNull() + + // Given val entry = FlagsStateEntry(fakeContext, emptyMap(), 1234L) val cache = JSONObject(FlagsStateSerializer(mockInternalLogger).serialize(entry)) JSONObject(json).keys().forEach { cache.put(it, JSONObject(json).get(it)) } - assertThat(FlagsStateDeserializer(mockInternalLogger).deserialize(cache.toString())).isNull() + + // When + val restored = FlagsStateDeserializer(mockInternalLogger).deserialize(cache.toString()) + + // Then + assertThat(restored).isNull() } @Test fun `M reject malformed map keys W map() { encoded payload }`() { for (key in listOf("plaintext", "a".repeat(63), "a".repeat(65), "A".repeat(64), "a".repeat(63) + "\n")) { - assertThat(testedMapper.map(payload(key))).isNull() + // Given + val response = payload(key) + + // When + val assignments = testedMapper.map(response) + + // Then + assertThat(assignments).isNull() } } @Test fun `M accept legacy descriptors W map() { absent or false }`() { for (attrs in listOf(JSONObject(), JSONObject().put("obfuscated", false))) { + // Given attrs.put("flags", JSONObject().put("flag", assignment())) + + // When install(JSONObject().put("data", JSONObject().put("attributes", attrs)).toString()) + + // Then assertThat(testedClient.resolveBooleanValue("flag", false)).isTrue() } } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt index d7b3b46e7e..ecdff4e14f 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt @@ -1216,6 +1216,7 @@ internal class EvaluationsManagerTest { @Test fun `M retain matching assignments on invalid encoding W updateEvaluationsForContext() { HTTP integration }`() { + // Given fakeDatadogContext = fakeDatadogContext.copy(source = "android") val context = EvaluationContext("athlete-123", emptyMap()) val testedRepository = createIntegrationRepository() @@ -1223,8 +1224,10 @@ internal class EvaluationsManagerTest { val mockCallback = mock() mockWebServer.enqueue(MockResponse().setBody(ENCODED_RESPONSE_JSON)) + // When testedManager.updateEvaluationsForContext(context, mockCallback) + // Then assertThat(testedRepository.getPrecomputedFlag("flag")?.variationValue).isEqualTo("true") verify(mockCallback).onSuccess() val request = checkNotNull(mockWebServer.takeRequest(1, TimeUnit.SECONDS)) @@ -1233,23 +1236,32 @@ internal class EvaluationsManagerTest { assertThat(request.getHeader("X-DD-FEATURE-FLAGS-CAPABILITIES")) .isEqualTo("assignment-encoding-flag-key-256-v1") assertThat(attributes.has("supported_capabilities")).isFalse() + + // Given mockWebServer.enqueue(MockResponse().setBody(ENCODED_RESPONSE_JSON.replace("flag-key-sha256-v1", "unknown"))) + // When testedManager.updateEvaluationsForContext(context, mockCallback) + // Then verify(mockFlagsStateManager).updateState(FlagsClientState.Stale) verify(mockCallback).onFailure(any()) assertThat(testedRepository.getPrecomputedFlag("flag")?.variationValue).isEqualTo("true") + + // Given mockWebServer.enqueue(MockResponse().setBody(ENCODED_RESPONSE_JSON.replace("flag-key-sha256-v1", "unknown"))) + // When testedManager.updateEvaluationsForContext(EvaluationContext("other-user", emptyMap()), mockCallback) + // Then verify(mockFlagsStateManager).updateState(any()) assertThat(testedRepository.getEvaluationContext()).isEqualTo(context) } @Test fun `M reject unsolicited encoding W updateEvaluationsForContext() { React Native HTTP integration }`() { + // Given fakeDatadogContext = fakeDatadogContext.copy(source = "react-native") val context = EvaluationContext("athlete-123", emptyMap()) val testedRepository = createIntegrationRepository() @@ -1257,8 +1269,10 @@ internal class EvaluationsManagerTest { val mockCallback = mock() mockWebServer.enqueue(MockResponse().setBody(ENCODED_RESPONSE_JSON)) + // When testedManager.updateEvaluationsForContext(context, mockCallback) + // Then verify(mockCallback).onFailure(any()) assertThat(testedRepository.hasFlags()).isFalse() assertThat(testedRepository.getFlagsSnapshot()).isEmpty() diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt index f18b96a8bf..ebc5651048 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt @@ -66,12 +66,16 @@ internal class PrecomputedAssignmentsRequestFactoryTest { @Test fun `M advertise encoding capability W create() { native source }`() { + // Given val context = fakeDatadogContext.copy(source = "android", sdkVersion = "overridden-core-version") + + // When val request = checkNotNull(testedFactory.create(EvaluationContext("subject", emptyMap()), context)) val buffer = Buffer() checkNotNull(request.body).writeTo(buffer) val attributes = JSONObject(buffer.readUtf8()).getJSONObject("data").getJSONObject("attributes") + // Then assertThat(request.header("X-DD-FEATURE-FLAGS-CAPABILITIES")) .isEqualTo("assignment-encoding-flag-key-256-v1") assertThat(attributes.has("supported_capabilities")).isFalse() @@ -81,12 +85,16 @@ internal class PrecomputedAssignmentsRequestFactoryTest { @Test fun `M omit encoding capability W create() { bridge sources }`() { for (source in listOf("react-native", "flutter", "unknown")) { + // Given val context = fakeDatadogContext.copy(source = source) + + // When val request = checkNotNull(testedFactory.create(EvaluationContext("subject", emptyMap()), context)) val buffer = Buffer() checkNotNull(request.body).writeTo(buffer) val attributes = JSONObject(buffer.readUtf8()).getJSONObject("data").getJSONObject("attributes") + // Then assertThat(attributes.has("supported_capabilities")).isFalse() assertThat(request.header("X-DD-FEATURE-FLAGS-CAPABILITIES")).isNull() } From 3c503fca2be9650b61a0f3a2262f70e70fc90541 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 8 Oct 2026 07:43:35 -0400 Subject: [PATCH 08/17] FFLSDK-254: Use Mockito annotations in obfuscation tests --- .../flags/internal/FlagKeyObfuscationTest.kt | 61 ++++++++++++++----- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt index eb6a7d5753..41cdf1d5bd 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt @@ -27,43 +27,75 @@ import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.model.ExposureEvent import com.datadog.android.flags.model.ResolutionReason +import com.datadog.android.internal.time.TimeProvider import org.assertj.core.api.Assertions.assertThat import org.json.JSONObject import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn -import org.mockito.kotlin.mock import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) internal class FlagKeyObfuscationTest { - private val mockInternalLogger: InternalLogger = mock() - private val mockSdkCore: FeatureSdkCore = mock() - private val mockFeatureScope: FeatureScope = mock() - private val mockDatadogContext: DatadogContext = mock() - private val mockEvaluationsManager: EvaluationsManager = mock() - private val mockDataStore: DataStoreHandler = mock() - private val mockWriter: RecordWriter = mock() - private val mockRumLogger: RumEvaluationLogger = mock() - private val mockEvaluationsFeature: EvaluationsFeature = mock() - private val fakeContext = EvaluationContext("athlete-123", mapOf("team" to "cycling")) - private val testedMapper = PrecomputeMapper(mockInternalLogger) + private lateinit var testedMapper: PrecomputeMapper private lateinit var testedRepository: DefaultFlagsRepository private lateinit var testedClient: DatadogFlagsClient + @Mock + lateinit var mockInternalLogger: InternalLogger + + @Mock + lateinit var mockSdkCore: FeatureSdkCore + + @Mock + lateinit var mockFeatureScope: FeatureScope + + @Mock + lateinit var mockDatadogContext: DatadogContext + + @Mock + lateinit var mockEvaluationsManager: EvaluationsManager + + @Mock + lateinit var mockDataStore: DataStoreHandler + + @Mock + lateinit var mockWriter: RecordWriter + + @Mock + lateinit var mockRumLogger: RumEvaluationLogger + + @Mock + lateinit var mockEvaluationsFeature: EvaluationsFeature + + @Mock + lateinit var mockTimeProvider: TimeProvider + + @Mock + lateinit var mockFlagStateManager: FlagsStateManager + + private val fakeContext = EvaluationContext("athlete-123", mapOf("team" to "cycling")) + @BeforeEach fun setUp() { whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger - whenever(mockSdkCore.timeProvider) doReturn mock() + whenever(mockSdkCore.timeProvider) doReturn mockTimeProvider whenever(mockSdkCore.getFeature(Feature.FLAGS_FEATURE_NAME)) doReturn mockFeatureScope whenever(mockDatadogContext.source) doReturn "android" doAnswer { it.getArgument<(DatadogContext) -> Unit>(1).invoke(mockDatadogContext) } @@ -71,6 +103,7 @@ internal class FlagKeyObfuscationTest { doAnswer { it.getArgument>(2).onFailure() }.whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) + testedMapper = PrecomputeMapper(mockInternalLogger) testedRepository = DefaultFlagsRepository(mockSdkCore, "obfuscation", mockDataStore) testedClient = createClient() } @@ -467,7 +500,7 @@ internal class FlagKeyObfuscationTest { rumEvaluationLogger = mockRumLogger, exposureProcessor = ExposureEventsProcessor(mockWriter, mockSdkCore.timeProvider), evaluationsFeature = mockEvaluationsFeature, - flagStateManager = mock() + flagStateManager = mockFlagStateManager ) companion object { From 26d87d60e445b87ec81ebcf1f971eeb55c8fa470 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 8 Oct 2026 07:46:01 -0400 Subject: [PATCH 09/17] FFLSDK-254: Generate incidental obfuscation test fixtures --- .../flags/internal/FlagKeyObfuscationTest.kt | 86 ++++++++++++------- .../evaluation/EvaluationsManagerTest.kt | 9 +- ...recomputedAssignmentsRequestFactoryTest.kt | 14 +-- 3 files changed, 70 insertions(+), 39 deletions(-) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt index 41cdf1d5bd..97a82f0abc 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt @@ -27,7 +27,13 @@ import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.model.ExposureEvent import com.datadog.android.flags.model.ResolutionReason +import com.datadog.android.flags.utils.forge.ForgeConfigurator import com.datadog.android.internal.time.TimeProvider +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat import org.json.JSONObject import org.junit.jupiter.api.BeforeEach @@ -50,7 +56,8 @@ import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness -@ExtendWith(MockitoExtension::class) +@ExtendWith(MockitoExtension::class, ForgeExtension::class) +@ForgeConfiguration(ForgeConfigurator::class) @MockitoSettings(strictness = Strictness.LENIENT) internal class FlagKeyObfuscationTest { private lateinit var testedMapper: PrecomputeMapper @@ -90,7 +97,23 @@ internal class FlagKeyObfuscationTest { @Mock lateinit var mockFlagStateManager: FlagsStateManager - private val fakeContext = EvaluationContext("athlete-123", mapOf("team" to "cycling")) + @Forgery + lateinit var fakeContext: EvaluationContext + + @StringForgery + lateinit var fakeInstanceName: String + + @StringForgery + lateinit var fakeAllocationKey: String + + @StringForgery + lateinit var fakeVariationKey: String + + @LongForgery(min = 1L) + var fakeSerialId = 0L + + @LongForgery(min = 0L) + var fakeTimestamp = 0L @BeforeEach fun setUp() { @@ -104,7 +127,7 @@ internal class FlagKeyObfuscationTest { it.getArgument>(2).onFailure() }.whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) testedMapper = PrecomputeMapper(mockInternalLogger) - testedRepository = DefaultFlagsRepository(mockSdkCore, "obfuscation", mockDataStore) + testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore) testedClient = createClient() } @@ -124,15 +147,15 @@ internal class FlagKeyObfuscationTest { // Then assertThat(testedClient.resolve(key, false)).isEqualTo(plain) assertThat(testedClient.resolveBooleanValue(key, false)).isTrue() - verify(mockRumLogger, times(3)).logEvaluation(key, "variation-456") + verify(mockRumLogger, times(3)).logEvaluation(key, fakeVariationKey) verify( mockEvaluationsFeature, times(3) - ).processEvaluation(key, fakeContext, "variation-456", "allocation-123", null, null) + ).processEvaluation(key, fakeContext, fakeVariationKey, fakeAllocationKey, null, null) val exposure = argumentCaptor() verify(mockWriter).write(exposure.capture()) assertThat(exposure.firstValue.flag.key).isEqualTo(key) - assertThat(exposure.firstValue.serialId).isEqualTo(123L) + assertThat(exposure.firstValue.serialId).isEqualTo(fakeSerialId) } @Test @@ -194,7 +217,7 @@ internal class FlagKeyObfuscationTest { val descriptor = metadata(salt) val encoding = checkNotNull(FlagKeyObfuscation.read(descriptor)) val decoded = checkNotNull(testedMapper.map(payload(checkNotNull(encoding.encode("flag")), salt = salt))) - val entry = FlagsStateEntry(fakeContext, decoded.flags, 1234L, decoded.obfuscation) + val entry = FlagsStateEntry(fakeContext, decoded.flags, fakeTimestamp, decoded.obfuscation) // When val serialized = FlagsStateSerializer(mockInternalLogger).serialize(entry) @@ -216,14 +239,14 @@ internal class FlagKeyObfuscationTest { // Then assertThat(testedClient.resolveBooleanValue("flag", false)).isTrue() assertThat(testedRepository.getPrecomputedFlag("flag")).isNotNull() - assertThat(testedRepository.getPrecomputedFlag("flag")?.variationKey).isEqualTo("variation-456") - assertThat(testedRepository.getPrecomputedFlag("flag")?.serialId).isEqualTo(123L) + assertThat(testedRepository.getPrecomputedFlag("flag")?.variationKey).isEqualTo(fakeVariationKey) + assertThat(testedRepository.getPrecomputedFlag("flag")?.serialId).isEqualTo(fakeSerialId) assertThat(testedRepository.getPrecomputedFlag(checkNotNull(encoding.encode("flag")))).isNull() } // Then verify(mockWriter, times(1)).write(any()) - verify(mockRumLogger, times(3)).logEvaluation("flag", "variation-456") + verify(mockRumLogger, times(3)).logEvaluation("flag", fakeVariationKey) } @Test @@ -265,7 +288,7 @@ internal class FlagKeyObfuscationTest { }.whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) // When - testedRepository = DefaultFlagsRepository(mockSdkCore, "obfuscation", mockDataStore) + testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore) testedClient = createClient() // Then @@ -299,10 +322,10 @@ internal class FlagKeyObfuscationTest { var cacheCallback: DataStoreReadCallback? = null doAnswer { cacheCallback = it.getArgument(2) } .whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) - val testedRepository = DefaultFlagsRepository(mockSdkCore, "late-cache", mockDataStore) + val testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore) testedRepository.setObfuscationSupported(false) val decoded = checkNotNull(testedMapper.map(payload(VECTORS[2].second))) - val entry = FlagsStateEntry(fakeContext, decoded.flags, 1234L, decoded.obfuscation) + val entry = FlagsStateEntry(fakeContext, decoded.flags, fakeTimestamp, decoded.obfuscation) // When checkNotNull(cacheCallback).onSuccess(DataStoreContent(0, entry)) @@ -317,12 +340,12 @@ internal class FlagKeyObfuscationTest { fun `M restore encoded cache W DefaultFlagsRepository() { native consumer }`() { // Given val decoded = checkNotNull(testedMapper.map(payload(VECTORS[2].second))) - val entry = FlagsStateEntry(fakeContext, decoded.flags, 1234L, decoded.obfuscation) + val entry = FlagsStateEntry(fakeContext, decoded.flags, fakeTimestamp, decoded.obfuscation) doAnswer { it.getArgument>(2).onSuccess(DataStoreContent(0, entry)) } .whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) // When - val testedRepository = DefaultFlagsRepository(mockSdkCore, "restored-cache", mockDataStore) + val testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore) // Then assertThat(testedRepository.getPrecomputedFlagWithContext("flag")?.second).isEqualTo(fakeContext) @@ -347,8 +370,8 @@ internal class FlagKeyObfuscationTest { .whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) whenever(mockDatadogContext.source) doReturn source val decoded = checkNotNull(testedMapper.map(payload(if (encoded) VECTORS[2].second else "flag", encoded))) - val entry = FlagsStateEntry(fakeContext, decoded.flags, 1234L, decoded.obfuscation) - testedRepository = DefaultFlagsRepository(mockSdkCore, "deferred-cache", mockDataStore) + val entry = FlagsStateEntry(fakeContext, decoded.flags, fakeTimestamp, decoded.obfuscation) + testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore) testedClient = createClient() val firstFlags = mutableListOf>() testedRepository.firstFlags.whenComplete { firstFlags.add(it) } @@ -404,11 +427,11 @@ internal class FlagKeyObfuscationTest { fun `M notify first readable flags W setFlagsAndContext() { wrapper replaces encoded cache }`() { // Given val decoded = checkNotNull(testedMapper.map(payload(VECTORS[2].second))) - val entry = FlagsStateEntry(fakeContext, decoded.flags, 1234L, decoded.obfuscation) + val entry = FlagsStateEntry(fakeContext, decoded.flags, fakeTimestamp, decoded.obfuscation) whenever(mockDatadogContext.source) doReturn "react-native" doAnswer { it.getArgument>(2).onSuccess(DataStoreContent(0, entry)) } .whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) - testedRepository = DefaultFlagsRepository(mockSdkCore, "wrapper-cache", mockDataStore) + testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore) val firstFlags = mutableListOf>() testedRepository.firstFlags.whenComplete { firstFlags.add(it) } assertThat(firstFlags).isEmpty() @@ -448,7 +471,7 @@ internal class FlagKeyObfuscationTest { assertThat(assignments).isNull() // Given - val entry = FlagsStateEntry(fakeContext, emptyMap(), 1234L) + val entry = FlagsStateEntry(fakeContext, emptyMap(), fakeTimestamp) val cache = JSONObject(FlagsStateSerializer(mockInternalLogger).serialize(entry)) JSONObject(json).keys().forEach { cache.put(it, JSONObject(json).get(it)) } @@ -503,7 +526,19 @@ internal class FlagKeyObfuscationTest { flagStateManager = mockFlagStateManager ) + private fun assignment(type: String = "boolean", value: Any = true): JSONObject = JSONObject() + .put("variationType", type).put("variationValue", value).put("allocationKey", fakeAllocationKey) + .put("variationKey", fakeVariationKey).put("doLog", true).put("serialId", fakeSerialId) + .put("reason", "TARGETING_MATCH").put("extraLogging", JSONObject().put("test", "metadata")) + + private fun payload(key: String, encoded: Boolean = true, salt: String = SALT): String { + val attrs = if (encoded) metadata(salt) else JSONObject() + attrs.put("flags", JSONObject().put(key, assignment())) + return JSONObject().put("data", JSONObject().put("attributes", attrs)).toString() + } + companion object { + // Keep these shared protocol vectors fixed; generate unrelated fixture values above. private const val SALT = "000102030405060708090a0b0c0d0e0f" private val VECTORS = listOf( "new-route-planner" to "a60479237ef2f69175bbe0bd581966d1583766941815dc1d414c883767795190", @@ -544,16 +579,5 @@ internal class FlagKeyObfuscationTest { private fun metadata(salt: String = SALT): JSONObject = JSONObject() .put("obfuscated", true) .put("obfuscation", JSONObject().put("scheme", FlagKeyObfuscation.SCHEME).put("salt", salt)) - - private fun assignment(type: String = "boolean", value: Any = true): JSONObject = JSONObject() - .put("variationType", type).put("variationValue", value).put("allocationKey", "allocation-123") - .put("variationKey", "variation-456").put("doLog", true).put("serialId", 123L) - .put("reason", "TARGETING_MATCH").put("extraLogging", JSONObject().put("test", "metadata")) - - private fun payload(key: String, encoded: Boolean = true, salt: String = SALT): String { - val attrs = if (encoded) metadata(salt) else JSONObject() - attrs.put("flags", JSONObject().put(key, assignment())) - return JSONObject().put("data", JSONObject().put("attributes", attrs)).toString() - } } } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt index ecdff4e14f..6884e86201 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt @@ -1218,7 +1218,7 @@ internal class EvaluationsManagerTest { fun `M retain matching assignments on invalid encoding W updateEvaluationsForContext() { HTTP integration }`() { // Given fakeDatadogContext = fakeDatadogContext.copy(source = "android") - val context = EvaluationContext("athlete-123", emptyMap()) + val context = EvaluationContext(fakeTargetingKey, emptyMap()) val testedRepository = createIntegrationRepository() val testedManager = createIntegrationManager(testedRepository) val mockCallback = mock() @@ -1252,7 +1252,10 @@ internal class EvaluationsManagerTest { mockWebServer.enqueue(MockResponse().setBody(ENCODED_RESPONSE_JSON.replace("flag-key-sha256-v1", "unknown"))) // When - testedManager.updateEvaluationsForContext(EvaluationContext("other-user", emptyMap()), mockCallback) + testedManager.updateEvaluationsForContext( + EvaluationContext("$fakeTargetingKey-other", emptyMap()), + mockCallback + ) // Then verify(mockFlagsStateManager).updateState(any()) @@ -1263,7 +1266,7 @@ internal class EvaluationsManagerTest { fun `M reject unsolicited encoding W updateEvaluationsForContext() { React Native HTTP integration }`() { // Given fakeDatadogContext = fakeDatadogContext.copy(source = "react-native") - val context = EvaluationContext("athlete-123", emptyMap()) + val context = EvaluationContext(fakeTargetingKey, emptyMap()) val testedRepository = createIntegrationRepository() val testedManager = createIntegrationManager(testedRepository) val mockCallback = mock() diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt index ebc5651048..b357b3ac3c 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt @@ -65,12 +65,14 @@ internal class PrecomputedAssignmentsRequestFactoryTest { // region create() - Success cases @Test - fun `M advertise encoding capability W create() { native source }`() { + fun `M advertise encoding capability W create() { native source }`( + @Forgery fakeEvaluationContext: EvaluationContext + ) { // Given - val context = fakeDatadogContext.copy(source = "android", sdkVersion = "overridden-core-version") + val context = fakeDatadogContext.copy(source = "android") // When - val request = checkNotNull(testedFactory.create(EvaluationContext("subject", emptyMap()), context)) + val request = checkNotNull(testedFactory.create(fakeEvaluationContext, context)) val buffer = Buffer() checkNotNull(request.body).writeTo(buffer) val attributes = JSONObject(buffer.readUtf8()).getJSONObject("data").getJSONObject("attributes") @@ -83,13 +85,15 @@ internal class PrecomputedAssignmentsRequestFactoryTest { } @Test - fun `M omit encoding capability W create() { bridge sources }`() { + fun `M omit encoding capability W create() { bridge sources }`( + @Forgery fakeEvaluationContext: EvaluationContext + ) { for (source in listOf("react-native", "flutter", "unknown")) { // Given val context = fakeDatadogContext.copy(source = source) // When - val request = checkNotNull(testedFactory.create(EvaluationContext("subject", emptyMap()), context)) + val request = checkNotNull(testedFactory.create(fakeEvaluationContext, context)) val buffer = Buffer() checkNotNull(request.body).writeTo(buffer) val attributes = JSONObject(buffer.readUtf8()).getJSONObject("data").getJSONObject("attributes") From ad7aa8d6960cf8bdcc823d41b43b48efafa00c1f Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 8 Oct 2026 08:02:13 -0400 Subject: [PATCH 10/17] FFLSDK-254: Limit obfuscation suppressions to specific calls --- detekt_custom_safe_calls_third_party.yml | 4 ++ detekt_custom_unsafe_calls.yml | 3 ++ .../internal/model/FlagKeyObfuscation.kt | 46 ++++++++++++++----- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/detekt_custom_safe_calls_third_party.yml b/detekt_custom_safe_calls_third_party.yml index cc6cabd804..6d88a6f472 100644 --- a/detekt_custom_safe_calls_third_party.yml +++ b/detekt_custom_safe_calls_third_party.yml @@ -496,6 +496,7 @@ datadog: - "kotlin.collections.Iterable.toJsonArray()" - "kotlin.collections.Iterable.toList()" - "kotlin.collections.Iterable.toMap(kotlin.collections.MutableMap)" + - "kotlin.collections.Iterator.forEach(kotlin.Function1)" - "kotlin.collections.LinkedHashMap()" - "kotlin.collections.LinkedHashMap(kotlin.collections.MutableMap?)" - "kotlin.collections.List.all(kotlin.Function1)" @@ -871,7 +872,9 @@ datadog: - "kotlin.ByteArray.constructor(kotlin.Int)" - "kotlin.ByteArray.copyTo(kotlin.Int, kotlin.ByteArray, kotlin.Int, kotlin.Int, com.datadog.android.api.InternalLogger)" - "kotlin.Char.digitToIntOrNull()" + - "kotlin.Char.isHighSurrogate()" - "kotlin.Char.isLowerCase()" + - "kotlin.Char.isLowSurrogate()" - "kotlin.Char.titlecase(java.util.Locale)" - "kotlin.CharArray.constructor(kotlin.Int, kotlin.Function1)" - "kotlin.Double.coerceAtMost(kotlin.Double)" @@ -1161,6 +1164,7 @@ datadog: - "org.json.JSONArray.length()" - "org.json.JSONArray.put(kotlin.Any?)" - "org.json.JSONArray.toJsonArray()" + - "org.json.JSONException.constructor(kotlin.String?)" - "org.json.JSONObject.constructor()" - "org.json.JSONObject.has(kotlin.String?)" - "org.json.JSONObject.keys()" diff --git a/detekt_custom_unsafe_calls.yml b/detekt_custom_unsafe_calls.yml index a198d8811f..ec612792d1 100644 --- a/detekt_custom_unsafe_calls.yml +++ b/detekt_custom_unsafe_calls.yml @@ -57,6 +57,7 @@ datadog: - "androidx.collection.LruCache.get(java.io.File):java.lang.NullPointerException" - "androidx.collection.LruCache.get(kotlin.String):java.lang.NullPointerException" - "androidx.collection.LruCache.put(java.io.File, kotlin.Unit):java.lang.NullPointerException" + - "androidx.collection.LruCache.put(kotlin.String, kotlin.String):java.lang.NullPointerException" - "androidx.collection.LruCache.remove(java.io.File):java.lang.NullPointerException" - "androidx.metrics.performance.JankStats.createAndTrack(android.view.Window, androidx.metrics.performance.JankStats.OnFrameListener):java.lang.IllegalStateException" - "androidx.navigation.Navigation.findNavController(android.app.Activity, kotlin.Int):java.lang.IllegalStateException" @@ -191,6 +192,7 @@ datadog: - "java.util.zip.Deflater.deflate(kotlin.ByteArray, kotlin.Int, kotlin.Int, kotlin.Int):java.lang.IllegalArgumentException" # endregion # region Kotlin primitives + - "kotlin.ByteArray.constructor(kotlin.Int, kotlin.Function1):java.lang.NegativeArraySizeException" - "kotlin.ByteArray.copyOf(kotlin.Int):java.lang.NegativeArraySizeException" - "kotlin.ByteArray.copyOfRange(kotlin.Int, kotlin.Int):java.lang.IndexOutOfBoundsException,java.lang.IllegalArgumentException" - "kotlin.ByteArray.get(kotlin.Int):java.lang.IndexOutOfBoundsException" @@ -205,6 +207,7 @@ datadog: - "kotlin.String.substring(kotlin.Int, kotlin.Int):java.lang.IndexOutOfBoundsException" - "kotlin.String.takeLast(kotlin.Int):java.lang.IllegalArgumentException" - "kotlin.String.takeLast(kotlin.Int):kotlin.IllegalArgumentException" + - "kotlin.String.toInt(kotlin.Int):java.lang.NumberFormatException,java.lang.IllegalArgumentException" - "kotlin.String.toLong():java.lang.NumberFormatException" - "kotlin.collections.MutableSet.first():java.util.NoSuchElementException" - "kotlin.String.format(kotlin.Array):java.util.IllegalFormatException" diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt index af11f3697b..c1192857eb 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt @@ -17,21 +17,33 @@ internal data class FlagKeyObfuscation private constructor(val salt: String) { @Suppress("UnsafeThirdPartyFunctionCall") // The cache limit is a positive constant. private val lookupKeys = LruCache(LOOKUP_CACHE_LIMIT) - @Suppress("UnsafeThirdPartyFunctionCall") // SHA-256 is mandatory on Android; salt and Unicode are validated. fun encode(key: String): String? { // Reject unpaired surrogates instead of silently replacing their UTF-8 encoding. if (!isWellFormedUnicode(key)) return null - return lookupKeys.get(key) ?: run { + @Suppress("UnsafeThirdPartyFunctionCall") // The key is non-null. + val cachedKey = lookupKeys.get(key) + return cachedKey ?: run { + @Suppress("UnsafeThirdPartyFunctionCall") // SHA-256 is mandatory on Android. val digest = MessageDigest.getInstance("SHA-256") digest.update("datadog.feature-flags.flag-key.v1\u0000".toByteArray(Charsets.UTF_8)) - digest.update(ByteArray(SALT_BYTES) { salt.substring(it * 2, it * 2 + 2).toInt(HEX_RADIX).toByte() }) + @Suppress("UnsafeThirdPartyFunctionCall") // The size is positive; the salt is validated hexadecimal. + val saltBytes = ByteArray(SALT_BYTES) { salt.substring(it * 2, it * 2 + 2).toInt(HEX_RADIX).toByte() } + digest.update(saltBytes) digest.update(key.toByteArray(Charsets.UTF_8)) - digest.digest().joinToString("") { "%02x".format(it) }.also { lookupKeys.put(key, it) } + digest.digest().joinToString("") { + @Suppress("UnsafeThirdPartyFunctionCall") // The constant format accepts a byte. + "%02x".format(it) + }.also { + @Suppress("UnsafeThirdPartyFunctionCall") // The key and encoded value are non-null. + lookupKeys.put(key, it) + } } } - @Suppress("UnsafeThirdPartyFunctionCall") // Both fields have validated, non-null names and values. - fun toJson(): JSONObject = JSONObject().put("scheme", SCHEME).put("salt", salt) + fun toJson(): JSONObject { + @Suppress("UnsafeThirdPartyFunctionCall") // Both fields have validated, non-null names and string values. + return JSONObject().put("scheme", SCHEME).put("salt", salt) + } companion object { const val SCHEME = "flag-key-sha256-v1" @@ -43,32 +55,42 @@ internal data class FlagKeyObfuscation private constructor(val salt: String) { /** Throws for unsupported or inconsistent descriptors, including explicit JSON null. */ // JSON errors propagate to the response or cache parser, where they are caught. - @Suppress("UnsafeThirdPartyFunctionCall", "ThrowingInternalException", "ThrowsCount") + @Suppress("ThrowsCount") fun read(json: JSONObject): FlagKeyObfuscation? { val obfuscated = json.opt("obfuscated") if ((obfuscated == null || obfuscated == false) && !json.has("obfuscation")) return null - if (obfuscated != true) throw JSONException("Invalid flag-key obfuscation metadata") + if (obfuscated != true) { + @Suppress("ThrowingInternalException") // Caught by the response or cache parser. + throw JSONException("Invalid flag-key obfuscation metadata") + } + @Suppress("UnsafeThirdPartyFunctionCall") // JSON errors are caught by the response or cache parser. val descriptor = json.getJSONObject("obfuscation") - if (descriptor.opt("scheme") != SCHEME) throw JSONException("Unsupported flag-key obfuscation scheme") + if (descriptor.opt("scheme") != SCHEME) { + @Suppress("ThrowingInternalException") // Caught by the response or cache parser. + throw JSONException("Unsupported flag-key obfuscation scheme") + } val salt = descriptor.opt("salt") if (salt !is String || !isLowercaseHex(salt, SALT_BYTES)) { + @Suppress("ThrowingInternalException") // Caught by the response or cache parser. throw JSONException("Flag-key salt must contain 32 lowercase hexadecimal characters") } return FlagKeyObfuscation(salt) } // Caught by the response or cache parser. - @Suppress("UnsafeThirdPartyFunctionCall", "ThrowingInternalException") fun validateKeys(keys: Iterator) { keys.forEach { - if (!isLowercaseHex(it, DIGEST_BYTES)) throw JSONException("Invalid encoded flag key") + if (!isLowercaseHex(it, DIGEST_BYTES)) { + @Suppress("ThrowingInternalException") // Caught by the response or cache parser. + throw JSONException("Invalid encoded flag key") + } } } private fun isLowercaseHex(value: String, bytes: Int): Boolean = value.length == bytes * 2 && value.all { it in '0'..'9' || it in 'a'..'f' } - @Suppress("UnsafeThirdPartyFunctionCall", "ReturnCount") // Character predicates cannot throw. + @Suppress("ReturnCount") private fun isWellFormedUnicode(value: String): Boolean { var index = 0 while (index < value.length) { From 1d236bc956b2f3788d1d8b853ade1878ffcb8158 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 8 Oct 2026 08:03:10 -0400 Subject: [PATCH 11/17] FFLSDK-254: Declare obfuscation parser exceptions --- .../datadog/android/flags/internal/model/FlagKeyObfuscation.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt index c1192857eb..91cdeaabcb 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt @@ -55,6 +55,7 @@ internal data class FlagKeyObfuscation private constructor(val salt: String) { /** Throws for unsupported or inconsistent descriptors, including explicit JSON null. */ // JSON errors propagate to the response or cache parser, where they are caught. + @Throws(JSONException::class) @Suppress("ThrowsCount") fun read(json: JSONObject): FlagKeyObfuscation? { val obfuscated = json.opt("obfuscated") @@ -78,6 +79,7 @@ internal data class FlagKeyObfuscation private constructor(val salt: String) { } // Caught by the response or cache parser. + @Throws(JSONException::class) fun validateKeys(keys: Iterator) { keys.forEach { if (!isLowercaseHex(it, DIGEST_BYTES)) { From 71cc4df42dfa3c5f4ffbaddb612d895344a6c595 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 8 Oct 2026 09:27:39 -0400 Subject: [PATCH 12/17] FFLSDK-254: Generate assignment and cache JSON models --- .../jsonschema/GenerateJsonSchemaTask.kt | 7 +- .../jsonschema/generator/FileGenerator.kt | 7 +- .../jsonschema/generator/FileGeneratorTest.kt | 14 +++ detekt_custom_safe_calls_third_party.yml | 3 + .../dd-sdk-android-flags/build.gradle.kts | 6 + .../src/main/json/internal/assignment.json | 17 +++ .../json/internal/assignments-response.json | 22 ++++ .../src/main/json/internal/flags-state.json | 21 ++++ .../src/main/json/internal/obfuscation.json | 10 ++ .../internal/model/FlagKeyObfuscation.kt | 46 +++---- .../model/PrecomputedFlagConstants.kt | 21 ---- .../internal/model/PrecomputedFlagJson.kt | 69 ++++++++++ .../persistence/FlagsStateDeserializer.kt | 118 ++++++------------ .../persistence/FlagsStateSerializer.kt | 77 ++++-------- .../repository/net/PrecomputeMapper.kt | 58 +++------ .../flags/internal/FlagKeyObfuscationTest.kt | 19 +-- .../persistence/FlagsStateDeserializerTest.kt | 90 ++++++++++++- .../repository/net/PrecomputeMapperTest.kt | 53 ++++++++ 18 files changed, 430 insertions(+), 228 deletions(-) create mode 100644 features/dd-sdk-android-flags/src/main/json/internal/assignment.json create mode 100644 features/dd-sdk-android-flags/src/main/json/internal/assignments-response.json create mode 100644 features/dd-sdk-android-flags/src/main/json/internal/flags-state.json create mode 100644 features/dd-sdk-android-flags/src/main/json/internal/obfuscation.json create mode 100644 features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/PrecomputedFlagJson.kt diff --git a/build-logic/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/GenerateJsonSchemaTask.kt b/build-logic/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/GenerateJsonSchemaTask.kt index 224847b731..a3136ef732 100644 --- a/build-logic/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/GenerateJsonSchemaTask.kt +++ b/build-logic/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/GenerateJsonSchemaTask.kt @@ -38,6 +38,7 @@ abstract class GenerateJsonSchemaTask : DefaultTask() { init { group = "datadog" description = "Read source JSON schema files and generate the relevant Kotlin data classes" + internalModels.convention(false) } // region Input/Output @@ -80,6 +81,10 @@ abstract class GenerateJsonSchemaTask : DefaultTask() { @get:Input abstract val targetPackageName: Property + /** Keep generated models out of the public SDK API when requested. */ + @get:Input + abstract val internalModels: Property + /** * The list of schema files to be ignored. */ @@ -124,7 +129,7 @@ abstract class GenerateJsonSchemaTask : DefaultTask() { logger.info("Found ${files.size} files in input dir: $inputDir") val reader = JsonSchemaReader(inputNameMapping.get(), logger) - val generator = FileGenerator(outputDir, targetPackageName.get(), logger) + val generator = FileGenerator(outputDir, targetPackageName.get(), logger, internalModels.get()) files.forEach { val type = reader.readSchema(it) generator.generate(type) diff --git a/build-logic/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/generator/FileGenerator.kt b/build-logic/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/generator/FileGenerator.kt index 8b3afcde93..49b31038b3 100644 --- a/build-logic/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/generator/FileGenerator.kt +++ b/build-logic/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/generator/FileGenerator.kt @@ -9,6 +9,7 @@ package com.datadog.gradle.plugin.jsonschema.generator import com.datadog.gradle.plugin.jsonschema.TypeDefinition import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.TypeSpec import org.gradle.api.logging.Logger import java.io.File @@ -16,7 +17,8 @@ import java.io.File class FileGenerator( private val outputDir: File, private val packageName: String, - private val logger: Logger + private val logger: Logger, + private val internalModels: Boolean = false ) { private val knownTypes: MutableSet = mutableSetOf() @@ -75,6 +77,9 @@ class FileGenerator( ).apply { written = true } ) val topLevelTypeBuilder = generateTypeSpec(definition, rootTypeName) + if (internalModels) { + topLevelTypeBuilder.addModifiers(KModifier.INTERNAL) + } while (knownTypes.any(isUnwrittenClass)) { val nestedClasses = knownTypes.filter(isUnwrittenClass).toSet() diff --git a/build-logic/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/generator/FileGeneratorTest.kt b/build-logic/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/generator/FileGeneratorTest.kt index 1bf88334db..d397be5fc7 100644 --- a/build-logic/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/generator/FileGeneratorTest.kt +++ b/build-logic/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/generator/FileGeneratorTest.kt @@ -41,6 +41,7 @@ import com.datadog.gradle.plugin.jsonschema.UserMerged import com.datadog.gradle.plugin.jsonschema.Version import com.datadog.gradle.plugin.jsonschema.Video import com.datadog.gradle.plugin.jsonschema.WeirdCombo +import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource @@ -50,6 +51,19 @@ import java.nio.file.Paths class FileGeneratorTest { + @Test + fun `M generate internal model W generate() { internal package }`(@TempDir tempDir: File) { + // Given + val testedGenerator = FileGenerator(tempDir, "com.example.internal.model", NoOpLogger(), internalModels = true) + + // When + testedGenerator.generate(Person) + + // Then + val generated = File(tempDir, "com/example/internal/model/Person.kt").readText() + org.junit.jupiter.api.Assertions.assertTrue(generated.contains("internal data class Person(")) + } + @ParameterizedTest @MethodSource("data") fun `generates a Poko file`( diff --git a/detekt_custom_safe_calls_third_party.yml b/detekt_custom_safe_calls_third_party.yml index 6d88a6f472..b6a8ddee80 100644 --- a/detekt_custom_safe_calls_third_party.yml +++ b/detekt_custom_safe_calls_third_party.yml @@ -497,6 +497,8 @@ datadog: - "kotlin.collections.Iterable.toList()" - "kotlin.collections.Iterable.toMap(kotlin.collections.MutableMap)" - "kotlin.collections.Iterator.forEach(kotlin.Function1)" + - "kotlin.collections.MutableSet.iterator()" + - "kotlin.collections.MutableMap.mapNotNull(kotlin.Function1)" - "kotlin.collections.LinkedHashMap()" - "kotlin.collections.LinkedHashMap(kotlin.collections.MutableMap?)" - "kotlin.collections.List.all(kotlin.Function1)" @@ -872,6 +874,7 @@ datadog: - "kotlin.ByteArray.constructor(kotlin.Int)" - "kotlin.ByteArray.copyTo(kotlin.Int, kotlin.ByteArray, kotlin.Int, kotlin.Int, com.datadog.android.api.InternalLogger)" - "kotlin.Char.digitToIntOrNull()" + - "kotlin.String.equals(kotlin.String?, kotlin.Boolean)" - "kotlin.Char.isHighSurrogate()" - "kotlin.Char.isLowerCase()" - "kotlin.Char.isLowSurrogate()" diff --git a/features/dd-sdk-android-flags/build.gradle.kts b/features/dd-sdk-android-flags/build.gradle.kts index 3a52437f0c..bf58e52ddc 100644 --- a/features/dd-sdk-android-flags/build.gradle.kts +++ b/features/dd-sdk-android-flags/build.gradle.kts @@ -44,6 +44,12 @@ createJsonModelsGenerationTask("generateFlagsModelsFromJson") { targetPackageName = "com.datadog.android.flags.model" } +createJsonModelsGenerationTask("generateFlagsInternalModelsFromJson") { + inputDirPath = "src/main/json/internal" + targetPackageName = "com.datadog.android.flags.internal.model.generated" + internalModels.set(true) +} + // TODO RUM-18189 Support new AGP DSL @Suppress("DEPRECATION") android { diff --git a/features/dd-sdk-android-flags/src/main/json/internal/assignment.json b/features/dd-sdk-android-flags/src/main/json/internal/assignment.json new file mode 100644 index 0000000000..b4822e1f9b --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/json/internal/assignment.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Assignment", + "description": "An assignment after conversion of its value to the SDK string representation.", + "type": "object", + "properties": { + "variationType": { "type": "string" }, + "variationValue": { "type": "string" }, + "doLog": { "type": "boolean" }, + "allocationKey": { "type": "string" }, + "variationKey": { "type": "string" }, + "extraLogging": { "type": "object", "additionalProperties": true }, + "reason": { "type": "string" }, + "serialId": { "type": "integer" } + }, + "required": ["variationType", "variationValue", "doLog", "allocationKey", "variationKey", "extraLogging", "reason"] +} diff --git a/features/dd-sdk-android-flags/src/main/json/internal/assignments-response.json b/features/dd-sdk-android-flags/src/main/json/internal/assignments-response.json new file mode 100644 index 0000000000..487c35c5c0 --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/json/internal/assignments-response.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "AssignmentsResponse", + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "attributes": { + "type": "object", + "properties": { + "flags": { "type": "object", "additionalProperties": true } + }, + "required": ["flags"], + "additionalProperties": true + } + }, + "required": ["attributes"] + } + }, + "required": ["data"] +} diff --git a/features/dd-sdk-android-flags/src/main/json/internal/flags-state.json b/features/dd-sdk-android-flags/src/main/json/internal/flags-state.json new file mode 100644 index 0000000000..f1d8f7e26c --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/json/internal/flags-state.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "FlagsState", + "type": "object", + "properties": { + "evaluationContext": { + "type": "object", + "properties": { + "targetingKey": { "type": "string" }, + "attributes": { "type": "object", "additionalProperties": true } + }, + "required": ["targetingKey"] + }, + "flags": { "type": "object", "additionalProperties": true }, + "encodedFlags": { "type": "object", "additionalProperties": true }, + "lastUpdateTimestamp": { "type": "integer" }, + "obfuscated": { "type": "boolean" }, + "obfuscation": { "$ref": "obfuscation.json" } + }, + "required": ["evaluationContext", "lastUpdateTimestamp"] +} diff --git a/features/dd-sdk-android-flags/src/main/json/internal/obfuscation.json b/features/dd-sdk-android-flags/src/main/json/internal/obfuscation.json new file mode 100644 index 0000000000..7522671b32 --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/json/internal/obfuscation.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Obfuscation", + "type": "object", + "properties": { + "scheme": { "type": "string" }, + "salt": { "type": "string" } + }, + "required": ["scheme", "salt"] +} diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt index 91cdeaabcb..eafe1a3a5a 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt @@ -7,8 +7,9 @@ package com.datadog.android.flags.internal.model import androidx.collection.LruCache -import org.json.JSONException -import org.json.JSONObject +import com.datadog.android.flags.internal.model.generated.Obfuscation +import com.google.gson.JsonObject +import com.google.gson.JsonParseException import java.security.MessageDigest /** The public encoding descriptor travels with its assignment map. */ @@ -40,11 +41,6 @@ internal data class FlagKeyObfuscation private constructor(val salt: String) { } } - fun toJson(): JSONObject { - @Suppress("UnsafeThirdPartyFunctionCall") // Both fields have validated, non-null names and string values. - return JSONObject().put("scheme", SCHEME).put("salt", salt) - } - companion object { const val SCHEME = "flag-key-sha256-v1" const val CAPABILITY = "assignment-encoding-flag-key-256-v1" @@ -55,36 +51,40 @@ internal data class FlagKeyObfuscation private constructor(val salt: String) { /** Throws for unsupported or inconsistent descriptors, including explicit JSON null. */ // JSON errors propagate to the response or cache parser, where they are caught. - @Throws(JSONException::class) + @Throws(JsonParseException::class) @Suppress("ThrowsCount") - fun read(json: JSONObject): FlagKeyObfuscation? { - val obfuscated = json.opt("obfuscated") - if ((obfuscated == null || obfuscated == false) && !json.has("obfuscation")) return null - if (obfuscated != true) { + fun read(json: JsonObject): FlagKeyObfuscation? { + val obfuscated = json.get("obfuscated") + val isBoolean = obfuscated?.isJsonPrimitive == true && obfuscated.asJsonPrimitive.isBoolean + val isPlaintext = obfuscated == null || (isBoolean && !obfuscated.asBoolean) + if (isPlaintext && !json.has("obfuscation")) return null + if (!isBoolean || !obfuscated.asBoolean) { @Suppress("ThrowingInternalException") // Caught by the response or cache parser. - throw JSONException("Invalid flag-key obfuscation metadata") + throw JsonParseException("Invalid flag-key obfuscation metadata") } - @Suppress("UnsafeThirdPartyFunctionCall") // JSON errors are caught by the response or cache parser. - val descriptor = json.getJSONObject("obfuscation") - if (descriptor.opt("scheme") != SCHEME) { + val descriptor = PrecomputedFlagJson.objectValue(json.get("obfuscation")) + val scheme = descriptor.get("scheme") + if (scheme?.isJsonPrimitive != true || !scheme.asJsonPrimitive.isString || scheme.asString != SCHEME) { @Suppress("ThrowingInternalException") // Caught by the response or cache parser. - throw JSONException("Unsupported flag-key obfuscation scheme") + throw JsonParseException("Unsupported flag-key obfuscation scheme") } - val salt = descriptor.opt("salt") - if (salt !is String || !isLowercaseHex(salt, SALT_BYTES)) { + val salt = descriptor.get("salt") + if (salt?.isJsonPrimitive != true || !salt.asJsonPrimitive.isString || + !isLowercaseHex(salt.asString, SALT_BYTES) + ) { @Suppress("ThrowingInternalException") // Caught by the response or cache parser. - throw JSONException("Flag-key salt must contain 32 lowercase hexadecimal characters") + throw JsonParseException("Flag-key salt must contain 32 lowercase hexadecimal characters") } - return FlagKeyObfuscation(salt) + return FlagKeyObfuscation(Obfuscation.fromJsonObject(descriptor).salt) } // Caught by the response or cache parser. - @Throws(JSONException::class) + @Throws(JsonParseException::class) fun validateKeys(keys: Iterator) { keys.forEach { if (!isLowercaseHex(it, DIGEST_BYTES)) { @Suppress("ThrowingInternalException") // Caught by the response or cache parser. - throw JSONException("Invalid encoded flag key") + throw JsonParseException("Invalid encoded flag key") } } } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/PrecomputedFlagConstants.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/PrecomputedFlagConstants.kt index 4a013b0da1..864745ff3a 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/PrecomputedFlagConstants.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/PrecomputedFlagConstants.kt @@ -6,27 +6,6 @@ package com.datadog.android.flags.internal.model -internal enum class JsonKeys(val value: String) { - // EvaluationContext keys - EVALUATION_CONTEXT("evaluationContext"), - TARGETING_KEY("targetingKey"), - ATTRIBUTES("attributes"), - - // FlagsStateEntry keys - FLAGS("flags"), - LAST_UPDATE_TIMESTAMP("lastUpdateTimestamp"), - - // PrecomputedFlag keys - VARIATION_TYPE("variationType"), - VARIATION_VALUE("variationValue"), - DO_LOG("doLog"), - ALLOCATION_KEY("allocationKey"), - VARIATION_KEY("variationKey"), - EXTRA_LOGGING("extraLogging"), - REASON("reason"), - SERIAL_ID("serialId") -} - internal enum class VariationType(val value: String) { BOOLEAN("boolean"), STRING("string"), diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/PrecomputedFlagJson.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/PrecomputedFlagJson.kt new file mode 100644 index 0000000000..0682dc7c40 --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/PrecomputedFlagJson.kt @@ -0,0 +1,69 @@ +/* + * 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.flags.internal.model + +import com.datadog.android.flags.internal.model.generated.Assignment +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import org.json.JSONObject + +/** Converts the generated JSON model to the existing evaluator model. */ +internal object PrecomputedFlagJson { + @Throws(JsonParseException::class) + fun read(json: JsonObject): PrecomputedFlag { + // Preserve JSONObject's string conversion, including JSON variants and explicit null. + STRING_FIELDS.forEach { field -> + val value = json.get(field) ?: throw JsonParseException("Missing assignment field: $field") + json.addProperty(field, stringValue(value)) + } + val doLog = json.get("doLog") + val booleanValue = doLog?.takeIf { it.isJsonPrimitive }?.asString + if (!booleanValue.equals("true", ignoreCase = true) && !booleanValue.equals("false", ignoreCase = true)) { + throw JsonParseException("Invalid assignment doLog") + } + json.addProperty("doLog", booleanValue.equals("true", ignoreCase = true)) + // Existing readers ignore null and nonnumeric serial IDs. + val serialId = json.get("serialId") + if (serialId?.isJsonPrimitive != true || !serialId.asJsonPrimitive.isNumber) json.remove("serialId") + + val assignment = Assignment.fromJsonObject(json) + + @Suppress("UnsafeThirdPartyFunctionCall") // Generated JSON is a valid object. + val extraLogging = JSONObject(assignment.extraLogging.toJson().toString()) + return PrecomputedFlag( + variationType = assignment.variationType, + variationValue = assignment.variationValue, + doLog = assignment.doLog, + allocationKey = assignment.allocationKey, + variationKey = assignment.variationKey, + extraLogging = extraLogging, + reason = assignment.reason, + serialId = assignment.serialId + ) + } + + fun write(flag: PrecomputedFlag): Assignment = Assignment( + variationType = flag.variationType, + variationValue = flag.variationValue, + doLog = flag.doLog, + allocationKey = flag.allocationKey, + variationKey = flag.variationKey, + extraLogging = Assignment.ExtraLogging.fromJson(flag.extraLogging.toString()), + reason = flag.reason, + serialId = flag.serialId + ) + + fun stringValue(value: JsonElement): String = + if (value.isJsonPrimitive && value.asJsonPrimitive.isString) value.asString else value.toString() + + @Throws(JsonParseException::class) + fun objectValue(value: Any?): JsonObject = (value as? JsonObject) + ?: throw JsonParseException("Expected a JSON object") + + private val STRING_FIELDS = listOf("variationType", "variationValue", "allocationKey", "variationKey", "reason") +} diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializer.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializer.kt index 2d71e5e281..6b35fdba6c 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializer.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializer.kt @@ -10,43 +10,61 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.core.internal.persistence.Deserializer import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.internal.model.FlagsStateEntry -import com.datadog.android.flags.internal.model.JsonKeys import com.datadog.android.flags.internal.model.PrecomputedFlag +import com.datadog.android.flags.internal.model.PrecomputedFlagJson +import com.datadog.android.flags.internal.model.generated.FlagsState import com.datadog.android.flags.model.EvaluationContext -import org.json.JSONException -import org.json.JSONObject +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser internal class FlagsStateDeserializer(private val internalLogger: InternalLogger) : Deserializer { override fun deserialize(model: String): FlagsStateEntry? = try { - val json = JSONObject(model) - - @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch - val contextJson = json.getJSONObject(JsonKeys.EVALUATION_CONTEXT.value) - val evaluationContext = deserializeEvaluationContext(contextJson) - - @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch + val json = PrecomputedFlagJson.objectValue(JsonParser.parseString(model)) val obfuscation = FlagKeyObfuscation.read(json) - val flagsJson = json.getJSONObject(if (obfuscation == null) JsonKeys.FLAGS.value else "encodedFlags") - if (obfuscation != null) FlagKeyObfuscation.validateKeys(flagsJson.keys()) - val flags = deserializeFlags(flagsJson) - - @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch - val timestamp = json.getLong(JsonKeys.LAST_UPDATE_TIMESTAMP.value) + // As before, only the map selected by the encoding descriptor is read. + json.remove(if (obfuscation == null) "encodedFlags" else "flags") + val contextJson = PrecomputedFlagJson.objectValue(json.get("evaluationContext")) + if (contextJson.get("attributes") !is JsonObject) contextJson.remove("attributes") + contextJson.get("targetingKey")?.let { + contextJson.addProperty("targetingKey", PrecomputedFlagJson.stringValue(it)) + } + if (json.get("lastUpdateTimestamp")?.isJsonPrimitive != true) { + @Suppress("ThrowingInternalException") // Caught below and reported as an invalid cache. + throw JsonParseException("Invalid persisted flag timestamp") + } + val state = FlagsState.fromJsonObject(json) + val assignments = (if (obfuscation == null) state.flags else state.encodedFlags)?.additionalProperties + if (assignments == null) { + @Suppress("ThrowingInternalException") // Caught below and reported as an invalid cache. + throw JsonParseException("Missing persisted assignments") + } + if (obfuscation != null) FlagKeyObfuscation.validateKeys(assignments.keys.iterator()) + val flags = assignments.mapNotNull { (key, value) -> + // A malformed object can be skipped; a non-object entry invalidates the cache, as before. + deserializePrecomputedFlag(PrecomputedFlagJson.objectValue(value))?.let { key to it } + }.toMap() // A nonempty cache with no readable flags is not a valid empty configuration. - if (flags.isEmpty() && flagsJson.length() > 0) { + if (flags.isEmpty() && assignments.isNotEmpty()) { null } else { FlagsStateEntry( - evaluationContext = evaluationContext, + evaluationContext = EvaluationContext( + state.evaluationContext.targetingKey, + state.evaluationContext.attributes?.additionalProperties.orEmpty().mapValues { + PrecomputedFlagJson.stringValue(it.value as JsonElement) + } + ), flags = flags, - lastUpdateTimestamp = timestamp, + lastUpdateTimestamp = state.lastUpdateTimestamp, obfuscation = obfuscation ) } - } catch (e: JSONException) { + } catch (e: JsonParseException) { internalLogger.log( InternalLogger.Level.ERROR, InternalLogger.Target.MAINTAINER, @@ -65,63 +83,9 @@ internal class FlagsStateDeserializer(private val internalLogger: InternalLogger null } - @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch - private fun deserializeEvaluationContext(contextJson: JSONObject): EvaluationContext { - val targetingKey = contextJson.getString(JsonKeys.TARGETING_KEY.value) - - val attributes = try { - deserializeAttributes(contextJson.getJSONObject(JsonKeys.ATTRIBUTES.value)) - } catch (_: JSONException) { - emptyMap() - } - - return EvaluationContext(targetingKey, attributes) - } - - @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch - private fun deserializeAttributes(attributesJson: JSONObject): Map { - val attributes = mutableMapOf() - val keys = attributesJson.keys() - - while (keys.hasNext()) { - val key = keys.next() - val value = attributesJson.get(key).toString() - attributes[key] = value - } - - return attributes - } - - @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch - private fun deserializeFlags(flagsJson: JSONObject): Map { - val flags = mutableMapOf() - val keys = flagsJson.keys() - - while (keys.hasNext()) { - val key = keys.next() - val flagJson = flagsJson.getJSONObject(key) - val flag = deserializePrecomputedFlag(flagJson) - if (flag != null) { - flags[key] = flag - } - } - - return flags - } - - @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch - private fun deserializePrecomputedFlag(flagJson: JSONObject): PrecomputedFlag? = try { - PrecomputedFlag( - variationType = flagJson.getString(JsonKeys.VARIATION_TYPE.value), - variationValue = flagJson.getString(JsonKeys.VARIATION_VALUE.value), - doLog = flagJson.getBoolean(JsonKeys.DO_LOG.value), - allocationKey = flagJson.getString(JsonKeys.ALLOCATION_KEY.value), - variationKey = flagJson.getString(JsonKeys.VARIATION_KEY.value), - extraLogging = flagJson.getJSONObject(JsonKeys.EXTRA_LOGGING.value), - reason = flagJson.getString(JsonKeys.REASON.value), - serialId = (flagJson.opt(JsonKeys.SERIAL_ID.value) as? Number)?.toLong() - ) - } catch (e: JSONException) { + private fun deserializePrecomputedFlag(flagJson: JsonObject): PrecomputedFlag? = try { + PrecomputedFlagJson.read(flagJson) + } catch (e: JsonParseException) { internalLogger.log( InternalLogger.Level.ERROR, InternalLogger.Target.MAINTAINER, diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializer.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializer.kt index 8ac90101b8..ecce9e11f9 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializer.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializer.kt @@ -8,44 +8,34 @@ package com.datadog.android.flags.internal.persistence import com.datadog.android.api.InternalLogger import com.datadog.android.core.persistence.Serializer +import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.internal.model.FlagsStateEntry -import com.datadog.android.flags.internal.model.JsonKeys -import com.datadog.android.flags.internal.model.PrecomputedFlag -import org.json.JSONException -import org.json.JSONObject +import com.datadog.android.flags.internal.model.PrecomputedFlagJson +import com.datadog.android.flags.internal.model.generated.FlagsState +import com.google.gson.JsonParseException -/** - * Serializer for converting FlagsStateEntry objects to JSON strings for datastore persistence. - */ +/** Maps evaluator state to the generated disk-cache model. */ internal class FlagsStateSerializer(private val internalLogger: InternalLogger) : Serializer { - @Suppress("TooGenericExceptionCaught", "UnsafeThirdPartyFunctionCall") // JSON operations are caught below. override fun serialize(model: FlagsStateEntry): String = try { - val json = JSONObject() - - val contextJson = JSONObject().apply { - put(JsonKeys.TARGETING_KEY.value, model.evaluationContext.targetingKey) - put(JsonKeys.ATTRIBUTES.value, serializeAttributes(model.evaluationContext.attributes)) - } - json.put(JsonKeys.EVALUATION_CONTEXT.value, contextJson) - - val flagsJson = JSONObject() - model.flags.forEach { (key, flag) -> - flagsJson.put(key, serializePrecomputedFlag(flag)) - } - if (model.obfuscation == null) { - json.put(JsonKeys.FLAGS.value, flagsJson) - } else { - // Older SDKs must reject this cache instead of reading digests as original keys. - json.put("encodedFlags", flagsJson) - json.put("obfuscated", true) - json.put("obfuscation", model.obfuscation.toJson()) - } - - json.put(JsonKeys.LAST_UPDATE_TIMESTAMP.value, model.lastUpdateTimestamp) - - json.toString() - } catch (e: JSONException) { + val flags = FlagsState.Flags( + model.flags.mapValues { + PrecomputedFlagJson.write(it.value).toJson() + }.toMutableMap() + ) + FlagsState( + evaluationContext = FlagsState.EvaluationContext( + targetingKey = model.evaluationContext.targetingKey, + attributes = FlagsState.Flags(model.evaluationContext.attributes.toMutableMap()) + ), + // Older SDKs must reject encoded caches instead of reading digests as original keys. + flags = flags.takeIf { model.obfuscation == null }, + encodedFlags = flags.takeIf { model.obfuscation != null }, + lastUpdateTimestamp = model.lastUpdateTimestamp, + obfuscated = true.takeIf { model.obfuscation != null }, + obfuscation = model.obfuscation?.let { FlagsState.Obfuscation(FlagKeyObfuscation.SCHEME, it.salt) } + ).toJson().toString() + } catch (e: JsonParseException) { internalLogger.log( InternalLogger.Level.ERROR, InternalLogger.Target.MAINTAINER, @@ -54,25 +44,4 @@ internal class FlagsStateSerializer(private val internalLogger: InternalLogger) ) "" } - - @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch - private fun serializeAttributes(attributes: Map): JSONObject { - val attributesJson = JSONObject() - attributes.forEach { (key, value) -> - attributesJson.put(key, value) - } - return attributesJson - } - - @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch - private fun serializePrecomputedFlag(flag: PrecomputedFlag): JSONObject = JSONObject().apply { - put(JsonKeys.VARIATION_TYPE.value, flag.variationType) - put(JsonKeys.VARIATION_VALUE.value, flag.variationValue) - put(JsonKeys.DO_LOG.value, flag.doLog) - put(JsonKeys.ALLOCATION_KEY.value, flag.allocationKey) - put(JsonKeys.VARIATION_KEY.value, flag.variationKey) - put(JsonKeys.EXTRA_LOGGING.value, flag.extraLogging) - put(JsonKeys.REASON.value, flag.reason) - flag.serialId?.let { put(JsonKeys.SERIAL_ID.value, it) } - } } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapper.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapper.kt index 89c4a75d3d..20ad67987c 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapper.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapper.kt @@ -8,55 +8,33 @@ package com.datadog.android.flags.internal.repository.net import com.datadog.android.api.InternalLogger import com.datadog.android.flags.internal.model.FlagKeyObfuscation -import com.datadog.android.flags.internal.model.JsonKeys import com.datadog.android.flags.internal.model.PrecomputedAssignments import com.datadog.android.flags.internal.model.PrecomputedFlag -import org.json.JSONException -import org.json.JSONObject +import com.datadog.android.flags.internal.model.PrecomputedFlagJson +import com.datadog.android.flags.internal.model.generated.AssignmentsResponse +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParseException /** * Responsible for parsing network response to [PrecomputedFlag] objects. */ internal class PrecomputeMapper(private val internalLogger: InternalLogger) { - // JSONObject methods accept non-null String parameters despite Detekt's incorrect nullable interpretation - // All getJsonObject calls are wrapped in try-catch for JSONException which is the actual exception thrown - @Suppress("UnsafeThirdPartyFunctionCall") internal fun map(rawJson: String): PrecomputedAssignments? = try { - val jsonResponse = JSONObject(rawJson) - val data = jsonResponse.getJSONObject("data") - val attributes = data.getJSONObject("attributes") - val flags = attributes.getJSONObject("flags") - val obfuscation = FlagKeyObfuscation.read(attributes) - if (obfuscation != null) FlagKeyObfuscation.validateKeys(flags.keys()) - - val flagsMap = mutableMapOf() - - val flagKeys = flags.keys() - while (flagKeys.hasNext()) { - val flagKey = flagKeys.next() - val flagData = flags.getJSONObject(flagKey) - - val precomputedFlag = PrecomputedFlag( - variationType = flagData.getString(JsonKeys.VARIATION_TYPE.value), - variationValue = when (val value = flagData.get(JsonKeys.VARIATION_VALUE.value)) { - is Boolean -> value.toString() - is String -> value - is Number -> value.toString() - else -> value.toString() - }, - doLog = flagData.getBoolean(JsonKeys.DO_LOG.value), - allocationKey = flagData.getString(JsonKeys.ALLOCATION_KEY.value), - variationKey = flagData.getString(JsonKeys.VARIATION_KEY.value), - extraLogging = flagData.getJSONObject(JsonKeys.EXTRA_LOGGING.value), - reason = flagData.getString(JsonKeys.REASON.value), - serialId = (flagData.opt(JsonKeys.SERIAL_ID.value) as? Number)?.toLong() - ) - - flagsMap[flagKey] = precomputedFlag + val attributes = AssignmentsResponse.fromJson(rawJson).data.attributes + val metadata = JsonObject().apply { + attributes.additionalProperties.forEach { (key, value) -> + add(key, value as? JsonElement) + } } - - PrecomputedAssignments(flagsMap, obfuscation) - } catch (e: JSONException) { + val obfuscation = FlagKeyObfuscation.read(metadata) + val flags = attributes.flags.additionalProperties + if (obfuscation != null) FlagKeyObfuscation.validateKeys(flags.keys.iterator()) + PrecomputedAssignments( + flags.mapValues { PrecomputedFlagJson.read(PrecomputedFlagJson.objectValue(it.value)) }, + obfuscation + ) + } catch (e: JsonParseException) { internalLogger.log( level = InternalLogger.Level.WARN, target = InternalLogger.Target.MAINTAINER, diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt index 97a82f0abc..1bcd525fcb 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt @@ -29,6 +29,8 @@ import com.datadog.android.flags.model.ExposureEvent import com.datadog.android.flags.model.ResolutionReason import com.datadog.android.flags.utils.forge.ForgeConfigurator import com.datadog.android.internal.time.TimeProvider +import com.google.gson.JsonObject +import com.google.gson.JsonParser import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.LongForgery import fr.xgouchet.elmyr.annotation.StringForgery @@ -136,7 +138,7 @@ internal class FlagKeyObfuscationTest { fun `M resolve original key W resolve() { shared edge hash vectors }`(vector: Pair) { // Given val (key, digest) = vector - val encoding = checkNotNull(FlagKeyObfuscation.read(metadata())) + val encoding = checkNotNull(FlagKeyObfuscation.read(metadataJson())) assertThat(encoding.encode(key)).isEqualTo(digest) install(payload(key, encoded = false)) val plain = testedClient.resolve(key, false) @@ -161,8 +163,8 @@ internal class FlagKeyObfuscationTest { @Test fun `M cache bounded lookup hashes W encode() { descriptors keep separate caches }`() { // Given - val encoding = checkNotNull(FlagKeyObfuscation.read(metadata())) - val other = checkNotNull(FlagKeyObfuscation.read(metadata("f".repeat(32)))) + val encoding = checkNotNull(FlagKeyObfuscation.read(metadataJson())) + val other = checkNotNull(FlagKeyObfuscation.read(metadataJson("f".repeat(32)))) // When val first = checkNotNull(encoding.encode("flag")) @@ -183,7 +185,7 @@ internal class FlagKeyObfuscationTest { @Test fun `M preserve results and defaults W resolve() { encoded types and errors }`() { // Given - val encoding = checkNotNull(FlagKeyObfuscation.read(metadata())) + val encoding = checkNotNull(FlagKeyObfuscation.read(metadataJson())) val flags = JSONObject() listOf( "string" to "visible", @@ -214,7 +216,7 @@ internal class FlagKeyObfuscationTest { fun `M retain lookup and deduplicate exposure W setFlagsAndContext() { salt changes and cache round trip }`() { for (salt in listOf(SALT, "f".repeat(32), SALT)) { // Given - val descriptor = metadata(salt) + val descriptor = metadataJson(salt) val encoding = checkNotNull(FlagKeyObfuscation.read(descriptor)) val decoded = checkNotNull(testedMapper.map(payload(checkNotNull(encoding.encode("flag")), salt = salt))) val entry = FlagsStateEntry(fakeContext, decoded.flags, fakeTimestamp, decoded.obfuscation) @@ -252,7 +254,7 @@ internal class FlagKeyObfuscationTest { @Test fun `M accept new flags and isolate unknown types W resolve() { forward compatible response }`() { // Given - val encoding = checkNotNull(FlagKeyObfuscation.read(metadata())) + val encoding = checkNotNull(FlagKeyObfuscation.read(metadataJson())) val flags = JSONObject() .put(checkNotNull(encoding.encode("flag")), assignment()) .put(checkNotNull(encoding.encode("new-flag")), assignment().put("future-field", true)) @@ -272,7 +274,7 @@ internal class FlagKeyObfuscationTest { fun `M restore latest salt and value W DefaultFlagsRepository() { after multiple writes }`() { // Given for ((salt, value) in listOf(SALT to true, "f".repeat(32) to false)) { - val encoding = checkNotNull(FlagKeyObfuscation.read(metadata(salt))) + val encoding = checkNotNull(FlagKeyObfuscation.read(metadataJson(salt))) val flags = JSONObject().put(checkNotNull(encoding.encode("flag")), assignment("boolean", value)) val attributes = metadata(salt).put("flags", flags) install(JSONObject().put("data", JSONObject().put("attributes", attributes)).toString()) @@ -579,5 +581,8 @@ internal class FlagKeyObfuscationTest { private fun metadata(salt: String = SALT): JSONObject = JSONObject() .put("obfuscated", true) .put("obfuscation", JSONObject().put("scheme", FlagKeyObfuscation.SCHEME).put("salt", salt)) + + private fun metadataJson(salt: String = SALT): JsonObject = + JsonParser.parseString(metadata(salt).toString()).asJsonObject } } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializerTest.kt index f31f70f6f1..4205ca0d77 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializerTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializerTest.kt @@ -7,14 +7,17 @@ package com.datadog.android.flags.internal.persistence import com.datadog.android.api.InternalLogger +import com.google.gson.JsonParseException +import com.google.gson.JsonParser import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat -import org.json.JSONException import org.json.JSONObject import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings @@ -171,7 +174,7 @@ internal class FlagsStateDeserializerTest { eq(InternalLogger.Level.ERROR), eq(InternalLogger.Target.MAINTAINER), maintainerMessageCaptor.capture(), - isA(), + isA(), eq(false), eq(null) ) @@ -183,7 +186,7 @@ internal class FlagsStateDeserializerTest { eq(InternalLogger.Level.ERROR), eq(InternalLogger.Target.TELEMETRY), telemetryMessageCaptor.capture(), - isA(), + isA(), eq(true), eq(null) ) @@ -272,7 +275,7 @@ internal class FlagsStateDeserializerTest { eq(InternalLogger.Level.ERROR), eq(InternalLogger.Target.MAINTAINER), isA<() -> String>(), - isA(), + isA(), eq(false), eq(null) ) @@ -319,6 +322,85 @@ internal class FlagsStateDeserializerTest { assertThat(result.evaluationContext.attributes["valid_boolean"]).isEqualTo(validBoolean) } + @ParameterizedTest + @ValueSource(strings = ["null", "[]", "true", "0", "\"text\""]) + fun `M reject nonobject cache W deserialize() { generated model }`(json: String) { + // Given + // The cache is supplied by the test case. + // When + val result = testedDeserializer.deserialize(json) + + // Then + assertThat(result).isNull() + } + + @ParameterizedTest + @ValueSource(strings = ["null", "[]", "true", "\"invalid\""]) + fun `M preserve legacy attributes fallback W deserialize() { invalid attributes }`( + attributes: String, + forge: Forge + ) { + // Given + val targetingKey = forge.anAlphabeticalString() + val json = JSONObject(buildStateJson(targetingKey, JSONObject().put("flag", buildFlagJson()))) + json.getJSONObject("evaluationContext").put("attributes", JSONObject("{\"value\":$attributes}").get("value")) + // Legacy readers ignored the inactive map. + json.put("encodedFlags", JSONObject.NULL) + + // When + val result = checkNotNull(testedDeserializer.deserialize(json.toString())) + + // Then + assertThat(result.evaluationContext.targetingKey).isEqualTo(targetingKey) + assertThat(result.evaluationContext.attributes).isEmpty() + assertThat(result.flags.getValue("flag").variationValue).isEqualTo("true") + } + + @ParameterizedTest + @ValueSource(strings = ["null", "[]", "{}", "true", "\"invalid\""]) + fun `M reject invalid cache timestamp W deserialize() { generated model }`(timestamp: String, forge: Forge) { + // Given + val json = JSONObject(buildStateJson(forge.anAlphabeticalString(), JSONObject())) + json.put("lastUpdateTimestamp", JSONObject("{\"value\":$timestamp}").get("value")) + + // When + val result = testedDeserializer.deserialize(json.toString()) + + // Then + assertThat(result).isNull() + } + + @Test + fun `M preserve legacy values W serialize() and deserialize() { generated models }`(forge: Forge) { + // Given + val targetingKey = forge.anAlphabeticalString() + val value = forge.anAlphabeticalString() + val flags = JSONObject() + val variants = listOf("true", forge.anInt().toString(), value, "{\"nested\":[true,null,1]}", "null") + variants.forEachIndexed { index, variant -> + flags.put( + "flag-$index", + buildFlagJson().apply { + put("variationValue", variant) + put("extraLogging", JSONObject("{\"nested\":{\"array\":[true,null,1]},\"text\":\"<>&\"}")) + } + ) + } + val legacy = buildStateJson(targetingKey, flags) + + // When + val loaded = checkNotNull(testedDeserializer.deserialize(legacy)) + val rewritten = FlagsStateSerializer(mockInternalLogger).serialize(loaded) + val restored = checkNotNull(testedDeserializer.deserialize(rewritten)) + + // Then + assertThat(JsonParser.parseString(rewritten)).isEqualTo(JsonParser.parseString(legacy)) + assertThat(restored.evaluationContext).isEqualTo(loaded.evaluationContext) + variants.forEachIndexed { index, variant -> + assertThat(restored.flags.getValue("flag-$index").variationValue).isEqualTo(variant) + } + } + private fun buildFlagJson(): JSONObject = JSONObject().apply { put("variationType", "boolean") put("variationValue", "true") diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapperTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapperTest.kt index 95d740d0bd..b62e1af9b5 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapperTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapperTest.kt @@ -22,6 +22,8 @@ import org.json.JSONObject import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any @@ -70,6 +72,57 @@ internal class PrecomputeMapperTest { // region Valid JSON Parsing + @ParameterizedTest + @ValueSource( + strings = [ + "null", "[]", "true", "0", "\"text\"", + "{\"data\":null}", "{\"data\":[]}", + "{\"data\":{\"attributes\":null}}", "{\"data\":{\"attributes\":[]}}", + "{\"data\":{\"attributes\":{\"flags\":null}}}", + "{\"data\":{\"attributes\":{\"flags\":[]}}}" + ] + ) + fun `M reject malformed structure W map() { generated model }`(json: String) { + // Given + // The response is supplied by the test case. + // When + val result = testedMapper.map(json) + + // Then + assertThat(result).isNull() + } + + @ParameterizedTest + @ValueSource(strings = ["null", "[]", "{}", "0", "\"invalid\""]) + fun `M reject invalid logging control W map() { generated model }`(value: String) { + // Given + val flag = buildFlagJson(VariationType.BOOLEAN.value, true) + flag.put("doLog", JSONObject("{\"value\":$value}").get("value")) + val json = buildValidJson(mapOf(fakeFlagKey to flag)) + + // When + val result = testedMapper.map(json) + + // Then + assertThat(result).isNull() + } + + @ParameterizedTest + @ValueSource(strings = ["null", "[]", "{}", "true", "\"123\""]) + fun `M ignore nonnumeric serial id W map() { generated model }`(value: String) { + // Given + val flag = buildFlagJson(VariationType.BOOLEAN.value, true) + flag.put("serialId", JSONObject("{\"value\":$value}").get("value")) + val json = buildValidJson(mapOf(fakeFlagKey to flag)) + + // When + val result = checkNotNull(testedMapper.map(json)).flags.getValue(fakeFlagKey) + + // Then + assertThat(result.serialId).isNull() + assertThat(result.variationValue).isEqualTo("true") + } + @Test fun `M parse single flag with string variation W map() { valid JSON }`() { // Given From 935ada09ed2d4119f707386dc973725f58a57f84 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 8 Oct 2026 09:58:59 -0400 Subject: [PATCH 13/17] FFLSDK-261: Add context to generated assignments test --- .../flags/internal/repository/DefaultFlagsRepositoryTest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt index ba06ee9dce..3b1b7f2dd6 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt @@ -129,11 +129,14 @@ internal class DefaultFlagsRepositoryTest { } @Test - fun `M store generated assignments W setFlagsAndContext()`(forge: Forge) { + fun `M store generated assignments W setFlagsAndContext() { forged assignments }`(forge: Forge) { + // Given val assignments = forge.getForgery() + // When testedRepository.setFlagsAndContext(testContext, assignments.flags, assignments.obfuscation) + // Then assertThat(testedRepository.getFlagsSnapshot()).isEqualTo(assignments.flags) } From 586f86a0a7756c3bcf9efcc6dd310c20d842da55 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 8 Oct 2026 09:59:01 -0400 Subject: [PATCH 14/17] FFLSDK-261: Explain obfuscation copy visibility --- .../datadog/android/flags/internal/model/FlagKeyObfuscation.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt index eafe1a3a5a..e48df077ff 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt @@ -12,7 +12,8 @@ import com.google.gson.JsonObject import com.google.gson.JsonParseException import java.security.MessageDigest -/** The public encoding descriptor travels with its assignment map. */ +/** Encoding metadata validated by read(). */ +// Keep copy() private so callers cannot bypass salt validation. @ConsistentCopyVisibility internal data class FlagKeyObfuscation private constructor(val salt: String) { @Suppress("UnsafeThirdPartyFunctionCall") // The cache limit is a positive constant. From 723409bc0f602ad39756650a60d9f0bfb57bad62 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 8 Oct 2026 10:14:06 -0400 Subject: [PATCH 15/17] FFLSDK-261: Apply documented Kotlin conventions --- .../jsonschema/generator/FileGeneratorTest.kt | 3 ++- detekt_custom_safe_calls_third_party.yml | 6 +++--- .../flags/internal/model/FlagKeyObfuscation.kt | 6 +++--- .../repository/DefaultFlagsRepository.kt | 16 ++++++++-------- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/build-logic/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/generator/FileGeneratorTest.kt b/build-logic/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/generator/FileGeneratorTest.kt index d397be5fc7..5c0affa5df 100644 --- a/build-logic/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/generator/FileGeneratorTest.kt +++ b/build-logic/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/generator/FileGeneratorTest.kt @@ -41,6 +41,7 @@ import com.datadog.gradle.plugin.jsonschema.UserMerged import com.datadog.gradle.plugin.jsonschema.Version import com.datadog.gradle.plugin.jsonschema.Video import com.datadog.gradle.plugin.jsonschema.WeirdCombo +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.params.ParameterizedTest @@ -61,7 +62,7 @@ class FileGeneratorTest { // Then val generated = File(tempDir, "com/example/internal/model/Person.kt").readText() - org.junit.jupiter.api.Assertions.assertTrue(generated.contains("internal data class Person(")) + assertTrue(generated.contains("internal data class Person(")) } @ParameterizedTest diff --git a/detekt_custom_safe_calls_third_party.yml b/detekt_custom_safe_calls_third_party.yml index b6a8ddee80..41d41adfd0 100644 --- a/detekt_custom_safe_calls_third_party.yml +++ b/detekt_custom_safe_calls_third_party.yml @@ -497,8 +497,6 @@ datadog: - "kotlin.collections.Iterable.toList()" - "kotlin.collections.Iterable.toMap(kotlin.collections.MutableMap)" - "kotlin.collections.Iterator.forEach(kotlin.Function1)" - - "kotlin.collections.MutableSet.iterator()" - - "kotlin.collections.MutableMap.mapNotNull(kotlin.Function1)" - "kotlin.collections.LinkedHashMap()" - "kotlin.collections.LinkedHashMap(kotlin.collections.MutableMap?)" - "kotlin.collections.List.all(kotlin.Function1)" @@ -696,6 +694,7 @@ datadog: - "kotlin.collections.MutableMap.iterator()" - "kotlin.collections.MutableMap.orEmpty()" - "kotlin.collections.MutableMap.map(kotlin.Function1)" + - "kotlin.collections.MutableMap.mapNotNull(kotlin.Function1)" - "kotlin.collections.MutableMap.mapValues(kotlin.Function1)" - "kotlin.collections.MutableMap.put(java.lang.Class, kotlin.String?)" - "kotlin.collections.MutableMap.put(kotlin.Any, kotlin.Any?)" @@ -744,6 +743,7 @@ datadog: - "kotlin.collections.MutableSet.forEach(kotlin.Function1)" - "kotlin.collections.MutableSet.elementAtOrNull(kotlin.Int)" - "kotlin.collections.MutableSet.indexOf(kotlin.String)" + - "kotlin.collections.MutableSet.iterator()" - "kotlin.collections.MutableSet.joinToString(kotlin.CharSequence, kotlin.CharSequence, kotlin.CharSequence, kotlin.Int, kotlin.CharSequence, kotlin.Function1?)" - "kotlin.collections.MutableSet.map(kotlin.Function1)" - "kotlin.collections.MutableSet.remove(android.app.Activity?)" @@ -874,7 +874,6 @@ datadog: - "kotlin.ByteArray.constructor(kotlin.Int)" - "kotlin.ByteArray.copyTo(kotlin.Int, kotlin.ByteArray, kotlin.Int, kotlin.Int, com.datadog.android.api.InternalLogger)" - "kotlin.Char.digitToIntOrNull()" - - "kotlin.String.equals(kotlin.String?, kotlin.Boolean)" - "kotlin.Char.isHighSurrogate()" - "kotlin.Char.isLowerCase()" - "kotlin.Char.isLowSurrogate()" @@ -994,6 +993,7 @@ datadog: - "kotlin.String.count(kotlin.Function1)" - "kotlin.String.endsWith(kotlin.Char, kotlin.Boolean)" - "kotlin.String.endsWith(kotlin.String, kotlin.Boolean)" + - "kotlin.String.equals(kotlin.String?, kotlin.Boolean)" - "kotlin.String.filter(kotlin.Function1)" - "kotlin.String.format(java.util.Locale?, kotlin.Array)" - "kotlin.String.format(java.util.Locale?, kotlin.String, kotlin.Array)" diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt index e48df077ff..767c42c850 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt @@ -11,6 +11,7 @@ import com.datadog.android.flags.internal.model.generated.Obfuscation import com.google.gson.JsonObject import com.google.gson.JsonParseException import java.security.MessageDigest +import java.util.Locale /** Encoding metadata validated by read(). */ // Keep copy() private so callers cannot bypass salt validation. @@ -33,8 +34,7 @@ internal data class FlagKeyObfuscation private constructor(val salt: String) { digest.update(saltBytes) digest.update(key.toByteArray(Charsets.UTF_8)) digest.digest().joinToString("") { - @Suppress("UnsafeThirdPartyFunctionCall") // The constant format accepts a byte. - "%02x".format(it) + "%02x".format(Locale.US, it) }.also { @Suppress("UnsafeThirdPartyFunctionCall") // The key and encoded value are non-null. lookupKeys.put(key, it) @@ -42,7 +42,7 @@ internal data class FlagKeyObfuscation private constructor(val salt: String) { } } - companion object { + internal companion object { const val SCHEME = "flag-key-sha256-v1" const val CAPABILITY = "assignment-encoding-flag-key-256-v1" private const val SALT_BYTES = 16 diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt index 210deb609c..33e80b5c44 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt @@ -136,14 +136,6 @@ internal class DefaultFlagsRepository( readableState?.takeIf { it.restoredFromCache }?.let { publishFirstFlags(it, it.flags.keys) } } - private fun publishFirstFlags(state: FlagsState, installedKeys: Collection) { - if (state.obfuscation == null || obfuscationSupported.get()) { - // Encoded responses cannot enumerate the original application keys. - val keys = if (state.obfuscation == null) installedKeys else emptySet() - firstFlags.complete(keys, deliverFirstFlags) - } - } - override fun getPrecomputedFlag(key: String): PrecomputedFlag? { waitForPersistenceLoad() val state = readableState @@ -196,6 +188,14 @@ internal class DefaultFlagsRepository( return flag to state.context } + private fun publishFirstFlags(state: FlagsState, installedKeys: Collection) { + if (state.obfuscation == null || obfuscationSupported.get()) { + // Encoded responses cannot enumerate the original application keys. + val keys = if (state.obfuscation == null) installedKeys else emptySet() + firstFlags.complete(keys, deliverFirstFlags) + } + } + private fun waitForPersistenceLoad() { try { persistenceLoadedLatch.await(persistenceLoadTimeoutMs, TimeUnit.MILLISECONDS) From f4a6d1f9d79d210ed3e725aa915d19585b662e7d Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 8 Oct 2026 10:14:07 -0400 Subject: [PATCH 16/17] FFLSDK-261: Tighten obfuscation test assertions --- .../flags/internal/FlagKeyObfuscationTest.kt | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt index 1bcd525fcb..591e519865 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt @@ -57,6 +57,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness +import java.util.Locale @ExtendWith(MockitoExtension::class, ForgeExtension::class) @ForgeConfiguration(ForgeConfigurator::class) @@ -240,14 +241,17 @@ internal class FlagKeyObfuscationTest { // Then assertThat(testedClient.resolveBooleanValue("flag", false)).isTrue() - assertThat(testedRepository.getPrecomputedFlag("flag")).isNotNull() - assertThat(testedRepository.getPrecomputedFlag("flag")?.variationKey).isEqualTo(fakeVariationKey) - assertThat(testedRepository.getPrecomputedFlag("flag")?.serialId).isEqualTo(fakeSerialId) + val flag = checkNotNull(testedRepository.getPrecomputedFlag("flag")) + assertThat(flag.variationKey).isEqualTo(fakeVariationKey) + assertThat(flag.serialId).isEqualTo(fakeSerialId) assertThat(testedRepository.getPrecomputedFlag(checkNotNull(encoding.encode("flag")))).isNull() } // Then - verify(mockWriter, times(1)).write(any()) + val exposure = argumentCaptor() + verify(mockWriter).write(exposure.capture()) + assertThat(exposure.firstValue.flag.key).isEqualTo("flag") + assertThat(exposure.firstValue.serialId).isEqualTo(fakeSerialId) verify(mockRumLogger, times(3)).logEvaluation("flag", fakeVariationKey) } @@ -284,7 +288,7 @@ internal class FlagKeyObfuscationTest { verify(mockDataStore, times(2)).setValue(any(), written.capture(), any(), anyOrNull(), any()) val serialized = FlagsStateSerializer(mockInternalLogger).serialize(written.lastValue) val restored = checkNotNull(FlagsStateDeserializer(mockInternalLogger).deserialize(serialized)) - assertThat(restored.obfuscation?.salt).isEqualTo("f".repeat(32)) + assertThat(checkNotNull(restored.obfuscation).salt).isEqualTo("f".repeat(32)) doAnswer { it.getArgument>(2).onSuccess(DataStoreContent(0, restored)) }.whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) @@ -350,9 +354,10 @@ internal class FlagKeyObfuscationTest { val testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore) // Then - assertThat(testedRepository.getPrecomputedFlagWithContext("flag")?.second).isEqualTo(fakeContext) - assertThat(testedRepository.getPrecomputedFlag("flag")?.variationValue).isEqualTo("true") - assertThat(testedRepository.getPrecomputedFlag("flag")?.reason).isEqualTo(ResolutionReason.CACHED.name) + val (flag, context) = checkNotNull(testedRepository.getPrecomputedFlagWithContext("flag")) + assertThat(context).isEqualTo(fakeContext) + assertThat(flag.variationValue).isEqualTo("true") + assertThat(flag.reason).isEqualTo(ResolutionReason.CACHED.name) assertThat(testedRepository.getPrecomputedFlag(VECTORS[2].second)).isNull() } @@ -575,8 +580,17 @@ internal class FlagKeyObfuscationTest { metadata().put("obfuscated", false).toString(), metadata().apply { remove("obfuscated") }.toString(), metadata().apply { getJSONObject("obfuscation").put("scheme", "unknown") }.toString() - ) + listOf("", "0".repeat(30), "0".repeat(34), "G".repeat(32), SALT.uppercase(), "0".repeat(31) + "\n", 42) - .map { metadata().apply { getJSONObject("obfuscation").put("salt", it) }.toString() } + ) + + listOf( + "", + "0".repeat(30), + "0".repeat(34), + "G".repeat(32), + SALT.uppercase(Locale.US), + "0".repeat(31) + "\n", + 42 + ) + .map { metadata().apply { getJSONObject("obfuscation").put("salt", it) }.toString() } private fun metadata(salt: String = SALT): JSONObject = JSONObject() .put("obfuscated", true) From 27ba428242b97afb227be83c686cb88a955af64a Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 8 Oct 2026 10:14:08 -0400 Subject: [PATCH 17/17] FFLSDK-261: Keep evaluation tests independent of HTTP --- .../evaluation/EvaluationsManagerTest.kt | 130 ++++++++---------- 1 file changed, 55 insertions(+), 75 deletions(-) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt index 6884e86201..90a411f61e 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt @@ -22,9 +22,7 @@ import com.datadog.android.flags.internal.model.FlagsStateEntry import com.datadog.android.flags.internal.model.PrecomputedAssignments import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.internal.net.NetworkRequestFailedException -import com.datadog.android.flags.internal.net.PrecomputedAssignmentsDownloader import com.datadog.android.flags.internal.net.PrecomputedAssignmentsReader -import com.datadog.android.flags.internal.net.PrecomputedAssignmentsRequestFactory import com.datadog.android.flags.internal.repository.DefaultFlagsRepository import com.datadog.android.flags.internal.repository.FlagsRepository import com.datadog.android.flags.internal.repository.NoOpFlagsRepository @@ -37,12 +35,8 @@ import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension -import okhttp3.OkHttpClient -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer import org.assertj.core.api.Assertions.assertThat import org.json.JSONObject -import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertDoesNotThrow import org.junit.jupiter.api.Assertions.fail import org.junit.jupiter.api.BeforeEach @@ -117,14 +111,10 @@ internal class EvaluationsManagerTest { @Forgery lateinit var fakeDatadogContext: DatadogContext - private lateinit var mockWebServer: MockWebServer private lateinit var evaluationsManager: EvaluationsManager @BeforeEach fun setUp() { - mockWebServer = MockWebServer() - mockWebServer.start() - evaluationsManager = EvaluationsManager( sdkCore = mockSdkCore, executorService = mockExecutorService, @@ -156,11 +146,6 @@ internal class EvaluationsManagerTest { } } - @AfterEach - fun tearDown() { - mockWebServer.shutdown() - } - @ParameterizedTest @ValueSource(strings = ["android", "react-native"]) fun `M set encoding support W updateEvaluationsForContext() { network executor delayed }`(source: String) { @@ -1215,105 +1200,78 @@ internal class EvaluationsManagerTest { // region Cold-start integration @Test - fun `M retain matching assignments on invalid encoding W updateEvaluationsForContext() { HTTP integration }`() { + fun `M retain matching assignments on invalid encoding W updateEvaluationsForContext() { invalid refresh }`( + @Mock mockCallback: EvaluationContextCallback + ) { // Given fakeDatadogContext = fakeDatadogContext.copy(source = "android") val context = EvaluationContext(fakeTargetingKey, emptyMap()) - val testedRepository = createIntegrationRepository() - val testedManager = createIntegrationManager(testedRepository) - val mockCallback = mock() - mockWebServer.enqueue(MockResponse().setBody(ENCODED_RESPONSE_JSON)) + val testedRepository = createRepository() + val testedManager = createManagerWithRepository(testedRepository) + whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)) + .thenReturn(ENCODED_RESPONSE_JSON) // When testedManager.updateEvaluationsForContext(context, mockCallback) // Then - assertThat(testedRepository.getPrecomputedFlag("flag")?.variationValue).isEqualTo("true") + assertThat(checkNotNull(testedRepository.getPrecomputedFlag("flag")).variationValue).isEqualTo("true") verify(mockCallback).onSuccess() - val request = checkNotNull(mockWebServer.takeRequest(1, TimeUnit.SECONDS)) - assertThat(request.path).isEqualTo("/precompute-assignments") - val attributes = JSONObject(request.body.readUtf8()).getJSONObject("data").getJSONObject("attributes") - assertThat(request.getHeader("X-DD-FEATURE-FLAGS-CAPABILITIES")) - .isEqualTo("assignment-encoding-flag-key-256-v1") - assertThat(attributes.has("supported_capabilities")).isFalse() // Given - mockWebServer.enqueue(MockResponse().setBody(ENCODED_RESPONSE_JSON.replace("flag-key-sha256-v1", "unknown"))) + val invalidResponse = ENCODED_RESPONSE_JSON.replace("flag-key-sha256-v1", "unknown") + whenever( + mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext) + ).thenReturn(invalidResponse) // When testedManager.updateEvaluationsForContext(context, mockCallback) // Then verify(mockFlagsStateManager).updateState(FlagsClientState.Stale) - verify(mockCallback).onFailure(any()) - assertThat(testedRepository.getPrecomputedFlag("flag")?.variationValue).isEqualTo("true") + val failure = argumentCaptor() + verify(mockCallback).onFailure(failure.capture()) + assertThat(failure.firstValue.message).isEqualTo("Unable to read the feature flags response.") + assertThat(checkNotNull(testedRepository.getPrecomputedFlag("flag")).variationValue).isEqualTo("true") // Given - mockWebServer.enqueue(MockResponse().setBody(ENCODED_RESPONSE_JSON.replace("flag-key-sha256-v1", "unknown"))) + val otherContext = EvaluationContext("$fakeTargetingKey-other", emptyMap()) + whenever(mockAssignmentsDownloader.readPrecomputedFlags(otherContext, fakeDatadogContext)) + .thenReturn(invalidResponse) // When - testedManager.updateEvaluationsForContext( - EvaluationContext("$fakeTargetingKey-other", emptyMap()), - mockCallback - ) + testedManager.updateEvaluationsForContext(otherContext, mockCallback) // Then - verify(mockFlagsStateManager).updateState(any()) + val states = argumentCaptor() + verify(mockFlagsStateManager, times(6)).updateState(states.capture()) + assertThat(states.lastValue).isInstanceOf(FlagsClientState.Error::class.java) assertThat(testedRepository.getEvaluationContext()).isEqualTo(context) } @Test - fun `M reject unsolicited encoding W updateEvaluationsForContext() { React Native HTTP integration }`() { + fun `M reject unsolicited encoding W updateEvaluationsForContext() { React Native source }`( + @Mock mockCallback: EvaluationContextCallback + ) { // Given fakeDatadogContext = fakeDatadogContext.copy(source = "react-native") val context = EvaluationContext(fakeTargetingKey, emptyMap()) - val testedRepository = createIntegrationRepository() - val testedManager = createIntegrationManager(testedRepository) - val mockCallback = mock() - mockWebServer.enqueue(MockResponse().setBody(ENCODED_RESPONSE_JSON)) + val testedRepository = createRepository() + val testedManager = createManagerWithRepository(testedRepository) + whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)) + .thenReturn(ENCODED_RESPONSE_JSON) // When testedManager.updateEvaluationsForContext(context, mockCallback) // Then - verify(mockCallback).onFailure(any()) + val failure = argumentCaptor() + verify(mockCallback).onFailure(failure.capture()) + assertThat(failure.firstValue.message).isEqualTo("Unable to read the feature flags response.") assertThat(testedRepository.hasFlags()).isFalse() assertThat(testedRepository.getFlagsSnapshot()).isEmpty() - val request = checkNotNull(mockWebServer.takeRequest(1, TimeUnit.SECONDS)) - val attributes = JSONObject(request.body.readUtf8()).getJSONObject("data").getJSONObject("attributes") - assertThat(attributes.has("supported_capabilities")).isFalse() - assertThat(request.getHeader("X-DD-FEATURE-FLAGS-CAPABILITIES")).isNull() - } - - private fun createIntegrationRepository(): DefaultFlagsRepository { - val mockDataStore = mock() - whenever(mockSdkCore.timeProvider) doReturn mock() - whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger - doAnswer { - it.getArgument>(2).onFailure() - }.whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) - return DefaultFlagsRepository(mockSdkCore, "http-test", mockDataStore) } - private fun createIntegrationManager(repository: FlagsRepository): EvaluationsManager = EvaluationsManager( - sdkCore = mockSdkCore, - executorService = mockExecutorService, - internalLogger = mockInternalLogger, - flagsRepository = repository, - assignmentsReader = PrecomputedAssignmentsDownloader( - OkHttpClient(), - mockInternalLogger, - PrecomputedAssignmentsRequestFactory( - mockInternalLogger, - mockWebServer.url("/precompute-assignments").toString() - ) - ), - precomputeMapper = PrecomputeMapper(mockInternalLogger), - flagStateManager = mockFlagsStateManager, - initializationTimeoutMs = null, - initializationTimeoutScheduler = { _, _ -> {} } - ) - @Test fun `M notify STALE W updateEvaluationsForContext() { cold start network failure, cached flags match context }`() { // Given @@ -1384,6 +1342,28 @@ internal class EvaluationsManagerTest { // endregion + private fun createRepository(): DefaultFlagsRepository { + val mockDataStore = mock() + whenever(mockSdkCore.timeProvider) doReturn mock() + whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger + doAnswer { + it.getArgument>(2).onFailure() + }.whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) + return DefaultFlagsRepository(mockSdkCore, fakeTargetingKey, mockDataStore) + } + + private fun createManagerWithRepository(repository: FlagsRepository): EvaluationsManager = EvaluationsManager( + sdkCore = mockSdkCore, + executorService = mockExecutorService, + internalLogger = mockInternalLogger, + flagsRepository = repository, + assignmentsReader = mockAssignmentsDownloader, + precomputeMapper = PrecomputeMapper(mockInternalLogger), + flagStateManager = mockFlagsStateManager, + initializationTimeoutMs = null, + initializationTimeoutScheduler = { _, _ -> {} } + ) + private fun createManager( flagStateManager: FlagsStateManager = mockFlagsStateManager, initializationTimeoutMs: Long? = null,