diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 27cd262eb..f34eed39b 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -3,6 +3,7 @@ // using Azure; using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.SnapshotReference; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; using Microsoft.Extensions.Diagnostics.HealthChecks; @@ -874,6 +875,31 @@ await CallWithRequestTracing(async () => foreach (ConfigurationSetting setting in page.Values) { + if (setting.ContentType == SnapshotReferenceConstants.ContentType) + { + // Track snapshot reference usage for telemetry + if (_requestTracingEnabled && _requestTracingOptions != null) + { + _requestTracingOptions.UsesSnapshotReference = true; + } + + SnapshotReference.SnapshotReference snapshotReference = SnapshotReferenceParser.Parse(setting); + + Dictionary resolvedSettings = await LoadSnapshotData(snapshotReference.SnapshotName, client, cancellationToken).ConfigureAwait(false); + + if (_requestTracingEnabled && _requestTracingOptions != null) + { + _requestTracingOptions.UsesSnapshotReference = false; + } + + foreach (KeyValuePair resolvedSetting in resolvedSettings) + { + data[resolvedSetting.Key] = resolvedSetting.Value; + } + + continue; + } + data[setting.Key] = setting; if (loadOption.IsFeatureFlagSelector) @@ -899,37 +925,54 @@ await CallWithRequestTracing(async () => } else { - ConfigurationSnapshot snapshot; + Dictionary resolvedSettings = await LoadSnapshotData(loadOption.SnapshotName, client, cancellationToken).ConfigureAwait(false); - try - { - snapshot = await client.GetSnapshotAsync(loadOption.SnapshotName).ConfigureAwait(false); - } - catch (RequestFailedException rfe) when (rfe.Status == (int)HttpStatusCode.NotFound) + foreach (KeyValuePair resolvedSetting in resolvedSettings) { - throw new InvalidOperationException($"Could not find snapshot with name '{loadOption.SnapshotName}'.", rfe); + data[resolvedSetting.Key] = resolvedSetting.Value; } + } + } - if (snapshot.SnapshotComposition != SnapshotComposition.Key) - { - throw new InvalidOperationException($"{nameof(snapshot.SnapshotComposition)} for the selected snapshot with name '{snapshot.Name}' must be 'key', found '{snapshot.SnapshotComposition}'."); - } + return data; + } - IAsyncEnumerable settingsEnumerable = client.GetConfigurationSettingsForSnapshotAsync( - loadOption.SnapshotName, - cancellationToken); + private async Task> LoadSnapshotData(string snapshotName, ConfigurationClient client, CancellationToken cancellationToken) + { + var resolvedSettings = new Dictionary(); - await CallWithRequestTracing(async () => - { - await foreach (ConfigurationSetting setting in settingsEnumerable.ConfigureAwait(false)) - { - data[setting.Key] = setting; - } - }).ConfigureAwait(false); - } + Debug.Assert(!string.IsNullOrWhiteSpace(snapshotName)); + + ConfigurationSnapshot snapshot = null; + + try + { + await CallWithRequestTracing(async () => snapshot = await client.GetSnapshotAsync(snapshotName, cancellationToken: cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); } + catch (RequestFailedException rfe) when (rfe.Status == (int)HttpStatusCode.NotFound) + { - return data; + return resolvedSettings; // Return empty dictionary if snapshot not found + } + + if (snapshot.SnapshotComposition != SnapshotComposition.Key) + { + throw new InvalidOperationException(string.Format(ErrorMessages.SnapshotInvalidComposition, nameof(snapshot.SnapshotComposition), snapshot.Name, snapshot.SnapshotComposition)); + } + + IAsyncEnumerable settingsEnumerable = client.GetConfigurationSettingsForSnapshotAsync( + snapshotName, + cancellationToken); + + await CallWithRequestTracing(async () => + { + await foreach (ConfigurationSetting setting in settingsEnumerable.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + resolvedSettings[setting.Key] = setting; + } + }).ConfigureAwait(false); + + return resolvedSettings; } private async Task> LoadKeyValuesRegisteredForRefresh( @@ -969,7 +1012,33 @@ private async Task> LoadKey if (watchedKv != null) { watchedIndividualKvs[watchedKeyLabel] = new ConfigurationSetting(watchedKv.Key, watchedKv.Value, watchedKv.Label, watchedKv.ETag); - existingSettings[watchedKey] = watchedKv; + + if (watchedKv.ContentType == SnapshotReferenceConstants.ContentType) + { + // Track snapshot reference usage for telemetry + if (_requestTracingEnabled && _requestTracingOptions != null) + { + _requestTracingOptions.UsesSnapshotReference = true; + } + + SnapshotReference.SnapshotReference snapshotReference = SnapshotReferenceParser.Parse(watchedKv); + + Dictionary resolvedSettings = await LoadSnapshotData(snapshotReference.SnapshotName, client, cancellationToken).ConfigureAwait(false); + + if (_requestTracingEnabled && _requestTracingOptions != null) + { + _requestTracingOptions.UsesSnapshotReference = false; + } + + foreach (KeyValuePair resolvedSetting in resolvedSettings) + { + existingSettings[resolvedSetting.Key] = resolvedSetting.Value; + } + } + else + { + existingSettings[watchedKey] = watchedKv; + } } } @@ -1034,7 +1103,8 @@ await CallWithRequestTracing( logInfoBuilder.AppendLine(LogHelper.BuildKeyValueSettingUpdatedMessage(change.Key)); keyValueChanges.Add(change); - if (kvWatcher.RefreshAll) + // If the watcher is set to refresh all, or the content type matches the snapshot reference content type then refresh all + if (kvWatcher.RefreshAll || watchedKv.ContentType == SnapshotReferenceConstants.ContentType) { return true; } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs index c79747360..7bc0e84f9 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs @@ -10,5 +10,9 @@ internal class ErrorMessages public const string FeatureFlagInvalidJsonProperty = "Invalid property '{0}' for feature flag. Key: '{1}'. Found type: '{2}'. Expected type: '{3}'."; public const string FeatureFlagInvalidFormat = "Invalid json format for feature flag. Key: '{0}'."; public const string InvalidKeyVaultReference = "Invalid Key Vault reference."; + public const string SnapshotReferenceInvalidFormat = "Invalid snapshot reference format for key '{0}' (label: '{1}')."; + public const string SnapshotReferenceInvalidJsonProperty = "Invalid snapshot reference format for key '{0}' (label: '{1}'). The '{2}' property must be a string value, but found {3}."; + public const string SnapshotReferencePropertyMissing = "Invalid snapshot reference format for key '{0}' (label: '{1}'). The '{2}' property is required."; + public const string SnapshotInvalidComposition = "{0} for the selected snapshot with name '{1}' must be 'key', found '{2}'."; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs index 340fba853..5c4df33ec 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs @@ -33,7 +33,7 @@ internal class RequestTracingConstants public const string LoadBalancingEnabledTag = "LB"; public const string AIConfigurationTag = "AI"; public const string AIChatCompletionConfigurationTag = "AICC"; - + public const string SnapshotReferenceTag = "SnapshotRef"; public const string SignalRUsedTag = "SignalR"; public const string FailoverRequestTag = "Failover"; public const string PushRefreshTag = "PushRefresh"; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs index 8a3a7b202..2f50a3856 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs @@ -3,6 +3,7 @@ // using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.SnapshotReference; using System.Net.Mime; using System.Text; @@ -87,6 +88,11 @@ internal class RequestTracingOptions /// public bool UsesAIChatCompletionConfiguration { get; set; } = false; + /// + /// Flag to indicate whether any key-value uses snapshot references. + /// + public bool UsesSnapshotReference { get; set; } = false; + /// /// Resets the AI configuration tracing flags. /// @@ -125,7 +131,8 @@ public bool UsesAnyTracingFeature() return IsLoadBalancingEnabled || IsSignalRUsed || UsesAIConfiguration || - UsesAIChatCompletionConfiguration; + UsesAIChatCompletionConfiguration || + UsesSnapshotReference; } /// @@ -176,6 +183,16 @@ public string CreateFeaturesString() sb.Append(RequestTracingConstants.AIChatCompletionConfigurationTag); } + if (UsesSnapshotReference) + { + if (sb.Length > 0) + { + sb.Append(RequestTracingConstants.Delimiter); + } + + sb.Append(RequestTracingConstants.SnapshotReferenceTag); + } + return sb.ToString(); } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/SnapshotReference/JsonFields.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/SnapshotReference/JsonFields.cs new file mode 100644 index 000000000..c773c8e31 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/SnapshotReference/JsonFields.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.SnapshotReference +{ + internal static class JsonFields + { + public const string SnapshotName = "snapshot_name"; + } +} \ No newline at end of file diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/SnapshotReference/SnapshotReference.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/SnapshotReference/SnapshotReference.cs new file mode 100644 index 000000000..b0731f1c0 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/SnapshotReference/SnapshotReference.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.SnapshotReference +{ + internal class SnapshotReference + { + public string SnapshotName { get; set; } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/SnapshotReference/SnapshotReferenceConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/SnapshotReference/SnapshotReferenceConstants.cs new file mode 100644 index 000000000..04b702b28 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/SnapshotReference/SnapshotReferenceConstants.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.SnapshotReference +{ + internal class SnapshotReferenceConstants + { + public const string ContentType = "application/json; profile=\"https://azconfig.io/mime-profiles/snapshot-ref\"; charset=utf-8"; + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/SnapshotReference/SnapshotReferenceParser.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/SnapshotReference/SnapshotReferenceParser.cs new file mode 100644 index 000000000..a1249979b --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/SnapshotReference/SnapshotReferenceParser.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure.Data.AppConfiguration; +using System; +using System.Text.Json; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.SnapshotReference +{ + /// + /// Provides parsing functionality for snapshot reference configuration settings. + /// + internal static class SnapshotReferenceParser + { + /// + /// Parses a snapshot name from a configuration setting containing snapshot reference JSON. + /// + /// The configuration setting containing the snapshot reference JSON. + /// The snapshot reference containing a valid, non-empty snapshot name. + /// Thrown when the setting is null. + /// Thrown when the setting contains invalid JSON, invalid snapshot reference format, or empty/whitespace snapshot name. + public static SnapshotReference Parse(ConfigurationSetting setting) + { + if (setting == null) + { + throw new ArgumentNullException(nameof(setting)); + } + + if (string.IsNullOrWhiteSpace(setting.Value)) + { + throw new FormatException(string.Format(ErrorMessages.SnapshotReferenceInvalidFormat, setting.Key, setting.Label)); + } + + try + { + var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(setting.Value)); + + if (reader.Read() && reader.TokenType != JsonTokenType.StartObject) + { + throw new FormatException(string.Format(ErrorMessages.SnapshotReferenceInvalidFormat, setting.Key, setting.Label)); + } + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + if (reader.GetString() == JsonFields.SnapshotName) + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + string snapshotName = reader.GetString(); + if (string.IsNullOrWhiteSpace(snapshotName)) + { + throw new FormatException(string.Format(ErrorMessages.SnapshotReferenceInvalidFormat, setting.Key, setting.Label)); + } + + return new SnapshotReference { SnapshotName = snapshotName }; + } + + throw new FormatException(string.Format(ErrorMessages.SnapshotReferenceInvalidJsonProperty, setting.Key, setting.Label, JsonFields.SnapshotName, reader.TokenType)); + } + + // Skip unknown properties + reader.Skip(); + } + + throw new FormatException(string.Format(ErrorMessages.SnapshotReferencePropertyMissing, setting.Key, setting.Label, JsonFields.SnapshotName)); + } + catch (JsonException jsonEx) + { + throw new FormatException(string.Format(ErrorMessages.SnapshotReferenceInvalidFormat, setting.Key, setting.Label), jsonEx); + } + } + } +} diff --git a/tests/Tests.AzureAppConfiguration/Integration/IntegrationTests.cs b/tests/Tests.AzureAppConfiguration/Integration/IntegrationTests.cs index b932b3278..6aca25b9a 100644 --- a/tests/Tests.AzureAppConfiguration/Integration/IntegrationTests.cs +++ b/tests/Tests.AzureAppConfiguration/Integration/IntegrationTests.cs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// using Azure; using Azure.Core; using Azure.Core.Pipeline; @@ -12,6 +15,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.AzureAppConfiguration; using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.SnapshotReference; using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; using Microsoft.FeatureManagement; using System; @@ -65,6 +69,8 @@ private class TestContext public string FeatureFlagKey { get; set; } public string KeyVaultReferenceKey { get; set; } public string SecretValue { get; set; } + public string SnapshotReferenceKey { get; set; } + public string SnapshotName { get; set; } } private ConfigurationClient _configClient; @@ -263,6 +269,8 @@ private TestContext CreateTestContext(string testName) string secretName = $"{keyPrefix}-secret"; string secretValue = "SecretValue"; string keyVaultReferenceKey = $"{keyPrefix}:KeyVaultRef"; + string snapshotReferenceKey = $"{keyPrefix}:SnapshotRef"; + string snapshotName = $"{keyPrefix}-snapshot"; return new TestContext { @@ -270,7 +278,9 @@ private TestContext CreateTestContext(string testName) SentinelKey = sentinelKey, FeatureFlagKey = featureFlagKey, KeyVaultReferenceKey = keyVaultReferenceKey, - SecretValue = secretValue + SecretValue = secretValue, + SnapshotReferenceKey = snapshotReferenceKey, + SnapshotName = snapshotName }; } @@ -318,6 +328,24 @@ private async Task SetupKeyVaultReferences(TestContext context) } } + private async Task SetUpSnapshotReferences(TestContext context) + { + if (_configClient != null) + { + await CreateSnapshot(context.SnapshotName, new List + { + new ConfigurationSettingsFilter(context.KeyPrefix + ":*") + }); + + ConfigurationSetting snapshotReferenceSetting = ConfigurationModelFactory.ConfigurationSetting( + context.SnapshotReferenceKey, + JsonSerializer.Serialize(new { snapshot_name = context.SnapshotName }), + contentType: SnapshotReferenceConstants.ContentType + ); + await _configClient.SetConfigurationSettingAsync(snapshotReferenceSetting); + } + } + private async Task SetupTaggedSettings(TestContext context) { // Create configuration settings with various tags @@ -423,6 +451,7 @@ private async Task SetupAllTestData(string testName) await SetupKeyValues(context); await SetupFeatureFlags(context); await SetupKeyVaultReferences(context); + await SetUpSnapshotReferences(context); return context; } @@ -583,6 +612,7 @@ public async Task RefreshAsync_RefreshesFeatureFlags_WhenConfigured() .AddAzureAppConfiguration(options => { options.Connect(_connectionString); + options.ConfigureKeyVault(kv => kv.SetCredential(_defaultAzureCredential)); // Configure feature flags with the correct ID pattern options.UseFeatureFlags(featureFlagOptions => @@ -653,6 +683,7 @@ await _configClient.SetConfigurationSettingAsync( .AddAzureAppConfiguration(options => { options.Connect(_connectionString); + options.ConfigureKeyVault(kv => kv.SetCredential(_defaultAzureCredential)); options.UseFeatureFlags(featureFlagOptions => { featureFlagOptions.Select(testContext.KeyPrefix + "*"); @@ -768,6 +799,7 @@ await _configClient.SetConfigurationSettingAsync( .AddAzureAppConfiguration(options => { options.Connect(_connectionString); + options.ConfigureKeyVault(kv => kv.SetCredential(_defaultAzureCredential)); options.UseFeatureFlags(featureFlagOptions => { featureFlagOptions.Select(testContext.KeyPrefix + "*"); @@ -1215,26 +1247,23 @@ public async Task LoadSnapshot_RetrievesValuesFromSnapshot() } [Fact] - public async Task LoadSnapshot_ThrowsException_WhenSnapshotDoesNotExist() + public void LoadSnapshot_ReturnsEmpty_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()); - }); + // Act - Loading a non-existent snapshot should return empty configuration + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.SelectSnapshot(nonExistentSnapshotName); + }) + .Build(); - // Verify the exception message contains snapshot name - Assert.Contains(nonExistentSnapshotName, exception.Message); + // Assert - Configuration should be empty (no settings loaded from non-existent snapshot) + Assert.Empty(config.AsEnumerable()); } [Fact] @@ -1700,6 +1729,121 @@ await _configClient.SetConfigurationSettingAsync( Assert.Equal(secretValue2, config[kvRefKey2]); // Not updated - long refresh interval } + [Fact] + public async Task SnapshotReference_ResolveCorrectly() + { + TestContext testContext = CreateTestContext("SnapshotReference"); + await SetupKeyValues(testContext); + await SetUpSnapshotReferences(testContext); + + await _configClient.SetConfigurationSettingAsync( + new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedAfterSnapshot")); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:Setting*"); + options.Select(testContext.SnapshotReferenceKey); + }) + .Build(); + + // Assert - Should get values from snapshot, not current values + Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue2", config[$"{testContext.KeyPrefix}:Setting2"]); + + await _configClient.ArchiveSnapshotAsync(testContext.SnapshotName); + } + + [Fact] + public async Task SnapshotReference_HandleNonExistentSnapshot() + { + TestContext testContext = CreateTestContext("SnapshotRefNonExistent"); + await SetupKeyValues(testContext); + + string nonExistentSnapshotName = $"snapshot-does-not-exist-{testContext.SnapshotName}"; + + // Create snapshot reference pointing to non-existent snapshot + ConfigurationSetting snapshotReferenceSetting = ConfigurationModelFactory.ConfigurationSetting( + testContext.SnapshotReferenceKey, + JsonSerializer.Serialize(new { snapshot_name = nonExistentSnapshotName }), + contentType: SnapshotReferenceConstants.ContentType + ); + await _configClient.SetConfigurationSettingAsync(snapshotReferenceSetting); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select($"{testContext.KeyPrefix}:*"); + }) + .Build(); + + // Assert - Empty result for snapshot reference + // Live values should still be accessible + Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue2", config[$"{testContext.KeyPrefix}:Setting2"]); + + Assert.Null(config[testContext.SnapshotReferenceKey]); + } + + [Fact] + public async Task SnapshotReference_WithKeyVaultReference() + { + TestContext testContext = CreateTestContext("SnapshotRefKeyVault"); + await SetupKeyValues(testContext); + + // Create a Key Vault reference WITHOUT label + string keyVaultReferenceKeyNoLabel = $"{testContext.KeyPrefix}:KeyVaultRefNoLabel"; + + if (_secretClient != null) + { + await _secretClient.SetSecretAsync(testContext.KeyPrefix + "-secret", testContext.SecretValue); + + string keyVaultUri = $"{_keyVaultEndpoint}secrets/{testContext.KeyPrefix}-secret"; + string keyVaultRefValue = @$"{{""uri"":""{keyVaultUri}""}}"; + + ConfigurationSetting keyVaultRefSetting = ConfigurationModelFactory.ConfigurationSetting( + keyVaultReferenceKeyNoLabel, + keyVaultRefValue, + contentType: KeyVaultConstants.ContentType); + + await _configClient.SetConfigurationSettingAsync(keyVaultRefSetting); + } + + await CreateSnapshot(testContext.SnapshotName, new List + { + new ConfigurationSettingsFilter(testContext.KeyPrefix + "*") + }); + + ConfigurationSetting snapshotReferenceSetting = ConfigurationModelFactory.ConfigurationSetting( + testContext.SnapshotReferenceKey, + JsonSerializer.Serialize(new { snapshot_name = testContext.SnapshotName }), + contentType: SnapshotReferenceConstants.ContentType + ); + await _configClient.SetConfigurationSettingAsync(snapshotReferenceSetting); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(_connectionString); + options.Select(testContext.SnapshotReferenceKey); + options.ConfigureKeyVault(kv => kv.SetCredential(_defaultAzureCredential)); + }) + .Build(); + + try + { + Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]); + Assert.Equal("InitialValue2", config[$"{testContext.KeyPrefix}:Setting2"]); + Assert.Equal("SecretValue", config[keyVaultReferenceKeyNoLabel]); + } + finally + { + await _configClient.ArchiveSnapshotAsync(testContext.SnapshotName); + } + } + [Fact] public async Task RequestTracing_SetsCorrectCorrelationContextHeader() { diff --git a/tests/Tests.AzureAppConfiguration/Unit/SnapshotReferenceTests.cs b/tests/Tests.AzureAppConfiguration/Unit/SnapshotReferenceTests.cs new file mode 100644 index 000000000..830a92f28 --- /dev/null +++ b/tests/Tests.AzureAppConfiguration/Unit/SnapshotReferenceTests.cs @@ -0,0 +1,615 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure; +using Azure.Core.Testing; +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.SnapshotReference; +using Moq; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace Tests.AzureAppConfiguration +{ + + public class SnapshotReferenceTests + { + private readonly ITestOutputHelper _output; + + public SnapshotReferenceTests(ITestOutputHelper output) + { + _output = output; + } + + // The actual configuration value we expect to get from the snapshot + string _snapshotConfigValue = "ValueFromSnapshot"; + + // A snapshot reference setting + ConfigurationSetting _snapshotReference1 = ConfigurationModelFactory.ConfigurationSetting( + key: "SnapshotRef1", + value: @"{""snapshot_name"": ""snapshot1""}", + eTag: new ETag("snapshot-ref-etag-123"), + contentType: SnapshotReferenceConstants.ContentType); + + // A regular configuration setting + ConfigurationSetting _settingInSnapshot1 = ConfigurationModelFactory.ConfigurationSetting( + key: "TestKey1", + value: "ValueFromSnapshot", + eTag: new ETag("snapshot-setting-etag-456")); + + ConfigurationSetting _snapshotReference2 = ConfigurationModelFactory.ConfigurationSetting( + key: "SnapshotRef2", + value: @"{""snapshot_name"": ""snapshot2""}", + eTag: new ETag("snapshot-ref-etag-456"), + contentType: SnapshotReferenceConstants.ContentType); + + ConfigurationSetting _settingInSnapshot2 = ConfigurationModelFactory.ConfigurationSetting( + key: "TestKey2", + value: "ValueFromSnapshot2", + eTag: new ETag("snapshot-setting-etag-789")); + + // Reference points to an empty snapshot + ConfigurationSetting _snapshotReferenceEmptyValue = ConfigurationModelFactory.ConfigurationSetting( + key: "SnapshotRefEmptyValue", + value: @"{""snapshot_name"": """"}", + eTag: new ETag("snapshot-ref-etag-789"), + contentType: SnapshotReferenceConstants.ContentType); + + // Reference with invalid JSON + ConfigurationSetting _snapshotReferenceInvalidJson = ConfigurationModelFactory.ConfigurationSetting( + key: "SnapshotRefInvalidJson", + value: "{invalid json, missing quotes}", + eTag: new ETag("snapshot-ref-etag-999"), + contentType: SnapshotReferenceConstants.ContentType); + + ConfigurationSetting _snapshotReferenceInvalidJson2 = ConfigurationModelFactory.ConfigurationSetting( + key: "SnapshotRefInvalidJson2", + value: "", + eTag: new ETag("snapshot-ref-etag-999"), + contentType: SnapshotReferenceConstants.ContentType); + + ConfigurationSetting _regularKeyValue = ConfigurationModelFactory.ConfigurationSetting( + key: "RegularKey", + value: "RegularValue", + eTag: new ETag("regular-etag-123"), + contentType: ""); + + ConfigurationSetting _snapshotReferenceWithExtraProperties = ConfigurationModelFactory.ConfigurationSetting( + key: "SnapshotRefExtraProps", + value: @"{""snapshot_name"": ""snapshot1"", ""extra_property"": ""extra_value""}", + eTag: new ETag("snapshot-ref-etag-777"), + contentType: SnapshotReferenceConstants.ContentType); + + ConfigurationSetting updatedSnapshotRef1 = ConfigurationModelFactory.ConfigurationSetting( + key: "SnapshotRef1", + value: @"{""snapshot_name"": ""snapshot2""}", + eTag: new ETag("snapshot-ref-etag-2"), + contentType: SnapshotReferenceConstants.ContentType); + + [Fact] + public void UseSnapshotReference() + { + // Create mock objects (fake Azure services) + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + // Set up what the fake Azure App Configuration client should return + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List { _snapshotReference1 })); + + // Create a real ConfigurationSnapshot object instead of mocking it + var settingsToInclude = new List + { + new ConfigurationSettingsFilter("*") + }; + var realSnapshot = new ConfigurationSnapshot(settingsToInclude) + { + SnapshotComposition = SnapshotComposition.Key + }; + + mockClient.Setup(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny())) + .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse.Object)); + + // Set up what settings are inside the snapshot + mockClient.Setup(c => c.GetConfigurationSettingsForSnapshotAsync("snapshot1", It.IsAny())) + .Returns(new MockAsyncPageable(new List { _settingInSnapshot1 })); + + // Build the configuration using our fake clients + var configuration = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + }) + .Build(); + + // Test that we get the value from the snapshot, not the reference + Assert.Equal(_snapshotConfigValue, configuration["TestKey1"]); + + // Verify snapshot references themselves are not in the config + Assert.Null(configuration["SnapshotRef1"]); + } + + [Fact] + public void MultipleSnapshotReferences() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List { _snapshotReference1, _snapshotReference2 })); + + var settingsToInclude = new List + { + new ConfigurationSettingsFilter("*") + }; + + var realSnapshot1 = new ConfigurationSnapshot(settingsToInclude) + { + SnapshotComposition = SnapshotComposition.Key + }; + + var realSnapshot2 = new ConfigurationSnapshot(settingsToInclude) + { + SnapshotComposition = SnapshotComposition.Key + }; + + mockClient.Setup(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny())) + .ReturnsAsync(Response.FromValue(realSnapshot1, mockResponse.Object)); + + mockClient.Setup(c => c.GetSnapshotAsync("snapshot2", It.IsAny>(), It.IsAny())) + .ReturnsAsync(Response.FromValue(realSnapshot2, mockResponse.Object)); + + mockClient.Setup(c => c.GetConfigurationSettingsForSnapshotAsync("snapshot1", It.IsAny())) + .Returns(new MockAsyncPageable(new List { _settingInSnapshot1 })); + + mockClient.Setup(c => c.GetConfigurationSettingsForSnapshotAsync("snapshot2", It.IsAny())) + .Returns(new MockAsyncPageable(new List { _settingInSnapshot2 })); + + var configuration = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + }) + .Build(); + + Assert.Equal("ValueFromSnapshot", configuration["TestKey1"]); // From first snapshot + Assert.Equal("ValueFromSnapshot2", configuration["TestKey2"]); // From second snapshot + + Assert.Null(configuration["SnapshotRef1"]); + Assert.Null(configuration["SnapshotRef2"]); + } + + [Fact] + public void SnapshotReferenceWithEmptyValue() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List { _snapshotReferenceEmptyValue })); + + var exception = Assert.Throws(() => + { + new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + }) + .Build(); + }); + + mockClient.Verify(c => c.GetSnapshotAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + mockClient.Verify(c => c.GetConfigurationSettingsForSnapshotAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public void SnapshotReferenceWithNonExistentSnapshot() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List { _snapshotReference1 })); + + // Set up the snapshot retrieval to throw 404 + mockClient.Setup(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny())) + .ThrowsAsync(new RequestFailedException(404, "Snapshot not found", "SnapshotNotFound", null)); + + var configuration = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + }) + .Build(); + + // The snapshot reference should be removed and no settings added to store + Assert.Null(configuration["SnapshotRef1"]); + + mockClient.Verify(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny()), Times.Once); + mockClient.Verify(c => c.GetConfigurationSettingsForSnapshotAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public void ThrowsWhenWrongSnapshotComposition() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List { _snapshotReference1 })); + + var settingsToInclude = new List + { + new ConfigurationSettingsFilter("*") + }; + var snapshotWithWrongComposition = new ConfigurationSnapshot(settingsToInclude) + { + SnapshotComposition = SnapshotComposition.KeyLabel + }; + + mockClient.Setup(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny())) + .ReturnsAsync(Response.FromValue(snapshotWithWrongComposition, mockResponse.Object)); + + var exception = Assert.Throws(() => + { + new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + }) + .Build(); + }); + + Assert.Contains("SnapshotComposition", exception.Message); + Assert.Contains("must be 'key'", exception.Message); + + mockClient.Verify(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny()), Times.Once); + mockClient.Verify(c => c.GetConfigurationSettingsForSnapshotAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public void ThrowsWhenInvalidSnapshotReferencesJson() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List { _snapshotReferenceInvalidJson, _snapshotReferenceInvalidJson2 })); + + var exception = Assert.Throws(() => + { + new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + }) + .Build(); + }); + + Assert.Contains("Invalid snapshot reference format", exception.Message); + + mockClient.Verify(c => c.GetSnapshotAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + mockClient.Verify(c => c.GetConfigurationSettingsForSnapshotAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public void RegularKeyValue() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List { _regularKeyValue })); + + var configuration = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + }) + .Build(); + + Assert.Equal("RegularValue", configuration["RegularKey"]); + + mockClient.Verify(c => c.GetSnapshotAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + mockClient.Verify(c => c.GetConfigurationSettingsForSnapshotAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public void SnapshotReferenceWithExtraProperties() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List { _snapshotReferenceWithExtraProperties })); + + var settingsToInclude = new List + { + new ConfigurationSettingsFilter("*") + }; + var realSnapshot = new ConfigurationSnapshot(settingsToInclude) + { + SnapshotComposition = SnapshotComposition.Key + }; + + mockClient.Setup(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny())) + .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse.Object)); + + mockClient.Setup(c => c.GetConfigurationSettingsForSnapshotAsync("snapshot1", It.IsAny())) + .Returns(new MockAsyncPageable(new List { _settingInSnapshot1 })); + + var configuration = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + }) + .Build(); + + // Should work normally despite extra properties in the JSON - extra properties are ignored + Assert.Equal("ValueFromSnapshot", configuration["TestKey1"]); + Assert.Null(configuration["SnapshotRefExtraProps"]); + + mockClient.Verify(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny()), Times.Once); + mockClient.Verify(c => c.GetConfigurationSettingsForSnapshotAsync("snapshot1", It.IsAny()), Times.Once); + } + + //Register("SnapshotRefKey", refreshAll: false) → Still triggers refreshAll due to content type + [Fact] + public async Task SnapshotReferenceRegisteredWithRefreshAllFalse() + { + IConfigurationRefresher refresher = null; + TimeSpan refreshInterval = TimeSpan.FromSeconds(1); + + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + bool refreshAllTriggered = false; + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List { _snapshotReference1, _regularKeyValue })); + + var settingsToInclude = new List { new ConfigurationSettingsFilter("*") }; + var realSnapshot = new ConfigurationSnapshot(settingsToInclude) { SnapshotComposition = SnapshotComposition.Key }; + + mockClient.Setup(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny())) + .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse.Object)); + + mockClient.Setup(c => c.GetConfigurationSettingsForSnapshotAsync("snapshot1", It.IsAny())) + .Returns(new MockAsyncPageable(new List { _settingInSnapshot1 })); + + // Add mock for snapshot2 since the updated reference points to it + var realSnapshot2 = new ConfigurationSnapshot(settingsToInclude) { SnapshotComposition = SnapshotComposition.Key }; + + mockClient.Setup(c => c.GetSnapshotAsync("snapshot2", It.IsAny>(), It.IsAny())) + .ReturnsAsync(Response.FromValue(realSnapshot2, mockResponse.Object)); + + mockClient.Setup(c => c.GetConfigurationSettingsForSnapshotAsync("snapshot2", It.IsAny())) + .Returns(new MockAsyncPageable(new List { _settingInSnapshot2 })); + + mockClient.Setup(c => c.GetConfigurationSettingAsync("SnapshotRef1", It.IsAny(), It.IsAny())) + .ReturnsAsync(() => + { + return Response.FromValue(updatedSnapshotRef1, mockResponse.Object); + }); + + // Setup refresh check - simulate change detected + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ConfigurationSetting setting, bool onlyIfChanged, CancellationToken token) => + { + if (setting.Key == "SnapshotRef1") + { + // Snapshot reference changed - this should trigger refreshAll despite refreshAll: false + refreshAllTriggered = true; + return Response.FromValue(updatedSnapshotRef1, new MockResponse(200)); + } + + return Response.FromValue(setting, new MockResponse(304)); + }); + + var configuration = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("SnapshotRef1", refreshAll: false) + .SetRefreshInterval(refreshInterval); + }); + refresher = options.GetRefresher(); + }) + .Build(); + + // Trigger refresh + Thread.Sleep(refreshInterval); + await refresher.RefreshAsync(); + + Assert.True(refreshAllTriggered, "RefreshAll should be triggered for snapshot references even when refreshAll: false"); + } + + // Scenario B: Register("SnapshotRef1", refreshAll: true) → Triggers refreshAll (explicitly configured) + [Fact] + public async Task SnapshotReferenceRegisteredWithRefreshAllTrue_TriggersRefreshAll() + { + IConfigurationRefresher refresher = null; + TimeSpan refreshInterval = TimeSpan.FromSeconds(1); + + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + bool refreshAllTriggered = false; + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List { _snapshotReference1, _regularKeyValue })); + + var settingsToInclude = new List { new ConfigurationSettingsFilter("*") }; + var realSnapshot = new ConfigurationSnapshot(settingsToInclude) { SnapshotComposition = SnapshotComposition.Key }; + + mockClient.Setup(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny())) + .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse.Object)); + + mockClient.Setup(c => c.GetConfigurationSettingsForSnapshotAsync("snapshot1", It.IsAny())) + .Returns(new MockAsyncPageable(new List { _settingInSnapshot1 })); + + mockClient.Setup(c => c.GetConfigurationSettingAsync("SnapshotRef1", It.IsAny(), It.IsAny())) + .ReturnsAsync(() => Response.FromValue(_snapshotReference1, mockResponse.Object)); + + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ConfigurationSetting setting, bool onlyIfChanged, CancellationToken token) => + { + if (setting.Key == "SnapshotRef1") + { + refreshAllTriggered = true; + return Response.FromValue(_snapshotReference1, new MockResponse(200)); + } + + return Response.FromValue(setting, new MockResponse(304)); + }); + + var configuration = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("SnapshotRef1", refreshAll: true) // Explicit true + .SetRefreshInterval(refreshInterval); + }); + refresher = options.GetRefresher(); + }) + .Build(); + + Thread.Sleep(refreshInterval); + await refresher.RefreshAsync(); + + Assert.True(refreshAllTriggered, "RefreshAll should be triggered when explicitly configured"); + } + + // Scenario C: Register("SnapshotRef1") → Still triggers refreshAll due to content type + [Fact] + public async Task SnapshotReferenceRegisteredWithoutRefreshAllParameter_StillTriggersRefreshAll() + { + IConfigurationRefresher refresher = null; + TimeSpan refreshInterval = TimeSpan.FromSeconds(1); + + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + bool refreshAllTriggered = false; + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List { _snapshotReference1, _regularKeyValue })); + + var settingsToInclude = new List { new ConfigurationSettingsFilter("*") }; + var realSnapshot = new ConfigurationSnapshot(settingsToInclude) { SnapshotComposition = SnapshotComposition.Key }; + + mockClient.Setup(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny())) + .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse.Object)); + + mockClient.Setup(c => c.GetConfigurationSettingsForSnapshotAsync("snapshot1", It.IsAny())) + .Returns(new MockAsyncPageable(new List { _settingInSnapshot1 })); + + mockClient.Setup(c => c.GetConfigurationSettingAsync("SnapshotRef1", It.IsAny(), It.IsAny())) + .ReturnsAsync(() => Response.FromValue(_snapshotReference1, mockResponse.Object)); + + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ConfigurationSetting setting, bool onlyIfChanged, CancellationToken token) => + { + if (setting.Key == "SnapshotRef1") + { + refreshAllTriggered = true; + return Response.FromValue(_snapshotReference1, new MockResponse(200)); + } + + return Response.FromValue(setting, new MockResponse(304)); + }); + + var configuration = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("SnapshotRef1") // No refreshAll parameter + .SetRefreshInterval(refreshInterval); + }); + refresher = options.GetRefresher(); + }) + .Build(); + + Thread.Sleep(refreshInterval); + await refresher.RefreshAsync(); + + Assert.True(refreshAllTriggered, "RefreshAll should be triggered for snapshot references even without explicit refreshAll parameter"); + } + + // Scenario D: Register("SnapshotRef1") but not in Select() → Should resolve and load settings during LoadKeyValuesRegisteredForRefresh + [Fact] + public void SnapshotReferenceRegisteredForRefreshButNotInSelect() + { + IConfigurationRefresher refresher = null; + TimeSpan refreshInterval = TimeSpan.FromSeconds(1); + + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + // Only return regular key-value in initial load (snapshot reference not selected) + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List { _regularKeyValue })); + + // Setup mocks for when registered key is loaded during refresh initialization + var settingsToInclude = new List { new ConfigurationSettingsFilter("*") }; + var realSnapshot = new ConfigurationSnapshot(settingsToInclude) { SnapshotComposition = SnapshotComposition.Key }; + + mockClient.Setup(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny())) + .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse.Object)); + + mockClient.Setup(c => c.GetConfigurationSettingsForSnapshotAsync("snapshot1", It.IsAny())) + .Returns(new MockAsyncPageable(new List { _settingInSnapshot1 })); + + // Mock the GetConfigurationSettingAsync call for the registered snapshot reference + mockClient.Setup(c => c.GetConfigurationSettingAsync("SnapshotRef1", It.IsAny(), It.IsAny())) + .ReturnsAsync(() => Response.FromValue(_snapshotReference1, mockResponse.Object)); + + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ConfigurationSetting setting, bool onlyIfChanged, CancellationToken token) => + { + // Simulates no changes + return Response.FromValue(setting, new MockResponse(304)); + }); + + var configuration = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigureRefresh(refreshOptions => + { + // Register snapshot reference for monitoring but not in Select() + refreshOptions.Register("SnapshotRef1", refreshAll: false) + .SetRefreshInterval(refreshInterval); + }); + refresher = options.GetRefresher(); + }) + .Build(); + + // Verify that snapshot reference was resolved + Assert.Equal("ValueFromSnapshot", configuration["TestKey1"]); + Assert.Equal("RegularValue", configuration["RegularKey"]); + + // Snapshot reference itself should not be in config + Assert.Null(configuration["SnapshotRef1"]); + + // Verify the snapshot was resolved during registration + mockClient.Verify(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny()), Times.Once); + mockClient.Verify(c => c.GetConfigurationSettingsForSnapshotAsync("snapshot1", It.IsAny()), Times.Once); + + // Verify snapshot reference was loaded for monitoring + mockClient.Verify(c => c.GetConfigurationSettingAsync("SnapshotRef1", It.IsAny(), It.IsAny()), Times.Once); + } + } +} \ No newline at end of file