diff --git a/src/ConfigCat.Client.Tests/UserTests.cs b/src/ConfigCat.Client.Tests/UserTests.cs index 94dc5470..2d2fdd0d 100644 --- a/src/ConfigCat.Client.Tests/UserTests.cs +++ b/src/ConfigCat.Client.Tests/UserTests.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Globalization; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace ConfigCat.Client.Tests; @@ -124,4 +125,38 @@ public void CreateUser_ShouldSetIdentifier(string identifier, string expectedVal Assert.AreEqual(expectedValue, user.Identifier); Assert.AreEqual(expectedValue, user.GetAllAttributes()[nameof(User.Identifier)]); } + + + [DataTestMethod] + [DataRow("datetime", "2023-09-19T11:01:35.0000000+00:00", "1695121295")] + [DataRow("datetime", "2023-09-19T13:01:35.0000000+02:00", "1695121295")] + [DataRow("datetime", "2023-09-19T11:01:35.0510886+00:00", "1695121295.051")] + [DataRow("datetime", "2023-09-19T13:01:35.0510886+02:00", "1695121295.051")] + [DataRow("number", "3", "3")] + [DataRow("number", "3.14", "3.14")] + [DataRow("number", "-1.23e-100", "-1.23e-100")] + [DataRow("stringlist", "a,,b,c", "[\"a\",\"\",\"b\",\"c\"]")] + public void HelperMethodsShouldWork(string type, string value, string expectedAttributeValue) + { + string actualAttributeValue; + switch (type) + { + case "datetime": + var dateTimeOffset = DateTimeOffset.ParseExact(value, "o", CultureInfo.InvariantCulture); + actualAttributeValue = User.AttributeValueFrom(dateTimeOffset); + break; + case "number": + var number = double.Parse(value, NumberStyles.Float, CultureInfo.InvariantCulture); + actualAttributeValue = User.AttributeValueFrom(number); + break; + case "stringlist": + var items = value.Split(','); + actualAttributeValue = User.AttributeValueFrom(items); + break; + default: + throw new InvalidOperationException(); + } + + Assert.AreEqual(expectedAttributeValue, actualAttributeValue); + } } diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/comparators.json b/src/ConfigCat.Client.Tests/data/evaluationlog/comparators.json index f9f4213b..12393039 100644 --- a/src/ConfigCat.Client.Tests/data/evaluationlog/comparators.json +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/comparators.json @@ -9,7 +9,7 @@ "user": { "Identifier": "12345", "Email": "joe@example.com", - "Country": "USA", + "Country": "[\"USA\"]", "Version": "1.0.0", "Number": "1.0", "Date": "1693497500" diff --git a/src/ConfigCat.Client.Tests/data/evaluationlog/comparators/allinone.txt b/src/ConfigCat.Client.Tests/data/evaluationlog/comparators/allinone.txt index fbbb5413..889bfdd1 100644 --- a/src/ConfigCat.Client.Tests/data/evaluationlog/comparators/allinone.txt +++ b/src/ConfigCat.Client.Tests/data/evaluationlog/comparators/allinone.txt @@ -1,4 +1,4 @@ -INFO [5000] Evaluating 'allinone' for User '{"Identifier":"12345","Email":"joe@example.com","Country":"USA","Version":"1.0.0","Number":"1.0","Date":"1693497500"}' +INFO [5000] Evaluating 'allinone' for User '{"Identifier":"12345","Email":"joe@example.com","Country":"[\"USA\"]","Version":"1.0.0","Number":"1.0","Date":"1693497500"}' Evaluating targeting rules and applying the first match if any: - IF User.Email EQUALS '' => true AND User.Email NOT EQUALS '' => false, skipping the remaining AND conditions diff --git a/src/ConfigCat.Client.Tests/data/testmatrix_comparators_v6.csv b/src/ConfigCat.Client.Tests/data/testmatrix_comparators_v6.csv index 2fc188ff..593fd541 100644 --- a/src/ConfigCat.Client.Tests/data/testmatrix_comparators_v6.csv +++ b/src/ConfigCat.Client.Tests/data/testmatrix_comparators_v6.csv @@ -2,15 +2,21 @@ Identifier;Email;Country;Custom1;boolTrueIn202304;stringEqualsDogDefaultCat;stri ##null##;;;;False;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Chicken;Chicken;Chicken ;;;;False;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Chicken;Chicken;Chicken a@configcat.com;a@configcat.com;##null##;##null##;False;Dog;Dog;Dog;Cat;Dog;Cat;Cat;Cat;Cat;Cat;Chicken;NotFound;Chicken -b@configcat.com;b@configcat.com;Hungary;0;False;Cat;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Cat;Dog;Horse;NotFound;Falcon -c@configcat.com;c@configcat.com;United Kingdom;1680307199.9;False;Cat;Dog;Cat;Dog;Dog;Cat;Cat;Dog;Cat;Dog;Falcon;NotFound;Falcon -anna@configcat.com;anna@configcat.com;Hungary;1681118000.56;True;Cat;Dog;Dog;Cat;Dog;Cat;Cat;Dog;Cat;Dog;Falcon;NotFound;Falcon -bogjobber@verizon.net;bogjobber@verizon.net;##null##;1682899200.1;False;Cat;Dog;Cat;Dog;Cat;Dog;Cat;Dog;Cat;Dog;Horse;Chicken;Chicken -cliffordj@aol.com;cliffordj@aol.com;Austria;1682999200;False;Cat;Dog;Cat;Dog;Cat;Dog;Cat;Dog;Cat;Dog;Falcon;Chicken;Falcon -reader@configcat.com;reader@configcat.com;Bahamas;read,execute;False;Cat;Dog;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Dog;Falcon;NotFound;Falcon -writer@configcat.com;writer@configcat.com;Belgium;write, execute;False;Cat;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Dog;Horse;NotFound;Horse -reader@configcat.com;reader@configcat.com;Canada;execute, Read;False;Cat;Dog;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Horse;NotFound;Horse -writer@configcat.com;writer@configcat.com;China;Write;False;Cat;Dog;Cat;Dog;Dog;Cat;Cat;Dog;Cat;Cat;Falcon;NotFound;Horse -admin@configcat.com;admin@configcat.com;France;read, write,execute;False;Cat;Dog;Dog;Cat;Dog;Cat;Dog;Cat;Cat;Dog;Falcon;NotFound;Horse -user@configcat.com;user@configcat.com;Greece;,execute;False;Cat;Dog;Cat;Dog;Dog;Cat;Cat;Dog;Cat;Dog;Falcon;NotFound;Horse -user@configcat.com;user@configcat.com;Monaco;,null, ,,nil, None;False;Cat;Dog;Cat;Dog;Dog;Cat;Cat;Dog;Cat;Dog;Falcon;NotFound;Horse +b@configcat.com;b@configcat.com;Hungary;0;False;Cat;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Horse;NotFound;Falcon +c@configcat.com;c@configcat.com;United Kingdom;1680307199.9;False;Cat;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Falcon +anna@configcat.com;anna@configcat.com;Hungary;1681118000.56;True;Cat;Dog;Dog;Cat;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Falcon +bogjobber@verizon.net;bogjobber@verizon.net;##null##;1682899200.1;False;Cat;Dog;Cat;Dog;Cat;Dog;Cat;Cat;Cat;Cat;Horse;Chicken;Chicken +cliffordj@aol.com;cliffordj@aol.com;Austria;1682999200;False;Cat;Dog;Cat;Dog;Cat;Dog;Cat;Cat;Cat;Cat;Falcon;Chicken;Falcon +reader@configcat.com;reader@configcat.com;Bahamas;read,execute;False;Cat;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Falcon +writer@configcat.com;writer@configcat.com;Belgium;write, execute;False;Cat;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Horse;NotFound;Horse +reader@configcat.com;reader@configcat.com;Canada;execute, Read;False;Cat;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Horse;NotFound;Horse +writer@configcat.com;writer@configcat.com;China;Write;False;Cat;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Horse +admin@configcat.com;admin@configcat.com;France;read, write,execute;False;Cat;Dog;Dog;Cat;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Horse +user@configcat.com;user@configcat.com;Greece;,execute;False;Cat;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Horse +reader@configcat.com;reader@configcat.com;Bahamas;["read","execute"];False;Cat;Dog;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Dog;Falcon;NotFound;Falcon +writer@configcat.com;writer@configcat.com;Belgium;["write", "execute"];False;Cat;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Dog;Falcon;NotFound;Horse +reader@configcat.com;reader@configcat.com;Canada;["execute", "Read"];False;Cat;Dog;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Horse;NotFound;Horse +writer@configcat.com;writer@configcat.com;China;["Write"];False;Cat;Dog;Cat;Dog;Dog;Cat;Cat;Dog;Cat;Cat;Horse;NotFound;Horse +admin@configcat.com;admin@configcat.com;France;["read", "write","execute"];False;Cat;Dog;Dog;Cat;Dog;Cat;Dog;Cat;Cat;Dog;Falcon;NotFound;Horse +user@configcat.com;user@configcat.com;Greece;["","execute"];False;Cat;Dog;Cat;Dog;Dog;Cat;Cat;Dog;Cat;Dog;Horse;NotFound;Horse +user@configcat.com;user@configcat.com;Monaco;,null, ,,nil, None;False;Cat;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Horse diff --git a/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs b/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs index 69485d1b..b29fac3d 100644 --- a/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs +++ b/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs @@ -355,7 +355,7 @@ private bool EvaluateUserCondition(UserCondition condition, string contextSalt, case UserComparator.SemVerNotOneOf: if (!SemVersion.TryParse(userAttributeValue!.Trim(), out var version, strict: true)) { - error = HandleInvalidSemVerUserAttribute(condition, context.Key, userAttributeName, userAttributeValue); + error = HandleInvalidUserAttribute(condition, context.Key, userAttributeName, $"'{userAttributeValue}' is not a valid semantic version"); return false; } return EvaluateSemVerOneOf(version, condition.StringListValue, negate: comparator == UserComparator.SemVerNotOneOf); @@ -366,7 +366,7 @@ private bool EvaluateUserCondition(UserCondition condition, string contextSalt, case UserComparator.SemVerGreaterThanEqual: if (!SemVersion.TryParse(userAttributeValue!.Trim(), out version, strict: true)) { - error = HandleInvalidSemVerUserAttribute(condition, context.Key, userAttributeName, userAttributeValue); + error = HandleInvalidUserAttribute(condition, context.Key, userAttributeName, $"'{userAttributeValue}' is not a valid semantic version"); return false; } return EvaluateSemVerRelation(version, comparator, condition.StringValue); @@ -379,7 +379,7 @@ private bool EvaluateUserCondition(UserCondition condition, string contextSalt, case UserComparator.NumberGreaterThanEqual: if (!double.TryParse(userAttributeValue!.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out var number)) { - error = HandleInvalidNumberUserAttribute(condition, context.Key, userAttributeName, userAttributeValue); + error = HandleInvalidUserAttribute(condition, context.Key, userAttributeName, $"'{userAttributeValue}' is not a valid decimal number"); return false; } return EvaluateNumberRelation(number, condition.Comparator, condition.DoubleValue); @@ -388,14 +388,24 @@ private bool EvaluateUserCondition(UserCondition condition, string contextSalt, case UserComparator.DateTimeAfter: if (!double.TryParse(userAttributeValue!.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out number)) { - error = HandleInvalidNumberUserAttribute(condition, context.Key, userAttributeName, userAttributeValue, isDateTime: true); + error = HandleInvalidUserAttribute(condition, context.Key, userAttributeName, $"'{userAttributeValue}' is not a valid Unix timestamp (number of seconds elapsed since Unix epoch)"); return false; } return EvaluateDateTimeRelation(number, condition.DoubleValue, before: comparator == UserComparator.DateTimeBefore); case UserComparator.SensitiveArrayContains: case UserComparator.SensitiveArrayNotContains: - return EvaluateSensitiveArrayContains(userAttributeValue!, condition.StringListValue, + string[]? array; + try { array = userAttributeValue!.Deserialize(); } + catch { array = null; } + + if (array is null) + { + error = HandleInvalidUserAttribute(condition, context.Key, userAttributeName, $"'{userAttributeValue}' is not a valid JSON string array"); + return false; + } + + return EvaluateSensitiveArrayContains(array, condition.StringListValue, EnsureConfigJsonSalt(context.Setting.ConfigJsonSalt), contextSalt, negate: comparator == UserComparator.SensitiveArrayNotContains); default: @@ -560,25 +570,17 @@ private static bool EvaluateDateTimeRelation(double number, double? comparisonVa return before ? number < number2 : number > number2; } - private static bool EvaluateSensitiveArrayContains(string csvText, string[]? comparisonValues, string configJsonSalt, string contextSalt, bool negate) + private static bool EvaluateSensitiveArrayContains(string[] array, string[]? comparisonValues, string configJsonSalt, string contextSalt, bool negate) { EnsureComparisonValue(comparisonValues); - int index; - for (var startIndex = 0; startIndex < csvText.Length; startIndex = index + 1) + for (var i = 0; i < array.Length; i++) { - index = csvText.IndexOf(',', startIndex); - if (index < 0) - { - index = csvText.Length; - } + var hash = HashComparisonValue(array[i].AsSpan(), configJsonSalt, contextSalt); - var slice = csvText.AsSpan(startIndex, index - startIndex).Trim(); - var hash = HashComparisonValue(slice, configJsonSalt, contextSalt); - - for (var i = 0; i < comparisonValues.Length; i++) + for (var j = 0; j < comparisonValues.Length; j++) { - if (hash.Equals(hexString: EnsureComparisonValue(comparisonValues[i]).AsSpan())) + if (hash.Equals(hexString: EnsureComparisonValue(comparisonValues[j]).AsSpan())) { return !negate; } @@ -719,18 +721,8 @@ private static T EnsureComparisonValue([NotNull] T? value) return value ?? throw new InvalidOperationException("Comparison value is missing or invalid."); } - private string HandleInvalidSemVerUserAttribute(UserCondition condition, string key, string userAttributeName, string userAttributeValue) - { - var reason = $"'{userAttributeValue}' is not a valid semantic version"; - this.logger.UserObjectAttributeIsInvalid(condition.ToString(), key, reason, userAttributeName); - return string.Format(CultureInfo.InvariantCulture, InvalidUserAttributeError, userAttributeName, reason); - } - - private string HandleInvalidNumberUserAttribute(UserCondition condition, string key, string userAttributeName, string userAttributeValue, bool isDateTime = false) + private string HandleInvalidUserAttribute(UserCondition condition, string key, string userAttributeName, string reason) { - var reason = isDateTime - ? $"'{userAttributeValue}' is not a valid Unix timestamp (number of seconds elapsed since Unix epoch)" - : $"'{userAttributeValue}' is not a valid decimal number"; this.logger.UserObjectAttributeIsInvalid(condition.ToString(), key, reason, userAttributeName); return string.Format(CultureInfo.InvariantCulture, InvalidUserAttributeError, userAttributeName, reason); } diff --git a/src/ConfigCatClient/Extensions/SerializationExtensions.cs b/src/ConfigCatClient/Extensions/SerializationExtensions.cs index a4612c65..684b3818 100644 --- a/src/ConfigCatClient/Extensions/SerializationExtensions.cs +++ b/src/ConfigCatClient/Extensions/SerializationExtensions.cs @@ -2,6 +2,7 @@ using System.IO; using Newtonsoft.Json; #else +using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; #endif @@ -12,6 +13,11 @@ internal static class SerializationExtensions { #if USE_NEWTONSOFT_JSON private static readonly JsonSerializer Serializer = JsonSerializer.Create(); +#else + private static readonly JsonSerializerOptions SerializerOptions = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; #endif public static T? Deserialize(this string json) => json.AsSpan().Deserialize(); @@ -46,7 +52,7 @@ public static string Serialize(this T objectToSerialize) #if USE_NEWTONSOFT_JSON return JsonConvert.SerializeObject(objectToSerialize); #else - return JsonSerializer.Serialize(objectToSerialize); + return JsonSerializer.Serialize(objectToSerialize, SerializerOptions); #endif } } diff --git a/src/ConfigCatClient/ProjectConfig.cs b/src/ConfigCatClient/ProjectConfig.cs index d4172497..87b22cf8 100644 --- a/src/ConfigCatClient/ProjectConfig.cs +++ b/src/ConfigCatClient/ProjectConfig.cs @@ -14,6 +14,7 @@ internal sealed class ProjectConfig public ProjectConfig(string? configJson, Config? config, DateTime timeStamp, string? httpETag) { Debug.Assert(!(configJson is null ^ config is null), $"{nameof(configJson)} and {nameof(config)} must be both null or both not null."); + Debug.Assert(timeStamp.Kind == DateTimeKind.Utc, "Timestamp must be a UTC datetime."); ConfigJson = configJson; Config = config; diff --git a/src/ConfigCatClient/User.cs b/src/ConfigCatClient/User.cs index 603f15cf..e92fe4e2 100644 --- a/src/ConfigCatClient/User.cs +++ b/src/ConfigCatClient/User.cs @@ -1,4 +1,8 @@ +using System; using System.Collections.Generic; +using System.Globalization; +using ConfigCat.Client.Utils; +using System.Linq; #if USE_NEWTONSOFT_JSON using Newtonsoft.Json; @@ -13,6 +17,47 @@ namespace ConfigCat.Client; /// public class User { + /// + /// Converts the specified value to the format expected by datetime comparison operators (BEFORE/AFTER). + /// + /// The value to convert. + /// The User Object attribute value in the expected format. + public static string AttributeValueFrom(DateTimeOffset dateTime) + { + var unixTimeSeconds = DateTimeUtils.ToUnixTimeMilliseconds(dateTime.UtcDateTime) / 1000.0; + return unixTimeSeconds.ToString("0.###", CultureInfo.InvariantCulture); + } + + /// + /// Converts the specified value to the format expected by number comparison operators. + /// + /// The value to convert. + /// The User Object attribute value in the expected format. + public static string AttributeValueFrom(double number) + { + return number.ToString("g", CultureInfo.InvariantCulture); // format "g" allows scientific notation as well + } + + /// + /// Converts the specified items to the format expected by array comparison operators (ARRAY CONTAINS ANY OF/ARRAY NOT CONTAINS ANY OF). + /// + /// The items to convert. + /// The User Object attribute value in the expected format. + public static string AttributeValueFrom(params string[] items) + { + return AttributeValueFrom(items.AsEnumerable()); + } + + /// + /// Converts the specified items to the format expected by array comparison operators (ARRAY CONTAINS ANY OF/ARRAY NOT CONTAINS ANY OF). + /// + /// The items to convert. + /// The User Object attribute value in the expected format. + public static string AttributeValueFrom(IEnumerable items) + { + return (items ?? throw new ArgumentNullException("items")).Serialize(); + } + internal const string DefaultIdentifierValue = ""; /// diff --git a/src/ConfigCatClient/Utils/DateTimeUtils.cs b/src/ConfigCatClient/Utils/DateTimeUtils.cs index be7bd514..38a58ece 100644 --- a/src/ConfigCatClient/Utils/DateTimeUtils.cs +++ b/src/ConfigCatClient/Utils/DateTimeUtils.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Globalization; namespace ConfigCat.Client.Utils; @@ -7,6 +8,9 @@ internal static class DateTimeUtils { public static long ToUnixTimeMilliseconds(this DateTime dateTime) { + // NOTE: Internally we should always work with UTC datetime values (as DateTimeKind.Unspecified can lead to incorrect results). + Debug.Assert(dateTime.Kind == DateTimeKind.Utc, "Non-UTC datetime encountered."); + #if !NET45 return new DateTimeOffset(dateTime).ToUnixTimeMilliseconds(); #else