Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ public static IServiceCollection ConfigureCertificateOptions(this IServiceCollec
? CertificateOptions.ConfigurationKeyPrefix
: ConfigurationPath.Combine(CertificateOptions.ConfigurationKeyPrefix, certificateName);

services.AddOptions<CertificateOptions>().BindConfiguration(configurationKey);
services.WatchFilePathInOptions<CertificateOptions>(configurationKey, certificateName, "CertificateFileName");
services.WatchFilePathInOptions<CertificateOptions>(configurationKey, certificateName, "PrivateKeyFileName");
services.AddOptions<CertificateOptions>(certificateName).BindConfiguration(configurationKey);
services.WatchFilePathInOptions<CertificateOptions>(CertificateOptions.ConfigurationKeyPrefix, certificateName, "CertificateFilePath");
services.WatchFilePathInOptions<CertificateOptions>(CertificateOptions.ConfigurationKeyPrefix, certificateName, "PrivateKeyFilePath");

services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<CertificateOptions>, ConfigureCertificateOptions>());
return services;
Expand Down
184 changes: 126 additions & 58 deletions src/Common/test/Certificates.Test/ConfigureCertificateOptionsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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;
Expand All @@ -17,176 +18,243 @@ 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());
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<string, string?>
{
[$"{CertificateOptions.ConfigurationKeyPrefix}:{CertificateName}:certificateFilePath"] = "does-not-exist.crt"
[$"{GetConfigurationKey(certificateName, "CertificateFilePath")}"] = "does-not-exist.crt"
}).Build();

var configureOptions = new ConfigureCertificateOptions(configurationRoot);

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_EmptyFile_Crashes(string certificateName)
{
IConfigurationRoot configurationRoot = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
[$"{CertificateOptions.ConfigurationKeyPrefix}:{CertificateName}:certificateFilePath"] = "empty.crt"
[$"{GetConfigurationKey(certificateName, "CertificateFilePath")}"] = "empty.crt"
}).Build();

var configureOptions = new ConfigureCertificateOptions(configurationRoot);
var options = new CertificateOptions();

Action action = () => configureOptions.Configure(CertificateName, options);
Action action = () => configureOptions.Configure(certificateName, options);
action.Should().Throw<CryptographicException>();

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<string, string?>
{
[$"{CertificateOptions.ConfigurationKeyPrefix}:{CertificateName}:certificateFilePath"] = "instance.crt",
[$"{CertificateOptions.ConfigurationKeyPrefix}:{CertificateName}:privateKeyFilePath"] = "invalid.key"
[$"{GetConfigurationKey(certificateName, "CertificateFilePath")}"] = "instance.crt",
[$"{GetConfigurationKey(certificateName, "PrivateKeyFilePath")}"] = "invalid.key"
}).Build();

var configureOptions = new ConfigureCertificateOptions(configurationRoot);
var options = new CertificateOptions();

Assert.Throws<CryptographicException>(() => configureOptions.Configure(CertificateName, options));
Assert.Throws<CryptographicException>(() => configureOptions.Configure(certificateName, options));

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();
IConfigurationRoot configurationRoot = new ConfigurationBuilder().AddCertificate(certificateName, "instance.p12").Build();
configurationRoot[$"{GetConfigurationKey(certificateName, "CertificateFilePath")}"].Should().NotBeNull();
var configureOptions = new ConfigureCertificateOptions(configurationRoot);
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();
IConfigurationRoot configurationRoot = new ConfigurationBuilder().AddCertificate(certificateName, "instance.crt", "instance.key").Build();
var configureOptions = new ConfigureCertificateOptions(configurationRoot);
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 CertificateOptionsUpdateOnFileContentChange(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);
var secondX509 = X509Certificate2.CreateFromPemFile("instance2.crt", "instance2.key");
string certificateFilePath = sandbox.CreateFile(Guid.NewGuid() + ".crt", firstCertificateContent);
string privateKeyFilePath = sandbox.CreateFile(Guid.NewGuid() + ".key", firstPrivateKeyContent);

if (TestContext.Current.IsRunningOnBuildServer())
{
await Task.Delay(2.Seconds(), TestContext.Current.CancellationToken);
}

