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");
}
///
/// Register row 61: the allowlist is now EMPTY and must stay that way.
///
///
/// This replaces the former staleness self-test, which checked that each entry still named
/// a field the scanner would flag. Its two entries — AuditLogIngestActor and
/// SiteCallAuditActor's _injectedRepository — covered repository fields
/// assigned only by test-mode constructors; those constructors have been deleted and the
/// fixtures fold onto the IServiceProvider 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.
///
///
[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.");
}
///
/// 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.
///
[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");
}
///
/// A minimal descriptor set naming the two repository types the deleted test-mode
/// constructors used to cache, 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));
}
}