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