using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text.Json; using Shouldly; using ZB.MOM.NatsNet.Server; using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider; using ZB.MOM.NatsNet.Server.Auth.Ocsp; namespace ZB.MOM.NatsNet.Server.Tests.Auth; public sealed class OcspFoundationTests : IDisposable { private readonly List _tempDirs = []; private readonly List _certs = []; [Fact] public void GetNextRun_NoCachedResponse_ReturnsDefaultInterval() { var monitor = new OcspMonitor(); monitor.GetNextRun().ShouldBe(TimeSpan.FromHours(24)); } [Fact] public void GetLocalStatus_StoreDirMissing_ReturnsError() { var monitor = new OcspMonitor { Server = NewServer(new ServerOptions()), Leaf = CreateSelfSignedCertificate("CN=leaf"), Issuer = CreateSelfSignedCertificate("CN=issuer"), }; var (_, _, err) = monitor.GetLocalStatus(); err.ShouldNotBeNull(); err!.Message.ShouldContain("store_dir"); } [Fact] public void GetStatus_UsesLocalStatus_WhenCacheIsEmpty() { var dir = MakeTempDir(); var monitor = NewMonitorWithStore(dir); var key = GetLeafKey(monitor.Leaf!); var responseBytes = SerializeResponse( status: (int)OcspStatusAssertion.Good, thisUpdate: DateTime.UtcNow.AddMinutes(-1), nextUpdate: DateTime.UtcNow.AddHours(1)); Directory.CreateDirectory(Path.Combine(dir, "ocsp")); File.WriteAllBytes(Path.Combine(dir, "ocsp", key), responseBytes); var (raw, response, err) = monitor.GetStatus(); err.ShouldBeNull(); raw.ShouldNotBeNull(); response.ShouldNotBeNull(); response.Status.ShouldBe(OcspStatusAssertion.Good); } [Fact] public void GetRemoteStatus_NoResponders_ReturnsError() { var monitor = NewMonitorWithStore(MakeTempDir()); var (_, _, err) = monitor.GetRemoteStatus(); err.ShouldNotBeNull(); err!.Message.ShouldContain("no available ocsp servers"); } [Fact] public void WriteOcspStatus_ValidPath_WritesFile() { var dir = MakeTempDir(); Directory.CreateDirectory(Path.Combine(dir, "ocsp")); var monitor = NewMonitorWithStore(dir); var err = monitor.WriteOCSPStatus(dir, "status.bin", [1, 2, 3, 4]); err.ShouldBeNull(); var path = Path.Combine(dir, "ocsp", "status.bin"); File.Exists(path).ShouldBeTrue(); File.ReadAllBytes(path).ShouldBe([1, 2, 3, 4]); } [Fact] public async Task Run_CancelledToken_Completes() { var monitor = new OcspMonitor(); using var cts = new CancellationTokenSource(); cts.Cancel(); await monitor.Run(cts.Token); } [Fact] public void ParseCertPem_CertificateFile_ReturnsCertificate() { var cert = CreateSelfSignedCertificate("CN=parsecert"); var path = Path.Combine(MakeTempDir(), "ca.pem"); File.WriteAllText(path, cert.ExportCertificatePem()); var (certs, err) = OcspHandler.ParseCertPEM(path); err.ShouldBeNull(); certs.ShouldNotBeNull(); certs.Count.ShouldBe(1); } [Fact] public void GetOcspIssuerLocally_LeafAndIssuerBundle_ReturnsIssuer() { var (leaf, issuer) = CreateLeafAndIssuer(); var (resolvedIssuer, err) = OcspHandler.GetOCSPIssuerLocally([], [leaf, issuer]); err.ShouldBeNull(); resolvedIssuer.ShouldNotBeNull(); resolvedIssuer!.Subject.ShouldBe(issuer.Subject); } [Fact] public void GetOcspIssuer_WithChain_ReturnsIssuer() { var (leaf, issuer) = CreateLeafAndIssuer(); var chain = new[] { leaf.RawData, issuer.RawData }; var (resolvedIssuer, err) = OcspHandler.GetOCSPIssuer(string.Empty, chain); err.ShouldBeNull(); resolvedIssuer.ShouldNotBeNull(); resolvedIssuer!.Subject.ShouldBe(issuer.Subject); } [Theory] [InlineData(0, "good")] [InlineData(1, "revoked")] [InlineData(99, "unknown")] public void OcspStatusString_AnyStatus_ReturnsExpectedString(int status, string expected) { OcspHandler.OcspStatusString(status).ShouldBe(expected); } [Fact] public void ValidOcspResponse_ExpiredNextUpdate_ReturnsError() { var response = new OcspResponse { Status = OcspStatusAssertion.Good, ThisUpdate = DateTime.UtcNow.AddMinutes(-2), NextUpdate = DateTime.UtcNow.AddMinutes(-1), }; var err = OcspHandler.ValidOCSPResponse(response); err.ShouldNotBeNull(); } [Fact] public void ValidOcspResponse_CurrentResponse_ReturnsNull() { var response = new OcspResponse { Status = OcspStatusAssertion.Good, ThisUpdate = DateTime.UtcNow.AddMinutes(-1), NextUpdate = DateTime.UtcNow.AddMinutes(10), }; var err = OcspHandler.ValidOCSPResponse(response); err.ShouldBeNull(); } public void Dispose() { foreach (var cert in _certs) { cert.Dispose(); } foreach (var dir in _tempDirs) { try { Directory.Delete(dir, recursive: true); } catch { } } } private NatsServer NewServer(ServerOptions options) { var (server, err) = NatsServer.NewServer(options); err.ShouldBeNull(); return server!; } private OcspMonitor NewMonitorWithStore(string storeDir) { var opts = new ServerOptions { StoreDir = storeDir, OcspConfig = new OcspConfig(), }; return new OcspMonitor { Server = NewServer(opts), Leaf = CreateSelfSignedCertificate("CN=leaf"), Issuer = CreateSelfSignedCertificate("CN=issuer"), }; } private string MakeTempDir() { var path = Path.Combine(Path.GetTempPath(), "ocsp-foundation-" + Path.GetRandomFileName()); Directory.CreateDirectory(path); _tempDirs.Add(path); return path; } private X509Certificate2 CreateSelfSignedCertificate(string subjectName) { var request = new CertificateRequest( subjectName, RSA.Create(2048), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); var cert = request.CreateSelfSigned( DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30)); _certs.Add(cert); return cert; } private (X509Certificate2 leaf, X509Certificate2 issuer) CreateLeafAndIssuer() { var issuerRequest = new CertificateRequest( "CN=issuer", RSA.Create(2048), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); issuerRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); issuerRequest.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(issuerRequest.PublicKey, false)); var issuer = issuerRequest.CreateSelfSigned( DateTimeOffset.UtcNow.AddDays(-2), DateTimeOffset.UtcNow.AddDays(365)); var leafRequest = new CertificateRequest( "CN=leaf", RSA.Create(2048), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); leafRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); leafRequest.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(leafRequest.PublicKey, false)); var serial = new byte[16]; RandomNumberGenerator.Fill(serial); var leaf = leafRequest.Create( issuer, DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(90), serial); _certs.Add(issuer); _certs.Add(leaf); return (leaf, issuer); } private static byte[] SerializeResponse(int status, DateTime thisUpdate, DateTime nextUpdate) { var payload = new { Status = status, ThisUpdate = thisUpdate, NextUpdate = nextUpdate, }; return JsonSerializer.SerializeToUtf8Bytes(payload); } private static string GetLeafKey(X509Certificate2 cert) { var hash = SHA256.HashData(cert.RawData); return Convert.ToHexString(hash).ToLowerInvariant(); } }