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.
422 lines
19 KiB
C#
422 lines
19 KiB
C#
using Akka.Actor;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
|
|
|
/// <summary>
|
|
/// The guard's own self-tests. These are what make the production scans below trustworthy:
|
|
/// a reflection guard that silently matches nothing passes just as green as one that works,
|
|
/// so the positive controls are permanent fixtures, not scaffolding.
|
|
/// </summary>
|
|
public class CaptiveDependencyGuardSelfTests
|
|
{
|
|
/// <summary>A stand-in for any service the container registers <c>Scoped</c>.</summary>
|
|
private interface IPretendScopedService;
|
|
|
|
/// <summary>
|
|
/// POSITIVE CONTROL — the original captive-dependency shape, preserved permanently. An actor that caches a
|
|
/// scoped-typed service for its lifetime is exactly what <c>NotificationOutboxActor</c>
|
|
/// looked like before 9e769735, and the guard must report it.
|
|
/// </summary>
|
|
private sealed class CachesScopedServiceActor : ReceiveActor
|
|
{
|
|
#pragma warning disable CS0169, IDE0051, CA1823 // the unused field IS the fixture
|
|
private readonly IPretendScopedService? _captive;
|
|
#pragma warning restore CS0169, IDE0051, CA1823
|
|
}
|
|
|
|
/// <summary>POSITIVE CONTROL — caching the scope itself, the other half of row 45.</summary>
|
|
private sealed class CachesScopeActor : ReceiveActor
|
|
{
|
|
#pragma warning disable CS0169, IDE0051, CA1823
|
|
private readonly IServiceScope? _scope;
|
|
#pragma warning restore CS0169, IDE0051, CA1823
|
|
}
|
|
|
|
/// <summary>POSITIVE CONTROL — an EF context held for the actor's lifetime.</summary>
|
|
private sealed class CachesDbContextActor : ReceiveActor
|
|
{
|
|
#pragma warning disable CS0169, IDE0051, CA1823
|
|
private readonly ScadaBridgeDbContext? _db;
|
|
#pragma warning restore CS0169, IDE0051, CA1823
|
|
}
|
|
|
|
/// <summary>
|
|
/// NEGATIVE CONTROL — the correct pattern. Holding the root provider and/or the scope
|
|
/// factory for the actor's lifetime must NOT be flagged; every production actor does this.
|
|
/// </summary>
|
|
private sealed class CreatesScopePerOperationActor : ReceiveActor
|
|
{
|
|
#pragma warning disable CS0169, IDE0051, CA1823
|
|
private readonly IServiceProvider? _services;
|
|
private readonly IServiceScopeFactory? _scopeFactory;
|
|
#pragma warning restore CS0169, IDE0051, CA1823
|
|
}
|
|
|
|
/// <summary>POSITIVE CONTROL — an auto-property backing field must be caught too.</summary>
|
|
private sealed class ScopedAutoPropertyActor : ReceiveActor
|
|
{
|
|
private IPretendScopedService? Captive { get; init; }
|
|
}
|
|
|
|
private static readonly IReadOnlySet<Type> PretendScopedSet =
|
|
new HashSet<Type> { typeof(IPretendScopedService) };
|
|
|
|
/// <summary>Verifies that the guard flags an actor that caches a scoped service.</summary>
|
|
[Fact]
|
|
public void Guard_Flags_ActorCachingAScopedService()
|
|
{
|
|
var violations = CaptiveDependencyGuard.Scan(
|
|
[typeof(CachesScopedServiceActor)], PretendScopedSet);
|
|
|
|
var violation = Assert.Single(violations);
|
|
Assert.Equal("_captive", violation.FieldName);
|
|
Assert.Equal(typeof(IPretendScopedService), violation.FieldType);
|
|
}
|
|
|
|
/// <summary>Verifies that the guard flags an actor that caches a service scope even when the scoped set is empty.</summary>
|
|
[Fact]
|
|
public void Guard_Flags_ActorCachingAServiceScope_EvenWhenTheScopedSetIsEmpty()
|
|
{
|
|
// An IServiceScope field is wrong whatever the container says, so this must not
|
|
// depend on the derived scoped set having caught anything.
|
|
var violations = CaptiveDependencyGuard.Scan(
|
|
[typeof(CachesScopeActor)], new HashSet<Type>());
|
|
|
|
var violation = Assert.Single(violations);
|
|
Assert.Equal("_scope", violation.FieldName);
|
|
}
|
|
|
|
/// <summary>Verifies that the guard flags an actor that caches a DbContext.</summary>
|
|
[Fact]
|
|
public void Guard_Flags_ActorCachingADbContext()
|
|
{
|
|
var violations = CaptiveDependencyGuard.Scan(
|
|
[typeof(CachesDbContextActor)], new HashSet<Type>());
|
|
|
|
var violation = Assert.Single(violations);
|
|
Assert.Equal(typeof(ScadaBridgeDbContext), violation.FieldType);
|
|
}
|
|
|
|
/// <summary>Verifies that the guard flags the compiler-generated backing field of an auto-property of a scoped type.</summary>
|
|
[Fact]
|
|
public void Guard_Flags_ScopedAutoPropertyBackingField()
|
|
{
|
|
var violations = CaptiveDependencyGuard.Scan(
|
|
[typeof(ScopedAutoPropertyActor)], PretendScopedSet);
|
|
|
|
var violation = Assert.Single(violations);
|
|
Assert.Contains("Captive", violation.FieldName, StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>Verifies that the guard allows fields that hold the root provider or a scope factory.</summary>
|
|
[Fact]
|
|
public void Guard_Allows_RootProviderAndScopeFactoryFields()
|
|
{
|
|
var violations = CaptiveDependencyGuard.Scan(
|
|
[typeof(CreatesScopePerOperationActor)], PretendScopedSet);
|
|
|
|
Assert.Empty(violations);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The scoped set must be derived from real registrations rather than hand-listed, and it
|
|
/// must pick up BOTH sides of a descriptor — row 45 cached the interface, but caching the
|
|
/// concrete adapter would have been the same bug.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ScopedTypes_CollectsServiceAndImplementationTypes()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddScoped<IPretendScopedService, PretendScopedService>();
|
|
services.AddSingleton<PretendSingleton>();
|
|
|
|
var scoped = CaptiveDependencyGuard.ScopedTypes(services);
|
|
|
|
Assert.Contains(typeof(IPretendScopedService), scoped);
|
|
Assert.Contains(typeof(PretendScopedService), scoped);
|
|
Assert.DoesNotContain(typeof(PretendSingleton), scoped);
|
|
}
|
|
|
|
private sealed class PretendScopedService : IPretendScopedService;
|
|
|
|
private sealed class PretendSingleton;
|
|
|
|
/// <summary>
|
|
/// The assembly walk must actually find the production actors. Without this floor a
|
|
/// broken loader would make every scan below vacuously green — the classic way a
|
|
/// reflection guard rots into a no-op.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ProductionActorTypes_FindsTheShippedActors()
|
|
{
|
|
var actorTypes = CaptiveDependencyGuard.ProductionActorTypes();
|
|
|
|
// 28 actor classes ship today; the floor is deliberately loose so adding or
|
|
// consolidating an actor does not fail this, while a loader that finds nothing does.
|
|
Assert.True(
|
|
actorTypes.Count >= 20,
|
|
$"Expected the ScadaBridge assembly walk to find at least 20 actors, found {actorTypes.Count}. " +
|
|
"A guard that scans no types passes vacuously.");
|
|
|
|
// Spot-check one actor from a site assembly and one from a central assembly, so a
|
|
// walk that only reached the Host assembly's own actors cannot satisfy the floor.
|
|
Assert.Contains(actorTypes, t => t.Name == "InstanceActor");
|
|
Assert.Contains(actorTypes, t => t.Name == "NotificationOutboxActor");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Register row 61: the allowlist is now EMPTY and must stay that way.
|
|
///
|
|
/// <para>
|
|
/// This replaces the former staleness self-test, which checked that each entry still named
|
|
/// a field the scanner would flag. Its two entries — <c>AuditLogIngestActor</c> and
|
|
/// <c>SiteCallAuditActor</c>'s <c>_injectedRepository</c> — covered repository fields
|
|
/// assigned only by test-mode constructors; those constructors have been deleted and the
|
|
/// fixtures fold onto the <c>IServiceProvider</c> overload, so there is no longer a field
|
|
/// to exempt. An empty allowlist is a strictly stronger statement than a correct one:
|
|
/// nothing is exempt, so nothing can rot.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void Allowlist_IsEmpty_NoActorExemptFromTheCaptiveDependencyScan()
|
|
{
|
|
Assert.True(
|
|
CaptiveDependencyGuard.Allowlist.Count == 0,
|
|
"The captive-dependency allowlist must stay empty (register row 61). Entries present: " +
|
|
string.Join(", ", CaptiveDependencyGuard.Allowlist) +
|
|
". An entry means an actor caches a scoped service on a field — fix the actor " +
|
|
"(fold test fixtures onto the IServiceProvider overload) rather than exempting it.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Register row 61 (the other half of the pin): with the allowlist empty, the two actors
|
|
/// that used to need exemptions must now scan CLEAN against the scoped repository types
|
|
/// they resolve. This is what makes the empty allowlist meaningful — it proves the entries
|
|
/// were removed because the fields are gone, not because the scan stopped looking.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FormerlyAllowlistedActors_NowScanClean()
|
|
{
|
|
var actorTypes = CaptiveDependencyGuard.ProductionActorTypes();
|
|
var everyScopedType = CaptiveDependencyGuard.ScopedTypes(BuildCentralishScopedProbe());
|
|
|
|
// Allowlist disabled, so a surviving _injectedRepository-shaped field would still show.
|
|
var unfiltered = CaptiveDependencyGuard.Scan(actorTypes, everyScopedType, applyAllowlist: false);
|
|
|
|
var offenders = unfiltered
|
|
.Where(v => v.ActorType.Name is "AuditLogIngestActor" or "SiteCallAuditActor")
|
|
.Select(v => v.ToString())
|
|
.ToList();
|
|
|
|
Assert.True(offenders.Count == 0,
|
|
"AuditLogIngestActor / SiteCallAuditActor must hold no scoped repository field " +
|
|
"(register row 61). Found: " + string.Join(" | ", offenders));
|
|
|
|
// Sanity: the scan really did examine those two actors, so the clean result above is
|
|
// evidence rather than an empty walk.
|
|
Assert.Contains(actorTypes, t => t.Name == "AuditLogIngestActor");
|
|
Assert.Contains(actorTypes, t => t.Name == "SiteCallAuditActor");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A minimal descriptor set naming the two repository types the deleted test-mode
|
|
/// constructors used to cache, so <see cref="FormerlyAllowlistedActors_NowScanClean"/> does
|
|
/// not need to boot a host. Both are registered <c>Scoped</c> by
|
|
/// <c>AddConfigurationDatabase</c>; the role-scan tests below verify that against the real
|
|
/// container.
|
|
/// </summary>
|
|
/// <returns>The probe descriptors.</returns>
|
|
private static IServiceCollection BuildCentralishScopedProbe()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddScoped<
|
|
ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.IAuditLogRepository>(
|
|
_ => throw new NotSupportedException("registration shape only"));
|
|
services.AddScoped<
|
|
ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.ISiteCallAuditRepository>(
|
|
_ => throw new NotSupportedException("registration shape only"));
|
|
return services;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs the guard against every shipped actor using the CENTRAL composition root's real
|
|
/// scoped registrations.
|
|
/// </summary>
|
|
[Collection(HostBootCollection.Name)]
|
|
public class CentralCaptiveDependencyGuardTests : IDisposable
|
|
{
|
|
private readonly WebApplicationFactory<Program> _factory;
|
|
private readonly string? _previousEnv;
|
|
private readonly CentralDbTestEnvironment _dbEnv;
|
|
private readonly List<ServiceDescriptor> _descriptors = [];
|
|
|
|
/// <summary>Initializes a new instance of the <c>CentralCaptiveDependencyGuardTests</c> class.</summary>
|
|
public CentralCaptiveDependencyGuardTests()
|
|
{
|
|
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
|
_dbEnv = new CentralDbTestEnvironment();
|
|
|
|
_factory = new WebApplicationFactory<Program>()
|
|
.WithWebHostBuilder(builder =>
|
|
{
|
|
builder.ConfigureAppConfiguration((_, config) =>
|
|
{
|
|
config.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["ScadaBridge:Node:NodeName"] = "central-a",
|
|
["ScadaBridge:Node:NodeHostname"] = "localhost",
|
|
["ScadaBridge:Node:RemotingPort"] = "0",
|
|
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
|
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
|
["ScadaBridge:Database:SkipMigrations"] = "true",
|
|
["ScadaBridge:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
|
|
["ScadaBridge:Security:Ldap:Server"] = "localhost",
|
|
["ScadaBridge:Security:Ldap:Port"] = "3893",
|
|
["ScadaBridge:Security:Ldap:Transport"] = "None",
|
|
["ScadaBridge:Security:Ldap:AllowInsecure"] = "true",
|
|
["ScadaBridge:Security:Ldap:SearchBase"] = "dc=zb,dc=local",
|
|
["ScadaBridge:Security:Ldap:ServiceAccountDn"] = "cn=admin,dc=zb,dc=local",
|
|
["ScadaBridge:InboundApi:ApiKeyPepper"] = "test-inbound-api-key-pepper-at-least-32-chars!",
|
|
});
|
|
});
|
|
builder.UseSetting("ScadaBridge:Node:Role", "Central");
|
|
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
|
|
builder.ConfigureServices(services =>
|
|
{
|
|
var descriptorsToRemove = services
|
|
.Where(d =>
|
|
d.ServiceType == typeof(DbContextOptions<ScadaBridgeDbContext>) ||
|
|
d.ServiceType == typeof(DbContextOptions) ||
|
|
d.ServiceType == typeof(ScadaBridgeDbContext) ||
|
|
d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true)
|
|
.ToList();
|
|
foreach (var d in descriptorsToRemove)
|
|
services.Remove(d);
|
|
|
|
services.AddDbContext<ScadaBridgeDbContext>(options =>
|
|
options.UseInMemoryDatabase($"CaptiveGuard_{Guid.NewGuid()}"));
|
|
|
|
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
|
|
|
|
// ConfigureServices runs after Program.cs has added everything, so this
|
|
// snapshot IS the Central composition root's registration set.
|
|
_descriptors.AddRange(services);
|
|
});
|
|
});
|
|
|
|
_ = _factory.Server;
|
|
}
|
|
|
|
/// <summary>Disposes the web application factory.</summary>
|
|
public void Dispose()
|
|
{
|
|
try
|
|
{
|
|
_factory.Dispose();
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
|
|
_dbEnv.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that no actor in the central composition root holds captive scoped state.</summary>
|
|
[Fact]
|
|
public void Central_NoActorHoldsCaptiveScopedState()
|
|
{
|
|
var scopedTypes = CaptiveDependencyGuard.ScopedTypes(_descriptors);
|
|
Assert.NotEmpty(scopedTypes);
|
|
|
|
var violations = CaptiveDependencyGuard.Scan(
|
|
CaptiveDependencyGuard.ProductionActorTypes(), scopedTypes);
|
|
|
|
Assert.True(
|
|
violations.Count == 0,
|
|
"Actors holding captive scope state (register row 45's bug class):\n "
|
|
+ string.Join("\n ", violations));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The same scan against the SITE composition root's real scoped registrations. Both roles
|
|
/// are checked because the scoped set differs between them — a type that is a singleton on
|
|
/// central can be scoped on a site, and only the role that registers it scoped can catch an
|
|
/// actor caching it.
|
|
/// </summary>
|
|
public class SiteCaptiveDependencyGuardTests : IDisposable
|
|
{
|
|
private readonly string _tempDbPath;
|
|
private readonly string _tempTrackingDbPath;
|
|
private readonly string _tempLocalDbPath;
|
|
private readonly string _tempAuditDbPath;
|
|
private readonly IServiceCollection _services;
|
|
|
|
/// <summary>Initializes a new instance of the <c>SiteCaptiveDependencyGuardTests</c> class.</summary>
|
|
public SiteCaptiveDependencyGuardTests()
|
|
{
|
|
_tempDbPath = Path.Combine(Path.GetTempPath(), $"sb_guard_{Guid.NewGuid()}.db");
|
|
_tempTrackingDbPath = Path.Combine(Path.GetTempPath(), $"sb_guard_track_{Guid.NewGuid()}.db");
|
|
_tempLocalDbPath = Path.Combine(Path.GetTempPath(), $"sb_guard_localdb_{Guid.NewGuid()}.db");
|
|
_tempAuditDbPath = Path.Combine(Path.GetTempPath(), $"sb_guard_audit_{Guid.NewGuid()}.db");
|
|
|
|
var builder = WebApplication.CreateBuilder();
|
|
builder.Configuration.Sources.Clear();
|
|
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["ScadaBridge:Node:Role"] = "Site",
|
|
["ScadaBridge:Node:NodeName"] = "node-a",
|
|
["ScadaBridge:Node:NodeHostname"] = "test-site",
|
|
["ScadaBridge:Node:SiteId"] = "TestSite",
|
|
["ScadaBridge:Node:RemotingPort"] = "0",
|
|
["ScadaBridge:Node:GrpcPort"] = "0",
|
|
["ScadaBridge:Database:SiteDbPath"] = _tempDbPath,
|
|
["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={_tempTrackingDbPath}",
|
|
["LocalDb:Path"] = _tempLocalDbPath,
|
|
["AuditLog:SiteWriter:DatabasePath"] = _tempAuditDbPath,
|
|
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
|
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
|
});
|
|
|
|
builder.Services.AddGrpc();
|
|
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
|
|
|
|
// Registration only — the provider is never built here, so nothing resolves and no
|
|
// site database file is opened.
|
|
_services = builder.Services;
|
|
}
|
|
|
|
/// <summary>Deletes the temporary database files, ignoring failures.</summary>
|
|
public void Dispose()
|
|
{
|
|
foreach (var path in new[] { _tempDbPath, _tempTrackingDbPath, _tempLocalDbPath, _tempAuditDbPath })
|
|
{
|
|
try { File.Delete(path); } catch { /* best effort */ }
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that no actor in the site composition root holds captive scoped state.</summary>
|
|
[Fact]
|
|
public void Site_NoActorHoldsCaptiveScopedState()
|
|
{
|
|
var scopedTypes = CaptiveDependencyGuard.ScopedTypes(_services);
|
|
Assert.NotEmpty(scopedTypes);
|
|
|
|
var violations = CaptiveDependencyGuard.Scan(
|
|
CaptiveDependencyGuard.ProductionActorTypes(), scopedTypes);
|
|
|
|
Assert.True(
|
|
violations.Count == 0,
|
|
"Actors holding captive scope state (register row 45's bug class):\n "
|
|
+ string.Join("\n ", violations));
|
|
}
|
|
}
|