using System.Net; 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.Instance, time); /// Verifies that a generated certificate has the expected validity window, SANs (localhost, machine name, additional DNS names, loopback IPs), and the serverAuth EKU. [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); X509SubjectAlternativeNameExtension san = ReadSubjectAltNames(cert); string[] dnsNames = [.. san.EnumerateDnsNames()]; IPAddress[] ipAddresses = [.. san.EnumerateIPAddresses()]; // DNS SANs are compared case-insensitively (DNS names are), and IP SANs are compared // as parsed IPAddress values. Asserting against the extension's Format() string instead // would be platform-dependent: Windows' CryptFormatObject renders the IPv6 loopback // fully expanded ("0000:0000:...:0001") while the managed formatter renders "::1". Assert.Contains(dnsNames, name => name.Equals("localhost", StringComparison.OrdinalIgnoreCase)); Assert.Contains(dnsNames, name => name.Equals("gw.internal", StringComparison.OrdinalIgnoreCase)); Assert.Contains(dnsNames, name => name.Equals(Environment.MachineName, StringComparison.OrdinalIgnoreCase)); Assert.Contains(IPAddress.Loopback, ipAddresses); Assert.Contains(IPAddress.IPv6Loopback, ipAddresses); X509EnhancedKeyUsageExtension eku = cert.Extensions.OfType().Single(); Assert.Contains(eku.EnhancedKeyUsages.Cast(), o => o.Value == "1.3.6.1.5.5.7.3.1"); // serverAuth } /// Verifies that LoadOrCreate generates and persists a certificate on first call, then reuses the same persisted certificate (same thumbprint) on a subsequent call. [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); } } /// Verifies that LoadOrCreate regenerates the certificate (a new thumbprint) once the persisted certificate's validity window has elapsed. [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); } } /// Verifies that LoadOrCreate regenerates a valid certificate when the persisted PFX file is corrupt or unreadable. [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); } } /// Verifies that LoadOrCreate throws for an expired persisted certificate when regeneration is disabled. [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(() => CreateProvider(options, time).LoadOrCreate()); } finally { Directory.Delete(dir, recursive: true); } } /// Verifies that LoadOrCreate throws when SelfSignedCertPath is blank. [Fact] public void LoadOrCreate_Throws_WhenSelfSignedCertPathBlank() { TlsOptions options = new() { SelfSignedCertPath = " " }; Assert.Throws( () => CreateProvider(options, new FakeTimeProvider()).LoadOrCreate()); } /// /// 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. /// [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(() => 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 X509SubjectAlternativeNameExtension ReadSubjectAltNames(X509Certificate2 cert) => new(cert.Extensions.First(e => e.Oid?.Value == SubjectAltNameOid).RawData); }