6ae605160c
Replace dc=scadabridge,dc=local with dc=zb,dc=local in all dev/test LDAP references — app config, docker test-cluster node configs (docker/ and docker-env2/), GLAuth fixture, dev tooling, Host.Tests fixtures, IntegrationTests factory, and operational test_infra docs. OU structure (ou=SCADA-Admins,ou=users,etc.) preserved throughout. Email domains (@scadabridge.local), hostnames, and container names are untouched. Historical plan docs (2026-05-24-second-environment.md, 2026-05-31-folder-repo-rename-scadabridge-design.md) excluded as point-in-time records. No synthetic dc=example,dc=com placeholders touched.
418 lines
18 KiB
C#
418 lines
18 KiB
C#
using Akka.Configuration;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.ScadaBridge.AuditLog;
|
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
|
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
|
using ZB.MOM.WW.ScadaBridge.Host;
|
|
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
|
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
|
|
|
/// <summary>
|
|
/// Bundle E (M2 Task E1) — verifies the Audit Log (#23) DI surface is wired
|
|
/// into both composition roots and that the HOCON document emitted by
|
|
/// <see cref="AkkaHostedService.BuildHocon"/> includes the dedicated
|
|
/// <c>audit-telemetry-dispatcher</c> the site telemetry actor binds to.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Full cluster bring-up is exercised by the existing
|
|
/// <see cref="CompositionRootTests"/> pattern — these tests reuse the same
|
|
/// <see cref="AkkaHostedServiceRemover"/> trick to short-circuit
|
|
/// <see cref="AkkaHostedService.StartAsync"/> so DI resolution is exercised
|
|
/// without the actor system actually being created.
|
|
/// </para>
|
|
/// </remarks>
|
|
public class AkkaHostedServiceAuditWiringHoconTests
|
|
{
|
|
[Fact]
|
|
public void BuildHocon_Emits_AuditTelemetryDispatcher_Block()
|
|
{
|
|
// Bundle E acceptance: the HOCON document the host parses must declare
|
|
// the dedicated dispatcher the SiteAuditTelemetryActor binds to. A
|
|
// missing dispatcher block would route the actor to the default
|
|
// dispatcher and silently lose the isolation guarantee.
|
|
var nodeOptions = new NodeOptions
|
|
{
|
|
Role = "Site",
|
|
NodeHostname = "site-test-1",
|
|
RemotingPort = 0,
|
|
SiteId = "TestSite",
|
|
};
|
|
var clusterOptions = new ClusterOptions
|
|
{
|
|
SeedNodes = new List<string> { "akka.tcp://scadabridge@localhost:2551" },
|
|
SplitBrainResolverStrategy = "keep-oldest",
|
|
MinNrOfMembers = 1,
|
|
StableAfter = TimeSpan.FromSeconds(15),
|
|
HeartbeatInterval = TimeSpan.FromSeconds(2),
|
|
FailureDetectionThreshold = TimeSpan.FromSeconds(10),
|
|
};
|
|
|
|
var hocon = AkkaHostedService.BuildHocon(
|
|
nodeOptions,
|
|
clusterOptions,
|
|
new[] { "Site", "site-TestSite" },
|
|
TimeSpan.FromSeconds(5),
|
|
TimeSpan.FromSeconds(15));
|
|
|
|
var config = ConfigurationFactory.ParseString(hocon);
|
|
|
|
// The dispatcher is declared at the root, so the lookup is by its
|
|
// unqualified name. The HOCON parser must accept the block as a
|
|
// standalone dispatcher definition the actor system can resolve.
|
|
var dispatcherType = config.GetString("audit-telemetry-dispatcher.type");
|
|
Assert.Equal("ForkJoinDispatcher", dispatcherType);
|
|
|
|
var throughput = config.GetInt("audit-telemetry-dispatcher.throughput");
|
|
Assert.Equal(100, throughput);
|
|
|
|
var threadCount = config.GetInt("audit-telemetry-dispatcher.dedicated-thread-pool.thread-count");
|
|
Assert.Equal(2, threadCount);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies Audit Log (#23) services land in the Central composition root.
|
|
/// </summary>
|
|
public class CentralAuditWiringTests : IDisposable
|
|
{
|
|
private readonly WebApplicationFactory<Program> _factory;
|
|
private readonly string? _previousEnv;
|
|
|
|
public CentralAuditWiringTests()
|
|
{
|
|
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
|
|
|
// Supply the pepper so the Central-role StartupValidator preflight (1fcc4f5)
|
|
// passes. The pre-host config builder uses AddEnvironmentVariables(), which
|
|
// runs before WithWebHostBuilder.ConfigureAppConfiguration applies DI config.
|
|
Environment.SetEnvironmentVariable("ScadaBridge__InboundApi__ApiKeyPepper",
|
|
CentralDbTestEnvironment.TestPepper);
|
|
|
|
_factory = new WebApplicationFactory<Program>()
|
|
.WithWebHostBuilder(builder =>
|
|
{
|
|
builder.ConfigureAppConfiguration((_, config) =>
|
|
{
|
|
config.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["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!",
|
|
// Task 1.4: LDAP settings nest under Security:Ldap (shared LdapOptions).
|
|
// ServiceAccountDn is now required by the library's LdapOptionsValidator
|
|
// (ValidateOnStart), so it must be present for the host to start.
|
|
["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($"CentralAuditWiringTests_{Guid.NewGuid()}"));
|
|
|
|
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
|
|
});
|
|
});
|
|
|
|
_ = _factory.Server;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_factory.Dispose();
|
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
|
|
Environment.SetEnvironmentVariable("ScadaBridge__InboundApi__ApiKeyPepper", null);
|
|
}
|
|
|
|
[Fact]
|
|
public void Central_Resolves_IAuditWriter_AsFallbackAuditWriter()
|
|
{
|
|
// Central nodes still register the writer chain because AddAuditLog is
|
|
// shared between roles — the registrations are lazy singletons and the
|
|
// writer is never resolved on a central node in production. Asserting
|
|
// it resolves here confirms the chain is intact and ready for the
|
|
// future case where a central-only actor needs to emit audit events.
|
|
var writer = _factory.Services.GetService<IAuditWriter>();
|
|
Assert.NotNull(writer);
|
|
Assert.IsType<FallbackAuditWriter>(writer);
|
|
}
|
|
|
|
[Fact]
|
|
public void Central_Resolves_AuditLogOptions()
|
|
{
|
|
var opts = _factory.Services.GetService<IOptions<ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.AuditLogOptions>>();
|
|
Assert.NotNull(opts);
|
|
Assert.NotNull(opts!.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void Central_Resolves_SqliteAuditWriterOptions()
|
|
{
|
|
var opts = _factory.Services.GetService<IOptions<SqliteAuditWriterOptions>>();
|
|
Assert.NotNull(opts);
|
|
Assert.NotNull(opts!.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void Central_Resolves_SiteAuditTelemetryOptions()
|
|
{
|
|
var opts = _factory.Services.GetService<IOptions<SiteAuditTelemetryOptions>>();
|
|
Assert.NotNull(opts);
|
|
Assert.NotNull(opts!.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void Central_Resolves_ISiteStreamAuditClient_AsNoOpDefault()
|
|
{
|
|
var client = _factory.Services.GetService<ISiteStreamAuditClient>();
|
|
Assert.NotNull(client);
|
|
Assert.IsType<NoOpSiteStreamAuditClient>(client);
|
|
}
|
|
|
|
/// <summary>
|
|
/// M3 Bundle F (T15): the Central composition root calls
|
|
/// <c>AddSiteCallAudit()</c>. Today that extension is a no-op placeholder,
|
|
/// but invoking it must not throw and the central host's service collection
|
|
/// must build successfully — the actor's Props are constructed inline in
|
|
/// <c>AkkaHostedService</c> (via the root <see cref="IServiceProvider"/>),
|
|
/// not from a DI factory. Asserting the host built confirms the wiring
|
|
/// call is in place; this test guards against accidentally removing it
|
|
/// from <c>Program.cs</c>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Central_HostBuilds_With_AddSiteCallAudit_Wired()
|
|
{
|
|
// Reaching _factory.Services means WebApplicationFactory built the host
|
|
// (DI validation completed). The fact this test is in the
|
|
// CentralAuditWiringTests fixture means it ran against the Central
|
|
// composition root path through Program.cs.
|
|
Assert.NotNull(_factory.Services);
|
|
}
|
|
|
|
/// <summary>
|
|
/// M3 Bundle F: the Central composition root registers
|
|
/// <c>ICachedCallTelemetryForwarder</c> as a lazy singleton (the
|
|
/// forwarder degrades to audit-only emission when the site-only
|
|
/// <c>IOperationTrackingStore</c> is absent, matching the M2 lazy chain
|
|
/// pattern). The binding is exercised here so a future regression that
|
|
/// removes the registration or makes IOperationTrackingStore mandatory
|
|
/// fails on the Central node, not just at first script execution.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Central_Resolves_ICachedCallTelemetryForwarder_LazySingleton()
|
|
{
|
|
var forwarder = _factory.Services.GetService<ICachedCallTelemetryForwarder>();
|
|
Assert.NotNull(forwarder);
|
|
Assert.IsType<CachedCallTelemetryForwarder>(forwarder);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies Audit Log (#23) services land in the Site composition root.
|
|
/// </summary>
|
|
public class SiteAuditWiringTests : IDisposable
|
|
{
|
|
private readonly WebApplication _host;
|
|
private readonly string _tempDbPath;
|
|
|
|
public SiteAuditWiringTests()
|
|
{
|
|
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_audit_wiring_{Guid.NewGuid()}.db");
|
|
|
|
var builder = WebApplication.CreateBuilder();
|
|
builder.Configuration.Sources.Clear();
|
|
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["ScadaBridge:Node:Role"] = "Site",
|
|
["ScadaBridge:Node:NodeHostname"] = "test-site",
|
|
["ScadaBridge:Node:SiteId"] = "TestSite",
|
|
["ScadaBridge:Node:RemotingPort"] = "0",
|
|
["ScadaBridge:Node:GrpcPort"] = "0",
|
|
["ScadaBridge:Database:SiteDbPath"] = _tempDbPath,
|
|
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
|
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
|
// SqliteAuditWriter would attempt to open a SQLite file when first
|
|
// resolved; point it at an in-memory connection so the test doesn't
|
|
// pollute the working directory.
|
|
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
|
|
});
|
|
|
|
builder.Services.AddGrpc();
|
|
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
|
|
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
|
|
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
|
|
|
|
_host = builder.Build();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
(_host as IDisposable)?.Dispose();
|
|
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_Resolves_IAuditWriter_AsFallbackAuditWriter()
|
|
{
|
|
var writer = _host.Services.GetService<IAuditWriter>();
|
|
Assert.NotNull(writer);
|
|
Assert.IsType<FallbackAuditWriter>(writer);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_Resolves_SqliteAuditWriter_AsSingleton()
|
|
{
|
|
var a = _host.Services.GetService<SqliteAuditWriter>();
|
|
var b = _host.Services.GetService<SqliteAuditWriter>();
|
|
Assert.NotNull(a);
|
|
Assert.NotNull(b);
|
|
Assert.Same(a, b);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_ISiteAuditQueue_AndSqliteAuditWriter_AreSameInstance()
|
|
{
|
|
// The telemetry actor reads from ISiteAuditQueue while ScriptRuntimeContext
|
|
// writes through IAuditWriter → SqliteAuditWriter. If these don't resolve
|
|
// to the same instance, pending rows are invisible to the actor.
|
|
var queue = _host.Services.GetService<ISiteAuditQueue>();
|
|
var writer = _host.Services.GetService<SqliteAuditWriter>();
|
|
Assert.NotNull(queue);
|
|
Assert.NotNull(writer);
|
|
Assert.Same(writer, queue);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_Resolves_RingBufferFallback()
|
|
{
|
|
var ring = _host.Services.GetService<RingBufferFallback>();
|
|
Assert.NotNull(ring);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_Resolves_IAuditWriteFailureCounter_AsHealthMetricsBridge()
|
|
{
|
|
// Bundle G (M2-T11): site composition root calls
|
|
// AddAuditLogHealthMetricsBridge() after AddAuditLog + AddSiteHealthMonitoring,
|
|
// which swaps the NoOp default for the real health-metrics bridge so
|
|
// FallbackAuditWriter primary failures surface in the site health
|
|
// report payload as SiteAuditWriteFailures.
|
|
var counter = _host.Services.GetService<IAuditWriteFailureCounter>();
|
|
Assert.NotNull(counter);
|
|
Assert.IsType<HealthMetricsAuditWriteFailureCounter>(counter);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_Resolves_ISiteStreamAuditClient_AsNoOpDefault()
|
|
{
|
|
var client = _host.Services.GetService<ISiteStreamAuditClient>();
|
|
Assert.NotNull(client);
|
|
Assert.IsType<NoOpSiteStreamAuditClient>(client);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_Resolves_SiteAuditTelemetryOptions_WithDefaults()
|
|
{
|
|
var opts = _host.Services.GetService<IOptions<SiteAuditTelemetryOptions>>();
|
|
Assert.NotNull(opts);
|
|
Assert.Equal(256, opts!.Value.BatchSize);
|
|
Assert.Equal(5, opts.Value.BusyIntervalSeconds);
|
|
Assert.Equal(30, opts.Value.IdleIntervalSeconds);
|
|
}
|
|
|
|
/// <summary>
|
|
/// M3 Bundle F (T15): the site composition root resolves the cached-call
|
|
/// telemetry forwarder. ScriptExecutionActor consumes this through
|
|
/// <c>GetService<ICachedCallTelemetryForwarder>()</c> on every script
|
|
/// execution; a missing registration would silently degrade
|
|
/// <c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c> to the
|
|
/// "no-emission" path and break the M3 audit pipeline.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Site_Resolves_ICachedCallTelemetryForwarder()
|
|
{
|
|
var forwarder = _host.Services.GetService<ICachedCallTelemetryForwarder>();
|
|
Assert.NotNull(forwarder);
|
|
Assert.IsType<CachedCallTelemetryForwarder>(forwarder);
|
|
}
|
|
|
|
/// <summary>
|
|
/// M3 Bundle F (T15): the site composition root resolves the lifecycle
|
|
/// bridge that translates S&F retry-loop attempt notifications into
|
|
/// cached-call telemetry packets.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Site_Resolves_CachedCallLifecycleBridge_AsSingleton()
|
|
{
|
|
var a = _host.Services.GetService<CachedCallLifecycleBridge>();
|
|
var b = _host.Services.GetService<CachedCallLifecycleBridge>();
|
|
Assert.NotNull(a);
|
|
Assert.NotNull(b);
|
|
Assert.Same(a, b);
|
|
}
|
|
|
|
/// <summary>
|
|
/// M3 Bundle F (T15): the lifecycle bridge is bound to the
|
|
/// <see cref="ICachedCallLifecycleObserver"/> contract that
|
|
/// StoreAndForwardService consults at construction time. Without this
|
|
/// binding the S&F service is built with a null observer and the
|
|
/// retry-loop telemetry never reaches the audit pipeline.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Site_ICachedCallLifecycleObserver_IsTheLifecycleBridge()
|
|
{
|
|
var observer = _host.Services.GetService<ICachedCallLifecycleObserver>();
|
|
var bridge = _host.Services.GetService<CachedCallLifecycleBridge>();
|
|
Assert.NotNull(observer);
|
|
Assert.NotNull(bridge);
|
|
Assert.Same(bridge, observer);
|
|
}
|
|
|
|
/// <summary>
|
|
/// M3 Bundle F (T15): the Host registers an
|
|
/// <see cref="IStoreAndForwardSiteContext"/> adapter so the S&F service
|
|
/// can resolve the site id at composition time WITHOUT introducing a
|
|
/// StoreAndForward → HealthMonitoring project-reference cycle.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Site_Resolves_IStoreAndForwardSiteContext_FromHost()
|
|
{
|
|
var ctx = _host.Services.GetService<IStoreAndForwardSiteContext>();
|
|
Assert.NotNull(ctx);
|
|
Assert.Equal("TestSite", ctx!.SiteId);
|
|
}
|
|
}
|