From cba86ddb99f87998b7cf3a9d13a64569b3706186 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Tue, 27 May 2025 12:17:38 -0700 Subject: [PATCH 1/8] Add integration testing (#634) * add initial integration test file * update auth * add skipping * organize tests into unit and integration * update formatting * WIP create config store * edit * update integration setup * copilot updates * initial working version of integration tests * remove obsolete tags and update formatting * updating tests * format * remove skipping, add tests * add cleanup stale resource call on startup and dispose * add back removed line * use persistent resource group * add ordering of apis test * add refresh test * add tests * add failover test * adding snapshot tests first draft * fix snapshot tests * add key vault tests * add more keyvault tests * fix keyvault tests * add cleanup of key values/secrets/snapshot * fix key vault isolation * remove base exception catches * update ci.yml to work with integration tests and use github repo secrets * add id token write permission * update ci.yml step to create subscription file * edit ci.yml * fix github action * update comment in powershell script * remove unused packages * update comment, remove unused methods * add request tracing test * check for status code in catch, move cleanupstaleresources call to dispose * PR comments * comments * comments * update copilot instructions * update copilot instructions * fix try blocks to only surround throwing code * fix setuptestkeys, simplify comments and setup code * tagfilters integration test in progress * tagfilters integration test in progress * correct api version * fixed tagfilter test * fix requesttracing test * allow commas in json reader * stale threshold * remove copilot instructions * use outputhelper * fix cleanupstale * update setup method, remove flag --- .github/workflows/ci.yml | 10 + .gitignore | 3 + .../FeatureManagementKeyValueAdapter.cs | 7 +- .../Integration/GetAzureSubscription.ps1 | 47 + .../Integration/IntegrationTests.cs | 2086 +++++++++++++++++ .../Tests.AzureAppConfiguration.csproj | 15 +- .../{ => Unit}/CallbackMessageHandler.cs | 0 .../{ => Unit}/ClientOptionsTests.cs | 0 .../{ => Unit}/ConnectTests.cs | 0 .../{ => Unit}/FailoverTests.cs | 0 .../{ => Unit}/FeatureManagementTests.cs | 0 .../HttpRequestCountPipelinePolicy.cs | 0 .../{ => Unit}/JsonContentTypeTests.cs | 0 .../{ => Unit}/KeyVaultReferenceTests.cs | 0 .../{ => Unit}/LoadBalancingTests.cs | 0 .../{ => Unit}/LoggingTests.cs | 0 .../{ => Unit}/MapTests.cs | 0 .../MockedConfigurationClientManager.cs | 0 .../{ => Unit}/PushRefreshTests.cs | 0 .../{ => Unit}/RefreshTests.cs | 0 .../{ => Unit}/TagFiltersTests.cs | 0 .../{ => Unit}/TestHelper.cs | 0 .../{ => Unit}/Tests.cs | 0 23 files changed, 2165 insertions(+), 3 deletions(-) create mode 100644 tests/Tests.AzureAppConfiguration/Integration/GetAzureSubscription.ps1 create mode 100644 tests/Tests.AzureAppConfiguration/Integration/IntegrationTests.cs rename tests/Tests.AzureAppConfiguration/{ => Unit}/CallbackMessageHandler.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/ClientOptionsTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/ConnectTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/FailoverTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/FeatureManagementTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/HttpRequestCountPipelinePolicy.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/JsonContentTypeTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/KeyVaultReferenceTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/LoadBalancingTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/LoggingTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/MapTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/MockedConfigurationClientManager.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/PushRefreshTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/RefreshTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/TagFiltersTests.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/TestHelper.cs (100%) rename tests/Tests.AzureAppConfiguration/{ => Unit}/Tests.cs (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af9b1d227..2928be53e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,7 @@ on: permissions: security-events: write + id-token: write jobs: build: @@ -40,8 +41,17 @@ jobs: - name: Dotnet Pack run: pwsh pack.ps1 + - name: Azure Login with OIDC + uses: azure/login@v1 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Dotnet Test run: pwsh test.ps1 + env: + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: Publish Test Results uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 530af0645..39f97d76a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ # Azure Functions localsettings file local.settings.json +# Integration test secrets +appsettings.Secrets.json + # User-specific files *.suo *.user diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs index cbfa411aa..13408385c 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs @@ -426,7 +426,12 @@ private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue) { var featureFlag = new FeatureFlag(); - var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(settingValue)); + var reader = new Utf8JsonReader( + System.Text.Encoding.UTF8.GetBytes(settingValue), + new JsonReaderOptions + { + AllowTrailingCommas = true + }); try { diff --git a/tests/Tests.AzureAppConfiguration/Integration/GetAzureSubscription.ps1 b/tests/Tests.AzureAppConfiguration/Integration/GetAzureSubscription.ps1 new file mode 100644 index 000000000..c08e434db --- /dev/null +++ b/tests/Tests.AzureAppConfiguration/Integration/GetAzureSubscription.ps1 @@ -0,0 +1,47 @@ +#!/usr/bin/env pwsh + +# GetAzureSubscription.ps1 +# This script gets the AppConfig - Dev subscription ID and saves it to a JSON file + +$outputPath = Join-Path $PSScriptRoot "appsettings.Secrets.json" + +Write-Host "Checking for active Azure CLI login" + +az account show | Out-Null + +if ($LASTEXITCODE -ne 0) { + Write-Host "Must be logged in with the Azure CLI to proceed" + + az login + + if ($LASTEXITCODE -ne 0) { + Write-Host "Azure login failed" + return + } +} + +az account set --name "AppConfig - Dev" + +# Get current subscription from az CLI +$subscriptionId = az account show --query id -o tsv 2>&1 + +if ($LASTEXITCODE -ne 0) { + Write-Host "Azure CLI command failed with exit code $LASTEXITCODE. Output: $subscriptionId" + return +} + +# Check if the output is empty +if ([string]::IsNullOrWhiteSpace($subscriptionId)) { + Write-Host "No active Azure subscription found. Please run 'az login' first." + + exit 1 +} + +# If successful, save the subscription ID to a JSON file +$result = @{ + SubscriptionId = $subscriptionId.Trim() +} + +$result | ConvertTo-Json | Out-File $outputPath -Encoding utf8 +Write-Host "Subscription information saved to: $outputPath" +exit 0 diff --git a/tests/Tests.AzureAppConfiguration/Integration/IntegrationTests.cs b/tests/Tests.AzureAppConfiguration/Integration/IntegrationTests.cs new file mode 100644 index 000000000..b932b3278 --- /dev/null +++ b/tests/Tests.AzureAppConfiguration/Integration/IntegrationTests.cs @@ -0,0 +1,2086 @@ +using Azure; +using Azure.Core; +using Azure.Core.Pipeline; +using Azure.Data.AppConfiguration; +using Azure.Identity; +using Azure.ResourceManager; +using Azure.ResourceManager.AppConfiguration; +using Azure.ResourceManager.AppConfiguration.Models; +using Azure.ResourceManager.KeyVault; +using Azure.ResourceManager.Resources; +using Azure.Security.KeyVault.Secrets; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; +using Microsoft.FeatureManagement; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Tests.AzureAppConfiguration +{ + using Xunit.Abstractions; + + /// + /// Integration tests for Azure App Configuration that connect to a real service. + /// Uses an existing App Configuration store and Key Vault for testing. + /// Requires Azure credentials with appropriate permissions. + /// NOTE: Before running these tests locally, execute the GetAzureSubscription.ps1 script to create appsettings.Secrets.json. + /// + [Trait("Category", "Integration")] + [CollectionDefinition(nameof(IntegrationTests), DisableParallelization = true)] + public class IntegrationTests : IAsyncLifetime + { + // Test constants + private const string TestKeyPrefix = "IntegrationTest"; + private const string SubscriptionJsonPath = "appsettings.Secrets.json"; + private static readonly TimeSpan StaleResourceThreshold = TimeSpan.FromHours(3); + private const string KeyVaultReferenceLabel = "KeyVaultRef"; + + // Content type constants + private const string FeatureFlagContentType = FeatureManagementConstants.ContentType + ";charset=utf-8"; + private const string JsonContentType = "application/json"; + + // Fixed resource names - already existing + private const string AppConfigStoreName = "appconfig-dotnetprovider-integrationtest"; + private const string KeyVaultName = "keyvault-dotnetprovider"; + private const string ResourceGroupName = "dotnetprovider-integrationtest"; + + private readonly DefaultAzureCredential _defaultAzureCredential = new DefaultAzureCredential( + new DefaultAzureCredentialOptions + { + ExcludeSharedTokenCacheCredential = true + }); + + private class TestContext + { + public string KeyPrefix { get; set; } + public string SentinelKey { get; set; } + public string FeatureFlagKey { get; set; } + public string KeyVaultReferenceKey { get; set; } + public string SecretValue { get; set; } + } + + private ConfigurationClient _configClient; + + private SecretClient _secretClient; + + private string _connectionString; + + private Uri _keyVaultEndpoint; + + private readonly ITestOutputHelper _output; + + public IntegrationTests(ITestOutputHelper output) + { + _output = output; + } + + private string GetCurrentSubscriptionId() + { + string subscriptionIdFromEnv = Environment.GetEnvironmentVariable("AZURE_SUBSCRIPTION_ID"); + + if (!string.IsNullOrEmpty(subscriptionIdFromEnv)) + { + return subscriptionIdFromEnv; + } + + // Read the JSON file created by the script + string jsonPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Integration", SubscriptionJsonPath); + + if (!File.Exists(jsonPath)) + { + throw new InvalidOperationException($"Subscription JSON file not found at {jsonPath}. Run the GetAzureSubscription.ps1 script first."); + } + + string jsonContent = File.ReadAllText(jsonPath); + + using JsonDocument doc = JsonDocument.Parse(jsonContent); + JsonElement root = doc.RootElement; + + return root.GetProperty("SubscriptionId").GetString(); + } + + private string GetUniqueKeyPrefix(string testName) + { + // Use a combination of the test prefix and test method name to ensure uniqueness + return $"{TestKeyPrefix}-{testName}-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; + } + + private async Task CreateSnapshot(string snapshotName, IEnumerable settingsToInclude, CancellationToken cancellationToken = default) + { + ConfigurationSnapshot snapshot = new ConfigurationSnapshot(settingsToInclude); + + snapshot.SnapshotComposition = SnapshotComposition.Key; + + CreateSnapshotOperation operation = await _configClient.CreateSnapshotAsync( + WaitUntil.Completed, + snapshotName, + snapshot, + cancellationToken); + + return operation.Value.Name; + } + + public async Task InitializeAsync() + { + DefaultAzureCredential credential = _defaultAzureCredential; + string subscriptionId = GetCurrentSubscriptionId(); + + var armClient = new ArmClient(credential); + SubscriptionResource subscription = armClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{subscriptionId}")); + + ResourceGroupResource resourceGroup = await subscription.GetResourceGroups().GetAsync(ResourceGroupName); + + AppConfigurationStoreResource appConfigStore = null; + + try + { + appConfigStore = await resourceGroup.GetAppConfigurationStores().GetAsync(AppConfigStoreName); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + throw new InvalidOperationException($"App Configuration store '{AppConfigStoreName}' not found in resource group '{ResourceGroupName}'. Please create it before running tests.", ex); + } + + AsyncPageable accessKeys = appConfigStore.GetKeysAsync(); + + _connectionString = (await accessKeys.FirstAsync()).ConnectionString; + + _configClient = new ConfigurationClient(_connectionString); + + KeyVaultResource keyVault = null; + + // Find and initialize Key Vault - look in the same resource group + keyVault = await resourceGroup.GetKeyVaults().GetAsync(KeyVaultName); + + if (keyVault == null) + { + throw new InvalidOperationException( + $"Key Vault '{KeyVaultName}' not found in subscription {subscriptionId}. " + + "This resource is required for integration tests. " + + "Please create the Key Vault with the appropriate permissions before running tests."); + } + + _keyVaultEndpoint = keyVault.Data.Properties.VaultUri; + + _secretClient = new SecretClient(_keyVaultEndpoint, credential); + + _output.WriteLine($"Successfully connected to App Configuration store '{AppConfigStoreName}' and Key Vault '{KeyVaultName}'"); + } + + private async Task CleanupStaleResources() + { + _output.WriteLine($"Checking for stale resources older than {StaleResourceThreshold}..."); + + var cutoffTime = DateTimeOffset.UtcNow.Subtract(StaleResourceThreshold); + var cleanupTasks = new List(); + + // Clean up stale configuration settings, snapshots, and Key Vault secrets + try + { + int staleConfigCount = 0; + var configSettingsToCleanup = new List(); + + AsyncPageable kvSettings = _configClient.GetConfigurationSettingsAsync(new SettingSelector + { + KeyFilter = TestKeyPrefix + "*" + }); + + AsyncPageable flagSettings = _configClient.GetConfigurationSettingsAsync(new SettingSelector + { + KeyFilter = FeatureManagementConstants.FeatureFlagMarker + TestKeyPrefix + "*" + }); + + await foreach (ConfigurationSetting setting in kvSettings.Concat(flagSettings)) + { + // Check if the setting is older than the threshold + if (setting.LastModified < cutoffTime) + { + configSettingsToCleanup.Add(setting); + staleConfigCount++; + } + } + + foreach (ConfigurationSetting setting in configSettingsToCleanup) + { + cleanupTasks.Add(_configClient.DeleteConfigurationSettingAsync(setting.Key, setting.Label)); + } + + int staleSnapshotCount = 0; + AsyncPageable snapshots = _configClient.GetSnapshotsAsync(new SnapshotSelector()); + await foreach (ConfigurationSnapshot snapshot in snapshots) + { + if (snapshot.Name.StartsWith("snapshot-" + TestKeyPrefix) && snapshot.CreatedOn < cutoffTime) + { + cleanupTasks.Add(_configClient.ArchiveSnapshotAsync(snapshot.Name)); + staleSnapshotCount++; + } + } + + int staleSecretCount = 0; + if (_secretClient != null) + { + AsyncPageable secrets = _secretClient.GetPropertiesOfSecretsAsync(); + await foreach (SecretProperties secretProperties in secrets) + { + if (secretProperties.Name.StartsWith(TestKeyPrefix) && secretProperties.CreatedOn.HasValue && secretProperties.CreatedOn.Value < cutoffTime) + { + cleanupTasks.Add(_secretClient.StartDeleteSecretAsync(secretProperties.Name)); + staleSecretCount++; + } + } + } + + // Wait for all cleanup tasks to complete + await Task.WhenAll(cleanupTasks); + + _output.WriteLine($"Cleaned up {staleConfigCount} stale configuration settings, {staleSnapshotCount} snapshots, and {staleSecretCount} secrets"); + } + catch (RequestFailedException ex) + { + _output.WriteLine($"Error during stale resource cleanup: {ex.Message}"); + // Continue execution even if cleanup fails + } + } + + public async Task DisposeAsync() + { + await CleanupStaleResources(); + } + + private TestContext CreateTestContext(string testName) + { + string keyPrefix = GetUniqueKeyPrefix(testName); + string sentinelKey = $"{keyPrefix}:Sentinel"; + string featureFlagKey = $".appconfig.featureflag/{keyPrefix}Feature"; + string secretName = $"{keyPrefix}-secret"; + string secretValue = "SecretValue"; + string keyVaultReferenceKey = $"{keyPrefix}:KeyVaultRef"; + + return new TestContext + { + KeyPrefix = keyPrefix, + SentinelKey = sentinelKey, + FeatureFlagKey = featureFlagKey, + KeyVaultReferenceKey = keyVaultReferenceKey, + SecretValue = secretValue + }; + } + + private async Task SetupKeyValues(TestContext context) + { + var testSettings = new List + { + new ConfigurationSetting($"{context.KeyPrefix}:Setting1", "InitialValue1"), + new ConfigurationSetting($"{context.KeyPrefix}:Setting2", "InitialValue2"), + new ConfigurationSetting(context.SentinelKey, "Initial") + }; + + foreach (ConfigurationSetting setting in testSettings) + { + await _configClient.SetConfigurationSettingAsync(setting); + } + } + + private async Task SetupFeatureFlags(TestContext context) + { + var featureFlagSetting = ConfigurationModelFactory.ConfigurationSetting( + context.FeatureFlagKey, + @"{""id"":""" + context.KeyPrefix + @"Feature"",""description"":""Test feature"",""enabled"":false}", + contentType: FeatureFlagContentType); + + await _configClient.SetConfigurationSettingAsync(featureFlagSetting); + } + + private async Task SetupKeyVaultReferences(TestContext context) + { + if (_secretClient != null) + { + await _secretClient.SetSecretAsync(context.KeyPrefix + "-secret", context.SecretValue); + + string keyVaultUri = $"{_keyVaultEndpoint}secrets/{context.KeyPrefix}-secret"; + string keyVaultRefValue = @$"{{""uri"":""{keyVaultUri}""}}"; + + ConfigurationSetting keyVaultRefSetting = ConfigurationModelFactory.ConfigurationSetting( + context.KeyVaultReferenceKey, + keyVaultRefValue, + label: KeyVaultReferenceLabel, + contentType: KeyVaultConstants.ContentType); + + await _configClient.SetConfigurationSettingAsync(keyVaultRefSetting); + } + } + + private async Task SetupTaggedSettings(TestContext context) + { + // Create configuration settings with various tags + var taggedSettings = new List + { + // Basic environment tags + CreateSettingWithTags( + $"{context.KeyPrefix}:TaggedSetting1", + "Value1", + new Dictionary { + { "Environment", "Development" }, + { "App", "TestApp" } + }), + + CreateSettingWithTags( + $"{context.KeyPrefix}:TaggedSetting2", + "Value2", + new Dictionary { + { "Environment", "Production" }, + { "App", "TestApp" } + }), + + CreateSettingWithTags( + $"{context.KeyPrefix}:TaggedSetting3", + "Value3", + new Dictionary { + { "Environment", "Development" }, + { "Component", "API" } + }), + + // Special characters in tags + CreateSettingWithTags( + $"{context.KeyPrefix}:TaggedSetting4", + "Value4", + new Dictionary { + { "Special:Tag", "Value:With:Colons" }, + { "Tag@With@At", "Value@With@At" } + }), + + // Empty and null tag values + CreateSettingWithTags( + $"{context.KeyPrefix}:TaggedSetting5", + "Value5", + new Dictionary { + { "EmptyTag", "" }, + { "NullTag", null } + }) + }; + + foreach (ConfigurationSetting setting in taggedSettings) + { + await _configClient.SetConfigurationSettingAsync(setting); + } + + // Create feature flags with tags + var taggedFeatureFlags = new List + { + // Basic environment tags on feature flags + CreateFeatureFlagWithTags( + $"{context.KeyPrefix}:FeatureDev", + true, + new Dictionary { + { "Environment", "Development" }, + { "App", "TestApp" } + }), + + CreateFeatureFlagWithTags( + $"{context.KeyPrefix}:FeatureProd", + false, + new Dictionary { + { "Environment", "Production" }, + { "App", "TestApp" } + }), + + // Feature flags with special character tags + CreateFeatureFlagWithTags( + $"{context.KeyPrefix}:FeatureSpecial", + true, + new Dictionary { + { "Special:Tag", "Value:With:Colons" } + }), + + // Feature flags with empty/null tags + CreateFeatureFlagWithTags( + $"{context.KeyPrefix}:FeatureEmpty", + false, + new Dictionary { + { "EmptyTag", "" }, + { "NullTag", null } + }) + }; + + foreach (ConfigurationSetting setting in taggedFeatureFlags) + { + await _configClient.SetConfigurationSettingAsync(setting); + } + } + + private async Task SetupAllTestData(string testName) + { + TestContext context = CreateTestContext(testName); + + await SetupKeyValues(context); + await SetupFeatureFlags(context); + await SetupKeyVaultReferences(context); + + return context; + } + + [Fact] + public async Task LoadConfiguration_RetrievesValuesFromAppConfiguration() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("BasicConfig"); + await SetupKeyValues(testContext); + + // Act + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*"); + }) + .Build(); + + // Assert + Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue2", config[$"{testContext.KeyPrefix}:Setting2"]); + } + + [Fact] + public async Task RefreshAsync_UpdatesConfiguration_WhenSentinelKeyChanged() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("UpdatesConfig"); + await SetupKeyValues(testContext); + IConfigurationRefresher refresher = null; + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*"); + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext.SentinelKey, refreshAll: true) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + // Verify initial values + Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + + // Update values in the store + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedValue1")); + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting(testContext.SentinelKey, "Updated")); + + // Wait for cache to expire + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Act + await refresher.RefreshAsync(); + + // Assert + Assert.Equal("UpdatedValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + } + + [Fact] + public async Task RegisterAll_RefreshesAllKeys() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("RefreshesAllKeys"); + await SetupKeyValues(testContext); + IConfigurationRefresher refresher = null; + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*"); + + // Use RegisterAll to refresh everything when sentinel changes + options.ConfigureRefresh(refresh => + { + refresh.RegisterAll() + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + // Verify initial values + Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue2", config[$"{testContext.KeyPrefix}:Setting2"]); + + // Update all values in the store + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedValue1")); + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting2", "UpdatedValue2")); + + // Wait for cache to expire + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Act + await refresher.RefreshAsync(); + + // Assert + Assert.Equal("UpdatedValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("UpdatedValue2", config[$"{testContext.KeyPrefix}:Setting2"]); + } + + [Fact] + public async Task RefreshAsync_SentinelKeyUnchanged() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("SentinelUnchanged"); + await SetupKeyValues(testContext); + IConfigurationRefresher refresher = null; + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*"); + + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext.SentinelKey) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + // Verify initial values + Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + + // Update data but not sentinel + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedValue1")); + + // Wait for cache to expire + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Act + await refresher.RefreshAsync(); + + // Assert + Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]); // Should not update + } + + [Fact] + public async Task RefreshAsync_RefreshesFeatureFlags_WhenConfigured() + { + TestContext testContext = CreateTestContext("FeatureFlagRefresh"); + await SetupFeatureFlags(testContext); + IConfigurationRefresher refresher = null; + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + + // Configure feature flags with the correct ID pattern + options.UseFeatureFlags(featureFlagOptions => + { + featureFlagOptions.Select(testContext.KeyPrefix + "*"); + featureFlagOptions.SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + // Verify the feature flag is disabled initially + Assert.Equal("False", config[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + + // Update the feature flag to enabled=true + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + testContext.FeatureFlagKey, + @"{""id"":""" + testContext.KeyPrefix + @"Feature"",""description"":""Test feature"",""enabled"":true}", + contentType: FeatureFlagContentType)); + + // Wait for cache to expire + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Act + await refresher.RefreshAsync(); + + // Assert + Assert.Equal("True", config[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + } + + [Fact] + public async Task UseFeatureFlags_WithClientFiltersAndConditions() + { + TestContext testContext = CreateTestContext("FeatureFlagFilters"); + await SetupFeatureFlags(testContext); + + // Create a feature flag with complex conditions + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + testContext.FeatureFlagKey, + @"{ + ""id"": """ + testContext.KeyPrefix + @"Feature"", + ""description"": ""Test feature with filters"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""Browser"", + ""parameters"": { + ""AllowedBrowsers"": [""Chrome"", ""Edge""] + } + }, + { + ""name"": ""TimeWindow"", + ""parameters"": { + ""Start"": ""\/Date(" + DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeMilliseconds() + @")\/"", + ""End"": ""\/Date(" + DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds() + @")\/"" + } + } + ] + } + }", + contentType: FeatureFlagContentType)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.UseFeatureFlags(featureFlagOptions => + { + featureFlagOptions.Select(testContext.KeyPrefix + "*"); + }); + }) + .Build(); + + // Verify feature flag structure is loaded correctly + Assert.Equal("Browser", config[$"FeatureManagement:{testContext.KeyPrefix}Feature:EnabledFor:0:Name"]); + Assert.Equal("Chrome", config[$"FeatureManagement:{testContext.KeyPrefix}Feature:EnabledFor:0:Parameters:AllowedBrowsers:0"]); + Assert.Equal("Edge", config[$"FeatureManagement:{testContext.KeyPrefix}Feature:EnabledFor:0:Parameters:AllowedBrowsers:1"]); + Assert.Equal("TimeWindow", config[$"FeatureManagement:{testContext.KeyPrefix}Feature:EnabledFor:1:Name"]); + } + + [Fact] + public async Task MultipleProviders_LoadAndRefresh() + { + TestContext testContext1 = CreateTestContext("MultiProviderTest1"); + await SetupKeyValues(testContext1); + TestContext testContext2 = CreateTestContext("MultiProviderTest2"); + await SetupKeyValues(testContext2); + IConfigurationRefresher refresher1 = null; + IConfigurationRefresher refresher2 = null; + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext1.KeyPrefix}:*"); + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext1.SentinelKey, true) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher1 = options.GetRefresher(); + }) + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext2.KeyPrefix}:*"); + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext2.SentinelKey) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher2 = options.GetRefresher(); + }) + .Build(); + + // Verify initial values + Assert.Equal("InitialValue1", config[$"{testContext1.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue1", config[$"{testContext2.KeyPrefix}:Setting1"]); + + // Update values and sentinel keys + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext1.KeyPrefix}:Setting1", "UpdatedValue1")); + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting(testContext1.SentinelKey, "Updated")); + + // Wait for cache to expire + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Refresh only the first provider + await refresher1.RefreshAsync(); + + // Assert: Only the first provider's values should be updated + Assert.Equal("UpdatedValue1", config[$"{testContext1.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue1", config[$"{testContext2.KeyPrefix}:Setting1"]); + } + + [Fact] + public async Task FeatureFlag_WithVariants() + { + TestContext testContext = CreateTestContext("FeatureFlagVariants"); + await SetupFeatureFlags(testContext); + + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + testContext.FeatureFlagKey, + @"{""id"":""" + testContext.KeyPrefix + @"Feature"",""description"":""Test feature"",""enabled"":true}", + contentType: FeatureFlagContentType)); + + // Create a feature flag with variants + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + testContext.FeatureFlagKey + "WithVariants", + @"{ + ""id"": """ + testContext.KeyPrefix + @"FeatureWithVariants"", + ""description"": ""Feature flag with variants"", + ""enabled"": true, + ""conditions"": { ""client_filters"": [] }, + ""variants"": [ + { + ""name"": ""LargeSize"", + ""configuration_value"": ""800px"" + }, + { + ""name"": ""MediumSize"", + ""configuration_value"": ""600px"" + }, + { + ""name"": ""SmallSize"", + ""configuration_value"": ""400px"" + } + ], + ""allocation"": { + ""default_when_enabled"": ""MediumSize"" + } + }", + contentType: FeatureFlagContentType)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.UseFeatureFlags(featureFlagOptions => + { + featureFlagOptions.Select(testContext.KeyPrefix + "*"); + }); + }) + .Build(); + + // Verify variants are loaded correctly + Assert.Equal("True", config[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + Assert.Equal("LargeSize", config[$"feature_management:feature_flags:0:variants:0:name"]); + Assert.Equal("800px", config[$"feature_management:feature_flags:0:variants:0:configuration_value"]); + Assert.Equal("MediumSize", config[$"feature_management:feature_flags:0:variants:1:name"]); + Assert.Equal("600px", config[$"feature_management:feature_flags:0:variants:1:configuration_value"]); + Assert.Equal("SmallSize", config[$"feature_management:feature_flags:0:variants:2:name"]); + Assert.Equal("400px", config[$"feature_management:feature_flags:0:variants:2:configuration_value"]); + Assert.Equal("MediumSize", config[$"feature_management:feature_flags:0:allocation:default_when_enabled"]); + } + + [Fact] + public async Task JsonContentType_LoadsAndFlattensHierarchicalData() + { + TestContext testContext = CreateTestContext("JsonContent"); + await SetupKeyValues(testContext); + + // Create a complex JSON structure + string jsonKey = $"{testContext.KeyPrefix}:JsonConfig"; + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + jsonKey, + @"{ + ""database"": { + ""connection"": { + ""string"": ""Server=myserver;Database=mydb;User Id=sa;Password=mypassword;"", + ""timeout"": 30 + }, + ""retries"": 3, + ""enabled"": true + }, + ""logging"": { + ""level"": ""Information"", + ""providers"": [""Console"", ""Debug"", ""EventLog""] + } + }", + contentType: JsonContentType)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*"); + }) + .Build(); + + // Verify JSON was flattened properly + Assert.Equal("Server=myserver;Database=mydb;User Id=sa;Password=mypassword;", config[$"{jsonKey}:database:connection:string"]); + Assert.Equal("30", config[$"{jsonKey}:database:connection:timeout"]); + Assert.Equal("3", config[$"{jsonKey}:database:retries"]); + Assert.Equal("True", config[$"{jsonKey}:database:enabled"]); + Assert.Equal("Information", config[$"{jsonKey}:logging:level"]); + Assert.Equal("Console", config[$"{jsonKey}:logging:providers:0"]); + Assert.Equal("Debug", config[$"{jsonKey}:logging:providers:1"]); + Assert.Equal("EventLog", config[$"{jsonKey}:logging:providers:2"]); + } + + [Fact] + public async Task MethodOrderingDoesNotAffectConfiguration() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("MethodOrdering"); + await SetupKeyValues(testContext); + await SetupFeatureFlags(testContext); + + // Add an additional feature flag for testing + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + testContext.FeatureFlagKey + "_Ordering", + @"{ + ""id"": """ + testContext.KeyPrefix + @"FeatureOrdering"", + ""description"": ""Test feature for ordering"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [] + } + }", + contentType: FeatureFlagContentType)); + + // Add a section-based setting + await _configClient.SetConfigurationSettingAsync( + new ConfigurationSetting($"{testContext.KeyPrefix}:Section1:Setting1", "SectionValue1")); + + // Create four different configurations with different method orderings + var configurations = new List(); + IConfigurationRefresher refresher1 = null; + IConfigurationRefresher refresher2 = null; + IConfigurationRefresher refresher3 = null; + IConfigurationRefresher refresher4 = null; + + // Configuration 1: Select -> ConfigureRefresh -> UseFeatureFlags + var config1 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*"); + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext.SentinelKey, true) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + options.UseFeatureFlags(featureFlagOptions => + { + featureFlagOptions.Select(testContext.KeyPrefix + "*"); + }); + + refresher1 = options.GetRefresher(); + }) + .Build(); + configurations.Add(config1); + + // Configuration 2: ConfigureRefresh -> Select -> UseFeatureFlags + var config2 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext.SentinelKey, true) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + options.Select($"{testContext.KeyPrefix}:*"); + options.UseFeatureFlags(featureFlagOptions => + { + featureFlagOptions.Select(testContext.KeyPrefix + "*"); + }); + + refresher2 = options.GetRefresher(); + }) + .Build(); + configurations.Add(config2); + + // Configuration 3: UseFeatureFlags -> Select -> ConfigureRefresh + var config3 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.UseFeatureFlags(featureFlagOptions => + { + featureFlagOptions.Select(testContext.KeyPrefix + "*"); + }); + options.Select($"{testContext.KeyPrefix}:*"); + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext.SentinelKey, true) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher3 = options.GetRefresher(); + }) + .Build(); + configurations.Add(config3); + + // Configuration 4: UseFeatureFlags (with Select inside) -> ConfigureRefresh -> Select + var config4 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.UseFeatureFlags(featureFlagOptions => + { + featureFlagOptions.Select(testContext.KeyPrefix + "*"); + }); + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext.SentinelKey, true) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + options.Select($"{testContext.KeyPrefix}:*"); + + refresher4 = options.GetRefresher(); + }) + .Build(); + configurations.Add(config4); + + // Assert - Initial values should be the same across all configurations + foreach (var config in configurations) + { + // Regular settings + Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue2", config[$"{testContext.KeyPrefix}:Setting2"]); + Assert.Equal("SectionValue1", config[$"{testContext.KeyPrefix}:Section1:Setting1"]); + + // Feature flags + Assert.Equal("False", config[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + Assert.Equal("True", config[$"FeatureManagement:{testContext.KeyPrefix}FeatureOrdering"]); + } + + // Update values in the store + await _configClient.SetConfigurationSettingAsync( + new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedValue1")); + await _configClient.SetConfigurationSettingAsync( + new ConfigurationSetting($"{testContext.KeyPrefix}:Section1:Setting1", "UpdatedSectionValue1")); + + // Update a feature flag + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + testContext.FeatureFlagKey, + @"{ + ""id"": """ + testContext.KeyPrefix + @"Feature"", + ""description"": ""Updated test feature"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [] + } + }", + contentType: FeatureFlagContentType)); + + // Update the sentinel key to trigger refresh + await _configClient.SetConfigurationSettingAsync( + new ConfigurationSetting(testContext.SentinelKey, "Updated")); + + // Wait for cache to expire + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Refresh all configurations + await refresher1.RefreshAsync(); + await refresher2.RefreshAsync(); + await refresher3.RefreshAsync(); + await refresher4.RefreshAsync(); + + // Assert - Updated values should be the same across all configurations + foreach (var config in configurations) + { + // Regular settings + Assert.Equal("UpdatedValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue2", config[$"{testContext.KeyPrefix}:Setting2"]); + Assert.Equal("UpdatedSectionValue1", config[$"{testContext.KeyPrefix}:Section1:Setting1"]); + + // Feature flags - first one should be updated to true + Assert.Equal("True", config[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + Assert.Equal("True", config[$"FeatureManagement:{testContext.KeyPrefix}FeatureOrdering"]); + } + } + + [Fact] + public async Task RegisterWithRefreshAllAndRegisterAll_BehaveIdentically() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("RefreshEquivalency"); + await SetupKeyValues(testContext); + await SetupFeatureFlags(testContext); + + // Add another feature flag for testing + string secondFeatureFlagKey = $".appconfig.featureflag/{testContext.KeyPrefix}Feature2"; + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + secondFeatureFlagKey, + @"{""id"":""" + testContext.KeyPrefix + @"Feature2"",""description"":""Second test feature"",""enabled"":false}", + contentType: FeatureFlagContentType)); + + // Create two separate configuration builders with different refresh methods + // First configuration uses Register with refreshAll: true + IConfigurationRefresher refresher1 = null; + var config1 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*"); + options.UseFeatureFlags(featureFlagOptions => + { + featureFlagOptions.Select(testContext.KeyPrefix + "*"); + }); + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext.SentinelKey, refreshAll: true) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher1 = options.GetRefresher(); + }) + .Build(); + + // Second configuration uses RegisterAll() + IConfigurationRefresher refresher2 = null; + var config2 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*"); + options.UseFeatureFlags(featureFlagOptions => + { + featureFlagOptions.Select(testContext.KeyPrefix + "*"); + }); + options.ConfigureRefresh(refresh => + { + refresh.RegisterAll() + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher2 = options.GetRefresher(); + }) + .Build(); + + // Verify initial values for both configurations + Assert.Equal("InitialValue1", config1[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue2", config1[$"{testContext.KeyPrefix}:Setting2"]); + Assert.Equal("False", config1[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + Assert.Equal("False", config1[$"FeatureManagement:{testContext.KeyPrefix}Feature2"]); + + Assert.Equal("InitialValue1", config2[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue2", config2[$"{testContext.KeyPrefix}:Setting2"]); + Assert.Equal("False", config2[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + Assert.Equal("False", config2[$"FeatureManagement:{testContext.KeyPrefix}Feature2"]); + + // Update all values in the store + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedValue1")); + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting2", "UpdatedValue2")); + + // Update the feature flags + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + testContext.FeatureFlagKey, + @"{""id"":""" + testContext.KeyPrefix + @"Feature"",""description"":""Test feature"",""enabled"":true}", + contentType: FeatureFlagContentType)); + + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + secondFeatureFlagKey, + @"{""id"":""" + testContext.KeyPrefix + @"Feature2"",""description"":""Second test feature"",""enabled"":true}", + contentType: FeatureFlagContentType)); + + // Update the sentinel key to trigger refresh + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting(testContext.SentinelKey, "Updated")); + + // Wait for cache to expire + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Act - Refresh both configurations + await refresher1.RefreshAsync(); + await refresher2.RefreshAsync(); + + // Assert - Both configurations should be updated the same way + // For config1 (Register with refreshAll: true) + Assert.Equal("UpdatedValue1", config1[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("UpdatedValue2", config1[$"{testContext.KeyPrefix}:Setting2"]); + Assert.Equal("True", config1[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + Assert.Equal("True", config1[$"FeatureManagement:{testContext.KeyPrefix}Feature2"]); + + // For config2 (RegisterAll) + Assert.Equal("UpdatedValue1", config2[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("UpdatedValue2", config2[$"{testContext.KeyPrefix}:Setting2"]); + Assert.Equal("True", config2[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + Assert.Equal("True", config2[$"FeatureManagement:{testContext.KeyPrefix}Feature2"]); + + // Test deleting a key and a feature flag + await _configClient.DeleteConfigurationSettingAsync($"{testContext.KeyPrefix}:Setting2"); + await _configClient.DeleteConfigurationSettingAsync(secondFeatureFlagKey); + + // Update the sentinel key again to trigger refresh + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting(testContext.SentinelKey, "UpdatedAgain")); + + // Wait for cache to expire + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Refresh both configurations again + await refresher1.RefreshAsync(); + await refresher2.RefreshAsync(); + + // Both configurations should have removed the deleted key-value and feature flag + Assert.Equal("UpdatedValue1", config1[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Null(config1[$"{testContext.KeyPrefix}:Setting2"]); + Assert.Equal("True", config1[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + Assert.Null(config1[$"FeatureManagement:{testContext.KeyPrefix}Feature2"]); + + Assert.Equal("UpdatedValue1", config2[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Null(config2[$"{testContext.KeyPrefix}:Setting2"]); + Assert.Equal("True", config2[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + Assert.Null(config2[$"FeatureManagement:{testContext.KeyPrefix}Feature2"]); + } + + [Fact] + public async Task HandlesFailoverOnStartup() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("FailoverStartup"); + await SetupKeyValues(testContext); + IConfigurationRefresher refresher = null; + + string connectionString = _connectionString; + + // Create a connection string that will fail + string primaryConnectionString = ConnectionStringUtils.Build( + TestHelpers.PrimaryConfigStoreEndpoint, + ConnectionStringUtils.Parse(connectionString, ConnectionStringUtils.IdSection), + ConnectionStringUtils.Parse(connectionString, ConnectionStringUtils.SecretSection)); + string secondaryConnectionString = connectionString; + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(new List { primaryConnectionString, secondaryConnectionString }); + options.Select($"{testContext.KeyPrefix}:*"); + + // Configure refresh + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext.SentinelKey, refreshAll: true) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + // Verify initial values + Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + } + + [Fact] + public async Task LoadSnapshot_RetrievesValuesFromSnapshot() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("SnapshotTest"); + await SetupKeyValues(testContext); + string snapshotName = $"snapshot-{testContext.KeyPrefix}"; + + // Create a snapshot with the test keys + await CreateSnapshot(snapshotName, new List { new ConfigurationSettingsFilter(testContext.KeyPrefix + "*") }); + + // Update values after snapshot is taken to verify snapshot has original values + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedAfterSnapshot")); + + // Act - Load configuration from snapshot + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.SelectSnapshot(snapshotName); + }) + .Build(); + + // Assert - Should have original values from snapshot, not updated values + Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue2", config[$"{testContext.KeyPrefix}:Setting2"]); + + // Cleanup - Delete the snapshot + await _configClient.ArchiveSnapshotAsync(snapshotName); + } + + [Fact] + public async Task LoadSnapshot_ThrowsException_WhenSnapshotDoesNotExist() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("NonExistentSnapshotTest"); + string nonExistentSnapshotName = $"snapshot-does-not-exist-{Guid.NewGuid()}"; + + // Act & Assert - Loading a non-existent snapshot should throw + var exception = await Assert.ThrowsAsync(() => + { + return Task.FromResult(new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.SelectSnapshot(nonExistentSnapshotName); + }) + .Build()); + }); + + // Verify the exception message contains snapshot name + Assert.Contains(nonExistentSnapshotName, exception.Message); + } + + [Fact] + public async Task LoadMultipleSnapshots_MergesConfigurationCorrectly() + { + // Arrange - Setup test-specific keys for two separate snapshots + TestContext testContext1 = CreateTestContext("SnapshotMergeTest1"); + await SetupKeyValues(testContext1); + TestContext testContext2 = CreateTestContext("SnapshotMergeTest2"); + await SetupKeyValues(testContext2); + + // Create specific values for second snapshot + await _configClient.SetConfigurationSettingAsync( + new ConfigurationSetting($"{testContext2.KeyPrefix}:UniqueKey", "UniqueValue")); + + string snapshotName1 = $"snapshot-{testContext1.KeyPrefix}"; + string snapshotName2 = $"snapshot-{testContext2.KeyPrefix}"; + + // Create snapshots + await CreateSnapshot(snapshotName1, new List { new ConfigurationSettingsFilter(testContext1.KeyPrefix + "*") }); + await CreateSnapshot(snapshotName2, new List { new ConfigurationSettingsFilter(testContext2.KeyPrefix + "*") }); + + try + { + // Act - Load configuration from both snapshots + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.SelectSnapshot(snapshotName1); + options.SelectSnapshot(snapshotName2); + }) + .Build(); + + // Assert - Should have values from both snapshots + Assert.Equal("InitialValue1", config[$"{testContext1.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue1", config[$"{testContext2.KeyPrefix}:Setting1"]); + Assert.Equal("UniqueValue", config[$"{testContext2.KeyPrefix}:UniqueKey"]); + } + finally + { + // Cleanup - Delete the snapshots + await _configClient.ArchiveSnapshotAsync(snapshotName1); + await _configClient.ArchiveSnapshotAsync(snapshotName2); + } + } + + [Fact] + public async Task SnapshotCompositionTypes_AreHandledCorrectly() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("SnapshotCompositionTest"); + await SetupKeyValues(testContext); + string keyOnlySnapshotName = $"snapshot-key-{testContext.KeyPrefix}"; + string invalidCompositionSnapshotName = $"snapshot-invalid-{testContext.KeyPrefix}"; + + // Create a snapshot with the test keys + var settingsToInclude = new List + { + new ConfigurationSettingsFilter($"{testContext.KeyPrefix}:*") + }; + + ConfigurationSnapshot keyOnlySnapshot = new ConfigurationSnapshot(settingsToInclude); + + keyOnlySnapshot.SnapshotComposition = SnapshotComposition.Key; + + // Create the snapshot + await _configClient.CreateSnapshotAsync(WaitUntil.Completed, keyOnlySnapshotName, keyOnlySnapshot); + + ConfigurationSnapshot invalidSnapshot = new ConfigurationSnapshot(settingsToInclude); + + invalidSnapshot.SnapshotComposition = SnapshotComposition.KeyLabel; + + // Create the snapshot + await _configClient.CreateSnapshotAsync(WaitUntil.Completed, invalidCompositionSnapshotName, invalidSnapshot); + + try + { + // Act & Assert - Loading a key-only snapshot should work + var config1 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.SelectSnapshot(keyOnlySnapshotName); + }) + .Build(); + + Assert.Equal("InitialValue1", config1[$"{testContext.KeyPrefix}:Setting1"]); + + // Act & Assert - Loading a snapshot with invalid composition should throw + var exception = await Assert.ThrowsAsync(() => + { + return Task.FromResult(new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.SelectSnapshot(invalidCompositionSnapshotName); + }) + .Build()); + }); + + // Verify the exception message mentions composition type + Assert.Contains("SnapshotComposition", exception.Message); + Assert.Contains("key", exception.Message); + Assert.Contains("label", exception.Message); + } + finally + { + // Cleanup - Delete the snapshots + await _configClient.ArchiveSnapshotAsync(keyOnlySnapshotName); + await _configClient.ArchiveSnapshotAsync(invalidCompositionSnapshotName); + } + } + + [Fact] + public async Task SnapshotWithFeatureFlags_LoadsConfigurationCorrectly() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("SnapshotFeatureFlagTest"); + await SetupFeatureFlags(testContext); + string snapshotName = $"snapshot-ff-{testContext.KeyPrefix}"; + + // Update the feature flag to be enabled before creating the snapshot + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + testContext.FeatureFlagKey, + @"{""id"":""" + testContext.KeyPrefix + @"Feature"",""description"":""Test feature"",""enabled"":true}", + contentType: FeatureFlagContentType)); + + // Create a snapshot with the test keys + var settingsToInclude = new List + { + new ConfigurationSettingsFilter($".appconfig.featureflag/{testContext.KeyPrefix}*") + }; + + ConfigurationSnapshot snapshot = new ConfigurationSnapshot(settingsToInclude); + + snapshot.SnapshotComposition = SnapshotComposition.Key; + + // Create the snapshot + await _configClient.CreateSnapshotAsync(WaitUntil.Completed, snapshotName, snapshot); + + // Update feature flag to disabled after creating snapshot + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + testContext.FeatureFlagKey, + @"{""id"":""" + testContext.KeyPrefix + @"Feature"",""description"":""Test feature"",""enabled"":false}", + contentType: FeatureFlagContentType)); + + try + { + // Act - Load configuration from snapshot with feature flags + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.UseFeatureFlags(); + options.SelectSnapshot(snapshotName); + }) + .Build(); + + // Assert - Should have feature flag enabled state from snapshot + Assert.Equal("True", config[$"FeatureManagement:{testContext.KeyPrefix}Feature"]); + } + finally + { + // Cleanup - Delete the snapshot + await _configClient.ArchiveSnapshotAsync(snapshotName); + } + } + + [Fact] + public async Task CallOrdering_SnapshotsWithSelectAndFeatureFlags() + { + // Arrange - Setup test-specific keys for multiple snapshots + TestContext mainContext = CreateTestContext("SnapshotOrdering"); + await SetupKeyValues(mainContext); + await SetupFeatureFlags(mainContext); + + TestContext secondContext = CreateTestContext("SnapshotOrdering2"); + await SetupKeyValues(secondContext); + await SetupFeatureFlags(secondContext); + + TestContext thirdContext = CreateTestContext("SnapshotOrdering3"); + await SetupKeyValues(thirdContext); + + // Create specific values for each snapshot + await _configClient.SetConfigurationSettingAsync( + new ConfigurationSetting($"{mainContext.KeyPrefix}:UniqueMain", "MainValue")); + + await _configClient.SetConfigurationSettingAsync( + new ConfigurationSetting($"{secondContext.KeyPrefix}:UniqueSecond", "SecondValue")); + + await _configClient.SetConfigurationSettingAsync( + new ConfigurationSetting($"{thirdContext.KeyPrefix}:UniqueThird", "ThirdValue")); + + // Create additional feature flags + string secondFeatureFlagKey = $".appconfig.featureflag/{mainContext.KeyPrefix}Feature2"; + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + secondFeatureFlagKey, + @"{""id"":""" + mainContext.KeyPrefix + @"Feature2"",""description"":""Second test feature"",""enabled"":true}", + contentType: FeatureFlagContentType)); + + string thirdFeatureFlagKey = $".appconfig.featureflag/{secondContext.KeyPrefix}Feature"; + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + thirdFeatureFlagKey, + @"{""id"":""" + secondContext.KeyPrefix + @"Feature"",""description"":""Third test feature"",""enabled"":true}", + contentType: FeatureFlagContentType)); + + // Create snapshots + string snapshot1 = $"snapshot-{mainContext.KeyPrefix}-1"; + string snapshot2 = $"snapshot-{secondContext.KeyPrefix}-2"; + string snapshot3 = $"snapshot-{thirdContext.KeyPrefix}-3"; + + await CreateSnapshot(snapshot1, new List { new ConfigurationSettingsFilter(mainContext.KeyPrefix + "*") }); + await CreateSnapshot(snapshot2, new List { new ConfigurationSettingsFilter(secondContext.KeyPrefix + "*") }); + await CreateSnapshot(snapshot3, new List { new ConfigurationSettingsFilter(thirdContext.KeyPrefix + "*") }); + + try + { + // Test different orderings of SelectSnapshot, Select and UseFeatureFlags + + // Order 1: SelectSnapshot -> Select -> UseFeatureFlags + var config1 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.SelectSnapshot(snapshot1); + options.Select($"{mainContext.KeyPrefix}:*"); + options.UseFeatureFlags(ff => + { + ff.Select($"{mainContext.KeyPrefix}Feature*"); + }); + }) + .Build(); + + // Order 2: Select -> SelectSnapshot -> UseFeatureFlags + var config2 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{secondContext.KeyPrefix}:*"); + options.SelectSnapshot(snapshot2); + options.UseFeatureFlags(ff => + { + ff.Select($"{secondContext.KeyPrefix}Feature*"); + }); + }) + .Build(); + + // Order 3: UseFeatureFlags -> SelectSnapshot -> Select + var config3 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.UseFeatureFlags(); + options.SelectSnapshot(snapshot3); + options.Select($"{thirdContext.KeyPrefix}:*"); + }) + .Build(); + + // Order 4: Multiple snapshots with interleaved operations + var config4 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.SelectSnapshot(snapshot1); + options.UseFeatureFlags(ff => + { + ff.Select($"{mainContext.KeyPrefix}Feature*"); + }); + options.SelectSnapshot(snapshot2); + options.Select($"{secondContext.KeyPrefix}:*"); + options.UseFeatureFlags(ff => + { + ff.Select($"{secondContext.KeyPrefix}Feature*"); + }); + options.SelectSnapshot(snapshot3); + }) + .Build(); + + // Verify config1: Should have values from snapshot1 and feature flags from mainContext + Assert.Equal("InitialValue1", config1[$"{mainContext.KeyPrefix}:Setting1"]); + Assert.Equal("MainValue", config1[$"{mainContext.KeyPrefix}:UniqueMain"]); + Assert.Equal("False", config1[$"FeatureManagement:{mainContext.KeyPrefix}Feature"]); + Assert.Equal("True", config1[$"FeatureManagement:{mainContext.KeyPrefix}Feature2"]); + + // Verify config2: Should have values from snapshot2 and feature flags from secondContext + Assert.Equal("InitialValue1", config2[$"{secondContext.KeyPrefix}:Setting1"]); + Assert.Equal("SecondValue", config2[$"{secondContext.KeyPrefix}:UniqueSecond"]); + Assert.Equal("True", config2[$"FeatureManagement:{secondContext.KeyPrefix}Feature"]); + + // Verify config3: Should have values from snapshot3 and all feature flags + Assert.Equal("InitialValue1", config3[$"{thirdContext.KeyPrefix}:Setting1"]); + Assert.Equal("ThirdValue", config3[$"{thirdContext.KeyPrefix}:UniqueThird"]); + Assert.Equal("False", config3[$"FeatureManagement:{mainContext.KeyPrefix}Feature"]); + Assert.Equal("True", config3[$"FeatureManagement:{secondContext.KeyPrefix}Feature"]); + + // Verify config4: Should have values from all three snapshots + Assert.Equal("InitialValue1", config4[$"{mainContext.KeyPrefix}:Setting1"]); + Assert.Equal("MainValue", config4[$"{mainContext.KeyPrefix}:UniqueMain"]); + Assert.Equal("InitialValue1", config4[$"{secondContext.KeyPrefix}:Setting1"]); + Assert.Equal("SecondValue", config4[$"{secondContext.KeyPrefix}:UniqueSecond"]); + Assert.Equal("InitialValue1", config4[$"{thirdContext.KeyPrefix}:Setting1"]); + Assert.Equal("ThirdValue", config4[$"{thirdContext.KeyPrefix}:UniqueThird"]); + Assert.Equal("False", config4[$"FeatureManagement:{mainContext.KeyPrefix}Feature"]); + Assert.Equal("True", config4[$"FeatureManagement:{mainContext.KeyPrefix}Feature2"]); + Assert.Equal("True", config4[$"FeatureManagement:{secondContext.KeyPrefix}Feature"]); + } + finally + { + // Cleanup - Delete the snapshots + await _configClient.ArchiveSnapshotAsync(snapshot1); + await _configClient.ArchiveSnapshotAsync(snapshot2); + await _configClient.ArchiveSnapshotAsync(snapshot3); + } + } + + [Fact] + public async Task KeyVaultReferences_ResolveCorrectly() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("KeyVaultReference"); + await SetupKeyVaultReferences(testContext); + + // Act - Create configuration with Key Vault support + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*", KeyVaultReferenceLabel); + options.ConfigureKeyVault(kv => kv.SetCredential(_defaultAzureCredential)); + }) + .Build(); + + // Assert - Key Vault reference should be resolved to the secret value + Assert.Equal("SecretValue", config[testContext.KeyVaultReferenceKey]); + } + + /// + /// Tests that Key Vault secrets are properly cached to avoid unnecessary requests. + /// + [Fact] + public async Task KeyVaultReference_UsesCache_DoesNotCallKeyVaultAgain() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("KeyVaultCacheTest"); + await SetupKeyVaultReferences(testContext); + + // Create a monitoring client to track calls to Key Vault + int requestCount = 0; + var testSecretClient = new SecretClient( + _keyVaultEndpoint, + _defaultAzureCredential, + new SecretClientOptions + { + Transport = new HttpPipelineTransportWithRequestCount(() => requestCount++) + }); + + // Act + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*", KeyVaultReferenceLabel); + options.ConfigureKeyVault(kv => + { + kv.Register(testSecretClient); + }); + }) + .Build(); + + // First access should resolve from Key Vault + string firstValue = config[testContext.KeyVaultReferenceKey]; + int firstRequestCount = requestCount; + + // Second access should use the cache + string secondValue = config[testContext.KeyVaultReferenceKey]; + int secondRequestCount = requestCount; + + // Assert + Assert.Equal(testContext.SecretValue, firstValue); + Assert.Equal(testContext.SecretValue, secondValue); + Assert.Equal(1, firstRequestCount); // Should make exactly one request + Assert.Equal(firstRequestCount, secondRequestCount); // No additional requests for the second access + } + + [Fact] + public async Task KeyVaultReference_DifferentRefreshIntervals() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("KeyVaultDifferentIntervals"); + IConfigurationRefresher refresher = null; + + // Create a secret in Key Vault with short refresh interval + string secretName1 = $"test-secret1-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; + string secretValue1 = $"SecretValue1-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; + await _secretClient.SetSecretAsync(secretName1, secretValue1); + + // Create another secret in Key Vault with long refresh interval + string secretName2 = $"test-secret2-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; + string secretValue2 = $"SecretValue2-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; + await _secretClient.SetSecretAsync(secretName2, secretValue2); + + // Create Key Vault references in App Configuration + string keyVaultUri = _keyVaultEndpoint.ToString().TrimEnd('/'); + string kvRefKey1 = $"{testContext.KeyPrefix}:KeyVaultRef1"; + string kvRefKey2 = $"{testContext.KeyPrefix}:KeyVaultRef2"; + + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + kvRefKey1, + $@"{{""uri"":""{keyVaultUri}/secrets/{secretName1}""}}", + label: KeyVaultReferenceLabel, + contentType: KeyVaultConstants.ContentType + "; charset=utf-8")); + + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + kvRefKey2, + $@"{{""uri"":""{keyVaultUri}/secrets/{secretName2}""}}", + label: KeyVaultReferenceLabel, + contentType: KeyVaultConstants.ContentType + "; charset=utf-8")); + + // Act - Create configuration with different refresh intervals + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*", KeyVaultReferenceLabel); + options.ConfigureKeyVault(kv => + { + kv.SetCredential(_defaultAzureCredential); + // Set different refresh intervals for each secret + kv.SetSecretRefreshInterval(kvRefKey1, TimeSpan.FromSeconds(60)); // Short interval + kv.SetSecretRefreshInterval(kvRefKey2, TimeSpan.FromDays(1)); // Long interval + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + // Verify initial values + Assert.Equal(secretValue1, config[kvRefKey1]); + Assert.Equal(secretValue2, config[kvRefKey2]); + + // Update both secrets in Key Vault + string updatedValue1 = $"UpdatedValue1-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; + string updatedValue2 = $"UpdatedValue2-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; + + await _secretClient.SetSecretAsync(secretName1, updatedValue1); + await _secretClient.SetSecretAsync(secretName2, updatedValue2); + + // Wait for the short interval cache to expire + await Task.Delay(TimeSpan.FromSeconds(61)); + + // Refresh the configuration + await refresher.RefreshAsync(); + + // Assert - Only the first secret should be refreshed due to having a short interval + Assert.Equal(updatedValue1, config[kvRefKey1]); // Updated - short refresh interval + Assert.Equal(secretValue2, config[kvRefKey2]); // Not updated - long refresh interval + } + + [Fact] + public async Task RequestTracing_SetsCorrectCorrelationContextHeader() + { + // Arrange - Setup test-specific keys + TestContext testContext = CreateTestContext("RequestTracing"); + await SetupFeatureFlags(testContext); + await SetupKeyVaultReferences(testContext); + + // Used to trigger FMVer tag in request tracing + IFeatureManager featureManager; + + // Create a custom HttpPipeline that can inspect outgoing requests + var requestInspector = new RequestInspectionHandler(); + + await _configClient.SetConfigurationSettingAsync( + ConfigurationModelFactory.ConfigurationSetting( + testContext.FeatureFlagKey, + @"{ + ""id"": """ + testContext.KeyPrefix + @"Feature"", + ""description"": ""Test feature with filters"", + ""enabled"": true, + ""variants"": [ + { + ""name"": ""LargeSize"", + ""configuration_value"": ""800px"" + }, + { + ""name"": ""MediumSize"", + ""configuration_value"": ""600px"" + }, + { + ""name"": ""SmallSize"", + ""configuration_value"": ""400px"" + } + ], + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""Browser"", + ""parameters"": { + ""AllowedBrowsers"": [""Chrome"", ""Edge""] + } + }, + { + ""name"": ""TimeWindow"", + ""parameters"": { + ""Start"": ""\/Date(" + DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeMilliseconds() + @")\/"", + ""End"": ""\/Date(" + DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds() + @")\/"" + } + } + ] + } + }", + contentType: FeatureFlagContentType)); + + IConfigurationRefresher refresher = null; + + using HttpClientTransportWithRequestInspection transportWithRequestInspection = new HttpClientTransportWithRequestInspection(requestInspector); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*"); + options.ConfigureClientOptions(clientOptions => + { + clientOptions.Transport = transportWithRequestInspection; + }); + options.ConfigureKeyVault(kv => kv.SetCredential(_defaultAzureCredential)); + options.UseFeatureFlags(); + options.LoadBalancingEnabled = true; + refresher = options.GetRefresher(); + options.ConfigureRefresh(refresh => + { + refresh.RegisterAll(); + refresh.SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + }) + .Build(); + + // Assert - Verify correlation context headers + + // Basic request should have at least the request type + Assert.Contains(RequestTracingConstants.RequestTypeKey, requestInspector.CorrelationContextHeaders.Last()); + Assert.Contains("Startup", requestInspector.CorrelationContextHeaders.Last()); + Assert.Contains(RequestTracingConstants.KeyVaultConfiguredTag, requestInspector.CorrelationContextHeaders.Last()); + Assert.Contains(RequestTracingConstants.LoadBalancingEnabledTag, requestInspector.CorrelationContextHeaders.Last()); + + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedValue1")); + await Task.Delay(1500); + await refresher.RefreshAsync(); + + Assert.Contains("Watch", requestInspector.CorrelationContextHeaders.Last()); + Assert.Contains(RequestTracingConstants.FeatureFlagFilterTypeKey, requestInspector.CorrelationContextHeaders.Last()); + Assert.Contains(RequestTracingConstants.TimeWindowFilter, requestInspector.CorrelationContextHeaders.Last()); + Assert.Contains(RequestTracingConstants.CustomFilter, requestInspector.CorrelationContextHeaders.Last()); + Assert.Contains(RequestTracingConstants.FeatureFlagMaxVariantsKey, requestInspector.CorrelationContextHeaders.Last()); + Assert.Contains($"{RequestTracingConstants.FeatureManagementVersionKey}=4.0.0", requestInspector.CorrelationContextHeaders.Last()); + } + + [Fact] + public async Task TagFilters() + { + TestContext testContext = CreateTestContext("TagFilters"); + await SetupTaggedSettings(testContext); + string keyPrefix = testContext.KeyPrefix; + + // Test case 1: Basic tag filtering with single tag + var config1 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{keyPrefix}:*", tagFilters: new[] { "Environment=Development" }); + options.UseFeatureFlags(ff => + { + ff.Select($"{keyPrefix}:*", tagFilters: new[] { "Environment=Development" }); + }); + }) + .Build(); + + // Assert - Should only get settings with Environment=Development tag + Assert.Equal("Value1", config1[$"{keyPrefix}:TaggedSetting1"]); + Assert.Equal("Value3", config1[$"{keyPrefix}:TaggedSetting3"]); + Assert.Null(config1[$"{keyPrefix}:TaggedSetting2"]); + Assert.Null(config1[$"{keyPrefix}:TaggedSetting4"]); + Assert.Null(config1[$"{keyPrefix}:TaggedSetting5"]); + + // Feature flags should be filtered as well + Assert.Equal("True", config1[$"FeatureManagement:{keyPrefix}:FeatureDev"]); + Assert.Null(config1[$"FeatureManagement:{keyPrefix}:FeatureProd"]); + Assert.Null(config1[$"FeatureManagement:{keyPrefix}:FeatureSpecial"]); + Assert.Null(config1[$"FeatureManagement:{keyPrefix}:FeatureEmpty"]); + + // Test case 2: Multiple tag filters (AND condition) + var config2 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{keyPrefix}:*", tagFilters: new[] { "Environment=Development", "App=TestApp" }); + options.UseFeatureFlags(ff => + { + ff.Select($"{keyPrefix}:*", tagFilters: new[] { "Environment=Development", "App=TestApp" }); + }); + }) + .Build(); + + // Assert - Should only get settings with both Environment=Development AND App=TestApp tags + Assert.Equal("Value1", config2[$"{keyPrefix}:TaggedSetting1"]); + Assert.Null(config2[$"{keyPrefix}:TaggedSetting2"]); + Assert.Null(config2[$"{keyPrefix}:TaggedSetting3"]); + Assert.Null(config2[$"{keyPrefix}:TaggedSetting4"]); + Assert.Null(config2[$"{keyPrefix}:TaggedSetting5"]); + + // Feature flags + Assert.Equal("True", config2[$"FeatureManagement:{keyPrefix}:FeatureDev"]); + Assert.Null(config2[$"FeatureManagement:{keyPrefix}:FeatureProd"]); + Assert.Null(config2[$"FeatureManagement:{keyPrefix}:FeatureSpecial"]); + Assert.Null(config2[$"FeatureManagement:{keyPrefix}:FeatureEmpty"]); + + // Test case 3: Special characters in tags + var config3 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{keyPrefix}:*", tagFilters: new[] { "Special:Tag=Value:With:Colons" }); + options.UseFeatureFlags(ff => + { + ff.Select($"{keyPrefix}:*", tagFilters: new[] { "Special:Tag=Value:With:Colons" }); + }); + }) + .Build(); + + // Assert - Should only get settings with the special character tag + Assert.Equal("Value4", config3[$"{keyPrefix}:TaggedSetting4"]); + Assert.Null(config3[$"{keyPrefix}:TaggedSetting1"]); + + Assert.Null(config3[$"{keyPrefix}:TaggedSetting2"]); + Assert.Null(config3[$"{keyPrefix}:TaggedSetting3"]); + Assert.Null(config3[$"{keyPrefix}:TaggedSetting5"]); + + // Feature flags + Assert.Equal("True", config3[$"FeatureManagement:{keyPrefix}:FeatureSpecial"]); + Assert.Null(config3[$"FeatureManagement:{keyPrefix}:FeatureDev"]); + Assert.Null(config3[$"FeatureManagement:{keyPrefix}:FeatureProd"]); + Assert.Null(config3[$"FeatureManagement:{keyPrefix}:FeatureEmpty"]); + + // Test case 4: Tag with @ symbol + var config4 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{keyPrefix}:*", tagFilters: new[] { "Tag@With@At=Value@With@At" }); + }) + .Build(); + + // Assert - Should only get settings with the @ symbol tag + Assert.Equal("Value4", config4[$"{keyPrefix}:TaggedSetting4"]); + Assert.Null(config4[$"{keyPrefix}:TaggedSetting1"]); + Assert.Null(config4[$"{keyPrefix}:TaggedSetting2"]); + Assert.Null(config4[$"{keyPrefix}:TaggedSetting3"]); + Assert.Null(config4[$"{keyPrefix}:TaggedSetting5"]); + + // Test case 5: Empty and null tag values + var config5 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{keyPrefix}:*", tagFilters: new[] { "EmptyTag=", $"NullTag={TagValue.Null}" }); + options.UseFeatureFlags(ff => + { + ff.Select($"{keyPrefix}:*", tagFilters: new[] { "EmptyTag=", $"NullTag={TagValue.Null}" }); + }); + }) + .Build(); + + // Assert - Should only get settings with both empty and null tag values + Assert.Equal("Value5", config5[$"{keyPrefix}:TaggedSetting5"]); + Assert.Null(config5[$"{keyPrefix}:TaggedSetting1"]); + Assert.Null(config5[$"{keyPrefix}:TaggedSetting2"]); + Assert.Null(config5[$"{keyPrefix}:TaggedSetting3"]); + Assert.Null(config5[$"{keyPrefix}:TaggedSetting4"]); + + // Feature flags + Assert.Equal("False", config5[$"FeatureManagement:{keyPrefix}:FeatureEmpty"]); + Assert.Null(config5[$"FeatureManagement:{keyPrefix}:FeatureDev"]); + Assert.Null(config5[$"FeatureManagement:{keyPrefix}:FeatureProd"]); + Assert.Null(config5[$"FeatureManagement:{keyPrefix}:FeatureSpecial"]); + + // Test case 6: Interaction with refresh functionality + IConfigurationRefresher refresher = null; + var config9 = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{keyPrefix}:*", tagFilters: new[] { "Environment=Development" }); + options.ConfigureRefresh(refresh => + { + refresh.Register(testContext.SentinelKey, refreshAll: true) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + refresher = options.GetRefresher(); + }) + .Build(); + + // Assert initial state + Assert.Equal("Value1", config9[$"{keyPrefix}:TaggedSetting1"]); + Assert.Equal("Value3", config9[$"{keyPrefix}:TaggedSetting3"]); + + // Update a tagged setting's value + await _configClient.SetConfigurationSettingAsync( + CreateSettingWithTags( + $"{keyPrefix}:TaggedSetting1", + "UpdatedValue1", + new Dictionary { + { "Environment", "Development" }, + { "App", "TestApp" } + })); + + // Add a new setting with Development tag + await _configClient.SetConfigurationSettingAsync( + CreateSettingWithTags( + $"{keyPrefix}:TaggedSetting7", + "Value7", + new Dictionary { + { "Environment", "Development" } + })); + + // Update the sentinel key to trigger refresh + await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting(testContext.SentinelKey, "Updated")); + + // Wait for cache to expire + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Refresh the configuration + await refresher.RefreshAsync(); + + Assert.Equal("UpdatedValue1", config9[$"{keyPrefix}:TaggedSetting1"]); + Assert.Equal("Value3", config9[$"{keyPrefix}:TaggedSetting3"]); + Assert.Equal("Value7", config9[$"{keyPrefix}:TaggedSetting7"]); + Assert.Null(config9[$"{keyPrefix}:TaggedSetting2"]); + } + + private class HttpPipelineTransportWithRequestCount : HttpPipelineTransport + { + private readonly HttpClientTransport _innerTransport = new HttpClientTransport(); + private readonly Action _onRequest; + + public HttpPipelineTransportWithRequestCount(Action onRequest) + { + _onRequest = onRequest; + } + + public override Request CreateRequest() + { + return _innerTransport.CreateRequest(); + } + + public override void Process(HttpMessage message) + { + _onRequest(); + _innerTransport.Process(message); + } + + public override ValueTask ProcessAsync(HttpMessage message) + { + _onRequest(); + return _innerTransport.ProcessAsync(message); + } + } + + private class HttpClientTransportWithRequestInspection : HttpClientTransport + { + private readonly RequestInspectionHandler _inspector; + + public HttpClientTransportWithRequestInspection(RequestInspectionHandler inspector) + { + _inspector = inspector; + } + + public override async ValueTask ProcessAsync(HttpMessage message) + { + _inspector.InspectRequest(message); + await base.ProcessAsync(message); + } + } + + private class RequestInspectionHandler + { + public List CorrelationContextHeaders { get; } = new List(); + + public void InspectRequest(HttpMessage message) + { + if (message.Request.Headers.TryGetValue(RequestTracingConstants.CorrelationContextHeader, out string header)) + { + CorrelationContextHeaders.Add(header); + } + } + } + + private ConfigurationSetting CreateSettingWithTags(string key, string value, IDictionary tags) + { + var setting = new ConfigurationSetting(key, value); + + if (tags != null) + { + foreach (var tag in tags) + { + setting.Tags.Add(tag.Key, tag.Value); + } + } + + return setting; + } + + private ConfigurationSetting CreateFeatureFlagWithTags(string featureId, bool enabled, IDictionary tags) + { + string jsonValue = $@"{{ + ""id"": ""{featureId}"", + ""description"": ""Test feature flag with tags"", + ""enabled"": {enabled.ToString().ToLowerInvariant()}, + ""conditions"": {{ + ""client_filters"": [] + }} + }}"; + + var setting = new ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + featureId, + value: jsonValue) + { + ContentType = FeatureFlagContentType + }; + + if (tags != null) + { + foreach (var tag in tags) + { + setting.Tags.Add(tag.Key, tag.Value); + } + } + + return setting; + } + } +} diff --git a/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj b/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj index 49c58ec60..79f47316e 100644 --- a/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj +++ b/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj @@ -7,12 +7,17 @@ true ..\..\build\AzureAppConfiguration.snk false - false + true + + + + + @@ -23,7 +28,13 @@ - + + + + Always + + + diff --git a/tests/Tests.AzureAppConfiguration/CallbackMessageHandler.cs b/tests/Tests.AzureAppConfiguration/Unit/CallbackMessageHandler.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/CallbackMessageHandler.cs rename to tests/Tests.AzureAppConfiguration/Unit/CallbackMessageHandler.cs diff --git a/tests/Tests.AzureAppConfiguration/ClientOptionsTests.cs b/tests/Tests.AzureAppConfiguration/Unit/ClientOptionsTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/ClientOptionsTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/ClientOptionsTests.cs diff --git a/tests/Tests.AzureAppConfiguration/ConnectTests.cs b/tests/Tests.AzureAppConfiguration/Unit/ConnectTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/ConnectTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/ConnectTests.cs diff --git a/tests/Tests.AzureAppConfiguration/FailoverTests.cs b/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/FailoverTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs diff --git a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs diff --git a/tests/Tests.AzureAppConfiguration/HttpRequestCountPipelinePolicy.cs b/tests/Tests.AzureAppConfiguration/Unit/HttpRequestCountPipelinePolicy.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/HttpRequestCountPipelinePolicy.cs rename to tests/Tests.AzureAppConfiguration/Unit/HttpRequestCountPipelinePolicy.cs diff --git a/tests/Tests.AzureAppConfiguration/JsonContentTypeTests.cs b/tests/Tests.AzureAppConfiguration/Unit/JsonContentTypeTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/JsonContentTypeTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/JsonContentTypeTests.cs diff --git a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs b/tests/Tests.AzureAppConfiguration/Unit/KeyVaultReferenceTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/KeyVaultReferenceTests.cs diff --git a/tests/Tests.AzureAppConfiguration/LoadBalancingTests.cs b/tests/Tests.AzureAppConfiguration/Unit/LoadBalancingTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/LoadBalancingTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/LoadBalancingTests.cs diff --git a/tests/Tests.AzureAppConfiguration/LoggingTests.cs b/tests/Tests.AzureAppConfiguration/Unit/LoggingTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/LoggingTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/LoggingTests.cs diff --git a/tests/Tests.AzureAppConfiguration/MapTests.cs b/tests/Tests.AzureAppConfiguration/Unit/MapTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/MapTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/MapTests.cs diff --git a/tests/Tests.AzureAppConfiguration/MockedConfigurationClientManager.cs b/tests/Tests.AzureAppConfiguration/Unit/MockedConfigurationClientManager.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/MockedConfigurationClientManager.cs rename to tests/Tests.AzureAppConfiguration/Unit/MockedConfigurationClientManager.cs diff --git a/tests/Tests.AzureAppConfiguration/PushRefreshTests.cs b/tests/Tests.AzureAppConfiguration/Unit/PushRefreshTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/PushRefreshTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/PushRefreshTests.cs diff --git a/tests/Tests.AzureAppConfiguration/RefreshTests.cs b/tests/Tests.AzureAppConfiguration/Unit/RefreshTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/RefreshTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/RefreshTests.cs diff --git a/tests/Tests.AzureAppConfiguration/TagFiltersTests.cs b/tests/Tests.AzureAppConfiguration/Unit/TagFiltersTests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/TagFiltersTests.cs rename to tests/Tests.AzureAppConfiguration/Unit/TagFiltersTests.cs diff --git a/tests/Tests.AzureAppConfiguration/TestHelper.cs b/tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/TestHelper.cs rename to tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs diff --git a/tests/Tests.AzureAppConfiguration/Tests.cs b/tests/Tests.AzureAppConfiguration/Unit/Tests.cs similarity index 100% rename from tests/Tests.AzureAppConfiguration/Tests.cs rename to tests/Tests.AzureAppConfiguration/Unit/Tests.cs From 96968abd809a8d308b6ccdc54bb4fa746c20c77a Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Thu, 29 May 2025 12:11:52 +0800 Subject: [PATCH 2/8] Support health check (#644) * support health check * update * reord last successful time in ExecuteWithFailoverPolicy * make health check compatible with DI * add health check for each provider instance * update * update comment --- .../AzureAppConfigurationHealthCheck.cs | 75 ++++++++++ ...figurationHealthChecksBuilderExtensions.cs | 45 ++++++ .../AzureAppConfigurationProvider.cs | 27 +++- .../Constants/HealthCheckConstants.cs | 14 ++ ...Configuration.AzureAppConfiguration.csproj | 1 + .../HealthCheckTest.cs | 140 ++++++++++++++++++ 6 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/HealthCheckConstants.cs create mode 100644 tests/Tests.AzureAppConfiguration/HealthCheckTest.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs new file mode 100644 index 000000000..76b706c07 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Diagnostics.HealthChecks; +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; +using System.Threading; +using System.Linq; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + internal class AzureAppConfigurationHealthCheck : IHealthCheck + { + private static readonly PropertyInfo _propertyInfo = typeof(ChainedConfigurationProvider).GetProperty("Configuration", BindingFlags.Public | BindingFlags.Instance); + private readonly IEnumerable _healthChecks; + + public AzureAppConfigurationHealthCheck(IConfiguration configuration) + { + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + var healthChecks = new List(); + var configurationRoot = configuration as IConfigurationRoot; + FindHealthChecks(configurationRoot, healthChecks); + + _healthChecks = healthChecks; + } + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + if (!_healthChecks.Any()) + { + return HealthCheckResult.Unhealthy(HealthCheckConstants.NoProviderFoundMessage); + } + + foreach (IHealthCheck healthCheck in _healthChecks) + { + var result = await healthCheck.CheckHealthAsync(context, cancellationToken).ConfigureAwait(false); + + if (result.Status == HealthStatus.Unhealthy) + { + return result; + } + } + + return HealthCheckResult.Healthy(); + } + + private void FindHealthChecks(IConfigurationRoot configurationRoot, List healthChecks) + { + if (configurationRoot != null) + { + foreach (IConfigurationProvider provider in configurationRoot.Providers) + { + if (provider is AzureAppConfigurationProvider appConfigurationProvider) + { + healthChecks.Add(appConfigurationProvider); + } + else if (provider is ChainedConfigurationProvider chainedProvider) + { + if (_propertyInfo != null) + { + var chainedProviderConfigurationRoot = _propertyInfo.GetValue(chainedProvider) as IConfigurationRoot; + FindHealthChecks(chainedProviderConfigurationRoot, healthChecks); + } + } + } + } + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs new file mode 100644 index 000000000..f006b7464 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using System; +using System.Collections.Generic; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extension methods to configure . + /// + public static class AzureAppConfigurationHealthChecksBuilderExtensions + { + /// + /// Add a health check for Azure App Configuration to given . + /// + /// The to add to. + /// A factory to obtain instance. + /// The health check name. + /// The that should be reported when the health check fails. + /// A list of tags that can be used to filter sets of health checks. + /// A representing the timeout of the check. + /// The provided health checks builder. + public static IHealthChecksBuilder AddAzureAppConfiguration( + this IHealthChecksBuilder builder, + Func factory = default, + string name = HealthCheckConstants.HealthCheckRegistrationName, + HealthStatus failureStatus = default, + IEnumerable tags = default, + TimeSpan? timeout = default) + { + return builder.Add(new HealthCheckRegistration( + name ?? HealthCheckConstants.HealthCheckRegistrationName, + sp => new AzureAppConfigurationHealthCheck( + factory?.Invoke(sp) ?? sp.GetRequiredService()), + failureStatus, + tags, + timeout)); + } + } +} + diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index a83c74135..6b100f8da 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -5,6 +5,7 @@ using Azure.Data.AppConfiguration; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; @@ -21,7 +22,7 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { - internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigurationRefresher, IDisposable + internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigurationRefresher, IHealthCheck, IDisposable { private readonly ActivitySource _activitySource = new ActivitySource(ActivityNames.AzureAppConfigurationActivitySource); private bool _optional; @@ -53,6 +54,10 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura private Logger _logger = new Logger(); private ILoggerFactory _loggerFactory; + // For health check + private DateTimeOffset? _lastSuccessfulAttempt = null; + private DateTimeOffset? _lastFailedAttempt = null; + private class ConfigurationClientBackoffStatus { public int FailedAttempts { get; set; } @@ -256,6 +261,8 @@ public async Task RefreshAsync(CancellationToken cancellationToken) _logger.LogDebug(LogHelper.BuildRefreshSkippedNoClientAvailableMessage()); + _lastFailedAttempt = DateTime.UtcNow; + return; } @@ -571,6 +578,22 @@ public void ProcessPushNotification(PushNotification pushNotification, TimeSpan? } } + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + if (!_lastSuccessfulAttempt.HasValue) + { + return HealthCheckResult.Unhealthy(HealthCheckConstants.LoadNotCompletedMessage); + } + + if (_lastFailedAttempt.HasValue && + _lastSuccessfulAttempt.Value < _lastFailedAttempt.Value) + { + return HealthCheckResult.Unhealthy(HealthCheckConstants.RefreshFailedMessage); + } + + return HealthCheckResult.Healthy(); + } + private void SetDirty(TimeSpan? maxDelay) { DateTimeOffset nextRefreshTime = AddRandomDelay(DateTimeOffset.UtcNow, maxDelay ?? DefaultMaxSetDirtyDelay); @@ -1158,6 +1181,7 @@ private async Task ExecuteWithFailOverPolicyAsync( success = true; _lastSuccessfulEndpoint = _configClientManager.GetEndpointForClient(currentClient); + _lastSuccessfulAttempt = DateTime.UtcNow; return result; } @@ -1183,6 +1207,7 @@ private async Task ExecuteWithFailOverPolicyAsync( { if (!success && backoffAllClients) { + _lastFailedAttempt = DateTime.UtcNow; _logger.LogWarning(LogHelper.BuildLastEndpointFailedMessage(previousEndpoint?.ToString())); do diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/HealthCheckConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/HealthCheckConstants.cs new file mode 100644 index 000000000..069398158 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/HealthCheckConstants.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + internal class HealthCheckConstants + { + public const string HealthCheckRegistrationName = "Microsoft.Extensions.Configuration.AzureAppConfiguration"; + public const string NoProviderFoundMessage = "No configuration provider is found."; + public const string LoadNotCompletedMessage = "The initial load is not completed."; + public const string RefreshFailedMessage = "The last refresh attempt failed."; + } +} 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 87c4251dd..7934e5e4e 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -19,6 +19,7 @@ + diff --git a/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs b/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs new file mode 100644 index 000000000..9dce8e826 --- /dev/null +++ b/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure; +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Moq; +using System.Threading; +using System.Collections.Generic; +using System.Threading.Tasks; +using Xunit; +using System; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; + +namespace Tests.AzureAppConfiguration +{ + public class HealthCheckTest + { + readonly List kvCollection = new List + { + ConfigurationModelFactory.ConfigurationSetting("TestKey1", "TestValue1", "label", + eTag: new ETag("0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63"), + contentType:"text"), + ConfigurationModelFactory.ConfigurationSetting("TestKey2", "TestValue2", "label", + eTag: new ETag("31c38369-831f-4bf1-b9ad-79db56c8b989"), + contentType: "text"), + ConfigurationModelFactory.ConfigurationSetting("TestKey3", "TestValue3", "label", + + eTag: new ETag("bb203f2b-c113-44fc-995d-b933c2143339"), + contentType: "text"), + ConfigurationModelFactory.ConfigurationSetting("TestKey4", "TestValue4", "label", + eTag: new ETag("3ca43b3e-d544-4b0c-b3a2-e7a7284217a2"), + contentType: "text"), + }; + + [Fact] + public async Task HealthCheckTests_ReturnsHealthyWhenInitialLoadIsCompleted() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(kvCollection)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + }) + .Build(); + + IHealthCheck healthCheck = new AzureAppConfigurationHealthCheck(config); + + Assert.True(config["TestKey1"] == "TestValue1"); + var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + [Fact] + public async Task HealthCheckTests_ReturnsUnhealthyWhenRefreshFailed() + { + IConfigurationRefresher refresher = null; + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(kvCollection)) + .Throws(new RequestFailedException(503, "Request failed.")) + .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())) + .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.MinBackoffDuration = TimeSpan.FromSeconds(2); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.RegisterAll() + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + refresher = options.GetRefresher(); + }) + .Build(); + + IHealthCheck healthCheck = new AzureAppConfigurationHealthCheck(config); + + var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Healthy, result.Status); + + // Wait for the refresh interval to expire + Thread.Sleep(1000); + + await refresher.TryRefreshAsync(); + result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Unhealthy, result.Status); + + // Wait for client backoff to end + Thread.Sleep(3000); + + await refresher.RefreshAsync(); + result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + [Fact] + public async Task HealthCheckTests_RegisterAzureAppConfigurationHealthCheck() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(kvCollection)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + }) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(config); + services.AddLogging(); // add logging for health check service + services.AddHealthChecks() + .AddAzureAppConfiguration(); + var provider = services.BuildServiceProvider(); + var healthCheckService = provider.GetRequiredService(); + + var result = await healthCheckService.CheckHealthAsync(); + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Contains(HealthCheckConstants.HealthCheckRegistrationName, result.Entries.Keys); + Assert.Equal(HealthStatus.Healthy, result.Entries[HealthCheckConstants.HealthCheckRegistrationName].Status); + } + } +} From 41d83206a5bb962ee5f6d4586981f97e7df82d16 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Tue, 17 Jun 2025 12:11:44 -0700 Subject: [PATCH 3/8] Add Copilot instructions (#667) * add copilot instructions * fix grammar * Update .github/code-gen-instructions.md Co-authored-by: Jimmy Campbell --------- Co-authored-by: Jimmy Campbell --- .github/code-gen-instructions.md | 93 ++++++++++++++++++++++++++++++++ .github/copilot-instructions.md | 3 ++ 2 files changed, 96 insertions(+) create mode 100644 .github/code-gen-instructions.md create mode 100644 .github/copilot-instructions.md diff --git a/.github/code-gen-instructions.md b/.github/code-gen-instructions.md new file mode 100644 index 000000000..edddb668b --- /dev/null +++ b/.github/code-gen-instructions.md @@ -0,0 +1,93 @@ +# AppConfiguration-DotnetProvider Coding Guidelines + +This document outlines coding guidelines for the Azure App Configuration .NET Provider repository. Follow these guidelines when generating or modifying code. + +## General Guidelines + +1. **Exception Handling**: + * When adding error handling, always catch specific exceptions and avoid catching the base `Exception` class in catch blocks. + * Throw specific exception types (e.g., `ArgumentNullException`, `FormatException`, custom exceptions) rather than generic `System.Exception`. + * Include the parameter name when throwing `ArgumentNullException` using `nameof()`. + +2. **Variable Declaration**: + * Never use `var` to declare a variable if the assignment doesn't include the type or the type isn't immediately obvious. + * Use explicit type names for fields, properties, method parameters, and return types. + * Use `var` only when the type is obvious from the right-hand side (e.g., `var user = new User();`). + +3. **Null Handling**: + * Validate arguments in public methods and constructors with explicit null checks. + * Use explicit `if (argument == null) throw new ArgumentNullException(nameof(argument));` checks at the beginning of methods/constructors. + * Avoid using the null-forgiving operator (`!`) unless absolutely necessary. + +4. **Asynchronous Programming**: + * All async methods should accept a `CancellationToken` as the last parameter. + * Pass the `cancellationToken` down the call stack to all subsequent asynchronous operations. + * Use `Task` or `Task` for asynchronous methods. + +5. **LINQ and Collections**: + * Prefer simple, readable LINQ queries. + * Break down complex LINQ queries into separate statements with intermediate variables. + * Use collection interfaces (e.g., `IList`, `IReadOnlyList`) in parameter and return types. + +6. **Resource Management**: + * Wrap `IDisposable` instances in `using` statements to ensure proper disposal. + * Implement `IDisposable` correctly if your class manages disposable objects. + +7. **Dependency Injection**: + * Use constructor injection for dependencies. + * Store injected dependencies in `private readonly` fields. + * Validate injected dependencies for null in the constructor. + +8. **Naming Conventions**: + * Use `PascalCase` for classes, interfaces, enums, methods, properties, and constants. + * Use `camelCase` for local variables and method parameters. + * Prefix private fields with an underscore (`_`). + * Define constants for error messages and other string literals. + +9. **Comments**: + * Only add comments when it's not obvious what the code is doing. For example, if a variable name is already fairly descriptive, a comment isn't needed explaining its name. + * Add summary comments to public classes and members of those classes. + +## AppConfiguration-Specific Guidelines + +1. **Feature Flag Handling**: + * Validate feature flag data structure before processing. + * Handle different feature flag schemas (Microsoft vs .NET) appropriately. + * Use proper error handling when parsing feature flags with clear error messages. + +2. **Configuration Key-Value Processing**: + * Follow adapter pattern for processing different configuration types. + * Properly handle key-value pairs with appropriate content type detection. + * Use `KeyValuePair` for configuration values. + +3. **Content Type Handling**: + * Validate content types before processing. + * Use appropriate content type constants. + * Check content type using extension methods like `IsFeatureFlag()`. + +4. **JSON Parsing**: + * Use `Utf8JsonReader` for performance-critical JSON parsing. + * Validate JSON structure and provide clear error messages for malformed input. + * Handle JSON token types appropriately with proper error handling. + +5. **Refresh Mechanisms**: + * Implement proper configuration refresh patterns. + * Use sentinel-based refresh mechanisms when appropriate. + * Handle refresh failures gracefully. + +## Performance Considerations + +1. **String Handling**: + * Use `StringBuilder` for concatenating multiple strings. + * Define string constants for recurring strings. + * Use string interpolation instead of string concatenation when appropriate. + +2. **Collections**: + * Initialize collections with estimated capacity when possible. + * Use appropriate collection types for the use case (e.g., `List`, `Dictionary`). + * Avoid unnecessary collection allocations. + +3. **Memory Management**: + * Use `Span` and `ReadOnlySpan` for high-performance scenarios. + * Minimize allocations in performance-critical paths. + * Be mindful of closure allocations in LINQ and lambdas. \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..29084da31 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,3 @@ +This is the Azure App Configuration .NET Provider codebase. The service abides by coding guidelines specified in the `github/code-gen-instructions.md` file. + +When suggesting code changes, do not modify the files directly. Instead, provide a detailed explanation of the changes you would make and ask for confirmation before editing the files. You may create markdown files to demonstrate the changes you would like to make. From f2128f42dec71fe656e9826c58bdf63559070de0 Mon Sep 17 00:00:00 2001 From: Richard Muniu Date: Wed, 25 Jun 2025 01:55:13 +0300 Subject: [PATCH 4/8] Add CodeQL exclusions for code samples --- CodeQL.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 CodeQL.yml diff --git a/CodeQL.yml b/CodeQL.yml new file mode 100644 index 000000000..01662492c --- /dev/null +++ b/CodeQL.yml @@ -0,0 +1,10 @@ +path_classifiers: + docs: + # Documentation + - "examples" # Exclude code samples from scan results + library: + # Library code + - "" + generated: + # Generated code + - "" From d5a0462451fe96bef3152187ea91651a871eb2c8 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Tue, 24 Jun 2025 16:57:09 -0700 Subject: [PATCH 5/8] Fix ConfigStoreDemo sample for CodeQL alerts (#677) * initial fixes to jquery for codeql alerts * add other wwwroot files * remove comment * redo line endings on configstoredemo sample files * update site css to fit title * fix title spacing * update to aspnet ajax instead --- .gitattributes | 6 + examples/ConfigStoreDemo/Pages/_Layout.cshtml | 70 +- .../Pages/_ValidationScriptsPartial.cshtml | 12 +- examples/ConfigStoreDemo/libman.json | 26 + examples/ConfigStoreDemo/wwwroot/css/site.css | 6 +- .../wwwroot/lib/bootstrap/.bower.json | 45 - .../wwwroot/lib/bootstrap/LICENSE | 21 - .../lib/bootstrap/css/bootstrap-grid.css | 4085 ++++++ .../lib/bootstrap/css/bootstrap-grid.css.map | 1 + .../lib/bootstrap/css/bootstrap-grid.min.css | 6 + .../bootstrap/css/bootstrap-grid.min.css.map | 1 + .../lib/bootstrap/css/bootstrap-grid.rtl.css | 4084 ++++++ .../bootstrap/css/bootstrap-grid.rtl.css.map | 1 + .../bootstrap/css/bootstrap-grid.rtl.min.css | 6 + .../css/bootstrap-grid.rtl.min.css.map | 1 + .../lib/bootstrap/css/bootstrap-reboot.css | 597 + .../bootstrap/css/bootstrap-reboot.css.map | 1 + .../bootstrap/css/bootstrap-reboot.min.css | 6 + .../css/bootstrap-reboot.min.css.map | 1 + .../bootstrap/css/bootstrap-reboot.rtl.css | 594 + .../css/bootstrap-reboot.rtl.css.map | 1 + .../css/bootstrap-reboot.rtl.min.css | 6 + .../css/bootstrap-reboot.rtl.min.css.map | 1 + .../lib/bootstrap/css/bootstrap-utilities.css | 5402 +++++++ .../bootstrap/css/bootstrap-utilities.css.map | 1 + .../bootstrap/css/bootstrap-utilities.min.css | 6 + .../css/bootstrap-utilities.min.css.map | 1 + .../bootstrap/css/bootstrap-utilities.rtl.css | 5393 +++++++ .../css/bootstrap-utilities.rtl.css.map | 1 + .../css/bootstrap-utilities.rtl.min.css | 6 + .../css/bootstrap-utilities.rtl.min.css.map | 1 + .../wwwroot/lib/bootstrap/css/bootstrap.css | 12057 ++++++++++++++++ .../lib/bootstrap/css/bootstrap.css.map | 1 + .../lib/bootstrap/css/bootstrap.min.css | 6 + .../lib/bootstrap/css/bootstrap.min.css.map | 1 + .../lib/bootstrap/css/bootstrap.rtl.css | 12030 +++++++++++++++ .../lib/bootstrap/css/bootstrap.rtl.css.map | 1 + .../lib/bootstrap/css/bootstrap.rtl.min.css | 6 + .../bootstrap/css/bootstrap.rtl.min.css.map | 1 + .../bootstrap/dist/css/bootstrap-theme.css | 587 - .../dist/css/bootstrap-theme.css.map | 1 - .../dist/css/bootstrap-theme.min.css.map | 1 - .../lib/bootstrap/dist/css/bootstrap.css | 6757 --------- .../lib/bootstrap/dist/css/bootstrap.css.map | 1 - .../bootstrap/dist/css/bootstrap.min.css.map | 1 - .../fonts/glyphicons-halflings-regular.eot | Bin 20127 -> 0 bytes .../fonts/glyphicons-halflings-regular.svg | 288 - .../fonts/glyphicons-halflings-regular.ttf | Bin 45404 -> 0 bytes .../fonts/glyphicons-halflings-regular.woff | Bin 23424 -> 0 bytes .../fonts/glyphicons-halflings-regular.woff2 | Bin 18028 -> 0 bytes .../lib/bootstrap/dist/js/bootstrap.js | 2377 --- .../wwwroot/lib/bootstrap/dist/js/npm.js | 13 - .../lib/bootstrap/js/bootstrap.bundle.js | 6314 ++++++++ .../lib/bootstrap/js/bootstrap.bundle.js.map | 1 + .../lib/bootstrap/js/bootstrap.bundle.min.js | 7 + .../bootstrap/js/bootstrap.bundle.min.js.map | 1 + .../wwwroot/lib/bootstrap/js/bootstrap.esm.js | 4447 ++++++ .../lib/bootstrap/js/bootstrap.esm.js.map | 1 + .../lib/bootstrap/js/bootstrap.esm.min.js | 7 + .../lib/bootstrap/js/bootstrap.esm.min.js.map | 1 + .../wwwroot/lib/bootstrap/js/bootstrap.js | 4494 ++++++ .../wwwroot/lib/bootstrap/js/bootstrap.js.map | 1 + .../wwwroot/lib/bootstrap/js/bootstrap.min.js | 7 + .../lib/bootstrap/js/bootstrap.min.js.map | 1 + .../lib/bootstrap/scss/_accordion.scss | 158 + .../wwwroot/lib/bootstrap/scss/_alert.scss | 68 + .../wwwroot/lib/bootstrap/scss/_badge.scss | 38 + .../lib/bootstrap/scss/_breadcrumb.scss | 40 + .../lib/bootstrap/scss/_button-group.scss | 142 + .../wwwroot/lib/bootstrap/scss/_buttons.scss | 216 + .../wwwroot/lib/bootstrap/scss/_card.scss | 239 + .../wwwroot/lib/bootstrap/scss/_carousel.scss | 236 + .../wwwroot/lib/bootstrap/scss/_close.scss | 63 + .../lib/bootstrap/scss/_containers.scss | 41 + .../wwwroot/lib/bootstrap/scss/_dropdown.scss | 250 + .../wwwroot/lib/bootstrap/scss/_forms.scss | 9 + .../lib/bootstrap/scss/_functions.scss | 302 + .../wwwroot/lib/bootstrap/scss/_grid.scss | 39 + .../wwwroot/lib/bootstrap/scss/_helpers.scss | 12 + .../wwwroot/lib/bootstrap/scss/_images.scss | 42 + .../lib/bootstrap/scss/_list-group.scss | 197 + .../wwwroot/lib/bootstrap/scss/_maps.scss | 174 + .../wwwroot/lib/bootstrap/scss/_mixins.scss | 42 + .../wwwroot/lib/bootstrap/scss/_modal.scss | 236 + .../wwwroot/lib/bootstrap/scss/_nav.scss | 197 + .../wwwroot/lib/bootstrap/scss/_navbar.scss | 289 + .../lib/bootstrap/scss/_offcanvas.scss | 143 + .../lib/bootstrap/scss/_pagination.scss | 109 + .../lib/bootstrap/scss/_placeholders.scss | 51 + .../wwwroot/lib/bootstrap/scss/_popover.scss | 196 + .../wwwroot/lib/bootstrap/scss/_progress.scss | 68 + .../wwwroot/lib/bootstrap/scss/_reboot.scss | 611 + .../wwwroot/lib/bootstrap/scss/_root.scss | 187 + .../wwwroot/lib/bootstrap/scss/_spinners.scss | 85 + .../wwwroot/lib/bootstrap/scss/_tables.scss | 171 + .../wwwroot/lib/bootstrap/scss/_toasts.scss | 73 + .../wwwroot/lib/bootstrap/scss/_tooltip.scss | 119 + .../lib/bootstrap/scss/_transitions.scss | 27 + .../wwwroot/lib/bootstrap/scss/_type.scss | 106 + .../lib/bootstrap/scss/_utilities.scss | 806 ++ .../lib/bootstrap/scss/_variables-dark.scss | 87 + .../lib/bootstrap/scss/_variables.scss | 1751 +++ .../lib/bootstrap/scss/bootstrap-grid.scss | 62 + .../lib/bootstrap/scss/bootstrap-reboot.scss | 10 + .../bootstrap/scss/bootstrap-utilities.scss | 19 + .../wwwroot/lib/bootstrap/scss/bootstrap.scss | 52 + .../scss/forms/_floating-labels.scss | 95 + .../lib/bootstrap/scss/forms/_form-check.scss | 189 + .../bootstrap/scss/forms/_form-control.scss | 214 + .../lib/bootstrap/scss/forms/_form-range.scss | 91 + .../bootstrap/scss/forms/_form-select.scss | 80 + .../lib/bootstrap/scss/forms/_form-text.scss | 11 + .../bootstrap/scss/forms/_input-group.scss | 132 + .../lib/bootstrap/scss/forms/_labels.scss | 36 + .../lib/bootstrap/scss/forms/_validation.scss | 12 + .../lib/bootstrap/scss/helpers/_clearfix.scss | 3 + .../lib/bootstrap/scss/helpers/_color-bg.scss | 7 + .../scss/helpers/_colored-links.scss | 30 + .../bootstrap/scss/helpers/_focus-ring.scss | 5 + .../bootstrap/scss/helpers/_icon-link.scss | 25 + .../lib/bootstrap/scss/helpers/_position.scss | 36 + .../lib/bootstrap/scss/helpers/_ratio.scss | 26 + .../lib/bootstrap/scss/helpers/_stacks.scss | 15 + .../scss/helpers/_stretched-link.scss | 15 + .../scss/helpers/_text-truncation.scss | 7 + .../scss/helpers/_visually-hidden.scss | 8 + .../lib/bootstrap/scss/helpers/_vr.scss | 8 + .../lib/bootstrap/scss/mixins/_alert.scss | 18 + .../lib/bootstrap/scss/mixins/_backdrop.scss | 14 + .../lib/bootstrap/scss/mixins/_banner.scss | 7 + .../bootstrap/scss/mixins/_border-radius.scss | 78 + .../bootstrap/scss/mixins/_box-shadow.scss | 18 + .../bootstrap/scss/mixins/_breakpoints.scss | 127 + .../lib/bootstrap/scss/mixins/_buttons.scss | 70 + .../lib/bootstrap/scss/mixins/_caret.scss | 69 + .../lib/bootstrap/scss/mixins/_clearfix.scss | 9 + .../bootstrap/scss/mixins/_color-mode.scss | 21 + .../bootstrap/scss/mixins/_color-scheme.scss | 7 + .../lib/bootstrap/scss/mixins/_container.scss | 11 + .../lib/bootstrap/scss/mixins/_deprecate.scss | 10 + .../lib/bootstrap/scss/mixins/_forms.scss | 163 + .../lib/bootstrap/scss/mixins/_gradients.scss | 47 + .../lib/bootstrap/scss/mixins/_grid.scss | 151 + .../lib/bootstrap/scss/mixins/_image.scss | 16 + .../bootstrap/scss/mixins/_list-group.scss | 26 + .../lib/bootstrap/scss/mixins/_lists.scss | 7 + .../bootstrap/scss/mixins/_pagination.scss | 10 + .../bootstrap/scss/mixins/_reset-text.scss | 17 + .../lib/bootstrap/scss/mixins/_resize.scss | 6 + .../scss/mixins/_table-variants.scss | 24 + .../bootstrap/scss/mixins/_text-truncate.scss | 8 + .../bootstrap/scss/mixins/_transition.scss | 26 + .../lib/bootstrap/scss/mixins/_utilities.scss | 97 + .../scss/mixins/_visually-hidden.scss | 33 + .../lib/bootstrap/scss/utilities/_api.scss | 47 + .../lib/bootstrap/scss/vendor/_rfs.scss | 348 + .../jquery-validation-unobtrusive/.bower.json | 86 +- .../jquery.validate.unobtrusive.js | 851 +- .../jquery.validate.unobtrusive.min.js | 13 +- .../wwwroot/lib/jquery-validation/.bower.json | 40 - .../wwwroot/lib/jquery-validation/LICENSE.md | 22 - .../jquery-validation/additional-methods.js | 1505 ++ .../additional-methods.min.js | 4 + .../dist/additional-methods.js | 998 -- .../jquery-validation-sri.json | 1172 ++ .../{dist => }/jquery.validate.js | 921 +- .../jquery-validation/jquery.validate.min.js | 4 + .../localization/messages_ar.js | 64 + .../localization/messages_ar.min.js | 4 + .../localization/messages_az.js | 35 + .../localization/messages_az.min.js | 4 + .../localization/messages_bg.js | 35 + .../localization/messages_bg.min.js | 4 + .../localization/messages_bn_BD.js | 35 + .../localization/messages_bn_BD.min.js | 4 + .../localization/messages_ca.js | 35 + .../localization/messages_ca.min.js | 4 + .../localization/messages_cs.js | 36 + .../localization/messages_cs.min.js | 4 + .../localization/messages_da.js | 46 + .../localization/messages_da.min.js | 4 + .../localization/messages_de.js | 82 + .../localization/messages_de.min.js | 4 + .../localization/messages_el.js | 35 + .../localization/messages_el.min.js | 4 + .../localization/messages_es.js | 38 + .../localization/messages_es.min.js | 4 + .../localization/messages_es_AR.js | 39 + .../localization/messages_es_AR.min.js | 4 + .../localization/messages_es_PE.js | 39 + .../localization/messages_es_PE.min.js | 4 + .../localization/messages_et.js | 33 + .../localization/messages_et.min.js | 4 + .../localization/messages_eu.js | 35 + .../localization/messages_eu.min.js | 4 + .../localization/messages_fa.js | 39 + .../localization/messages_fa.min.js | 4 + .../localization/messages_fi.js | 33 + .../localization/messages_fi.min.js | 4 + .../localization/messages_fr.js | 65 + .../localization/messages_fr.min.js | 4 + .../localization/messages_ge.js | 35 + .../localization/messages_ge.min.js | 4 + .../localization/messages_gl.js | 40 + .../localization/messages_gl.min.js | 4 + .../localization/messages_he.js | 35 + .../localization/messages_he.min.js | 4 + .../localization/messages_hi.js | 54 + .../localization/messages_hi.min.js | 4 + .../localization/messages_hr.js | 35 + .../localization/messages_hr.min.js | 4 + .../localization/messages_hu.js | 35 + .../localization/messages_hu.min.js | 4 + .../localization/messages_hy_AM.js | 35 + .../localization/messages_hy_AM.min.js | 4 + .../localization/messages_id.js | 34 + .../localization/messages_id.min.js | 4 + .../localization/messages_is.js | 33 + .../localization/messages_is.min.js | 4 + .../localization/messages_it.js | 39 + .../localization/messages_it.min.js | 4 + .../localization/messages_ja.js | 36 + .../localization/messages_ja.min.js | 4 + .../localization/messages_ka.js | 35 + .../localization/messages_ka.min.js | 4 + .../localization/messages_kk.js | 35 + .../localization/messages_kk.min.js | 4 + .../localization/messages_ko.js | 35 + .../localization/messages_ko.min.js | 4 + .../localization/messages_lt.js | 35 + .../localization/messages_lt.min.js | 4 + .../localization/messages_lv.js | 35 + .../localization/messages_lv.min.js | 4 + .../localization/messages_mk.js | 35 + .../localization/messages_mk.min.js | 4 + .../localization/messages_my.js | 35 + .../localization/messages_my.min.js | 4 + .../localization/messages_nl.js | 46 + .../localization/messages_nl.min.js | 4 + .../localization/messages_no.js | 35 + .../localization/messages_no.min.js | 4 + .../localization/messages_pl.js | 38 + .../localization/messages_pl.min.js | 4 + .../localization/messages_pt_BR.js | 91 + .../localization/messages_pt_BR.min.js | 4 + .../localization/messages_pt_PT.js | 39 + .../localization/messages_pt_PT.min.js | 4 + .../localization/messages_ro.js | 35 + .../localization/messages_ro.min.js | 4 + .../localization/messages_ru.js | 35 + .../localization/messages_ru.min.js | 4 + .../localization/messages_sd.js | 35 + .../localization/messages_sd.min.js | 4 + .../localization/messages_si.js | 35 + .../localization/messages_si.min.js | 4 + .../localization/messages_sk.js | 33 + .../localization/messages_sk.min.js | 4 + .../localization/messages_sl.js | 35 + .../localization/messages_sl.min.js | 4 + .../localization/messages_sr.js | 36 + .../localization/messages_sr.min.js | 4 + .../localization/messages_sr_lat.js | 36 + .../localization/messages_sr_lat.min.js | 4 + .../localization/messages_sv.js | 35 + .../localization/messages_sv.min.js | 4 + .../localization/messages_th.js | 35 + .../localization/messages_th.min.js | 4 + .../localization/messages_tj.js | 35 + .../localization/messages_tj.min.js | 4 + .../localization/messages_tr.js | 37 + .../localization/messages_tr.min.js | 4 + .../localization/messages_uk.js | 35 + .../localization/messages_uk.min.js | 4 + .../localization/messages_ur.js | 35 + .../localization/messages_ur.min.js | 4 + .../localization/messages_vi.js | 35 + .../localization/messages_vi.min.js | 4 + .../localization/messages_zh.js | 36 + .../localization/messages_zh.min.js | 4 + .../localization/messages_zh_TW.js | 37 + .../localization/messages_zh_TW.min.js | 4 + .../localization/methods_de.js | 24 + .../localization/methods_de.min.js | 4 + .../localization/methods_es_CL.js | 24 + .../localization/methods_es_CL.min.js | 4 + .../localization/methods_fi.js | 24 + .../localization/methods_fi.min.js | 4 + .../localization/methods_it.js | 24 + .../localization/methods_it.min.js | 4 + .../localization/methods_nl.js | 24 + .../localization/methods_nl.min.js | 4 + .../localization/methods_pt.js | 21 + .../localization/methods_pt.min.js | 4 + .../wwwroot/lib/jquery/.bower.json | 25 - .../wwwroot/lib/jquery/LICENSE.txt | 36 - .../wwwroot/lib/jquery/dist/jquery.min.map | 1 - .../wwwroot/lib/jquery/{dist => }/jquery.js | 6411 ++++---- .../wwwroot/lib/jquery/jquery.min.js | 2 + .../wwwroot/lib/jquery/jquery.min.map | 1 + .../wwwroot/lib/jquery/jquery.slim.js | 8617 +++++++++++ .../wwwroot/lib/jquery/jquery.slim.min.js | 2 + .../wwwroot/lib/jquery/jquery.slim.min.map | 1 + 302 files changed, 88634 insertions(+), 14793 deletions(-) create mode 100644 examples/ConfigStoreDemo/libman.json delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/.bower.json delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/LICENSE create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-grid.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-grid.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-grid.min.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-grid.min.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-grid.rtl.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-grid.rtl.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-grid.rtl.min.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-grid.rtl.min.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-reboot.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-reboot.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-reboot.min.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-reboot.min.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-reboot.rtl.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-reboot.rtl.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-reboot.rtl.min.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-reboot.rtl.min.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-utilities.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-utilities.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-utilities.min.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-utilities.min.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-utilities.rtl.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-utilities.rtl.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-utilities.rtl.min.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap-utilities.rtl.min.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap.min.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap.min.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap.rtl.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap.rtl.css.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap.rtl.min.css create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/css/bootstrap.rtl.min.css.map delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/css/bootstrap-theme.css delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/css/bootstrap-theme.css.map delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/css/bootstrap-theme.min.css.map delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/css/bootstrap.css delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.eot delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.svg delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.ttf delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.woff delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.woff2 delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/js/bootstrap.js delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/dist/js/npm.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.bundle.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.bundle.js.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.bundle.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.bundle.min.js.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.esm.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.esm.js.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.esm.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.esm.min.js.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.js.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/js/bootstrap.min.js.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_accordion.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_alert.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_badge.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_breadcrumb.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_button-group.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_buttons.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_card.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_carousel.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_close.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_containers.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_dropdown.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_forms.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_functions.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_grid.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_helpers.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_images.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_list-group.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_maps.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_mixins.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_modal.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_nav.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_navbar.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_offcanvas.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_pagination.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_placeholders.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_popover.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_progress.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_reboot.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_root.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_spinners.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_tables.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_toasts.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_tooltip.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_transitions.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_type.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_utilities.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_variables-dark.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/_variables.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/bootstrap-grid.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/bootstrap-reboot.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/bootstrap-utilities.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/bootstrap.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/forms/_floating-labels.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/forms/_form-check.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/forms/_form-control.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/forms/_form-range.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/forms/_form-select.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/forms/_form-text.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/forms/_input-group.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/forms/_labels.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/forms/_validation.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_clearfix.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_color-bg.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_colored-links.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_focus-ring.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_icon-link.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_position.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_ratio.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_stacks.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_stretched-link.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_text-truncation.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_visually-hidden.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/helpers/_vr.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_alert.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_backdrop.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_banner.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_border-radius.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_box-shadow.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_breakpoints.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_buttons.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_caret.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_clearfix.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_color-mode.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_color-scheme.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_container.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_deprecate.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_forms.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_gradients.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_grid.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_image.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_list-group.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_lists.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_pagination.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_reset-text.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_resize.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_table-variants.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_text-truncate.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_transition.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_utilities.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/mixins/_visually-hidden.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/utilities/_api.scss create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/bootstrap/scss/vendor/_rfs.scss delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/.bower.json delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/LICENSE.md create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/additional-methods.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/additional-methods.min.js delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/dist/additional-methods.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/jquery-validation-sri.json rename examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/{dist => }/jquery.validate.js (56%) create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/jquery.validate.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ar.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ar.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_az.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_az.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_bg.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_bg.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_bn_BD.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_bn_BD.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ca.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ca.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_cs.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_cs.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_da.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_da.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_de.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_de.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_el.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_el.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_es.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_es.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_es_AR.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_es_AR.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_es_PE.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_es_PE.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_et.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_et.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_eu.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_eu.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_fa.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_fa.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_fi.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_fi.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_fr.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_fr.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ge.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ge.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_gl.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_gl.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_he.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_he.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_hi.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_hi.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_hr.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_hr.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_hu.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_hu.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_hy_AM.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_hy_AM.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_id.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_id.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_is.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_is.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_it.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_it.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ja.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ja.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ka.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ka.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_kk.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_kk.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ko.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ko.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_lt.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_lt.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_lv.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_lv.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_mk.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_mk.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_my.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_my.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_nl.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_nl.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_no.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_no.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_pl.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_pl.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_pt_BR.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_pt_BR.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_pt_PT.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_pt_PT.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ro.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ro.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ru.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ru.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sd.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sd.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_si.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_si.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sk.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sk.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sl.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sl.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sr.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sr.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sr_lat.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sr_lat.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sv.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_sv.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_th.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_th.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_tj.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_tj.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_tr.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_tr.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_uk.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_uk.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ur.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_ur.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_vi.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_vi.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_zh.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_zh.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_zh_TW.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/messages_zh_TW.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_de.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_de.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_es_CL.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_es_CL.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_fi.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_fi.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_it.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_it.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_nl.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_nl.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_pt.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery-validation/localization/methods_pt.min.js delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery/.bower.json delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery/LICENSE.txt delete mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery/dist/jquery.min.map rename examples/ConfigStoreDemo/wwwroot/lib/jquery/{dist => }/jquery.js (60%) create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery/jquery.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery/jquery.min.map create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery/jquery.slim.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery/jquery.slim.min.js create mode 100644 examples/ConfigStoreDemo/wwwroot/lib/jquery/jquery.slim.min.map diff --git a/.gitattributes b/.gitattributes index 538c95f55..5cfb8610b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,9 @@ # If there are abnormal line endings in any file, run "git add --renormalize ", # review the changes, and commit them to fix the line endings. * text=auto + +# Third-party library files should not have line endings normalized +**/wwwroot/lib/** -text +*.min.css -text +*.min.js -text +*.map -text diff --git a/examples/ConfigStoreDemo/Pages/_Layout.cshtml b/examples/ConfigStoreDemo/Pages/_Layout.cshtml index 553e6de60..5da3134b8 100644 --- a/examples/ConfigStoreDemo/Pages/_Layout.cshtml +++ b/examples/ConfigStoreDemo/Pages/_Layout.cshtml @@ -3,15 +3,16 @@ - @ViewData["Title"] - Azure App Configuration Demo - + @ViewData["Title"] - Azure App Configuration Demo - - + + - + -