var configurationBuilder = new ConfigurationBuilder();
configurationBuilder.AddCertificate(CertificateName, certificateFilePath, privateKeyFilePath);
configurationBuilder.AddCertificate(certificateName, certificateFilePath, privateKeyFilePath);
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<IOptionsMonitor<CertificateOptions>>();

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);
SpinWait.SpinUntil(() =>
{
try
{
return optionsMonitor.Get(certificateName).Certificate!.Equals(secondX509);
}
catch
{
return false; // File(s) may not be readable yet. Swallow exceptions and keep spinning
}
}, 4.Seconds());

optionsMonitor.Get(certificateName).Certificate.Should().Be(secondX509);
}

[Fact]
public async Task CertificateOptionsUpdateOnFileLocationChange()
[Theory]
[InlineData("")]
[InlineData(CertificateName)]
public async Task CertificateOptionsUpdateOnFileLocationChange(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);
var secondX509 = X509Certificate2.CreateFromPemFile("instance2.crt", "instance2.key");
string certificate1FilePath = sandbox.CreateFile(Guid.NewGuid() + ".crt", instance1Certificate);
string privateKey1FilePath = sandbox.CreateFile(Guid.NewGuid() + ".key", instance1PrivateKey);
string certificate2FilePath = sandbox.CreateFile(Guid.NewGuid() + ".crt", instance2Certificate);
string privateKey2FilePath = sandbox.CreateFile(Guid.NewGuid() + ".key", instance2PrivateKey);

if (TestContext.Current.IsRunningOnBuildServer())
{
await Task.Delay(2.Seconds(), TestContext.Current.CancellationToken);
}

var configurationBuilder = new ConfigurationBuilder();
configurationBuilder.AddCertificate(CertificateName, certificate1FilePath, privateKey1FilePath);
configurationBuilder.AddCertificate(certificateName, certificate1FilePath, privateKey1FilePath);
IConfigurationRoot configurationRoot = configurationBuilder.Build();

IServiceCollection services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IConfiguration>(configurationRoot);
services.ConfigureCertificateOptions(CertificateName);
services.ConfigureCertificateOptions(certificateName);

await using ServiceProvider serviceProvider = services.BuildServiceProvider(true);

var optionsMonitor = serviceProvider.GetRequiredService<IOptionsMonitor<CertificateOptions>>();
optionsMonitor.Get(CertificateName).Certificate.Should().BeEquivalentTo(firstX509);
optionsMonitor.Get(certificateName).Certificate.Should().BeEquivalentTo(firstX509);

IOptionsChangeTokenSource<CertificateOptions>[] tokenSources = [.. serviceProvider.GetServices<IOptionsChangeTokenSource<CertificateOptions>>()];

tokenSources.OfType<FilePathInOptionsChangeTokenSource<CertificateOptions>>().Should().HaveCount(2);
IChangeToken changeToken = tokenSources.OfType<ConfigurationChangeTokenSource<CertificateOptions>>().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");

using (changeToken.RegisterChangeCallback(_ => changeCalled = true, "changed-path"))
{
changeCalled.Should().BeFalse("nothing has changed yet");
configurationRoot[$"{GetConfigurationKey(certificateName, "CertificateFilePath")}"] = certificate2FilePath;
configurationRoot[$"{GetConfigurationKey(certificateName, "PrivateKeyFilePath")}"] = privateKey2FilePath;
configurationRoot.Reload();
changeCalled.Should().BeTrue("file path information changed");
optionsMonitor.Get(certificateName).Certificate.Should().Be(secondX509);
}

changeCalled = false;
changeToken = tokenSources.OfType<FilePathInOptionsChangeTokenSource<CertificateOptions>>().First().GetChangeToken();

using (changeToken.RegisterChangeCallback(_ => changeCalled = true, "original-content-in-new-path"))
{
changeCalled.Should().BeFalse("nothing has changed yet");
await File.WriteAllTextAsync(certificate2FilePath, instance1Certificate, TestContext.Current.CancellationToken);
await File.WriteAllTextAsync(privateKey2FilePath, instance1PrivateKey, TestContext.Current.CancellationToken);

SpinWait.SpinUntil(() =>
{
try
{
return optionsMonitor.Get(certificateName).Certificate!.Equals(firstX509);
}
catch
{
return false; // File(s) may not be readable yet. Swallow exceptions and keep spinning
}
}, 6.Seconds());

changeCalled.Should().BeTrue("file contents changed");
optionsMonitor.Get(certificateName).Certificate.Should().Be(firstX509);
}
}

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);
}
}
Loading