Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs
T
Joseph Doherty fca978de07 docs(src): add missing XML docs and strip tracking-ID comments
Sweep of 203 source files resolving CommentChecker findings: add
<summary>/<param>/<returns>/<inheritdoc> where missing, and remove
resolved task/issue tracking markers (Tests-NNN, Worker-NNN, Server-NNN,
Task N) from code comments. Comment/doc-only — no logic changes.
Server+Tests build clean under TreatWarningsAsErrors.
2026-07-07 14:09:49 -04:00

163 lines
7.8 KiB
C#

using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Tls;
using Xunit;
namespace ZB.MOM.WW.MxGateway.Tests.Security.Tls;
public sealed class SelfSignedCertificateProviderTests
{
private static SelfSignedCertificateProvider CreateProvider(TlsOptions options, FakeTimeProvider time)
=> new(options, NullLogger<SelfSignedCertificateProvider>.Instance, time);
/// <summary>Verifies that a generated certificate has the expected validity window, SANs (localhost, machine name, additional DNS names, loopback IPs), and the serverAuth EKU.</summary>
[Fact]
public void GenerateCertificate_HasExpectedSansEkuAndValidity()
{
FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
TlsOptions options = new() { ValidityYears = 7, AdditionalDnsNames = ["gw.internal"] };
using X509Certificate2 cert = CreateProvider(options, time).GenerateCertificate();
Assert.Equal(time.GetUtcNow().AddYears(7).UtcDateTime.Date, cert.NotAfter.ToUniversalTime().Date);
Assert.True(cert.NotBefore.ToUniversalTime() < time.GetUtcNow().UtcDateTime);
Assert.True(cert.HasPrivateKey);
string sans = ReadSubjectAltNames(cert);
Assert.Contains("localhost", sans);
Assert.Contains("gw.internal", sans);
Assert.Contains(Environment.MachineName, sans);
// Format() renders IP SANs as "IP Address:<addr>"; the IPv6 loopback may appear
// as "::1" or its expanded form depending on the platform crypto library.
Assert.Contains("127.0.0.1", sans);
Assert.True(sans.Contains("::1") || sans.Contains("0:0:0:0:0:0:0:1"),
$"Expected IPv6 loopback in SANs but got: {sans}");
X509EnhancedKeyUsageExtension eku = cert.Extensions.OfType<X509EnhancedKeyUsageExtension>().Single();
Assert.Contains(eku.EnhancedKeyUsages.Cast<System.Security.Cryptography.Oid>(),
o => o.Value == "1.3.6.1.5.5.7.3.1"); // serverAuth
}
/// <summary>Verifies that LoadOrCreate generates and persists a certificate on first call, then reuses the same persisted certificate (same thumbprint) on a subsequent call.</summary>
[Fact]
public void LoadOrCreate_GeneratesPersistsAndReuses_SameThumbprint()
{
string dir = Directory.CreateTempSubdirectory().FullName;
try
{
string path = Path.Combine(dir, "gw.pfx");
FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
TlsOptions options = new() { SelfSignedCertPath = path };
using X509Certificate2 first = CreateProvider(options, time).LoadOrCreate();
Assert.True(File.Exists(path));
using X509Certificate2 second = CreateProvider(options, time).LoadOrCreate();
Assert.Equal(first.Thumbprint, second.Thumbprint); // reused, not regenerated
}
finally { Directory.Delete(dir, recursive: true); }
}
/// <summary>Verifies that LoadOrCreate regenerates the certificate (a new thumbprint) once the persisted certificate's validity window has elapsed.</summary>
[Fact]
public void LoadOrCreate_Regenerates_WhenPersistedCertExpired()
{
string dir = Directory.CreateTempSubdirectory().FullName;
try
{
string path = Path.Combine(dir, "gw.pfx");
FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
TlsOptions options = new() { SelfSignedCertPath = path, ValidityYears = 1 };
using X509Certificate2 first = CreateProvider(options, time).LoadOrCreate();
time.Advance(TimeSpan.FromDays(800)); // past 1-year validity
using X509Certificate2 second = CreateProvider(options, time).LoadOrCreate();
Assert.NotEqual(first.Thumbprint, second.Thumbprint);
}
finally { Directory.Delete(dir, recursive: true); }
}
/// <summary>Verifies that LoadOrCreate regenerates a valid certificate when the persisted PFX file is corrupt or unreadable.</summary>
[Fact]
public void LoadOrCreate_Regenerates_WhenPersistedFileCorrupt()
{
string dir = Directory.CreateTempSubdirectory().FullName;
try
{
string path = Path.Combine(dir, "gw.pfx");
File.WriteAllText(path, "not a pfx");
TlsOptions options = new() { SelfSignedCertPath = path };
using X509Certificate2 cert = CreateProvider(options, new FakeTimeProvider()).LoadOrCreate();
Assert.True(cert.HasPrivateKey);
}
finally { Directory.Delete(dir, recursive: true); }
}
/// <summary>Verifies that LoadOrCreate throws <see cref="InvalidOperationException"/> for an expired persisted certificate when regeneration is disabled.</summary>
[Fact]
public void LoadOrCreate_Throws_WhenExpiredAndRegenerateDisabled()
{
string dir = Directory.CreateTempSubdirectory().FullName;
try
{
string path = Path.Combine(dir, "gw.pfx");
FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
TlsOptions options = new() { SelfSignedCertPath = path, ValidityYears = 1, RegenerateIfExpired = false };
using (CreateProvider(options, time).LoadOrCreate()) { }
time.Advance(TimeSpan.FromDays(800));
Assert.Throws<InvalidOperationException>(() => CreateProvider(options, time).LoadOrCreate());
}
finally { Directory.Delete(dir, recursive: true); }
}
/// <summary>Verifies that LoadOrCreate throws <see cref="InvalidOperationException"/> when <c>SelfSignedCertPath</c> is blank.</summary>
[Fact]
public void LoadOrCreate_Throws_WhenSelfSignedCertPathBlank()
{
TlsOptions options = new() { SelfSignedCertPath = " " };
Assert.Throws<InvalidOperationException>(
() => CreateProvider(options, new FakeTimeProvider()).LoadOrCreate());
}
/// <summary>
/// Verifies that GenerateAndPersist cleans up the hardened .tmp file when persist fails.
/// The failure is induced by setting SelfSignedCertPath to a path whose parent directory
/// is an existing regular file, causing Directory.CreateDirectory (or the subsequent write)
/// to throw an IOException/UnauthorizedAccessException.
/// </summary>
[Fact]
public void LoadOrCreate_DeletesTempFile_WhenPersistFails()
{
string outerDir = Directory.CreateTempSubdirectory().FullName;
try
{
// Create a regular file at what would be the parent directory of the cert path.
// Any attempt to create that "directory" or write files into it must fail.
string fileActingAsDir = Path.Combine(outerDir, "notadir");
File.WriteAllText(fileActingAsDir, "block");
// Point the cert path inside the regular file — Directory.CreateDirectory will
// throw because the parent path component is a file, not a directory.
string certPath = Path.Combine(fileActingAsDir, "gw.pfx");
string expectedTemp = certPath + ".tmp";
TlsOptions options = new() { SelfSignedCertPath = certPath };
Assert.ThrowsAny<Exception>(() => CreateProvider(options, new FakeTimeProvider()).LoadOrCreate());
// The .tmp file must not be left behind.
Assert.False(File.Exists(expectedTemp), $"Leaked temp file: {expectedTemp}");
}
finally { Directory.Delete(outerDir, recursive: true); }
}
private const string SubjectAltNameOid = "2.5.29.17";
private static string ReadSubjectAltNames(X509Certificate2 cert)
=> cert.Extensions
.First(e => e.Oid?.Value == SubjectAltNameOid)
.Format(false);
}