Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CaptiveDependencyGuard.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
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.
2026-10-07 15:05:49 -04:00

285 lines
12 KiB
C#

using System.Reflection;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Deferred register row 50, mitigation 2: a reflection guard that no Akka actor holds
/// captive scope state — a scoped service, or an <see cref="IServiceScope"/>, cached for
/// the actor's lifetime.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists separately from scope validation.</b> Register row 45 was exactly this
/// bug: <c>NotificationOutboxActor</c> cached delivery adapters resolved from an
/// actor-lifetime <c>IServiceScope</c>, so <c>MaxParallelDeliveries</c> concurrent deliveries
/// shared one EF <c>DbContext</c> and EF threw "a second operation was started on this
/// context instance" continuously on the rig (fixed 9e769735). .NET's own
/// <c>ValidateScopes</c> could never have caught it: the actors take the ROOT
/// <see cref="IServiceProvider"/> and call <c>GetRequiredService</c> by hand, which the
/// container's static graph analysis cannot see through. This guard closes that half.
/// </para>
/// <para>
/// <b>What is allowed.</b> Holding <see cref="IServiceProvider"/> or
/// <see cref="IServiceScopeFactory"/> for the actor's lifetime is the CORRECT pattern and is
/// deliberately not flagged — every actor here does that and opens a fresh scope per
/// operation. What is forbidden is holding the SCOPE, or anything resolved from one, past
/// the operation that opened it.
/// </para>
/// <para>
/// <b>How "scoped" is decided.</b> The caller supplies the scoped set derived from the actual
/// DI registrations of a real composition root, so the guard tracks the container rather than
/// a hand-maintained list — re-registering a service from singleton to scoped automatically
/// brings any actor field of that type into scope for the check. Two types are flagged
/// unconditionally on top of that set, because they are wrong in an actor field whatever the
/// container says: <see cref="IServiceScope"/> itself, and any <see cref="DbContext"/>.
/// </para>
/// <para>
/// Auto-property backing fields need no special handling: the compiler emits them as private
/// instance fields, so <c>GetFields(NonPublic | Instance)</c> already returns them.
/// </para>
/// </remarks>
internal static class CaptiveDependencyGuard
{
/// <summary>One offending field on one actor type.</summary>
/// <param name="ActorType">The actor class declaring the field.</param>
/// <param name="FieldName">The field's name (a compiler-generated backing field keeps its mangled name).</param>
/// <param name="FieldType">The field's declared type.</param>
/// <param name="Reason">Why the field was flagged.</param>
internal sealed record Violation(Type ActorType, string FieldName, Type FieldType, string Reason)
{
/// <inheritdoc/>
public override string ToString() =>
$"{ActorType.FullName}.{FieldName} : {FieldType.FullName} — {Reason}";
}
/// <summary>
/// Field types that are always correct to hold for an actor's lifetime: they ARE the
/// per-operation-scope pattern rather than a violation of it.
/// </summary>
internal static readonly IReadOnlyList<Type> AllowedProviderTypes =
[
typeof(IServiceProvider),
typeof(IServiceScopeFactory),
];
/// <summary>
/// Fields that hold a scoped-typed value but are provably safe, keyed
/// <c>"Namespace.ActorType.fieldName"</c>. Every entry carries its safety argument —
/// nothing is exempted silently.
/// </summary>
/// <remarks>
/// <para>
/// EMPTY, and pinned empty by <c>CaptiveDependencyGuardTests.Allowlist_IsEmpty</c>.
/// </para>
/// <para>
/// It previously held two entries — <c>AuditLogIngestActor._injectedRepository</c> and
/// <c>SiteCallAuditActor._injectedRepository</c> — nullable repository fields assigned only
/// by explicitly test-mode constructors. They were safe solely because no production call
/// site picked those overloads, which left the captive-dependency bug one
/// constructor call away. Register row 61 deleted the constructors instead: both actors now
/// have a single <see cref="IServiceProvider"/> constructor, the fields are gone, and the
/// test fixtures build stub providers that hand a repository out of a scope. With the shape
/// removed there is nothing left to exempt.
/// </para>
/// <para>
/// Keep it empty. A new entry means someone reintroduced a scoped service cached on an
/// actor field; prefer fixing the actor.
/// </para>
/// </remarks>
internal static readonly IReadOnlySet<string> Allowlist =
new HashSet<string>(StringComparer.Ordinal);
/// <summary>
/// Scans the given actor types for instance fields holding captive scope state.
/// </summary>
/// <param name="actorTypes">Actor classes to inspect.</param>
/// <param name="scopedServiceTypes">
/// Service and implementation types registered with <see cref="ServiceLifetime.Scoped"/>
/// in the composition root being checked.
/// </param>
/// <param name="applyAllowlist">
/// False makes the scan report allowlisted fields too — used by the guard's own self-tests
/// so the allowlist cannot quietly grow to cover a real regression.
/// </param>
/// <returns>Every violation found, in declaration order.</returns>
internal static IReadOnlyList<Violation> Scan(
IEnumerable<Type> actorTypes,
IReadOnlySet<Type> scopedServiceTypes,
bool applyAllowlist = true)
{
ArgumentNullException.ThrowIfNull(actorTypes);
ArgumentNullException.ThrowIfNull(scopedServiceTypes);
var violations = new List<Violation>();
foreach (var actorType in actorTypes)
{
foreach (var field in InstanceFields(actorType))
{
var fieldType = field.FieldType;
if (AllowedProviderTypes.Contains(fieldType))
{
// The correct pattern — create a scope per operation.
continue;
}
string? reason = null;
if (fieldType == typeof(IServiceScope) || fieldType == typeof(AsyncServiceScope))
{
reason = "holds a DI scope for the actor's lifetime; open a scope per operation instead";
}
else if (typeof(DbContext).IsAssignableFrom(fieldType))
{
reason = "holds an EF DbContext for the actor's lifetime; resolve it inside a per-operation scope";
}
else if (scopedServiceTypes.Contains(fieldType))
{
reason = "holds a service registered Scoped; resolve it inside a per-operation scope";
}
if (reason is null)
{
continue;
}
if (applyAllowlist && Allowlist.Contains($"{actorType.FullName}.{field.Name}"))
{
continue;
}
violations.Add(new Violation(actorType, field.Name, fieldType, reason));
}
}
return violations;
}
/// <summary>
/// Every instance field declared by the actor class and by its ScadaBridge-owned base
/// classes. The walk stops at the first non-ScadaBridge base, which is how Akka's own
/// <see cref="ActorBase"/> internals are excluded without naming them.
/// </summary>
/// <param name="actorType">The actor class to walk.</param>
/// <returns>The instance fields, most-derived first.</returns>
private static IEnumerable<FieldInfo> InstanceFields(Type actorType)
{
for (var t = actorType; t is not null && IsScadaBridgeType(t); t = t.BaseType)
{
foreach (var field in t.GetFields(
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
{
yield return field;
}
}
}
/// <summary>Whether a type belongs to a first-party ScadaBridge assembly.</summary>
/// <param name="type">The type to test.</param>
/// <returns>True when the declaring assembly is a ScadaBridge assembly.</returns>
private static bool IsScadaBridgeType(Type type) =>
type.Assembly.GetName().Name?.StartsWith(AssemblyPrefix, StringComparison.Ordinal) == true;
private const string AssemblyPrefix = "ZB.MOM.WW.ScadaBridge";
/// <summary>
/// Every concrete Akka actor class in the shipped ScadaBridge assemblies. Test assemblies
/// are excluded deliberately — the guard's own deliberately-violating fixtures live there,
/// and a test-only actor is not part of any production composition root.
/// </summary>
/// <returns>The production actor types, ordered by full name.</returns>
internal static IReadOnlyList<Type> ProductionActorTypes() =>
ProductionAssemblies()
.SelectMany(a => a.GetTypes())
.Where(t => typeof(ActorBase).IsAssignableFrom(t) && t is { IsAbstract: false, IsInterface: false })
.OrderBy(t => t.FullName, StringComparer.Ordinal)
.ToList();
/// <summary>
/// Transitively loads and returns the shipped ScadaBridge assemblies.
/// </summary>
/// <remarks>
/// Referenced assemblies load lazily, so enumerating <c>AppDomain.CurrentDomain</c> alone
/// would silently scan whichever subset the test run happened to touch — a guard that
/// quietly checks nothing. The reference graph is walked from the Host assembly instead,
/// and the caller asserts a floor on the actor count so a regression here fails loudly.
/// </remarks>
/// <returns>The distinct production ScadaBridge assemblies.</returns>
private static IReadOnlyList<Assembly> ProductionAssemblies()
{
var seen = new Dictionary<string, Assembly>(StringComparer.Ordinal);
var pending = new Stack<Assembly>();
pending.Push(typeof(Program).Assembly);
while (pending.Count > 0)
{
var assembly = pending.Pop();
var name = assembly.GetName().Name;
if (name is null
|| !name.StartsWith(AssemblyPrefix, StringComparison.Ordinal)
|| name.EndsWith(".Tests", StringComparison.Ordinal)
|| !seen.TryAdd(name, assembly))
{
continue;
}
foreach (var reference in assembly.GetReferencedAssemblies())
{
if (reference.Name?.StartsWith(AssemblyPrefix, StringComparison.Ordinal) != true
|| seen.ContainsKey(reference.Name))
{
continue;
}
try
{
pending.Push(Assembly.Load(reference));
}
catch (Exception ex) when (ex is FileNotFoundException or BadImageFormatException)
{
// A ScadaBridge assembly that is referenced but not deployed alongside the
// test binaries cannot contribute actors to this run either.
}
}
}
return seen.Values.ToList();
}
/// <summary>
/// Collects the service and implementation types registered with
/// <see cref="ServiceLifetime.Scoped"/> in a composition root.
/// </summary>
/// <remarks>
/// Both sides of a descriptor are collected: an actor can just as easily cache the
/// concrete <c>EmailNotificationDeliveryAdapter</c> as the <c>INotificationDeliveryAdapter</c>
/// it is registered as, and row 45 in fact cached the interface.
/// </remarks>
/// <param name="services">The composition root's service collection.</param>
/// <returns>The scoped service and implementation types.</returns>
internal static IReadOnlySet<Type> ScopedTypes(IEnumerable<ServiceDescriptor> services)
{
ArgumentNullException.ThrowIfNull(services);
var scoped = new HashSet<Type>();
foreach (var descriptor in services.Where(d => d.Lifetime == ServiceLifetime.Scoped))
{
scoped.Add(descriptor.ServiceType);
// ImplementationType throws on a keyed descriptor and vice versa, so branch.
var implementationType = descriptor.IsKeyedService
? descriptor.KeyedImplementationType
: descriptor.ImplementationType;
if (implementationType is not null)
{
scoped.Add(implementationType);
}
}
return scoped;
}
}