- Use cert management to trust this certificate.
-
+
+
+
+
+ Trusting adds this certificate to every node of the site's
+ trusted-peer store (node-wide), then re-runs Verify.
+
+
+
+
+ An Administrator must trust this certificate (cert management).
+
+
+
+
+ @if (_trustError is { } trustError)
+ {
+
@trustError
+ }
+ @if (_trustSucceeded)
+ {
+
+ ✓ Certificate trusted.
+
+ }
}
}
@@ -322,11 +359,25 @@
private bool _verifying;
private VerifyEndpointResult? _verifyResult;
+ private bool _trusting;
+ private bool _trustSucceeded;
+ private string? _trustError;
- private async Task VerifyEndpoint()
+ private Task VerifyEndpoint() => VerifyEndpoint(clearTrustNotes: true);
+
+ private async Task VerifyEndpoint(bool clearTrustNotes)
{
_verifying = true;
_verifyResult = null;
+ // A fresh (user-initiated) verify supersedes any prior trust outcome — clear
+ // the inline notes so a stale "trusted" / error banner doesn't linger across
+ // probes. The trust-triggered re-verify keeps the success note so the operator
+ // still sees confirmation even if the re-probe also surfaces a (different) cert.
+ if (clearTrustNotes)
+ {
+ _trustSucceeded = false;
+ _trustError = null;
+ }
try
{
_verifyResult = await VerificationService.VerifyAsync(
@@ -343,6 +394,42 @@
}
}
+ // M7 T17: trust the captured untrusted server certificate at every node of the
+ // owning site, then re-run Verify (which should now succeed and clear the cert
+ // panel). Administrator-gated by the AuthorizeView wrapping the button; the
+ // CertManagementService enforces the same role server-side-of-the-trust-boundary.
+ private async Task TrustCert(ServerCertInfo cert)
+ {
+ _trusting = true;
+ _trustError = null;
+ _trustSucceeded = false;
+ try
+ {
+ var result = await CertManagementService.TrustAsync(
+ SiteIdentifier, ConnectionName, cert.DerBase64, cert.Thumbprint);
+ if (result.Success)
+ {
+ _trustSucceeded = true;
+ // Re-run Verify so the editor reflects the now-trusted state (and
+ // clears the cert panel on the expected success). Preserve the
+ // success note (clearTrustNotes:false) so confirmation is shown.
+ await VerifyEndpoint(clearTrustNotes: false);
+ }
+ else
+ {
+ _trustError = result.Error ?? "Failed to trust certificate.";
+ }
+ }
+ catch (Exception ex)
+ {
+ _trustError = ex.Message;
+ }
+ finally
+ {
+ _trusting = false;
+ }
+ }
+
private void EnableHeartbeat() =>
Config.Heartbeat = new OpcUaHeartbeatConfig();
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ConnectionCertificates.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ConnectionCertificates.razor
new file mode 100644
index 00000000..20f56960
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ConnectionCertificates.razor
@@ -0,0 +1,183 @@
+@page "/design/connections/{Id:int}/certificates"
+@using ZB.MOM.WW.ScadaBridge.Security
+@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
+@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
+@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
+@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
+@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
+@inject ISiteRepository SiteRepository
+@inject ICertManagementService CertManagementService
+@inject NavigationManager NavigationManager
+
+
+
+
+
Server Certificates@(string.IsNullOrEmpty(_connectionName) ? "" : $" — {_connectionName}")
+
+
+
+ The trusted-peer certificate store is node-wide for the site
+ (shared by every site node), not per data connection. Trusting or removing a
+ certificate affects all OPC UA connections at this site.
+
+
+ @if (_loading)
+ {
+
+ }
+ else if (_loadError is { } loadError)
+ {
+
+ View, trust, and remove OPC UA server certificates in the
+ site's node-wide trusted-peer store.
+
+
+ }
+
Primary endpoint
@if (_protocol == "MxGateway")
{
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs
index 2b32d780..e22484e7 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs
@@ -78,6 +78,14 @@ public static class ServiceCollectionExtensions
// on the OPC UA endpoint editor (read-only connect probe, never trusts certs).
services.AddScoped();
+ // OPC UA Cert Management (M7 T17 / D6): facade over the three
+ // CommunicationService cert-trust relay methods. Enforces the CentralUI-side
+ // role trust boundary (D7: Trust + Remove require Administrator, List requires
+ // Designer) and translates transport failures into typed CertTrustResults.
+ // Backs the "Trust certificate" button on the OPC UA endpoint editor and the
+ // connection-certificates management page (node-wide site PKI store).
+ services.AddScoped();
+
// Test Bindings: facade over CommunicationService.ReadTagValuesAsync —
// same Design-role guard + typed-failure translation as the browse
// service. Backs the Test Bindings dialog on the Configure Instance
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CertManagementService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CertManagementService.cs
new file mode 100644
index 00000000..4e06e845
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CertManagementService.cs
@@ -0,0 +1,148 @@
+using Microsoft.AspNetCore.Components.Authorization;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
+using ZB.MOM.WW.ScadaBridge.Communication;
+using ZB.MOM.WW.ScadaBridge.Security;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
+
+///
+/// Default implementation — a thin facade over
+/// the three cert-trust relay methods that enforces
+/// the CentralUI-side role trust boundary (Decision D7: Trust + Remove require
+/// Administrator, List requires Designer), and translates transport
+/// exceptions into a typed .
+///
+///
+/// Site-side actors (SiteCommunicationActor + DeploymentManagerActor) do
+/// not unwrap the central trust envelope, so the role check MUST run here — never on
+/// the site (mirrors and ).
+/// On an unauthorized caller the method returns a non-success
+/// with "Not authorized." rather than throwing;
+/// transport failures (timeouts, unreachable sites) collapse into a non-success result
+/// so the editor / page can render an inline outcome.
+///
+public sealed class CertManagementService : ICertManagementService
+{
+ private readonly CommunicationService _communication;
+ private readonly AuthenticationStateProvider _auth;
+
+ ///
+ /// Initializes a new instance of the .
+ ///
+ /// Central-side cluster communication service.
+ /// Authentication state provider used for the role guards.
+ public CertManagementService(CommunicationService communication, AuthenticationStateProvider auth)
+ {
+ _communication = communication ?? throw new ArgumentNullException(nameof(communication));
+ _auth = auth ?? throw new ArgumentNullException(nameof(auth));
+ }
+
+ ///
+ public async Task TrustAsync(
+ string siteIdentifier,
+ string connectionName,
+ string derBase64,
+ string thumbprint,
+ CancellationToken cancellationToken = default)
+ {
+ // D7: trusting a server certificate mutates every site node's PKI store, so
+ // it is an Administrator-only action. The site does not enforce envelope-level
+ // roles, so this check must happen here before any cross-cluster traffic.
+ if (!await HasRoleAsync(Roles.Administrator))
+ {
+ return new CertTrustResult(false, "Not authorized.", null);
+ }
+
+ try
+ {
+ return await _communication.TrustServerCertAsync(
+ siteIdentifier,
+ new TrustServerCertCommand(connectionName, derBase64, thumbprint),
+ cancellationToken);
+ }
+ catch (TimeoutException ex)
+ {
+ return new CertTrustResult(false, ex.Message, null);
+ }
+ catch (OperationCanceledException)
+ {
+ // Caller-initiated cancel — propagate so Blazor can drop the response
+ // cleanly. Distinct from Timeout (which the UI renders inline).
+ throw;
+ }
+ catch (Exception ex)
+ {
+ return new CertTrustResult(false, ex.Message, null);
+ }
+ }
+
+ ///
+ public async Task ListAsync(
+ string siteIdentifier,
+ CancellationToken cancellationToken = default)
+ {
+ // D7: listing trusted certs is read-only, so the lower Designer bar applies
+ // (an Administrator also satisfies this because admins hold every role claim
+ // by convention). Same CentralUI-side guard rationale as TrustAsync.
+ if (!await HasRoleAsync(Roles.Designer))
+ {
+ return new CertTrustResult(false, "Not authorized.", null);
+ }
+
+ try
+ {
+ return await _communication.ListServerCertsAsync(
+ siteIdentifier, new ListServerCertsCommand(), cancellationToken);
+ }
+ catch (TimeoutException ex)
+ {
+ return new CertTrustResult(false, ex.Message, null);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ return new CertTrustResult(false, ex.Message, null);
+ }
+ }
+
+ ///
+ public async Task RemoveAsync(
+ string siteIdentifier,
+ string thumbprint,
+ CancellationToken cancellationToken = default)
+ {
+ // D7: removing trust mutates every site node's PKI store, so it is an
+ // Administrator-only action — same gate as TrustAsync.
+ if (!await HasRoleAsync(Roles.Administrator))
+ {
+ return new CertTrustResult(false, "Not authorized.", null);
+ }
+
+ try
+ {
+ return await _communication.RemoveServerCertAsync(
+ siteIdentifier, new RemoveServerCertCommand(thumbprint), cancellationToken);
+ }
+ catch (TimeoutException ex)
+ {
+ return new CertTrustResult(false, ex.Message, null);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ return new CertTrustResult(false, ex.Message, null);
+ }
+ }
+
+ private async Task HasRoleAsync(string role)
+ {
+ var state = await _auth.GetAuthenticationStateAsync();
+ return state.User.HasClaim(JwtTokenService.RoleClaimType, role);
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ICertManagementService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ICertManagementService.cs
new file mode 100644
index 00000000..0d759ce1
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ICertManagementService.cs
@@ -0,0 +1,74 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
+
+///
+/// CentralUI facade over the central-to-site OPC UA server-certificate trust
+/// commands (T17 / D6). Backs the "Trust certificate" affordance on the OPC UA
+/// endpoint editor and the dedicated connection-certificates management page: it
+/// forwards /
+/// / to the owning site via
+/// , which routes
+/// them to the site Deployment Manager singleton.
+///
+///
+/// The service is the trust boundary for the cert-management capability: site-side
+/// actors do not unwrap the central trust envelope, so the role check MUST run here
+/// before any cross-cluster traffic is generated (mirrors
+/// and ). Per Decision D7: Trust + Remove
+/// require the Administrator role; List requires the Designer role.
+/// Transport failures (timeouts, unreachable sites) are translated into a typed
+/// so callers can render an inline outcome rather than
+/// throwing.
+///
+///
+/// The trusted-peer store is NODE-WIDE per site node (not per connection); a
+/// trust/remove decision is broadcast by the site Deployment Manager to every site
+/// node's CertStoreActor, and a list answers from the singleton's own node.
+///
+///
+public interface ICertManagementService
+{
+ ///
+ /// Trusts an OPC UA server certificate at every node of the owning site
+ /// (Administrator-gated). The captured DER bytes are written into each node's
+ /// trusted-peer PKI store under the thumbprint filename key.
+ ///
+ /// The target site identifier (the machine-readable SiteIdentifier used in Akka addresses, NOT the numeric primary 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.
+ /// Cancellation token.
+ /// A task that resolves to a — success, or a classified failure (unauthorized / timeout / transport error).
+ Task TrustAsync(
+ string siteIdentifier,
+ string connectionName,
+ string derBase64,
+ string thumbprint,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists the certificates currently present in the owning site's trusted-peer
+ /// and rejected PKI stores (Designer-gated). Answered from the site Deployment
+ /// Manager singleton's own node.
+ ///
+ /// The target site identifier.
+ /// Cancellation token.
+ /// A task that resolves to a carrying the listed certificates on success.
+ Task ListAsync(
+ string siteIdentifier,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Removes a previously-trusted OPC UA server certificate from every node of
+ /// the owning site (Administrator-gated), identified by thumbprint.
+ ///
+ /// The target site identifier.
+ /// The thumbprint of the certificate to remove.
+ /// Cancellation token.
+ /// A task that resolves to a — success, or a classified failure.
+ Task RemoveAsync(
+ string siteIdentifier,
+ string thumbprint,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs
index 8cab30db..d1913029 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs
@@ -161,6 +161,18 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
// children holding the live OPC UA sessions.
Receive(msg => _deploymentManagerProxy.Forward(msg));
+ // OPC UA server-certificate trust management (T17 / D6) — forward to the
+ // Deployment Manager singleton, which owns the cross-node trust broadcast.
+ // The trusted-peer PKI store is node-wide per site node, so a trust/remove
+ // decision must reach BOTH nodes' CertStoreActor; the singleton broadcasts
+ // to every site node (list answers from the singleton's own node). The
+ // singleton always lands on the active node, the same routing rationale as
+ // BrowseNodeCommand above. Forward preserves the central Ask sender so the
+ // CertTrustResult routes straight back to the waiting Ask.
+ Receive(msg => _deploymentManagerProxy.Forward(msg));
+ Receive(msg => _deploymentManagerProxy.Forward(msg));
+ Receive(msg => _deploymentManagerProxy.Forward(msg));
+
// Pattern 7: Remote Queries
Receive(msg =>
{
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs
index 55fd8014..f502e6a2 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs
@@ -416,6 +416,73 @@ public class CommunicationService
envelope, _options.QueryTimeout, cancellationToken);
}
+ // ── OPC UA Server-Certificate Trust (T17 / D6 — node-wide site PKI store) ──
+
+ ///
+ /// Asks the owning site to trust an OPC UA server certificate at every site
+ /// node. Backs the CentralUI cert-management Trust action. The site Deployment
+ /// Manager singleton broadcasts the captured DER bytes to every site node's
+ /// CertStoreActor; the result reflects whether every reachable node
+ /// acked. The Ask is bounded by ,
+ /// mirroring and the other interactive
+ /// design-time site queries.
+ ///
+ /// The target site identifier.
+ /// The trust-server-cert command (connection name + DER + thumbprint).
+ /// Cancellation token.
+ /// The cert-trust result (per-node aggregate success + first error).
+ public Task TrustServerCertAsync(
+ string siteId,
+ TrustServerCertCommand command,
+ CancellationToken cancellationToken = default)
+ {
+ var envelope = new SiteEnvelope(siteId, command);
+ return GetActor().Ask(
+ envelope, _options.QueryTimeout, cancellationToken);
+ }
+
+ ///
+ /// Asks the owning site to list the certificates in its trusted-peer and
+ /// rejected PKI stores. Backs the CentralUI cert-management list page. Answered
+ /// by the site Deployment Manager singleton from its own node (the store is
+ /// node-wide per site node). The Ask is bounded by
+ /// .
+ ///
+ /// The target site identifier.
+ /// The list-server-certs command.
+ /// Cancellation token.
+ /// The cert-trust result carrying the listed certificates on success.
+ public Task ListServerCertsAsync(
+ string siteId,
+ ListServerCertsCommand command,
+ CancellationToken cancellationToken = default)
+ {
+ var envelope = new SiteEnvelope(siteId, command);
+ return GetActor().Ask(
+ envelope, _options.QueryTimeout, cancellationToken);
+ }
+
+ ///
+ /// Asks the owning site to remove a previously-trusted OPC UA server
+ /// certificate from every site node, identified by thumbprint. Backs the
+ /// CentralUI cert-management Remove action. The site Deployment Manager
+ /// singleton broadcasts the removal to every site node's CertStoreActor.
+ /// The Ask is bounded by .
+ ///
+ /// The target site identifier.
+ /// The remove-server-cert command (thumbprint).
+ /// Cancellation token.
+ /// The cert-trust result (per-node aggregate success + first error).
+ public Task RemoveServerCertAsync(
+ string siteId,
+ RemoveServerCertCommand command,
+ CancellationToken cancellationToken = default)
+ {
+ var envelope = new SiteEnvelope(siteId, command);
+ return GetActor().Ask(
+ envelope, _options.QueryTimeout, cancellationToken);
+ }
+
// ── Test Bindings (one-shot live read of bound tags) ──
///
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/ConnectionCertificatesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/ConnectionCertificatesTests.cs
new file mode 100644
index 00000000..64c0ab15
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/ConnectionCertificatesTests.cs
@@ -0,0 +1,144 @@
+using System.Security.Claims;
+using Bunit;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
+using ZB.MOM.WW.ScadaBridge.Security;
+using ConnectionCertificates =
+ ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.ConnectionCertificates;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components;
+
+///
+/// Covers the M7-B10 (T17) connection-certificates management page: an
+/// Administrator-gated page that resolves a data connection's owning site, lists
+/// the site's trusted-peer / rejected certificates via
+/// , and removes a certificate via
+/// (the store is node-wide per
+/// site node).
+///
+public class ConnectionCertificatesTests : BunitContext
+{
+ private readonly ICertManagementService _certs = Substitute.For();
+ private readonly ISiteRepository _siteRepo = Substitute.For();
+
+ private const int ConnectionId = 7;
+ private const string SiteIdentifier = "plant-a";
+
+ public ConnectionCertificatesTests()
+ {
+ Services.AddSingleton(_certs);
+ Services.AddSingleton(_siteRepo);
+
+ // The page resolves the connection → owning site so the cert relay targets
+ // the right site identifier (the trusted-peer store is node-wide per site node).
+ var connection = new DataConnection("PLC-OPC", "OpcUa", 1) { Id = ConnectionId };
+ _siteRepo.GetDataConnectionByIdAsync(ConnectionId, Arg.Any())
+ .Returns(Task.FromResult(connection));
+ _siteRepo.GetSiteByIdAsync(1, Arg.Any())
+ .Returns(Task.FromResult(new Site("Plant-A", SiteIdentifier) { Id = 1 }));
+
+ UseRoles(Roles.Administrator);
+ }
+
+ private static ClaimsPrincipal BuildPrincipal(params string[] roles)
+ {
+ var claims = new List { new(JwtTokenService.UsernameClaimType, "tester") };
+ claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
+ return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
+ }
+
+ private void UseRoles(params string[] roles)
+ {
+ Services.AddSingleton(
+ new TestAuthStateProvider(BuildPrincipal(roles)));
+ Services.AddAuthorizationCore();
+ AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
+ Services.AddSingleton();
+ }
+
+ private static TrustedCertInfo Cert(string thumbprint, bool rejected = false) => new(
+ Thumbprint: thumbprint,
+ Subject: $"CN={thumbprint}",
+ Issuer: "CN=ca",
+ NotBeforeUtc: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
+ NotAfterUtc: new DateTime(2027, 1, 1, 0, 0, 0, DateTimeKind.Utc),
+ Rejected: rejected);
+
+ private IRenderedComponent RenderPage()
+ {
+ var host = Render(parameters => parameters
+ .Add(p => p.ChildContent, (RenderFragment)(builder =>
+ {
+ builder.OpenComponent(0);
+ builder.AddAttribute(1, nameof(ConnectionCertificates.Id), ConnectionId);
+ builder.CloseComponent();
+ })));
+ return host.FindComponent();
+ }
+
+ [Fact]
+ public void Lists_Two_Certs_From_Owning_Site()
+ {
+ _certs.ListAsync(SiteIdentifier, Arg.Any())
+ .Returns(new CertTrustResult(true, null, new[]
+ {
+ Cert("AAAA1111"),
+ Cert("BBBB2222", rejected: true),
+ }));
+
+ var cut = RenderPage();
+
+ Assert.NotEmpty(cut.FindAll("[data-test=connection-certificates]"));
+ var rows = cut.FindAll("[data-test=cert-row]");
+ Assert.Equal(2, rows.Count);
+ Assert.Contains("AAAA1111", cut.Markup);
+ Assert.Contains("BBBB2222", cut.Markup);
+ // List was resolved against the connection's owning site identifier.
+ _certs.Received(1).ListAsync(SiteIdentifier, Arg.Any());
+ }
+
+ [Fact]
+ public void Remove_Calls_RemoveAsync_With_SiteIdentifier_And_Thumbprint()
+ {
+ _certs.ListAsync(SiteIdentifier, Arg.Any())
+ .Returns(new CertTrustResult(true, null, new[] { Cert("AAAA1111") }));
+ _certs.RemoveAsync(SiteIdentifier, "AAAA1111", Arg.Any())
+ .Returns(new CertTrustResult(true, null, null));
+
+ var cut = RenderPage();
+ cut.Find("[data-test=cert-remove-btn]").Click();
+
+ _certs.Received(1).RemoveAsync(SiteIdentifier, "AAAA1111", Arg.Any());
+ }
+
+ [Fact]
+ public void Empty_Store_Shows_Empty_Note()
+ {
+ _certs.ListAsync(SiteIdentifier, Arg.Any())
+ .Returns(new CertTrustResult(true, null, Array.Empty()));
+
+ var cut = RenderPage();
+
+ Assert.NotEmpty(cut.FindAll("[data-test=cert-empty]"));
+ Assert.Empty(cut.FindAll("[data-test=cert-row]"));
+ }
+
+ [Fact]
+ public void List_Failure_Shows_Load_Error()
+ {
+ _certs.ListAsync(SiteIdentifier, Arg.Any())
+ .Returns(new CertTrustResult(false, "Site unreachable.", null));
+
+ var cut = RenderPage();
+
+ var error = cut.Find("[data-test=cert-load-error]");
+ Assert.Contains("Site unreachable.", error.TextContent);
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/OpcUaEndpointVerifyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/OpcUaEndpointVerifyTests.cs
index 407f15f3..c4b07f5d 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/OpcUaEndpointVerifyTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/OpcUaEndpointVerifyTests.cs
@@ -1,10 +1,14 @@
+using System.Security.Claims;
using Bunit;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Forms;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
+using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components;
@@ -15,23 +19,71 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components;
/// renders the typed — green on success, a red
/// failure line otherwise, and a read-only certificate panel when the failure is
/// with a captured cert.
+///
+///
+/// Also covers the M7-B10 (T17) "Trust certificate" affordance on the untrusted-cert
+/// panel: an Administrator-gated button that calls
+/// and re-runs Verify on success.
+///
///
public class OpcUaEndpointVerifyTests : BunitContext
{
private readonly IEndpointVerificationService _verify =
Substitute.For();
+ private readonly ICertManagementService _certs =
+ Substitute.For();
+
public OpcUaEndpointVerifyTests()
{
Services.AddSingleton(_verify);
+ Services.AddSingleton(_certs);
}
- private IRenderedComponent RenderEditor() =>
- Render(p => p
- .Add(c => c.Config, new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://host:4840" })
- .Add(c => c.SiteIdentifier, "plant-a")
- .Add(c => c.ConnectionName, "PLC-OPC")
- .Add(c => c.Protocol, "OpcUa"));
+ private static ClaimsPrincipal BuildPrincipal(params string[] roles)
+ {
+ var claims = new List { new(JwtTokenService.UsernameClaimType, "tester") };
+ claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
+ return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
+ }
+
+ private bool _authConfigured;
+
+ private void UseRoles(params string[] roles)
+ {
+ Services.AddSingleton(
+ new TestAuthStateProvider(BuildPrincipal(roles)));
+ Services.AddAuthorizationCore();
+ AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
+ Services.AddSingleton();
+ _authConfigured = true;
+ }
+
+ // The editor's untrusted-cert panel hosts an AuthorizeView (RequireAdmin) around
+ // the Trust button, so the editor must render inside a CascadingAuthenticationState
+ // with an auth provider available. Tests that don't call UseRoles get a default
+ // Designer principal (can verify + see the cert panel, but NOT the Trust button).
+ private IRenderedComponent RenderEditor()
+ {
+ if (!_authConfigured)
+ {
+ UseRoles(Roles.Designer);
+ }
+
+ var host = Render(parameters => parameters
+ .Add(p => p.ChildContent, (Microsoft.AspNetCore.Components.RenderFragment)(builder =>
+ {
+ builder.OpenComponent(0);
+ builder.AddAttribute(1, nameof(OpcUaEndpointEditor.Config),
+ new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://host:4840" });
+ builder.AddAttribute(2, nameof(OpcUaEndpointEditor.SiteIdentifier), "plant-a");
+ builder.AddAttribute(3, nameof(OpcUaEndpointEditor.ConnectionName), "PLC-OPC");
+ builder.AddAttribute(4, nameof(OpcUaEndpointEditor.Protocol), "OpcUa");
+ builder.CloseComponent();
+ })));
+
+ return host.FindComponent();
+ }
[Fact]
public void Verify_Success_ShowsSuccessMessage()
@@ -92,4 +144,55 @@ public class OpcUaEndpointVerifyTests : BunitContext
Assert.Empty(cut.FindAll("[data-test=verify-success]"));
Assert.Empty(cut.FindAll("[data-test=verify-cert-panel]"));
}
+
+ private static ServerCertInfo SampleCert() => new(
+ Thumbprint: "ABCDEF0123456789",
+ Subject: "CN=opc-server",
+ Issuer: "CN=opc-server",
+ NotBeforeUtc: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
+ NotAfterUtc: new DateTime(2027, 1, 1, 0, 0, 0, DateTimeKind.Utc),
+ DerBase64: "ZGVy");
+
+ private void ArrangeUntrustedVerify(ServerCertInfo cert) =>
+ _verify.VerifyAsync(
+ Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any())
+ .Returns(new VerifyEndpointResult(
+ false, VerifyFailureKind.UntrustedCertificate, "Untrusted certificate.", cert));
+
+ [Fact]
+ public void TrustButton_Admin_SeesButton_AndClick_CallsTrustAsync()
+ {
+ UseRoles(Roles.Administrator);
+ var cert = SampleCert();
+ ArrangeUntrustedVerify(cert);
+ _certs.TrustAsync(
+ Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any())
+ .Returns(new CertTrustResult(true, null, null));
+
+ var cut = RenderEditor();
+ cut.Find("[data-test=verify-endpoint-btn]").Click();
+
+ // Admin sees the Trust button on the untrusted-cert panel.
+ var trustBtn = cut.Find("[data-test=trust-cert-btn]");
+ trustBtn.Click();
+
+ _certs.Received(1).TrustAsync(
+ "plant-a", "PLC-OPC", cert.DerBase64, cert.Thumbprint, Arg.Any());
+ }
+
+ [Fact]
+ public void TrustButton_NonAdmin_DesignerOnly_DoesNotSeeButton()
+ {
+ UseRoles(Roles.Designer);
+ ArrangeUntrustedVerify(SampleCert());
+
+ var cut = RenderEditor();
+ cut.Find("[data-test=verify-endpoint-btn]").Click();
+
+ // The cert panel is shown, but a Designer (non-Admin) does not get the Trust button.
+ Assert.NotEmpty(cut.FindAll("[data-test=verify-cert-panel]"));
+ Assert.Empty(cut.FindAll("[data-test=trust-cert-btn]"));
+ }
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs
index 064f613e..e8ed1029 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs
@@ -21,8 +21,10 @@ public class DataConnectionFormTests : BunitContext
{
Services.AddSingleton(_siteRepo);
// The OPC UA editor rendered inside the form injects IEndpointVerificationService
- // (B8 / T17 Verify-endpoint button); a no-op substitute satisfies the injection.
+ // (B8 / T17 Verify-endpoint button) and ICertManagementService (B10 / T17
+ // Trust-certificate button); no-op substitutes satisfy the injections.
Services.AddSingleton(Substitute.For());
+ Services.AddSingleton(Substitute.For());
AddTestAuth();
var sites = new List { new("Plant-A", "plant-a") { Id = 1 } };
_siteRepo.GetAllSitesAsync(Arg.Any())
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Forms/OpcUaEndpointEditorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Forms/OpcUaEndpointEditorTests.cs
index 970e1bd7..2cd1def9 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Forms/OpcUaEndpointEditorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Forms/OpcUaEndpointEditorTests.cs
@@ -14,9 +14,11 @@ public class OpcUaEndpointEditorTests : BunitContext
public OpcUaEndpointEditorTests()
{
// OpcUaEndpointEditor injects IEndpointVerificationService for its Verify-endpoint
- // button (B8 / T17). These tests only exercise the form bindings, so a no-op
- // substitute satisfies the injection without driving verification.
+ // button (B8 / T17) and ICertManagementService for the Trust-certificate button
+ // (B10 / T17). These tests only exercise the form bindings, so no-op substitutes
+ // satisfy the injections without driving verification or trust.
Services.AddSingleton(Substitute.For());
+ Services.AddSingleton(Substitute.For());
}
[Fact]