+ 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]"));
+ }
+}