using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Admin.Services; namespace ZB.MOM.WW.OtOpcUa.Admin.Tests; [Trait("Category", "Unit")] public sealed class CertTrustServiceTests : IDisposable { private readonly string _root; public CertTrustServiceTests() { _root = Path.Combine(Path.GetTempPath(), $"otopcua-cert-test-{Guid.NewGuid():N}"); Directory.CreateDirectory(Path.Combine(_root, "rejected", "certs")); Directory.CreateDirectory(Path.Combine(_root, "trusted", "certs")); } public void Dispose() { if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); } private CertTrustService Service() => new( Options.Create(new CertTrustOptions { PkiStoreRoot = _root }), NullLogger.Instance); private X509Certificate2 WriteTestCert(CertStoreKind kind, string subject) { using var rsa = RSA.Create(2048); var req = new CertificateRequest($"CN={subject}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); var dir = Path.Combine(_root, kind == CertStoreKind.Rejected ? "rejected" : "trusted", "certs"); var path = Path.Combine(dir, $"{subject} [{cert.Thumbprint}].der"); File.WriteAllBytes(path, cert.Export(X509ContentType.Cert)); return cert; } [Fact] public void ListRejected_returns_parsed_cert_info_for_each_der_in_rejected_certs_dir() { var c = WriteTestCert(CertStoreKind.Rejected, "test-client-A"); var rows = Service().ListRejected(); rows.Count.ShouldBe(1); rows[0].Thumbprint.ShouldBe(c.Thumbprint); rows[0].Subject.ShouldContain("test-client-A"); rows[0].Store.ShouldBe(CertStoreKind.Rejected); } [Fact] public void ListTrusted_is_separate_from_rejected() { WriteTestCert(CertStoreKind.Rejected, "rej"); WriteTestCert(CertStoreKind.Trusted, "trust"); var svc = Service(); svc.ListRejected().Count.ShouldBe(1); svc.ListTrusted().Count.ShouldBe(1); svc.ListRejected()[0].Subject.ShouldContain("rej"); svc.ListTrusted()[0].Subject.ShouldContain("trust"); } [Fact] public void TrustRejected_moves_file_from_rejected_to_trusted() { var c = WriteTestCert(CertStoreKind.Rejected, "promoteme"); var svc = Service(); svc.TrustRejected(c.Thumbprint).ShouldBeTrue(); svc.ListRejected().ShouldBeEmpty(); var trusted = svc.ListTrusted(); trusted.Count.ShouldBe(1); trusted[0].Thumbprint.ShouldBe(c.Thumbprint); } [Fact] public void TrustRejected_returns_false_when_thumbprint_not_in_rejected() { var svc = Service(); svc.TrustRejected("00DEADBEEF00DEADBEEF00DEADBEEF00DEADBEEF").ShouldBeFalse(); } [Fact] public void DeleteRejected_removes_the_file() { var c = WriteTestCert(CertStoreKind.Rejected, "killme"); var svc = Service(); svc.DeleteRejected(c.Thumbprint).ShouldBeTrue(); svc.ListRejected().ShouldBeEmpty(); } [Fact] public void UntrustCert_removes_from_trusted_only() { var c = WriteTestCert(CertStoreKind.Trusted, "revoke"); var svc = Service(); svc.UntrustCert(c.Thumbprint).ShouldBeTrue(); svc.ListTrusted().ShouldBeEmpty(); } [Fact] public void Thumbprint_match_is_case_insensitive() { var c = WriteTestCert(CertStoreKind.Rejected, "case"); var svc = Service(); // X509Certificate2.Thumbprint is upper-case hex; operators pasting from logs often // lowercase it. IsAllowed-style case-insensitive match keeps the UX forgiving. svc.TrustRejected(c.Thumbprint.ToLowerInvariant()).ShouldBeTrue(); } [Fact] public void Missing_store_directories_produce_empty_lists_not_exceptions() { // Fresh root with no certs subfolder — service should tolerate a pristine install. var altRoot = Path.Combine(Path.GetTempPath(), $"otopcua-cert-empty-{Guid.NewGuid():N}"); try { var svc = new CertTrustService( Options.Create(new CertTrustOptions { PkiStoreRoot = altRoot }), NullLogger.Instance); svc.ListRejected().ShouldBeEmpty(); svc.ListTrusted().ShouldBeEmpty(); } finally { if (Directory.Exists(altRoot)) Directory.Delete(altRoot, recursive: true); } } [Fact] public void Malformed_file_is_skipped_not_fatal() { // Drop junk bytes that don't parse as a cert into the rejected/certs directory. The // service must skip it and still return the valid certs — one bad file can't take the // whole management page offline. File.WriteAllText(Path.Combine(_root, "rejected", "certs", "junk.der"), "not a cert"); var c = WriteTestCert(CertStoreKind.Rejected, "valid"); var rows = Service().ListRejected(); rows.Count.ShouldBe(1); rows[0].Thumbprint.ShouldBe(c.Thumbprint); } }