using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Certificates; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Certificates; public sealed class CertificateStoreManagerTests : IDisposable { private readonly string _root; private readonly RecordingAuditWriter _audit; private readonly CertificateStoreManager _sut; public CertificateStoreManagerTests() { _root = Path.Combine(Path.GetTempPath(), $"pki-test-{Guid.NewGuid():N}"); _audit = new RecordingAuditWriter(); _sut = new CertificateStoreManager(_root, _audit); } // ── Recording audit-writer fake ─────────────────────────────────────────── private sealed class RecordingAuditWriter : ZB.MOM.WW.Audit.IAuditWriter { public List Events { get; } = new(); public Task WriteAsync(ZB.MOM.WW.Audit.AuditEvent evt, CancellationToken ct = default) { Events.Add(evt); return Task.CompletedTask; } } public void Dispose() { if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); } // ── Seed helper ────────────────────────────────────────────────────────── private static string SeedCert(string storeCertsDir, string cn) { Directory.CreateDirectory(storeCertsDir); using var rsa = RSA.Create(2048); var req = new CertificateRequest( $"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); var file = Path.Combine(storeCertsDir, $"{cn} [{cert.Thumbprint}].der"); File.WriteAllBytes(file, cert.Export(X509ContentType.Cert)); return cert.Thumbprint!; } // ── Assertion helpers ───────────────────────────────────────────────────── private static bool ThumbprintExistsInDir(string certsDir, string thumbprint) { if (!Directory.Exists(certsDir)) return false; string[] exts = [".der", ".cer", ".crt"]; foreach (var file in Directory.EnumerateFiles(certsDir)) { if (!exts.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase)) continue; try { using var cert = X509CertificateLoader.LoadCertificateFromFile(file); if (string.Equals(cert.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase)) return true; } catch { /* ignore */ } } return false; } private static int CertCountInDir(string certsDir) { if (!Directory.Exists(certsDir)) return 0; string[] exts = [".der", ".cer", ".crt"]; int count = 0; foreach (var file in Directory.EnumerateFiles(certsDir)) { if (!exts.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase)) continue; try { using var cert = X509CertificateLoader.LoadCertificateFromFile(file); count++; } catch { /* ignore */ } } return count; } // ── Tests ───────────────────────────────────────────────────────────────── [Fact] public void Trust_MovesRejectedToTrusted() { var rejectedCerts = Path.Combine(_root, "rejected", "certs"); var tp = SeedCert(rejectedCerts, "TestClient1"); var result = _sut.Trust(tp, "tester"); result.Success.ShouldBeTrue(result.Error); ThumbprintExistsInDir(rejectedCerts, tp).ShouldBeFalse("cert should have left rejected"); ThumbprintExistsInDir(Path.Combine(_root, "trusted", "certs"), tp).ShouldBeTrue("cert should be in trusted"); } [Fact] public void Untrust_MovesTrustedToRejected() { var trustedCerts = Path.Combine(_root, "trusted", "certs"); var tp = SeedCert(trustedCerts, "TestClient2"); var result = _sut.Untrust(tp, "tester"); result.Success.ShouldBeTrue(result.Error); ThumbprintExistsInDir(trustedCerts, tp).ShouldBeFalse("cert should have left trusted"); ThumbprintExistsInDir(Path.Combine(_root, "rejected", "certs"), tp).ShouldBeTrue("cert should be in rejected"); } [Fact] public void Delete_Rejected_RemovesFile() { var rejectedCerts = Path.Combine(_root, "rejected", "certs"); var tp = SeedCert(rejectedCerts, "TestClient3"); var result = _sut.Delete("rejected", tp, "tester"); result.Success.ShouldBeTrue(result.Error); ThumbprintExistsInDir(rejectedCerts, tp).ShouldBeFalse("cert should be gone from rejected"); } [Fact] public void Delete_Trusted_RemovesFile() { var trustedCerts = Path.Combine(_root, "trusted", "certs"); var tp = SeedCert(trustedCerts, "TestClient4"); var result = _sut.Delete("trusted", tp, "tester"); result.Success.ShouldBeTrue(result.Error); ThumbprintExistsInDir(trustedCerts, tp).ShouldBeFalse("cert should be gone from trusted"); } [Fact] public void Trust_UnknownThumbprint_FailsNoChange() { var tp = new string('A', 40); var result = _sut.Trust(tp, "tester"); result.Success.ShouldBeFalse(); CertCountInDir(Path.Combine(_root, "trusted", "certs")).ShouldBe(0); } [Fact] public void Trust_PathTraversalThumbprint_Rejected() { var result = _sut.Trust("../../x", "tester"); result.Success.ShouldBeFalse(); result.Error.ShouldNotBeNull(); result.Error.ShouldContain("invalid", Case.Insensitive); // Verify no files were created anywhere under root if (Directory.Exists(_root)) Directory.GetFiles(_root, "*", SearchOption.AllDirectories).ShouldBeEmpty(); } [Fact] public void Delete_DisallowedStore_Fails() { var ownCerts = Path.Combine(_root, "own", "certs"); var tp = SeedCert(ownCerts, "OwnCert"); var result = _sut.Delete("own", tp, "tester"); result.Success.ShouldBeFalse(); result.Error.ShouldNotBeNull(); result.Error.ShouldContain("store", Case.Insensitive); ThumbprintExistsInDir(ownCerts, tp).ShouldBeTrue("own cert should still be present"); } [Fact] public void Trust_AlreadyInDest_IdempotentSuccess() { var rejectedCerts = Path.Combine(_root, "rejected", "certs"); var trustedCerts = Path.Combine(_root, "trusted", "certs"); // Seed into rejected, then copy that exact file into trusted so both have identical bytes/thumbprint var tp = SeedCert(rejectedCerts, "TestClient8"); Directory.CreateDirectory(trustedCerts); var srcFile = Directory.GetFiles(rejectedCerts).Single(); File.Copy(srcFile, Path.Combine(trustedCerts, Path.GetFileName(srcFile))); var result = _sut.Trust(tp, "tester"); result.Success.ShouldBeTrue(result.Error); ThumbprintExistsInDir(rejectedCerts, tp).ShouldBeFalse("source (rejected) should no longer have it"); CertCountInDir(trustedCerts).ShouldBe(1, "trusted should still have exactly one copy"); } [Fact] public void Trust_NullOrEmptyThumbprint_Fails() { var result = _sut.Trust("", "tester"); result.Success.ShouldBeFalse(); result.Error.ShouldNotBeNull(); } // ── Audit tests ─────────────────────────────────────────────────────────── [Fact] public void Trust_success_writes_one_success_audit_event() { var rejectedCerts = Path.Combine(_root, "rejected", "certs"); var tp = SeedCert(rejectedCerts, "AuditTrust"); var result = _sut.Trust(tp, "alice"); result.Success.ShouldBeTrue(result.Error); var evt = _audit.Events.ShouldHaveSingleItem(); evt.Action.ShouldBe("Trust"); evt.Outcome.ShouldBe(ZB.MOM.WW.Audit.AuditOutcome.Success); evt.SourceNode.ShouldBe(tp); evt.Actor.ShouldBe("alice"); } [Fact] public void Untrust_success_writes_one_success_audit_event() { var trustedCerts = Path.Combine(_root, "trusted", "certs"); var tp = SeedCert(trustedCerts, "AuditUntrust"); var result = _sut.Untrust(tp, "alice"); result.Success.ShouldBeTrue(result.Error); var evt = _audit.Events.ShouldHaveSingleItem(); evt.Action.ShouldBe("Untrust"); evt.Outcome.ShouldBe(ZB.MOM.WW.Audit.AuditOutcome.Success); evt.SourceNode.ShouldBe(tp); evt.Actor.ShouldBe("alice"); } [Fact] public void Delete_success_writes_one_success_audit_event() { var rejectedCerts = Path.Combine(_root, "rejected", "certs"); var tp = SeedCert(rejectedCerts, "AuditDelete"); var result = _sut.Delete("rejected", tp, "alice"); result.Success.ShouldBeTrue(result.Error); var evt = _audit.Events.ShouldHaveSingleItem(); evt.Action.ShouldBe("Delete"); evt.Outcome.ShouldBe(ZB.MOM.WW.Audit.AuditOutcome.Success); evt.SourceNode.ShouldBe(tp); evt.Actor.ShouldBe("alice"); } [Fact] public void Delete_not_found_writes_one_failure_audit_event() { var absent = new string('0', 40); var result = _sut.Delete("trusted", absent, "bob"); result.Success.ShouldBeFalse(); var evt = _audit.Events.ShouldHaveSingleItem(); evt.Action.ShouldBe("Delete"); evt.Outcome.ShouldBe(ZB.MOM.WW.Audit.AuditOutcome.Failure); evt.SourceNode.ShouldBe(absent); evt.Actor.ShouldBe("bob"); } [Fact] public void Trust_not_found_writes_one_failure_audit_event() { var absent = new string('0', 40); var result = _sut.Trust(absent, "bob"); result.Success.ShouldBeFalse(); var evt = _audit.Events.ShouldHaveSingleItem(); evt.Action.ShouldBe("Trust"); evt.Outcome.ShouldBe(ZB.MOM.WW.Audit.AuditOutcome.Failure); evt.SourceNode.ShouldBe(absent); evt.Actor.ShouldBe("bob"); } [Fact] public void Untrust_not_found_writes_one_failure_audit_event() { var absent = new string('0', 40); var result = _sut.Untrust(absent, "erin"); result.Success.ShouldBeFalse(); var evt = _audit.Events.ShouldHaveSingleItem(); evt.Action.ShouldBe("Untrust"); evt.Outcome.ShouldBe(ZB.MOM.WW.Audit.AuditOutcome.Failure); evt.SourceNode.ShouldBe(absent); evt.Actor.ShouldBe("erin"); } }