e2352d1666
Both failures in the windev baseline were test bugs that reproduce on any Windows host, not anything missing or misconfigured on windev. SelfSignedCertificateProviderTests.GenerateCertificate_HasExpectedSansEkuAndValidity asserted SAN content by substring-matching X509Extension.Format(false). That string comes from the platform crypto library: Windows' CryptFormatObject renders the IPv6 loopback fully expanded (0000:0000:...:0001) where the managed formatter renders "::1", so the loopback assertion could never hold on Windows. Decode the extension with X509SubjectAlternativeNameExtension and compare parsed IPAddress values and DNS names instead, which removes the platform-dependent formatting from the assertion. SessionManagerTests.OpenSessionAsync_PipeNameIsShortAndUniquePerPidAndSession guards the 104-byte macOS sun_path budget NEXT-01 shortened the pipe name to fit. It padded the measured name up to a five-digit pid but never substituted that worst case downward, so Windows' routine six-digit pids over-counted by a character against a budget that does not constrain the host running the test. Substitute the five-digit macOS worst case for the running pid's digit count so the check measures the name format rather than the current pid. EventStreamServiceTests.WaitUntilAsync now reports the unmet condition on timeout instead of letting a bare TaskCanceledException escape. Its five-second real-clock deadline is genuinely load-sensitive on windev (36 logical CPUs, maxParallelThreads -1), and an opaque cancellation there is exactly what got the previous failures filed as "environmental" and left unexplained. Documents the windev run in docs/GatewayTesting.md: the two fixed bugs and their root causes, the real-pipe suites whose failures are evidence of machine load rather than of the change under test, and the full-suite testhost that completes every test and then never exits (filtered runs exit normally; macOS exits cleanly). Corrects the CLAUDE.md claim that the suite exits cleanly on the Windows dev box.
166 lines
8.3 KiB
C#
166 lines
8.3 KiB
C#
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<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);
|
|
|
|
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<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 X509SubjectAlternativeNameExtension ReadSubjectAltNames(X509Certificate2 cert)
|
|
=> new(cert.Extensions.First(e => e.Oid?.Value == SubjectAltNameOid).RawData);
|
|
}
|