From 384204b71aa822bbd089b30db523a295f110c10a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 03:53:32 -0400 Subject: [PATCH] feat(centralui): cert-management UI + Trust action + site relay (T17) --- .../Forms/OpcUaEndpointEditor.razor | 95 ++++++++- .../Pages/Design/ConnectionCertificates.razor | 183 ++++++++++++++++++ .../Pages/Design/DataConnectionForm.razor | 15 ++ .../ServiceCollectionExtensions.cs | 8 + .../Services/CertManagementService.cs | 148 ++++++++++++++ .../Services/ICertManagementService.cs | 74 +++++++ .../Actors/SiteCommunicationActor.cs | 12 ++ .../CommunicationService.cs | 67 +++++++ .../Components/ConnectionCertificatesTests.cs | 144 ++++++++++++++ .../Components/OpcUaEndpointVerifyTests.cs | 115 ++++++++++- .../DataConnectionFormTests.cs | 4 +- .../Forms/OpcUaEndpointEditorTests.cs | 6 +- 12 files changed, 858 insertions(+), 13 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ConnectionCertificates.razor create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CertManagementService.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ICertManagementService.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/ConnectionCertificatesTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Forms/OpcUaEndpointEditor.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Forms/OpcUaEndpointEditor.razor index 23e4fbcf..6d53e9a7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Forms/OpcUaEndpointEditor.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Forms/OpcUaEndpointEditor.razor @@ -3,7 +3,9 @@ @using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management @using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections @using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening +@using ZB.MOM.WW.ScadaBridge.Security @inject IEndpointVerificationService VerificationService +@inject ICertManagementService CertManagementService
@Title
@@ -91,9 +93,44 @@
Not after
@cert.NotAfterUtc.ToString("u")
-
- 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) + { +
@loadError
+ } + else + { + @if (_actionError is { } actionError) + { +
@actionError
+ } + + @if (_certs.Count == 0) + { +
+ No certificates are present in this site's trusted-peer or rejected stores. +
+ } + else + { + + + + + + + + + + + + + + @foreach (var cert in _certs) + { + + + + + + + + + + } + +
SubjectIssuerThumbprintNot beforeNot afterStatus
@cert.Subject@cert.Issuer@cert.Thumbprint@cert.NotBeforeUtc.ToString("u")@cert.NotAfterUtc.ToString("u") + @if (cert.Rejected) + { + Rejected + } + else + { + Trusted + } + + +
+ } + } +
+ +@code { + [Parameter] public int Id { get; set; } + + private bool _loading = true; + private bool _removing; + private string? _loadError; + private string? _actionError; + private string _connectionName = string.Empty; + private string _siteIdentifier = string.Empty; + private IReadOnlyList _certs = Array.Empty(); + + protected override async Task OnInitializedAsync() + { + try + { + // Resolve the connection's owning site so the cert relay targets the + // right site (the trusted-peer store is node-wide PER SITE node). + var connection = await SiteRepository.GetDataConnectionByIdAsync(Id); + if (connection is null) + { + _loadError = $"Data connection {Id} not found."; + return; + } + + _connectionName = connection.Name; + var site = await SiteRepository.GetSiteByIdAsync(connection.SiteId); + if (site is null) + { + _loadError = $"Site {connection.SiteId} not found."; + return; + } + + _siteIdentifier = site.SiteIdentifier; + await LoadCertsAsync(); + } + catch (Exception ex) + { + _loadError = $"Failed to load: {ex.Message}"; + } + finally + { + _loading = false; + } + } + + private async Task LoadCertsAsync() + { + var result = await CertManagementService.ListAsync(_siteIdentifier); + if (result.Success) + { + _certs = result.Certs ?? Array.Empty(); + _loadError = null; + } + else + { + _loadError = result.Error ?? "Failed to list certificates."; + } + } + + private async Task RemoveCert(string thumbprint) + { + _removing = true; + _actionError = null; + try + { + var result = await CertManagementService.RemoveAsync(_siteIdentifier, thumbprint); + if (result.Success) + { + // Re-list so the table reflects the removal (the store is node-wide, + // so the authoritative list comes back from the site, not local state). + await LoadCertsAsync(); + } + else + { + _actionError = result.Error ?? "Failed to remove certificate."; + } + } + catch (Exception ex) + { + _actionError = ex.Message; + } + finally + { + _removing = false; + } + } + + private void GoBack() => + NavigationManager.NavigateTo($"/design/connections/{Id}/edit"); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnectionForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnectionForm.razor index f3a4869d..7c6b0c7d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnectionForm.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnectionForm.razor @@ -72,6 +72,21 @@ + @if (Id.HasValue && _protocol == "OpcUa") + { +
+ + Manage certificates + +
+ 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]