diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationExtensions.cs index cc7a31af..c58c72b5 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationExtensions.cs @@ -54,6 +54,7 @@ public static IServiceCollection AddAzureAppConfiguration(this IServiceCollectio throw new ArgumentNullException(nameof(services)); } + services.AddLogging(); services.AddSingleton(); return services; } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index b1b0069e..40e07e21 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; +using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Diagnostics; @@ -18,7 +19,6 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { - internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigurationRefresher { private bool _optional; @@ -41,6 +41,8 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura // To avoid concurrent network operations, this flag is used to achieve synchronization between multiple threads. private int _networkOperationsInProgress = 0; + private ILogger _logger; + private ILoggerFactory _loggerFactory; public Uri AppConfigurationEndpoint { @@ -68,6 +70,19 @@ public Uri AppConfigurationEndpoint } } + public ILoggerFactory LoggerFactory + { + get + { + return _loggerFactory; + } + set + { + _loggerFactory = value; + _logger = _loggerFactory?.CreateLogger(LoggingConstants.AppConfigRefreshLogCategory); + } + } + public AzureAppConfigurationProvider(ConfigurationClient client, AzureAppConfigurationOptions options, bool optional) { _client = client ?? throw new ArgumentNullException(nameof(client)); @@ -182,12 +197,40 @@ public async Task TryRefreshAsync() { await RefreshAsync().ConfigureAwait(false); } - catch (Exception e) when ( - e is KeyVaultReferenceException || - e is RequestFailedException || - ((e as AggregateException)?.InnerExceptions?.All(e => e is RequestFailedException) ?? false) || - e is OperationCanceledException) + catch (RequestFailedException e) { + if (IsAuthenticationError(e)) + { + _logger?.LogWarning(e, LoggingConstants.RefreshFailedDueToAuthenticationError); + } + else + { + _logger?.LogWarning(e, LoggingConstants.RefreshFailedError); + } + + return false; + } + catch (AggregateException e) when (e?.InnerExceptions?.All(e => e is RequestFailedException) ?? false) + { + if (IsAuthenticationError(e)) + { + _logger?.LogWarning(e, LoggingConstants.RefreshFailedDueToAuthenticationError); + } + else + { + _logger?.LogWarning(e, LoggingConstants.RefreshFailedError); + } + + return false; + } + catch (KeyVaultReferenceException e) + { + _logger?.LogWarning(e, LoggingConstants.RefreshFailedDueToKeyVaultError); + return false; + } + catch (OperationCanceledException) + { + _logger?.LogWarning(LoggingConstants.RefreshCanceledError); return false; } @@ -634,5 +677,20 @@ private DateTimeOffset AddRandomDelay(DateTimeOffset dt, TimeSpan maxDelay) long randomTicks = (long)(maxDelay.Ticks * RandomGenerator.NextDouble()); return dt.AddTicks(randomTicks); } + + private bool IsAuthenticationError(Exception ex) + { + if (ex is RequestFailedException rfe) + { + return rfe.Status == (int)HttpStatusCode.Unauthorized || rfe.Status == (int)HttpStatusCode.Forbidden; + } + + if (ex is AggregateException ae) + { + return ae.InnerExceptions?.Any(inner => IsAuthenticationError(inner)) ?? false; + } + + return false; + } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefresher.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefresher.cs index e6b98adc..2f18b00b 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefresher.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefresher.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using Microsoft.Extensions.Logging; using System; using System.Threading.Tasks; @@ -11,6 +12,19 @@ internal class AzureAppConfigurationRefresher : IConfigurationRefresher private AzureAppConfigurationProvider _provider = null; public Uri AppConfigurationEndpoint { get; private set; } = null; + + public ILoggerFactory LoggerFactory { + get + { + ThrowIfNullProvider(nameof(LoggerFactory)); + return _provider.LoggerFactory; + } + set + { + ThrowIfNullProvider(nameof(LoggerFactory)); + _provider.LoggerFactory = value; + } + } public void SetProvider(AzureAppConfigurationProvider provider) { @@ -20,7 +34,7 @@ public void SetProvider(AzureAppConfigurationProvider provider) public async Task RefreshAsync() { - ThrowIfNullProvider(); + ThrowIfNullProvider(nameof(RefreshAsync)); await _provider.RefreshAsync().ConfigureAwait(false); } @@ -36,15 +50,15 @@ public async Task TryRefreshAsync() public void SetDirty(TimeSpan? maxDelay) { - ThrowIfNullProvider(); + ThrowIfNullProvider(nameof(SetDirty)); _provider.SetDirty(maxDelay); } - private void ThrowIfNullProvider() + private void ThrowIfNullProvider(string operation) { if (_provider == null) { - throw new InvalidOperationException("ConfigurationBuilder.Build() must be called before this operation can be performed."); + throw new InvalidOperationException($"ConfigurationBuilder.Build() must be called before {operation} can be accessed."); } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefresherProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefresherProvider.cs index fdbc9311..cf00317f 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefresherProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefresherProvider.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Linq; @@ -11,7 +12,7 @@ internal class AzureAppConfigurationRefresherProvider : IConfigurationRefresherP { public IEnumerable Refreshers { get; } - public AzureAppConfigurationRefresherProvider(IConfiguration configuration) + public AzureAppConfigurationRefresherProvider(IConfiguration configuration, ILoggerFactory _loggerFactory) { var configurationRoot = configuration as IConfigurationRoot; var refreshers = new List(); @@ -22,6 +23,12 @@ public AzureAppConfigurationRefresherProvider(IConfiguration configuration) { if (provider is IConfigurationRefresher refresher) { + // Use _loggerFactory only if LoggerFactory hasn't been set in AzureAppConfigurationOptions + if (refresher.LoggerFactory == null) + { + refresher.LoggerFactory = _loggerFactory; + } + refreshers.Add(refresher); } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/LoggingConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/LoggingConstants.cs new file mode 100644 index 00000000..3c194e0a --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/LoggingConstants.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + internal class LoggingConstants + { + // Categories + public const string AppConfigRefreshLogCategory = "Microsoft.Extensions.Configuration.AzureAppConfiguration.Refresh"; + + // Error messages + public const string RefreshFailedDueToAuthenticationError = "A refresh operation failed due to an authentication error."; + public const string RefreshFailedDueToKeyVaultError = "A refresh operation failed while resolving a Key Vault reference."; + public const string RefreshFailedError = "A refresh operation failed."; + public const string RefreshCanceledError = "A refresh operation was canceled."; + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationRefresher.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationRefresher.cs index b7bb3db0..9940ab6d 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationRefresher.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationRefresher.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. // using Azure; +using Microsoft.Extensions.Logging; using System; using System.Threading.Tasks; @@ -17,6 +18,11 @@ public interface IConfigurationRefresher /// Uri AppConfigurationEndpoint { get; } + /// + /// An for creating a logger to log errors. + /// + ILoggerFactory LoggerFactory { get; set; } + /// /// Refreshes the data from App Configuration asynchronously. /// 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 09e6dfb9..69f817b1 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -18,6 +18,7 @@ + diff --git a/tests/Tests.AzureAppConfiguration/LoggingTests.cs b/tests/Tests.AzureAppConfiguration/LoggingTests.cs new file mode 100644 index 00000000..42f9fd1b --- /dev/null +++ b/tests/Tests.AzureAppConfiguration/LoggingTests.cs @@ -0,0 +1,304 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure; +using Azure.Core.Testing; +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Xunit; + +namespace Tests.AzureAppConfiguration +{ + public class LoggingTests + { + List _kvCollection = new List + { + ConfigurationModelFactory.ConfigurationSetting( + key: "TestKey1", + label: "label", + value: "TestValue1", + eTag: new ETag("0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63"), + contentType: "text"), + + ConfigurationModelFactory.ConfigurationSetting( + key: "TestKey2", + label: "label", + value: "TestValue2", + eTag: new ETag("31c38369-831f-4bf1-b9ad-79db56c8b989"), + contentType: "text") + }; + + ConfigurationSetting FirstKeyValue => _kvCollection.First(); + ConfigurationSetting sentinelKv = new ConfigurationSetting("SentinelKey", "SentinelValue"); + ConfigurationSetting _kvr = ConfigurationModelFactory.ConfigurationSetting( + key: "TestKey1", + value: @" + { + ""uri"":""https://keyvault-theclassics.vault.azure.net/secrets/TheTrialSecret"" + }", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"), + contentType: KeyVaultConstants.ContentType + "; charset=utf-8"); + + TimeSpan CacheExpirationTime = TimeSpan.FromSeconds(1); + + [Fact] + public void ThrowsIfLoggerFactorySetWithIConfigurationRefresherBeforeBuild() + { + IConfigurationRefresher refresher = null; + + void action() => new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("TestKey1", "label") + .SetCacheExpiration(CacheExpirationTime); + }); + + refresher = options.GetRefresher(); + refresher.LoggerFactory = NullLoggerFactory.Instance; // Throws + }) + .Build(); + + Assert.Throws(action); + } + + [Fact] + public void ValidateExceptionLoggedDuringRefresh() + { + IConfigurationRefresher refresher = null; + var mockClient = GetMockConfigurationClient(); + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(new RequestFailedException("Request failed.")); + + var mockLogger = new Mock(); + var mockLoggerFactory = new Mock(); + mockLoggerFactory.Setup(mlf => mlf.CreateLogger(LoggingConstants.AppConfigRefreshLogCategory)).Returns(mockLogger.Object); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Client = mockClient.Object; + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("TestKey1", "label") + .SetCacheExpiration(CacheExpirationTime); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + Assert.Equal("TestValue1", config["TestKey1"]); + FirstKeyValue.Value = "newValue1"; + + Thread.Sleep(CacheExpirationTime); + refresher.LoggerFactory = mockLoggerFactory.Object; + refresher.TryRefreshAsync().Wait(); + + Assert.NotEqual("newValue1", config["TestKey1"]); + Assert.True(ValidateLoggedError(mockLogger, LoggingConstants.RefreshFailedError)); + } + + [Fact] + public void ValidateUnauthorizedExceptionLoggedDuringRefresh() + { + IConfigurationRefresher refresher = null; + var mockClient = GetMockConfigurationClient(); + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(new RequestFailedException(401, "Unauthorized")); + + var mockLogger = new Mock(); + var mockLoggerFactory = new Mock(); + mockLoggerFactory.Setup(mlf => mlf.CreateLogger(LoggingConstants.AppConfigRefreshLogCategory)).Returns(mockLogger.Object); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Client = mockClient.Object; + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("TestKey1", "label") + .SetCacheExpiration(CacheExpirationTime); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + Assert.Equal("TestValue1", config["TestKey1"]); + FirstKeyValue.Value = "newValue1"; + + Thread.Sleep(CacheExpirationTime); + refresher.LoggerFactory = mockLoggerFactory.Object; + refresher.TryRefreshAsync().Wait(); + + Assert.NotEqual("newValue1", config["TestKey1"]); + Assert.True(ValidateLoggedError(mockLogger, LoggingConstants.RefreshFailedDueToAuthenticationError)); + } + + [Fact] + public void ValidateKeyVaultExceptionLoggedDuringRefresh() + { + IConfigurationRefresher refresher = null; + TimeSpan cacheExpirationTime = TimeSpan.FromSeconds(1); + + // Mock ConfigurationClient + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict, TestHelpers.CreateMockEndpointString()); + + Response GetTestKey(string key, string label, CancellationToken cancellationToken) + { + return Response.FromValue(TestHelpers.CloneSetting(sentinelKv), mockResponse.Object); + } + + Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) + { + var unchanged = sentinelKv.Key == setting.Key && sentinelKv.Label == setting.Label && sentinelKv.Value == setting.Value; + var response = new MockResponse(unchanged ? 304 : 200); + return Response.FromValue(sentinelKv, response); + } + + // No KVR during startup; return KVR during refresh operation to see error because ConfigureKeyVault is missing + mockClient.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(_kvCollection.Select(setting => TestHelpers.CloneSetting(setting)).ToList())) + .Returns(new MockAsyncPageable(new List { _kvr })); + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Func>)GetTestKey); + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Func>)GetIfChanged); + + // Mock ILogger and ILoggerFactory + var mockLogger = new Mock(); + var mockLoggerFactory = new Mock(); + mockLoggerFactory.Setup(mlf => mlf.CreateLogger(LoggingConstants.AppConfigRefreshLogCategory)).Returns(mockLogger.Object); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Client = mockClient.Object; + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("SentinelKey", refreshAll: true) + .SetCacheExpiration(CacheExpirationTime); + }); + refresher = options.GetRefresher(); + }) + .Build(); + + Assert.Equal("SentinelValue", config["SentinelKey"]); + + // Update sentinel key-value to trigger refreshAll operation + sentinelKv.Value = "UpdatedSentinelValue"; + Thread.Sleep(CacheExpirationTime); + refresher.LoggerFactory = mockLoggerFactory.Object; + refresher.TryRefreshAsync().Wait(); + + Assert.True(ValidateLoggedError(mockLogger, LoggingConstants.RefreshFailedDueToKeyVaultError)); + } + + [Fact] + public void OverwriteLoggerFactory() + { + IConfigurationRefresher refresher = null; + var mockClient = GetMockConfigurationClient(); + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(new RequestFailedException(403, "Forbidden")); + + var mockLogger1 = new Mock(); + var mockLoggerFactory1 = new Mock(); + mockLoggerFactory1.Setup(mlf => mlf.CreateLogger(LoggingConstants.AppConfigRefreshLogCategory)).Returns(mockLogger1.Object); + + var mockLogger2 = new Mock(); + var mockLoggerFactory2 = new Mock(); + mockLoggerFactory2.Setup(mlf => mlf.CreateLogger(LoggingConstants.AppConfigRefreshLogCategory)).Returns(mockLogger2.Object); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Client = mockClient.Object; + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("TestKey1", "label") + .SetCacheExpiration(CacheExpirationTime); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + Assert.Equal("TestValue1", config["TestKey1"]); + FirstKeyValue.Value = "newValue1"; + + // Set LoggerFactory + refresher.LoggerFactory = mockLoggerFactory1.Object; + + // Overwrite LoggerFactory + refresher.LoggerFactory = mockLoggerFactory2.Object; + + Thread.Sleep(CacheExpirationTime); + refresher.TryRefreshAsync().Wait(); + + Assert.NotEqual("newValue1", config["TestKey1"]); + Assert.True(ValidateLoggedError(mockLogger2, LoggingConstants.RefreshFailedDueToAuthenticationError)); + } + + private bool ValidateLoggedError(Mock logger, string expectedMessage) + { + Func state = (v, t) => v.ToString().StartsWith(expectedMessage); + + logger.Verify( + x => x.Log( + It.Is(l => l == LogLevel.Warning), + It.IsAny(), + It.Is((v, t) => state(v, t)), + It.IsAny(), + It.Is>((v, t) => true))); + + return true; + } + + private Mock GetMockConfigurationClient() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict, TestHelpers.CreateMockEndpointString()); + + Response GetTestKey(string key, string label, CancellationToken cancellationToken) + { + return Response.FromValue(TestHelpers.CloneSetting(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label)), mockResponse.Object); + } + + Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) + { + var newSetting = _kvCollection.FirstOrDefault(s => (s.Key == setting.Key && s.Label == setting.Label)); + var unchanged = (newSetting.Key == setting.Key && newSetting.Label == setting.Label && newSetting.Value == setting.Value); + var response = new MockResponse(unchanged ? 304 : 200); + return Response.FromValue(newSetting, response); + } + + // We don't actually select KV based on SettingSelector, we just return a deep copy of _kvCollection + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(() => + { + return new MockAsyncPageable(_kvCollection.Select(setting => TestHelpers.CloneSetting(setting)).ToList()); + }); + + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Func>)GetTestKey); + + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Func>)GetIfChanged); + + return mockClient; + } + } +} diff --git a/tests/Tests.AzureAppConfiguration/RefreshTests.cs b/tests/Tests.AzureAppConfiguration/RefreshTests.cs index adbfe57a..2c4af372 100644 --- a/tests/Tests.AzureAppConfiguration/RefreshTests.cs +++ b/tests/Tests.AzureAppConfiguration/RefreshTests.cs @@ -7,6 +7,7 @@ using Azure.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.Extensions.Logging.Abstractions; using Moq; using System; using System.Collections.Generic; @@ -1103,7 +1104,7 @@ static void optionsInitializer(AzureAppConfigurationOptions options) .AddAzureAppConfiguration(optionsInitializer, optional: true) .Build(); - IConfigurationRefresherProvider refresherProvider = new AzureAppConfigurationRefresherProvider(configuration); + IConfigurationRefresherProvider refresherProvider = new AzureAppConfigurationRefresherProvider(configuration, NullLoggerFactory.Instance); Assert.Equal(2, refresherProvider.Refreshers.Count()); } @@ -1111,7 +1112,7 @@ static void optionsInitializer(AzureAppConfigurationOptions options) public void RefreshTests_AzureAppConfigurationRefresherProviderThrowsIfNoRefresher() { IConfiguration configuration = new ConfigurationBuilder().Build(); - void action() => new AzureAppConfigurationRefresherProvider(configuration); + void action() => new AzureAppConfigurationRefresherProvider(configuration, NullLoggerFactory.Instance); Assert.Throws(action); }