diff --git a/src/Common/src/Certificates/CertificateConfigurationExtensions.cs b/src/Common/src/Certificates/CertificateConfigurationExtensions.cs index 5b429703cd..d751d3eea7 100644 --- a/src/Common/src/Certificates/CertificateConfigurationExtensions.cs +++ b/src/Common/src/Certificates/CertificateConfigurationExtensions.cs @@ -10,49 +10,6 @@ public static class CertificateConfigurationExtensions { internal const string AppInstanceIdentityCertificateName = "AppInstanceIdentity"; - /// - /// Adds file path information for a certificate and (optional) private key to configuration, for use with . - /// - /// - /// The to add configuration to. - /// - /// - /// Name of the certificate, or for an unnamed certificate. - /// - /// - /// The path on disk to locate a valid certificate file. - /// - /// - /// The path on disk to locate a valid PEM-encoded RSA key file. - /// - /// - /// The incoming so that additional calls can be chained. - /// - internal static IConfigurationBuilder AddCertificate(this IConfigurationBuilder builder, string certificateName, string certificateFilePath, - string? privateKeyFilePath = null) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(certificateName); - ArgumentException.ThrowIfNullOrEmpty(certificateFilePath); - - string keyPrefix = certificateName.Length == 0 - ? $"{CertificateOptions.ConfigurationKeyPrefix}{ConfigurationPath.KeyDelimiter}" - : $"{CertificateOptions.ConfigurationKeyPrefix}{ConfigurationPath.KeyDelimiter}{certificateName}{ConfigurationPath.KeyDelimiter}"; - - var keys = new Dictionary - { - [$"{keyPrefix}CertificateFilePath"] = certificateFilePath - }; - - if (!string.IsNullOrEmpty(privateKeyFilePath)) - { - keys[$"{keyPrefix}PrivateKeyFilePath"] = privateKeyFilePath; - } - - builder.AddInMemoryCollection(keys); - return builder; - } - /// /// Adds PEM certificate files representing application identity to the application configuration. When running outside of Cloud Foundry-based platforms, /// this method will create certificates resembling those found on the platform. @@ -152,7 +109,15 @@ public static IConfigurationBuilder AddAppInstanceIdentityCertificate(this IConf if (certificateFile != null && keyFile != null) { - builder.AddCertificate(AppInstanceIdentityCertificateName, certificateFile, keyFile); + const string keyPrefix = $"{CertificateOptions.ConfigurationKeyPrefix}:{AppInstanceIdentityCertificateName}:"; + + var keys = new Dictionary + { + [$"{keyPrefix}{nameof(CertificateSettings.CertificateFilePath)}"] = certificateFile, + [$"{keyPrefix}{nameof(CertificateSettings.PrivateKeyFilePath)}"] = keyFile + }; + + builder.AddInMemoryCollection(keys); } return builder; diff --git a/src/Common/src/Certificates/CertificateServiceCollectionExtensions.cs b/src/Common/src/Certificates/CertificateServiceCollectionExtensions.cs index 84264a7071..b0a408112b 100644 --- a/src/Common/src/Certificates/CertificateServiceCollectionExtensions.cs +++ b/src/Common/src/Certificates/CertificateServiceCollectionExtensions.cs @@ -32,9 +32,13 @@ public static IServiceCollection ConfigureCertificateOptions(this IServiceCollec ? CertificateOptions.ConfigurationKeyPrefix : ConfigurationPath.Combine(CertificateOptions.ConfigurationKeyPrefix, certificateName); - services.AddOptions().BindConfiguration(configurationKey); - services.WatchFilePathInOptions(configurationKey, certificateName, "CertificateFileName"); - services.WatchFilePathInOptions(configurationKey, certificateName, "PrivateKeyFileName"); + services.AddOptions(certificateName).BindConfiguration(configurationKey); + + services.WatchFilePathInOptions(CertificateOptions.ConfigurationKeyPrefix, certificateName, + nameof(CertificateSettings.CertificateFilePath)); + + services.WatchFilePathInOptions(CertificateOptions.ConfigurationKeyPrefix, certificateName, + nameof(CertificateSettings.PrivateKeyFilePath)); services.TryAddEnumerable(ServiceDescriptor.Singleton, ConfigureCertificateOptions>()); return services; diff --git a/src/Common/src/Certificates/FilePathInOptionsChangeTokenSource.cs b/src/Common/src/Certificates/FilePathInOptionsChangeTokenSource.cs index ddf7cc1aa7..7caf19fdbd 100644 --- a/src/Common/src/Certificates/FilePathInOptionsChangeTokenSource.cs +++ b/src/Common/src/Certificates/FilePathInOptionsChangeTokenSource.cs @@ -32,9 +32,6 @@ public void ChangePath(string filePath) { _filePath = filePath; - // Wait until the file is fully written to disk. - Thread.Sleep(500); - ConfigurationReloadToken previousToken = Interlocked.Exchange(ref _changeFilePathToken, new ConfigurationReloadToken()); previousToken.OnReload(); } @@ -44,8 +41,12 @@ public IChangeToken GetChangeToken() { IChangeToken watcherChangeToken = _fileWatcher.GetChangeToken(_filePath); + // Wrap the watcher token to delay signaling to the options monitor + // -- avoids IOException when certificate and key change around the same time. + IChangeToken debouncedToken = new DebouncedChangeToken(watcherChangeToken, TimeSpan.FromMilliseconds(200)); + return new CompositeChangeToken([ - watcherChangeToken, + debouncedToken, _changeFilePathToken ]); } @@ -126,4 +127,30 @@ private static string EnsureTrailingSlash(string path) return path.Length > 0 && path[^1] != Path.DirectorySeparatorChar ? $"{path}{Path.DirectorySeparatorChar}" : path; } } + + private sealed class DebouncedChangeToken(IChangeToken inner, TimeSpan delay) : IChangeToken + { + private readonly IChangeToken _inner = inner; + private readonly TimeSpan _delay = delay; + + public bool HasChanged => _inner.HasChanged; + + public bool ActiveChangeCallbacks => _inner.ActiveChangeCallbacks; + + public IDisposable RegisterChangeCallback(Action callback, object? state) + { + return _inner.RegisterChangeCallback(async void (_) => + { + try + { + await Task.Delay(_delay).ConfigureAwait(false); + callback(state); + } + catch + { + // Swallow exceptions to avoid crashing the options infrastructure + } + }, state); + } + } } diff --git a/src/Common/test/Certificates.Test/CertificateConfigurationExtensionsTest.cs b/src/Common/test/Certificates.Test/CertificateConfigurationExtensionsTest.cs index 9694696225..0e29234f3a 100644 --- a/src/Common/test/Certificates.Test/CertificateConfigurationExtensionsTest.cs +++ b/src/Common/test/Certificates.Test/CertificateConfigurationExtensionsTest.cs @@ -3,18 +3,32 @@ // See the LICENSE file in the project root for more information. using Microsoft.Extensions.Configuration; +using Steeltoe.Common.TestResources; namespace Steeltoe.Common.Certificates.Test; public sealed class CertificateConfigurationExtensionsTest { - private const string CertificateName = "test"; + [Fact] + public void AddAppInstanceIdentityCertificate_SetsPaths_RunningLocal() + { + IConfiguration configuration = new ConfigurationBuilder().AddAppInstanceIdentityCertificate().Build(); + + configuration[$"Certificates:{CertificateConfigurationExtensions.AppInstanceIdentityCertificateName}:certificateFilePath"].Should() + .EndWith($"{LocalCertificateWriter.CertificateFilenamePrefix}Cert.pem"); + + configuration[$"Certificates:{CertificateConfigurationExtensions.AppInstanceIdentityCertificateName}:privateKeyFilePath"].Should() + .EndWith($"{LocalCertificateWriter.CertificateFilenamePrefix}Key.pem"); + } [Fact] - public void AddCertificate_SetsPaths() + public void AddAppInstanceIdentityCertificate_SetsPaths_RunningOnCloudFoundry() { - IConfigurationRoot configurationRoot = new ConfigurationBuilder().AddCertificate(CertificateName, "instance.crt", "instance.key").Build(); - configurationRoot[$"Certificates:{CertificateName}:certificateFilePath"].Should().Be("instance.crt"); - configurationRoot[$"Certificates:{CertificateName}:privateKeyFilePath"].Should().Be("instance.key"); + using var vcapScope = new EnvironmentVariableScope("VCAP_APPLICATION", "{}"); + using var certificateScope = new EnvironmentVariableScope("CF_INSTANCE_CERT", "instance.crt"); + using var privateKeyScope = new EnvironmentVariableScope("CF_INSTANCE_KEY", "instance.key"); + IConfiguration configuration = new ConfigurationBuilder().AddAppInstanceIdentityCertificate().Build(); + configuration[$"Certificates:{CertificateConfigurationExtensions.AppInstanceIdentityCertificateName}:certificateFilePath"].Should().Be("instance.crt"); + configuration[$"Certificates:{CertificateConfigurationExtensions.AppInstanceIdentityCertificateName}:privateKeyFilePath"].Should().Be("instance.key"); } } diff --git a/src/Common/test/Certificates.Test/ConfigureCertificateOptionsTest.cs b/src/Common/test/Certificates.Test/ConfigureCertificateOptionsTest.cs index 8e80037c45..ea209f2551 100644 --- a/src/Common/test/Certificates.Test/ConfigureCertificateOptionsTest.cs +++ b/src/Common/test/Certificates.Test/ConfigureCertificateOptionsTest.cs @@ -4,11 +4,11 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; -using FluentAssertions.Extensions; +using System.Text.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; +using Steeltoe.Common.TestResources; using Steeltoe.Common.TestResources.IO; namespace Steeltoe.Common.Certificates.Test; @@ -17,176 +17,231 @@ public sealed class ConfigureCertificateOptionsTest { private const string CertificateName = "test"; - [Fact] - public void ConfigureCertificateOptions_NoPath_NoCertificate() + [Theory] + [InlineData("")] + [InlineData(CertificateName)] + public void ConfigureCertificateOptions_NoPath_NoCertificate(string certificateName) { - var configureOptions = new ConfigureCertificateOptions(new ConfigurationBuilder().Build()); + IConfiguration configuration = new ConfigurationBuilder().Build(); + var configureOptions = new ConfigureCertificateOptions(configuration); var options = new CertificateOptions(); - configureOptions.Configure(CertificateName, options); + configureOptions.Configure(certificateName, options); options.Certificate.Should().BeNull(); } - [Fact] - public void ConfigureCertificateOptions_BadPath_NoCertificate() + [Theory] + [InlineData("")] + [InlineData(CertificateName)] + public void ConfigureCertificateOptions_BadPath_NoCertificate(string certificateName) { - IConfigurationRoot configurationRoot = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + var appSettings = new Dictionary { - [$"{CertificateOptions.ConfigurationKeyPrefix}:{CertificateName}:certificateFilePath"] = "does-not-exist.crt" - }).Build(); - - var configureOptions = new ConfigureCertificateOptions(configurationRoot); + [$"{GetConfigurationKey(certificateName, "CertificateFilePath")}"] = "does-not-exist.crt" + }; + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(appSettings).Build(); + var configureOptions = new ConfigureCertificateOptions(configuration); var options = new CertificateOptions(); - configureOptions.Configure(CertificateName, options); + configureOptions.Configure(certificateName, options); options.Certificate.Should().BeNull(); } - [Fact] - public void ConfigureCertificateOptions_EmptyFile_Crashes() + [Theory] + [InlineData("")] + [InlineData(CertificateName)] + public void ConfigureCertificateOptions_ThrowsOnEmptyFile(string certificateName) { - IConfigurationRoot configurationRoot = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + var appSettings = new Dictionary { - [$"{CertificateOptions.ConfigurationKeyPrefix}:{CertificateName}:certificateFilePath"] = "empty.crt" - }).Build(); + [$"{GetConfigurationKey(certificateName, "CertificateFilePath")}"] = "empty.crt" + }; + + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(appSettings).Build(); - var configureOptions = new ConfigureCertificateOptions(configurationRoot); + var configureOptions = new ConfigureCertificateOptions(configuration); var options = new CertificateOptions(); - Action action = () => configureOptions.Configure(CertificateName, options); - action.Should().Throw(); + Action configureAction = () => configureOptions.Configure(certificateName, options); + configureAction.Should().Throw(); options.Certificate.Should().BeNull(); } - [Fact] - public void ConfigureCertificateOptions_ThrowsOnInvalidKey() + [Theory] + [InlineData("")] + [InlineData(CertificateName)] + public void ConfigureCertificateOptions_ThrowsOnInvalidKey(string certificateName) { - IConfigurationRoot configurationRoot = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + var appSettings = new Dictionary { - [$"{CertificateOptions.ConfigurationKeyPrefix}:{CertificateName}:certificateFilePath"] = "instance.crt", - [$"{CertificateOptions.ConfigurationKeyPrefix}:{CertificateName}:privateKeyFilePath"] = "invalid.key" - }).Build(); + [$"{GetConfigurationKey(certificateName, "CertificateFilePath")}"] = "instance.crt", + [$"{GetConfigurationKey(certificateName, "PrivateKeyFilePath")}"] = "invalid.key" + }; - var configureOptions = new ConfigureCertificateOptions(configurationRoot); + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(appSettings).Build(); + var configureOptions = new ConfigureCertificateOptions(configuration); var options = new CertificateOptions(); - Assert.Throws(() => configureOptions.Configure(CertificateName, options)); + Action configureAction = () => configureOptions.Configure(certificateName, options); + configureAction.Should().Throw(); options.Certificate.Should().BeNull(); } - [Fact] - public void ConfigureCertificateOptions_ReadsP12File_CreatesCertificate() + [Theory] + [InlineData("")] + [InlineData(CertificateName)] + public void ConfigureCertificateOptions_ReadsP12File_CreatesCertificate(string certificateName) { - IConfigurationRoot configurationRoot = new ConfigurationBuilder().AddCertificate(CertificateName, "instance.p12").Build(); - configurationRoot[$"{CertificateOptions.ConfigurationKeyPrefix}:{CertificateName}:certificateFilePath"].Should().NotBeNull(); - var configureOptions = new ConfigureCertificateOptions(configurationRoot); + var appSettings = new Dictionary + { + [$"{GetConfigurationKey(certificateName, "CertificateFilePath")}"] = "instance.p12" + }; + + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(appSettings).Build(); + var configureOptions = new ConfigureCertificateOptions(configuration); var options = new CertificateOptions(); - configureOptions.Configure(CertificateName, options); + configureOptions.Configure(certificateName, options); options.Certificate.Should().NotBeNull(); options.Certificate.HasPrivateKey.Should().BeTrue(); } - [Fact] - public void ConfigureCertificateOptions_ReadsPemFiles_CreatesCertificate() + [Theory] + [InlineData("")] + [InlineData(CertificateName)] + public void ConfigureCertificateOptions_ReadsPemFiles_CreatesCertificate(string certificateName) { - IConfigurationRoot configurationRoot = new ConfigurationBuilder().AddCertificate(CertificateName, "instance.crt", "instance.key").Build(); - var configureOptions = new ConfigureCertificateOptions(configurationRoot); + var appSettings = new Dictionary + { + [$"{GetConfigurationKey(certificateName, "CertificateFilePath")}"] = "instance.crt", + [$"{GetConfigurationKey(certificateName, "PrivateKeyFilePath")}"] = "instance.key" + }; + + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(appSettings).Build(); + var configureOptions = new ConfigureCertificateOptions(configuration); var options = new CertificateOptions(); - configureOptions.Configure(CertificateName, options); + configureOptions.Configure(certificateName, options); options.Certificate.Should().NotBeNull(); options.Certificate.HasPrivateKey.Should().BeTrue(); } - [Fact] - public async Task CertificateOptionsUpdateOnFileContentChange() + [Theory] + [InlineData("")] + [InlineData(CertificateName)] + public async Task CertificateOptions_update_on_changed_contents(string certificateName) { using var sandbox = new Sandbox(); string firstCertificateContent = await File.ReadAllTextAsync("instance.crt", TestContext.Current.CancellationToken); string firstPrivateKeyContent = await File.ReadAllTextAsync("instance.key", TestContext.Current.CancellationToken); - var firstX509 = X509Certificate2.CreateFromPemFile("instance.crt", "instance.key"); - string secondCertificateContent = await File.ReadAllTextAsync("instance2.crt", TestContext.Current.CancellationToken); - string secondPrivateKeyContent = await File.ReadAllTextAsync("instance2.key", TestContext.Current.CancellationToken); - var secondX509 = X509Certificate2.CreateFromPemFile("instance.crt", "instance.key"); - string certificateFilePath = sandbox.CreateFile("cert", firstCertificateContent); - string privateKeyFilePath = sandbox.CreateFile("key", firstPrivateKeyContent); - + using var firstX509 = X509Certificate2.CreateFromPemFile("instance.crt", "instance.key"); + string certificateFilePath = sandbox.CreateFile(Guid.NewGuid() + ".crt", firstCertificateContent); + string privateKeyFilePath = sandbox.CreateFile(Guid.NewGuid() + ".key", firstPrivateKeyContent); + string secondCertificateContent = await File.ReadAllTextAsync("secondInstance.crt", TestContext.Current.CancellationToken); + string secondPrivateKeyContent = await File.ReadAllTextAsync("secondInstance.key", TestContext.Current.CancellationToken); + using var secondX509 = X509Certificate2.CreateFromPemFile("secondInstance.crt", "secondInstance.key"); + string appSettings = BuildAppSettingsJson(certificateName, certificateFilePath, privateKeyFilePath); + string appSettingsPath = sandbox.CreateFile(MemoryFileProvider.DefaultAppSettingsFileName, appSettings); var configurationBuilder = new ConfigurationBuilder(); - configurationBuilder.AddCertificate(CertificateName, certificateFilePath, privateKeyFilePath); + configurationBuilder.AddJsonFile(appSettingsPath, false, true); IConfiguration configuration = configurationBuilder.Build(); IServiceCollection services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(configuration); - services.ConfigureCertificateOptions(Options.DefaultName); + services.ConfigureCertificateOptions(certificateName); await using ServiceProvider serviceProvider = services.BuildServiceProvider(true); var optionsMonitor = serviceProvider.GetRequiredService>(); - - optionsMonitor.Get(CertificateName).Certificate.Should().BeEquivalentTo(firstX509); + optionsMonitor.Get(certificateName).Certificate.Should().BeEquivalentTo(firstX509); await File.WriteAllTextAsync(certificateFilePath, secondCertificateContent, TestContext.Current.CancellationToken); await File.WriteAllTextAsync(privateKeyFilePath, secondPrivateKeyContent, TestContext.Current.CancellationToken); - await Task.Delay(2.Seconds(), TestContext.Current.CancellationToken); - optionsMonitor.Get(CertificateName).Certificate.Should().BeEquivalentTo(secondX509); + using Task pollTask = WaitUntilCertificateChangedToAsync(secondX509, optionsMonitor, certificateName, TestContext.Current.CancellationToken); + await pollTask.WaitAsync(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + optionsMonitor.Get(certificateName).Certificate.Should().Be(secondX509); } - [Fact] - public async Task CertificateOptionsUpdateOnFileLocationChange() + [Theory] + [InlineData("")] + [InlineData(CertificateName)] + public async Task CertificateOptions_update_on_changed_path(string certificateName) { using var sandbox = new Sandbox(); - string instance1Certificate = await File.ReadAllTextAsync("instance.crt", TestContext.Current.CancellationToken); - string instance1PrivateKey = await File.ReadAllTextAsync("instance.key", TestContext.Current.CancellationToken); - var firstX509 = X509Certificate2.CreateFromPemFile("instance.crt", "instance.key"); - string instance2Certificate = await File.ReadAllTextAsync("instance2.crt", TestContext.Current.CancellationToken); - string instance2PrivateKey = await File.ReadAllTextAsync("instance2.key", TestContext.Current.CancellationToken); - var secondX509 = X509Certificate2.CreateFromPemFile("instance.crt", "instance.key"); - string certificate1FilePath = sandbox.CreateFile("cert", instance1Certificate); - string privateKey1FilePath = sandbox.CreateFile("key", instance1PrivateKey); - string certificate2FilePath = sandbox.CreateFile("cert2", instance2Certificate); - string privateKey2FilePath = sandbox.CreateFile("key2", instance2PrivateKey); - + string firstCertificateContent = await File.ReadAllTextAsync("instance.crt", TestContext.Current.CancellationToken); + string firstPrivateKeyContent = await File.ReadAllTextAsync("instance.key", TestContext.Current.CancellationToken); + using var firstX509 = X509Certificate2.CreateFromPemFile("instance.crt", "instance.key"); + string firstCertificateFilePath = sandbox.CreateFile(Guid.NewGuid() + ".crt", firstCertificateContent); + string firstPrivateKeyFilePath = sandbox.CreateFile(Guid.NewGuid() + ".key", firstPrivateKeyContent); + using var secondX509 = X509Certificate2.CreateFromPemFile("secondInstance.crt", "secondInstance.key"); + string appSettings = BuildAppSettingsJson(certificateName, firstCertificateFilePath, firstPrivateKeyFilePath); + string appSettingsPath = sandbox.CreateFile(MemoryFileProvider.DefaultAppSettingsFileName, appSettings); var configurationBuilder = new ConfigurationBuilder(); - configurationBuilder.AddCertificate(CertificateName, certificate1FilePath, privateKey1FilePath); - IConfigurationRoot configurationRoot = configurationBuilder.Build(); + configurationBuilder.AddJsonFile(appSettingsPath, false, true); + IConfiguration configuration = configurationBuilder.Build(); IServiceCollection services = new ServiceCollection(); services.AddLogging(); - services.AddSingleton(configurationRoot); - services.ConfigureCertificateOptions(CertificateName); + services.AddSingleton(configuration); + services.ConfigureCertificateOptions(certificateName); await using ServiceProvider serviceProvider = services.BuildServiceProvider(true); var optionsMonitor = serviceProvider.GetRequiredService>(); - optionsMonitor.Get(CertificateName).Certificate.Should().BeEquivalentTo(firstX509); - - IOptionsChangeTokenSource[] tokenSources = [.. serviceProvider.GetServices>()]; - tokenSources.OfType>().Should().HaveCount(2); - IChangeToken changeToken = tokenSources.OfType>().Single().GetChangeToken(); - - bool changeCalled = false; - _ = changeToken.RegisterChangeCallback(_ => changeCalled = true, "state"); - configurationRoot[$"{CertificateOptions.ConfigurationKeyPrefix}:{CertificateName}:CertificateFilePath"] = certificate2FilePath; - configurationRoot[$"{CertificateOptions.ConfigurationKeyPrefix}:{CertificateName}:PrivateKeyFilePath"] = privateKey2FilePath; - configurationRoot.Reload(); - optionsMonitor.Get(CertificateName).Certificate.Should().BeEquivalentTo(secondX509); - changeCalled.Should().BeTrue("file path information changed"); - - _ = changeToken.RegisterChangeCallback(_ => changeCalled = true, "state"); - await File.WriteAllTextAsync(certificate2FilePath, instance1Certificate, TestContext.Current.CancellationToken); - await File.WriteAllTextAsync(privateKey2FilePath, instance1PrivateKey, TestContext.Current.CancellationToken); - await Task.Delay(2.Seconds(), TestContext.Current.CancellationToken); - optionsMonitor.Get(CertificateName).Certificate.Should().BeEquivalentTo(firstX509); - changeCalled.Should().BeTrue("file contents changed"); + optionsMonitor.Get(certificateName).Certificate.Should().BeEquivalentTo(firstX509); + + appSettings = BuildAppSettingsJson(certificateName, "secondInstance.crt", "secondInstance.key"); + await File.WriteAllTextAsync(appSettingsPath, appSettings, TestContext.Current.CancellationToken); + + using Task pollTask = WaitUntilCertificateChangedToAsync(secondX509, optionsMonitor, certificateName, TestContext.Current.CancellationToken); + await pollTask.WaitAsync(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + optionsMonitor.Get(certificateName).Certificate.Should().Be(secondX509); + } + + private static string BuildAppSettingsJson(string certificateName, string certificatePath, string keyPath) + { + string certificateBlock = $""" + "CertificateFilePath": {JsonSerializer.Serialize(certificatePath)}, + "PrivateKeyFilePath": {JsonSerializer.Serialize(keyPath)} + """; + + string namedCertificateSection = string.IsNullOrEmpty(certificateName) + ? certificateBlock + : $"{JsonSerializer.Serialize(certificateName)}: {{ {certificateBlock} }}"; + + return $$""" + { + "Certificates": { + {{namedCertificateSection}} + } + } + """; + } + + private static async Task WaitUntilCertificateChangedToAsync(X509Certificate2 expectedCertificate, IOptionsMonitor optionsMonitor, + string certificateName, CancellationToken cancellationToken) + { + while (!Equals(optionsMonitor.Get(certificateName).Certificate, expectedCertificate)) + { + await Task.Delay(50, cancellationToken); + } + } + + private static string GetConfigurationKey(string? optionName, string propertyName) + { + return string.IsNullOrEmpty(optionName) + ? string.Join(ConfigurationPath.KeyDelimiter, CertificateOptions.ConfigurationKeyPrefix, propertyName) + : string.Join(ConfigurationPath.KeyDelimiter, CertificateOptions.ConfigurationKeyPrefix, optionName, propertyName); } } diff --git a/src/Common/test/Certificates.Test/instance2.crt b/src/Common/test/Certificates.Test/secondInstance.crt similarity index 100% rename from src/Common/test/Certificates.Test/instance2.crt rename to src/Common/test/Certificates.Test/secondInstance.crt diff --git a/src/Common/test/Certificates.Test/instance2.key b/src/Common/test/Certificates.Test/secondInstance.key similarity index 100% rename from src/Common/test/Certificates.Test/instance2.key rename to src/Common/test/Certificates.Test/secondInstance.key