// Tests for TLS certificate hot reload (E9). // Verifies that TlsCertificateProvider supports atomic cert swapping // and that ConfigReloader.ReloadTlsCertificate integrates correctly. // Reference: golang/nats-server/server/reload_test.go — TestConfigReloadRotateTLS (line 392). using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using NATS.Server.Configuration; using NATS.Server.Tls; namespace NATS.Server.Tests.Configuration; public class TlsReloadTests { /// /// Generates a self-signed X509Certificate2 for testing. /// private static X509Certificate2 GenerateSelfSignedCert(string cn = "test") { using var rsa = RSA.Create(2048); var req = new CertificateRequest($"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(1)); // Export and re-import to ensure the cert has the private key bound return X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pkcs12), null); } [Fact] public void CertificateProvider_GetCurrentCertificate_ReturnsInitialCert() { // Go parity: TestConfigReloadRotateTLS — initial cert is usable var cert = GenerateSelfSignedCert("initial"); using var provider = new TlsCertificateProvider(cert); var current = provider.GetCurrentCertificate(); current.ShouldNotBeNull(); current.Subject.ShouldContain("initial"); } [Fact] public void CertificateProvider_SwapCertificate_ReturnsOldCert() { // Go parity: TestConfigReloadRotateTLS — cert rotation returns old cert var cert1 = GenerateSelfSignedCert("cert1"); var cert2 = GenerateSelfSignedCert("cert2"); using var provider = new TlsCertificateProvider(cert1); var old = provider.SwapCertificate(cert2); old.ShouldNotBeNull(); old.Subject.ShouldContain("cert1"); old.Dispose(); var current = provider.GetCurrentCertificate(); current.ShouldNotBeNull(); current.Subject.ShouldContain("cert2"); } [Fact] public void CertificateProvider_SwapCertificate_IncrementsVersion() { // Go parity: TestConfigReloadRotateTLS — version tracking for reload detection var cert1 = GenerateSelfSignedCert("v1"); var cert2 = GenerateSelfSignedCert("v2"); using var provider = new TlsCertificateProvider(cert1); var v0 = provider.Version; v0.ShouldBe(0); provider.SwapCertificate(cert2)?.Dispose(); provider.Version.ShouldBe(1); } [Fact] public void CertificateProvider_MultipleSwa_NewConnectionsGetLatest() { // Go parity: TestConfigReloadRotateTLS — multiple rotations, each new // handshake gets the latest certificate var cert1 = GenerateSelfSignedCert("round1"); var cert2 = GenerateSelfSignedCert("round2"); var cert3 = GenerateSelfSignedCert("round3"); using var provider = new TlsCertificateProvider(cert1); provider.GetCurrentCertificate()!.Subject.ShouldContain("round1"); provider.SwapCertificate(cert2)?.Dispose(); provider.GetCurrentCertificate()!.Subject.ShouldContain("round2"); provider.SwapCertificate(cert3)?.Dispose(); provider.GetCurrentCertificate()!.Subject.ShouldContain("round3"); provider.Version.ShouldBe(2); } [Fact] public async Task CertificateProvider_ConcurrentAccess_IsThreadSafe() { // Go parity: TestConfigReloadRotateTLS — cert swap must be safe under // concurrent connection accept var cert1 = GenerateSelfSignedCert("concurrent1"); using var provider = new TlsCertificateProvider(cert1); var tasks = new Task[50]; for (int i = 0; i < tasks.Length; i++) { var idx = i; tasks[i] = Task.Run(() => { if (idx % 2 == 0) { // Readers — simulate new connections getting current cert var c = provider.GetCurrentCertificate(); c.ShouldNotBeNull(); } else { // Writers — simulate reload var newCert = GenerateSelfSignedCert($"swap-{idx}"); provider.SwapCertificate(newCert)?.Dispose(); } }); } await Task.WhenAll(tasks); // After all swaps, the provider should still return a valid cert provider.GetCurrentCertificate().ShouldNotBeNull(); } [Fact] public void ReloadTlsCertificate_NullProvider_ReturnsFalse() { // Edge case: server running without TLS var opts = new NatsOptions(); var result = ConfigReloader.ReloadTlsCertificate(opts, null); result.ShouldBeFalse(); } [Fact] public void ReloadTlsCertificate_NoTlsConfig_ReturnsFalse() { // Edge case: provider exists but options don't have TLS paths var cert = GenerateSelfSignedCert("no-tls"); using var provider = new TlsCertificateProvider(cert); var opts = new NatsOptions(); // HasTls is false (no TlsCert/TlsKey) var result = ConfigReloader.ReloadTlsCertificate(opts, provider); result.ShouldBeFalse(); } [Fact] public void ReloadTlsCertificate_WithCertFiles_SwapsCertAndSslOptions() { // Go parity: TestConfigReloadRotateTLS — full reload with cert files. // Write a self-signed cert to temp files and verify the provider loads it. var tempDir = Path.Combine(Path.GetTempPath(), $"nats-tls-test-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); try { var certPath = Path.Combine(tempDir, "cert.pem"); var keyPath = Path.Combine(tempDir, "key.pem"); WriteSelfSignedCertFiles(certPath, keyPath, "reload-test"); // Create provider with initial cert var initialCert = GenerateSelfSignedCert("initial"); using var provider = new TlsCertificateProvider(initialCert); var opts = new NatsOptions { TlsCert = certPath, TlsKey = keyPath }; var result = ConfigReloader.ReloadTlsCertificate(opts, provider); result.ShouldBeTrue(); provider.Version.ShouldBeGreaterThan(0); provider.GetCurrentCertificate().ShouldNotBeNull(); provider.GetCurrentSslOptions().ShouldNotBeNull(); } finally { Directory.Delete(tempDir, recursive: true); } } [Fact] public void ConfigDiff_DetectsTlsChanges() { // Go parity: TestConfigReloadEnableTLS, TestConfigReloadDisableTLS // Verify that diff detects TLS option changes and flags them var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem", TlsKey = "/old/key.pem" }; var newOpts = new NatsOptions { TlsCert = "/new/cert.pem", TlsKey = "/new/key.pem" }; var changes = ConfigReloader.Diff(oldOpts, newOpts); changes.Count.ShouldBeGreaterThan(0); changes.ShouldContain(c => c.IsTlsChange && c.Name == "TlsCert"); changes.ShouldContain(c => c.IsTlsChange && c.Name == "TlsKey"); } [Fact] public void ConfigDiff_TlsVerifyChange_IsTlsChange() { // Go parity: TestConfigReloadRotateTLS — enabling client verification var oldOpts = new NatsOptions { TlsVerify = false }; var newOpts = new NatsOptions { TlsVerify = true }; var changes = ConfigReloader.Diff(oldOpts, newOpts); changes.ShouldContain(c => c.IsTlsChange && c.Name == "TlsVerify"); } [Fact] public void ConfigApplyResult_ReportsTlsChanges() { // Verify ApplyDiff flags TLS changes correctly var changes = new List { new ConfigChange("TlsCert", isTlsChange: true), new ConfigChange("TlsKey", isTlsChange: true), }; var oldOpts = new NatsOptions(); var newOpts = new NatsOptions(); var result = ConfigReloader.ApplyDiff(changes, oldOpts, newOpts); result.HasTlsChanges.ShouldBeTrue(); result.ChangeCount.ShouldBe(2); } /// /// Helper to write a self-signed certificate to PEM files. /// private static void WriteSelfSignedCertFiles(string certPath, string keyPath, string cn) { using var rsa = RSA.Create(2048); var req = new CertificateRequest($"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(1)); File.WriteAllText(certPath, cert.ExportCertificatePem()); File.WriteAllText(keyPath, rsa.ExportRSAPrivateKeyPem()); } }