diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/CertTrustCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/CertTrustCommands.cs
new file mode 100644
index 00000000..10b15f95
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/CertTrustCommands.cs
@@ -0,0 +1,92 @@
+namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// T17 / D6 — OPC UA server-certificate trust management.
+//
+// Cert trust is SITE-LOCAL: there is no central persistence of trusted server
+// certificates. The trusted-peer PKI store lives on each site node's file
+// system, so a trust/remove decision must reach BOTH site nodes (node-a and
+// node-b) or the two PKI stores diverge across failover (Decision D6).
+//
+// The public commands below are handled by the site Deployment Manager
+// singleton (active node only). For trust/remove it broadcasts the
+// corresponding per-node internal message to the per-node CertStoreActor on
+// EVERY site cluster node; for list it answers from the local node.
+// ─────────────────────────────────────────────────────────────────────────────
+
+///
+/// Trust an OPC UA server certificate at every site node. Carries the raw
+/// DER bytes (base64-encoded) so the certificate can be written into each
+/// node's trusted-peer PKI store. The thumbprint is the store filename key.
+///
+/// The data-connection the certificate was captured from (diagnostics / correlation only).
+/// The server certificate's DER encoding, base64-encoded.
+/// The certificate thumbprint — used as the store filename key.
+public record TrustServerCertCommand(string ConnectionName, string DerBase64, string Thumbprint);
+
+///
+/// Remove a previously-trusted OPC UA server certificate from every site
+/// node's trusted-peer PKI store, identified by thumbprint.
+///
+/// The thumbprint of the certificate to remove.
+public record RemoveServerCertCommand(string Thumbprint);
+
+///
+/// List the certificates currently present in this site's trusted-peer and
+/// rejected PKI stores. Answered from the local node (the Deployment Manager
+/// singleton's own node).
+///
+public record ListServerCertsCommand();
+
+///
+/// Read-only projection of a certificate found in a site PKI store.
+///
+/// The certificate thumbprint.
+/// The certificate subject distinguished name.
+/// The certificate issuer distinguished name.
+/// Validity start (UTC).
+/// Validity end (UTC).
+/// True if the certificate is in the rejected store; false if trusted.
+public record TrustedCertInfo(
+ string Thumbprint,
+ string Subject,
+ string Issuer,
+ DateTime NotBeforeUtc,
+ DateTime NotAfterUtc,
+ bool Rejected);
+
+///
+/// Aggregate result of a cert-trust command. For trust/remove,
+/// reflects whether every reachable site node acked; carries the
+/// first node error (or a partial-failure note when a node did not ack in time).
+/// For list, carries the local node's store contents.
+///
+/// True when the operation succeeded on all targeted nodes.
+/// First error encountered, or null on success.
+/// Listed certificates (list command only), otherwise null.
+public record CertTrustResult(bool Success, string? Error, IReadOnlyList? Certs);
+
+// ── Per-node internal messages (CertStoreActor wire protocol) ──
+// Sent by the Deployment Manager singleton to each site node's CertStoreActor,
+// or used directly in tests. Not part of the public management surface.
+
+/// Per-node: decode and write it into the local trusted-peer store as <Thumbprint>.der.
+/// The certificate's DER encoding, base64-encoded.
+/// The thumbprint used as the store filename key.
+public record WriteCertToLocalStore(string DerBase64, string Thumbprint);
+
+/// Per-node: delete the trusted-peer store file matching .
+/// The thumbprint of the certificate to remove.
+public record RemoveCertFromLocalStore(string Thumbprint);
+
+/// Per-node: enumerate the local trusted-peer and rejected stores.
+public record ListLocalCerts();
+
+///
+/// Per-node ack for a ,
+/// or operation.
+///
+/// True if the local store operation succeeded.
+/// The error message on failure, otherwise null.
+/// Listed certificates (list only), otherwise null.
+public record LocalCertOpAck(bool Success, string? Error, IReadOnlyList? Certs);
diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs
index 76f5b5fa..0d3aa801 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs
@@ -800,6 +800,21 @@ akka {{
_logger.LogInformation("Data Connection Layer manager actor created");
}
+ // T17 / D6 — per-node OPC UA certificate-store actor. Created on EVERY
+ // site node (NOT a singleton) at a well-known name so the Deployment
+ // Manager singleton can fan a trust/remove out to BOTH nodes' PKI stores
+ // (node-a + node-b) and keep them in lock-step across failover. It needs
+ // the same deployment-wide OpcUaGlobalOptions the DCL manager uses so a
+ // trusted cert lands in the exact store RealOpcUaClient validates against.
+ var certStoreOpcUaOptions = _serviceProvider
+ .GetService>()?.Value
+ ?? new ZB.MOM.WW.ScadaBridge.DataConnectionLayer.OpcUaGlobalOptions();
+ _actorSystem!.ActorOf(
+ Props.Create(() => new ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors.CertStoreActor(certStoreOpcUaOptions)),
+ ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors.CertStoreActor.WellKnownName);
+ _logger.LogInformation("Per-node CertStoreActor created at well-known name '{Name}' (T17/D6)",
+ ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors.CertStoreActor.WellKnownName);
+
// Resolve the health collector for the Deployment Manager
var siteHealthCollector = _serviceProvider.GetService();
siteHealthCollector?.SetNodeHostname(_nodeOptions.NodeHostname);
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/CertStoreActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/CertStoreActor.cs
new file mode 100644
index 00000000..19b60bca
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/CertStoreActor.cs
@@ -0,0 +1,159 @@
+using System.Security.Cryptography.X509Certificates;
+using Akka.Actor;
+using Akka.Event;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
+using ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
+
+namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
+
+///
+/// Per-node OPC UA certificate-store actor (T17 / D6). Runs on EVERY site
+/// node (NOT a singleton) at a well-known name so the Deployment Manager
+/// singleton can address it on each node via .
+///
+/// Cert trust is site-local: the trusted-peer PKI store is a directory on each
+/// node's file system. A trust/remove decision must therefore reach BOTH site
+/// nodes or the two stores diverge across failover (D6). The singleton fans the
+/// per-node /
+/// message out; this actor performs the actual file write/delete/enumerate.
+///
+/// NOTE on the script-trust forbidden-IO rule: that rule applies only to USER
+/// scripts. This is a framework/DCL actor doing legitimate PKI store file I/O,
+/// exactly as the underlying OPC UA stack does when it persists rejected certs.
+///
+public class CertStoreActor : ReceiveActor
+{
+ ///
+ /// Well-known actor name. The Deployment Manager singleton addresses each
+ /// site node's instance via {member.Address}/user/{WellKnownName}.
+ ///
+ public const string WellKnownName = "cert-store";
+
+ private readonly ILoggingAdapter _log = Context.GetLogger();
+ private readonly string _trustedStoreDir;
+ private readonly string _rejectedStoreDir;
+
+ ///
+ /// Initializes the actor with the deployment-wide OPC UA options used to
+ /// resolve the trusted-peer and rejected store directories.
+ ///
+ /// Deployment-wide OPC UA options (store paths).
+ public CertStoreActor(OpcUaGlobalOptions opcUaGlobalOptions)
+ {
+ // Resolve store directories with the SAME fallback logic
+ // RealOpcUaClient.ResolveStorePath uses, so a cert trusted here lands in
+ // the exact store the OPC UA client validates against. That helper is a
+ // private static method on RealOpcUaClient; the tiny logic is replicated
+ // here rather than widening its visibility.
+ _trustedStoreDir = ResolveStorePath(opcUaGlobalOptions.TrustedPeerStorePath, "trusted");
+ _rejectedStoreDir = ResolveStorePath(opcUaGlobalOptions.RejectedCertificateStorePath, "rejected");
+
+ Receive(HandleWrite);
+ Receive(HandleRemove);
+ Receive(_ => HandleList());
+ }
+
+ ///
+ /// Replicates RealOpcUaClient.ResolveStorePath: an empty configured
+ /// path falls back to %TEMP%/ScadaBridge/pki/<leaf>.
+ ///
+ private static string ResolveStorePath(string configured, string fallbackLeaf) =>
+ string.IsNullOrWhiteSpace(configured)
+ ? Path.Combine(Path.GetTempPath(), "ScadaBridge", "pki", fallbackLeaf)
+ : configured;
+
+ private void HandleWrite(WriteCertToLocalStore msg)
+ {
+ try
+ {
+ Directory.CreateDirectory(_trustedStoreDir);
+ var der = Convert.FromBase64String(msg.DerBase64);
+ var path = Path.Combine(_trustedStoreDir, FileNameFor(msg.Thumbprint));
+ File.WriteAllBytes(path, der);
+ _log.Info("Trusted server certificate {Thumbprint} written to {Path}", msg.Thumbprint, path);
+ Sender.Tell(new LocalCertOpAck(true, null, null));
+ }
+ catch (Exception ex)
+ {
+ _log.Warning(ex, "Failed to write trusted server certificate {Thumbprint}", msg.Thumbprint);
+ Sender.Tell(new LocalCertOpAck(false, ex.Message, null));
+ }
+ }
+
+ private void HandleRemove(RemoveCertFromLocalStore msg)
+ {
+ try
+ {
+ var path = Path.Combine(_trustedStoreDir, FileNameFor(msg.Thumbprint));
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ _log.Info("Trusted server certificate {Thumbprint} removed from {Path}", msg.Thumbprint, path);
+ }
+ else
+ {
+ _log.Info("Trusted server certificate {Thumbprint} not present; remove is a no-op", msg.Thumbprint);
+ }
+ Sender.Tell(new LocalCertOpAck(true, null, null));
+ }
+ catch (Exception ex)
+ {
+ _log.Warning(ex, "Failed to remove trusted server certificate {Thumbprint}", msg.Thumbprint);
+ Sender.Tell(new LocalCertOpAck(false, ex.Message, null));
+ }
+ }
+
+ private void HandleList()
+ {
+ try
+ {
+ var certs = new List();
+ certs.AddRange(EnumerateStore(_trustedStoreDir, rejected: false));
+ certs.AddRange(EnumerateStore(_rejectedStoreDir, rejected: true));
+ Sender.Tell(new LocalCertOpAck(true, null, certs));
+ }
+ catch (Exception ex)
+ {
+ _log.Warning(ex, "Failed to list certificates from PKI stores");
+ Sender.Tell(new LocalCertOpAck(false, ex.Message, null));
+ }
+ }
+
+ private IEnumerable EnumerateStore(string storeDir, bool rejected)
+ {
+ if (!Directory.Exists(storeDir))
+ {
+ yield break;
+ }
+
+ foreach (var file in Directory.EnumerateFiles(storeDir)
+ .Where(f => f.EndsWith(".der", StringComparison.OrdinalIgnoreCase)
+ || f.EndsWith(".crt", StringComparison.OrdinalIgnoreCase)))
+ {
+ TrustedCertInfo? info = null;
+ try
+ {
+ var cert = X509CertificateLoader.LoadCertificate(File.ReadAllBytes(file));
+ info = new TrustedCertInfo(
+ cert.Thumbprint,
+ cert.Subject,
+ cert.Issuer,
+ cert.NotBefore.ToUniversalTime(),
+ cert.NotAfter.ToUniversalTime(),
+ rejected);
+ }
+ catch (Exception ex)
+ {
+ // A malformed file in the store should not abort the whole listing.
+ _log.Warning(ex, "Skipping unreadable certificate file {File}", file);
+ }
+
+ if (info is not null)
+ {
+ yield return info;
+ }
+ }
+ }
+
+ private static string FileNameFor(string thumbprint) => $"{thumbprint}.der";
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs
index c33b6360..866bb507 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs
@@ -1,4 +1,5 @@
using Akka.Actor;
+using Akka.Cluster;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
@@ -167,6 +168,15 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Receive(msg =>
Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender));
+ // T17 / D6 — OPC UA server-certificate trust. Trust is site-local: the
+ // trusted-peer PKI store is per-node, so a trust/remove MUST reach BOTH
+ // site nodes (node-a + node-b) or they diverge across failover. This
+ // singleton fans the corresponding per-node message out to the
+ // CertStoreActor on EVERY site node; list is answered from this node.
+ Receive(HandleTrustServerCert);
+ Receive(HandleRemoveServerCert);
+ Receive(HandleListServerCerts);
+
// Internal startup messages
Receive(HandleStartupConfigsLoaded);
Receive(HandleSharedScriptsLoaded);
@@ -701,6 +711,120 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}).PipeTo(sender);
}
+ // ── T17 / D6 — OPC UA server-certificate trust ──
+
+ ///
+ /// The base cluster role every site node carries (in addition to its
+ /// per-site role site-{SiteId}). Used to enumerate the site nodes a
+ /// trust/remove must reach so node-a and node-b PKI stores stay in lock-step
+ /// across failover (D6). Matches the role string set in NodeOptions.Role
+ /// for site hosts (see AkkaHostedService.BuildRoles).
+ ///
+ private const string SiteClusterRole = "Site";
+
+ ///
+ /// The per-node ask timeout for a cert broadcast. A node that does not ack
+ /// within this window is reported as a partial failure (the command is not
+ /// failed wholesale).
+ ///
+ private static readonly TimeSpan CertBroadcastTimeout = TimeSpan.FromSeconds(5);
+
+ ///
+ /// T17 / D6: broadcasts a trust to the per-node
+ /// on every Up site node so both PKI stores receive the certificate.
+ ///
+ private void HandleTrustServerCert(TrustServerCertCommand command) =>
+ BroadcastToSiteCertStores(
+ new WriteCertToLocalStore(command.DerBase64, command.Thumbprint),
+ $"trust cert {command.Thumbprint} (connection {command.ConnectionName})");
+
+ ///
+ /// T17 / D6: broadcasts a remove to the per-node
+ /// on every Up site node so the certificate leaves both PKI stores.
+ ///
+ private void HandleRemoveServerCert(RemoveServerCertCommand command) =>
+ BroadcastToSiteCertStores(
+ new RemoveCertFromLocalStore(command.Thumbprint),
+ $"remove cert {command.Thumbprint}");
+
+ ///
+ /// T17: lists this node's trusted + rejected PKI stores by asking the LOCAL
+ /// (the singleton's own node). The list reflects
+ /// the active node's view; a trust broadcast keeps the standby in sync.
+ ///
+ private void HandleListServerCerts(ListServerCertsCommand command)
+ {
+ var sender = Sender;
+ var local = Context.ActorSelection($"/user/{CertStoreActor.WellKnownName}");
+ local.Ask(new ListLocalCerts(), CertBroadcastTimeout)
+ .ContinueWith(t =>
+ {
+ if (t.IsCompletedSuccessfully)
+ {
+ var ack = t.Result;
+ return new CertTrustResult(ack.Success, ack.Error, ack.Certs);
+ }
+
+ var err = t.Exception?.GetBaseException().Message ?? "cert store did not respond";
+ _logger.LogWarning("Local cert store list failed: {Error}", err);
+ return new CertTrustResult(false, err, null);
+ }).PipeTo(sender);
+ }
+
+ ///
+ /// Fans out to the
+ /// on every Up site node, asks each with a short timeout, and aggregates the
+ /// acks into one . A node that fails to ack is a
+ /// partial failure (Success=false, first error reported) — it never throws,
+ /// so a single unreachable standby cannot stall the singleton.
+ ///
+ private void BroadcastToSiteCertStores(object localMessage, string description)
+ {
+ var sender = Sender;
+ var cluster = Cluster.Get(Context.System);
+ var targets = cluster.State.Members
+ .Where(m => m.Status == MemberStatus.Up && m.HasRole(SiteClusterRole))
+ .Select(m => m.Address)
+ .ToList();
+
+ if (targets.Count == 0)
+ {
+ _logger.LogWarning("No Up site nodes found to {Description}; nothing trusted", description);
+ sender.Tell(new CertTrustResult(false, "no site nodes available", null));
+ return;
+ }
+
+ _logger.LogInformation("Broadcasting cert op to {Count} site node(s): {Description}",
+ targets.Count, description);
+
+ var asks = targets.Select(address =>
+ {
+ var path = new RootActorPath(address) / "user" / CertStoreActor.WellKnownName;
+ return Context.ActorSelection(path)
+ .Ask(localMessage, CertBroadcastTimeout)
+ .ContinueWith(t => t.IsCompletedSuccessfully
+ ? t.Result
+ : new LocalCertOpAck(false,
+ $"node {address} did not ack: {t.Exception?.GetBaseException().Message}",
+ null));
+ }).ToArray();
+
+ Task.WhenAll(asks).ContinueWith(t =>
+ {
+ // ContinueWith on WhenAll over per-task error-trapping completions
+ // never faults, so t.Result is always populated.
+ var acks = t.Result;
+ var allSucceeded = acks.All(a => a.Success);
+ var firstError = acks.FirstOrDefault(a => !a.Success)?.Error;
+ if (!allSucceeded)
+ {
+ _logger.LogWarning("Cert broadcast partial/total failure ({Description}): {Error}",
+ description, firstError);
+ }
+ return new CertTrustResult(allSucceeded, firstError, null);
+ }).PipeTo(sender);
+ }
+
// ── DCL connection management ──
///
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj
index ce9bce60..0ffcb370 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj
@@ -35,6 +35,13 @@
+
+
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/CertStoreActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/CertStoreActorTests.cs
new file mode 100644
index 00000000..216418ce
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/CertStoreActorTests.cs
@@ -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;
+
+///
+/// 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");
+ }
+}