513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
209 lines
9.6 KiB
C#
209 lines
9.6 KiB
C#
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;
|
|
|
|
/// <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
|
|
/// HTTP-registered command, 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"/> 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
|
|
/// 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
|
|
{
|
|
private static IEnumerable<Type> 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);
|
|
|
|
/// <inheritdoc />
|
|
void IDisposable.Dispose() => Shutdown();
|
|
|
|
/// <summary>Verifies that every registered command reaches a dispatch arm.</summary>
|
|
[Fact]
|
|
public void EveryRegisteredCommand_ReachesADispatchArm()
|
|
{
|
|
var logger = new RecordingLogger<ManagementActor>();
|
|
var actor = CreateSentinelActor(logger, out var transaction);
|
|
|
|
var commandTypes = RegisteredCommandTypes()
|
|
.OrderBy(t => t.Name, StringComparer.Ordinal)
|
|
.ToList();
|
|
|
|
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(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)}");
|
|
}
|
|
|
|
/// <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, out PassthroughTransaction transaction)
|
|
{
|
|
var scopedProvider = Substitute.For<IServiceProvider>();
|
|
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);
|
|
|
|
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>Verifies that the probe detects a command without a dispatch arm.</summary>
|
|
[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
|
|
{
|
|
/// <summary>The number of operations dispatched through this transaction.</summary>
|
|
public int DispatchCount { get; private set; }
|
|
/// <inheritdoc />
|
|
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
|
|
{
|
|
/// <summary>Initializes the exception with a fixed sentinel message.</summary>
|
|
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>
|
|
{
|
|
/// <summary>The exception logged for each correlation id.</summary>
|
|
public ConcurrentDictionary<string, Exception> Captured { get; } = new();
|
|
|
|
/// <inheritdoc />
|
|
IDisposable? ILogger.BeginScope<TState>(TState state) => null;
|
|
|
|
/// <summary>Reports every log level as enabled.</summary>
|
|
/// <param name="logLevel">The log level being checked.</param>
|
|
/// <returns>Always <see langword="true"/>.</returns>
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
/// <summary>Captures the logged exception under its correlation id.</summary>
|
|
/// <typeparam name="TState">The type of the log state.</typeparam>
|
|
/// <param name="logLevel">The log level.</param>
|
|
/// <param name="eventId">The event id.</param>
|
|
/// <param name="state">The log state, searched for a correlation id.</param>
|
|
/// <param name="exception">The exception being logged, if any.</param>
|
|
/// <param name="formatter">The message formatter.</param>
|
|
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;
|
|
}
|
|
}
|
|
}
|