diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DispatchCoverageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DispatchCoverageTests.cs new file mode 100644 index 00000000..859031b6 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DispatchCoverageTests.cs @@ -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; + +/// +/// arch-review UA2: dispatch-coverage guard. Every command type registered in +/// must be reachable by a real dispatch arm +/// in . A command with no arm falls through to the +/// registry's default case, which throws +/// ("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. +/// +/// +/// Mechanism: for every registered command type we send a +/// 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 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 . +/// A recording logger captures the exception 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 +/// and therefore pass. +/// +public class DispatchCoverageTests : TestKit, IDisposable +{ + /// + /// Commands that are registered in (so they + /// carry a stable wire name across the management boundary) but are intentionally + /// NOT dispatched by because they are serviced by a + /// different actor path. Each entry is a deliberate design decision, not a missing arm. + /// + private static readonly HashSet 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 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(); + 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(); + + 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()), + 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(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)}"); + } + + /// + /// Builds a over a service provider whose scoped + /// GetService always throws . The root + /// provider still resolves so the actor's + /// CreateScope() call succeeds and the fault surfaces only when a handler + /// actually resolves a service. + /// + private IActorRef CreateSentinelActor(ILogger logger) + { + var scopedProvider = Substitute.For(); + scopedProvider.GetService(Arg.Any()).Throws(_ => new SentinelException()); + + var scope = Substitute.For(); + scope.ServiceProvider.Returns(scopedProvider); + + var scopeFactory = Substitute.For(); + scopeFactory.CreateScope().Returns(scope); + + var rootProvider = Substitute.For(); + rootProvider.GetService(typeof(IServiceScopeFactory)).Returns(scopeFactory); + + return Sys.ActorOf(Props.Create(() => new ManagementActor(rootProvider, logger))); + } + + /// Marker fault thrown by the scoped provider — distinct from the + /// "no dispatch arm" signature. + private sealed class SentinelException : Exception + { + public SentinelException() : base("sentinel: scoped provider touched") { } + } + + /// + /// Minimal that records the exception passed + /// to each error log, keyed by the CorrelationId structured field that + /// 's fault path always includes. + /// + private sealed class RecordingLogger : ILogger + { + public ConcurrentDictionary Captured { get; } = new(); + + IDisposable? ILogger.BeginScope(TState state) => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, + Exception? exception, Func formatter) + { + if (exception is null) + return; + + var correlationId = ExtractCorrelationId(state); + if (correlationId is not null) + Captured[correlationId] = exception; + } + + private static string? ExtractCorrelationId(TState state) + { + if (state is IEnumerable> pairs) + { + foreach (var pair in pairs) + { + if (pair.Key == "CorrelationId") + return pair.Value?.ToString(); + } + } + return null; + } + } +}