Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/CertStoreActorTests.cs
T

170 lines
6.4 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
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<LocalCertOpAck>();
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<LocalCertOpAck>();
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<LocalCertOpAck>();
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<LocalCertOpAck>();
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<LocalCertOpAck>();
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<LocalCertOpAck>();
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 "<root>/escape.der",
// OUTSIDE the trusted-store dir, once combined into a ".der" file name.
actor.Tell(new WriteCertToLocalStore(derBase64, "../escape"));
var ack = ExpectMsg<LocalCertOpAck>();
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<LocalCertOpAck>();
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");
}
}