using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Akka.Actor; using Akka.TestKit.Xunit2; using FluentAssertions; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.DataConnectionLayer; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// /// T17 / D6: per-node CertStoreActor write/list/remove round-trip against a /// temp trusted-peer PKI store. The cluster broadcast (singleton → every node's /// CertStoreActor) is validated live at integration E1; here we cover the /// testable per-node core. /// public class CertStoreActorTests : TestKit, IDisposable { private readonly string _trustedDir; private readonly string _rejectedDir; private readonly OpcUaGlobalOptions _options; public CertStoreActorTests() { var root = Path.Combine(Path.GetTempPath(), "ScadaBridge-CertStoreTests", Guid.NewGuid().ToString("N")); _trustedDir = Path.Combine(root, "trusted"); _rejectedDir = Path.Combine(root, "rejected"); _options = new OpcUaGlobalOptions { TrustedPeerStorePath = _trustedDir, RejectedCertificateStorePath = _rejectedDir, }; } void IDisposable.Dispose() { Shutdown(); TryDeleteDir(Path.GetDirectoryName(_trustedDir)!); } private static void TryDeleteDir(string dir) { try { if (Directory.Exists(dir)) { Directory.Delete(dir, recursive: true); } } catch { // Best-effort temp cleanup. } } private static (string DerBase64, string Thumbprint) BuildSelfSignedCert() { using var rsa = RSA.Create(2048); var req = new CertificateRequest( "CN=ScadaBridge-CertStoreTest", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); using var cert = req.CreateSelfSigned( DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365)); var der = cert.Export(X509ContentType.Cert); return (Convert.ToBase64String(der), cert.Thumbprint); } [Fact] public void Write_ThenList_ThenRemove_RoundTrips() { var (derBase64, thumbprint) = BuildSelfSignedCert(); var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options))); // Write actor.Tell(new WriteCertToLocalStore(derBase64, thumbprint)); var writeAck = ExpectMsg(); writeAck.Success.Should().BeTrue(); writeAck.Error.Should().BeNull(); var expectedFile = Path.Combine(_trustedDir, $"{thumbprint}.der"); File.Exists(expectedFile).Should().BeTrue("the cert is written into the trusted-peer store"); // List — the cert appears, not rejected actor.Tell(new ListLocalCerts()); var listAck = ExpectMsg(); listAck.Success.Should().BeTrue(); listAck.Certs.Should().NotBeNull(); listAck.Certs!.Should().ContainSingle(c => c.Thumbprint == thumbprint) .Which.Rejected.Should().BeFalse(); // Remove — ack + file gone actor.Tell(new RemoveCertFromLocalStore(thumbprint)); var removeAck = ExpectMsg(); removeAck.Success.Should().BeTrue(); File.Exists(expectedFile).Should().BeFalse("the cert file is deleted from the trusted-peer store"); // List again — empty actor.Tell(new ListLocalCerts()); var emptyAck = ExpectMsg(); emptyAck.Success.Should().BeTrue(); emptyAck.Certs.Should().NotBeNull(); emptyAck.Certs!.Should().NotContain(c => c.Thumbprint == thumbprint); } [Fact] public void Write_InvalidBase64_ReturnsFailureAck() { var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options))); actor.Tell(new WriteCertToLocalStore("not-valid-base64!!", "ABC123")); var ack = ExpectMsg(); ack.Success.Should().BeFalse(); ack.Error.Should().NotBeNullOrEmpty(); } [Fact] public void Remove_MissingCert_IsNoOpSuccess() { var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options))); actor.Tell(new RemoveCertFromLocalStore("NONEXISTENTTHUMBPRINT")); var ack = ExpectMsg(); ack.Success.Should().BeTrue("removing an absent cert is an idempotent no-op"); } [Fact] public void Write_PathTraversalThumbprint_RejectedWithoutTouchingFilesystem() { var (derBase64, _) = BuildSelfSignedCert(); var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options))); // A "../escape" thumbprint would otherwise resolve to "/escape.der", // OUTSIDE the trusted-store dir, once combined into a ".der" file name. actor.Tell(new WriteCertToLocalStore(derBase64, "../escape")); var ack = ExpectMsg(); ack.Success.Should().BeFalse("a path-traversal thumbprint must be rejected"); ack.Error.Should().Be("invalid thumbprint"); // No file escaped above the trusted-store dir (the shared temp root). var escapeRoot = Path.GetDirectoryName(_trustedDir)!; File.Exists(Path.Combine(escapeRoot, "escape.der")).Should().BeFalse( "the actor must not write outside the trusted-store directory"); } [Fact] public void Remove_PathTraversalThumbprint_RejectedWithoutTouchingFilesystem() { // Seed a decoy file one level up to prove the rejected remove never // reaches the filesystem and therefore cannot delete it. var escapeRoot = Path.GetDirectoryName(_trustedDir)!; Directory.CreateDirectory(escapeRoot); var decoy = Path.Combine(escapeRoot, "escape.der"); File.WriteAllBytes(decoy, new byte[] { 0x01, 0x02, 0x03 }); var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options))); actor.Tell(new RemoveCertFromLocalStore("../escape")); var ack = ExpectMsg(); ack.Success.Should().BeFalse("a path-traversal thumbprint must be rejected"); ack.Error.Should().Be("invalid thumbprint"); File.Exists(decoy).Should().BeTrue( "the actor must not delete files outside the trusted-store directory"); } }