From 229297c18ce69e065d76c635791672009dad1f6a Mon Sep 17 00:00:00 2001 From: Adam Simon Date: Tue, 30 Jan 2024 13:51:21 +0100 Subject: [PATCH] Add tests for comparison attribute conversion to canonical string representation --- .../ConfigV2EvaluationTests.cs | 66 ++ .../data/comparison_attribute_conversion.json | 789 ++++++++++++++++++ .../Evaluation/RolloutEvaluator.cs | 4 +- .../Extensions/SerializationExtensions.cs | 35 +- 4 files changed, 889 insertions(+), 5 deletions(-) create mode 100644 src/ConfigCat.Client.Tests/data/comparison_attribute_conversion.json diff --git a/src/ConfigCat.Client.Tests/ConfigV2EvaluationTests.cs b/src/ConfigCat.Client.Tests/ConfigV2EvaluationTests.cs index e3472e26..ba5b68c9 100644 --- a/src/ConfigCat.Client.Tests/ConfigV2EvaluationTests.cs +++ b/src/ConfigCat.Client.Tests/ConfigV2EvaluationTests.cs @@ -587,6 +587,72 @@ public void UserObjectAttributeValueConversion_NonTextComparisons_Test(string sd Assert.AreEqual(expectedReturnValue, evaluationDetails.Value); } + [DataTestMethod] + [DataRow("numberToStringConversion", .12345, "1")] + [DataRow("numberToStringConversion", "decimal:0.12345", "1")] + [DataRow("numberToStringConversionInt", (sbyte)125, "4")] + [DataRow("numberToStringConversionInt", (byte)125, "4")] + [DataRow("numberToStringConversionInt", (short)125, "4")] + [DataRow("numberToStringConversionInt", (ushort)125, "4")] + [DataRow("numberToStringConversionInt", 125, "4")] + [DataRow("numberToStringConversionInt", 125u, "4")] + [DataRow("numberToStringConversionInt", 125L, "4")] + [DataRow("numberToStringConversionInt", 125ul, "4")] + [DataRow("numberToStringConversionPositiveExp", -1.23456789e96, "2")] + [DataRow("numberToStringConversionNegativeExp", -12345.6789E-100, "4")] + [DataRow("numberToStringConversionNaN", double.NaN, "3")] + [DataRow("numberToStringConversionPositiveInf", double.PositiveInfinity, "4")] + [DataRow("numberToStringConversionNegativeInf", double.NegativeInfinity, "3")] + [DataRow("dateToStringConversion", "datetime:2023-03-31T23:59:59.9990000Z", "3")] + [DataRow("dateToStringConversion", "datetimeoffset:2023-03-31T23:59:59.9990000Z", "3")] + [DataRow("dateToStringConversion", 1680307199.999, "3")] + [DataRow("dateToStringConversion", "decimal:1680307199.999", "3")] + [DataRow("dateToStringConversionNaN", double.NaN, "3")] + [DataRow("dateToStringConversionPositiveInf", double.PositiveInfinity, "1")] + [DataRow("dateToStringConversionNegativeInf", double.NegativeInfinity, "5")] + [DataRow("stringArrayToStringConversion", new[] { "read", "Write", " eXecute " }, "4")] + [DataRow("stringArrayToStringConversionEmpty", new string[0], "5")] + [DataRow("stringArrayToStringConversionSpecialChars", new[] { "+<>%\"'\\/\t\r\n" }, "3")] + [DataRow("stringArrayToStringConversionUnicode", new[] { "äöüÄÖÜçéèñışğ⢙✓😀" }, "2")] + public void ComparisonAttributeConversionToCanonicalStringRepresentation_Test(string key, object customAttributeValue, string expectedReturnValue) + { + var config = new ConfigLocation.LocalFile("data", "comparison_attribute_conversion.json").FetchConfig(); + + var logger = new Mock().Object.AsWrapper(); + var evaluator = new RolloutEvaluator(logger); + + if (customAttributeValue is string s) + { + const string decimalPrefix = "decimal:", dateTimePrefix = "datetime:", dateTimeOffsetPrefix = "datetimeoffset:"; + if (s.StartsWith(decimalPrefix, StringComparison.Ordinal)) + { + customAttributeValue = decimal.Parse(s.Substring(decimalPrefix.Length)); + } + else if (s.StartsWith(dateTimePrefix, StringComparison.Ordinal)) + { + var dateTimeStyle = s.EndsWith("Z", StringComparison.Ordinal) ? DateTimeStyles.AdjustToUniversal : DateTimeStyles.None; + customAttributeValue = DateTime.ParseExact(s.Substring(dateTimePrefix.Length), "o", CultureInfo.InvariantCulture, dateTimeStyle); + } + else if (s.StartsWith(dateTimeOffsetPrefix, StringComparison.Ordinal)) + { + customAttributeValue = DateTimeOffset.ParseExact(s.Substring(dateTimeOffsetPrefix.Length), "o", CultureInfo.InvariantCulture); + } + } + + var user = new User("12345") + { + Custom = + { + ["Custom1"] = customAttributeValue + } + }; + + const string defaultValue = "default"; + var actualReturnValue = evaluator.Evaluate(config!.Settings, key, defaultValue, user, remoteConfig: null, logger).Value; + + Assert.AreEqual(expectedReturnValue, actualReturnValue); + } + [DataTestMethod] [DataRow("isoneof", "no trim")] [DataRow("isnotoneof", "no trim")] diff --git a/src/ConfigCat.Client.Tests/data/comparison_attribute_conversion.json b/src/ConfigCat.Client.Tests/data/comparison_attribute_conversion.json new file mode 100644 index 00000000..5a900ae6 --- /dev/null +++ b/src/ConfigCat.Client.Tests/data/comparison_attribute_conversion.json @@ -0,0 +1,789 @@ +{ + "p": { + "u": "https://test-cdn-global.configcat.com", + "r": 0, + "s": "uM29sy1rjx71ze3ehr\u002BqCnoIpx8NZgL8V//MN7OL1aM=" + }, + "f": { + "numberToStringConversion": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "0.12345" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "numberToStringConversionInt": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "125" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "numberToStringConversionPositiveExp": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "-1.23456789e+96" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "numberToStringConversionNegativeExp": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "-1.23456789e-96" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "numberToStringConversionNaN": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "NaN" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "numberToStringConversionPositiveInf": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "Infinity" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "numberToStringConversionNegativeInf": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "-Infinity" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "dateToStringConversion": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "1680307199.999" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "dateToStringConversionNaN": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "NaN" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "dateToStringConversionPositiveInf": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "Infinity" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "dateToStringConversionNegativeInf": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "-Infinity" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "stringArrayToStringConversion": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "[\"read\",\"Write\",\" eXecute \"]" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "stringArrayToStringConversionEmpty": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "[]" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "stringArrayToStringConversionSpecialChars": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "[\"+<>%\\\"'\\\\/\\t\\r\\n\"]" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "stringArrayToStringConversionUnicode": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "[\"äöüÄÖÜçéèñışğ⢙✓😀\"]" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + } + } +} diff --git a/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs b/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs index 831c8493..d19290f3 100644 --- a/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs +++ b/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs @@ -864,11 +864,11 @@ private static string UserAttributeValueToString(object attributeValue) } else if (attributeValue is string[] stringArray) { - return stringArray.Serialize(); + return stringArray.Serialize(unescapeAstral: true); } else if (attributeValue.TryConvertNumericToDouble(out var number)) { - return number.ToString(CultureInfo.InvariantCulture); + return number.ToString(CultureInfo.InvariantCulture).Replace("E", "e"); } else if (attributeValue.TryConvertDateTimeToDateTimeOffset(out var dateTimeOffset)) { diff --git a/src/ConfigCatClient/Extensions/SerializationExtensions.cs b/src/ConfigCatClient/Extensions/SerializationExtensions.cs index 9c483ccb..93250918 100644 --- a/src/ConfigCatClient/Extensions/SerializationExtensions.cs +++ b/src/ConfigCatClient/Extensions/SerializationExtensions.cs @@ -2,9 +2,10 @@ using System.IO; using Newtonsoft.Json; #else +using System.Globalization; using System.Text.Encodings.Web; using System.Text.Json; -using System.Text.Json.Serialization; +using System.Text.RegularExpressions; #endif namespace System; @@ -51,12 +52,40 @@ internal static class SerializationExtensions } } - public static string Serialize(this T objectToSerialize) + public static string Serialize(this T objectToSerialize, bool unescapeAstral = false) { #if USE_NEWTONSOFT_JSON return JsonConvert.SerializeObject(objectToSerialize); #else - return JsonSerializer.Serialize(objectToSerialize, TolerantSerializerOptions); + var json = JsonSerializer.Serialize(objectToSerialize, TolerantSerializerOptions); + if (unescapeAstral) + { + // NOTE: There's no easy way to configure System.Text.Json not to encode surrogate pairs (i.e. Unicode code points above U+FFFF). + // The only way of doing it during serialization (https://github.com/dotnet/runtime/issues/54193#issuecomment-861155179) needs unsafe code, + // which we want to avoid in this project. So, we resort to the following regex-based workaround: + json = Regex.Replace(json, @"\\u[dD][89abAB][0-9a-fA-F]{2}\\u[dD][c-fC-F][0-9a-fA-F]{2}", match => + { + // Ignore possible matches that aren't really escaped ('\\uD800\uDC00', '\\\\uD800\uDC00', etc.) + var isEscaped = true; + for (var i = match.Index - 1; i >= 0; i--) + { + if (json[i] != '\\') + { + break; + } + isEscaped = !isEscaped; + } + if (!isEscaped) + { + return match.Value; + } + + var highSurrogate = ushort.Parse(match.Value.AsSpan(2, 4).ToParsable(), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture); + var lowSurrogate = ushort.Parse(match.Value.AsSpan(8, 4).ToParsable(), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture); + return char.ConvertFromUtf32(char.ConvertToUtf32((char)highSurrogate, (char)lowSurrogate)); + }); + } + return json; #endif } }