feat(siteruntime): per-node CertStore actor + trust broadcast to both site nodes (T17)
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user