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;
///
/// 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.
///
public class CaptiveDependencyGuardSelfTests
{
/// A stand-in for any service the container registers Scoped.
private interface IPretendScopedService;
///
/// POSITIVE CONTROL — the pre-row-45 shape, preserved permanently. An actor that caches a
/// scoped-typed service for its lifetime is exactly what NotificationOutboxActor
/// looked like before 9e769735, and the guard must report it.
///
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
}
/// POSITIVE CONTROL — caching the scope itself, the other half of row 45.
private sealed class CachesScopeActor : ReceiveActor
{
#pragma warning disable CS0169, IDE0051, CA1823
private readonly IServiceScope? _scope;
#pragma warning restore CS0169, IDE0051, CA1823
}
/// POSITIVE CONTROL — an EF context held for the actor's lifetime.
private sealed class CachesDbContextActor : ReceiveActor
{
#pragma warning disable CS0169, IDE0051, CA1823
private readonly ScadaBridgeDbContext? _db;
#pragma warning restore CS0169, IDE0051, CA1823
}
///
/// 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.
///
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
}
/// POSITIVE CONTROL — an auto-property backing field must be caught too.
private sealed class ScopedAutoPropertyActor : ReceiveActor
{
private IPretendScopedService? Captive { get; init; }
}
private static readonly IReadOnlySet PretendScopedSet =
new HashSet { typeof(IPretendScopedService) };
[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);
}
[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());
var violation = Assert.Single(violations);
Assert.Equal("_scope", violation.FieldName);
}
[Fact]
public void Guard_Flags_ActorCachingADbContext()
{
var violations = CaptiveDependencyGuard.Scan(
[typeof(CachesDbContextActor)], new HashSet());
var violation = Assert.Single(violations);
Assert.Equal(typeof(ScadaBridgeDbContext), violation.FieldType);
}
[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);
}
[Fact]
public void Guard_Allows_RootProviderAndScopeFactoryFields()
{
var violations = CaptiveDependencyGuard.Scan(
[typeof(CreatesScopePerOperationActor)], PretendScopedSet);
Assert.Empty(violations);
}
///
/// 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.
///
[Fact]
public void ScopedTypes_CollectsServiceAndImplementationTypes()
{
var services = new ServiceCollection();
services.AddScoped();
services.AddSingleton();
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;
///
/// 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.
///
[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");
}
///
/// The allowlist is an escape hatch, so it is itself pinned: every entry must name a
/// field that still exists and would still be flagged. An entry that has stopped matching
/// is either a stale exemption or — worse — a typo silently covering nothing.
///
[Fact]
public void Allowlist_EntriesAllStillMatchARealFlaggedField()
{
var actorTypes = CaptiveDependencyGuard.ProductionActorTypes();
// Scan with the allowlist disabled against a scoped set wide enough to include the
// repositories the allowlisted fields hold.
var everyScopedType = CaptiveDependencyGuard.ScopedTypes(BuildCentralishScopedProbe());
var unfiltered = CaptiveDependencyGuard.Scan(actorTypes, everyScopedType, applyAllowlist: false);
var flagged = unfiltered
.Select(v => $"{v.ActorType.FullName}.{v.FieldName}")
.ToHashSet(StringComparer.Ordinal);
foreach (var entry in CaptiveDependencyGuard.Allowlist)
{
Assert.True(
flagged.Contains(entry),
$"Allowlist entry '{entry}' no longer matches a flagged field. Remove it — a stale " +
"exemption is indistinguishable from an exemption that never worked.");
}
}
///
/// A minimal descriptor set naming the two repository types the allowlisted fields hold,
/// so does not need to boot a
/// host. Both are registered Scoped by AddConfigurationDatabase; the
/// role-scan tests below verify that against the real container.
///
/// The probe descriptors.
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;
}
}
///
/// Runs the guard against every shipped actor using the CENTRAL composition root's real
/// scoped registrations.
///
[Collection(HostBootCollection.Name)]
public class CentralCaptiveDependencyGuardTests : IDisposable
{
private readonly WebApplicationFactory _factory;
private readonly string? _previousEnv;
private readonly CentralDbTestEnvironment _dbEnv;
private readonly List _descriptors = [];
public CentralCaptiveDependencyGuardTests()
{
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
_dbEnv = new CentralDbTestEnvironment();
_factory = new WebApplicationFactory()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary
{
["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) ||
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(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;
}
public void Dispose()
{
try
{
_factory.Dispose();
}
finally
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
_dbEnv.Dispose();
}
}
[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));
}
}
///
/// 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.
///
public class SiteCaptiveDependencyGuardTests : IDisposable
{
private readonly string _tempDbPath;
private readonly string _tempTrackingDbPath;
private readonly string _tempLocalDbPath;
private readonly string _tempAuditDbPath;
private readonly IServiceCollection _services;
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
{
["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;
}
public void Dispose()
{
foreach (var path in new[] { _tempDbPath, _tempTrackingDbPath, _tempLocalDbPath, _tempAuditDbPath })
{
try { File.Delete(path); } catch { /* best effort */ }
}
}
[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));
}
}