diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdConfigurationClientManager.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdConfigurationClientManager.cs new file mode 100644 index 000000000..fbf057c1b --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdConfigurationClientManager.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Azure; +using System; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Afd +{ + internal class AfdConfigurationClientManager : IConfigurationClientManager + { + private readonly ConfigurationClientWrapper _clientWrapper; + + public AfdConfigurationClientManager( + IAzureClientFactory clientFactory, + Uri endpoint) + { + if (clientFactory == null) + { + throw new ArgumentNullException(nameof(clientFactory)); + } + + if (endpoint == null) + { + throw new ArgumentNullException(nameof(endpoint)); + } + + _clientWrapper = new ConfigurationClientWrapper(endpoint, clientFactory.CreateClient(endpoint.AbsoluteUri)); + } + + public IEnumerable GetClients() + { + return new List { _clientWrapper.Client }; + } + + public void RefreshClients() + { + return; + } + + public bool UpdateSyncToken(Uri endpoint, string syncToken) + { + return false; + } + + public Uri GetEndpointForClient(ConfigurationClient client) + { + if (client == null) + { + throw new ArgumentNullException(nameof(client)); + } + + return _clientWrapper.Client == client ? _clientWrapper.Endpoint : null; + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdPolicy.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdPolicy.cs new file mode 100644 index 000000000..da461b7bf --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdPolicy.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure.Core; +using Azure.Core.Pipeline; +using System; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Afd +{ + /// + /// HTTP pipeline policy that removes Authorization and Sync-Token headers from outgoing requests. + /// + internal class AfdPolicy : HttpPipelinePolicy + { + private const string AuthorizationHeader = "Authorization"; + private const string SyncTokenHeader = "Sync-Token"; + + /// + /// Processes the HTTP message and removes Authorization and Sync-Token headers. + /// + /// The HTTP message. + /// The pipeline. + public override void Process(HttpMessage message, ReadOnlyMemory pipeline) + { + message.Request.Headers.Remove(AuthorizationHeader); + + message.Request.Headers.Remove(SyncTokenHeader); + + ProcessNext(message, pipeline); + } + + /// + /// Processes the HTTP message and removes Authorization and Sync-Token headers. + /// + /// The HTTP message. + /// The pipeline. + /// A task representing the asynchronous operation. + public override async System.Threading.Tasks.ValueTask ProcessAsync(HttpMessage message, ReadOnlyMemory pipeline) + { + message.Request.Headers.Remove(AuthorizationHeader); + + message.Request.Headers.Remove(SyncTokenHeader); + + await ProcessNextAsync(message, pipeline).ConfigureAwait(false); + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/EmptyTokenCredential.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/EmptyTokenCredential.cs new file mode 100644 index 000000000..1239676f7 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/EmptyTokenCredential.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure.Core; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Afd +{ + /// + /// A token credential that provides an empty token. + /// + internal class EmptyTokenCredential : TokenCredential + { + /// + /// Gets an empty token. + /// + /// The context of the token request. + /// A cancellation token to cancel the operation. + /// An empty access token. + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new AccessToken(string.Empty, DateTimeOffset.MaxValue); + } + + /// + /// Asynchronously gets an empty token. + /// + /// The context of the token request. + /// A cancellation token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains an empty access token. + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new ValueTask(new AccessToken(string.Empty, DateTimeOffset.MaxValue)); + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index e5b6e2585..52fdcece7 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -4,6 +4,7 @@ using Azure.Core; using Azure.Data.AppConfiguration; using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Afd; using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; @@ -132,7 +133,7 @@ internal IEnumerable Adapters /// /// Options used to configure the client used to communicate with Azure App Configuration. /// - internal ConfigurationClientOptions ClientOptions { get; } = GetDefaultClientOptions(); + internal ConfigurationClientOptions ClientOptions { get; private set; } = GetDefaultClientOptions(); /// /// Flag to indicate whether Key Vault options have been configured. @@ -154,6 +155,11 @@ internal IEnumerable Adapters /// internal StartupOptions Startup { get; set; } = new StartupOptions(); + /// + /// Gets a value indicating whether Azure Front Door is used. + /// + internal bool IsAfdUsed { get; private set; } + /// /// Client factory that is responsible for creating instances of ConfigurationClient. /// @@ -186,11 +192,12 @@ public AzureAppConfigurationOptions() public AzureAppConfigurationOptions SetClientFactory(IAzureClientFactory factory) { ClientFactory = factory ?? throw new ArgumentNullException(nameof(factory)); + return this; } /// - /// Specify what key-values to include in the configuration provider. + /// Specifies what key-values to include in the configuration provider. /// can be called multiple times to include multiple sets of key-values. /// /// @@ -262,7 +269,7 @@ public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter } /// - /// Specify a snapshot and include its contained key-values in the configuration provider. + /// Specifies a snapshot and include its contained key-values in the configuration provider. /// can be called multiple times to include key-values from multiple snapshots. /// /// The name of the snapshot in Azure App Configuration. @@ -351,7 +358,7 @@ public AzureAppConfigurationOptions Connect(string connectionString) throw new ArgumentNullException(nameof(connectionString)); } - return Connect(new List { connectionString }); + return Connect(new string[] { connectionString }); } /// @@ -362,6 +369,11 @@ public AzureAppConfigurationOptions Connect(string connectionString) /// public AzureAppConfigurationOptions Connect(IEnumerable connectionStrings) { + if (IsAfdUsed) + { + throw new InvalidOperationException(ErrorMessages.ConnectionConflict); + } + if (connectionStrings == null || !connectionStrings.Any()) { throw new ArgumentNullException(nameof(connectionStrings)); @@ -395,7 +407,7 @@ public AzureAppConfigurationOptions Connect(Uri endpoint, TokenCredential creden throw new ArgumentNullException(nameof(credential)); } - return Connect(new List() { endpoint }, credential); + return Connect(new Uri[] { endpoint }, credential); } /// @@ -405,6 +417,11 @@ public AzureAppConfigurationOptions Connect(Uri endpoint, TokenCredential creden /// Token credential to use to connect. public AzureAppConfigurationOptions Connect(IEnumerable endpoints, TokenCredential credential) { + if (IsAfdUsed) + { + throw new InvalidOperationException(ErrorMessages.ConnectionConflict); + } + if (endpoints == null || !endpoints.Any()) { throw new ArgumentNullException(nameof(endpoints)); @@ -416,12 +433,40 @@ public AzureAppConfigurationOptions Connect(IEnumerable endpoints, TokenCre } Credential = credential ?? throw new ArgumentNullException(nameof(credential)); - Endpoints = endpoints; ConnectionStrings = null; return this; } + /// + /// Connect the provider to Azure Front Door endpoint. + /// + /// The endpoint of the Azure Front Door instance to connect to. + public AzureAppConfigurationOptions ConnectAzureFrontDoor(Uri endpoint) + { + if ((Credential != null && !(Credential is EmptyTokenCredential)) || (ConnectionStrings?.Any() ?? false)) + { + throw new InvalidOperationException(ErrorMessages.ConnectionConflict); + } + + if (IsAfdUsed) + { + throw new InvalidOperationException(ErrorMessages.AfdConnectionConflict); + } + + if (endpoint == null) + { + throw new ArgumentNullException(nameof(endpoint)); + } + + Credential ??= new EmptyTokenCredential(); + + Endpoints = new Uri[] { endpoint }; + ConnectionStrings = null; + IsAfdUsed = true; + return this; + } + /// /// Trims the provided prefix from the keys of all key-values retrieved from Azure App Configuration. /// diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index aa0656d68..bfcddd07e 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -35,8 +35,8 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura private Dictionary _mappedData; private Dictionary _watchedIndividualKvs = new Dictionary(); private HashSet _ffKeys = new HashSet(); - private Dictionary> _kvEtags = new Dictionary>(); - private Dictionary> _ffEtags = new Dictionary>(); + private Dictionary> _watchedKvPages = new Dictionary>(); + private Dictionary> _watchedFfPages = new Dictionary>(); private RequestTracingOptions _requestTracingOptions; private Dictionary _configClientBackoffs = new Dictionary(); private DateTimeOffset _nextCollectionRefreshTime; @@ -276,14 +276,14 @@ public async Task RefreshAsync(CancellationToken cancellationToken) // // Avoid instance state modification - Dictionary> kvEtags = null; - Dictionary> ffEtags = null; + Dictionary> kvEtags = null; + Dictionary> ffEtags = null; HashSet ffKeys = null; Dictionary watchedIndividualKvs = null; - List keyValueChanges = null; + List watchedIndividualKvChanges = null; Dictionary data = null; Dictionary ffCollectionData = null; - bool ffCollectionUpdated = false; + bool refreshFeatureFlag = false; bool refreshAll = false; StringBuilder logInfoBuilder = new StringBuilder(); StringBuilder logDebugBuilder = new StringBuilder(); @@ -294,10 +294,10 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => ffEtags = null; ffKeys = null; watchedIndividualKvs = null; - keyValueChanges = new List(); + watchedIndividualKvChanges = new List(); data = null; ffCollectionData = null; - ffCollectionUpdated = false; + refreshFeatureFlag = false; refreshAll = false; logDebugBuilder.Clear(); logInfoBuilder.Clear(); @@ -305,12 +305,11 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => if (_options.RegisterAllEnabled) { - // Get key value collection changes if RegisterAll was called if (isRefreshDue) { refreshAll = await HaveCollectionsChanged( _options.Selectors.Where(selector => !selector.IsFeatureFlagSelector), - _kvEtags, + _watchedKvPages, client, cancellationToken).ConfigureAwait(false); } @@ -319,7 +318,7 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => { refreshAll = await RefreshIndividualKvWatchers( client, - keyValueChanges, + watchedIndividualKvChanges, refreshableIndividualKvWatchers, endpoint, logDebugBuilder, @@ -331,18 +330,21 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => { // Trigger a single load-all operation if a change was detected in one or more key-values with refreshAll: true, // or if any key-value collection change was detected. - kvEtags = new Dictionary>(); - ffEtags = new Dictionary>(); + kvEtags = new Dictionary>(); + ffEtags = new Dictionary>(); ffKeys = new HashSet(); data = await LoadSelected(client, kvEtags, ffEtags, _options.Selectors, ffKeys, cancellationToken).ConfigureAwait(false); - watchedIndividualKvs = await LoadKeyValuesRegisteredForRefresh(client, data, cancellationToken).ConfigureAwait(false); + + watchedIndividualKvs = await LoadIndividualWatchedSettings(client, data, cancellationToken).ConfigureAwait(false); + logInfoBuilder.AppendLine(LogHelper.BuildConfigurationUpdatedMessage()); + return; } // Get feature flag changes - ffCollectionUpdated = await HaveCollectionsChanged( + refreshFeatureFlag = await HaveCollectionsChanged( refreshableFfWatchers.Select(watcher => new KeyValueSelector { KeyFilter = watcher.Key, @@ -350,18 +352,18 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => TagFilters = watcher.Tags, IsFeatureFlagSelector = true }), - _ffEtags, + _watchedFfPages, client, cancellationToken).ConfigureAwait(false); - if (ffCollectionUpdated) + if (refreshFeatureFlag) { - ffEtags = new Dictionary>(); + ffEtags = new Dictionary>(); ffKeys = new HashSet(); ffCollectionData = await LoadSelected( client, - new Dictionary>(), + new Dictionary>(), ffEtags, _options.Selectors.Where(selector => selector.IsFeatureFlagSelector), ffKeys, @@ -397,9 +399,9 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => { watchedIndividualKvs = new Dictionary(_watchedIndividualKvs); - await ProcessKeyValueChangesAsync(keyValueChanges, _mappedData, watchedIndividualKvs).ConfigureAwait(false); + await ProcessKeyValueChangesAsync(watchedIndividualKvChanges, _mappedData, watchedIndividualKvs).ConfigureAwait(false); - if (ffCollectionUpdated) + if (refreshFeatureFlag) { // Remove all feature flag keys that are not present in the latest loading of feature flags, but were loaded previously foreach (string key in _ffKeys.Except(ffKeys)) @@ -428,13 +430,13 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => _nextCollectionRefreshTime = DateTimeOffset.UtcNow.Add(_options.KvCollectionRefreshInterval); } - if (_options.Adapters.Any(adapter => adapter.NeedsRefresh()) || keyValueChanges.Any() || refreshAll || ffCollectionUpdated) + if (_options.Adapters.Any(adapter => adapter.NeedsRefresh()) || watchedIndividualKvChanges.Any() || refreshAll || refreshFeatureFlag) { _watchedIndividualKvs = watchedIndividualKvs ?? _watchedIndividualKvs; - _ffEtags = ffEtags ?? _ffEtags; + _watchedFfPages = ffEtags ?? _watchedFfPages; - _kvEtags = kvEtags ?? _kvEtags; + _watchedKvPages = kvEtags ?? _watchedKvPages; _ffKeys = ffKeys ?? _ffKeys; @@ -768,8 +770,8 @@ private async Task TryInitializeAsync(IEnumerable cli private async Task InitializeAsync(IEnumerable clients, CancellationToken cancellationToken = default) { Dictionary data = null; - Dictionary> kvEtags = new Dictionary>(); - Dictionary> ffEtags = new Dictionary>(); + Dictionary> kvEtags = new Dictionary>(); + Dictionary> ffEtags = new Dictionary>(); Dictionary watchedIndividualKvs = null; HashSet ffKeys = new HashSet(); @@ -786,7 +788,7 @@ await ExecuteWithFailOverPolicyAsync( cancellationToken) .ConfigureAwait(false); - watchedIndividualKvs = await LoadKeyValuesRegisteredForRefresh( + watchedIndividualKvs = await LoadIndividualWatchedSettings( client, data, cancellationToken) @@ -819,8 +821,8 @@ await ExecuteWithFailOverPolicyAsync( SetData(await PrepareData(mappedData, cancellationToken).ConfigureAwait(false)); _mappedData = mappedData; - _kvEtags = kvEtags; - _ffEtags = ffEtags; + _watchedKvPages = kvEtags; + _watchedFfPages = ffEtags; _watchedIndividualKvs = watchedIndividualKvs; _ffKeys = ffKeys; } @@ -828,8 +830,8 @@ await ExecuteWithFailOverPolicyAsync( private async Task> LoadSelected( ConfigurationClient client, - Dictionary> kvEtags, - Dictionary> ffEtags, + Dictionary> kvPageWatchers, + Dictionary> ffPageWatchers, IEnumerable selectors, HashSet ffKeys, CancellationToken cancellationToken) @@ -854,7 +856,7 @@ private async Task> LoadSelected( } } - var matchConditions = new List(); + var pageWatchers = new List(); await CallWithRequestTracing(async () => { @@ -862,7 +864,8 @@ await CallWithRequestTracing(async () => await foreach (Page page in pageableSettings.AsPages(_options.ConfigurationSettingPageIterator).ConfigureAwait(false)) { - using Response response = page.GetRawResponse(); + using Response rawResponse = page.GetRawResponse(); + DateTimeOffset serverResponseTime = rawResponse.GetMsDate(); foreach (ConfigurationSetting setting in page.Values) { @@ -901,17 +904,21 @@ await CallWithRequestTracing(async () => // The ETag will never be null here because it's not a conditional request // Each successful response should have 200 status code and an ETag - matchConditions.Add(new MatchConditions { IfNoneMatch = response.Headers.ETag }); + pageWatchers.Add(new WatchedPage() + { + MatchConditions = new MatchConditions { IfNoneMatch = rawResponse.Headers.ETag }, + LastServerResponseTime = serverResponseTime + }); } }).ConfigureAwait(false); if (loadOption.IsFeatureFlagSelector) { - ffEtags[loadOption] = matchConditions; + ffPageWatchers[loadOption] = pageWatchers; } else { - kvEtags[loadOption] = matchConditions; + kvPageWatchers[loadOption] = pageWatchers; } } else @@ -966,24 +973,27 @@ await CallWithRequestTracing(async () => return resolvedSettings; } - private async Task> LoadKeyValuesRegisteredForRefresh( + private async Task> LoadIndividualWatchedSettings( ConfigurationClient client, IDictionary existingSettings, CancellationToken cancellationToken) { - var watchedIndividualKvs = new Dictionary(); + var watchedIndividualKvs = new Dictionary(_watchedIndividualKvs); + + Debug.Assert(!_options.IsAfdUsed || !_options.IndividualKvWatchers.Any()); foreach (KeyValueWatcher kvWatcher in _options.IndividualKvWatchers) { string watchedKey = kvWatcher.Key; string watchedLabel = kvWatcher.Label; - KeyValueIdentifier watchedKeyLabel = new KeyValueIdentifier(watchedKey, watchedLabel); + var watchedKeyLabel = new KeyValueIdentifier(watchedKey, watchedLabel); // Skip the loading for the key-value in case it has already been loaded if (existingSettings.TryGetValue(watchedKey, out ConfigurationSetting loadedKv) && watchedKeyLabel.Equals(new KeyValueIdentifier(loadedKv.Key, loadedKv.Label))) { + // create a new instance to avoid that reference could be modified when mapping data watchedIndividualKvs[watchedKeyLabel] = new ConfigurationSetting(loadedKv.Key, loadedKv.Value, loadedKv.Label, loadedKv.ETag); continue; } @@ -1002,6 +1012,7 @@ private async Task> LoadKey // If the key-value was found, store it for updating the settings if (watchedKv != null) { + // create a new instance to avoid that reference could be modified when mapping data watchedIndividualKvs[watchedKeyLabel] = new ConfigurationSetting(watchedKv.Key, watchedKv.Value, watchedKv.Label, watchedKv.ETag); if (watchedKv.ContentType == SnapshotReferenceConstants.ContentType) @@ -1045,6 +1056,8 @@ private async Task RefreshIndividualKvWatchers( StringBuilder logInfoBuilder, CancellationToken cancellationToken) { + Debug.Assert(!_options.IsAfdUsed || !_options.IndividualKvWatchers.Any()); + foreach (KeyValueWatcher kvWatcher in refreshableIndividualKvWatchers) { string watchedKey = kvWatcher.Key; @@ -1058,17 +1071,23 @@ private async Task RefreshIndividualKvWatchers( // Find if there is a change associated with watcher if (_watchedIndividualKvs.TryGetValue(watchedKeyLabel, out ConfigurationSetting watchedKv)) { - await TracingUtils.CallWithRequestTracing(_requestTracingEnabled, RequestType.Watch, _requestTracingOptions, - async () => change = await client.GetKeyValueChange(watchedKv, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); + await CallWithRequestTracing(async () => + change = await client.GetKeyValueChange( + watchedKv, + cancellationToken).ConfigureAwait(false) + ).ConfigureAwait(false); } else { // Load the key-value in case the previous load attempts had failed - try { - await CallWithRequestTracing( - async () => watchedKv = await client.GetConfigurationSettingAsync(watchedKey, watchedLabel, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); + await CallWithRequestTracing(async () => + watchedKv = await client.GetConfigurationSettingAsync( + watchedKey, + watchedLabel, + cancellationToken).ConfigureAwait(false) + ).ConfigureAwait(false); } catch (RequestFailedException e) when (e.Status == (int)HttpStatusCode.NotFound) { @@ -1162,7 +1181,8 @@ private void SetRequestTracingOptions() IsKeyVaultConfigured = _options.IsKeyVaultConfigured, IsKeyVaultRefreshConfigured = _options.IsKeyVaultRefreshConfigured, FeatureFlagTracing = _options.FeatureFlagTracing, - IsLoadBalancingEnabled = _options.LoadBalancingEnabled + IsLoadBalancingEnabled = _options.LoadBalancingEnabled, + IsAfdUsed = _options.IsAfdUsed }; } @@ -1429,7 +1449,7 @@ private void UpdateClientBackoffStatus(Uri endpoint, bool successful) private async Task HaveCollectionsChanged( IEnumerable selectors, - Dictionary> pageEtags, + Dictionary> pageWatchers, ConfigurationClient client, CancellationToken cancellationToken) { @@ -1437,19 +1457,20 @@ private async Task HaveCollectionsChanged( foreach (KeyValueSelector selector in selectors) { - if (pageEtags.TryGetValue(selector, out IEnumerable matchConditions)) + if (pageWatchers.TryGetValue(selector, out IEnumerable watchers)) { await TracingUtils.CallWithRequestTracing(_requestTracingEnabled, RequestType.Watch, _requestTracingOptions, async () => haveCollectionsChanged = await client.HaveCollectionsChanged( selector, - matchConditions, + watchers, _options.ConfigurationSettingPageIterator, + makeConditionalRequest: !_options.IsAfdUsed, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); - } - if (haveCollectionsChanged) - { - return true; + if (haveCollectionsChanged) + { + return true; + } } } @@ -1464,6 +1485,7 @@ private async Task ProcessKeyValueChangesAsync( foreach (KeyValueChange change in keyValueChanges) { KeyValueIdentifier changeIdentifier = new KeyValueIdentifier(change.Key, change.Label); + Debug.Assert(watchedIndividualKvs.ContainsKey(changeIdentifier)); if (change.ChangeType == KeyValueChangeType.Modified) { diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs index 83d20e2fb..230b99cba 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using Azure.Core; using Azure.Data.AppConfiguration; using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Afd; using System; using System.Collections.Generic; using System.Linq; @@ -34,13 +36,36 @@ public IConfigurationProvider Build(IConfigurationBuilder builder) { AzureAppConfigurationOptions options = _optionsProvider(); + IAzureClientFactory clientFactory = options.ClientFactory; + + if (options.IsAfdUsed) + { + if (options.LoadBalancingEnabled) + { + throw new InvalidOperationException(ErrorMessages.AfdLoadBalancingUnsupported); + } + + if (clientFactory != null) + { + throw new InvalidOperationException(ErrorMessages.AfdCustomClientFactoryUnsupported); + } + + if (options.IndividualKvWatchers.Any()) + { + throw new InvalidOperationException($"Registering individual keys for refresh via `{nameof(AzureAppConfigurationRefreshOptions)}.{nameof(AzureAppConfigurationRefreshOptions.Register)}` is not supported when connecting to Azure Front Door. Instead, to enable configuration refresh, use `{nameof(AzureAppConfigurationRefreshOptions)}.{nameof(AzureAppConfigurationRefreshOptions.RegisterAll)}`."); + } + + options.ReplicaDiscoveryEnabled = false; + + options.ClientOptions.AddPolicy(new AfdPolicy(), HttpPipelinePosition.PerRetry); + } + if (options.ClientManager != null) { return new AzureAppConfigurationProvider(options.ClientManager, options, _optional); } IEnumerable endpoints; - IAzureClientFactory clientFactory = options.ClientFactory; if (options.ConnectionStrings != null) { @@ -56,10 +81,17 @@ public IConfigurationProvider Build(IConfigurationBuilder builder) } else { - throw new ArgumentException($"Please call {nameof(AzureAppConfigurationOptions)}.{nameof(AzureAppConfigurationOptions.Connect)} to specify how to connect to Azure App Configuration."); + throw new ArgumentException($"Please call {nameof(AzureAppConfigurationOptions)}.{nameof(AzureAppConfigurationOptions.Connect)} or {nameof(AzureAppConfigurationOptions)}.{nameof(AzureAppConfigurationOptions.ConnectAzureFrontDoor)} to specify how to connect to Azure App Configuration."); } - provider = new AzureAppConfigurationProvider(new ConfigurationClientManager(clientFactory, endpoints, options.ReplicaDiscoveryEnabled, options.LoadBalancingEnabled), options, _optional); + if (options.IsAfdUsed) + { + provider = new AzureAppConfigurationProvider(new AfdConfigurationClientManager(clientFactory, endpoints.First()), options, _optional); + } + else + { + provider = new AzureAppConfigurationProvider(new ConfigurationClientManager(clientFactory, endpoints, options.ReplicaDiscoveryEnabled, options.LoadBalancingEnabled), options, _optional); + } } catch (InvalidOperationException ex) // InvalidOperationException is thrown when any problems are found while configuring AzureAppConfigurationOptions or when SDK fails to create a configurationClient. { diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs index 7bc0e84f9..7b6e0acdd 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs @@ -14,5 +14,9 @@ internal class ErrorMessages 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}'."; + public const string ConnectionConflict = "Cannot connect to both Azure App Configuration and Azure Front Door at the same time."; + public const string AfdConnectionConflict = "Cannot connect to multiple Azure Front Doors."; + public const string AfdLoadBalancingUnsupported = "Load balancing is not supported when connecting to Azure Front Door. For guidance on how to take advantage of geo-replication when Azure Front Door is used, visit https://aka.ms/appconfig/geo-replication-with-afd"; + public const string AfdCustomClientFactoryUnsupported = "Custom client factory is not supported when connecting to Azure Front Door."; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs index 5c4df33ec..e3e7f6160 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs @@ -37,6 +37,7 @@ internal class RequestTracingConstants public const string SignalRUsedTag = "SignalR"; public const string FailoverRequestTag = "Failover"; public const string PushRefreshTag = "PushRefresh"; + public const string AfdTag = "AFD"; public const string FeatureFlagFilterTypeKey = "Filter"; public const string CustomFilter = "CSTM"; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs index c4edfb0ee..28b6749c6 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Threading; using System.Threading.Tasks; @@ -29,7 +30,8 @@ public static async Task GetKeyValueChange(this ConfigurationCli try { Response response = await client.GetConfigurationSettingAsync(setting, onlyIfChanged: true, cancellationToken).ConfigureAwait(false); - if (response.GetRawResponse().Status == (int)HttpStatusCode.OK && + using Response rawResponse = response.GetRawResponse(); + if (rawResponse.Status == (int)HttpStatusCode.OK && !response.Value.ETag.Equals(setting.ETag)) { return new KeyValueChange @@ -64,11 +66,17 @@ public static async Task GetKeyValueChange(this ConfigurationCli }; } - public static async Task HaveCollectionsChanged(this ConfigurationClient client, KeyValueSelector keyValueSelector, IEnumerable matchConditions, IConfigurationSettingPageIterator pageIterator, CancellationToken cancellationToken) + public static async Task HaveCollectionsChanged( + this ConfigurationClient client, + KeyValueSelector keyValueSelector, + IEnumerable pageWatchers, + IConfigurationSettingPageIterator pageIterator, + bool makeConditionalRequest, + CancellationToken cancellationToken) { - if (matchConditions == null) + if (pageWatchers == null) { - throw new ArgumentNullException(nameof(matchConditions)); + throw new ArgumentNullException(nameof(pageWatchers)); } if (keyValueSelector == null) @@ -89,23 +97,29 @@ public static async Task HaveCollectionsChanged(this ConfigurationClient c AsyncPageable pageable = client.GetConfigurationSettingsAsync(selector, cancellationToken); - using IEnumerator existingMatchConditionsEnumerator = matchConditions.GetEnumerator(); + using IEnumerator existingPageWatcherEnumerator = pageWatchers.GetEnumerator(); - await foreach (Page page in pageable.AsPages(pageIterator, matchConditions).ConfigureAwait(false)) + IAsyncEnumerable> pages = makeConditionalRequest + ? pageable.AsPages(pageIterator, pageWatchers.Select(p => p.MatchConditions)) + : pageable.AsPages(pageIterator); + + await foreach (Page page in pages.ConfigureAwait(false)) { - using Response response = page.GetRawResponse(); + using Response rawResponse = page.GetRawResponse(); + DateTimeOffset serverResponseTime = rawResponse.GetMsDate(); - // Return true if the lists of etags are different - if ((!existingMatchConditionsEnumerator.MoveNext() || - !existingMatchConditionsEnumerator.Current.IfNoneMatch.Equals(response.Headers.ETag)) && - response.Status == (int)HttpStatusCode.OK) + if (!existingPageWatcherEnumerator.MoveNext() || + (rawResponse.Status == (int)HttpStatusCode.OK && + // if the server response time is later than last server response time, the change is considered detected + serverResponseTime >= existingPageWatcherEnumerator.Current.LastServerResponseTime && + !existingPageWatcherEnumerator.Current.MatchConditions.IfNoneMatch.Equals(rawResponse.Headers.ETag))) { return true; } } - // Need to check if pages were deleted and no change was found within the new shorter list of match conditions - return existingMatchConditionsEnumerator.MoveNext(); + // Need to check if pages were deleted and no change was found within the new shorter list of page + return existingPageWatcherEnumerator.MoveNext(); } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ResponseExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ResponseExtensions.cs new file mode 100644 index 000000000..9cf8833ae --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ResponseExtensions.cs @@ -0,0 +1,22 @@ +using Azure; +using Azure.Core; +using System; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions +{ + internal static class ResponseExtensions + { + public static DateTimeOffset GetMsDate(this Response response) + { + if (response.Headers.TryGetValue(HttpHeader.Names.XMsDate, out string value)) + { + if (DateTimeOffset.TryParse(value, out DateTimeOffset date)) + { + return date; + } + } + + return DateTimeOffset.UtcNow; + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj index ed0ec6e59..ad2a93ac6 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs index 2f50a3856..d6e32ddc0 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs @@ -3,7 +3,6 @@ // 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; @@ -76,6 +75,11 @@ internal class RequestTracingOptions /// public bool IsPushRefreshUsed { get; set; } = false; + /// + /// Flag to indicate wether the request is sent to a AFD. + /// + public bool IsAfdUsed { get; set; } = false; + /// /// Flag to indicate whether any key-value uses the json content type and contains /// a parameter indicating an AI profile. @@ -132,7 +136,8 @@ public bool UsesAnyTracingFeature() IsSignalRUsed || UsesAIConfiguration || UsesAIChatCompletionConfiguration || - UsesSnapshotReference; + UsesSnapshotReference || + IsAfdUsed; } /// @@ -193,6 +198,16 @@ public string CreateFeaturesString() sb.Append(RequestTracingConstants.SnapshotReferenceTag); } + if (IsAfdUsed) + { + if (sb.Length > 0) + { + sb.Append(RequestTracingConstants.Delimiter); + } + + sb.Append(RequestTracingConstants.AfdTag); + } + return sb.ToString(); } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/WatchedPage.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/WatchedPage.cs new file mode 100644 index 000000000..5fd14a444 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/WatchedPage.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure; +using System; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + internal class WatchedPage + { + public MatchConditions MatchConditions { get; set; } + public DateTimeOffset LastServerResponseTime { get; set; } + } +} diff --git a/tests/Tests.AzureAppConfiguration/Azure.Core.Testing/MockResponse.cs b/tests/Tests.AzureAppConfiguration/Azure.Core.Testing/MockResponse.cs index c60c2a255..a205f0888 100644 --- a/tests/Tests.AzureAppConfiguration/Azure.Core.Testing/MockResponse.cs +++ b/tests/Tests.AzureAppConfiguration/Azure.Core.Testing/MockResponse.cs @@ -13,15 +13,17 @@ public class MockResponse : Response { private readonly Dictionary> _headers = new Dictionary>(StringComparer.OrdinalIgnoreCase); - public MockResponse(int status, string reasonPhrase = null) + public MockResponse(int status, string etag = null, DateTimeOffset? date = null, string reasonPhrase = null) { Status = status; ReasonPhrase = reasonPhrase; if (status == 200) { - AddHeader(new HttpHeader(HttpHeader.Names.ETag, "\"" + Guid.NewGuid().ToString() + "\"")); + AddHeader(new HttpHeader(HttpHeader.Names.ETag, "\"" + (etag ?? Guid.NewGuid().ToString()) + "\"")); } + + AddHeader(new HttpHeader(HttpHeader.Names.XMsDate, date?.ToString() ?? DateTimeOffset.UtcNow.ToString())); } public override int Status { get; } diff --git a/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs b/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs new file mode 100644 index 000000000..f19c93e9e --- /dev/null +++ b/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs @@ -0,0 +1,422 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure; +using Azure.Core.Testing; +using Azure.Data.AppConfiguration; +using Azure.Identity; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; // Added for feature flag constants +using Moq; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Tests.AzureAppConfiguration +{ + public class AfdTests + { + List _kvCollection = new List + { + ConfigurationModelFactory.ConfigurationSetting("TestKey1", "TestValue1", "label", + eTag: new ETag("0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63"), + contentType:"text"), + ConfigurationModelFactory.ConfigurationSetting("TestKey2", "TestValue2", "label", + eTag: new ETag("31c38369-831f-4bf1-b9ad-79db56c8b989"), + contentType: "text"), + ConfigurationModelFactory.ConfigurationSetting("TestKey3", "TestValue3", "label", + eTag: new ETag("bb203f2b-c113-44fc-995d-b933c2143339"), + contentType: "text"), + ConfigurationModelFactory.ConfigurationSetting("TestKey4", "TestValue4", "label", + eTag: new ETag("3ca43b3e-d544-4b0c-b3a2-e7a7284217a2"), + contentType: "text") + }; + + private class TestClientFactory : IAzureClientFactory + { + public ConfigurationClient CreateClient(string name) + { + throw new NotImplementedException(); + } + } + + [Fact] + public void AfdTests_ConnectThrowsAfterConnectAzureFrontDoor() + { + var afdEndpoint = new Uri("https://test.b01.azurefd.net"); + var endpoint = new Uri("https://fake-endpoint.azconfig.io"); + var connectionString = "Endpoint=https://fake-endpoint.azconfig.io;Id=test;Secret=123456"; + var builder = new ConfigurationBuilder(); + var exception = Record.Exception(() => + { + builder.AddAzureAppConfiguration(options => + { + options.ConnectAzureFrontDoor(afdEndpoint); + options.Connect(endpoint, new DefaultAzureCredential()); + }); + builder.Build(); + }); + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.IsType(exception.InnerException); + Assert.Equal(ErrorMessages.ConnectionConflict, exception.InnerException.Message); + + exception = Record.Exception(() => + { + builder.AddAzureAppConfiguration(options => + { + options.ConnectAzureFrontDoor(afdEndpoint); + options.Connect(connectionString); + }); + builder.Build(); + }); + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.IsType(exception.InnerException); + Assert.Equal(ErrorMessages.ConnectionConflict, exception.InnerException.Message); + } + + [Fact] + public void AfdTests_ConnectAzureFrontDoorThrowsAfterConnect() + { + var afdEndpoint = new Uri("https://test.b01.azurefd.net"); + var endpoint = new Uri("https://fake-endpoint.azconfig.io"); + var connectionString = "Endpoint=https://fake-endpoint.azconfig.io;Id=test;Secret=123456"; + var builder = new ConfigurationBuilder(); + var exception = Record.Exception(() => + { + builder.AddAzureAppConfiguration(options => + { + options.Connect(endpoint, new DefaultAzureCredential()); + options.ConnectAzureFrontDoor(afdEndpoint); + }); + builder.Build(); + }); + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.IsType(exception.InnerException); + Assert.Equal(ErrorMessages.ConnectionConflict, exception.InnerException.Message); + + exception = Record.Exception(() => + { + builder.AddAzureAppConfiguration(options => + { + options.Connect(connectionString); + options.ConnectAzureFrontDoor(afdEndpoint); + }); + builder.Build(); + }); + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.IsType(exception.InnerException); + Assert.Equal(ErrorMessages.ConnectionConflict, exception.InnerException.Message); + } + + [Fact] + public void AfdTests_ThrowsWhenConnectMultipleAzureFrontDoor() + { + var afdEndpoint = new Uri("https://test.b01.azurefd.net"); + var afdEndpoint2 = new Uri("https://test.b02.azurefd.net"); + var builder = new ConfigurationBuilder(); + var exception = Record.Exception(() => + { + builder.AddAzureAppConfiguration(options => + { + options.ConnectAzureFrontDoor(afdEndpoint); + options.ConnectAzureFrontDoor(afdEndpoint2); + }); + builder.Build(); + }); + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.IsType(exception.InnerException); + Assert.Equal(ErrorMessages.AfdConnectionConflict, exception.InnerException.Message); + } + + [Fact] + public void AfdTests_WatchedSettingIsUnsupportedWhenConnectAzureFrontDoor() + { + var afdEndpoint = new Uri("https://test.b01.azurefd.net"); + var builder = new ConfigurationBuilder(); + var exception = Record.Exception(() => + { + builder.AddAzureAppConfiguration(options => + { + options.ConnectAzureFrontDoor(afdEndpoint); + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("TestKey1", "label", true); + }); + }); + builder.Build(); + }); + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.IsType(exception.InnerException); + Assert.Contains("Registering individual keys for refresh via `AzureAppConfigurationRefreshOptions.Register` is not supported when connecting to Azure Front Door.", exception.InnerException.Message); + } + + [Fact] + public void AfdTests_LoadbalancingIsUnsupportedWhenConnectAzureFrontDoor() + { + var afdEndpoint = new Uri("https://test.b01.azurefd.net"); + var builder = new ConfigurationBuilder(); + var exception = Record.Exception(() => + { + builder.AddAzureAppConfiguration(options => + { + options.ConnectAzureFrontDoor(afdEndpoint); + options.LoadBalancingEnabled = true; + }); + builder.Build(); + }); + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.IsType(exception.InnerException); + Assert.Equal(ErrorMessages.AfdLoadBalancingUnsupported, exception.InnerException.Message); + } + + [Fact] + public void AfdTests_CustomClientOptionsNotSupported() + { + var afdEndpoint = new Uri("https://test.b01.azurefd.net"); + var builder = new ConfigurationBuilder(); + var exception = Record.Exception(() => + { + builder.AddAzureAppConfiguration(options => + { + options.ConnectAzureFrontDoor(afdEndpoint); + options.SetClientFactory(new TestClientFactory()); + }); + builder.Build(); + }); + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.IsType(exception.InnerException); + Assert.Equal(ErrorMessages.AfdCustomClientFactoryUnsupported, exception.InnerException.Message); + } + + [Fact] + public async Task AfdTests_RegisterAllRefresh() + { + var mockClient = new Mock(MockBehavior.Strict); + + var keyValueCollection1 = new List(_kvCollection); + string page1_etag = Guid.NewGuid().ToString(); + string page2_etag = Guid.NewGuid().ToString(); + var responses = new List() + { + new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")), + new MockResponse(200, page2_etag, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")) + }; + var mockAsyncPageable1 = new MockAsyncPageable(keyValueCollection1, null, 3, responses); + + var keyValueCollection2 = new List(_kvCollection); + keyValueCollection2[3].Value = "old-value"; + string page2_etag2 = Guid.NewGuid().ToString(); + var responses2 = new List() + { + new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")), + new MockResponse(200, page2_etag2, DateTimeOffset.Parse("2025-10-17T08:59:59+08:00")) // stale, should not refresh + }; + var mockAsyncPageable2 = new MockAsyncPageable(keyValueCollection2, null, 3, responses2); + + var keyValueCollection3 = new List(_kvCollection); + keyValueCollection3[3].Value = "new-value"; + var responses3 = new List() + { + new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")), // up-to-date, should refresh + new MockResponse(200, page2_etag2, DateTimeOffset.Parse("2025-10-17T09:00:02+08:00")) + }; + var mockAsyncPageable3 = new MockAsyncPageable(keyValueCollection3, null, 3, responses3); + + var responses4 = new List() + { + new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:03+08:00")), // up-to-date + new MockResponse(200, page2_etag2, DateTimeOffset.Parse("2025-10-17T09:00:03+08:00")) + }; + var mockAsyncPageable4 = new MockAsyncPageable(keyValueCollection3, null, 3, responses4); + + mockClient.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(mockAsyncPageable1) + .Returns(mockAsyncPageable2) + .Returns(mockAsyncPageable3) + .Returns(mockAsyncPageable4); + + var afdEndpoint = new Uri("https://test.b01.azurefd.net"); + IConfigurationRefresher refresher = null; + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ConnectAzureFrontDoor(afdEndpoint); + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); + options.Select("TestKey*", "label"); + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.RegisterAll() + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + refresher = options.GetRefresher(); + }) + .Build(); + + Assert.Equal("TestValue4", config["TestKey4"]); + + await Task.Delay(1500); + + await refresher.RefreshAsync(); + + Assert.Equal("TestValue4", config["TestKey4"]); // should not refresh, because page 2 is stale + + await Task.Delay(1500); + + await refresher.RefreshAsync(); + + Assert.Equal("new-value", config["TestKey4"]); + } + + [Fact] + public async Task AfdTests_FeatureFlagsRefresh() + { + var mockClient = new Mock(MockBehavior.Strict); + + var featureFlag = new List + { + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "BetaFlag", + value: @" + { + ""id"": ""BetaFlag"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""Browser"", + ""parameters"": { + ""AllowedBrowsers"": [ ""Firefox"", ""Safari"" ] + } + } + ] + } + }", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag(Guid.NewGuid().ToString())) + }; + + var staleFeatureFlag = new List + { + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "BetaFlag", + value: @" + { + ""id"": ""BetaFlag"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""Browser"", + ""parameters"": { + ""AllowedBrowsers"": [ ""360"" ] + } + } + ] + } + }", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag(Guid.NewGuid().ToString())) + }; + + var newFeatureFlag = new List + { + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "BetaFlag", + value: @" + { + ""id"": ""BetaFlag"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""Browser"", + ""parameters"": { + ""AllowedBrowsers"": [ ""Chrome"", ""Edge"" ] + } + } + ] + } + }", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag(Guid.NewGuid().ToString())) + }; + + string etag1 = Guid.NewGuid().ToString(); + var responses = new List() + { + new MockResponse(200, etag1, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")) + }; + var mockAsyncPageable1 = new MockAsyncPageable(featureFlag, null, 10, responses); + + string etag2 = Guid.NewGuid().ToString(); + var responses2 = new List() + { + new MockResponse(200, etag2, DateTimeOffset.Parse("2025-10-17T08:59:59+08:00")) + }; + var mockAsyncPageable2 = new MockAsyncPageable(staleFeatureFlag, null, 10, responses); + + string etag3 = Guid.NewGuid().ToString(); + var responses3 = new List() + { + new MockResponse(200, etag3, DateTimeOffset.Parse("2025-10-17T09:00:02+08:00")) + }; + var mockAsyncPageable3 = new MockAsyncPageable(newFeatureFlag, null, 10, responses3); + + mockClient.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(mockAsyncPageable1) // default load configuration settings + .Returns(mockAsyncPageable1) // load feature flag + .Returns(mockAsyncPageable2) // watch request, should not trigger refresh + .Returns(mockAsyncPageable3) // watch request, should trigger refresh + .Returns(mockAsyncPageable3) // default load configuration settings + .Returns(mockAsyncPageable3); // load feature flag + + var afdEndpoint = new Uri("https://test.b01.azurefd.net"); + IConfigurationRefresher refresher = null; + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ConnectAzureFrontDoor(afdEndpoint); + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); + options.UseFeatureFlags(o => o.SetRefreshInterval(TimeSpan.FromSeconds(1))); + refresher = options.GetRefresher(); + }) + .Build(); + + Assert.Equal("Browser", config["FeatureManagement:BetaFlag:EnabledFor:0:Name"]); + Assert.Equal("Firefox", config["FeatureManagement:BetaFlag:EnabledFor:0:Parameters:AllowedBrowsers:0"]); + Assert.Equal("Safari", config["FeatureManagement:BetaFlag:EnabledFor:0:Parameters:AllowedBrowsers:1"]); + + await Task.Delay(1500); + + await refresher.RefreshAsync(); + + // Still old values because page timestamp was stale + Assert.Equal("Firefox", config["FeatureManagement:BetaFlag:EnabledFor:0:Parameters:AllowedBrowsers:0"]); + Assert.Equal("Safari", config["FeatureManagement:BetaFlag:EnabledFor:0:Parameters:AllowedBrowsers:1"]); + + await Task.Delay(1500); + + await refresher.RefreshAsync(); + + Assert.Equal("Chrome", config["FeatureManagement:BetaFlag:EnabledFor:0:Parameters:AllowedBrowsers:0"]); + Assert.Equal("Edge", config["FeatureManagement:BetaFlag:EnabledFor:0:Parameters:AllowedBrowsers:1"]); + } + } +} diff --git a/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs b/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs index 57735f379..72b3a8260 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs @@ -2,6 +2,8 @@ // Licensed under the MIT license. // using Azure; +using Azure.Core; +using Azure.Core.Testing; using Azure.Data.AppConfiguration; using Azure.Identity; using Microsoft.Extensions.Configuration; @@ -27,7 +29,6 @@ public async Task FailOverTests_ReturnsAllClientsIfAllBackedOff() { // Arrange IConfigurationRefresher refresher = null; - var mockResponse = new Mock(); var mockClient1 = new Mock(); mockClient1.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -96,7 +97,6 @@ public void FailOverTests_PropagatesNonFailOverableExceptions() { // Arrange IConfigurationRefresher refresher = null; - var mockResponse = new Mock(); var mockClient1 = new Mock(); mockClient1.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -148,7 +148,7 @@ public async Task FailOverTests_BackoffStateIsUpdatedOnSuccessfulRequest() { // Arrange IConfigurationRefresher refresher = null; - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient1 = new Mock(); mockClient1.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -156,11 +156,11 @@ public async Task FailOverTests_BackoffStateIsUpdatedOnSuccessfulRequest() .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())) .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())); mockClient1.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))) + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient1.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))) + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient1.Setup(c => c.Equals(mockClient1)).Returns(true); var mockClient2 = new Mock(); @@ -168,11 +168,11 @@ public async Task FailOverTests_BackoffStateIsUpdatedOnSuccessfulRequest() .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())) .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())); mockClient2.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))) + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient2.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))) + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object); @@ -222,7 +222,7 @@ public void FailOverTests_AutoFailover() { // Arrange IConfigurationRefresher refresher = null; - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient1 = new Mock(); mockClient1.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -237,9 +237,9 @@ public void FailOverTests_AutoFailover() mockClient2.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())); mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object); @@ -340,9 +340,7 @@ public void FailOverTests_GetNoDynamicClient() [Fact] public void FailOverTests_NetworkTimeout() { - // Arrange - IConfigurationRefresher refresher = null; - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var client1 = new ConfigurationClient(TestHelpers.CreateMockEndpointString(), new ConfigurationClientOptions() @@ -357,9 +355,9 @@ public void FailOverTests_NetworkTimeout() mockClient2.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())); mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, client1); @@ -420,7 +418,7 @@ ae.InnerException is AggregateException ae2 && public async Task FailOverTests_AllClientsBackedOffAfterNonFailoverableException() { IConfigurationRefresher refresher = null; - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); // Setup first client - succeeds on startup, fails with 404 (non-failoverable) on first refresh var mockClient1 = new Mock(); @@ -429,7 +427,7 @@ public async Task FailOverTests_AllClientsBackedOffAfterNonFailoverableException .Throws(new RequestFailedException(412, "Request failed.")) .Throws(new RequestFailedException(412, "Request failed.")); mockClient1.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))) + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))) .Throws(new RequestFailedException(412, "Request failed.")) .Throws(new RequestFailedException(412, "Request failed.")); mockClient1.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) @@ -442,9 +440,9 @@ public async Task FailOverTests_AllClientsBackedOffAfterNonFailoverableException mockClient2.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())); mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object); diff --git a/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs index a668b3a15..de649a0d1 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs @@ -1072,7 +1072,7 @@ public void PreservesDefaultQuery() options.UseFeatureFlags(); }).Build(); - bool performedDefaultQuery = mockTransport.Requests.Any(r => r.Uri.PathAndQuery.Contains("/kv?key=%2A&label=%00")); + bool performedDefaultQuery = mockTransport.Requests.Any(r => r.Uri.PathAndQuery.Contains("key=%2A&label=%00")); bool queriedFeatureFlags = mockTransport.Requests.Any(r => r.Uri.PathAndQuery.Contains(Uri.EscapeDataString(FeatureManagementConstants.FeatureFlagMarker))); Assert.True(performedDefaultQuery); @@ -1100,7 +1100,7 @@ public void QueriesFeatureFlags() }) .Build(); - bool performedDefaultQuery = mockTransport.Requests.Any(r => r.Uri.PathAndQuery.Contains("/kv?key=%2A&label=%00")); + bool performedDefaultQuery = mockTransport.Requests.Any(r => r.Uri.PathAndQuery.Contains("key=%2A&label=%00")); bool queriedFeatureFlags = mockTransport.Requests.Any(r => r.Uri.PathAndQuery.Contains(Uri.EscapeDataString(FeatureManagementConstants.FeatureFlagMarker))); Assert.True(performedDefaultQuery); @@ -2367,7 +2367,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o Response GetTestKey(string key, string label, CancellationToken cancellationToken) { - return Response.FromValue(TestHelpers.CloneSetting(FirstKeyValue), new Mock().Object); + return Response.FromValue(TestHelpers.CloneSetting(FirstKeyValue), new MockResponse(200)); } private ConfigurationSetting CreateFeatureFlag(string featureId, diff --git a/tests/Tests.AzureAppConfiguration/Unit/KeyVaultReferenceTests.cs b/tests/Tests.AzureAppConfiguration/Unit/KeyVaultReferenceTests.cs index 3e856a1b0..a671300eb 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/KeyVaultReferenceTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/KeyVaultReferenceTests.cs @@ -171,7 +171,6 @@ public class KeyVaultReferenceTests [Fact] public void NotSecretIdentifierURI() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kvNoUrl })); @@ -198,7 +197,6 @@ public void NotSecretIdentifierURI() [Fact] public void UseSecret() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -223,7 +221,6 @@ public void UseSecret() [Fact] public void UseCertificate() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kvCertRef })); @@ -248,7 +245,6 @@ public void UseCertificate() [Fact] public void ThrowsWhenSecretNotFound() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -273,7 +269,6 @@ public void ThrowsWhenSecretNotFound() [Fact] public void DisabledSecretIdentifier() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -298,7 +293,6 @@ public void DisabledSecretIdentifier() [Fact] public void WrongContentType() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kvWrongContentType })); @@ -320,7 +314,6 @@ public void WrongContentType() [Fact] public void MultipleKeys() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(_kvCollectionPageOne)); @@ -346,7 +339,6 @@ public void MultipleKeys() [Fact] public void CancellationToken() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(_kvCollectionPageOne)); @@ -374,7 +366,6 @@ public void CancellationToken() [Fact] public void HasNoAccessToKeyVault() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -400,7 +391,6 @@ public void HasNoAccessToKeyVault() [Fact] public void RegisterMultipleClients() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -431,7 +421,6 @@ public void RegisterMultipleClients() [Fact] public void ServerRequestIsMadeWhenDefaultCredentialIsSet() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -452,7 +441,6 @@ public void ServerRequestIsMadeWhenDefaultCredentialIsSet() [Fact] public void ThrowsWhenNoMatchingSecretClientIsFound() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -481,7 +469,6 @@ public void ThrowsWhenNoMatchingSecretClientIsFound() [Fact] public void ThrowsWhenConfigureKeyVaultIsMissing() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -524,7 +511,6 @@ public void DoesNotThrowKeyVaultExceptionWhenProviderIsOptional() [Fact] public void CallsSecretResolverCallbackWhenNoMatchingSecretClientIsFound() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -549,7 +535,6 @@ public void CallsSecretResolverCallbackWhenNoMatchingSecretClientIsFound() [Fact] public void ThrowsWhenBothDefaultCredentialAndSecretResolverCallbackAreSet() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -579,7 +564,6 @@ public void ThrowsWhenBothDefaultCredentialAndSecretResolverCallbackAreSet() [Fact] public void ThrowsWhenSecretResolverIsNull() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -601,7 +585,6 @@ public void ThrowsWhenSecretResolverIsNull() [Fact] public void LastKeyVaultOptionsWinWithMultipleConfigureKeyVaultCalls() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -630,7 +613,6 @@ public void LastKeyVaultOptionsWinWithMultipleConfigureKeyVaultCalls() [Fact] public void DontUseSecretResolverCallbackWhenMatchingSecretClientIsPresent() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -663,7 +645,6 @@ public void DontUseSecretResolverCallbackWhenMatchingSecretClientIsPresent() [Fact] public void ThrowsWhenSecretRefreshIntervalIsTooShort() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -688,12 +669,12 @@ public async Task SecretIsReturnedFromCacheIfSecretCacheHasNotExpired() IConfigurationRefresher refresher = null; TimeSpan refreshInterval = TimeSpan.FromSeconds(1); - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); Response GetTestKey(string key, string label, CancellationToken cancellationToken) { - return Response.FromValue(TestHelpers.CloneSetting(sentinelKv), mockResponse.Object); + return Response.FromValue(TestHelpers.CloneSetting(sentinelKv), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) @@ -761,12 +742,12 @@ public async Task CachedSecretIsInvalidatedWhenRefreshAllIsTrue() IConfigurationRefresher refresher = null; TimeSpan refreshInterval = TimeSpan.FromSeconds(60); - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); Response GetTestKey(string key, string label, CancellationToken cancellationToken) { - return Response.FromValue(TestHelpers.CloneSetting(sentinelKv), mockResponse.Object); + return Response.FromValue(TestHelpers.CloneSetting(sentinelKv), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) @@ -833,7 +814,6 @@ public async Task SecretIsReloadedFromKeyVaultWhenCacheExpires() IConfigurationRefresher refresher = null; TimeSpan refreshInterval = TimeSpan.FromSeconds(60); - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -965,7 +945,6 @@ public async Task SecretsWithDifferentRefreshIntervals() [Fact] public void ThrowsWhenInvalidKeyVaultSecretReferenceJson() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); var cacheExpiration = TimeSpan.FromSeconds(1); @@ -1008,7 +987,6 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) [Fact] public void AlternateValidKeyVaultSecretReferenceJsons() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); var cacheExpiration = TimeSpan.FromSeconds(1); diff --git a/tests/Tests.AzureAppConfiguration/Unit/LoggingTests.cs b/tests/Tests.AzureAppConfiguration/Unit/LoggingTests.cs index 6fcec29fd..9fd87b887 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/LoggingTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/LoggingTests.cs @@ -187,12 +187,12 @@ public async Task ValidateKeyVaultExceptionLoggedDuringRefresh() IConfigurationRefresher refresher = null; // Mock ConfigurationClient - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); Response GetTestKey(string key, string label, CancellationToken cancellationToken) { - return Response.FromValue(TestHelpers.CloneSetting(sentinelKv), mockResponse.Object); + return Response.FromValue(TestHelpers.CloneSetting(sentinelKv), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) @@ -602,17 +602,24 @@ public async Task ValidateCorrectKeyVaultSecretLoggedDuringRefresh() private Mock GetMockConfigurationClient() { - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); - Response GetTestKey(string key, string label, CancellationToken cancellationToken) + Response GetSetting(string key, string label, CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) { cancellationToken.ThrowIfCancellationRequested(); } - return Response.FromValue(TestHelpers.CloneSetting(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label)), mockResponse.Object); + if (label.Equals(LabelFilter.Null)) + { + label = null; + } + + ConfigurationSetting setting = _kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label); + + return Response.FromValue(TestHelpers.CloneSetting(setting), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) @@ -636,7 +643,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o }); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((Func>)GetTestKey); + .ReturnsAsync((Func>)GetSetting); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((Func>)GetIfChanged); diff --git a/tests/Tests.AzureAppConfiguration/Unit/MapTests.cs b/tests/Tests.AzureAppConfiguration/Unit/MapTests.cs index cdf15f4e6..a82aa54fb 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/MapTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/MapTests.cs @@ -537,7 +537,7 @@ public async Task MapTransformSettingKeyWithLogAndRefresh() private Mock GetMockConfigurationClient() { - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); Response GetTestKey(string key, string label, CancellationToken cancellationToken) @@ -547,7 +547,7 @@ Response GetTestKey(string key, string label, Cancellation cancellationToken.ThrowIfCancellationRequested(); } - return Response.FromValue(TestHelpers.CloneSetting(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label)), mockResponse.Object); + return Response.FromValue(TestHelpers.CloneSetting(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label)), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) diff --git a/tests/Tests.AzureAppConfiguration/Unit/PushRefreshTests.cs b/tests/Tests.AzureAppConfiguration/Unit/PushRefreshTests.cs index 41e265d20..db0b7de5b 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/PushRefreshTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/PushRefreshTests.cs @@ -357,12 +357,12 @@ public async Task RefreshAsyncUpdatesConfig() private Mock GetMockConfigurationClient() { - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); Response GetTestKey(string key, string label, CancellationToken cancellationToken) { - return Response.FromValue(TestHelpers.CloneSetting(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label)), mockResponse.Object); + return Response.FromValue(TestHelpers.CloneSetting(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label)), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) diff --git a/tests/Tests.AzureAppConfiguration/Unit/RefreshTests.cs b/tests/Tests.AzureAppConfiguration/Unit/RefreshTests.cs index 08a414780..25d57baab 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/RefreshTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/RefreshTests.cs @@ -5,6 +5,7 @@ using Azure.Core.Testing; using Azure.Data.AppConfiguration; using Azure.Identity; +using Azure.ResourceManager.KeyVault; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.AzureAppConfiguration; using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; @@ -66,12 +67,12 @@ public class RefreshTests public void RefreshTests_RefreshRegisteredKeysAreLoadedOnStartup_DefaultUseQuery() { var keyValueCollection = new List(_kvCollection); - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); Response GetTestKey(string key, string label, CancellationToken cancellationToken) { - return Response.FromValue(keyValueCollection.FirstOrDefault(s => s.Key == key && s.Label == label), mockResponse.Object); + return Response.FromValue(keyValueCollection.FirstOrDefault(s => s.Key == key && s.Label == label), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) @@ -298,12 +299,12 @@ public async Task RefreshTests_RefreshAllTrueUpdatesEntireConfiguration() public async Task RefreshTests_RefreshAllTrueRemovesDeletedConfiguration() { var keyValueCollection = new List(_kvCollection); - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); Response GetSettingFromService(string k, string l, CancellationToken ct) { - return Response.FromValue(keyValueCollection.FirstOrDefault(s => s.Key == k && s.Label == l), mockResponse.Object); + return Response.FromValue(keyValueCollection.FirstOrDefault(s => s.Key == k && s.Label == l), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) @@ -370,12 +371,17 @@ Response GetIfChanged(ConfigurationSetting setting, bool o public async Task RefreshTests_RefreshAllForNonExistentSentinelDoesNothing() { var keyValueCollection = new List(_kvCollection); - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); - Response GetSettingFromService(string k, string l, CancellationToken ct) + Response GetSetting(string k, string l, CancellationToken ct) { - return Response.FromValue(keyValueCollection.FirstOrDefault(s => s.Key == k && s.Label == l), mockResponse.Object); + if (l.Equals(LabelFilter.Null)) + { + l = null; + } + + return Response.FromValue(keyValueCollection.FirstOrDefault(s => s.Key == k && s.Label == l), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) @@ -399,7 +405,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o }); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((Func>)GetSettingFromService); + .ReturnsAsync((Func>)GetSetting); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((Func>)GetIfChanged); @@ -448,7 +454,7 @@ public async Task RefreshTests_SingleServerCallOnSimultaneousMultipleRefresh() { var keyValueCollection = new List(_kvCollection); var requestCount = 0; - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); // Define delay for async operations @@ -478,9 +484,22 @@ async Task> GetIfChanged(ConfigurationSetting set return Response.FromValue(newSetting, response); } + Response GetSetting(string k, string l, CancellationToken ct) + { + if (l.Equals(LabelFilter.Null)) + { + l = null; + } + + return Response.FromValue(keyValueCollection.FirstOrDefault(s => s.Key == k && s.Label == l), mockResponse); + } + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns((Func>>)GetIfChanged); + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Func>)GetSetting); + IConfigurationRefresher refresher = null; var config = new ConfigurationBuilder() @@ -622,18 +641,32 @@ public async Task RefreshTests_TryRefreshAsyncUpdatesConfigurationAndReturnsTrue public async Task RefreshTests_TryRefreshAsyncReturnsFalseForAuthenticationFailedException() { IConfigurationRefresher refresher = null; - var mockResponse = new Mock(); + var keyValueCollection = new List(_kvCollection); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); + Response GetSetting(string k, string l, CancellationToken ct) + { + if (l.Equals(LabelFilter.Null)) + { + l = null; + } + + return Response.FromValue(keyValueCollection.FirstOrDefault(s => s.Key == k && s.Label == l), mockResponse); + } + mockClient.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(_kvCollection.Select(setting => TestHelpers.CloneSetting(setting)).ToList())); var innerException = new AuthenticationFailedException("Authentication failed.") { Source = "Azure.Identity" }; mockClient.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(_kvCollection.FirstOrDefault(s => s.Key == "TestKey1"), mockResponse.Object))) + .Returns(Task.FromResult(Response.FromValue(_kvCollection.FirstOrDefault(s => s.Key == "TestKey1"), mockResponse))) .Throws(new KeyVaultReferenceException(innerException.Message, innerException)); + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Func>)GetSetting); + var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { @@ -669,9 +702,20 @@ public async Task RefreshTests_TryRefreshAsyncReturnsFalseForAuthenticationFaile public async Task RefreshTests_RefreshAsyncThrowsOnExceptionWhenOptionalIsTrueForInitialLoad() { IConfigurationRefresher refresher = null; - var mockResponse = new Mock(); + var keyValueCollection = new List(_kvCollection); + var mockResponse = new MockResponse(200); var mockClient = new Mock() { CallBase = true }; + Response GetSetting(string k, string l, CancellationToken ct) + { + if (l.Equals(LabelFilter.Null)) + { + l = null; + } + + return Response.FromValue(keyValueCollection.FirstOrDefault(s => s.Key == k && s.Label == l), mockResponse); + } + Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) { var newSetting = _kvCollection.FirstOrDefault(s => s.Key == setting.Key && s.Label == setting.Label); @@ -684,6 +728,9 @@ Response GetIfChanged(ConfigurationSetting setting, bool o .Returns(new MockAsyncPageable(_kvCollection.Select(setting => TestHelpers.CloneSetting(setting)).ToList())) .Throws(new RequestFailedException("Request failed.")); + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Func>)GetSetting); + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((Func>)GetIfChanged); @@ -716,7 +763,7 @@ await refresher.RefreshAsync() [Fact] public async Task RefreshTests_UpdatesAllSettingsIfInitialLoadFails() { - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock() { CallBase = true }; mockClient.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -725,7 +772,7 @@ public async Task RefreshTests_UpdatesAllSettingsIfInitialLoadFails() .Returns(new MockAsyncPageable(_kvCollection)); mockClient.SetupSequence(c => c.GetConfigurationSettingAsync("TestKey1", It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(_kvCollection.FirstOrDefault(s => s.Key == "TestKey1" && s.Label == "label"), mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(_kvCollection.FirstOrDefault(s => s.Key == "TestKey1" && s.Label == "label"), mockResponse))); IConfigurationRefresher refresher = null; IConfiguration configuration = new ConfigurationBuilder() @@ -779,9 +826,19 @@ await Assert.ThrowsAsync(async () => public async Task RefreshTests_SentinelKeyNotUpdatedOnRefreshAllFailure() { var keyValueCollection = new List(_kvCollection); - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock() { CallBase = true }; + Response GetSetting(string k, string l, CancellationToken ct) + { + if (l.Equals(LabelFilter.Null)) + { + l = null; + } + + return Response.FromValue(keyValueCollection.FirstOrDefault(s => s.Key == k && s.Label == l), mockResponse); + } + Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) { var newSetting = keyValueCollection.FirstOrDefault(s => s.Key == setting.Key); @@ -795,13 +852,17 @@ Response GetIfChanged(ConfigurationSetting setting, bool o .Throws(new RequestFailedException(429, "Too many requests")) .Returns(new MockAsyncPageable(keyValueCollection.Select(setting => { - setting.Value = "newValue"; - return TestHelpers.CloneSetting(setting); + var newSetting = TestHelpers.CloneSetting(setting); + newSetting.Value = "newValue"; + return newSetting; }).ToList())); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((Func>)GetIfChanged); + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Func>)GetSetting); + IConfigurationRefresher refresher = null; var config = new ConfigurationBuilder() @@ -1323,7 +1384,7 @@ private void WaitAndRefresh(IConfigurationRefresher refresher, int millisecondsD private Mock GetMockConfigurationClient() { - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); Response GetTestKey(string key, string label, CancellationToken cancellationToken) @@ -1333,7 +1394,7 @@ Response GetTestKey(string key, string label, Cancellation cancellationToken.ThrowIfCancellationRequested(); } - return Response.FromValue(TestHelpers.CloneSetting(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label)), mockResponse.Object); + return Response.FromValue(TestHelpers.CloneSetting(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label)), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) @@ -1367,7 +1428,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o private Mock GetMockConfigurationClientSelectKeyLabel() { - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) @@ -1381,7 +1442,7 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) Response GetTestKey(string key, string label, CancellationToken cancellationToken) { - return Response.FromValue(TestHelpers.CloneSetting(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label)), mockResponse.Object); + return Response.FromValue(TestHelpers.CloneSetting(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label)), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) diff --git a/tests/Tests.AzureAppConfiguration/Unit/SnapshotReferenceTests.cs b/tests/Tests.AzureAppConfiguration/Unit/SnapshotReferenceTests.cs index 830a92f28..c49858e3e 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/SnapshotReferenceTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/SnapshotReferenceTests.cs @@ -366,7 +366,7 @@ public async Task SnapshotReferenceRegisteredWithRefreshAllFalse() IConfigurationRefresher refresher = null; TimeSpan refreshInterval = TimeSpan.FromSeconds(1); - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); bool refreshAllTriggered = false; @@ -378,7 +378,7 @@ public async Task SnapshotReferenceRegisteredWithRefreshAllFalse() var realSnapshot = new ConfigurationSnapshot(settingsToInclude) { SnapshotComposition = SnapshotComposition.Key }; mockClient.Setup(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny())) - .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse.Object)); + .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse)); mockClient.Setup(c => c.GetConfigurationSettingsForSnapshotAsync("snapshot1", It.IsAny())) .Returns(new MockAsyncPageable(new List { _settingInSnapshot1 })); @@ -387,7 +387,7 @@ public async Task SnapshotReferenceRegisteredWithRefreshAllFalse() var realSnapshot2 = new ConfigurationSnapshot(settingsToInclude) { SnapshotComposition = SnapshotComposition.Key }; mockClient.Setup(c => c.GetSnapshotAsync("snapshot2", It.IsAny>(), It.IsAny())) - .ReturnsAsync(Response.FromValue(realSnapshot2, mockResponse.Object)); + .ReturnsAsync(Response.FromValue(realSnapshot2, mockResponse)); mockClient.Setup(c => c.GetConfigurationSettingsForSnapshotAsync("snapshot2", It.IsAny())) .Returns(new MockAsyncPageable(new List { _settingInSnapshot2 })); @@ -395,7 +395,7 @@ public async Task SnapshotReferenceRegisteredWithRefreshAllFalse() mockClient.Setup(c => c.GetConfigurationSettingAsync("SnapshotRef1", It.IsAny(), It.IsAny())) .ReturnsAsync(() => { - return Response.FromValue(updatedSnapshotRef1, mockResponse.Object); + return Response.FromValue(updatedSnapshotRef1, mockResponse); }); // Setup refresh check - simulate change detected @@ -439,7 +439,7 @@ public async Task SnapshotReferenceRegisteredWithRefreshAllTrue_TriggersRefreshA IConfigurationRefresher refresher = null; TimeSpan refreshInterval = TimeSpan.FromSeconds(1); - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); bool refreshAllTriggered = false; @@ -451,13 +451,13 @@ public async Task SnapshotReferenceRegisteredWithRefreshAllTrue_TriggersRefreshA var realSnapshot = new ConfigurationSnapshot(settingsToInclude) { SnapshotComposition = SnapshotComposition.Key }; mockClient.Setup(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny())) - .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse.Object)); + .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse)); 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)); + .ReturnsAsync(() => Response.FromValue(_snapshotReference1, mockResponse)); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((ConfigurationSetting setting, bool onlyIfChanged, CancellationToken token) => @@ -497,7 +497,7 @@ public async Task SnapshotReferenceRegisteredWithoutRefreshAllParameter_StillTri IConfigurationRefresher refresher = null; TimeSpan refreshInterval = TimeSpan.FromSeconds(1); - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); bool refreshAllTriggered = false; @@ -509,13 +509,13 @@ public async Task SnapshotReferenceRegisteredWithoutRefreshAllParameter_StillTri var realSnapshot = new ConfigurationSnapshot(settingsToInclude) { SnapshotComposition = SnapshotComposition.Key }; mockClient.Setup(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny())) - .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse.Object)); + .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse)); 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)); + .ReturnsAsync(() => Response.FromValue(_snapshotReference1, mockResponse)); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((ConfigurationSetting setting, bool onlyIfChanged, CancellationToken token) => @@ -555,7 +555,7 @@ public void SnapshotReferenceRegisteredForRefreshButNotInSelect() IConfigurationRefresher refresher = null; TimeSpan refreshInterval = TimeSpan.FromSeconds(1); - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); // Only return regular key-value in initial load (snapshot reference not selected) @@ -567,14 +567,14 @@ public void SnapshotReferenceRegisteredForRefreshButNotInSelect() var realSnapshot = new ConfigurationSnapshot(settingsToInclude) { SnapshotComposition = SnapshotComposition.Key }; mockClient.Setup(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny())) - .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse.Object)); + .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse)); 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)); + .ReturnsAsync(() => Response.FromValue(_snapshotReference1, mockResponse)); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((ConfigurationSetting setting, bool onlyIfChanged, CancellationToken token) => @@ -612,4 +612,4 @@ public void SnapshotReferenceRegisteredForRefreshButNotInSelect() mockClient.Verify(c => c.GetConfigurationSettingAsync("SnapshotRef1", It.IsAny(), It.IsAny()), Times.Once); } } -} \ No newline at end of file +} diff --git a/tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs b/tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs index 9fd3f388e..437e1171d 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs @@ -16,6 +16,8 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using System.Globalization; +using System.Diagnostics; namespace Tests.AzureAppConfiguration { @@ -162,12 +164,18 @@ public static bool ValidateLog(Mock logger, string expectedMessage, Log class MockAsyncPageable : AsyncPageable { - private readonly List _collection = new List(); - private int _status; + private List> _pages; + private List _responses; + private int _status = 200; + private readonly int _itemsPerPage; + private List _collection = new List(); private readonly TimeSpan? _delay; - public MockAsyncPageable(List collection, TimeSpan? delay = null) + public MockAsyncPageable(List collection, TimeSpan? delay = null, int itemsPerPage = 100, List responses = null) { + _itemsPerPage = itemsPerPage; + _delay = delay; + foreach (ConfigurationSetting setting in collection) { var newSetting = new ConfigurationSetting(setting.Key, setting.Value, setting.Label, setting.ETag); @@ -177,18 +185,35 @@ public MockAsyncPageable(List collection, TimeSpan? delay _collection.Add(newSetting); } - _status = 200; - _delay = delay; + if (responses != null) + { + _responses = responses; + } + + SlicePages(); + } + + private void SlicePages() + { + int pageCount = (_collection.Count + _itemsPerPage - 1) / _itemsPerPage; + + _pages = new List>(); + for (int i = 0; i < pageCount; i++) + { + _pages.Add(_collection.Skip(i * _itemsPerPage).Take(_itemsPerPage).ToList()); + } } - public void UpdateCollection(List newCollection) + public void UpdateCollection(List newCollection, List responses = null) { - if (_collection.Count() == newCollection.Count() && - _collection.All(setting => newCollection.Any(newSetting => - setting.Key == newSetting.Key && - setting.Value == newSetting.Value && - setting.Label == newSetting.Label && - setting.ETag == newSetting.ETag))) + bool isUnchanged = _collection.Count == newCollection.Count && + _collection.All(setting => newCollection.Any(newSetting => + setting.Key == newSetting.Key && + setting.Value == newSetting.Value && + setting.Label == newSetting.Label && + setting.ETag == newSetting.ETag)); + + if (isUnchanged) { _status = 304; } @@ -206,6 +231,8 @@ public void UpdateCollection(List newCollection) _collection.Add(newSetting); } + + SlicePages(); } } @@ -216,7 +243,26 @@ public override async IAsyncEnumerable> AsPages(strin await Task.Delay(_delay.Value); } - yield return Page.FromValues(_collection, null, new MockResponse(_status)); + int pageIndex = 0; + + while (pageIndex < _pages.Count) + { + List pageItems = _pages[pageIndex]; + + MockResponse response; + + if (_responses == null) + { + response = new MockResponse(_status); + } + else + { + response = _responses[pageIndex]; + } + + yield return Page.FromValues(pageItems, null, response); + pageIndex++; + } } } @@ -232,19 +278,4 @@ public IAsyncEnumerable> IteratePages(AsyncPageable - { - private readonly List _collection; - - public MockPageable(List collection) - { - _collection = collection; - } - - public override IEnumerable> AsPages(string continuationToken = null, int? pageSizeHint = null) - { - yield return Page.FromValues(_collection, null, new MockResponse(200)); - } - } } diff --git a/tests/Tests.AzureAppConfiguration/Unit/Tests.cs b/tests/Tests.AzureAppConfiguration/Unit/Tests.cs index eb08bba26..052ef8b64 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/Tests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/Tests.cs @@ -13,7 +13,6 @@ using System.Diagnostics; using System.Linq; using System.Threading; -using System.Threading.Tasks; using Xunit; namespace Tests.AzureAppConfiguration @@ -33,7 +32,6 @@ public class Tests eTag: new ETag("31c38369-831f-4bf1-b9ad-79db56c8b989"), contentType: "text"), ConfigurationModelFactory.ConfigurationSetting("TestKey3", "TestValue3", "label", - eTag: new ETag("bb203f2b-c113-44fc-995d-b933c2143339"), contentType: "text"), ConfigurationModelFactory.ConfigurationSetting("TestKey4", "TestValue4", "label", @@ -47,14 +45,14 @@ public class Tests [Fact] public void AddsConfigurationValues() { - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(_kvCollectionPageOne)); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(Response.FromValue(_kv, mockResponse.Object)); + .ReturnsAsync(Response.FromValue(_kv, mockResponse)); var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object)) @@ -110,7 +108,6 @@ public void AddsInvalidConfigurationStore_MalformedSecret() [Fact] public void LoadConfigurationStore_OnFailure() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -129,7 +126,6 @@ public void LoadConfigurationStore_OnFailure() [Fact] public void LoadOptionalConfigurationStore_OnFailure() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -144,14 +140,14 @@ public void LoadOptionalConfigurationStore_OnFailure() [Fact] public void TrimKeyPrefix_TestCase1() { - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(_kvCollectionPageOne)); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(Response.FromValue(_kv, mockResponse.Object)); + .ReturnsAsync(Response.FromValue(_kv, mockResponse)); // Trim following prefixes from all keys in the configuration. var keyPrefix1 = "T"; @@ -176,14 +172,14 @@ public void TrimKeyPrefix_TestCase1() [Fact] public void TrimKeyPrefix_TestCase2() { - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(_kvCollectionPageOne)); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(Response.FromValue(_kv, mockResponse.Object)); + .ReturnsAsync(Response.FromValue(_kv, mockResponse)); // Trim following prefixes from all keys in the configuration. var keyPrefix1 = "T"; @@ -321,7 +317,6 @@ public void TestTurnOffRequestTracing() [Fact] public void TestKeepSelectorPrecedenceAfterDedup() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); var kvOfDevLabel = new List {