diff --git a/build/install-dotnet.ps1 b/build/install-dotnet.ps1 index 11552a78..194dbf5a 100644 --- a/build/install-dotnet.ps1 +++ b/build/install-dotnet.ps1 @@ -1,8 +1,10 @@ -# Installs .NET 6 and .NET 7 for CI/CD environment +# Installs .NET 6, .NET 7, and .NET 8 for CI/CD environment # see: https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-install-script#examples [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; &([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Channel 6.0 -&([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Channel 7.0 \ No newline at end of file +&([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Channel 7.0 + +&([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Channel 8.0 diff --git a/examples/ConfigStoreDemo/ConfigStoreDemo.csproj b/examples/ConfigStoreDemo/ConfigStoreDemo.csproj index 3b11aa18..caab5885 100644 --- a/examples/ConfigStoreDemo/ConfigStoreDemo.csproj +++ b/examples/ConfigStoreDemo/ConfigStoreDemo.csproj @@ -1,7 +1,7 @@  false - net7.0 + net8.0 diff --git a/examples/ConsoleAppWithFailOver/ConsoleAppWithFailOver.csproj b/examples/ConsoleAppWithFailOver/ConsoleAppWithFailOver.csproj index dcff4691..ff4b0398 100644 --- a/examples/ConsoleAppWithFailOver/ConsoleAppWithFailOver.csproj +++ b/examples/ConsoleAppWithFailOver/ConsoleAppWithFailOver.csproj @@ -2,11 +2,11 @@ Exe - net7.0 + net8.0 - + diff --git a/examples/ConsoleApplication/ConsoleApplication.csproj b/examples/ConsoleApplication/ConsoleApplication.csproj index 2bc2e9ec..be38de42 100644 --- a/examples/ConsoleApplication/ConsoleApplication.csproj +++ b/examples/ConsoleApplication/ConsoleApplication.csproj @@ -3,7 +3,7 @@ false Exe - net7.0 + net8.0 diff --git a/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj b/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj index 5107c108..c081cacd 100644 --- a/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj +++ b/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj @@ -2,7 +2,7 @@ - net6.0;net7.0 + net6.0;net7.0;net8.0 Microsoft.Azure.AppConfiguration.AspNetCore allows developers to use Microsoft Azure App Configuration service as a configuration source in their applications. This package adds additional features for ASP.NET Core applications to the existing package Microsoft.Extensions.Configuration.AzureAppConfiguration. true false @@ -21,7 +21,7 @@ - 7.1.0 + 7.2.0 @@ -34,7 +34,8 @@ ..\..\AzureAppConfigurationRules.ruleset - True + true + true diff --git a/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj b/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj index 2dbb3aac..90831b4d 100644 --- a/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj +++ b/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj @@ -2,7 +2,7 @@ - net6.0;net7.0 + net6.0;net7.0;net8.0 Microsoft.Azure.AppConfiguration.Functions.Worker allows developers to use the Microsoft Azure App Configuration service as a configuration source in their applications. This package adds additional features to the existing package Microsoft.Extensions.Configuration.AzureAppConfiguration for .NET Azure Functions running in an isolated process. true false @@ -24,7 +24,7 @@ - 7.1.0 + 7.2.0 @@ -37,7 +37,8 @@ ..\..\AzureAppConfigurationRules.ruleset - True + true + true diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs index 21b5165f..dd5cddbc 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs @@ -29,22 +29,12 @@ public AzureKeyVaultKeyValueAdapter(AzureKeyVaultSecretProvider secretProvider) /// returns the keyname and actual value public async Task>> ProcessKeyValue(ConfigurationSetting setting, Logger logger, CancellationToken cancellationToken) { - KeyVaultSecretReference secretRef; - - // Content validation - try - { - secretRef = JsonSerializer.Deserialize(setting.Value); - } - catch (JsonException e) - { - throw CreateKeyVaultReferenceException("Invalid Key Vault reference.", setting, e, null); - } + string secretRefUri = ParseSecretReferenceUri(setting); // Uri validation - if (string.IsNullOrEmpty(secretRef.Uri) || !Uri.TryCreate(secretRef.Uri, UriKind.Absolute, out Uri secretUri) || !KeyVaultSecretIdentifier.TryCreate(secretUri, out KeyVaultSecretIdentifier secretIdentifier)) + if (string.IsNullOrEmpty(secretRefUri) || !Uri.TryCreate(secretRefUri, UriKind.Absolute, out Uri secretUri) || !KeyVaultSecretIdentifier.TryCreate(secretUri, out KeyVaultSecretIdentifier secretIdentifier)) { - throw CreateKeyVaultReferenceException("Invalid Key vault secret identifier.", setting, null, secretRef); + throw CreateKeyVaultReferenceException("Invalid Key vault secret identifier.", setting, null, secretRefUri); } string secret; @@ -55,11 +45,11 @@ public async Task>> ProcessKeyValue(Con } catch (Exception e) when (e is UnauthorizedAccessException || (e.Source?.Equals(AzureIdentityAssemblyName, StringComparison.OrdinalIgnoreCase) ?? false)) { - throw CreateKeyVaultReferenceException(e.Message, setting, e, secretRef); + throw CreateKeyVaultReferenceException(e.Message, setting, e, secretRefUri); } catch (Exception e) when (e is RequestFailedException || ((e as AggregateException)?.InnerExceptions?.All(e => e is RequestFailedException) ?? false)) { - throw CreateKeyVaultReferenceException("Key vault error.", setting, e, secretRef); + throw CreateKeyVaultReferenceException("Key vault error.", setting, e, secretRefUri); } return new KeyValuePair[] @@ -68,7 +58,7 @@ public async Task>> ProcessKeyValue(Con }; } - KeyVaultReferenceException CreateKeyVaultReferenceException(string message, ConfigurationSetting setting, Exception inner, KeyVaultSecretReference secretRef = null) + KeyVaultReferenceException CreateKeyVaultReferenceException(string message, ConfigurationSetting setting, Exception inner, string secretRefUri = null) { return new KeyVaultReferenceException(message, inner) { @@ -76,7 +66,7 @@ KeyVaultReferenceException CreateKeyVaultReferenceException(string message, Conf Label = setting.Label, Etag = setting.ETag.ToString(), ErrorCode = (inner as RequestFailedException)?.ErrorCode, - SecretIdentifier = secretRef?.Uri + SecretIdentifier = secretRefUri }; } @@ -102,5 +92,50 @@ public bool NeedsRefresh() { return _secretProvider.ShouldRefreshKeyVaultSecrets(); } + + private string ParseSecretReferenceUri(ConfigurationSetting setting) + { + string secretRefUri = null; + + try + { + var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(setting.Value)); + + if (reader.Read() && reader.TokenType != JsonTokenType.StartObject) + { + throw CreateKeyVaultReferenceException(ErrorMessages.InvalidKeyVaultReference, setting, null, null); + } + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + if (reader.GetString() == KeyVaultConstants.SecretReferenceUriJsonPropertyName) + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + secretRefUri = reader.GetString(); + } + else + { + throw CreateKeyVaultReferenceException(ErrorMessages.InvalidKeyVaultReference, setting, null, null); + } + } + else + { + reader.Skip(); + } + } + } + catch (JsonException e) + { + throw CreateKeyVaultReferenceException(ErrorMessages.InvalidKeyVaultReference, setting, e, null); + } + + return secretRefUri; + } } } \ No newline at end of file diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/KeyVaultConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/KeyVaultConstants.cs index ec55e115..1309e58c 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/KeyVaultConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/KeyVaultConstants.cs @@ -6,5 +6,7 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault internal class KeyVaultConstants { public const string ContentType = "application/vnd.microsoft.appconfig.keyvaultref+json"; + + public const string SecretReferenceUriJsonPropertyName = "uri"; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/KeyVaultSecretReference.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/KeyVaultSecretReference.cs deleted file mode 100644 index 40efc2b1..00000000 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/KeyVaultSecretReference.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -using System.Text.Json.Serialization; - -namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault -{ - internal class KeyVaultSecretReference - { - [JsonPropertyName("uri")] - public string Uri { get; set; } - } -} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs index 0c19cccf..61b4bb3b 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs @@ -7,5 +7,8 @@ internal class ErrorMessages { public const string CacheExpirationTimeTooShort = "The cache expiration time cannot be less than {0} milliseconds."; public const string SecretRefreshIntervalTooShort = "The secret refresh interval cannot be less than {0} milliseconds."; + public const string FeatureFlagInvalidJsonProperty = "Invalid property '{0}' for feature flag. Key: '{1}'. Found type: '{2}'. Expected type: '{3}'."; + public const string FeatureFlagInvalidFormat = "Invalid json format for feature flag. Key: '{0}'"; + public const string InvalidKeyVaultReference = "Invalid Key Vault reference."; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/EventGridEventExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/EventGridEventExtensions.cs index bc91de1e..7ac04ca9 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/EventGridEventExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/EventGridEventExtensions.cs @@ -31,29 +31,59 @@ public static bool TryCreatePushNotification(this EventGridEvent eventGridEvent, if (Uri.TryCreate(eventGridEvent.Subject, UriKind.Absolute, out Uri resourceUri)) { - JsonElement eventGridEventData; + string syncToken = null; try { - eventGridEventData = JsonDocument.Parse(eventGridEvent.Data.ToString()).RootElement; + var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(eventGridEvent.Data.ToString())); + + if (reader.Read() && reader.TokenType != JsonTokenType.StartObject) + { + return false; + } + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + if (reader.GetString() == SyncTokenPropertyName) + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + syncToken = reader.GetString(); + } + else + { + return false; + } + } + else + { + reader.Skip(); + } + } } catch (JsonException) { return false; } - if (eventGridEventData.ValueKind == JsonValueKind.Object && - eventGridEventData.TryGetProperty(SyncTokenPropertyName, out JsonElement syncTokenJson) && - syncTokenJson.ValueKind == JsonValueKind.String) + if (syncToken == null) { - pushNotification = new PushNotification() - { - SyncToken = syncTokenJson.GetString(), - EventType = eventGridEvent.EventType, - ResourceUri = resourceUri - }; - return true; + return false; } + + pushNotification = new PushNotification() + { + SyncToken = syncToken, + EventType = eventGridEvent.EventType, + ResourceUri = resourceUri + }; + + return true; } return false; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/Utf8JsonReaderExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/Utf8JsonReaderExtensions.cs deleted file mode 100644 index f5e2a01e..00000000 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/Utf8JsonReaderExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -using System.Text.Json; - -namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions -{ - internal static class Utf8JsonReaderExtensions - { - public static string ReadAsString(this Utf8JsonReader reader) - { - if (reader.Read()) - { - return reader.GetString(); - } - - return null; - } - } -} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/ClientFilter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/ClientFilter.cs index efd4023d..80aed990 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/ClientFilter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/ClientFilter.cs @@ -2,16 +2,13 @@ // Licensed under the MIT license. // using System.Text.Json; -using System.Text.Json.Serialization; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement { internal class ClientFilter { - [JsonPropertyName("name")] public string Name { get; set; } - [JsonPropertyName("parameters")] public JsonElement Parameters { get; set; } } -} +} \ No newline at end of file diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureConditions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureConditions.cs index 6927d310..ec29c199 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureConditions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureConditions.cs @@ -2,16 +2,13 @@ // Licensed under the MIT license. // using System.Collections.Generic; -using System.Text.Json.Serialization; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement { internal class FeatureConditions { - [JsonPropertyName("client_filters")] public List ClientFilters { get; set; } = new List(); - [JsonPropertyName("requirement_type")] public string RequirementType { get; set; } } -} +} \ No newline at end of file diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlag.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlag.cs index 3be1d3ce..a26fb6cc 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlag.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlag.cs @@ -1,19 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // -using System.Text.Json.Serialization; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement { internal class FeatureFlag { - [JsonPropertyName("id")] public string Id { get; set; } - [JsonPropertyName("enabled")] public bool Enabled { get; set; } - [JsonPropertyName("conditions")] public FeatureConditions Conditions { get; set; } } -} +} \ No newline at end of file diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs index b0e09723..e4775950 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs @@ -10,5 +10,13 @@ internal class FeatureManagementConstants public const string SectionName = "FeatureManagement"; public const string EnabledFor = "EnabledFor"; public const string RequirementType = "RequirementType"; + + public const string EnabledJsonPropertyName = "enabled"; + public const string IdJsonPropertyName = "id"; + public const string ConditionsJsonPropertyName = "conditions"; + public const string RequirementTypeJsonPropertyName = "requirement_type"; + public const string ClientFiltersJsonPropertyName = "client_filters"; + public const string NameJsonPropertyName = "name"; + public const string ParametersJsonPropertyName = "parameters"; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs index b5138a46..a4f64d6b 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs @@ -22,58 +22,48 @@ public FeatureManagementKeyValueAdapter(FeatureFilterTracing featureFilterTracin public Task>> ProcessKeyValue(ConfigurationSetting setting, Logger logger, CancellationToken cancellationToken) { - FeatureFlag featureFlag; - try - { - featureFlag = JsonSerializer.Deserialize(setting.Value); - } - catch (JsonException e) - { - throw new FormatException(setting.Key, e); - } + FeatureFlag featureFlag = ParseFeatureFlag(setting.Key, setting.Value); var keyValues = new List>(); - if (featureFlag.Enabled) + if (!string.IsNullOrEmpty(featureFlag.Id)) { - //if (featureFlag.Conditions?.ClientFilters == null) - if (featureFlag.Conditions?.ClientFilters == null || !featureFlag.Conditions.ClientFilters.Any()) // workaround since we are not yet setting client filters to null + if (featureFlag.Enabled) { - // - // Always on - keyValues.Add(new KeyValuePair($"{FeatureManagementConstants.SectionName}:{featureFlag.Id}", true.ToString())); - } - else - { - // - // Conditionally on based on feature filters - for (int i = 0; i < featureFlag.Conditions.ClientFilters.Count; i++) + if (featureFlag.Conditions?.ClientFilters == null || !featureFlag.Conditions.ClientFilters.Any()) + { + keyValues.Add(new KeyValuePair($"{FeatureManagementConstants.SectionName}:{featureFlag.Id}", true.ToString())); + } + else { - ClientFilter clientFilter = featureFlag.Conditions.ClientFilters[i]; + for (int i = 0; i < featureFlag.Conditions.ClientFilters.Count; i++) + { + ClientFilter clientFilter = featureFlag.Conditions.ClientFilters[i]; - _featureFilterTracing.UpdateFeatureFilterTracing(clientFilter.Name); + _featureFilterTracing.UpdateFeatureFilterTracing(clientFilter.Name); - keyValues.Add(new KeyValuePair($"{FeatureManagementConstants.SectionName}:{featureFlag.Id}:{FeatureManagementConstants.EnabledFor}:{i}:Name", clientFilter.Name)); + keyValues.Add(new KeyValuePair($"{FeatureManagementConstants.SectionName}:{featureFlag.Id}:{FeatureManagementConstants.EnabledFor}:{i}:Name", clientFilter.Name)); - foreach (KeyValuePair kvp in new JsonFlattener().FlattenJson(clientFilter.Parameters)) - { - keyValues.Add(new KeyValuePair($"{FeatureManagementConstants.SectionName}:{featureFlag.Id}:{FeatureManagementConstants.EnabledFor}:{i}:Parameters:{kvp.Key}", kvp.Value)); + foreach (KeyValuePair kvp in new JsonFlattener().FlattenJson(clientFilter.Parameters)) + { + keyValues.Add(new KeyValuePair($"{FeatureManagementConstants.SectionName}:{featureFlag.Id}:{FeatureManagementConstants.EnabledFor}:{i}:Parameters:{kvp.Key}", kvp.Value)); + } } - } - // - // process RequirementType only when filters are not empty - if (featureFlag.Conditions.RequirementType != null) - { - keyValues.Add(new KeyValuePair( - $"{FeatureManagementConstants.SectionName}:{featureFlag.Id}:{FeatureManagementConstants.RequirementType}", - featureFlag.Conditions.RequirementType)); + // + // process RequirementType only when filters are not empty + if (featureFlag.Conditions.RequirementType != null) + { + keyValues.Add(new KeyValuePair( + $"{FeatureManagementConstants.SectionName}:{featureFlag.Id}:{FeatureManagementConstants.RequirementType}", + featureFlag.Conditions.RequirementType)); + } } } - } - else - { - keyValues.Add(new KeyValuePair($"{FeatureManagementConstants.SectionName}:{featureFlag.Id}", false.ToString())); + else + { + keyValues.Add(new KeyValuePair($"{FeatureManagementConstants.SectionName}:{featureFlag.Id}", false.ToString())); + } } return Task.FromResult>>(keyValues); @@ -96,5 +86,251 @@ public bool NeedsRefresh() { return false; } + + private FormatException CreateFeatureFlagFormatException(string jsonPropertyName, string settingKey, string foundJsonValueKind, string expectedJsonValueKind) + { + return new FormatException(string.Format( + ErrorMessages.FeatureFlagInvalidJsonProperty, + jsonPropertyName, + settingKey, + foundJsonValueKind, + expectedJsonValueKind)); + } + + private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue) + { + FeatureFlag featureFlag = new FeatureFlag(); + + var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(settingValue)); + + try + { + if (reader.Read() && reader.TokenType != JsonTokenType.StartObject) + { + throw new FormatException(string.Format(ErrorMessages.FeatureFlagInvalidFormat, settingKey)); + } + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + string propertyName = reader.GetString(); + + switch (propertyName) + { + case FeatureManagementConstants.IdJsonPropertyName: + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + featureFlag.Id = reader.GetString(); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.IdJsonPropertyName, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.String.ToString()); + } + + break; + } + + case FeatureManagementConstants.EnabledJsonPropertyName: + { + if (reader.Read() && (reader.TokenType == JsonTokenType.False || reader.TokenType == JsonTokenType.True)) + { + featureFlag.Enabled = reader.GetBoolean(); + } + else if (reader.TokenType == JsonTokenType.String && bool.TryParse(reader.GetString(), out bool enabled)) + { + featureFlag.Enabled = enabled; + } + else + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.EnabledJsonPropertyName, + settingKey, + reader.TokenType.ToString(), + $"{JsonTokenType.True}' or '{JsonTokenType.False}"); + } + + break; + } + + case FeatureManagementConstants.ConditionsJsonPropertyName: + { + if (reader.Read() && reader.TokenType == JsonTokenType.StartObject) + { + featureFlag.Conditions = ParseFeatureConditions(ref reader, settingKey); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.ConditionsJsonPropertyName, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.StartObject.ToString()); + } + + break; + } + + default: + reader.Skip(); + + break; + } + } + } + catch (JsonException e) + { + throw new FormatException(settingKey, e); + } + + return featureFlag; + } + + private FeatureConditions ParseFeatureConditions(ref Utf8JsonReader reader, string settingKey) + { + var featureConditions = new FeatureConditions(); + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + string conditionsPropertyName = reader.GetString(); + + switch (conditionsPropertyName) + { + case FeatureManagementConstants.ClientFiltersJsonPropertyName: + { + if (reader.Read() && reader.TokenType == JsonTokenType.Null) + { + break; + } + else if (reader.TokenType == JsonTokenType.StartArray) + { + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType == JsonTokenType.StartObject) + { + ClientFilter clientFilter = ParseClientFilter(ref reader, settingKey); + + if (clientFilter.Name != null || + (clientFilter.Parameters.ValueKind == JsonValueKind.Object && + clientFilter.Parameters.EnumerateObject().Any())) + { + featureConditions.ClientFilters.Add(clientFilter); + } + } + } + } + else + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.ClientFiltersJsonPropertyName, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.StartArray.ToString()); + } + + break; + } + + case FeatureManagementConstants.RequirementTypeJsonPropertyName: + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + featureConditions.RequirementType = reader.GetString(); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.RequirementTypeJsonPropertyName, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.String.ToString()); + } + + break; + } + + default: + reader.Skip(); + + break; + } + } + + return featureConditions; + } + + private ClientFilter ParseClientFilter(ref Utf8JsonReader reader, string settingKey) + { + var clientFilter = new ClientFilter(); + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + string clientFiltersPropertyName = reader.GetString(); + + switch (clientFiltersPropertyName) + { + case FeatureManagementConstants.NameJsonPropertyName: + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + clientFilter.Name = reader.GetString(); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.NameJsonPropertyName, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.String.ToString()); + } + + break; + } + + case FeatureManagementConstants.ParametersJsonPropertyName: + { + if (reader.Read() && reader.TokenType == JsonTokenType.StartObject) + { + clientFilter.Parameters = JsonDocument.ParseValue(ref reader).RootElement; + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.ParametersJsonPropertyName, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.StartObject.ToString()); + } + + break; + } + + default: + reader.Skip(); + + break; + } + } + + return clientFilter; + } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs index 0a47923f..2c11d423 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs @@ -30,10 +30,15 @@ public Task>> ProcessKeyValue(Configura } string rootJson = $"{{\"{setting.Key}\":{setting.Value}}}"; - JsonElement jsonData; + + List> keyValuePairs = new List>(); + try { - jsonData = JsonSerializer.Deserialize(rootJson); + using (JsonDocument document = JsonDocument.Parse(rootJson)) + { + keyValuePairs = new JsonFlattener().FlattenJson(document.RootElement); + } } catch (JsonException) { @@ -41,7 +46,7 @@ public Task>> ProcessKeyValue(Configura return Task.FromResult>>(new[] { new KeyValuePair(setting.Key, setting.Value) }); } - return Task.FromResult>>(new JsonFlattener().FlattenJson(jsonData)); + return Task.FromResult>>(keyValuePairs); } public bool CanProcess(ConfigurationSetting setting) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj index 6d6ddfd7..466c58be 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -14,7 +14,7 @@ - + @@ -34,7 +34,7 @@ - 7.1.0 + 7.2.0 @@ -47,7 +47,8 @@ ..\..\AzureAppConfigurationRules.ruleset - True + true + true diff --git a/tests/Tests.AzureAppConfiguration.AspNetCore/Tests.AzureAppConfiguration.AspNetCore.csproj b/tests/Tests.AzureAppConfiguration.AspNetCore/Tests.AzureAppConfiguration.AspNetCore.csproj index ef89659c..a8cd3b51 100644 --- a/tests/Tests.AzureAppConfiguration.AspNetCore/Tests.AzureAppConfiguration.AspNetCore.csproj +++ b/tests/Tests.AzureAppConfiguration.AspNetCore/Tests.AzureAppConfiguration.AspNetCore.csproj @@ -1,7 +1,7 @@  - net6.0;net7.0 + net6.0;net7.0;net8.0 8.0 false true @@ -10,11 +10,14 @@ - - - - - + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/tests/Tests.AzureAppConfiguration.Functions.Worker/Tests.AzureAppConfiguration.Functions.Worker.csproj b/tests/Tests.AzureAppConfiguration.Functions.Worker/Tests.AzureAppConfiguration.Functions.Worker.csproj index 0340afd5..f5af5a3f 100644 --- a/tests/Tests.AzureAppConfiguration.Functions.Worker/Tests.AzureAppConfiguration.Functions.Worker.csproj +++ b/tests/Tests.AzureAppConfiguration.Functions.Worker/Tests.AzureAppConfiguration.Functions.Worker.csproj @@ -1,7 +1,7 @@  - net6.0;net7.0 + net6.0;net7.0;net8.0 8.0 false true @@ -10,11 +10,14 @@ - - - - - + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs index 4eb83a90..99348464 100644 --- a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs @@ -9,7 +9,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.AzureAppConfiguration; using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; -using Microsoft.Extensions.Options; using Moq; using System; using System.Collections.Generic; @@ -85,7 +84,239 @@ public class FeatureManagementTests contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")); - List _featureFlagCollection = new List + List _nullOrMissingConditionsFeatureFlagCollection = new List + { + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "NullParameters", + value: @" + { + ""id"": ""NullParameters"", + ""description"": """", + ""display_name"": ""Null Parameters"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""Filter"", + ""parameters"": null + } + ] + } + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "NullConditions", + value: @" + { + ""id"": ""NullConditions"", + ""description"": """", + ""display_name"": ""Null Conditions"", + ""enabled"": true, + ""conditions"": null + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "NullClientFilters", + value: @" + { + ""id"": ""NullClientFilters"", + ""description"": """", + ""display_name"": ""Null Client Filters"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": null + } + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "NoConditions", + value: @" + { + ""id"": ""NoConditions"", + ""description"": """", + ""display_name"": ""No Conditions"", + ""enabled"": true + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "EmptyConditions", + value: @" + { + ""id"": ""EmptyConditions"", + ""description"": """", + ""display_name"": ""Empty Conditions"", + ""conditions"": {}, + ""enabled"": true + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "EmptyClientFilter", + value: @" + { + ""id"": ""EmptyClientFilter"", + ""description"": """", + ""display_name"": ""Empty Client Filter"", + ""conditions"": { + ""client_filters"": [ + {} + ] + }, + ""enabled"": true + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")) + }; + + List _validFormatFeatureFlagCollection = new List + { + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "AdditionalProperty", + value: @" + { + ""id"": ""AdditionalProperty"", + ""description"": ""Should not throw an exception, additional properties are skipped."", + ""ignored_object"": { + ""id"": false + }, + ""enabled"": true, + ""conditions"": {} + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "DuplicateProperty", + value: @" + { + ""id"": ""DuplicateProperty"", + ""description"": ""Should not throw an exception, last of duplicate properties will win."", + ""enabled"": false, + ""enabled"": true, + ""conditions"": {} + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "AllowNullRequirementType", + value: @" + { + ""id"": ""AllowNullRequirementType"", + ""description"": ""Should not throw an exception, requirement type is allowed as null."", + ""enabled"": true, + ""conditions"": { + ""requirement_type"": null + } + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")) + }; + + List _invalidFormatFeatureFlagCollection = new List + { + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "MissingClosingBracket1", + value: @" + { + ""id"": ""MissingClosingBracket1"", + ""description"": ""Should throw an exception, invalid end of json."", + ""enabled"": true, + ""conditions"": {} + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "MissingClosingBracket2", + value: @" + { + ""id"": ""MissingClosingBracket2"", + ""description"": ""Should throw an exception, invalid end of conditions object."", + ""conditions"": {, + ""enabled"": true + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "MissingClosingBracket3", + value: @" + { + ""id"": ""MissingClosingBracket3"", + ""description"": ""Should throw an exception, no closing bracket on client filters array."", + ""conditions"": { + ""client_filters"": [ + }, + ""enabled"": true + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "MissingOpeningBracket1", + value: @" + { + ""id"": ""MissingOpeningBracket1"", + ""description"": ""Should throw an exception, no opening bracket on conditions object."", + ""conditions"": }, + ""enabled"": true + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "MissingOpeningBracket2", + value: @" + { + ""id"": ""MissingOpeningBracket2"", + ""description"": ""Should throw an exception, no opening bracket on client filters array."", + ""conditions"": { + ""client_filters"": ] + }, + ""enabled"": true + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")) + }; + + List _featureFlagCollection = new List { ConfigurationModelFactory.ConfigurationSetting( key: FeatureManagementConstants.FeatureFlagMarker + "App1_Feature1", @@ -477,6 +708,124 @@ public void SelectFeatureFlags() Assert.Null(config["FeatureManagement:App2_Feature2"]); } + [Fact] + public void TestNullAndMissingValuesForConditions() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + var cacheExpiration = TimeSpan.FromSeconds(1); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(_nullOrMissingConditionsFeatureFlagCollection)); + + var testClient = mockClient.Object; + + // Makes sure that adapter properly processes values and doesn't throw an exception + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(testClient); + options.UseFeatureFlags(ff => + { + ff.CacheExpirationInterval = cacheExpiration; + ff.Select(KeyFilter.Any); + }); + }) + .Build(); + + Assert.Null(config["FeatureManagement:NullConditions:EnabledFor"]); + Assert.Equal("Filter", config["FeatureManagement:NullParameters:EnabledFor:0:Name"]); + Assert.Null(config["FeatureManagement:NullParameters:EnabledFor:0:Parameters"]); + Assert.Null(config["FeatureManagement:NullClientFilters:EnabledFor"]); + Assert.Null(config["FeatureManagement:NoConditions:EnabledFor"]); + Assert.Null(config["FeatureManagement:EmptyConditions:EnabledFor"]); + Assert.Null(config["FeatureManagement:EmptyClientFilter:EnabledFor"]); + } + + [Fact] + public void InvalidFeatureFlagFormatsThrowFormatException() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + var cacheExpiration = TimeSpan.FromSeconds(1); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns((Func)GetTestKeys); + + MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) + { + var copy = new List(); + var newSetting = _invalidFormatFeatureFlagCollection.FirstOrDefault(s => s.Key == selector.KeyFilter); + if (newSetting != null) + copy.Add(TestHelpers.CloneSetting(newSetting)); + return new MockAsyncPageable(copy); + }; + + var testClient = mockClient.Object; + + foreach (ConfigurationSetting setting in _invalidFormatFeatureFlagCollection) + { + void action() => new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Select("_"); + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(testClient); + options.UseFeatureFlags(ff => + { + ff.CacheExpirationInterval = cacheExpiration; + ff.Select(setting.Key.Substring(FeatureManagementConstants.FeatureFlagMarker.Length)); + }); + }) + .Build(); + + // Each of the feature flags should throw an exception + Assert.Throws(action); + } + } + + [Fact] + public void AlternateValidFeatureFlagFormats() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + var cacheExpiration = TimeSpan.FromSeconds(1); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns((Func)GetTestKeys); + + MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) + { + var copy = new List(); + var newSetting = _validFormatFeatureFlagCollection.FirstOrDefault(s => s.Key == selector.KeyFilter); + if (newSetting != null) + copy.Add(TestHelpers.CloneSetting(newSetting)); + return new MockAsyncPageable(copy); + }; + + var testClient = mockClient.Object; + + foreach (ConfigurationSetting setting in _validFormatFeatureFlagCollection) + { + string flagKey = setting.Key.Substring(FeatureManagementConstants.FeatureFlagMarker.Length); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Select("_"); + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(testClient); + options.UseFeatureFlags(ff => + { + ff.CacheExpirationInterval = cacheExpiration; + ff.Select(flagKey); + }); + }) + .Build(); + + // None of the feature flags should throw an exception, and the flag should be loaded like normal + Assert.Equal("True", config[$"FeatureManagement:{flagKey}"]); + } + } + [Fact] public void MultipleSelectsInSameUseFeatureFlags() { diff --git a/tests/Tests.AzureAppConfiguration/JsonContentTypeTests.cs b/tests/Tests.AzureAppConfiguration/JsonContentTypeTests.cs index 7723a612..91cb03a8 100644 --- a/tests/Tests.AzureAppConfiguration/JsonContentTypeTests.cs +++ b/tests/Tests.AzureAppConfiguration/JsonContentTypeTests.cs @@ -28,20 +28,15 @@ public void JsonContentTypeTests_CompareJsonSettingsBetweenAppConfigAndJsonFile( var appconfigSettings = new ConfigurationBuilder() .AddAzureAppConfiguration(options => options.ClientManager = mockClientManager) - .Build() - .AsEnumerable(); + .Build(); var jsonSettings = new ConfigurationBuilder() .AddJsonFile(jsonFilePath) - .Build() - .AsEnumerable(); - - Assert.Equal(jsonSettings.Count(), appconfigSettings.Count()); + .Build(); - foreach (KeyValuePair jsonSetting in jsonSettings) + foreach (KeyValuePair jsonSetting in jsonSettings.AsEnumerable()) { - KeyValuePair appconfigSetting = appconfigSettings.SingleOrDefault(x => x.Key == jsonSetting.Key); - Assert.Equal(jsonSetting, appconfigSetting); + Assert.Equal(jsonSettings.GetSection(jsonSetting.Key).Value, appconfigSettings.GetSection(jsonSetting.Key).Value); } } diff --git a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs b/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs index 0cccf3cd..88c15ffb 100644 --- a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs +++ b/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs @@ -12,6 +12,7 @@ using Moq; using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -82,6 +83,91 @@ public class KeyVaultReferenceTests contentType: KeyVaultConstants.ContentType + "; charset=utf-8"), }; + List _invalidJsonKvCollection = new List + { + ConfigurationModelFactory.ConfigurationSetting( + key:"MissingClosingBracket", + value: @" + { + ""uri"":""https://keyvault-theclassics.vault.azure.net/secrets/TheTrialSecret"" + ", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"), + contentType: KeyVaultConstants.ContentType + "; charset=utf-8"), + + ConfigurationModelFactory.ConfigurationSetting( + key:"MissingOpeningBracket", + value: @" + ""uri"":""https://keyvault-theclassics.vault.azure.net/secrets/TheTrialSecret"" + } + ", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"), + contentType: KeyVaultConstants.ContentType + "; charset=utf-8"), + + ConfigurationModelFactory.ConfigurationSetting( + key:"MissingUriInRootJson", + value: @" + { + { + ""uri"":""https://keyvault-theclassics.vault.azure.net/secrets/TheTrialSecret"" + } + } + ", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"), + contentType: KeyVaultConstants.ContentType + "; charset=utf-8"), + + ConfigurationModelFactory.ConfigurationSetting( + key:"UriValueInsideObject", + value: @" + { + { + ""uri"": { + ""https://keyvault-theclassics.vault.azure.net/secrets/TheTrialSecret"" + } + } + } + ", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"), + contentType: KeyVaultConstants.ContentType + "; charset=utf-8") + }; + + List _validJsonKvCollection = new List + { + ConfigurationModelFactory.ConfigurationSetting( + key:"AdditionalProperty1", + value: @" + { + ""additional_property"":""additional_property"", + ""uri"":""https://keyvault-theclassics.vault.azure.net/secrets/TheTrialSecret"" + } + ", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"), + contentType: KeyVaultConstants.ContentType + "; charset=utf-8"), + + ConfigurationModelFactory.ConfigurationSetting( + key:"AdditionalProperty2", + value: @" + { + ""uri"":""https://keyvault-theclassics.vault.azure.net/secrets/TheTrialSecret"", + ""additional_property"": { + ""inside_property"": ""inside_property"" + } + } + ", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"), + contentType: KeyVaultConstants.ContentType + "; charset=utf-8"), + + ConfigurationModelFactory.ConfigurationSetting( + key:"DuplicateUri", + value: @" + { + ""uri"":""https://keyvault-theclassics.vault.azure.net/certificates/TestCertificate"", + ""uri"":""https://keyvault-theclassics.vault.azure.net/secrets/TheTrialSecret"" + } + ", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"), + contentType: KeyVaultConstants.ContentType + "; charset=utf-8") + }; + [Fact] public void NotSecretIdentifierURI() { @@ -875,5 +961,95 @@ public void SecretsWithDifferentRefreshIntervals() // Validate that 3 calls were made to fetch secrets from KeyVault because the secret cache had expired for only one secret. mockSecretClient.Verify(client => client.GetSecretAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(3)); } + + + [Fact] + public void ThrowsWhenInvalidKeyVaultSecretReferenceJson() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + var cacheExpiration = TimeSpan.FromSeconds(1); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns((Func)GetTestKeys); + + var mockSecretClient = new Mock(MockBehavior.Strict); + mockSecretClient.SetupGet(client => client.VaultUri).Returns(new Uri("https://keyvault-theclassics.vault.azure.net")); + + MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) + { + var copy = new List(); + var newSetting = _invalidJsonKvCollection.FirstOrDefault(s => s.Key == selector.KeyFilter); + if (newSetting != null) + copy.Add(TestHelpers.CloneSetting(newSetting)); + return new MockAsyncPageable(copy); + }; + + var testClient = mockClient.Object; + + foreach (ConfigurationSetting setting in _invalidJsonKvCollection) + { + void action() => new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Select(setting.Key); + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(testClient); + options.ConfigureKeyVault(kv => + { + kv.Register(mockSecretClient.Object); + }); + }) + .Build(); + + // Each of the secret references should throw an exception when parsed + Assert.Throws(action); + } + } + + [Fact] + public void AlternateValidKeyVaultSecretReferenceJsons() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + var cacheExpiration = TimeSpan.FromSeconds(1); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns((Func)GetTestKeys); + + var mockSecretClient = new Mock(MockBehavior.Strict); + mockSecretClient.SetupGet(client => client.VaultUri).Returns(new Uri("https://keyvault-theclassics.vault.azure.net")); + mockSecretClient.Setup(client => client.GetSecretAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string name, string version, CancellationToken cancellationToken) => + Task.FromResult((Response)new MockResponse(new KeyVaultSecret(name, _secretValue)))); + + MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) + { + var copy = new List(); + var newSetting = _validJsonKvCollection.FirstOrDefault(s => s.Key == selector.KeyFilter); + if (newSetting != null) + copy.Add(TestHelpers.CloneSetting(newSetting)); + return new MockAsyncPageable(copy); + }; + + var testClient = mockClient.Object; + + foreach (ConfigurationSetting setting in _validJsonKvCollection) + { + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Select(setting.Key); + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(testClient); + options.ConfigureKeyVault(kv => + { + kv.Register(mockSecretClient.Object); + }); + }) + .Build(); + + // Each of the secret references should work as normal and use the uri + Assert.Equal(_secretValue, config[setting.Key]); + } + } } } diff --git a/tests/Tests.AzureAppConfiguration/PushRefreshTests.cs b/tests/Tests.AzureAppConfiguration/PushRefreshTests.cs index aa88fc86..c94e877f 100644 --- a/tests/Tests.AzureAppConfiguration/PushRefreshTests.cs +++ b/tests/Tests.AzureAppConfiguration/PushRefreshTests.cs @@ -170,10 +170,59 @@ public class PushRefreshTests "Microsoft.AppConfiguration.KeyValueModified", "2", BinaryData.FromString("{\"key\":\"searchQuery1\",\"etag\":\"etagValue1\",\"syncToken\":\"sn;CRAle3342\"}") ) + }, + + // Test that last syncToken is used + { + "sn;BYRte4456", + new EventGridEvent( + "https://store2.resource.io/kv/searchQuery2", + "Microsoft.AppConfiguration.KeyValueModified", "2", + BinaryData.FromString("{\"key\":\"searchQuery1\",\"etag\":\"etagValue1\",\"syncToken\":\"sn;CRAle3342\",\"syncToken\":\"sn;BYRte4456\"}") + ) } }; - ConfigurationSetting FirstKeyValue => _kvCollection.First(); + Dictionary _invalidFormatEventGridEvents = new Dictionary + { + { + "sn;Vxujfidne", + new EventGridEvent( + "https://store1.resource.io/kv/searchQuery1", + "Microsoft.AppConfiguration.KeyValueModified", "2", + BinaryData.FromString("\"key\":\"searchQuery1\",\"etag\":\"etagValue1\",\"syncToken\":\"sn;Vxujfidne\"}") + ) + }, + + { + "sn;AxRty78B", + new EventGridEvent( + "https://store1.resource.io/kv/searchQuery1", + "Microsoft.AppConfiguration.KeyValueModified", "2", + BinaryData.FromString("{\"key\":\"searchQuery1\",\"etag\":\"etagValue1\",\"syncToken\":\"sn;Vxujfidne\"") + ) + }, + + { + "sn;Ttylmable", + new EventGridEvent( + "https://store1.resource.io/kv/searchQuery2", + "Microsoft.AppConfiguration.KeyValueDeleted", "2", + BinaryData.FromString("{\"key\":\"searchQuery1\",\"etag\":\"etagValue1\",\"fake_property\":{\"syncToken\":\"sn;Ttylmable\"}}") + ) + }, + + { + "sn;CRAle3342", + new EventGridEvent( + "https://store2.resource.io/kv/searchQuery2", + "Microsoft.AppConfiguration.KeyValueModified", "2", + BinaryData.FromString("{\"fake_property\":{\"key\":\"searchQuery1\",\"etag\":\"etagValue1\",\"syncToken\":\"sn;CRAle3342\"}}") + ) + } + }; + + ConfigurationSetting FirstKeyValue => _kvCollection.First(); [Fact] public void ValidatePushNotificationCreation() @@ -191,6 +240,17 @@ public void ValidatePushNotificationCreation() } } + [Fact] + public void InvalidPushNotificationCreation() + { + foreach (KeyValuePair eventGridAndSync in _invalidFormatEventGridEvents) + { + EventGridEvent eventGridEvent = eventGridAndSync.Value; + + Assert.False(eventGridEvent.TryCreatePushNotification(out PushNotification _)); + } + } + [Fact] public void ProcessPushNotificationThrowsArgumentExceptions() { diff --git a/tests/Tests.AzureAppConfiguration/TestHelper.cs b/tests/Tests.AzureAppConfiguration/TestHelper.cs index fe1685d9..477b4429 100644 --- a/tests/Tests.AzureAppConfiguration/TestHelper.cs +++ b/tests/Tests.AzureAppConfiguration/TestHelper.cs @@ -115,16 +115,22 @@ public static ConfigurationSetting CloneSetting(ConfigurationSetting setting) public static List LoadJsonSettingsFromFile(string path) { List _kvCollection = new List(); - var valueArray = JsonSerializer.Deserialize(File.ReadAllText(path)).EnumerateArray(); - foreach (var setting in valueArray) + + using (JsonDocument document = JsonDocument.Parse(File.ReadAllText(path))) { - ConfigurationSetting kv = ConfigurationModelFactory - .ConfigurationSetting( - key: setting.GetProperty("key").ToString(), - value: setting.GetProperty("value").GetRawText(), - contentType: setting.GetProperty("contentType").ToString()); - _kvCollection.Add(kv); + var valueArray = document.RootElement.EnumerateArray(); + + foreach (var setting in valueArray) + { + ConfigurationSetting kv = ConfigurationModelFactory + .ConfigurationSetting( + key: setting.GetProperty("key").ToString(), + value: setting.GetProperty("value").GetRawText(), + contentType: setting.GetProperty("contentType").ToString()); + _kvCollection.Add(kv); + } } + return _kvCollection; } diff --git a/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj b/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj index 0dfcabbd..b0aef653 100644 --- a/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj +++ b/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj @@ -1,33 +1,33 @@  - net48;net6.0;net7.0 + net48;net6.0;net7.0;net8.0 8.0 false true ..\..\build\AzureAppConfiguration.snk false + false - - - - + + + - - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - + - - - - Always diff --git a/tests/Tests.AzureAppConfiguration/Tests.cs b/tests/Tests.AzureAppConfiguration/Tests.cs index b7e61978..c3b27124 100644 --- a/tests/Tests.AzureAppConfiguration/Tests.cs +++ b/tests/Tests.AzureAppConfiguration/Tests.cs @@ -239,7 +239,7 @@ public void TestUserAgentHeader() // 4. Contains the name and version of the App Configuration SDK package // 5. Contains the runtime information (target framework, OS description etc.) in the format set by the SDK // 6. Does not contain any additional components - string userAgentRegex = @"^Microsoft\.Extensions\.Configuration\.AzureAppConfiguration/\d+\.\d+\.\d+(-preview(\.\d+)?)?,azsdk-net-Data.AppConfiguration/[.+\w-]+ \([.;\w\s]+\)$"; + string userAgentRegex = @"^Microsoft\.Extensions\.Configuration\.AzureAppConfiguration/\d+\.\d+\.\d+(\+[a-z0-9]+)?(-preview(\.\d+)?)?,azsdk-net-Data.AppConfiguration/[.+\w-]+ \([.;\w\s]+\)$"; var response = new MockResponse(200); response.SetContent(SerializationHelpers.Serialize(_kvCollectionPageOne.ToArray(), TestHelpers.SerializeBatch));