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 645e8666..23e4fbcf 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Forms/OpcUaEndpointEditor.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Forms/OpcUaEndpointEditor.razor @@ -1,6 +1,9 @@ @namespace 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.Commons.Types.Flattening +@inject IEndpointVerificationService VerificationService
@Title
@@ -40,6 +43,62 @@
+
+ + + @if (_verifyResult is { } result) + { + @if (result.Success) + { + + ✓ Endpoint reachable + + } + else + { + + @result.FailureKind: @result.Error + + } + + @if (result.FailureKind == VerifyFailureKind.UntrustedCertificate + && result.Cert is { } cert) + { +
+
Untrusted server certificate
+
+
Subject
+
@cert.Subject
+
Issuer
+
@cert.Issuer
+
Thumbprint
+
@cert.Thumbprint
+
Not before
+
@cert.NotBeforeUtc.ToString("u")
+
Not after
+
@cert.NotAfterUtc.ToString("u")
+
+
+ Use cert management to trust this certificate. +
+
+ } + } +
+
Authentication
@if (Config.UserIdentity is null) { @@ -253,6 +312,37 @@ [Parameter] public bool IsLegacy { get; set; } [Parameter] public ValidationResult? Errors { get; set; } + // Verify-endpoint context (M7 T17): the site + connection identity the verify + // probe targets. Supplied by DataConnectionForm (_formSiteId → SiteIdentifier, + // _formName, _protocol). When SiteIdentifier is blank the connection has not been + // assigned a site yet, so verification is unavailable. + [Parameter] public string SiteIdentifier { get; set; } = string.Empty; + [Parameter] public string ConnectionName { get; set; } = string.Empty; + [Parameter] public string Protocol { get; set; } = "OpcUa"; + + private bool _verifying; + private VerifyEndpointResult? _verifyResult; + + private async Task VerifyEndpoint() + { + _verifying = true; + _verifyResult = null; + try + { + _verifyResult = await VerificationService.VerifyAsync( + SiteIdentifier, ConnectionName, Protocol, Config); + } + catch (Exception ex) + { + _verifyResult = new VerifyEndpointResult( + false, VerifyFailureKind.ServerError, ex.Message, null); + } + finally + { + _verifying = false; + } + } + private void EnableHeartbeat() => Config.Heartbeat = new OpcUaHeartbeatConfig(); 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 a96df290..f3a4869d 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 @@ -86,6 +86,9 @@ IdPrefix="primary" Config="_primaryConfig" IsLegacy="_primaryIsLegacy" + SiteIdentifier="@_formSiteIdentifier" + ConnectionName="@_formName" + Protocol="@_protocol" Errors="_primaryErrors" /> } @@ -118,6 +121,9 @@ IdPrefix="backup" Config="_backupConfig" IsLegacy="_backupIsLegacy" + SiteIdentifier="@_formSiteIdentifier" + ConnectionName="@_formName" + Protocol="@_protocol" Errors="_backupErrors" /> }
@@ -170,6 +176,13 @@ private ValidationResult? _backupErrors; private string? _formError; + // The machine-readable site identifier (used in Akka addresses) for the currently + // selected site — resolved from _formSiteId (the numeric primary key) so the + // endpoint editor's Verify probe can target the owning site. Blank until a site is + // chosen, which disables verification in the editor. + private string _formSiteIdentifier => + _sites.FirstOrDefault(s => s.Id == _formSiteId)?.SiteIdentifier ?? string.Empty; + protected override async Task OnInitializedAsync() { try diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs index fd018889..58a02dcc 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs @@ -71,6 +71,13 @@ public static class ServiceCollectionExtensions // transport failures into typed BrowseFailure results for the dialog. services.AddScoped(); + // Verify Endpoint (M7 T17): facade over CommunicationService.VerifyEndpointAsync + // that enforces the same CentralUI-side Design-role trust boundary as the browse + // service, serializes the in-progress endpoint config, and translates transport + // failures into typed VerifyEndpointResults. Backs the "Verify endpoint" button + // on the OPC UA endpoint editor (read-only connect probe, never trusts certs). + 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/EndpointVerificationService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/EndpointVerificationService.cs new file mode 100644 index 00000000..9c3858d3 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/EndpointVerificationService.cs @@ -0,0 +1,86 @@ +using Microsoft.AspNetCore.Components.Authorization; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Commons.Serialization; +using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections; +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 that enforces the +/// CentralUI-side Design-role trust boundary, serializes the endpoint config, +/// and translates transport exceptions into a typed +/// . +/// +/// +/// Site-side actors (SiteCommunicationActor + DataConnectionManagerActor) +/// do not unwrap the central trust envelope, so the role check MUST run here — never +/// on the site (mirrors ). Transport failures collapse into +/// a or +/// result so the editor can show an inline outcome rather than throwing. +/// +public sealed class EndpointVerificationService : IEndpointVerificationService +{ + 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 Design-role guard. + public EndpointVerificationService(CommunicationService communication, AuthenticationStateProvider auth) + { + _communication = communication ?? throw new ArgumentNullException(nameof(communication)); + _auth = auth ?? throw new ArgumentNullException(nameof(auth)); + } + + /// + public async Task VerifyAsync( + string siteIdentifier, + string connectionName, + string protocol, + OpcUaEndpointConfig config, + CancellationToken cancellationToken = default) + { + // CentralUI-side role guard — sites don't enforce envelope-level roles, so the + // Designer check must happen here before any cross-cluster traffic. + var state = await _auth.GetAuthenticationStateAsync(); + if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer)) + { + return new VerifyEndpointResult( + false, VerifyFailureKind.ServerError, "Not authorized.", null); + } + + var configJson = OpcUaEndpointConfigSerializer.Serialize(config); + var command = new VerifyEndpointCommand(connectionName, protocol, configJson); + + try + { + return await _communication.VerifyEndpointAsync(siteIdentifier, command, cancellationToken); + } + catch (TimeoutException ex) + { + // Akka Ask timed out — the site (or its OPC UA connect probe) didn't answer + // within CommunicationOptions.QueryTimeout. Surface as a typed Timeout + // failure so the editor can render the outcome inline. + return new VerifyEndpointResult( + false, VerifyFailureKind.Timeout, ex.Message, null); + } + catch (OperationCanceledException) + { + // Caller-initiated cancel — propagate so Blazor can drop the response + // cleanly. Distinct from Timeout (which the editor renders inline). + throw; + } + catch (Exception ex) + { + // Any other transport / serialization failure: keep the editor alive and + // show the failure inline rather than crashing the form. + return new VerifyEndpointResult( + false, VerifyFailureKind.ServerError, ex.Message, null); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IEndpointVerificationService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IEndpointVerificationService.cs new file mode 100644 index 00000000..cec65e42 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IEndpointVerificationService.cs @@ -0,0 +1,43 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// +/// CentralUI facade over the central-to-site verify-endpoint command. Backs the +/// "Verify endpoint" button on the OPC UA endpoint editor: it serializes the +/// in-progress endpoint config and forwards a +/// to the owning site via +/// , which +/// runs a read-only connect probe and reports back a typed +/// . +/// +/// +/// The service is the trust boundary for the verify capability: it enforces the +/// Design role at central before any cross-cluster traffic is generated, +/// because site-side actors do not unwrap the central trust envelope (mirrors +/// ). Transport failures (timeouts, unreachable sites) +/// are translated into a typed so the editor can +/// render an inline outcome rather than throwing. +/// +public interface IEndpointVerificationService +{ + /// + /// Runs a read-only verification probe of against the + /// live server at . The probe connects, captures + /// the server certificate if it is untrusted, then disconnects — it never persists + /// the config and never trusts the certificate (cert trust is a later action). + /// + /// The target site identifier (the machine-readable SiteIdentifier used in Akka addresses, NOT the numeric primary key). + /// Name of the data connection being verified (for logging/correlation). + /// Protocol type string (e.g. "OpcUa"); matched case-insensitively at the site. + /// The endpoint configuration to verify; serialized to JSON before being sent to the site. + /// Cancellation token. + /// A task that resolves to a — success, or a classified failure (with a captured certificate when the failure is ). + Task VerifyAsync( + string siteIdentifier, + string connectionName, + string protocol, + OpcUaEndpointConfig config, + CancellationToken cancellationToken = default); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs index b78da1af..55fd8014 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs @@ -372,6 +372,28 @@ public class CommunicationService envelope, _options.QueryTimeout, cancellationToken); } + /// + /// Asks a site to run a read-only verification probe of an endpoint + /// configuration without persisting it: connect, capture the server certificate + /// if it is untrusted, then disconnect. Backs the CentralUI "Verify endpoint" + /// button. The Ask is bounded by + /// — verification is a short, one-shot query that shares the same latency budget + /// as the other interactive design-time queries (browse, search, test bindings). + /// + /// The target site identifier. + /// The verify-endpoint command (connection name + protocol + serialized config). + /// Cancellation token. + /// The verification result (success or classified failure, with a captured certificate when the failure is an untrusted certificate). + public Task VerifyEndpointAsync( + string siteId, + VerifyEndpointCommand command, + CancellationToken cancellationToken = default) + { + var envelope = new SiteEnvelope(siteId, command); + return GetActor().Ask( + envelope, _options.QueryTimeout, cancellationToken); + } + /// /// Asks a site to run a bounded recursive search of the address space on the /// live server backing the given data connection. The address-space analogue diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/OpcUaEndpointVerifyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/OpcUaEndpointVerifyTests.cs new file mode 100644 index 00000000..407f15f3 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/OpcUaEndpointVerifyTests.cs @@ -0,0 +1,95 @@ +using Bunit; +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; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components; + +/// +/// Covers the M7-B8 (T17) "Verify endpoint" affordance added to +/// : a button that calls the injected +/// with the current endpoint config and +/// 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. +/// +public class OpcUaEndpointVerifyTests : BunitContext +{ + private readonly IEndpointVerificationService _verify = + Substitute.For(); + + public OpcUaEndpointVerifyTests() + { + Services.AddSingleton(_verify); + } + + 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")); + + [Fact] + public void Verify_Success_ShowsSuccessMessage() + { + _verify.VerifyAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(new VerifyEndpointResult(true, null, null, null)); + + var cut = RenderEditor(); + cut.Find("[data-test=verify-endpoint-btn]").Click(); + + Assert.NotEmpty(cut.FindAll("[data-test=verify-success]")); + Assert.Empty(cut.FindAll("[data-test=verify-failure]")); + Assert.Empty(cut.FindAll("[data-test=verify-cert-panel]")); + } + + [Fact] + public void Verify_UntrustedCertificate_ShowsCertPanelWithThumbprint() + { + var cert = new ServerCertInfo( + 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"); + _verify.VerifyAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(new VerifyEndpointResult( + false, VerifyFailureKind.UntrustedCertificate, "Untrusted certificate.", cert)); + + var cut = RenderEditor(); + cut.Find("[data-test=verify-endpoint-btn]").Click(); + + var panel = cut.Find("[data-test=verify-cert-panel]"); + Assert.Contains("ABCDEF0123456789", panel.TextContent); + Assert.Contains("CN=opc-server", panel.TextContent); + // The failure line is still shown alongside the cert panel. + Assert.NotEmpty(cut.FindAll("[data-test=verify-failure]")); + } + + [Fact] + public void Verify_GenericFailure_ShowsFailureMessage() + { + _verify.VerifyAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(new VerifyEndpointResult( + false, VerifyFailureKind.Unreachable, "Connection refused.", null)); + + var cut = RenderEditor(); + cut.Find("[data-test=verify-endpoint-btn]").Click(); + + var failure = cut.Find("[data-test=verify-failure]"); + Assert.Contains("Connection refused.", failure.TextContent); + Assert.Empty(cut.FindAll("[data-test=verify-success]")); + Assert.Empty(cut.FindAll("[data-test=verify-cert-panel]")); + } +}