diff --git a/packages/Datadog.Unity/Runtime/Flags/FlagAssignments.cs b/packages/Datadog.Unity/Runtime/Flags/FlagAssignments.cs new file mode 100644 index 00000000..4d2a9af7 --- /dev/null +++ b/packages/Datadog.Unity/Runtime/Flags/FlagAssignments.cs @@ -0,0 +1,22 @@ +// 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 2026-Present Datadog, Inc. + +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace Datadog.Unity.Flags +{ + /// Keeps the assignment map and its lookup encoding together. + internal sealed class FlagAssignments + { + internal readonly IReadOnlyDictionary Flags; + internal readonly FlagKeyObfuscation Obfuscation; + + internal FlagAssignments(Dictionary flags, FlagKeyObfuscation obfuscation = null) + { + Flags = new ReadOnlyDictionary(new Dictionary(flags)); + Obfuscation = obfuscation; + } + } +} diff --git a/packages/Datadog.Unity/Runtime/Flags/FlagAssignments.cs.meta b/packages/Datadog.Unity/Runtime/Flags/FlagAssignments.cs.meta new file mode 100644 index 00000000..a5f4fca7 --- /dev/null +++ b/packages/Datadog.Unity/Runtime/Flags/FlagAssignments.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 42d0a74081a74ac2a655f5d164a4f630 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/Datadog.Unity/Runtime/Flags/FlagKeyObfuscation.cs b/packages/Datadog.Unity/Runtime/Flags/FlagKeyObfuscation.cs new file mode 100644 index 00000000..6476fa71 --- /dev/null +++ b/packages/Datadog.Unity/Runtime/Flags/FlagKeyObfuscation.cs @@ -0,0 +1,87 @@ +// 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 2026-Present Datadog, Inc. + +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Datadog.Unity.Flags +{ + /// Public response encoding metadata. This is obfuscation, not encryption. + internal sealed class FlagKeyObfuscation + { + internal const string Scheme = "flag-key-sha256-v1"; + internal const string Capability = "assignment-encoding-flag-key-256-v1"; + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + private static readonly byte[] Domain = StrictUtf8.GetBytes("datadog.feature-flags.flag-key.v1\0"); + private readonly byte[] _prefix; + private readonly Dictionary _lookupKeys = new(StringComparer.Ordinal); + private const int LookupCacheLimit = 1024; + + private FlagKeyObfuscation(string salt) + { + _prefix = new byte[Domain.Length + 16]; + Array.Copy(Domain, _prefix, Domain.Length); + for (var i = 0; i < 16; i++) + _prefix[Domain.Length + i] = Convert.ToByte(salt.Substring(i * 2, 2), 16); + } + + internal static FlagKeyObfuscation Read(JToken marker, JToken descriptor) + { + if ((marker == null || (marker.Type == JTokenType.Boolean && !marker.Value())) && descriptor == null) + return null; + + if (marker?.Type != JTokenType.Boolean || !marker.Value() || !(descriptor is JObject metadata)) + throw new JsonSerializationException("Invalid flag-key obfuscation metadata."); + + if (metadata["scheme"]?.Type != JTokenType.String || metadata["scheme"].Value() != Scheme) + throw new JsonSerializationException("Unsupported flag-key obfuscation scheme."); + + var salt = metadata["salt"]; + if (salt?.Type != JTokenType.String || !IsLowercaseHex(salt.Value(), 32)) + throw new JsonSerializationException("Flag-key salt must contain 32 lowercase hexadecimal characters."); + + return new FlagKeyObfuscation(salt.Value()); + } + + internal string Encode(string key) + { + lock (_lookupKeys) + { + if (_lookupKeys.TryGetValue(key, out var cached)) return cached; + var digest = Hash(key); + if (_lookupKeys.Count >= LookupCacheLimit) _lookupKeys.Clear(); + _lookupKeys[key] = digest; + return digest; + } + } + + private string Hash(string key) + { + // Reject invalid UTF-16 rather than aliasing a key containing a replacement character. + var keyBytes = StrictUtf8.GetBytes(key); + var input = new byte[_prefix.Length + keyBytes.Length]; + Array.Copy(_prefix, input, _prefix.Length); + Array.Copy(keyBytes, 0, input, _prefix.Length, keyBytes.Length); + using var sha256 = SHA256.Create(); + var digest = sha256.ComputeHash(input); + var result = new StringBuilder(64); + foreach (var value in digest) + result.Append(value.ToString("x2")); + return result.ToString(); + } + + internal static bool IsLowercaseHex(string value, int length) + { + if (value == null || value.Length != length) return false; + foreach (var character in value) + if (!((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f'))) + return false; + return true; + } + } +} diff --git a/packages/Datadog.Unity/Runtime/Flags/FlagKeyObfuscation.cs.meta b/packages/Datadog.Unity/Runtime/Flags/FlagKeyObfuscation.cs.meta new file mode 100644 index 00000000..3e70beae --- /dev/null +++ b/packages/Datadog.Unity/Runtime/Flags/FlagKeyObfuscation.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a49a3f1210364e179aec939df37d2750 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/Datadog.Unity/Runtime/Flags/FlagsClient.cs b/packages/Datadog.Unity/Runtime/Flags/FlagsClient.cs index 09483e24..ba98a4e1 100644 --- a/packages/Datadog.Unity/Runtime/Flags/FlagsClient.cs +++ b/packages/Datadog.Unity/Runtime/Flags/FlagsClient.cs @@ -124,6 +124,8 @@ public void SetEvaluationContext(FlagsEvaluationContext context, Action on "Provide a stable, unique targeting key per user (e.g. user ID)."); } + // A failed or unsupported response must not retain another subject's assignments. + _repository.PrepareContext(context); TransitionState(FlagsClientState.Reconciling); _fetcher.Fetch(context, flags => diff --git a/packages/Datadog.Unity/Runtime/Flags/FlagsRepository.cs b/packages/Datadog.Unity/Runtime/Flags/FlagsRepository.cs index 27aa8409..135e269d 100644 --- a/packages/Datadog.Unity/Runtime/Flags/FlagsRepository.cs +++ b/packages/Datadog.Unity/Runtime/Flags/FlagsRepository.cs @@ -3,6 +3,7 @@ // Copyright 2025-Present Datadog, Inc. using System.Collections.Generic; +using System.Text; namespace Datadog.Unity.Flags { @@ -12,7 +13,7 @@ namespace Datadog.Unity.Flags internal class FlagsRepository { private readonly object _lock = new(); - private Dictionary _flags = new(); + private FlagAssignments _assignments = new(new Dictionary()); private FlagsEvaluationContext _context; /// @@ -36,7 +37,17 @@ public FlagAssignment GetFlagAssignment(string key) { lock (_lock) { - _flags.TryGetValue(key, out var flag); + if (key == null) return null; + string lookupKey; + try + { + lookupKey = _assignments.Obfuscation?.Encode(key) ?? key; + } + catch (EncoderFallbackException) + { + return null; + } + _assignments.Flags.TryGetValue(lookupKey, out var flag); return flag; } } @@ -45,14 +56,40 @@ public FlagAssignment GetFlagAssignment(string key) /// Sets the flags and context atomically. /// public void SetFlagsAndContext(FlagsEvaluationContext context, Dictionary flags) + { + SetFlagsAndContext(context, new FlagAssignments(flags ?? new Dictionary())); + } + + public void SetFlagsAndContext(FlagsEvaluationContext context, FlagAssignments assignments) { lock (_lock) { _context = context; - _flags = flags ?? new Dictionary(); + _assignments = assignments; } } + /// Keep fallback assignments only when the complete requested context matches. + public void PrepareContext(FlagsEvaluationContext context) + { + lock (_lock) + { + if (ContextsMatch(_context, context)) return; + _context = context; + _assignments = new FlagAssignments(new Dictionary()); + } + } + + private static bool ContextsMatch(FlagsEvaluationContext left, FlagsEvaluationContext right) + { + if (left == null || right == null || left.TargetingKey != right.TargetingKey || left.Attributes.Count != right.Attributes.Count) + return false; + foreach (var attribute in left.Attributes) + if (!right.Attributes.TryGetValue(attribute.Key, out var value) || value != attribute.Value) + return false; + return true; + } + /// /// Returns true if any flags are cached. /// @@ -60,18 +97,18 @@ public bool HasFlags() { lock (_lock) { - return _flags.Count > 0; + return _assignments.Flags.Count > 0; } } /// /// Returns a snapshot of all cached flags. /// - public Dictionary GetFlagsSnapshot() + public FlagAssignments GetFlagsSnapshot() { lock (_lock) { - return new Dictionary(_flags); + return _assignments; } } } diff --git a/packages/Datadog.Unity/Runtime/Flags/PrecomputeAssignmentsFetcher.cs b/packages/Datadog.Unity/Runtime/Flags/PrecomputeAssignmentsFetcher.cs index c460794c..2b793057 100644 --- a/packages/Datadog.Unity/Runtime/Flags/PrecomputeAssignmentsFetcher.cs +++ b/packages/Datadog.Unity/Runtime/Flags/PrecomputeAssignmentsFetcher.cs @@ -18,6 +18,8 @@ namespace Datadog.Unity.Flags internal class PrecomputeAssignmentsFetcher { public const int FetchTimeoutSeconds = 30; + private static readonly string CapabilitiesHeader = string.Join(",", + new SortedSet(StringComparer.Ordinal) { FlagKeyObfuscation.Capability }); private readonly string _endpointUrl; private readonly string _clientToken; @@ -43,24 +45,11 @@ public PrecomputeAssignmentsFetcher( /// Fetches precomputed assignments for the given evaluation context. /// Uses a callback since UnityWebRequest can be used from coroutines. /// - public void Fetch(FlagsEvaluationContext context, Action> onComplete) + public virtual void Fetch(FlagsEvaluationContext context, Action onComplete) { try { - var requestBody = BuildRequestBody(context); - var bodyBytes = Encoding.UTF8.GetBytes(requestBody); - - var request = new UnityWebRequest(_endpointUrl, "POST"); - request.uploadHandler = new UploadHandlerRaw(bodyBytes); - request.downloadHandler = new DownloadHandlerBuffer(); - request.timeout = FetchTimeoutSeconds; - request.SetRequestHeader("Content-Type", "application/vnd.api+json"); - request.SetRequestHeader("dd-client-token", _clientToken); - - if (!string.IsNullOrEmpty(_applicationId)) - { - request.SetRequestHeader("dd-application-id", _applicationId); - } + var request = BuildRequest(context); var operation = request.SendWebRequest(); operation.completed += _ => @@ -100,7 +89,23 @@ public void Fetch(FlagsEvaluationContext context, Action ParseResponse(string json) + internal static FlagAssignments ParseResponse(string json) { var flags = new Dictionary(); if (string.IsNullOrEmpty(json)) { - return flags; + return null; } - AssignmentsResponseDto response; + JObject response; try { - response = JsonConvert.DeserializeObject(json); + response = JObject.Parse(json); } catch { - return flags; + return null; } - var flagsDict = response?.Data?.Attributes?.Flags; + var attributes = response["data"]?["attributes"] as JObject; + var encoding = FlagKeyObfuscation.Read(attributes?["obfuscated"], attributes?["obfuscation"]); + var flagsDict = attributes?["flags"]?.ToObject>(); if (flagsDict == null) { - return flags; + return null; } foreach (var kvp in flagsDict) { + if (encoding != null && !FlagKeyObfuscation.IsLowercaseHex(kvp.Key, 64)) + throw new JsonSerializationException("Invalid obfuscated flag-map key."); var dto = kvp.Value; flags[kvp.Key] = new FlagAssignment( variationType: dto.VariationType, @@ -204,7 +213,7 @@ internal static Dictionary ParseResponse(string json) reason: dto.Reason); } - return flags; + return new FlagAssignments(flags, encoding); } private class AssignmentsRequestDto @@ -249,24 +258,6 @@ private class AssignmentsSubjectDto public IReadOnlyDictionary TargetingAttributes { get; set; } } - private class AssignmentsResponseDto - { - [JsonProperty("data")] - public AssignmentsResponseDataDto Data { get; set; } - } - - private class AssignmentsResponseDataDto - { - [JsonProperty("attributes")] - public AssignmentsResponseAttributesDto Attributes { get; set; } - } - - private class AssignmentsResponseAttributesDto - { - [JsonProperty("flags")] - public Dictionary Flags { get; set; } - } - private class FlagAssignmentDto { [JsonProperty("variationType")] diff --git a/packages/Datadog.Unity/Tests/Flags/FlagKeyObfuscationTests.cs b/packages/Datadog.Unity/Tests/Flags/FlagKeyObfuscationTests.cs new file mode 100644 index 00000000..ce5be6e0 --- /dev/null +++ b/packages/Datadog.Unity/Tests/Flags/FlagKeyObfuscationTests.cs @@ -0,0 +1,295 @@ +// 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 2026-Present Datadog, Inc. + +using System.Collections.Generic; +using System; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace Datadog.Unity.Flags.Tests +{ + public class FlagKeyObfuscationTests + { + private const string Salt = "000102030405060708090a0b0c0d0e0f"; + + private static JObject Descriptor(string salt = Salt) => new() + { + ["scheme"] = "flag-key-sha256-v1", + ["salt"] = salt, + }; + + private static FlagKeyObfuscation Encoding(string salt = Salt) => + FlagKeyObfuscation.Read(new JValue(true), Descriptor(salt)); + + private static JObject Assignment(string type = "boolean", JToken value = null) => new() + { + ["variationType"] = type, + ["variationValue"] = value ?? new JValue(true), + ["variationKey"] = "treatment", + ["allocationKey"] = "allocation-123", + ["reason"] = "TARGETING_MATCH", + ["doLog"] = true, + }; + + private static JObject Response(JObject attributes) => new() + { + ["data"] = new JObject { ["attributes"] = attributes }, + }; + + private static FlagAssignments Decode(string key, JObject assignment, string salt = Salt) + { + var attributes = new JObject + { + ["flags"] = new JObject { [Encoding(salt).Encode(key)] = assignment }, + ["obfuscated"] = true, + ["obfuscation"] = Descriptor(salt), + }; + return PrecomputeAssignmentsFetcher.ParseResponse(Response(attributes).ToString()); + } + + // Fixed vectors also run against the browser SDK and Rust edge encoder. + [TestCase("new-route-planner", "a60479237ef2f69175bbe0bd581966d1583766941815dc1d414c883767795190")] + [TestCase("Flag", "adca75d2141c51b0c0f084c1058edfb8e6e763f91aaf54ca06326d9847bd9586")] + [TestCase("flag", "9817872c144b018abd77e3915bd77e2c27f4f534dccff8ffaca27361e3a5e1ee")] + [TestCase(" flag ", "b1a5f851cc82a3fdf03a461d72a2241584dcf455df1d384e02a937901b3bc80b")] + [TestCase("café", "3bfa8c3c17c1b61035b98ecf14007cdf5cba5ce61a48e8cfb65f8106541892d3")] + [TestCase("cafe\u0301", "1e8b7ec5e8028a1ec96b38dd37f6ccea041c0a90358904af40ac5810c23c8765")] + [TestCase("🚲/旗", "94b611e0d3b26b52f6ad66013d1c72f8a92ea109390049759e75d3c8d3aa4dab")] + [TestCase("a\0b", "bac134d201be5e7f28fc7019248f0809c2a013b137e866446ee71add2bc344c7")] + [TestCase("", "072d985b427f536ad0a11b2d4c5e0f7e6f0ff6d23e779e082f75599ce3fe3eba")] + public void MatchesSharedHashVectors(string key, string expected) + { + Assert.AreEqual(expected, Encoding().Encode(key)); + } + + [Test] + public void BoundsLookupHashesAndKeepsDescriptorsSeparate() + { + var encoding = Encoding(); + var other = Encoding(new string('f', 32)); + var first = encoding.Encode("flag"); + Assert.AreSame(first, encoding.Encode("flag")); + Assert.AreNotEqual(first, other.Encode("flag")); + Assert.AreSame(first, encoding.Encode("flag")); + Assert.AreNotEqual(encoding.Encode("café"), encoding.Encode("cafe\u0301")); + for (var index = 0; index < 1024; index++) encoding.Encode($"flag-{index}"); + var afterEviction = encoding.Encode("flag"); + Assert.AreEqual(first, afterEviction); + Assert.AreNotSame(first, afterEviction); + } + + [Test] + public void RejectsInvalidUnicodeWithoutAliasingReplacementCharacter() + { + Assert.Throws(() => Encoding().Encode("\ud800")); + var repository = new FlagsRepository(); + repository.SetFlagsAndContext(new FlagsEvaluationContext("athlete"), Decode("\ufffd", Assignment())); + Assert.IsNull(repository.GetFlagAssignment("\ud800")); + Assert.IsNotNull(repository.GetFlagAssignment("\ufffd")); + } + + [TestCase("{}")] + [TestCase("{\"obfuscated\":false}")] + public void AcceptsLegacyMetadata(string metadata) + { + var attributes = JObject.Parse(metadata); + attributes["flags"] = new JObject { ["flag"] = Assignment() }; + var decoded = PrecomputeAssignmentsFetcher.ParseResponse(Response(attributes).ToString()); + Assert.IsNull(decoded.Obfuscation); + Assert.IsTrue(decoded.Flags.ContainsKey("flag")); + } + + [TestCase("{\"obfuscated\":true}")] + [TestCase("{\"obfuscated\":null}")] + [TestCase("{\"obfuscated\":\"true\"}")] + [TestCase("{\"obfuscation\":null}")] + [TestCase("{\"obfuscation\":{}}")] + [TestCase("{\"obfuscated\":true,\"obfuscation\":null}")] + [TestCase("{\"obfuscated\":true,\"obfuscation\":[]}")] + [TestCase("{\"obfuscated\":true,\"obfuscation\":{\"scheme\":\"future\",\"salt\":\"000102030405060708090a0b0c0d0e0f\"}}")] + [TestCase("{\"obfuscated\":false,\"obfuscation\":{\"scheme\":\"flag-key-sha256-v1\",\"salt\":\"000102030405060708090a0b0c0d0e0f\"}}")] + public void RejectsMalformedMetadata(string metadata) + { + var attributes = JObject.Parse(metadata); + attributes["flags"] = new JObject(); + Assert.Throws(() => PrecomputeAssignmentsFetcher.ParseResponse(Response(attributes).ToString())); + } + + [TestCase("")] + [TestCase("000102030405060708090a0b0c0d0e")] + [TestCase("000102030405060708090a0b0c0d0e0f00")] + [TestCase("000102030405060708090A0B0C0D0E0F")] + [TestCase("000102030405060708090a0b0c0d0e0g")] + [TestCase("000102030405060708090a0b0c0d0e0f\n")] + [TestCase("000102030405060708090a0b0c0d0e0\n")] + public void RejectsInvalidSalt(string salt) + { + Assert.Throws(() => Encoding(salt)); + } + + [Test] + public void RejectsInvalidEncodedMapKey() + { + foreach (var key in new[] { "plaintext", new string('a', 63), new string('a', 65), new string('A', 64), new string('a', 64) + "\n", new string('a', 63) + "\n" }) + { + var attributes = new JObject + { + ["obfuscated"] = true, ["obfuscation"] = Descriptor(), + ["flags"] = new JObject { [key] = Assignment() }, + }; + Assert.Throws(() => PrecomputeAssignmentsFetcher.ParseResponse(Response(attributes).ToString())); + } + } + + [TestCase("boolean", "true")] + [TestCase("string", "\"visible-value\"")] + [TestCase("integer", "42")] + [TestCase("number", "12.5")] + [TestCase("object", "{\"visible\":[42,true]}")] + public void PreservesAssignmentsAndChangesOnlyLookupKeys(string type, string value) + { + var assignment = Assignment(type, JToken.Parse(value)); + var plain = PrecomputeAssignmentsFetcher.ParseResponse(Response(new JObject + { + ["flags"] = new JObject { ["flag"] = assignment }, + }).ToString()); + var repository = new FlagsRepository(); + repository.SetFlagsAndContext(new FlagsEvaluationContext("athlete"), Decode("flag", assignment)); + var actual = repository.GetFlagAssignment("flag"); + var expected = plain.Flags["flag"]; + Assert.AreEqual(expected.VariationType, actual.VariationType); + Assert.IsTrue(JToken.DeepEquals(expected.VariationValue, actual.VariationValue)); + Assert.AreEqual(expected.VariationKey, actual.VariationKey); + Assert.AreEqual(expected.AllocationKey, actual.AllocationKey); + Assert.AreEqual(expected.Reason, actual.Reason); + Assert.AreEqual(expected.DoLog, actual.DoLog); + Assert.IsNull(repository.GetFlagAssignment("missing")); + Assert.IsNull(repository.GetFlagAssignment(Encoding().Encode("flag")), "No plaintext lookup fallback."); + } + + [Test] + public void AcceptsNewKeysAndFieldsAndPreservesUnknownVariantBehavior() + { + var encoding = Encoding(); + var attributes = new JObject + { + ["obfuscated"] = true, ["obfuscation"] = Descriptor(), ["future-field"] = true, + ["flags"] = new JObject + { + [encoding.Encode("flag")] = Assignment(), + [encoding.Encode("new-flag")] = Assignment(), + [encoding.Encode("future")] = Assignment("future-type"), + }, + }; + var repository = new FlagsRepository(); + repository.SetFlagsAndContext(new FlagsEvaluationContext("athlete"), + PrecomputeAssignmentsFetcher.ParseResponse(Response(attributes).ToString())); + using var client = new FlagsClient(repository, null, null, null, null, + false, false, null, FlagsClientState.Ready); + Assert.IsTrue(client.GetBooleanValue("flag", false)); + Assert.IsTrue(client.GetBooleanValue("new-flag", false)); + // Unity converts the value without requiring a known variationType. Encoding must not change that. + var plain = PrecomputeAssignmentsFetcher.ParseResponse(Response(new JObject + { + ["flags"] = new JObject { ["future"] = Assignment("future-type") }, + }).ToString()); + Assert.IsTrue(plain.Flags["future"].TryGetValue(out var expected)); + Assert.AreEqual(expected, client.GetBooleanValue("future", false)); + } + + [Test] + public void RetainsOriginalDetailsAndExposureIdentityAcrossSaltChanges() + { + var repository = new FlagsRepository(); + var context = new FlagsEvaluationContext("athlete"); + var exposures = new List(); + using var client = new FlagsClient(repository, new ExposureTracker(), null, null, null, + true, false, exposures.Add, FlagsClientState.Ready); + + foreach (var salt in new[] { Salt, new string('f', 32), Salt }) + { + repository.SetFlagsAndContext(context, Decode("flag", Assignment(), salt)); + var details = client.GetBooleanDetails("flag", false); + Assert.IsTrue(details.Value); + Assert.AreEqual("flag", details.Key); + Assert.AreEqual("treatment", details.Variant); + Assert.AreEqual("allocation-123", details.AllocationKey); + } + Assert.AreEqual(1, exposures.Count); + StringAssert.Contains("\"key\":\"flag\"", JsonConvert.SerializeObject(exposures[0])); + + repository.SetFlagsAndContext(new FlagsEvaluationContext("other"), Decode("other-flag", Assignment())); + Assert.IsNull(repository.GetFlagAssignment("flag")); + Assert.IsNotNull(repository.GetFlagAssignment("other-flag")); + } + + [Test] + public void PreservesPublicApiDefaultsAndEvaluationTelemetry() + { + var repository = new FlagsRepository(); + repository.SetFlagsAndContext(new FlagsEvaluationContext("athlete"), Decode("flag", Assignment("string", new JValue("not-a-boolean")))); + var events = new List(); + var aggregator = new EvaluationAggregator(events.AddRange); + using var client = new FlagsClient(repository, null, aggregator, null, null, + false, true, null, FlagsClientState.Ready); + Assert.AreEqual(FlagEvaluationError.TypeMismatch, client.GetBooleanDetails("flag", false).Error); + Assert.AreEqual(FlagEvaluationError.FlagNotFound, client.GetBooleanDetails("missing", false).Error); + client.Flush(); + Assert.AreEqual(2, events.Count); + Assert.IsTrue(events.Exists(value => value.Flag.Key == "flag")); + Assert.IsTrue(events.Exists(value => value.Flag.Key == "missing")); + } + + [Test] + public void AdvertisesAssignmentEncodingCapability() + { + var fetcher = new PrecomputeAssignmentsFetcher("https://example.invalid", "token", null, "prod", null); + using var httpRequest = fetcher.BuildRequest(new FlagsEvaluationContext("athlete")); + Assert.AreEqual("assignment-encoding-flag-key-256-v1", httpRequest.GetRequestHeader("X-DD-FEATURE-FLAGS-CAPABILITIES")); + var request = JObject.Parse(fetcher.BuildRequestBody(new FlagsEvaluationContext("athlete"))); + var attributes = request["data"]["attributes"]; + Assert.IsNull(attributes["supported_capabilities"]); + Assert.AreEqual("athlete", attributes["subject"]["targeting_key"].Value()); + } + + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + public void InvalidEncodingRetainsOnlySameContextFallback(bool changeSubject, bool changeAttributes) + { + var repository = new FlagsRepository(); + var context = new FlagsEvaluationContext("athlete", new Dictionary { ["country"] = "US" }); + repository.SetFlagsAndContext(context, Decode("flag", Assignment())); + var invalid = Response(new JObject { ["obfuscated"] = true, ["flags"] = new JObject() }).ToString(); + using var client = new FlagsClient(repository, null, null, new ResponseFetcher(invalid), null, + false, false, null, FlagsClientState.Ready); + var next = new FlagsEvaluationContext(changeSubject ? "other" : "athlete", + new Dictionary { ["country"] = changeAttributes ? "FR" : "US" }); + var succeeded = true; + client.SetEvaluationContext(next, value => succeeded = value); + Assert.IsFalse(succeeded); + Assert.AreEqual(!changeSubject && !changeAttributes, client.GetBooleanValue("flag", false)); + Assert.AreEqual(changeSubject || changeAttributes ? FlagsClientState.Error : FlagsClientState.Stale, client.State); + } + + private sealed class ResponseFetcher : PrecomputeAssignmentsFetcher + { + private readonly string _response; + internal ResponseFetcher(string response) : base("https://example.invalid", "token", null, "prod", null) + { + _response = response; + } + + public override void Fetch(FlagsEvaluationContext context, Action onComplete) + { + FlagAssignments assignments; + try { assignments = ParseResponse(_response); } + catch (JsonException) { assignments = null; } + onComplete(assignments); + } + } + } +} diff --git a/packages/Datadog.Unity/Tests/Flags/FlagKeyObfuscationTests.cs.meta b/packages/Datadog.Unity/Tests/Flags/FlagKeyObfuscationTests.cs.meta new file mode 100644 index 00000000..a68ad861 --- /dev/null +++ b/packages/Datadog.Unity/Tests/Flags/FlagKeyObfuscationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 73a2e239b292488496bac941292c63f2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/Datadog.Unity/Tests/Flags/PrecomputeParserTests.cs b/packages/Datadog.Unity/Tests/Flags/PrecomputeParserTests.cs index 35935eee..8078679f 100644 --- a/packages/Datadog.Unity/Tests/Flags/PrecomputeParserTests.cs +++ b/packages/Datadog.Unity/Tests/Flags/PrecomputeParserTests.cs @@ -29,7 +29,7 @@ public void ParsesValidBooleanFlag() } }"; - var flags = PrecomputeAssignmentsFetcher.ParseResponse(json); + var flags = PrecomputeAssignmentsFetcher.ParseResponse(json).Flags; Assert.AreEqual(1, flags.Count); Assert.IsTrue(flags.ContainsKey("enable-feature")); @@ -87,7 +87,7 @@ public void ParsesMultipleFlags() } }"; - var flags = PrecomputeAssignmentsFetcher.ParseResponse(json); + var flags = PrecomputeAssignmentsFetcher.ParseResponse(json).Flags; Assert.AreEqual(4, flags.Count); @@ -105,24 +105,22 @@ public void ParsesMultipleFlags() public void ParsesEmptyFlagsResponse() { var json = @"{""data"":{""attributes"":{""flags"":{}}}}"; - var flags = PrecomputeAssignmentsFetcher.ParseResponse(json); + var flags = PrecomputeAssignmentsFetcher.ParseResponse(json).Flags; Assert.AreEqual(0, flags.Count); } [Test] - public void ParsesInvalidJsonReturnsEmptyDict() + public void ParsesInvalidJsonReturnsFailure() { var flags = PrecomputeAssignmentsFetcher.ParseResponse("not json"); - Assert.IsNotNull(flags); - Assert.AreEqual(0, flags.Count); + Assert.IsNull(flags); } [Test] - public void ParsesNullJsonReturnsEmptyDict() + public void ParsesNullJsonReturnsFailure() { var flags = PrecomputeAssignmentsFetcher.ParseResponse(null); - Assert.IsNotNull(flags); - Assert.AreEqual(0, flags.Count); + Assert.IsNull(flags); } } }