refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,205 @@
using Akka.Actor;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.Host;
using ZB.MOM.WW.ScadaBridge.Host.Actors;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
[CollectionDefinition("ActorSystem")]
public class ActorSystemCollection : ICollectionFixture<object> { }
/// <summary>
/// Verifies that all expected Central-role actors are created at the correct paths
/// when AkkaHostedService starts.
/// </summary>
[Collection("ActorSystem")]
public class CentralActorPathTests : IAsyncLifetime
{
private WebApplicationFactory<Program>? _factory;
private ActorSystem? _actorSystem;
private string? _previousEnv;
public Task InitializeAsync()
{
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
_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:25510",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:25520",
["ScadaBridge:Cluster:MinNrOfMembers"] = "1",
["ScadaBridge:Database:SkipMigrations"] = "true",
["ScadaBridge:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
["ScadaBridge:Security:LdapServer"] = "localhost",
["ScadaBridge:Security:LdapPort"] = "3893",
["ScadaBridge:Security:LdapUseTls"] = "false",
["ScadaBridge:Security:AllowInsecureLdap"] = "true",
["ScadaBridge:Security:LdapSearchBase"] = "dc=scadabridge,dc=local",
});
});
builder.UseSetting("ScadaBridge:Node:Role", "Central");
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
builder.ConfigureServices(services =>
{
// Replace SQL Server with in-memory database
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($"ActorPathTests_{Guid.NewGuid()}"));
});
});
// CreateClient triggers host startup including AkkaHostedService
_ = _factory.CreateClient();
var akkaService = _factory.Services.GetRequiredService<AkkaHostedService>();
_actorSystem = akkaService.ActorSystem;
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
_factory?.Dispose();
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
await Task.CompletedTask;
}
[Fact]
public async Task CentralActors_DeadLetterMonitor_Exists()
=> await AssertActorExists("/user/dead-letter-monitor");
[Fact]
public async Task CentralActors_CentralCommunication_Exists()
=> await AssertActorExists("/user/central-communication");
[Fact]
public async Task CentralActors_Management_Exists()
=> await AssertActorExists("/user/management");
[Fact]
public async Task CentralActors_NotificationOutboxSingleton_Exists()
=> await AssertActorExists("/user/notification-outbox-singleton");
[Fact]
public async Task CentralActors_NotificationOutboxProxy_Exists()
=> await AssertActorExists("/user/notification-outbox-proxy");
private async Task AssertActorExists(string path)
{
Assert.NotNull(_actorSystem);
var selection = _actorSystem!.ActorSelection(path);
var identity = await selection.Ask<ActorIdentity>(
new Identify(path), TimeSpan.FromSeconds(5));
Assert.NotNull(identity.Subject);
}
}
/// <summary>
/// Verifies that all expected Site-role actors are created at the correct paths
/// when AkkaHostedService starts.
/// </summary>
[Collection("ActorSystem")]
public class SiteActorPathTests : IAsyncLifetime
{
private IHost? _host;
private ActorSystem? _actorSystem;
private string _tempDbPath = null!;
public async Task InitializeAsync()
{
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_actor_test_{Guid.NewGuid()}.db");
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder();
builder.ConfigureAppConfiguration(config =>
{
config.Sources.Clear();
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:Role"] = "Site",
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:SiteId"] = "TestSite",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:25510",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:25520",
["ScadaBridge:Cluster:MinNrOfMembers"] = "1",
["ScadaBridge:Database:SiteDbPath"] = _tempDbPath,
// Configure a dummy central contact point to trigger ClusterClient creation
["ScadaBridge:Communication:CentralContactPoints:0"] = "akka.tcp://scadabridge@localhost:25510",
});
});
builder.ConfigureServices((context, services) =>
{
SiteServiceRegistration.Configure(services, context.Configuration);
});
_host = builder.Build();
await _host.StartAsync();
var akkaService = _host.Services.GetRequiredService<AkkaHostedService>();
_actorSystem = akkaService.ActorSystem;
}
public async Task DisposeAsync()
{
if (_host != null)
{
await _host.StopAsync();
_host.Dispose();
}
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
}
[Fact]
public async Task SiteActors_DeadLetterMonitor_Exists()
=> await AssertActorExists("/user/dead-letter-monitor");
[Fact]
public async Task SiteActors_DclManager_Exists()
=> await AssertActorExists("/user/dcl-manager");
[Fact]
public async Task SiteActors_DeploymentManagerSingleton_Exists()
=> await AssertActorExists("/user/deployment-manager-singleton");
[Fact]
public async Task SiteActors_DeploymentManagerProxy_Exists()
=> await AssertActorExists("/user/deployment-manager-proxy");
[Fact]
public async Task SiteActors_SiteCommunication_Exists()
=> await AssertActorExists("/user/site-communication");
[Fact]
public async Task SiteActors_CentralClusterClient_Exists()
=> await AssertActorExists("/user/central-cluster-client");
private async Task AssertActorExists(string path)
{
Assert.NotNull(_actorSystem);
var selection = _actorSystem!.ActorSelection(path);
var identity = await selection.Ask<ActorIdentity>(
new Identify(path), TimeSpan.FromSeconds(5));
Assert.NotNull(identity.Subject);
}
}
@@ -0,0 +1,80 @@
using Akka.Actor;
using Akka.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// WP-13: Tests for Akka.NET actor system bootstrap.
/// </summary>
public class AkkaBootstrapTests : IDisposable
{
private ActorSystem? _actorSystem;
public void Dispose()
{
_actorSystem?.Dispose();
}
[Fact]
public void ActorSystem_CreatesWithClusterConfig()
{
var hocon = @"
akka {
actor {
provider = cluster
}
remote {
dot-netty.tcp {
hostname = ""localhost""
port = 0
}
}
cluster {
seed-nodes = [""akka.tcp://scadabridge-test@localhost:0""]
roles = [""Central""]
min-nr-of-members = 1
}
coordinated-shutdown {
run-by-clr-shutdown-hook = on
}
}";
var config = ConfigurationFactory.ParseString(hocon);
_actorSystem = ActorSystem.Create("scadabridge-test", config);
Assert.NotNull(_actorSystem);
Assert.Equal("scadabridge-test", _actorSystem.Name);
}
[Fact]
public void ActorSystem_HoconConfig_IncludesCoordinatedShutdown()
{
var hocon = @"
akka {
actor {
provider = cluster
}
remote {
dot-netty.tcp {
hostname = ""localhost""
port = 0
}
}
cluster {
seed-nodes = [""akka.tcp://scadabridge-test@localhost:0""]
roles = [""Central""]
run-coordinated-shutdown-when-down = on
}
coordinated-shutdown {
run-by-clr-shutdown-hook = on
}
}";
var config = ConfigurationFactory.ParseString(hocon);
_actorSystem = ActorSystem.Create("scadabridge-cs-test", config);
var csConfig = _actorSystem.Settings.Config.GetString("akka.coordinated-shutdown.run-by-clr-shutdown-hook");
Assert.Equal("on", csConfig);
var clusterShutdown = _actorSystem.Settings.Config.GetString("akka.cluster.run-coordinated-shutdown-when-down");
Assert.Equal("on", clusterShutdown);
}
}
@@ -0,0 +1,406 @@
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");
_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!",
["ScadaBridge:Security:LdapServer"] = "localhost",
["ScadaBridge:Security:LdapPort"] = "3893",
["ScadaBridge:Security:LdapUseTls"] = "false",
["ScadaBridge:Security:AllowInsecureLdap"] = "true",
["ScadaBridge:Security:LdapSearchBase"] = "dc=scadabridge,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);
}
[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&lt;ICachedCallTelemetryForwarder&gt;()</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&amp;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&amp;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&amp;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);
}
}
@@ -0,0 +1,34 @@
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Host-003: <c>appsettings.Central.json</c> no longer commits database connection
/// strings — they are externalised to environment variables. Tests that exercise the
/// full <c>Program</c> startup pipeline against the real SQL provider must therefore
/// supply the local dev connection string the way a deployment would: via an
/// environment variable (<c>Program</c>'s configuration builder calls
/// <c>AddEnvironmentVariables()</c>).
///
/// Dispose restores the previous value so tests stay isolated.
/// </summary>
internal sealed class CentralDbTestEnvironment : IDisposable
{
// Local dev/test database — same credentials the infra docker-compose stack uses.
// This is a test fixture value, not a committed production secret.
private const string ConfigurationDb =
"Server=localhost,1433;Database=ScadaBridgeConfig;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true";
private const string ConfigKey = "ScadaBridge__Database__ConfigurationDb";
private readonly string? _previousConfig;
public CentralDbTestEnvironment()
{
_previousConfig = Environment.GetEnvironmentVariable(ConfigKey);
Environment.SetEnvironmentVariable(ConfigKey, ConfigurationDb);
}
public void Dispose()
{
Environment.SetEnvironmentVariable(ConfigKey, _previousConfig);
}
}
@@ -0,0 +1,469 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components.Authorization;
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.ClusterInfrastructure;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.ExternalSystemGateway;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.Host;
using ZB.MOM.WW.ScadaBridge.Host.Actors;
using ZB.MOM.WW.ScadaBridge.Host.Health;
using ZB.MOM.WW.ScadaBridge.InboundAPI;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.NotificationService;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Removes AkkaHostedService from running as a hosted service while keeping it
/// resolvable in DI (other services like AkkaHealthReportTransport depend on it).
/// </summary>
internal static class AkkaHostedServiceRemover
{
internal static void RemoveAkkaHostedServiceOnly(IServiceCollection services)
{
// Pattern used in Program.cs:
// services.AddSingleton<AkkaHostedService>(); // index N
// services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>()); // index N+1
// We keep the singleton so DI resolution works, but remove the IHostedService
// factory so StartAsync is never called.
int akkaIndex = -1;
for (int i = 0; i < services.Count; i++)
{
if (services[i].ServiceType == typeof(AkkaHostedService))
{
akkaIndex = i;
break;
}
}
if (akkaIndex < 0) return;
// The IHostedService factory is the next registration after the singleton
for (int i = akkaIndex + 1; i < services.Count; i++)
{
if (services[i].ServiceType == typeof(IHostedService)
&& services[i].ImplementationFactory != null)
{
services.RemoveAt(i);
break;
}
}
}
}
/// <summary>
/// Verifies every expected DI service resolves from the Central composition root.
/// Uses WebApplicationFactory to exercise the real Program.cs pipeline.
/// </summary>
public class CentralCompositionRootTests : IDisposable
{
private readonly WebApplicationFactory<Program> _factory;
private readonly string? _previousEnv;
public CentralCompositionRootTests()
{
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
_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!",
["ScadaBridge:Security:LdapServer"] = "localhost",
["ScadaBridge:Security:LdapPort"] = "3893",
["ScadaBridge:Security:LdapUseTls"] = "false",
["ScadaBridge:Security:AllowInsecureLdap"] = "true",
["ScadaBridge:Security:LdapSearchBase"] = "dc=scadabridge,dc=local",
// ConfigurationDatabase-012: inbound-API keys are hashed
// with a server-side HMAC pepper; ApiKeyHasher fails fast
// if it is missing or weak, so resolving ApiKeyValidator
// requires a configured pepper.
["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 =>
{
// Replace SQL Server with in-memory database
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($"CompositionRootTests_{Guid.NewGuid()}"));
// Keep AkkaHostedService in DI (other services depend on it)
// but prevent it from starting by removing only its IHostedService registration.
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
});
});
// Trigger host build
_ = _factory.Server;
}
public void Dispose()
{
_factory.Dispose();
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
}
// --- Singletons ---
[Theory]
[MemberData(nameof(CentralSingletonServices))]
public void Central_ResolveSingleton(Type serviceType)
{
var service = _factory.Services.GetService(serviceType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> CentralSingletonServices => new[]
{
new object[] { typeof(CommunicationService) },
new object[] { typeof(ISiteHealthCollector) },
new object[] { typeof(CentralHealthAggregator) },
new object[] { typeof(ICentralHealthAggregator) },
new object[] { typeof(OperationLockManager) },
new object[] { typeof(OAuth2TokenService) },
new object[] { typeof(InboundScriptExecutor) },
};
// --- Scoped services ---
[Theory]
[MemberData(nameof(CentralScopedServices))]
public void Central_ResolveScoped(Type serviceType)
{
using var scope = _factory.Services.CreateScope();
var service = scope.ServiceProvider.GetService(serviceType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> CentralScopedServices => new[]
{
// TemplateEngine
new object[] { typeof(TemplateService) },
new object[] { typeof(SharedScriptService) },
new object[] { typeof(InstanceService) },
new object[] { typeof(SiteService) },
new object[] { typeof(AreaService) },
new object[] { typeof(TemplateDeletionService) },
// DeploymentManager
new object[] { typeof(IFlatteningPipeline) },
new object[] { typeof(DeploymentService) },
new object[] { typeof(ArtifactDeploymentService) },
// Security
new object[] { typeof(LdapAuthService) },
new object[] { typeof(JwtTokenService) },
new object[] { typeof(RoleMapper) },
// InboundAPI
new object[] { typeof(ApiKeyValidator) },
new object[] { typeof(RouteHelper) },
// ExternalSystemGateway
new object[] { typeof(ExternalSystemClient) },
new object[] { typeof(IExternalSystemClient) },
new object[] { typeof(DatabaseGateway) },
new object[] { typeof(IDatabaseGateway) },
// NotificationService — central-only SMTP primitives. The orphan
// NotificationDeliveryService + INotificationDeliveryService were removed
// (NS-019) when sites stopped delivering notifications; the central
// EmailNotificationDeliveryAdapter is now the only resolver of these
// primitives.
new object[] { typeof(Func<ISmtpClientWrapper>) },
new object[] { typeof(OAuth2TokenService) },
// ConfigurationDatabase repositories
new object[] { typeof(ScadaBridgeDbContext) },
new object[] { typeof(ISecurityRepository) },
new object[] { typeof(ICentralUiRepository) },
new object[] { typeof(ITemplateEngineRepository) },
new object[] { typeof(IDeploymentManagerRepository) },
new object[] { typeof(ISiteRepository) },
new object[] { typeof(IExternalSystemRepository) },
new object[] { typeof(INotificationRepository) },
new object[] { typeof(IInboundApiRepository) },
new object[] { typeof(IAuditService) },
new object[] { typeof(IInstanceLocator) },
// CentralUI
new object[] { typeof(AuthenticationStateProvider) },
};
// --- Transient services ---
[Theory]
[MemberData(nameof(CentralTransientServices))]
public void Central_ResolveTransient(Type serviceType)
{
using var scope = _factory.Services.CreateScope();
var service = scope.ServiceProvider.GetService(serviceType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> CentralTransientServices => new[]
{
new object[] { typeof(FlatteningService) },
new object[] { typeof(DiffService) },
new object[] { typeof(RevisionHashService) },
new object[] { typeof(ScriptCompiler) },
new object[] { typeof(SemanticValidator) },
new object[] { typeof(ValidationService) },
};
// --- Options ---
[Theory]
[MemberData(nameof(CentralOptions))]
public void Central_ResolveOptions(Type optionsType)
{
var service = _factory.Services.GetService(optionsType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> CentralOptions => new[]
{
new object[] { typeof(IOptions<NodeOptions>) },
new object[] { typeof(IOptions<ClusterOptions>) },
new object[] { typeof(IOptions<DatabaseOptions>) },
new object[] { typeof(IOptions<CommunicationOptions>) },
new object[] { typeof(IOptions<HealthMonitoringOptions>) },
new object[] { typeof(IOptions<NotificationOptions>) },
new object[] { typeof(IOptions<LoggingOptions>) },
new object[] { typeof(IOptions<SecurityOptions>) },
new object[] { typeof(IOptions<InboundApiOptions>) },
new object[] { typeof(IOptions<ManagementServiceOptions>) },
new object[] { typeof(IOptions<ExternalSystemGatewayOptions>) },
};
// --- Hosted services ---
[Fact]
public void Central_CentralHealthAggregator_RegisteredAsHostedService()
{
var hostedServices = _factory.Services.GetServices<IHostedService>();
Assert.Contains(hostedServices, s => s.GetType() == typeof(CentralHealthAggregator));
}
// --- InboundAPI-022 regression ---
/// <summary>
/// InboundAPI-022 regression: the Central composition root MUST register a
/// concrete <see cref="IActiveNodeGate"/> implementation. Without it,
/// <see cref="InboundApiEndpointFilter"/> falls through to "allow" and a
/// standby central node continues to serve the inbound API, racing the
/// active node and executing scripts against stale singleton state. The
/// design's "central cluster only (active node)" guarantee is enforced only
/// when the production gate is wired here.
///
/// Structural check on the built provider (not just <see cref="IServiceCollection"/>)
/// — a registration the framework cannot resolve to a concrete instance is
/// indistinguishable from "missing" at runtime, which is the failure mode
/// the finding describes.
/// </summary>
[Fact]
public void Central_IActiveNodeGate_IsRegisteredAsActiveNodeGate()
{
var gate = _factory.Services.GetService<IActiveNodeGate>();
Assert.NotNull(gate);
Assert.IsType<ActiveNodeGate>(gate);
}
}
/// <summary>
/// Verifies every expected DI service resolves from the Site composition root.
/// Uses the extracted SiteServiceRegistration.Configure() so the test always
/// matches the real Program.cs registration (WebApplicationBuilder + gRPC).
/// </summary>
public class SiteCompositionRootTests : IDisposable
{
private readonly WebApplication _host;
private readonly string _tempDbPath;
public SiteCompositionRootTests()
{
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_test_{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,
// ClusterOptions requires at least one seed node (ClusterOptionsValidator).
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
});
// gRPC server registration (mirrors Program.cs site section)
builder.Services.AddGrpc();
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
// Keep AkkaHostedService in DI (other services depend on it)
// but prevent it from starting by removing only its IHostedService registration.
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
_host = builder.Build();
}
public void Dispose()
{
(_host as IDisposable)?.Dispose();
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
}
// --- Singletons ---
[Theory]
[MemberData(nameof(SiteSingletonServices))]
public void Site_ResolveSingleton(Type serviceType)
{
var service = _host.Services.GetService(serviceType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> SiteSingletonServices => new[]
{
new object[] { typeof(CommunicationService) },
new object[] { typeof(ISiteHealthCollector) },
new object[] { typeof(SiteStorageService) },
new object[] { typeof(ScriptCompilationService) },
new object[] { typeof(SharedScriptLibrary) },
new object[] { typeof(SiteStreamManager) },
new object[] { typeof(ISiteStreamSubscriber) },
new object[] { typeof(SiteStreamGrpcServer) },
new object[] { typeof(IDataConnectionFactory) },
new object[] { typeof(StoreAndForwardStorage) },
new object[] { typeof(StoreAndForwardService) },
new object[] { typeof(ReplicationService) },
new object[] { typeof(ISiteEventLogger) },
new object[] { typeof(IEventLogQueryService) },
new object[] { typeof(ISiteIdentityProvider) },
new object[] { typeof(IHealthReportTransport) },
};
// --- Scoped services ---
[Theory]
[MemberData(nameof(SiteScopedServices))]
public void Site_ResolveScoped(Type serviceType)
{
using var scope = _host.Services.CreateScope();
var service = scope.ServiceProvider.GetService(serviceType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> SiteScopedServices => new[]
{
new object[] { typeof(IExternalSystemRepository) },
new object[] { typeof(INotificationRepository) },
new object[] { typeof(ExternalSystemClient) },
new object[] { typeof(IExternalSystemClient) },
new object[] { typeof(DatabaseGateway) },
new object[] { typeof(IDatabaseGateway) },
};
// --- Implementation type assertions ---
[Fact]
public void Site_ExternalSystemRepository_IsSiteImplementation()
{
using var scope = _host.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IExternalSystemRepository>();
Assert.IsType<SiteExternalSystemRepository>(repo);
}
[Fact]
public void Site_NotificationRepository_IsSiteImplementation()
{
using var scope = _host.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<INotificationRepository>();
Assert.IsType<SiteNotificationRepository>(repo);
}
// --- Options ---
[Theory]
[MemberData(nameof(SiteOptions))]
public void Site_ResolveOptions(Type optionsType)
{
var service = _host.Services.GetService(optionsType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> SiteOptions => new[]
{
new object[] { typeof(IOptions<NodeOptions>) },
new object[] { typeof(IOptions<ClusterOptions>) },
new object[] { typeof(IOptions<DatabaseOptions>) },
new object[] { typeof(IOptions<CommunicationOptions>) },
new object[] { typeof(IOptions<HealthMonitoringOptions>) },
new object[] { typeof(IOptions<NotificationOptions>) },
new object[] { typeof(IOptions<LoggingOptions>) },
new object[] { typeof(IOptions<SiteRuntimeOptions>) },
new object[] { typeof(IOptions<DataConnectionOptions>) },
new object[] { typeof(IOptions<StoreAndForwardOptions>) },
new object[] { typeof(IOptions<SiteEventLogOptions>) },
};
// --- Hosted services ---
[Theory]
[MemberData(nameof(SiteHostedServices))]
public void Site_HostedServiceRegistered(Type expectedType)
{
var hostedServices = _host.Services.GetServices<IHostedService>();
Assert.Contains(hostedServices, s => s.GetType() == expectedType);
}
public static IEnumerable<object[]> SiteHostedServices => new[]
{
new object[] { typeof(HealthReportSender) },
new object[] { typeof(SiteStorageInitializer) },
new object[] { typeof(EventLogPurgeService) },
};
}
@@ -0,0 +1,89 @@
using System.Reflection;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Host-003 regression: secrets must not be committed in plaintext in the
/// shipped <c>appsettings.Central.json</c>. Connection-string passwords, the LDAP
/// service-account password and the JWT signing key must be supplied via
/// environment variables (or another secret store) at deployment time — the
/// committed file may only carry non-sensitive structural defaults or
/// placeholder values.
/// </summary>
public class ConfigSecretsTests
{
private static string FindHostProjectDirectory()
{
var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
var dir = new DirectoryInfo(assemblyDir);
while (dir != null)
{
var hostPath = Path.Combine(dir.FullName, "src", "ZB.MOM.WW.ScadaBridge.Host");
if (Directory.Exists(hostPath))
return hostPath;
dir = dir.Parent;
}
throw new DirectoryNotFoundException("Could not locate src/ZB.MOM.WW.ScadaBridge.Host");
}
private static JsonElement ScadaBridgeSection()
{
var path = Path.Combine(FindHostProjectDirectory(), "appsettings.Central.json");
var json = File.ReadAllText(path);
using var doc = JsonDocument.Parse(json);
return doc.RootElement.GetProperty("ScadaBridge").Clone();
}
[Fact]
public void CentralConfig_ConnectionStrings_ContainNoPlaintextPassword()
{
var db = ScadaBridgeSection().GetProperty("Database");
foreach (var prop in db.EnumerateObject())
{
var value = prop.Value.GetString() ?? string.Empty;
// A committed connection string must not carry a literal Password= value.
// Either the password is delivered via an environment variable or the
// whole connection string is. A placeholder reference is acceptable.
var idx = value.IndexOf("Password=", StringComparison.OrdinalIgnoreCase);
if (idx >= 0)
{
var after = value[(idx + "Password=".Length)..];
var literal = after.Split(';')[0];
Assert.True(
literal.Length == 0 || literal.Contains('{') || literal.Contains('$'),
$"appsettings.Central.json '{prop.Name}' contains a plaintext Password value '{literal}'. " +
"Move the secret to an environment variable.");
}
}
}
[Fact]
public void CentralConfig_LdapServiceAccountPassword_IsNotCommitted()
{
var security = ScadaBridgeSection().GetProperty("Security");
if (security.TryGetProperty("LdapServiceAccountPassword", out var pw))
{
var value = pw.GetString() ?? string.Empty;
Assert.True(
value.Length == 0 || value.Contains('{') || value.Contains('$'),
$"appsettings.Central.json carries a plaintext LdapServiceAccountPassword '{value}'. " +
"Move it to an environment variable.");
}
}
[Fact]
public void CentralConfig_JwtSigningKey_IsNotCommitted()
{
var security = ScadaBridgeSection().GetProperty("Security");
if (security.TryGetProperty("JwtSigningKey", out var key))
{
var value = key.GetString() ?? string.Empty;
Assert.True(
value.Length == 0 || value.Contains('{') || value.Contains('$'),
$"appsettings.Central.json carries a committed JwtSigningKey '{value}'. " +
"A committed signing key lets anyone with repo access forge session tokens. " +
"Move it to an environment variable.");
}
}
}
@@ -0,0 +1,60 @@
using System.Reflection;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// WP-16: Tests for CoordinatedShutdown configuration.
/// Verifies no Environment.Exit calls exist in source and HOCON config is correct.
/// </summary>
public class CoordinatedShutdownTests
{
[Fact]
public void HostSource_DoesNotContainEnvironmentExit()
{
var hostProjectDir = FindHostProjectDirectory();
Assert.NotNull(hostProjectDir);
var sourceFiles = Directory.GetFiles(hostProjectDir, "*.cs", SearchOption.AllDirectories);
Assert.NotEmpty(sourceFiles);
foreach (var file in sourceFiles)
{
var content = File.ReadAllText(file);
Assert.DoesNotContain("Environment.Exit", content,
StringComparison.Ordinal);
}
}
[Fact]
public void AkkaHostedService_HoconConfig_IncludesCoordinatedShutdownSettings()
{
// Read the AkkaHostedService source to verify HOCON configuration
var hostProjectDir = FindHostProjectDirectory();
Assert.NotNull(hostProjectDir);
var akkaServiceFile = Path.Combine(hostProjectDir, "Actors", "AkkaHostedService.cs");
Assert.True(File.Exists(akkaServiceFile), $"AkkaHostedService.cs not found at {akkaServiceFile}");
var content = File.ReadAllText(akkaServiceFile);
// Verify critical HOCON settings are present
Assert.Contains("run-by-clr-shutdown-hook = on", content);
Assert.Contains("run-coordinated-shutdown-when-down = on", content);
}
private static string? FindHostProjectDirectory()
{
var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
var dir = new DirectoryInfo(assemblyDir);
while (dir != null)
{
var hostPath = Path.Combine(dir.FullName, "src", "ZB.MOM.WW.ScadaBridge.Host");
if (Directory.Exists(hostPath))
return hostPath;
dir = dir.Parent;
}
return null;
}
}
@@ -0,0 +1,74 @@
using Akka.Actor;
using Akka.Event;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Host.Actors;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// WP-15: Tests for DeadLetterMonitorActor.
/// </summary>
public class DeadLetterMonitorTests : TestKit
{
private readonly ILogger<DeadLetterMonitorActor> _logger =
NullLoggerFactory.Instance.CreateLogger<DeadLetterMonitorActor>();
[Fact]
public void DeadLetterMonitor_StartsWithZeroCount()
{
var monitor = Sys.ActorOf(Props.Create(() => new DeadLetterMonitorActor(_logger)));
monitor.Tell(GetDeadLetterCount.Instance);
var response = ExpectMsg<DeadLetterCountResponse>();
Assert.Equal(0, response.Count);
}
[Fact]
public void DeadLetterMonitor_IncrementsOnDeadLetter()
{
var monitor = Sys.ActorOf(Props.Create(() => new DeadLetterMonitorActor(_logger)));
// Ensure actor has started and subscribed by sending a message and waiting for response
monitor.Tell(GetDeadLetterCount.Instance);
ExpectMsg<DeadLetterCountResponse>();
// Now publish dead letters — actor is guaranteed to be subscribed
Sys.EventStream.Publish(new DeadLetter("test-message-1", Sys.DeadLetters, Sys.DeadLetters));
Sys.EventStream.Publish(new DeadLetter("test-message-2", Sys.DeadLetters, Sys.DeadLetters));
// Use AwaitAssert to handle async event delivery
AwaitAssert(() =>
{
monitor.Tell(GetDeadLetterCount.Instance);
var response = ExpectMsg<DeadLetterCountResponse>();
Assert.Equal(2, response.Count);
});
}
[Fact]
public void DeadLetterMonitor_CountAccumulates()
{
var monitor = Sys.ActorOf(Props.Create(() => new DeadLetterMonitorActor(_logger)));
// Ensure actor is started and subscribed
monitor.Tell(GetDeadLetterCount.Instance);
ExpectMsg<DeadLetterCountResponse>();
// Send 5 dead letters
for (var i = 0; i < 5; i++)
{
Sys.EventStream.Publish(
new DeadLetter($"message-{i}", Sys.DeadLetters, Sys.DeadLetters));
}
AwaitAssert(() =>
{
monitor.Tell(GetDeadLetterCount.Instance);
var response = ExpectMsg<DeadLetterCountResponse>();
Assert.Equal(5, response.Count);
});
}
}
@@ -0,0 +1,211 @@
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.ScadaBridge.Host.Health;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// WP-12: Tests for /health/ready and /health/active endpoints.
/// </summary>
public class HealthCheckTests : IDisposable
{
private readonly List<IDisposable> _disposables = new();
public HealthCheckTests()
{
// Host-003: connection strings are externalised; supply them via env vars.
_disposables.Add(new CentralDbTestEnvironment());
}
public void Dispose()
{
foreach (var d in _disposables)
{
try { d.Dispose(); } catch { /* best effort */ }
}
}
[Fact]
public async Task HealthReady_Endpoint_ReturnsResponse()
{
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
try
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((context, 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",
});
});
builder.UseSetting("ScadaBridge:Node:Role", "Central");
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
});
_disposables.Add(factory);
var client = factory.CreateClient();
_disposables.Add(client);
var response = await client.GetAsync("/health/ready");
// The endpoint exists and returns a status code.
// With test infrastructure (no real DB), the database check may fail,
// so we accept either 200 (Healthy) or 503 (Unhealthy).
Assert.True(
response.StatusCode == System.Net.HttpStatusCode.OK ||
response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable,
$"Expected 200 or 503, got {(int)response.StatusCode}");
}
finally
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", previousEnv);
}
}
[Fact]
public async Task HealthActive_Endpoint_ReturnsResponse()
{
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
try
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((context, 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",
});
});
builder.UseSetting("ScadaBridge:Node:Role", "Central");
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
});
_disposables.Add(factory);
var client = factory.CreateClient();
_disposables.Add(client);
var response = await client.GetAsync("/health/active");
// In test mode, the ActorSystem may not be fully available,
// so the active-node check returns 503 (Unhealthy).
Assert.True(
response.StatusCode == System.Net.HttpStatusCode.OK ||
response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable,
$"Expected 200 or 503, got {(int)response.StatusCode}");
}
finally
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", previousEnv);
}
}
[Fact]
public async Task HealthReady_Endpoint_ExcludesActiveNodeCheck()
{
// Host-001 regression: /health/ready must reflect cluster membership + DB
// connectivity only (REQ-HOST-4a), NOT cluster leadership. The leader-only
// "active-node" check belongs solely to /health/active. If /health/ready
// included "active-node", a fully operational standby central node would
// permanently report 503, breaking load-balancer failover readiness.
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
try
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((context, 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",
});
});
builder.UseSetting("ScadaBridge:Node:Role", "Central");
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
});
_disposables.Add(factory);
var client = factory.CreateClient();
_disposables.Add(client);
var response = await client.GetAsync("/health/ready");
var body = await response.Content.ReadAsStringAsync();
// The readiness body lists each executed check by name in its entries map.
// The leader-only "active-node" check must not be among them.
Assert.DoesNotContain("active-node", body);
}
finally
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", previousEnv);
}
}
[Fact]
public async Task ActiveNodeHealthCheck_SystemNotStarted_ReturnsUnhealthy()
{
// AkkaHostedService before StartAsync has ActorSystem == null.
// The integration test (HealthActive_Endpoint_ReturnsResponse) validates the full
// endpoint wiring. This test validates the null-system path via WebApplicationFactory
// where the ActorSystem may not be available.
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
try
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((context, 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:Database:SkipMigrations"] = "true",
});
});
builder.UseSetting("ScadaBridge:Node:Role", "Central");
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
});
_disposables.Add(factory);
var client = factory.CreateClient();
_disposables.Add(client);
var response = await client.GetAsync("/health/active");
var body = await response.Content.ReadAsStringAsync();
// Active-node check returns 503 when ActorSystem is not yet available or not leader
Assert.Equal(System.Net.HttpStatusCode.ServiceUnavailable, response.StatusCode);
Assert.Contains("active-node", body);
}
finally
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", previousEnv);
}
}
}
@@ -0,0 +1,163 @@
using Akka.Configuration;
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
using ZB.MOM.WW.ScadaBridge.Host.Actors;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Host-006: the Akka HOCON document must be assembled with every interpolated value
/// quoted/escaped, so a hostname, seed-node URI or split-brain strategy containing a
/// quote, backslash or whitespace cannot corrupt the document or be silently
/// misparsed.
/// </summary>
public class HoconBuilderTests
{
private static ClusterOptions DefaultCluster() => new()
{
SeedNodes = new List<string>
{
"akka.tcp://scadabridge@localhost:8081",
"akka.tcp://scadabridge@localhost:8082",
},
SplitBrainResolverStrategy = "keep-oldest",
MinNrOfMembers = 1,
StableAfter = TimeSpan.FromSeconds(15),
HeartbeatInterval = TimeSpan.FromSeconds(2),
FailureDetectionThreshold = TimeSpan.FromSeconds(10),
};
[Fact]
public void BuildHocon_PlainValues_ParsesAndPreservesValues()
{
var node = new NodeOptions
{
Role = "Central",
NodeHostname = "central-node1",
RemotingPort = 8081,
};
var hocon = AkkaHostedService.BuildHocon(
node, DefaultCluster(), new[] { "Central" },
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
var config = ConfigurationFactory.ParseString(hocon);
Assert.Equal("central-node1", config.GetString("akka.remote.dot-netty.tcp.hostname"));
Assert.Equal("keep-oldest", config.GetString("akka.cluster.split-brain-resolver.active-strategy"));
}
[Fact]
public void BuildHocon_HostnameWithQuote_DoesNotCorruptDocument()
{
// A hostname containing a double quote would, with raw interpolation, close
// the HOCON string literal early and corrupt every following key.
var node = new NodeOptions
{
Role = "Central",
NodeHostname = "evil\"host",
RemotingPort = 8081,
};
var hocon = AkkaHostedService.BuildHocon(
node, DefaultCluster(), new[] { "Central" },
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
// Must still parse, and the keys after the hostname must remain intact.
var config = ConfigurationFactory.ParseString(hocon);
Assert.Equal("evil\"host", config.GetString("akka.remote.dot-netty.tcp.hostname"));
Assert.Equal(8081, config.GetInt("akka.remote.dot-netty.tcp.port"));
Assert.Equal(1, config.GetInt("akka.cluster.min-nr-of-members"));
}
[Fact]
public void BuildHocon_StrategyWithWhitespace_RemainsQuoted()
{
var cluster = DefaultCluster();
cluster.SplitBrainResolverStrategy = "keep oldest";
var node = new NodeOptions
{
Role = "Central",
NodeHostname = "node1",
RemotingPort = 8081,
};
var hocon = AkkaHostedService.BuildHocon(
node, cluster, new[] { "Central" },
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
var config = ConfigurationFactory.ParseString(hocon);
Assert.Equal("keep oldest", config.GetString("akka.cluster.split-brain-resolver.active-strategy"));
}
private static NodeOptions DefaultNode() => new()
{
Role = "Central",
NodeHostname = "node1",
RemotingPort = 8081,
};
[Fact]
public void BuildHocon_DownIfAloneTrue_EmitsOn()
{
// Host-012: BuildHocon must consume ClusterOptions.DownIfAlone, not
// hard-code the down-if-alone token.
var cluster = DefaultCluster();
cluster.DownIfAlone = true;
var hocon = AkkaHostedService.BuildHocon(
DefaultNode(), cluster, new[] { "Central" },
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
var config = ConfigurationFactory.ParseString(hocon);
Assert.True(config.GetBoolean(
"akka.cluster.split-brain-resolver.keep-oldest.down-if-alone"));
}
[Fact]
public void BuildHocon_DownIfAloneFalse_EmitsOff()
{
// Host-012: setting DownIfAlone=false must actually flip the emitted token.
var cluster = DefaultCluster();
cluster.DownIfAlone = false;
var hocon = AkkaHostedService.BuildHocon(
DefaultNode(), cluster, new[] { "Central" },
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
var config = ConfigurationFactory.ParseString(hocon);
Assert.False(config.GetBoolean(
"akka.cluster.split-brain-resolver.keep-oldest.down-if-alone"));
}
[Fact]
public void BuildHocon_SubSecondTimings_PreservedWithoutRounding()
{
// Host-013: cluster timing values below one second must not be rounded to
// whole seconds. A 750ms heartbeat must survive as 750ms, not collapse to
// 1s — and a 500ms value must not collapse to a degenerate 0s.
var cluster = DefaultCluster();
cluster.HeartbeatInterval = TimeSpan.FromMilliseconds(750);
cluster.FailureDetectionThreshold = TimeSpan.FromMilliseconds(2600);
cluster.StableAfter = TimeSpan.FromMilliseconds(500);
var hocon = AkkaHostedService.BuildHocon(
DefaultNode(), cluster, new[] { "Central" },
TimeSpan.FromMilliseconds(2500), TimeSpan.FromMilliseconds(7500));
var config = ConfigurationFactory.ParseString(hocon);
Assert.Equal(
TimeSpan.FromMilliseconds(750),
config.GetTimeSpan("akka.cluster.failure-detector.heartbeat-interval"));
Assert.Equal(
TimeSpan.FromMilliseconds(2600),
config.GetTimeSpan("akka.cluster.failure-detector.acceptable-heartbeat-pause"));
Assert.Equal(
TimeSpan.FromMilliseconds(500),
config.GetTimeSpan("akka.cluster.split-brain-resolver.stable-after"));
Assert.Equal(
TimeSpan.FromMilliseconds(2500),
config.GetTimeSpan("akka.remote.transport-failure-detector.heartbeat-interval"));
Assert.Equal(
TimeSpan.FromMilliseconds(7500),
config.GetTimeSpan("akka.remote.transport-failure-detector.acceptable-heartbeat-pause"));
}
}
@@ -0,0 +1,180 @@
using System.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ZB.MOM.WW.ScadaBridge.Host;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
public class HostStartupTests : IDisposable
{
private readonly List<IDisposable> _disposables = new();
public void Dispose()
{
foreach (var d in _disposables)
{
try { d.Dispose(); } catch { /* best effort */ }
}
}
[Fact]
public void CentralRole_StartsWithoutError()
{
// WebApplicationFactory replays Program.Main, which reads config from files.
// Set the environment to Central so appsettings.Central.json is loaded,
// and set DOTNET_ENVIRONMENT before the factory creates the host.
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
// Host-003: connection strings are externalised; supply them via env vars.
using var dbEnv = new CentralDbTestEnvironment();
try
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
var 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",
});
});
builder.UseSetting("ScadaBridge:Node:Role", "Central");
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
});
_disposables.Add(factory);
// Creating the server exercises the full DI container build and startup pipeline
var client = factory.CreateClient();
_disposables.Add(client);
// If we get here without exception, the central host started successfully
Assert.NotNull(client);
}
finally
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", previousEnv);
}
}
[Fact]
public void SiteRole_StartsWithoutError()
{
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",
});
builder.Services.AddGrpc();
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
// Remove AkkaHostedService from running
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
var app = builder.Build();
_disposables.Add(app);
// Build succeeds = DI container is valid and all services resolve
Assert.NotNull(app);
Assert.NotNull(app.Services);
}
[Fact]
public void SiteRole_ConfiguresKestrelForGrpc()
{
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",
});
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenAnyIP(0, listenOptions =>
{
listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2;
});
});
builder.Services.AddGrpc();
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
// Remove AkkaHostedService from running
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
var app = builder.Build();
// Verify Kestrel IS configured (site now hosts gRPC via WebApplicationBuilder)
var serverType = Type.GetType(
"Microsoft.AspNetCore.Hosting.Server.IServer, Microsoft.AspNetCore.Hosting.Server.Abstractions");
if (serverType != null)
{
var server = app.Services.GetService(serverType);
Assert.NotNull(server);
}
(app as IDisposable)?.Dispose();
}
[Fact]
public void HostProject_DoesNotUseConditionalCompilation()
{
var hostProjectDir = FindHostProjectDirectory();
Assert.NotNull(hostProjectDir);
var sourceFiles = Directory.GetFiles(hostProjectDir, "*.cs", SearchOption.TopDirectoryOnly);
Assert.NotEmpty(sourceFiles);
foreach (var file in sourceFiles)
{
var content = File.ReadAllText(file);
Assert.DoesNotContain("#if", content);
Assert.DoesNotContain("#ifdef", content);
Assert.DoesNotContain("#ifndef", content);
Assert.DoesNotContain("#elif", content);
Assert.DoesNotContain("#else", content);
Assert.DoesNotContain("#endif", content);
}
}
private static string? FindHostProjectDirectory()
{
// Walk up from the test assembly location to find the src directory
var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
var dir = new DirectoryInfo(assemblyDir);
while (dir != null)
{
var hostPath = Path.Combine(dir.FullName, "src", "ZB.MOM.WW.ScadaBridge.Host");
if (Directory.Exists(hostPath))
return hostPath;
dir = dir.Parent;
}
return null;
}
}
@@ -0,0 +1,161 @@
using Microsoft.Extensions.Configuration;
using Serilog.Events;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Host-011: <c>ScadaBridge:Logging:MinimumLevel</c> must actually drive the Serilog
/// minimum level. Previously the value was bound into <see cref="LoggingOptions"/>
/// but never read, so editing it had no effect.
/// </summary>
public class LoggerConfigurationTests
{
private static IConfiguration BuildConfig(string? minimumLevel)
{
var values = new Dictionary<string, string?>();
if (minimumLevel != null)
values["ScadaBridge:Logging:MinimumLevel"] = minimumLevel;
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
}
[Fact]
public void MinimumLevel_Warning_SuppressesInformationLogs()
{
var sink = new InMemorySink();
var logger = LoggerConfigurationFactory
.Build(BuildConfig("Warning"), "Central", "central", "node1")
.WriteTo.Sink(sink)
.CreateLogger();
logger.Information("info message");
logger.Warning("warning message");
Assert.Single(sink.LogEvents);
Assert.Equal(LogEventLevel.Warning, sink.LogEvents[0].Level);
}
[Fact]
public void MinimumLevel_Debug_AllowsDebugLogs()
{
var sink = new InMemorySink();
var logger = LoggerConfigurationFactory
.Build(BuildConfig("Debug"), "Site", "site-a", "node1")
.WriteTo.Sink(sink)
.CreateLogger();
logger.Debug("debug message");
Assert.Single(sink.LogEvents);
Assert.Equal(LogEventLevel.Debug, sink.LogEvents[0].Level);
}
[Fact]
public void MinimumLevel_Absent_DefaultsToInformation()
{
var sink = new InMemorySink();
var logger = LoggerConfigurationFactory
.Build(BuildConfig(null), "Central", "central", "node1")
.WriteTo.Sink(sink)
.CreateLogger();
logger.Debug("debug message");
logger.Information("info message");
Assert.Single(sink.LogEvents);
Assert.Equal(LogEventLevel.Information, sink.LogEvents[0].Level);
}
/// <summary>
/// Host-022: an unrecognised <c>ScadaBridge:Logging:MinimumLevel</c> (e.g. a typo
/// like "Informaiton") must NOT abort startup but MUST emit a one-shot warning
/// naming the offending value and the fallback so the silent coercion is
/// visible. Null/blank is treated as "unset" and silently defaults.
/// </summary>
[Fact]
public void ParseLevel_UnrecognisedValue_FallsBackAndWarns()
{
var writer = new StringWriter();
var result = LoggerConfigurationFactory.ParseLevel("Informaiton", writer);
Assert.Equal(LogEventLevel.Information, result);
var warning = writer.ToString();
Assert.Contains("warning", warning, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Informaiton", warning);
Assert.Contains("Information", warning);
}
[Fact]
public void ParseLevel_NullOrBlank_FallsBackSilently()
{
var writer = new StringWriter();
var nullResult = LoggerConfigurationFactory.ParseLevel(null, writer);
var blankResult = LoggerConfigurationFactory.ParseLevel(" ", writer);
Assert.Equal(LogEventLevel.Information, nullResult);
Assert.Equal(LogEventLevel.Information, blankResult);
Assert.Empty(writer.ToString());
}
[Fact]
public void ParseLevel_RecognisedValue_NoWarning()
{
var writer = new StringWriter();
var result = LoggerConfigurationFactory.ParseLevel("Warning", writer);
Assert.Equal(LogEventLevel.Warning, result);
Assert.Empty(writer.ToString());
}
/// <summary>
/// Host-020: <c>ScadaBridge:Logging:MinimumLevel</c> is the documented source
/// of truth for the Serilog floor, and the explicit <c>MinimumLevel.Is</c>
/// call deliberately runs after <c>ReadFrom.Configuration(...)</c> so a
/// <c>Serilog:MinimumLevel</c> entry is overridden. To make that precedence
/// visible — instead of silently swallowed — <see cref="LoggerConfigurationFactory.Build(IConfiguration,string,string,string,TextWriter)"/>
/// writes a one-shot warning when both keys are present. The warning must
/// name both values and point the operator at the documented key. When the
/// Serilog key is absent the warning is silent.
/// </summary>
[Fact]
public void Build_BothMinimumLevelKeysSet_WarnsAboutOverride()
{
var writer = new StringWriter();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Logging:MinimumLevel"] = "Warning",
["Serilog:MinimumLevel"] = "Debug",
})
.Build();
LoggerConfigurationFactory.Build(configuration, "Central", "central", "node1", writer);
var warning = writer.ToString();
Assert.Contains("warning", warning, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Serilog:MinimumLevel", warning);
Assert.Contains("ScadaBridge:Logging:MinimumLevel", warning);
Assert.Contains("Debug", warning);
Assert.Contains("Warning", warning);
}
[Fact]
public void Build_OnlyScadaBridgeMinimumLevelSet_NoOverrideWarning()
{
var writer = new StringWriter();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Logging:MinimumLevel"] = "Warning",
})
.Build();
LoggerConfigurationFactory.Build(configuration, "Central", "central", "node1", writer);
// No Serilog override -> no override-warning. (The ScadaBridge value is
// a recognised level, so ParseLevel is silent too.)
Assert.Empty(writer.ToString());
}
}
@@ -0,0 +1,50 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Tests for NodeIdentityProvider — surfaces the operator-configured semantic
/// node name (e.g. node-a / node-b / central-a / central-b) used by downstream
/// audit writers to stamp the SourceNode column.
/// </summary>
public class NodeIdentityProviderTests
{
private static INodeIdentityProvider BuildProvider(string nodeName)
{
var options = Options.Create(new NodeOptions { NodeName = nodeName });
return new NodeIdentityProvider(options);
}
[Fact]
public void NodeIdentityProvider_returns_configured_NodeName()
{
var provider = BuildProvider("central-a");
Assert.Equal("central-a", provider.NodeName);
}
[Fact]
public void NodeIdentityProvider_returns_null_when_NodeName_unset()
{
var provider = BuildProvider(string.Empty);
Assert.Null(provider.NodeName);
}
[Fact]
public void NodeIdentityProvider_returns_null_when_NodeName_whitespace()
{
var provider = BuildProvider(" ");
Assert.Null(provider.NodeName);
}
[Fact]
public void NodeIdentityProvider_trims_whitespace()
{
var provider = BuildProvider(" node-a ");
Assert.Equal("node-a", provider.NodeName);
}
}
@@ -0,0 +1,66 @@
using System.Reflection;
using Microsoft.Extensions.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
public class OptionsTests
{
/// <summary>
/// Verify no component library (excluding Host) uses IConfiguration or accepts it
/// in its AddXxx() extension method. Component libraries should only depend on
/// DI abstractions and Options pattern, not on Configuration directly.
/// </summary>
[Fact]
public void ComponentLibraries_DoNotAcceptIConfigurationInAddMethods()
{
// All component assemblies (excluding Host itself and Commons)
var componentAssemblies = new[]
{
typeof(ClusterInfrastructure.ServiceCollectionExtensions).Assembly,
typeof(Communication.ServiceCollectionExtensions).Assembly,
typeof(HealthMonitoring.ServiceCollectionExtensions).Assembly,
typeof(ExternalSystemGateway.ServiceCollectionExtensions).Assembly,
typeof(NotificationService.ServiceCollectionExtensions).Assembly,
typeof(NotificationOutbox.ServiceCollectionExtensions).Assembly,
typeof(TemplateEngine.ServiceCollectionExtensions).Assembly,
typeof(DeploymentManager.ServiceCollectionExtensions).Assembly,
typeof(Security.ServiceCollectionExtensions).Assembly,
typeof(ConfigurationDatabase.ServiceCollectionExtensions).Assembly,
typeof(SiteRuntime.ServiceCollectionExtensions).Assembly,
typeof(DataConnectionLayer.ServiceCollectionExtensions).Assembly,
typeof(StoreAndForward.ServiceCollectionExtensions).Assembly,
typeof(SiteEventLogging.ServiceCollectionExtensions).Assembly,
typeof(CentralUI.ServiceCollectionExtensions).Assembly,
typeof(InboundAPI.ServiceCollectionExtensions).Assembly,
};
foreach (var assembly in componentAssemblies)
{
// Check that the assembly does not reference Microsoft.Extensions.Configuration
var configRef = assembly.GetReferencedAssemblies()
.FirstOrDefault(a => a.Name == "Microsoft.Extensions.Configuration.Abstractions");
// Find all public static extension methods named Add*
var extensionClasses = assembly.GetExportedTypes()
.Where(t => t.IsClass && t.IsAbstract && t.IsSealed); // static classes
foreach (var cls in extensionClasses)
{
var addMethods = cls.GetMethods(BindingFlags.Public | BindingFlags.Static)
.Where(m => m.Name.StartsWith("Add") || m.Name.StartsWith("Map"));
foreach (var method in addMethods)
{
var parameters = method.GetParameters();
foreach (var param in parameters)
{
Assert.False(
typeof(IConfiguration).IsAssignableFrom(param.ParameterType),
$"{assembly.GetName().Name}: {cls.Name}.{method.Name} accepts IConfiguration parameter '{param.Name}'. " +
"Component libraries should use the Options pattern, not IConfiguration.");
}
}
}
}
}
}
@@ -0,0 +1,93 @@
using System.Reflection;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Host-014 regression: REQ-HOST-8 requires the Host's Serilog sinks
/// (console and file at minimum) to be <em>configuration-driven</em>. The sinks
/// must be defined in a <c>Serilog</c> section in <c>appsettings.json</c> and
/// applied via <c>ReadFrom.Configuration</c> — they must not be hard-coded in
/// <c>Program.cs</c>, so an operator can change the file path, rolling interval or
/// output template without recompiling.
/// </summary>
public class SerilogSinkConfigTests
{
private static string FindHostProjectDirectory()
{
var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
var dir = new DirectoryInfo(assemblyDir);
while (dir != null)
{
var hostPath = Path.Combine(dir.FullName, "src", "ZB.MOM.WW.ScadaBridge.Host");
if (Directory.Exists(hostPath))
return hostPath;
dir = dir.Parent;
}
throw new DirectoryNotFoundException("Could not locate src/ZB.MOM.WW.ScadaBridge.Host");
}
[Fact]
public void ShippedAppSettings_HasSerilogSection_WithConsoleAndFileSinks()
{
var path = Path.Combine(FindHostProjectDirectory(), "appsettings.json");
using var doc = JsonDocument.Parse(File.ReadAllText(path));
Assert.True(
doc.RootElement.TryGetProperty("Serilog", out var serilog),
"appsettings.json must contain a `Serilog` section so ReadFrom.Configuration " +
"drives the sinks (REQ-HOST-8 / Host-014).");
Assert.True(
serilog.TryGetProperty("WriteTo", out var writeTo),
"the `Serilog` section must contain a `WriteTo` array defining the sinks.");
var sinkNames = writeTo.EnumerateArray()
.Select(e => e.TryGetProperty("Name", out var n) ? n.GetString() : null)
.ToList();
Assert.Contains("Console", sinkNames);
Assert.Contains("File", sinkNames);
}
[Fact]
public void LoggerConfigurationFactory_AppliesConfiguredFileSink()
{
// A `Serilog` section in configuration must actually reach the built logger
// via ReadFrom.Configuration — proving the sink set is configuration-driven.
var logDir = Path.Combine(Path.GetTempPath(), "scadabridge-host014-" + Guid.NewGuid().ToString("N"));
var logPath = Path.Combine(logDir, "test-.log");
try
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Serilog:Using:0"] = "Serilog.Sinks.File",
["Serilog:WriteTo:0:Name"] = "File",
["Serilog:WriteTo:0:Args:path"] = logPath,
["Serilog:WriteTo:0:Args:rollingInterval"] = "Day",
})
.Build();
var logger = LoggerConfigurationFactory
.Build(configuration, "Central", "central", "node1")
.CreateLogger();
logger.Information("host-014 configured-sink probe");
logger.Dispose();
Assert.True(Directory.Exists(logDir), "the configured file sink must have created its directory.");
var written = Directory.GetFiles(logDir, "test-*.log");
Assert.NotEmpty(written);
Assert.Contains(
"host-014 configured-sink probe",
File.ReadAllText(written[0]));
}
finally
{
if (Directory.Exists(logDir))
Directory.Delete(logDir, recursive: true);
}
}
}
@@ -0,0 +1,72 @@
using Serilog;
using Serilog.Events;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// WP-14: Tests for Serilog structured logging with enriched properties.
/// </summary>
public class SerilogTests
{
[Fact]
public void SerilogLogger_EnrichesWithNodeProperties()
{
var sink = new InMemorySink();
var logger = new LoggerConfiguration()
.Enrich.WithProperty("SiteId", "TestSite")
.Enrich.WithProperty("NodeHostname", "test-node1")
.Enrich.WithProperty("NodeRole", "Site")
.WriteTo.Sink(sink)
.CreateLogger();
logger.Information("Test log message");
Assert.Single(sink.LogEvents);
var logEvent = sink.LogEvents[0];
Assert.True(logEvent.Properties.ContainsKey("SiteId"));
Assert.Equal("\"TestSite\"", logEvent.Properties["SiteId"].ToString());
Assert.True(logEvent.Properties.ContainsKey("NodeHostname"));
Assert.Equal("\"test-node1\"", logEvent.Properties["NodeHostname"].ToString());
Assert.True(logEvent.Properties.ContainsKey("NodeRole"));
Assert.Equal("\"Site\"", logEvent.Properties["NodeRole"].ToString());
}
[Fact]
public void SerilogLogger_CentralRole_EnrichesSiteIdAsCentral()
{
var sink = new InMemorySink();
var logger = new LoggerConfiguration()
.Enrich.WithProperty("SiteId", "central")
.Enrich.WithProperty("NodeHostname", "central-node1")
.Enrich.WithProperty("NodeRole", "Central")
.WriteTo.Sink(sink)
.CreateLogger();
logger.Warning("Central warning");
Assert.Single(sink.LogEvents);
var logEvent = sink.LogEvents[0];
Assert.Equal(LogEventLevel.Warning, logEvent.Level);
Assert.Equal("\"central\"", logEvent.Properties["SiteId"].ToString());
Assert.Equal("\"Central\"", logEvent.Properties["NodeRole"].ToString());
}
}
/// <summary>
/// Simple in-memory Serilog sink for testing.
/// </summary>
public class InMemorySink : Serilog.Core.ILogEventSink
{
public List<LogEvent> LogEvents { get; } = new();
public void Emit(LogEvent logEvent)
{
LogEvents.Add(logEvent);
}
}
@@ -0,0 +1,115 @@
using Microsoft.Extensions.Logging.Abstractions;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Host-010: startup preconditions (database migration) must tolerate a database
/// that is briefly unavailable at boot — common when an app container and its DB
/// container start together — via a bounded retry with backoff.
/// </summary>
public class StartupRetryTests
{
[Fact]
public async Task ExecuteWithRetry_SucceedsFirstTry_RunsOnce()
{
var attempts = 0;
await StartupRetry.ExecuteWithRetryAsync(
"test-op",
() => { attempts++; return Task.CompletedTask; },
maxAttempts: 5,
initialDelay: TimeSpan.FromMilliseconds(1),
NullLogger.Instance);
Assert.Equal(1, attempts);
}
[Fact]
public async Task ExecuteWithRetry_TransientFailures_RetriesUntilSuccess()
{
var attempts = 0;
await StartupRetry.ExecuteWithRetryAsync(
"test-op",
() =>
{
attempts++;
if (attempts < 3)
throw new InvalidOperationException("db not ready");
return Task.CompletedTask;
},
maxAttempts: 5,
initialDelay: TimeSpan.FromMilliseconds(1),
NullLogger.Instance);
Assert.Equal(3, attempts);
}
[Fact]
public async Task ExecuteWithRetry_ExhaustsAttempts_RethrowsLastException()
{
var attempts = 0;
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
StartupRetry.ExecuteWithRetryAsync(
"test-op",
() =>
{
attempts++;
throw new InvalidOperationException($"failure {attempts}");
},
maxAttempts: 3,
initialDelay: TimeSpan.FromMilliseconds(1),
NullLogger.Instance,
isTransient: _ => true));
Assert.Equal(3, attempts);
Assert.Equal("failure 3", ex.Message);
}
[Fact]
public async Task ExecuteWithRetry_NonTransientFailure_RethrowsAfterSingleAttempt()
{
// Host-015: a permanent failure (e.g. a schema-version mismatch) must NOT be
// retried — retrying it cannot succeed and only delays the fatal exit by
// minutes. The isTransient predicate classifies it as non-retryable, so the
// operation runs exactly once before the exception propagates.
var attempts = 0;
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
StartupRetry.ExecuteWithRetryAsync(
"test-op",
() =>
{
attempts++;
throw new InvalidOperationException("permanent schema mismatch");
},
maxAttempts: 8,
initialDelay: TimeSpan.FromMilliseconds(1),
NullLogger.Instance,
isTransient: _ => false));
Assert.Equal(1, attempts);
Assert.Equal("permanent schema mismatch", ex.Message);
}
[Fact]
public async Task ExecuteWithRetry_TransientThenPermanent_StopsAtPermanent()
{
// A transient fault is retried; a subsequent permanent fault is not.
var attempts = 0;
await Assert.ThrowsAsync<InvalidOperationException>(() =>
StartupRetry.ExecuteWithRetryAsync(
"test-op",
() =>
{
attempts++;
if (attempts == 1)
throw new TimeoutException("transient");
throw new InvalidOperationException("permanent");
},
maxAttempts: 8,
initialDelay: TimeSpan.FromMilliseconds(1),
NullLogger.Instance,
isTransient: e => e is TimeoutException));
// 1 transient (retried) + 1 permanent (not retried) = 2.
Assert.Equal(2, attempts);
}
}
@@ -0,0 +1,370 @@
using Microsoft.Extensions.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// WP-11: Tests for StartupValidator configuration validation.
/// </summary>
public class StartupValidatorTests
{
private static IConfiguration BuildConfig(Dictionary<string, string?> values)
{
return new ConfigurationBuilder()
.AddInMemoryCollection(values)
.Build();
}
private static Dictionary<string, string?> ValidCentralConfig() => new()
{
["ScadaBridge:Node:Role"] = "Central",
["ScadaBridge:Node:NodeHostname"] = "central-node1",
["ScadaBridge:Node:RemotingPort"] = "8081",
["ScadaBridge:Database:ConfigurationDb"] = "Server=localhost;Database=Config;",
["ScadaBridge:Security:LdapServer"] = "ldap.example.com",
["ScadaBridge:Security:JwtSigningKey"] = "test-signing-key-at-least-32-chars-long",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@central-node1:8081",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@central-node2:8081",
};
private static Dictionary<string, string?> ValidSiteConfig() => new()
{
["ScadaBridge:Node:Role"] = "Site",
["ScadaBridge:Node:NodeHostname"] = "site-a-node1",
["ScadaBridge:Node:SiteId"] = "SiteA",
["ScadaBridge:Node:RemotingPort"] = "8082",
["ScadaBridge:Database:SiteDbPath"] = "./data/scadabridge.db",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@site-a-node1:8082",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@site-a-node2:8082",
};
[Fact]
public void ValidCentralConfig_PassesValidation()
{
var config = BuildConfig(ValidCentralConfig());
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
[Fact]
public void ValidSiteConfig_PassesValidation()
{
var config = BuildConfig(ValidSiteConfig());
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
[Fact]
public void MissingRole_FailsValidation()
{
var values = ValidCentralConfig();
values.Remove("ScadaBridge:Node:Role");
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("Role must be 'Central' or 'Site'", ex.Message);
}
[Fact]
public void InvalidRole_FailsValidation()
{
var values = ValidCentralConfig();
values["ScadaBridge:Node:Role"] = "Unknown";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("Role must be 'Central' or 'Site'", ex.Message);
}
[Fact]
public void EmptyHostname_FailsValidation()
{
var values = ValidCentralConfig();
values["ScadaBridge:Node:NodeHostname"] = "";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("NodeHostname is required", ex.Message);
}
[Fact]
public void MissingHostname_FailsValidation()
{
var values = ValidCentralConfig();
values.Remove("ScadaBridge:Node:NodeHostname");
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("NodeHostname is required", ex.Message);
}
[Theory]
[InlineData("0")]
[InlineData("-1")]
[InlineData("65536")]
[InlineData("abc")]
[InlineData("")]
public void InvalidPort_FailsValidation(string port)
{
var values = ValidCentralConfig();
values["ScadaBridge:Node:RemotingPort"] = port;
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("RemotingPort must be 1-65535", ex.Message);
}
[Theory]
[InlineData("1")]
[InlineData("8081")]
[InlineData("65535")]
public void ValidPort_PassesValidation(string port)
{
var values = ValidCentralConfig();
values["ScadaBridge:Node:RemotingPort"] = port;
var config = BuildConfig(values);
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
[Fact]
public void Site_MissingSiteId_FailsValidation()
{
var values = ValidSiteConfig();
values.Remove("ScadaBridge:Node:SiteId");
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("SiteId is required for Site nodes", ex.Message);
}
[Fact]
public void Central_MissingConfigurationDb_FailsValidation()
{
var values = ValidCentralConfig();
values.Remove("ScadaBridge:Database:ConfigurationDb");
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("ConfigurationDb connection string required for Central", ex.Message);
}
[Fact]
public void Central_MissingMachineDataDb_PassesValidation()
{
// Host-008 regression: MachineDataDb is never consumed anywhere in the
// system (only ConfigurationDb is wired into AddConfigurationDatabase).
// It is no longer a required key, so its absence must not fail startup.
var values = ValidCentralConfig();
values.Remove("ScadaBridge:Database:MachineDataDb");
var config = BuildConfig(values);
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
[Fact]
public void Central_MissingLdapServer_FailsValidation()
{
var values = ValidCentralConfig();
values.Remove("ScadaBridge:Security:LdapServer");
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("LdapServer required for Central", ex.Message);
}
[Fact]
public void Central_MissingJwtSigningKey_FailsValidation()
{
var values = ValidCentralConfig();
values.Remove("ScadaBridge:Security:JwtSigningKey");
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("JwtSigningKey required for Central", ex.Message);
}
[Fact]
public void Site_MissingSiteDbPath_FailsValidation()
{
var values = ValidSiteConfig();
values.Remove("ScadaBridge:Database:SiteDbPath");
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("SiteDbPath required for Site nodes", ex.Message);
}
[Fact]
public void FewerThanTwoSeedNodes_FailsValidation()
{
var values = ValidCentralConfig();
values.Remove("ScadaBridge:Cluster:SeedNodes:1");
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("SeedNodes must have at least 2 entries", ex.Message);
}
[Fact]
public void NoSeedNodes_FailsValidation()
{
var values = ValidCentralConfig();
values.Remove("ScadaBridge:Cluster:SeedNodes:0");
values.Remove("ScadaBridge:Cluster:SeedNodes:1");
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("SeedNodes must have at least 2 entries", ex.Message);
}
[Theory]
[InlineData("0")]
[InlineData("-1")]
[InlineData("65536")]
[InlineData("abc")]
public void Site_InvalidGrpcPort_FailsValidation(string grpcPort)
{
var values = ValidSiteConfig();
values["ScadaBridge:Node:GrpcPort"] = grpcPort;
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("GrpcPort must be 1-65535", ex.Message);
}
[Fact]
public void Site_ValidGrpcPort_PassesValidation()
{
var values = ValidSiteConfig();
values["ScadaBridge:Node:GrpcPort"] = "8083";
var config = BuildConfig(values);
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
[Fact]
public void Central_InvalidGrpcPort_NotValidated()
{
var values = ValidCentralConfig();
values["ScadaBridge:Node:GrpcPort"] = "0";
var config = BuildConfig(values);
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
[Fact]
public void Site_SeedNodeOnGrpcPort_FailsValidation()
{
// Host-004 regression: a site seed node must reference an Akka remoting
// endpoint, never the Kestrel HTTP/2 gRPC port. A seed node whose port
// equals this node's GrpcPort would make a joining node attempt an
// Akka.Remote TCP association against the gRPC listener and fail.
var values = ValidSiteConfig();
values["ScadaBridge:Node:GrpcPort"] = "8083";
values["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@site-a-node1:8083";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("must not target the gRPC port", ex.Message);
}
[Fact]
public void Site_SeedNodeOnDefaultGrpcPort_FailsValidation()
{
// GrpcPort is absent here, so the NodeOptions default of 8083 applies.
// A seed node on 8083 must still be rejected.
var values = ValidSiteConfig();
values["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@site-a-node2:8083";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("must not target the gRPC port", ex.Message);
}
[Fact]
public void Site_SeedNodesOnRemotingPort_PassesValidation()
{
// Two distinct site nodes, both seed entries on the remoting port (8082).
var values = ValidSiteConfig();
values["ScadaBridge:Node:GrpcPort"] = "8083";
values["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@site-a-node1:8082";
values["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@site-a-node2:8082";
var config = BuildConfig(values);
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
[Fact]
public void Central_SeedNodeOnPort8083_PassesValidation()
{
// The gRPC-port rule applies to Site nodes only. A Central node has no
// GrpcPort, so a seed node on 8083 must not be rejected.
var values = ValidCentralConfig();
values["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@central-node2:8083";
var config = BuildConfig(values);
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
[Fact]
public void Site_GrpcPortEqualsRemotingPort_FailsValidation()
{
// Host-007 regression: REQ-HOST-4 requires GrpcPort to differ from
// RemotingPort. Identical values cause Kestrel and Akka.Remote to
// contend for the same port at runtime.
var values = ValidSiteConfig();
values["ScadaBridge:Node:RemotingPort"] = "8082";
values["ScadaBridge:Node:GrpcPort"] = "8082";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("GrpcPort must differ from RemotingPort", ex.Message);
}
[Fact]
public void Site_DefaultGrpcPortEqualsRemotingPort_FailsValidation()
{
// GrpcPort absent => NodeOptions default 8083. A site whose RemotingPort
// is also 8083 must still be rejected.
var values = ValidSiteConfig();
values["ScadaBridge:Node:RemotingPort"] = "8083";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("GrpcPort must differ from RemotingPort", ex.Message);
}
[Fact]
public void Site_GrpcPortDiffersFromRemotingPort_PassesValidation()
{
var values = ValidSiteConfig();
values["ScadaBridge:Node:RemotingPort"] = "8082";
values["ScadaBridge:Node:GrpcPort"] = "8083";
var config = BuildConfig(values);
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
[Fact]
public void MultipleErrors_AllReported()
{
var values = new Dictionary<string, string?>
{
// Role is missing, hostname is missing, port is missing
};
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("Role must be 'Central' or 'Site'", ex.Message);
Assert.Contains("NodeHostname is required", ex.Message);
Assert.Contains("RemotingPort must be 1-65535", ex.Message);
Assert.Contains("SeedNodes must have at least 2 entries", ex.Message);
}
}
@@ -0,0 +1,56 @@
using System.Reflection;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// WP-17: Tests for Windows Service support.
/// Verifies UseWindowsService() is called in Program.cs.
/// </summary>
public class WindowsServiceTests
{
[Fact]
public void ProgramCs_CallsUseWindowsService()
{
var hostProjectDir = FindHostProjectDirectory();
Assert.NotNull(hostProjectDir);
var programFile = Path.Combine(hostProjectDir, "Program.cs");
Assert.True(File.Exists(programFile), "Program.cs not found");
var content = File.ReadAllText(programFile);
// Verify UseWindowsService() is called for both Central and Site paths
var occurrences = content.Split("UseWindowsService()").Length - 1;
Assert.True(occurrences >= 2,
$"Expected UseWindowsService() to be called at least twice (Central and Site paths), found {occurrences} occurrence(s)");
}
[Fact]
public void HostProject_ReferencesWindowsServicesPackage()
{
var hostProjectDir = FindHostProjectDirectory();
Assert.NotNull(hostProjectDir);
var csprojFile = Path.Combine(hostProjectDir, "ZB.MOM.WW.ScadaBridge.Host.csproj");
Assert.True(File.Exists(csprojFile), "ZB.MOM.WW.ScadaBridge.Host.csproj not found");
var content = File.ReadAllText(csprojFile);
Assert.Contains("Microsoft.Extensions.Hosting.WindowsServices", content);
}
private static string? FindHostProjectDirectory()
{
var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
var dir = new DirectoryInfo(assemblyDir);
while (dir != null)
{
var hostPath = Path.Combine(dir.FullName, "src", "ZB.MOM.WW.ScadaBridge.Host");
if (Directory.Exists(hostPath))
return hostPath;
dir = dir.Parent;
}
return null;
}
}
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Akka.TestKit.Xunit2" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Serilog" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj" />
</ItemGroup>
</Project>