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..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,8 @@ 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 import org.junit.jupiter.params.provider.MethodSource @@ -50,6 +52,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() + 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 cc6cabd804..41d41adfd0 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)" @@ -693,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?)" @@ -741,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?)" @@ -871,7 +874,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)" @@ -988,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)" @@ -1161,6 +1167,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/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/FlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt index d4bc80db9b..411b275194 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt @@ -67,6 +67,7 @@ interface FlagsClient { /** * Invokes [listener] once with the keys from the first installed cached or downloaded flags, * including an empty configuration. Missing or invalid cache does not trigger this callback. + * Obfuscated configurations return an empty key list because the original keys are not available. * Each registration receives a separate event with a copy of the retained first keys, * even after subsequent flag updates. * Replaying an available result reads retained memory and does not perform I/O. 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 ead3bc20ae..b3b6df0d5f 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 @@ -13,7 +13,7 @@ import com.datadog.android.core.internal.utils.executeSafe import com.datadog.android.flags.EvaluationContextCallback import com.datadog.android.flags.FlagsInitializationTimeoutException import com.datadog.android.flags.internal.FlagsStateManager -import com.datadog.android.flags.internal.model.PrecomputedFlag +import com.datadog.android.flags.internal.model.PrecomputedAssignments import com.datadog.android.flags.internal.net.NetworkRequestFailedException import com.datadog.android.flags.internal.net.PrecomputedAssignmentsReader import com.datadog.android.flags.internal.repository.FlagsRepository @@ -98,7 +98,7 @@ 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. A failed or unreadable + * and atomically updates the context, flag data, and encoding descriptor in the repository. A failed or unreadable * response keeps the last known good flags and their context. * * The operation is performed on the configured executor service and will not block the @@ -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 @@ -132,9 +134,10 @@ internal class EvaluationsManager( hadFlags && flagsRepository.getEvaluationContext() == context ) val response = assignmentsReader.readPrecomputedFlags(context, datadogContext) - val flagsMap = response?.let { precomputeMapper.map(it) } - if (flagsMap != null) { - installFlags(context, flagsMap, initializationCompletion, callback) + val assignments = response?.let { precomputeMapper.map(it) } + ?.takeIf { it.obfuscation == null || supportsObfuscation } + if (assignments != null) { + installFlags(context, assignments, initializationCompletion, callback) } else { val message = if (response == null) NETWORK_REQUEST_FAILED_MESSAGE else INVALID_RESPONSE_MESSAGE internalLogger.log( @@ -164,11 +167,11 @@ internal class EvaluationsManager( private fun installFlags( context: EvaluationContext, - flagsMap: Map, + assignments: PrecomputedAssignments, initializationCompletion: InitializationCompletion?, callback: EvaluationContextCallback? ) { - flagsRepository.setFlagsAndContext(context, flagsMap) { + flagsRepository.setFlagsAndContext(context, assignments.flags, assignments.obfuscation) { val completionCallback = synchronized(initializationTerminalLock) { val result = initializationCompletion?.take()?.callback ?: if (initializationCompletion == null) callback else null @@ -180,7 +183,7 @@ internal class EvaluationsManager( internalLogger.log( InternalLogger.Level.DEBUG, InternalLogger.Target.MAINTAINER, - { "Successfully processed context ${context.targetingKey} with ${flagsMap.size} flags" } + { "Successfully processed context ${context.targetingKey} with ${assignments.flags.size} flags" } ) } 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..767c42c850 --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagKeyObfuscation.kt @@ -0,0 +1,115 @@ +/* + * 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 androidx.collection.LruCache +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. +@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) + + fun encode(key: String): String? { + // Reject unpaired surrogates instead of silently replacing their UTF-8 encoding. + if (!isWellFormedUnicode(key)) return null + @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)) + @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(Locale.US, it) + }.also { + @Suppress("UnsafeThirdPartyFunctionCall") // The key and encoded value are non-null. + lookupKeys.put(key, it) + } + } + } + + 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 + 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. + @Throws(JsonParseException::class) + @Suppress("ThrowsCount") + 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 JsonParseException("Invalid flag-key obfuscation metadata") + } + 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 JsonParseException("Unsupported flag-key obfuscation scheme") + } + 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 JsonParseException("Flag-key salt must contain 32 lowercase hexadecimal characters") + } + return FlagKeyObfuscation(Obfuscation.fromJsonObject(descriptor).salt) + } + + // Caught by the response or cache parser. + @Throws(JsonParseException::class) + fun validateKeys(keys: Iterator) { + keys.forEach { + if (!isLowercaseHex(it, DIGEST_BYTES)) { + @Suppress("ThrowingInternalException") // Caught by the response or cache parser. + throw JsonParseException("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("ReturnCount") + 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/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/net/PrecomputedAssignmentsRequestFactory.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt index e7414d824c..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 @@ -10,6 +10,7 @@ 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.internal.getFlagsEndpoint +import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.model.EvaluationContext import okhttp3.Headers import okhttp3.Request @@ -66,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) } 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 c0216edeec..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 @@ -8,41 +8,63 @@ 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 +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 flagsJson = json.getJSONObject(JsonKeys.FLAGS.value) - val flags = deserializeFlags(flagsJson) - - @Suppress("UnsafeThirdPartyFunctionCall") // JSONObject operations wrapped in try-catch - val timestamp = json.getLong(JsonKeys.LAST_UPDATE_TIMESTAMP.value) + val json = PrecomputedFlagJson.objectValue(JsonParser.parseString(model)) + val obfuscation = FlagKeyObfuscation.read(json) + // 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, @@ -61,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 e950b6bdda..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,37 +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") 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)) - } - json.put(JsonKeys.FLAGS.value, flagsJson) - - 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, @@ -47,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/DefaultFlagsRepository.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt index 09b02f9405..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 @@ -7,15 +7,18 @@ 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 +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 com.datadog.android.flags.model.ResolutionReason 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( @@ -26,8 +29,21 @@ internal class DefaultFlagsRepository( private val persistenceLoadTimeoutMs: Long = PERSISTENCE_LOAD_TIMEOUT_MS, private val deliverFirstFlags: (Runnable) -> Unit = Runnable::run ) : FlagsRepository { - private data class FlagsState(val context: EvaluationContext, val flags: Map) + private data class FlagsState( + val context: EvaluationContext, + val flags: Map, + val obfuscation: FlagKeyObfuscation?, + val restoredFromCache: Boolean = false + ) { + 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(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") // CountDownLatch rejects negative counts; 1 is valid. private val persistenceLoadedLatch = CountDownLatch(1) @@ -38,30 +54,40 @@ internal class DefaultFlagsRepository( instanceName = instanceName, internalLogger = internalLogger ) { persistedState -> - val installed = try { - persistedState != null && atomicState.compareAndSet( - null, + val cachedState = try { + persistedState?.let { FlagsState( - persistedState.evaluationContext, - persistedState.flags.mapValues { (_, flag) -> flag.copy(reason = ResolutionReason.CACHED.name) } - ) - ) + it.evaluationContext, + it.flags.mapValues { (_, flag) -> flag.copy(reason = ResolutionReason.CACHED.name) }, + it.obfuscation, + restoredFromCache = true + ).takeIf { state -> atomicState.compareAndSet(null, state) } + } } finally { persistenceLoadedLatch.countDown() } - if (installed && persistedState != null) { - firstFlags.complete(persistedState.flags.keys, deliverFirstFlags) + if (cachedState != null && persistedState != null) { + publishFirstFlags(cachedState, persistedState.flags.keys) + } + } + + init { + featureSdkCore.getFeature(Feature.FLAGS_FEATURE_NAME)?.withContext { context -> + setObfuscationSupported(context.source == "android") } } override fun setFlagsAndContext( context: EvaluationContext, flags: Map, + obfuscation: FlagKeyObfuscation?, onInstalled: () -> Unit ) { - val newState = FlagsState(context, flags) + val newState = FlagsState(context, flags, obfuscation) - val firstInstallation = atomicState.getAndSet(newState) == null + val previousState = atomicState.getAndSet(newState) + val firstInstallation = previousState == null || + (previousState.obfuscation != null && !obfuscationSupported.get()) persistenceLoadedLatch.countDown() try { @@ -69,7 +95,7 @@ internal class DefaultFlagsRepository( context = context, flags = flags, currentTimestamp = featureSdkCore.timeProvider.getDeviceTimestampMillis(), - object : DataStoreWriteCallback { + callback = object : DataStoreWriteCallback { override fun onSuccess() { } @@ -80,7 +106,8 @@ internal class DefaultFlagsRepository( messageBuilder = { ERROR_SAVING_FLAGS_STATE } ) } - } + }, + obfuscation = obfuscation ) } catch ( // Storage submission failure must not abort an accepted installation. @@ -99,16 +126,21 @@ internal class DefaultFlagsRepository( onInstalled() } finally { if (firstInstallation) { - firstFlags.complete(flags.keys, deliverFirstFlags) + publishFirstFlags(newState, flags.keys) } } } + override fun setObfuscationSupported(supported: Boolean) { + obfuscationSupported.set(supported) + readableState?.takeIf { it.restoredFromCache }?.let { publishFirstFlags(it, it.flags.keys) } + } + override fun getPrecomputedFlag(key: String): PrecomputedFlag? { waitForPersistenceLoad() - val state = atomicState.get() + val state = readableState if (state != null) { - return state.flags[key] + return state.get(key) } internalLogger.log( InternalLogger.Level.WARN, @@ -120,9 +152,10 @@ internal class DefaultFlagsRepository( override fun getFlagsSnapshot(): Map { waitForPersistenceLoad() - val state = atomicState.get() + val state = readableState 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, @@ -134,27 +167,35 @@ 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 flag = state.flags[key] ?: return null + val state = readableState ?: return null + val flag = state.get(key) ?: return null 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) 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 14972be3c0..00bc17cd9a 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 @@ -20,8 +21,10 @@ internal interface FlagsRepository { fun setFlagsAndContext( context: EvaluationContext, flags: Map, + obfuscation: FlagKeyObfuscation? = null, onInstalled: () -> Unit = {} ) + fun setObfuscationSupported(supported: Boolean) fun getPrecomputedFlagWithContext(key: String): Pair? val firstFlags: FirstFlagsLatch fun hasFlags(): Boolean diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/NoOpFlagsRepository.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/NoOpFlagsRepository.kt index 4e9d1900f1..e80e5b797c 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/NoOpFlagsRepository.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/NoOpFlagsRepository.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 @@ -18,11 +19,14 @@ internal class NoOpFlagsRepository : FlagsRepository { override fun setFlagsAndContext( context: EvaluationContext, flags: Map, + obfuscation: FlagKeyObfuscation?, onInstalled: () -> Unit ) { onInstalled() } + override fun setObfuscationSupported(supported: Boolean) = Unit + override fun getPrecomputedFlagWithContext(key: String): Pair? = null override val firstFlags = FirstFlagsLatch() 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 13dd294baa..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 @@ -7,52 +7,34 @@ package com.datadog.android.flags.internal.repository.net import com.datadog.android.api.InternalLogger -import com.datadog.android.flags.internal.model.JsonKeys +import com.datadog.android.flags.internal.model.FlagKeyObfuscation +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): Map? = try { - val jsonResponse = JSONObject(rawJson) - val data = jsonResponse.getJSONObject("data") - val attributes = data.getJSONObject("attributes") - val flags = attributes.getJSONObject("flags") - - 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 + internal fun map(rawJson: String): PrecomputedAssignments? = try { + val attributes = AssignmentsResponse.fromJson(rawJson).data.attributes + val metadata = JsonObject().apply { + attributes.additionalProperties.forEach { (key, value) -> + add(key, value as? JsonElement) + } } - - flagsMap - } 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 new file mode 100644 index 0000000000..591e519865 --- /dev/null +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagKeyObfuscationTest.kt @@ -0,0 +1,602 @@ +/* + * 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.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 +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 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 +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 +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.times +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) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class FlagKeyObfuscationTest { + 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 + + @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() { + whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger + 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) } + .whenever(mockFeatureScope).withContext(any(), any()) + doAnswer { + it.getArgument>(2).onFailure() + }.whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) + testedMapper = PrecomputeMapper(mockInternalLogger) + testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore) + testedClient = createClient() + } + + @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(metadataJson())) + 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, fakeVariationKey) + verify( + mockEvaluationsFeature, + times(3) + ).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(fakeSerialId) + } + + @Test + fun `M cache bounded lookup hashes W encode() { descriptors keep separate caches }`() { + // Given + val encoding = checkNotNull(FlagKeyObfuscation.read(metadataJson())) + val other = checkNotNull(FlagKeyObfuscation.read(metadataJson("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(metadataJson())) + 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) + + // 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) + 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 setFlagsAndContext() { salt changes and cache round trip }`() { + for (salt in listOf(SALT, "f".repeat(32), SALT)) { + // Given + 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) + + // 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() + 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 + 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) + } + + @Test + fun `M accept new flags and isolate unknown types W resolve() { forward compatible response }`() { + // Given + 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)) + .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() + } + + @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(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()) + 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(checkNotNull(restored.obfuscation).salt).isEqualTo("f".repeat(32)) + doAnswer { + it.getArgument>(2).onSuccess(DataStoreContent(0, restored)) + }.whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) + + // When + testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, 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()) + val testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore) + testedRepository.setObfuscationSupported(false) + val decoded = checkNotNull(testedMapper.map(payload(VECTORS[2].second))) + val entry = FlagsStateEntry(fakeContext, decoded.flags, fakeTimestamp, decoded.obfuscation) + + // When + checkNotNull(cacheCallback).onSuccess(DataStoreContent(0, entry)) + + // Then + assertThat(testedRepository.getFlagsSnapshot()).isEmpty() + assertThat(testedRepository.hasFlags()).isFalse() + assertThat(testedRepository.getEvaluationContext()).isNull() + } + + @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, fakeTimestamp, decoded.obfuscation) + doAnswer { it.getArgument>(2).onSuccess(DataStoreContent(0, entry)) } + .whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) + + // When + val testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore) + + // Then + 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() + } + + @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, fakeTimestamp, decoded.obfuscation) + testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore) + testedClient = createClient() + val firstFlags = mutableListOf>() + testedRepository.firstFlags.whenComplete { firstFlags.add(it) } + + // 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) + assertThat(firstFlags).hasSize(if (encoded) 0 else 1) + } + 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) + assertThat(firstFlags).hasSize(if (readable) 1 else 0) + if (readable) { + assertThat(firstFlags.single()).isEqualTo(if (encoded) emptyList() else listOf("flag")) + } + verifyNoInteractions(mockEvaluationsManager) + } + + @Test + fun `M notify first flags W setFlagsAndContext() { encoded network response }`() { + // Given + val firstFlags = mutableListOf>() + testedRepository.firstFlags.whenComplete { firstFlags.add(it) } + + // When + install(payload(VECTORS[2].second)) + install(payload(VECTORS[2].second)) + + // Then + assertThat(firstFlags).containsExactly(emptyList()) + assertThat(testedClient.resolveBooleanValue("flag", false)).isTrue() + } + + @Test + 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, 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, fakeInstanceName, mockDataStore) + val firstFlags = mutableListOf>() + testedRepository.firstFlags.whenComplete { firstFlags.add(it) } + assertThat(firstFlags).isEmpty() + + // When + install(payload("flag", encoded = false)) + + // Then + assertThat(firstFlags).containsExactly(listOf("flag")) + assertThat(testedRepository.getFlagsSnapshot()).containsOnlyKeys("flag") + } + + @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")) { + // 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()) + + // When + val assignments = testedMapper.map(JSONObject().put("data", JSONObject().put("attributes", attrs)).toString()) + + // Then + assertThat(assignments).isNull() + + // Given + val entry = FlagsStateEntry(fakeContext, emptyMap(), fakeTimestamp) + val cache = JSONObject(FlagsStateSerializer(mockInternalLogger).serialize(entry)) + JSONObject(json).keys().forEach { cache.put(it, JSONObject(json).get(it)) } + + // 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")) { + // 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() + } + } + + private fun install(response: String) { + val assignments = checkNotNull(testedMapper.map(response)) + 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 = 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", + "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 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}", + "{\"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(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) + .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/evaluation/EvaluationsManagerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt index 56563791dc..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 @@ -19,7 +19,9 @@ 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.NetworkRequestFailedException import com.datadog.android.flags.internal.net.PrecomputedAssignmentsReader import com.datadog.android.flags.internal.repository.DefaultFlagsRepository import com.datadog.android.flags.internal.repository.FlagsRepository @@ -33,15 +35,15 @@ 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.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 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 @@ -58,6 +60,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -108,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, @@ -136,9 +135,9 @@ internal class EvaluationsManagerTest { } doAnswer { - it.getArgument<() -> Unit>(2).invoke() + it.getArgument<() -> Unit>(3).invoke() null - }.whenever(mockFlagsRepository).setFlagsAndContext(any(), any(), any()) + }.whenever(mockFlagsRepository).setFlagsAndContext(any(), any(), anyOrNull(), any()) // Mock executor to run tasks synchronously for testing whenever(mockExecutorService.execute(any())).thenAnswer { invocation -> @@ -147,9 +146,20 @@ 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) { + // 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 @@ -172,7 +182,7 @@ internal class EvaluationsManagerTest { initializationTimeoutScheduler = { _, _ -> {} } ) whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)).thenReturn("response") - whenever(mockPrecomputeMapper.map("response")).thenReturn(emptyMap()) + whenever(mockPrecomputeMapper.map("response")).thenReturn(PrecomputedAssignments(emptyMap(), null)) // When manager.updateEvaluationsForContext(context, callback) @@ -225,13 +235,13 @@ 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) // Then - verify(mockFlagsRepository).setFlagsAndContext(eq(context), eq(expectedFlags), any()) + verify(mockFlagsRepository).setFlagsAndContext(eq(context), eq(expectedFlags), anyOrNull(), any()) verify(mockInternalLogger, times(2)).log( eq(InternalLogger.Level.DEBUG), eq(InternalLogger.Target.MAINTAINER), @@ -294,7 +304,7 @@ internal class EvaluationsManagerTest { evaluationsManager.updateEvaluationsForContext(context, callback) // Then - verify(mockFlagsRepository, never()).setFlagsAndContext(any(), any(), any()) + verify(mockFlagsRepository, never()).setFlagsAndContext(any(), any(), anyOrNull(), any()) verify(mockFlagsStateManager).updateState(isA()) verify(mockFlagsStateManager, never()).updateState(FlagsClientState.Ready) verify(callback).onFailure(any()) @@ -338,7 +348,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) @@ -420,7 +430,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) @@ -428,7 +438,7 @@ internal class EvaluationsManagerTest { // Then verify(mockCallback).onSuccess() verify(mockCallback, never()).onFailure(any()) - verify(mockFlagsRepository).setFlagsAndContext(eq(publicContext), eq(emptyMap()), any()) + verify(mockFlagsRepository).setFlagsAndContext(eq(publicContext), eq(emptyMap()), anyOrNull(), any()) verify(mockFlagsStateManager).updateState(FlagsClientState.Ready) } @@ -481,7 +491,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) } @@ -501,7 +511,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 -> @@ -635,7 +645,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, @@ -784,7 +794,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 -> @@ -827,7 +837,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 -> @@ -856,7 +866,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, @@ -872,7 +882,7 @@ internal class EvaluationsManagerTest { // Then verify(mockCallback).onFailure(any()) verify(mockCallback, never()).onSuccess() - verify(mockFlagsRepository).setFlagsAndContext(eq(context), eq(emptyMap()), any()) + verify(mockFlagsRepository).setFlagsAndContext(eq(context), eq(emptyMap()), anyOrNull(), any()) verify(mockFlagsStateManager).updateState(FlagsClientState.Ready) } @@ -883,7 +893,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 { _, _ -> @@ -908,7 +918,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 { _, _ -> @@ -963,7 +973,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, @@ -1087,7 +1097,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) @@ -1118,7 +1128,8 @@ internal class EvaluationsManagerTest { val firstKeys = mutableListOf>() repository.firstFlags.whenComplete { firstKeys.add(it) } whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)).thenReturn("response") - whenever(mockPrecomputeMapper.map("response")).thenReturn(mapOf("enabled" to mock())) + whenever(mockPrecomputeMapper.map("response")) + .thenReturn(PrecomputedAssignments(mapOf("enabled" to mock()))) // When manager.updateEvaluationsForContext(context, callback) @@ -1170,7 +1181,7 @@ internal class EvaluationsManagerTest { manager.updateEvaluationsForContext(EvaluationContext("newer")) } whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)).thenReturn("response") - whenever(mockPrecomputeMapper.map("response")).thenReturn(emptyMap()) + whenever(mockPrecomputeMapper.map("response")).thenReturn(PrecomputedAssignments(emptyMap())) // When manager.updateEvaluationsForContext(context, callback) @@ -1188,6 +1199,79 @@ internal class EvaluationsManagerTest { // region Cold-start integration + @Test + 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 = createRepository() + val testedManager = createManagerWithRepository(testedRepository) + whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)) + .thenReturn(ENCODED_RESPONSE_JSON) + + // When + testedManager.updateEvaluationsForContext(context, mockCallback) + + // Then + assertThat(checkNotNull(testedRepository.getPrecomputedFlag("flag")).variationValue).isEqualTo("true") + verify(mockCallback).onSuccess() + + // Given + 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) + 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 + val otherContext = EvaluationContext("$fakeTargetingKey-other", emptyMap()) + whenever(mockAssignmentsDownloader.readPrecomputedFlags(otherContext, fakeDatadogContext)) + .thenReturn(invalidResponse) + + // When + testedManager.updateEvaluationsForContext(otherContext, mockCallback) + + // Then + 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 source }`( + @Mock mockCallback: EvaluationContextCallback + ) { + // Given + fakeDatadogContext = fakeDatadogContext.copy(source = "react-native") + val context = EvaluationContext(fakeTargetingKey, emptyMap()) + val testedRepository = createRepository() + val testedManager = createManagerWithRepository(testedRepository) + whenever(mockAssignmentsDownloader.readPrecomputedFlags(context, fakeDatadogContext)) + .thenReturn(ENCODED_RESPONSE_JSON) + + // When + testedManager.updateEvaluationsForContext(context, mockCallback) + + // Then + 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() + } + @Test fun `M notify STALE W updateEvaluationsForContext() { cold start network failure, cached flags match context }`() { // Given @@ -1258,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, @@ -1275,6 +1381,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..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 @@ -64,6 +64,46 @@ internal class PrecomputedAssignmentsRequestFactoryTest { // region create() - Success cases + @Test + fun `M advertise encoding capability W create() { native source }`( + @Forgery fakeEvaluationContext: EvaluationContext + ) { + // Given + val context = fakeDatadogContext.copy(source = "android") + + // When + val request = checkNotNull(testedFactory.create(fakeEvaluationContext, 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() + assertThat(attributes.getJSONObject("source").getString("sdk_version")).isEqualTo(context.sdkVersion) + } + + @Test + 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(fakeEvaluationContext, 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() + } + } + @Test fun `M create valid request W create() { all fields present }`( @StringForgery fakeTargetingKey: String 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/DefaultFlagsRepositoryTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt index 9b23272bf8..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 @@ -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,18 @@ internal class DefaultFlagsRepositoryTest { ) } + @Test + 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) + } + @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/internal/repository/net/PrecomputeMapperTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/net/PrecomputeMapperTest.kt index 8b127ccfac..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 @@ -84,7 +137,7 @@ internal class PrecomputeMapperTest { ) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -113,7 +166,7 @@ internal class PrecomputeMapperTest { ) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -137,7 +190,7 @@ internal class PrecomputeMapperTest { ) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -161,7 +214,7 @@ internal class PrecomputeMapperTest { ) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -185,7 +238,7 @@ internal class PrecomputeMapperTest { ) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -213,7 +266,7 @@ internal class PrecomputeMapperTest { ) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -242,7 +295,7 @@ internal class PrecomputeMapperTest { ) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) @@ -277,7 +330,7 @@ internal class PrecomputeMapperTest { ) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(3) @@ -304,7 +357,7 @@ internal class PrecomputeMapperTest { val json = buildValidJson(emptyMap()) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).isEmpty() @@ -325,7 +378,7 @@ internal class PrecomputeMapperTest { val json = buildValidJson(mapOf(fakeFlagKey to flagJson)) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then val flag = result[fakeFlagKey] @@ -344,7 +397,7 @@ internal class PrecomputeMapperTest { val json = buildValidJson(mapOf(fakeFlagKey to flagJson)) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then val flag = result[fakeFlagKey] @@ -363,7 +416,7 @@ internal class PrecomputeMapperTest { val json = buildValidJson(mapOf(fakeFlagKey to flagJson)) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then val flag = result[fakeFlagKey] @@ -385,7 +438,7 @@ internal class PrecomputeMapperTest { ) // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then val flag = result[fakeFlagKey] @@ -573,7 +626,7 @@ internal class PrecomputeMapperTest { }.toString() // When - val result = checkNotNull(testedMapper.map(json)) + val result = checkNotNull(testedMapper.map(json)).flags // Then assertThat(result).hasSize(1) 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() } + ) +}