test(management): dispatch-coverage test — every registered command has a handler (arch-review UA2)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Akka.Actor;
|
||||
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.ManagementService;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// arch-review UA2: dispatch-coverage guard. Every command type registered in
|
||||
/// <see cref="ManagementCommandRegistry"/> must be reachable by a real dispatch arm
|
||||
/// 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
|
||||
/// that someone forgot to wire up fails CI here rather than at runtime.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mechanism: for every registered command type we send a
|
||||
/// <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
|
||||
/// 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
|
||||
/// its fault path, keyed by correlation id; commands that answer without ever
|
||||
/// touching the provider (or throw some other fault) simply are not
|
||||
/// <see cref="NotSupportedException"/> and therefore pass.
|
||||
/// </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 ClusterClient 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);
|
||||
|
||||
void IDisposable.Dispose() => Shutdown();
|
||||
|
||||
[Fact]
|
||||
public void EveryRegisteredCommand_ReachesADispatchArm()
|
||||
{
|
||||
var logger = new RecordingLogger<ManagementActor>();
|
||||
var actor = CreateSentinelActor(logger);
|
||||
|
||||
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)
|
||||
{
|
||||
var command = RuntimeHelpers.GetUninitializedObject(type);
|
||||
var correlationId = Guid.NewGuid().ToString("N");
|
||||
var envelope = new ManagementEnvelope(
|
||||
new AuthenticatedUser("dispatch-probe", "Dispatch Probe", Roles.All, Array.Empty<string>()),
|
||||
command, correlationId);
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
// Barrier: every command produces exactly one response (Success / Error /
|
||||
// Unauthorized). By the time it arrives, MapFault has already logged any
|
||||
// fault, so the recording logger is populated for this correlation id.
|
||||
ExpectMsg<object>(TimeSpan.FromSeconds(10));
|
||||
|
||||
if (logger.Captured.TryGetValue(correlationId, out var ex) && ex is NotSupportedException)
|
||||
{
|
||||
noDispatchArm.Add(type.Name);
|
||||
}
|
||||
}
|
||||
|
||||
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)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="ManagementActor"/> over a service provider whose scoped
|
||||
/// <c>GetService</c> always throws <see cref="SentinelException"/>. The root
|
||||
/// provider still resolves <see cref="IServiceScopeFactory"/> so the actor's
|
||||
/// <c>CreateScope()</c> call succeeds and the fault surfaces only when a handler
|
||||
/// actually resolves a service.
|
||||
/// </summary>
|
||||
private IActorRef CreateSentinelActor(ILogger<ManagementActor> logger)
|
||||
{
|
||||
var scopedProvider = Substitute.For<IServiceProvider>();
|
||||
scopedProvider.GetService(Arg.Any<Type>()).Throws(_ => new SentinelException());
|
||||
|
||||
var scope = Substitute.For<IServiceScope>();
|
||||
scope.ServiceProvider.Returns(scopedProvider);
|
||||
|
||||
var scopeFactory = Substitute.For<IServiceScopeFactory>();
|
||||
scopeFactory.CreateScope().Returns(scope);
|
||||
|
||||
var rootProvider = Substitute.For<IServiceProvider>();
|
||||
rootProvider.GetService(typeof(IServiceScopeFactory)).Returns(scopeFactory);
|
||||
|
||||
return Sys.ActorOf(Props.Create(() => new ManagementActor(rootProvider, logger)));
|
||||
}
|
||||
|
||||
/// <summary>Marker fault thrown by the scoped provider — distinct from the
|
||||
/// "no dispatch arm" <see cref="NotSupportedException"/> signature.</summary>
|
||||
private sealed class SentinelException : Exception
|
||||
{
|
||||
public SentinelException() : base("sentinel: scoped provider touched") { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="ILogger{TCategoryName}"/> that records the exception passed
|
||||
/// to each error log, keyed by the <c>CorrelationId</c> structured field that
|
||||
/// <see cref="ManagementActor"/>'s fault path always includes.
|
||||
/// </summary>
|
||||
private sealed class RecordingLogger<T> : ILogger<T>
|
||||
{
|
||||
public ConcurrentDictionary<string, Exception> Captured { get; } = new();
|
||||
|
||||
IDisposable? ILogger.BeginScope<TState>(TState state) => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
|
||||
Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (exception is null)
|
||||
return;
|
||||
|
||||
var correlationId = ExtractCorrelationId(state);
|
||||
if (correlationId is not null)
|
||||
Captured[correlationId] = exception;
|
||||
}
|
||||
|
||||
private static string? ExtractCorrelationId<TState>(TState state)
|
||||
{
|
||||
if (state is IEnumerable<KeyValuePair<string, object?>> pairs)
|
||||
{
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
if (pair.Key == "CorrelationId")
|
||||
return pair.Value?.ToString();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user