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 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; 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 /// HTTP-registered command, 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 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 . /// 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 { private static IEnumerable RegisteredCommandTypes() => typeof(ManagementEnvelope).Assembly.GetTypes() .Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace && t.Name.EndsWith("Command", StringComparison.Ordinal) && !t.IsAbstract && ManagementCommandRegistry.Resolve(t.Name[..^"Command".Length]) == t); void IDisposable.Dispose() => Shutdown(); [Fact] public void EveryRegisteredCommand_ReachesADispatchArm() { var logger = new RecordingLogger(); var actor = CreateSentinelActor(logger, out var transaction); var commandTypes = RegisteredCommandTypes() .OrderBy(t => t.Name, StringComparer.Ordinal) .ToList(); 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(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 correct HTTP eligibility. " + $"Commands: {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, out PassthroughTransaction transaction) { var scopedProvider = Substitute.For(); var passthrough = new PassthroughTransaction(scopedProvider); transaction = passthrough; scopedProvider.GetService(Arg.Any()).Returns(call => call.Arg() == typeof(IConfigurationCommandTransaction) ? passthrough : throw 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))); } [Fact] public void Probe_DetectsACommandWithoutADispatchArm() { var logger = new RecordingLogger(); 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(TimeSpan.FromSeconds(10)); Assert.IsType(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 ExecuteAsync(Func> operation) { DispatchCount++; return operation(provider); } } /// 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; } } }