feat(centralui): Verify-endpoint button + result/cert panel (T17)

This commit is contained in:
Joseph Doherty
2026-06-18 03:12:11 -04:00
parent 45a5a92455
commit 303385fd98
7 changed files with 356 additions and 0 deletions
@@ -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
<div class="opcua-endpoint-editor">
<h6 class="text-muted border-bottom pb-1">@Title</h6>
@@ -40,6 +43,62 @@
</div>
</div>
<div class="mb-2">
<button type="button" class="btn btn-outline-primary btn-sm"
data-test="verify-endpoint-btn"
disabled="@_verifying"
@onclick="VerifyEndpoint">
@if (_verifying)
{
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
<span>Verifying…</span>
}
else
{
<span>Verify endpoint</span>
}
</button>
@if (_verifyResult is { } result)
{
@if (result.Success)
{
<span class="text-success small ms-2" data-test="verify-success">
&#10003; Endpoint reachable
</span>
}
else
{
<span class="text-danger small ms-2" data-test="verify-failure">
@result.FailureKind: @result.Error
</span>
}
@if (result.FailureKind == VerifyFailureKind.UntrustedCertificate
&& result.Cert is { } cert)
{
<div class="border rounded bg-light p-2 mt-2 small" data-test="verify-cert-panel">
<div class="text-muted mb-1">Untrusted server certificate</div>
<dl class="row mb-1 small">
<dt class="col-sm-3">Subject</dt>
<dd class="col-sm-9"><code>@cert.Subject</code></dd>
<dt class="col-sm-3">Issuer</dt>
<dd class="col-sm-9"><code>@cert.Issuer</code></dd>
<dt class="col-sm-3">Thumbprint</dt>
<dd class="col-sm-9"><code>@cert.Thumbprint</code></dd>
<dt class="col-sm-3">Not before</dt>
<dd class="col-sm-9">@cert.NotBeforeUtc.ToString("u")</dd>
<dt class="col-sm-3">Not after</dt>
<dd class="col-sm-9">@cert.NotAfterUtc.ToString("u")</dd>
</dl>
<div class="text-muted fst-italic">
Use cert management to trust this certificate.
</div>
</div>
}
}
</div>
<div class="text-muted small mt-2 mb-1">Authentication</div>
@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();
@@ -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" />
}
<div class="mb-2">
@@ -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
@@ -71,6 +71,13 @@ public static class ServiceCollectionExtensions
// transport failures into typed BrowseFailure results for the dialog.
services.AddScoped<IBrowseService, BrowseService>();
// 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<IEndpointVerificationService, EndpointVerificationService>();
// 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
@@ -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;
/// <summary>
/// Default <see cref="IEndpointVerificationService"/> implementation — a thin facade
/// over <see cref="CommunicationService.VerifyEndpointAsync"/> that enforces the
/// CentralUI-side <c>Design</c>-role trust boundary, serializes the endpoint config,
/// and translates transport exceptions into a typed
/// <see cref="VerifyEndpointResult"/>.
/// </summary>
/// <remarks>
/// Site-side actors (<c>SiteCommunicationActor</c> + <c>DataConnectionManagerActor</c>)
/// do not unwrap the central trust envelope, so the role check MUST run here — never
/// on the site (mirrors <see cref="BrowseService"/>). Transport failures collapse into
/// a <see cref="VerifyFailureKind.Timeout"/> or <see cref="VerifyFailureKind.ServerError"/>
/// result so the editor can show an inline outcome rather than throwing.
/// </remarks>
public sealed class EndpointVerificationService : IEndpointVerificationService
{
private readonly CommunicationService _communication;
private readonly AuthenticationStateProvider _auth;
/// <summary>
/// Initializes a new instance of the <see cref="EndpointVerificationService"/>.
/// </summary>
/// <param name="communication">Central-side cluster communication service.</param>
/// <param name="auth">Authentication state provider used for the Design-role guard.</param>
public EndpointVerificationService(CommunicationService communication, AuthenticationStateProvider auth)
{
_communication = communication ?? throw new ArgumentNullException(nameof(communication));
_auth = auth ?? throw new ArgumentNullException(nameof(auth));
}
/// <inheritdoc/>
public async Task<VerifyEndpointResult> 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);
}
}
}
@@ -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;
/// <summary>
/// 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 <see cref="VerifyEndpointCommand"/>
/// to the owning site via
/// <see cref="ZB.MOM.WW.ScadaBridge.Communication.CommunicationService"/>, which
/// runs a read-only connect probe and reports back a typed
/// <see cref="VerifyEndpointResult"/>.
/// </summary>
/// <remarks>
/// The service is the trust boundary for the verify capability: it enforces the
/// <c>Design</c> role at central before any cross-cluster traffic is generated,
/// because site-side actors do not unwrap the central trust envelope (mirrors
/// <see cref="IBrowseService"/>). Transport failures (timeouts, unreachable sites)
/// are translated into a typed <see cref="VerifyEndpointResult"/> so the editor can
/// render an inline outcome rather than throwing.
/// </remarks>
public interface IEndpointVerificationService
{
/// <summary>
/// Runs a read-only verification probe of <paramref name="config"/> against the
/// live server at <paramref name="siteIdentifier"/>. 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).
/// </summary>
/// <param name="siteIdentifier">The target site identifier (the machine-readable <c>SiteIdentifier</c> used in Akka addresses, NOT the numeric primary key).</param>
/// <param name="connectionName">Name of the data connection being verified (for logging/correlation).</param>
/// <param name="protocol">Protocol type string (e.g. <c>"OpcUa"</c>); matched case-insensitively at the site.</param>
/// <param name="config">The endpoint configuration to verify; serialized to JSON before being sent to the site.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that resolves to a <see cref="VerifyEndpointResult"/> — success, or a classified failure (with a captured certificate when the failure is <see cref="VerifyFailureKind.UntrustedCertificate"/>).</returns>
Task<VerifyEndpointResult> VerifyAsync(
string siteIdentifier,
string connectionName,
string protocol,
OpcUaEndpointConfig config,
CancellationToken cancellationToken = default);
}
@@ -372,6 +372,28 @@ public class CommunicationService
envelope, _options.QueryTimeout, cancellationToken);
}
/// <summary>
/// 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 <see cref="CommunicationOptions.QueryTimeout"/>
/// — verification is a short, one-shot query that shares the same latency budget
/// as the other interactive design-time queries (browse, search, test bindings).
/// </summary>
/// <param name="siteId">The target site identifier.</param>
/// <param name="command">The verify-endpoint command (connection name + protocol + serialized config).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The verification result (success or classified failure, with a captured certificate when the failure is an untrusted certificate).</returns>
public Task<VerifyEndpointResult> VerifyEndpointAsync(
string siteId,
VerifyEndpointCommand command,
CancellationToken cancellationToken = default)
{
var envelope = new SiteEnvelope(siteId, command);
return GetActor().Ask<VerifyEndpointResult>(
envelope, _options.QueryTimeout, cancellationToken);
}
/// <summary>
/// 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
@@ -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;
/// <summary>
/// Covers the M7-B8 (T17) "Verify endpoint" affordance added to
/// <see cref="OpcUaEndpointEditor"/>: a button that calls the injected
/// <see cref="IEndpointVerificationService"/> with the current endpoint config and
/// renders the typed <see cref="VerifyEndpointResult"/> — green on success, a red
/// failure line otherwise, and a read-only certificate panel when the failure is
/// <see cref="VerifyFailureKind.UntrustedCertificate"/> with a captured cert.
/// </summary>
public class OpcUaEndpointVerifyTests : BunitContext
{
private readonly IEndpointVerificationService _verify =
Substitute.For<IEndpointVerificationService>();
public OpcUaEndpointVerifyTests()
{
Services.AddSingleton(_verify);
}
private IRenderedComponent<OpcUaEndpointEditor> RenderEditor() =>
Render<OpcUaEndpointEditor>(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<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<OpcUaEndpointConfig>(), Arg.Any<CancellationToken>())
.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<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<OpcUaEndpointConfig>(), Arg.Any<CancellationToken>())
.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<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<OpcUaEndpointConfig>(), Arg.Any<CancellationToken>())
.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]"));
}
}