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; } } }