feat(management): actor dispatch for BrowseNode/SearchAddressSpace/VerifyEndpoint — CLI parity (arch-review C4)
Made CommunicationService.BrowseNodeAsync/SearchAddressSpaceAsync/VerifyEndpointAsync virtual to support the test seam (mirrors existing virtual WriteTagAsync). Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -207,9 +207,9 @@ The two-person authorization workflow for writes through the MxAccess Gateway. B
|
|||||||
- **QuerySiteEventLog**: Query site event log entries from a remote site (routed via communication layer). Supports date range, keyword search, and pagination.
|
- **QuerySiteEventLog**: Query site event log entries from a remote site (routed via communication layer). Supports date range, keyword search, and pagination.
|
||||||
- **QueryParkedMessages**: Query parked (dead-letter) messages at a remote site (routed via communication layer). Supports pagination.
|
- **QueryParkedMessages**: Query parked (dead-letter) messages at a remote site (routed via communication layer). Supports pagination.
|
||||||
- **DebugSnapshot**: Request a one-shot snapshot of attribute values and alarm states for a running instance. Resolves the instance's site from the config DB and routes via the communication layer. Uses 30s `QueryTimeout`.
|
- **DebugSnapshot**: Request a one-shot snapshot of attribute values and alarm states for a running instance. Resolves the instance's site from the config DB and routes via the communication layer. Uses 30s `QueryTimeout`.
|
||||||
- **BrowseNodeCommand**: Browse an OPC UA / MxGateway connection's address space one level at a time; supports a `BrowseNext` continuation token (paging) and returns per-node type-info for OPC UA Variables. (`Design` role.)
|
- **BrowseNodeCommand**: Browse an OPC UA / MxGateway connection's address space one level at a time; supports a `BrowseNext` continuation token (paging) and returns per-node type-info for OPC UA Variables. Actor-dispatched — the handler requires the command's `SiteIdentifier`, enforces site-scope, then relays via `CommunicationService.BrowseNodeAsync` (arch-review C4, CLI/UI parity). (`Design` role.)
|
||||||
- **SearchAddressSpaceCommand**: Bounded recursive address-space search (depth + result caps, case-insensitive substring) against an OPC UA connection. (`Design` role.)
|
- **SearchAddressSpaceCommand**: Bounded recursive address-space search (depth + result caps, case-insensitive substring) against an OPC UA connection. Actor-dispatched, same `SiteIdentifier`-required + site-scope pattern, relayed via `CommunicationService.SearchAddressSpaceAsync` (arch-review C4). (`Design` role.)
|
||||||
- **VerifyEndpointCommand**: Ask a site to probe an OPC UA endpoint (temporary client, short timeout) and report success / typed failure / a captured-but-untrusted server cert. Read-only — never trusts. (`Design` role — runs inside the Admin-gated connection editor.)
|
- **VerifyEndpointCommand**: Ask a site to probe an OPC UA endpoint (temporary client, short timeout) and report success / typed failure / a captured-but-untrusted server cert. Read-only — never trusts. Actor-dispatched, `SiteIdentifier`-required + site-scope, relayed via `CommunicationService.VerifyEndpointAsync` (arch-review C4). (`Design` role — runs inside the Admin-gated connection editor.)
|
||||||
- **TrustServerCertCommand** / **RemoveServerCertCommand** / **ListServerCertsCommand**: Manage the site's OPC UA trusted-peer PKI store; Trust/Remove broadcast to **both** site nodes (see Component-SiteRuntime.md). (`Admin` role.)
|
- **TrustServerCertCommand** / **RemoveServerCertCommand** / **ListServerCertsCommand**: Manage the site's OPC UA trusted-peer PKI store; Trust/Remove broadcast to **both** site nodes (see Component-SiteRuntime.md). (`Admin` role.)
|
||||||
|
|
||||||
## Authorization
|
## Authorization
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ public class CommunicationService
|
|||||||
/// <param name="command">The OPC UA browse command.</param>
|
/// <param name="command">The OPC UA browse command.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
/// <returns>The browse result (children + truncation flag + structured failure).</returns>
|
/// <returns>The browse result (children + truncation flag + structured failure).</returns>
|
||||||
public Task<BrowseNodeResult> BrowseNodeAsync(
|
public virtual Task<BrowseNodeResult> BrowseNodeAsync(
|
||||||
string siteId,
|
string siteId,
|
||||||
BrowseNodeCommand command,
|
BrowseNodeCommand command,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -385,7 +385,7 @@ public class CommunicationService
|
|||||||
/// <param name="command">The verify-endpoint command (connection name + protocol + serialized config).</param>
|
/// <param name="command">The verify-endpoint command (connection name + protocol + serialized config).</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</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>
|
/// <returns>The verification result (success or classified failure, with a captured certificate when the failure is an untrusted certificate).</returns>
|
||||||
public Task<VerifyEndpointResult> VerifyEndpointAsync(
|
public virtual Task<VerifyEndpointResult> VerifyEndpointAsync(
|
||||||
string siteId,
|
string siteId,
|
||||||
VerifyEndpointCommand command,
|
VerifyEndpointCommand command,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -407,7 +407,7 @@ public class CommunicationService
|
|||||||
/// <param name="command">The address-space search command (connection name + query + depth/result caps).</param>
|
/// <param name="command">The address-space search command (connection name + query + depth/result caps).</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
/// <returns>The search result (matches + cap-reached flag + structured failure).</returns>
|
/// <returns>The search result (matches + cap-reached flag + structured failure).</returns>
|
||||||
public Task<SearchAddressSpaceResult> SearchAddressSpaceAsync(
|
public virtual Task<SearchAddressSpaceResult> SearchAddressSpaceAsync(
|
||||||
string siteId,
|
string siteId,
|
||||||
SearchAddressSpaceCommand command,
|
SearchAddressSpaceCommand command,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
|
|||||||
@@ -243,6 +243,11 @@ public class ManagementActor : ReceiveActor
|
|||||||
or CreateTemplateFolderCommand or RenameTemplateFolderCommand
|
or CreateTemplateFolderCommand or RenameTemplateFolderCommand
|
||||||
or MoveTemplateFolderCommand or ReorderTemplateFolderCommand or DeleteTemplateFolderCommand
|
or MoveTemplateFolderCommand or ReorderTemplateFolderCommand or DeleteTemplateFolderCommand
|
||||||
or MoveTemplateToFolderCommand
|
or MoveTemplateToFolderCommand
|
||||||
|
// OPC UA / MxGateway design-time remote queries (arch-review C4):
|
||||||
|
// browse the address space, run a bounded search, and probe/verify an
|
||||||
|
// endpoint. Read-only against the live site server; gated Design to
|
||||||
|
// match the connection editor these back.
|
||||||
|
or BrowseNodeCommand or SearchAddressSpaceCommand or VerifyEndpointCommand
|
||||||
or ExportBundleCommand => DesignerOnly,
|
or ExportBundleCommand => DesignerOnly,
|
||||||
|
|
||||||
// Transport import operations (mirror the Central UI gating:
|
// Transport import operations (mirror the Central UI gating:
|
||||||
@@ -435,6 +440,9 @@ public class ManagementActor : ReceiveActor
|
|||||||
|
|
||||||
// Remote Queries
|
// Remote Queries
|
||||||
QueryEventLogsCommand cmd => await HandleQueryEventLogs(sp, cmd, user),
|
QueryEventLogsCommand cmd => await HandleQueryEventLogs(sp, cmd, user),
|
||||||
|
BrowseNodeCommand cmd => await HandleBrowseNode(sp, cmd, user),
|
||||||
|
SearchAddressSpaceCommand cmd => await HandleSearchAddressSpace(sp, cmd, user),
|
||||||
|
VerifyEndpointCommand cmd => await HandleVerifyEndpoint(sp, cmd, user),
|
||||||
QueryParkedMessagesCommand cmd => await HandleQueryParkedMessages(sp, cmd, user),
|
QueryParkedMessagesCommand cmd => await HandleQueryParkedMessages(sp, cmd, user),
|
||||||
RetryParkedMessageCommand cmd => await HandleRetryParkedMessage(sp, cmd, user),
|
RetryParkedMessageCommand cmd => await HandleRetryParkedMessage(sp, cmd, user),
|
||||||
DiscardParkedMessageCommand cmd => await HandleDiscardParkedMessage(sp, cmd, user),
|
DiscardParkedMessageCommand cmd => await HandleDiscardParkedMessage(sp, cmd, user),
|
||||||
@@ -2914,6 +2922,39 @@ public class ManagementActor : ReceiveActor
|
|||||||
return await commService.QueryEventLogsAsync(cmd.SiteIdentifier, request);
|
return await commService.QueryEventLogsAsync(cmd.SiteIdentifier, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OPC UA / MxGateway design-time remote queries (arch-review C4). These
|
||||||
|
// handlers give the management API/CLI parity with the Central UI, which
|
||||||
|
// already reaches these site actors directly. SiteIdentifier is REQUIRED —
|
||||||
|
// the actor must know which site to route to — and site-scope is enforced
|
||||||
|
// before relaying, exactly like HandleQueryEventLogs.
|
||||||
|
|
||||||
|
private static async Task<object?> HandleBrowseNode(IServiceProvider sp, BrowseNodeCommand cmd, AuthenticatedUser user)
|
||||||
|
{
|
||||||
|
var site = cmd.SiteIdentifier
|
||||||
|
?? throw new ManagementCommandException("SiteIdentifier is required when browsing an address space via the management API.");
|
||||||
|
await EnforceSiteScopeForIdentifier(sp, user, site);
|
||||||
|
var commService = sp.GetRequiredService<CommunicationService>();
|
||||||
|
return await commService.BrowseNodeAsync(site, cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object?> HandleSearchAddressSpace(IServiceProvider sp, SearchAddressSpaceCommand cmd, AuthenticatedUser user)
|
||||||
|
{
|
||||||
|
var site = cmd.SiteIdentifier
|
||||||
|
?? throw new ManagementCommandException("SiteIdentifier is required when searching an address space via the management API.");
|
||||||
|
await EnforceSiteScopeForIdentifier(sp, user, site);
|
||||||
|
var commService = sp.GetRequiredService<CommunicationService>();
|
||||||
|
return await commService.SearchAddressSpaceAsync(site, cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object?> HandleVerifyEndpoint(IServiceProvider sp, VerifyEndpointCommand cmd, AuthenticatedUser user)
|
||||||
|
{
|
||||||
|
var site = cmd.SiteIdentifier
|
||||||
|
?? throw new ManagementCommandException("SiteIdentifier is required when verifying an endpoint via the management API.");
|
||||||
|
await EnforceSiteScopeForIdentifier(sp, user, site);
|
||||||
|
var commService = sp.GetRequiredService<CommunicationService>();
|
||||||
|
return await commService.VerifyEndpointAsync(site, cmd);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<object?> HandleQueryParkedMessages(IServiceProvider sp, QueryParkedMessagesCommand cmd, AuthenticatedUser user)
|
private static async Task<object?> HandleQueryParkedMessages(IServiceProvider sp, QueryParkedMessagesCommand cmd, AuthenticatedUser user)
|
||||||
{
|
{
|
||||||
await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteIdentifier);
|
await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteIdentifier);
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ using Akka.Actor;
|
|||||||
using Akka.TestKit.Xunit2;
|
using Akka.TestKit.Xunit2;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||||
using NSubstitute.ExceptionExtensions;
|
using NSubstitute.ExceptionExtensions;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||||
@@ -3152,4 +3155,142 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
actor.Tell(Envelope(new CreateAreaCommand(1, "Area1", null), "Viewer"));
|
actor.Tell(Envelope(new CreateAreaCommand(1, "Area1", null), "Viewer"));
|
||||||
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Remote-query actor dispatch for the OPC UA design-time commands
|
||||||
|
// (arch-review C4): BrowseNode / SearchAddressSpace / VerifyEndpoint gain
|
||||||
|
// actor handlers so the management API/CLI reaches parity with the UI.
|
||||||
|
// SiteIdentifier is REQUIRED (the actor must route to a site); Designer-gated.
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BrowseNode_WithoutSiteIdentifier_ReturnsError()
|
||||||
|
{
|
||||||
|
var actor = CreateActor();
|
||||||
|
actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null), "Designer"));
|
||||||
|
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Contains("SiteIdentifier", resp.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BrowseNode_Designer_RelaysToSite()
|
||||||
|
{
|
||||||
|
var stub = new StubCommunicationService();
|
||||||
|
_services.AddSingleton<CommunicationService>(stub);
|
||||||
|
var actor = CreateActor();
|
||||||
|
actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null, "SITE1"), "Designer"));
|
||||||
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal(1, stub.BrowseCount);
|
||||||
|
Assert.Equal("SITE1", stub.LastBrowseSite);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BrowseNode_ViewerOnly_Unauthorized()
|
||||||
|
{
|
||||||
|
var actor = CreateActor();
|
||||||
|
actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null, "SITE1"), "Viewer"));
|
||||||
|
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SearchAddressSpace_WithoutSiteIdentifier_ReturnsError()
|
||||||
|
{
|
||||||
|
var actor = CreateActor();
|
||||||
|
actor.Tell(Envelope(new SearchAddressSpaceCommand("conn", "q", 3, 50), "Designer"));
|
||||||
|
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Contains("SiteIdentifier", resp.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SearchAddressSpace_Designer_RelaysToSite()
|
||||||
|
{
|
||||||
|
var stub = new StubCommunicationService();
|
||||||
|
_services.AddSingleton<CommunicationService>(stub);
|
||||||
|
var actor = CreateActor();
|
||||||
|
actor.Tell(Envelope(new SearchAddressSpaceCommand("conn", "q", 3, 50, "SITE1"), "Designer"));
|
||||||
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal(1, stub.SearchCount);
|
||||||
|
Assert.Equal("SITE1", stub.LastSearchSite);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SearchAddressSpace_ViewerOnly_Unauthorized()
|
||||||
|
{
|
||||||
|
var actor = CreateActor();
|
||||||
|
actor.Tell(Envelope(new SearchAddressSpaceCommand("conn", "q", 3, 50, "SITE1"), "Viewer"));
|
||||||
|
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VerifyEndpoint_WithoutSiteIdentifier_ReturnsError()
|
||||||
|
{
|
||||||
|
var actor = CreateActor();
|
||||||
|
actor.Tell(Envelope(new VerifyEndpointCommand("conn", "OpcUa", "{}"), "Designer"));
|
||||||
|
var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Contains("SiteIdentifier", resp.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VerifyEndpoint_Designer_RelaysToSite()
|
||||||
|
{
|
||||||
|
var stub = new StubCommunicationService();
|
||||||
|
_services.AddSingleton<CommunicationService>(stub);
|
||||||
|
var actor = CreateActor();
|
||||||
|
actor.Tell(Envelope(new VerifyEndpointCommand("conn", "OpcUa", "{}", "SITE1"), "Designer"));
|
||||||
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal(1, stub.VerifyCount);
|
||||||
|
Assert.Equal("SITE1", stub.LastVerifySite);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VerifyEndpoint_ViewerOnly_Unauthorized()
|
||||||
|
{
|
||||||
|
var actor = CreateActor();
|
||||||
|
actor.Tell(Envelope(new VerifyEndpointCommand("conn", "OpcUa", "{}", "SITE1"), "Viewer"));
|
||||||
|
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records remote-query relays for the actor-dispatch tests. The browse /
|
||||||
|
/// search / verify / cert-trust methods on <see cref="CommunicationService"/>
|
||||||
|
/// are virtual so the relay can be exercised without a live actor system.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class StubCommunicationService : CommunicationService
|
||||||
|
{
|
||||||
|
public StubCommunicationService()
|
||||||
|
: base(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public int BrowseCount { get; private set; }
|
||||||
|
public string? LastBrowseSite { get; private set; }
|
||||||
|
public int SearchCount { get; private set; }
|
||||||
|
public string? LastSearchSite { get; private set; }
|
||||||
|
public int VerifyCount { get; private set; }
|
||||||
|
public string? LastVerifySite { get; private set; }
|
||||||
|
|
||||||
|
public override Task<BrowseNodeResult> BrowseNodeAsync(
|
||||||
|
string siteId, BrowseNodeCommand command, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
BrowseCount++;
|
||||||
|
LastBrowseSite = siteId;
|
||||||
|
return Task.FromResult(new BrowseNodeResult(Array.Empty<BrowseNode>(), false, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<SearchAddressSpaceResult> SearchAddressSpaceAsync(
|
||||||
|
string siteId, SearchAddressSpaceCommand command, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
SearchCount++;
|
||||||
|
LastSearchSite = siteId;
|
||||||
|
return Task.FromResult(new SearchAddressSpaceResult(Array.Empty<AddressSpaceMatch>(), false, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<VerifyEndpointResult> VerifyEndpointAsync(
|
||||||
|
string siteId, VerifyEndpointCommand command, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
VerifyCount++;
|
||||||
|
LastVerifySite = siteId;
|
||||||
|
return Task.FromResult(new VerifyEndpointResult(true, null, null, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user