feat(centralui): Verify-endpoint button + result/cert panel (T17)
This commit is contained in:
@@ -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">
|
||||
✓ 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]"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user