Complete management API and CLI command parity
This commit is contained in:
@@ -6,8 +6,8 @@ using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
/// in <see cref="ManagementActor"/>. A command with no arm falls through to the
|
||||
/// registry's default case, which throws <see cref="NotSupportedException"/>
|
||||
/// ("Unknown command type"). This test proves that signature never occurs for any
|
||||
/// registered command (bar the documented exclusion list), so a newly-added command
|
||||
/// HTTP-registered command, so a newly-added command
|
||||
/// that someone forgot to wire up fails CI here rather than at runtime.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -27,7 +27,8 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
/// <see cref="RuntimeHelpers.GetUninitializedObject"/> instance (all fields default —
|
||||
/// we never execute the handler for real) inside an envelope carrying ALL roles, so
|
||||
/// the authorization gate always passes. The actor runs on a service provider whose
|
||||
/// scoped <see cref="IServiceProvider.GetService"/> throws a sentinel exception, so
|
||||
/// scoped <see cref="IServiceProvider.GetService"/> passes through configuration transactions
|
||||
/// and throws a sentinel exception for handler dependencies, so
|
||||
/// any handler that touches the provider faults with the sentinel (a pass — the arm
|
||||
/// exists). Only a command with NO arm faults with <see cref="NotSupportedException"/>.
|
||||
/// A recording logger captures the exception <see cref="ManagementActor"/> maps in
|
||||
@@ -37,36 +38,12 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
/// </remarks>
|
||||
public class DispatchCoverageTests : TestKit, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Commands that are registered in <see cref="ManagementCommandRegistry"/> (so they
|
||||
/// carry a stable wire name across the management boundary) but are intentionally
|
||||
/// NOT dispatched by <see cref="ManagementActor"/> because they are serviced by a
|
||||
/// different actor path. Each entry is a deliberate design decision, not a missing arm.
|
||||
/// </summary>
|
||||
private static readonly HashSet<Type> Excluded = new()
|
||||
{
|
||||
// The two-step "ResolveRoles + command" flow is retired — the HTTP endpoint
|
||||
// resolves roles itself and sends a single envelope. Leaving a handler would
|
||||
// expose role-mapping data to a raw in-cluster sender with no role gate.
|
||||
// See ManagementActor.DispatchCommand (the comment above the default arm, ~line 472).
|
||||
typeof(ResolveRolesCommand),
|
||||
|
||||
// Site-routed, not a central ManagementActor command. Backs the CentralUI
|
||||
// "Test Bindings" dialog, which sends it straight through
|
||||
// CommunicationService.ReadTagValuesAsync (in-process) to the site's
|
||||
// DataConnectionManagerActor / DataConnectionActor — never through the
|
||||
// ManagementActor dispatch switch. It lives in the Messages.Management
|
||||
// namespace (hence auto-registered) only to earn a stable wire name for the
|
||||
// cross-cluster boundary; see ReadTagValuesCommandRegistryTests. A
|
||||
// ManagementActor arm would be the wrong transport for this command.
|
||||
typeof(ReadTagValuesCommand),
|
||||
};
|
||||
|
||||
private static IEnumerable<Type> RegisteredCommandTypes() =>
|
||||
typeof(ManagementEnvelope).Assembly.GetTypes()
|
||||
.Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace
|
||||
&& t.Name.EndsWith("Command", StringComparison.Ordinal)
|
||||
&& !t.IsAbstract);
|
||||
&& !t.IsAbstract
|
||||
&& ManagementCommandRegistry.Resolve(t.Name[..^"Command".Length]) == t);
|
||||
|
||||
void IDisposable.Dispose() => Shutdown();
|
||||
|
||||
@@ -74,19 +51,12 @@ public class DispatchCoverageTests : TestKit, IDisposable
|
||||
public void EveryRegisteredCommand_ReachesADispatchArm()
|
||||
{
|
||||
var logger = new RecordingLogger<ManagementActor>();
|
||||
var actor = CreateSentinelActor(logger);
|
||||
var actor = CreateSentinelActor(logger, out var transaction);
|
||||
|
||||
var commandTypes = RegisteredCommandTypes()
|
||||
.Where(t => !Excluded.Contains(t))
|
||||
.OrderBy(t => t.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Sanity: the exclusion list must reference real, registered command types.
|
||||
foreach (var excluded in Excluded)
|
||||
{
|
||||
Assert.Contains(excluded, RegisteredCommandTypes());
|
||||
}
|
||||
|
||||
var noDispatchArm = new List<string>();
|
||||
|
||||
foreach (var type in commandTypes)
|
||||
@@ -110,11 +80,12 @@ public class DispatchCoverageTests : TestKit, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(transaction.DispatchCount > 0, "Configuration commands must execute beyond the transaction wrapper.");
|
||||
Assert.True(
|
||||
noDispatchArm.Count == 0,
|
||||
$"{noDispatchArm.Count} registered command(s) have no dispatch arm in ManagementActor " +
|
||||
$"(fell through to the NotSupportedException default). Add the missing arm, or — with " +
|
||||
$"justification — an entry to the exclusion list: {string.Join(", ", noDispatchArm)}");
|
||||
$"(fell through to the NotSupportedException default). Add the missing arm or correct HTTP eligibility. " +
|
||||
$"Commands: {string.Join(", ", noDispatchArm)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -124,10 +95,15 @@ public class DispatchCoverageTests : TestKit, IDisposable
|
||||
/// <c>CreateScope()</c> call succeeds and the fault surfaces only when a handler
|
||||
/// actually resolves a service.
|
||||
/// </summary>
|
||||
private IActorRef CreateSentinelActor(ILogger<ManagementActor> logger)
|
||||
private IActorRef CreateSentinelActor(ILogger<ManagementActor> logger, out PassthroughTransaction transaction)
|
||||
{
|
||||
var scopedProvider = Substitute.For<IServiceProvider>();
|
||||
scopedProvider.GetService(Arg.Any<Type>()).Throws(_ => new SentinelException());
|
||||
var passthrough = new PassthroughTransaction(scopedProvider);
|
||||
transaction = passthrough;
|
||||
scopedProvider.GetService(Arg.Any<Type>()).Returns(call =>
|
||||
call.Arg<Type>() == typeof(IConfigurationCommandTransaction)
|
||||
? passthrough
|
||||
: throw new SentinelException());
|
||||
|
||||
var scope = Substitute.For<IServiceScope>();
|
||||
scope.ServiceProvider.Returns(scopedProvider);
|
||||
@@ -141,6 +117,32 @@ public class DispatchCoverageTests : TestKit, IDisposable
|
||||
return Sys.ActorOf(Props.Create(() => new ManagementActor(rootProvider, logger)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_DetectsACommandWithoutADispatchArm()
|
||||
{
|
||||
var logger = new RecordingLogger<ManagementActor>();
|
||||
var actor = CreateSentinelActor(logger, out _);
|
||||
var correlationId = Guid.NewGuid().ToString("N");
|
||||
actor.Tell(new ManagementEnvelope(
|
||||
new AuthenticatedUser("probe", "Probe", Roles.All, []), new UnsupportedCommand(), correlationId));
|
||||
ExpectMsg<object>(TimeSpan.FromSeconds(10));
|
||||
Assert.IsType<NotSupportedException>(logger.Captured[correlationId]);
|
||||
}
|
||||
|
||||
private sealed record UnsupportedCommand;
|
||||
|
||||
// Do not throw while resolving the transaction: that would pass configuration
|
||||
// commands before DispatchCommand had a chance to select (or miss) their arm.
|
||||
private sealed class PassthroughTransaction(IServiceProvider provider) : IConfigurationCommandTransaction
|
||||
{
|
||||
public int DispatchCount { get; private set; }
|
||||
public Task<object?> ExecuteAsync(Func<IServiceProvider, Task<object?>> operation)
|
||||
{
|
||||
DispatchCount++;
|
||||
return operation(provider);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Marker fault thrown by the scoped provider — distinct from the
|
||||
/// "no dispatch arm" <see cref="NotSupportedException"/> signature.</summary>
|
||||
private sealed class SentinelException : Exception
|
||||
|
||||
Reference in New Issue
Block a user