Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs
T
Joseph Doherty 2e4e41a8f7 fix(auditlog): site audit DB onto the data volume; required path + soft flush
Closes WP1.2 of the arch-review remediation plan (finding #2, High):
SqliteAuditWriterOptions.DatabasePath defaulted to CWD-relative "auditlog.db",
which on the docker rig resolves onto the container's ephemeral overlayfs
(not the mounted /app/data volume), silently discarding the pending audit
forward-state backlog on every recreate; nothing in docker/ or docker-env2/
overrode it; FlushIntervalMs was validated but never read by the writer loop
(one commit per event even at trickle rate); and no PRAGMA synchronous was
set (SQLite's FULL default fsyncs every commit).

- DatabasePath now has no default (mirrors ZB.MOM.WW.LocalDb's LocalDbOptions.Path)
  and is required pre-host for Site nodes only, via a new StartupValidator raw-config
  check (top-level "AuditLog:SiteWriter:DatabasePath", NOT nested under ScadaBridge:
  AddAuditLog binds that section off the configuration root). SqliteAuditWriterOptionsValidator
  deliberately does NOT check DatabasePath itself, because AddAuditLog runs its
  ValidateOnStart on both Central and Site composition roots but only Site nodes
  ever resolve the writer — checking it there would fail Central's boot too.
- All 8 site-node appsettings under docker/ and docker-env2/ now set
  AuditLog:SiteWriter:DatabasePath to /app/data/auditlog.db (mounted volume,
  survives container recreate, same convention as LocalDb:Path); the local-dev
  base appsettings.Site.json sets ./data/auditlog.db to match.
- The writer loop now honors FlushIntervalMs: after draining the immediately
  available burst, it keeps the transaction open (bounded by FlushIntervalMs
  from the first event) waiting for more trickle-rate events before committing,
  instead of flushing (and fsyncing) per event.
- PRAGMA synchronous = NORMAL on the write connection — audit is best-effort by
  design (CLAUDE.md: "Audit-write failure NEVER aborts the user-facing action"),
  so NORMAL's narrower power-loss window is an acceptable trade for far fewer
  fsyncs; WAL mode still guarantees no corruption.
- Tests: StartupValidator site-required/blank/central-exempt cases; writer
  trickle-load single-transaction coalescing + beyond-interval separate-transaction
  regression (new FlushCountForTests seam); options-validator doc updates reflecting
  the moved responsibility. Full suite runs green: AuditLog.Tests 368/368,
  Host.Tests 480/480.

One-time migration note: the existing container-local auditlog.db (wherever it
landed under CWD) is abandoned by this change, not migrated — already-forwarded
rows are safe centrally (AuditLog is the durable copy), and any still-Pending
rows on the abandoned path are lost once. This is the exact bug being fixed, not
a new loss: those rows were already living outside the mounted volume and would
not have survived the next container recreate regardless. Cross-reference
docs/known-issues/2026-07-20-cached-telemetry-drain-hot-loop.md, which this
placement bug caused.
2026-08-14 20:13:31 -04:00

713 lines
29 KiB
C#

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:Database:MachineDataDb"] = "Server=localhost;Database=MachineData;",
["ScadaBridge:Security:Ldap:Server"] = "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",
// 1fcc4f5: Central requires a pepper (≥16 chars) for the inbound-API peppered-HMAC verifier.
["ScadaBridge:InboundApi:ApiKeyPepper"] = "test-pepper-01234567890",
};
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",
// T0.3: the gRPC control plane is fail-closed, so a Site node without a preshared
// key serves nothing while still looking healthy. Required at boot for that reason.
["ScadaBridge:Communication:GrpcPsk"] = "test-site-control-plane-key",
// Phase 4: gRPC (CentralControlService) is the only site→central transport, so a Site
// node must list at least one central gRPC endpoint to dial (no Akka fallback remains).
["ScadaBridge:Communication:CentralGrpcEndpoints:0"] = "http://central-a:8083",
// arch-review remediation WP1.2: the site hot-path audit writer's SQLite file has no
// default path (mirrors LocalDb:Path) — required so it lands on the mounted data volume,
// not the container's ephemeral overlayfs.
["AuditLog:SiteWriter:DatabasePath"] = "/app/data/auditlog.db",
};
[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 SiteWithoutGrpcPsk_FailsValidation()
{
// The failure this prevents is silent: ControlPlaneAuthInterceptor refuses every
// SiteStream call without a key, so the node boots, joins its pair, reports healthy —
// and serves no live streams, no audit pulls and no cached-telemetry ingest. Central
// sees a site that is up and answering heartbeats but never sends anything.
var values = ValidSiteConfig();
values.Remove("ScadaBridge:Communication:GrpcPsk");
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("GrpcPsk", ex.Message);
}
[Fact]
public void SiteWithBlankGrpcPsk_FailsValidation()
{
// Whitespace is not a key. An empty-string value would otherwise satisfy a
// key-present check while leaving the interceptor in its fail-closed state.
var values = ValidSiteConfig();
values["ScadaBridge:Communication:GrpcPsk"] = " ";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("GrpcPsk", ex.Message);
}
[Fact]
public void CentralWithoutGrpcPsk_PassesValidation()
{
// Central holds one key PER SITE (SitePsks / the secret store), not a single key of
// its own, so this setting is meaningless there and must not be required.
var config = BuildConfig(ValidCentralConfig());
Assert.Null(Record.Exception(() => StartupValidator.Validate(config)));
}
[Fact]
public void SiteWithoutCentralGrpcEndpoints_FailsValidation()
{
// Phase 4: gRPC (CentralControlService) is the only site→central transport, so a Site
// node with no central gRPC endpoint has nothing to dial — heartbeats, health, notification
// forwards and audit ingest all silently fail. Fail fast at boot instead.
var values = ValidSiteConfig();
values.Remove("ScadaBridge:Communication:CentralGrpcEndpoints:0");
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("CentralGrpcEndpoints", ex.Message);
}
[Fact]
public void CentralWithoutCentralGrpcEndpoints_PassesValidation()
{
// A central node hosts CentralControlService; it does not dial it, so it declares no
// endpoints. The site-only requirement must not fire for Central.
var config = BuildConfig(ValidCentralConfig());
Assert.Null(Record.Exception(() => StartupValidator.Validate(config)));
}
[Fact]
public void SiteWithoutAuditLogDatabasePath_FailsValidation()
{
// arch-review remediation WP1.2: SqliteAuditWriterOptions.DatabasePath has no default
// (mirrors LocalDb:Path). An unset value used to fall back to a bare "auditlog.db"
// resolved relative to CWD — on the docker rig, the container's ephemeral overlayfs —
// so the node booted, looked healthy, and lost its pending audit backlog on every
// recreate. Fail fast at boot instead.
var values = ValidSiteConfig();
values.Remove("AuditLog:SiteWriter:DatabasePath");
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("DatabasePath", ex.Message);
}
[Fact]
public void SiteWithBlankAuditLogDatabasePath_FailsValidation()
{
var values = ValidSiteConfig();
values["AuditLog:SiteWriter:DatabasePath"] = " ";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("DatabasePath", ex.Message);
}
[Fact]
public void CentralWithoutAuditLogDatabasePath_PassesValidation()
{
// AddAuditLog binds SqliteAuditWriterOptions on both roles, but only Site nodes ever
// resolve the writer — the requirement must not fire for Central.
var config = BuildConfig(ValidCentralConfig());
Assert.Null(Record.Exception(() => StartupValidator.Validate(config)));
}
[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;
// The self-first seed rule (2026-07-22) compares host AND port, so this node's own
// seed entry moves with its remoting port — otherwise this port-range test would be
// asserting against a config that is inconsistent for an unrelated reason.
values["ScadaBridge:Cluster:SeedNodes:0"] = $"akka.tcp://scadabridge@central-node1:{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_FailsValidation()
{
// Reverts Host-008. REQ-HOST-3/REQ-HOST-4 require MachineDataDb to be
// validated at startup for Central nodes, and the shipped docker appsettings
// (docker/central-node-a/appsettings.Central.json and central-node-b) carry
// the key. The prior Host-008 decision (which removed the Require) is reversed
// here (#17, M2.9): a missing MachineDataDb must fail fast with a clear error.
var values = ValidCentralConfig();
values.Remove("ScadaBridge:Database:MachineDataDb");
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("MachineDataDb connection string required for Central", ex.Message);
}
[Fact]
public void Central_MissingLdapServer_FailsValidation()
{
// Task 1.4: the LDAP server key nests under Security:Ldap now. The pre-host
// preflight validates the nested key and still fails fast for Central.
var values = ValidCentralConfig();
values.Remove("ScadaBridge:Security:Ldap:Server");
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("Ldap:Server 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 Central_MissingApiKeyPepper_FailsValidation()
{
// Guard for 1fcc4f5: Central nodes require a pepper (≥16 chars) to back
// the inbound-API peppered-HMAC verifier. A missing pepper must fail fast
// so a misconfigured deployment is caught before the actor system starts.
var values = ValidCentralConfig();
values.Remove("ScadaBridge:InboundApi:ApiKeyPepper");
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("ApiKeyPepper", ex.Message);
}
[Fact]
public void Central_ShortApiKeyPepper_FailsValidation()
{
// Guard for 1fcc4f5: a pepper shorter than 16 characters must also be rejected.
var values = ValidCentralConfig();
values["ScadaBridge:InboundApi:ApiKeyPepper"] = "tooshort";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("ApiKeyPepper", ex.Message);
}
[Fact]
public void Site_ApiKeyPepper_NotRequired()
{
// Site nodes do not host the inbound API, so the pepper must NOT be required
// for them — absence must not fail validation.
var values = ValidSiteConfig();
// Explicitly ensure no pepper is present
values.Remove("ScadaBridge:InboundApi:ApiKeyPepper");
var config = BuildConfig(values);
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
/// <summary>
/// The inverse of the rule this replaces. SiteDbPath was mandatory for Site
/// nodes until LocalDb Phase 2 moved the site tables into the consolidated
/// LocalDb database; it now names only the legacy file the boot-time migrator
/// drains, so its absence means "nothing to migrate". Requiring it would make
/// every already-migrated node carry a dead key forever.
/// </summary>
[Fact]
public void Site_MissingSiteDbPath_IsAccepted_BecauseThePathIsMigrationOnly()
{
var values = ValidSiteConfig();
values.Remove("ScadaBridge:Database:SiteDbPath");
var config = BuildConfig(values);
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
[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);
}
[Fact]
public void PeerFirstSeedOrder_FailsValidation()
{
// Decision 2026-07-22: every node must list ITSELF as seed-nodes[0]. Akka only runs
// FirstSeedNodeProcess (the process that can form a new cluster when no peer answers)
// when seed-nodes[0] is this node's own address; with the peer first the node can
// never cold-start alone — the "registered outage gap".
var values = ValidCentralConfig();
values["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@central-node2:8081";
values["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@central-node1:8081";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("SeedNodes", ex.Message);
Assert.Contains("must list this node itself first", ex.Message);
}
[Fact]
public void SelfFirstSeedOrder_OnASiteNode_PassesValidation()
{
// Positive control for the rule above: the shipped ordering must validate on a Site
// node too (the rule is unconditional, not Central-only).
var config = BuildConfig(ValidSiteConfig());
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
[Fact]
public void SelfFirstSeed_MatchedOnHostAndPort_NotJustHost()
{
// Both nodes of a pair can share a hostname when they differ by port (a two-node
// dev/loopback install). The rule must compare host AND port, or such a node passes
// while actually being the non-first seed.
var values = ValidCentralConfig();
values["ScadaBridge:Node:NodeHostname"] = "localhost";
values["ScadaBridge:Node:RemotingPort"] = "8082";
values["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:8081";
values["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:8082";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("must list this node itself first", 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_SeedNodeOnMetricsPort_FailsValidation()
{
// Review 01 [Medium]: appsettings.Site.json shipped a seed pointing at the
// Kestrel HTTP/1.1 metrics listener (8084) — a doomed Akka.Remote
// association. The validator guarded GrpcPort but not MetricsPort.
var values = ValidSiteConfig();
values["ScadaBridge:Node:GrpcPort"] = "8083";
values["ScadaBridge:Node:MetricsPort"] = "8084";
values["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@site-a-node1:8084";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("must not target the metrics port", ex.Message);
}
[Fact]
public void Site_SeedNodeOnDefaultMetricsPort_FailsValidation()
{
// MetricsPort absent => NodeOptions default 8084. A seed on 8084 must
// still be rejected. Keep GrpcPort distinct so only the metrics rule fires.
var values = ValidSiteConfig();
values["ScadaBridge:Node:GrpcPort"] = "8083";
values["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@site-a-node2:8084";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("must not target the metrics 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);
}
[Theory]
[InlineData("0")]
[InlineData("-1")]
[InlineData("65536")]
[InlineData("abc")]
public void Site_InvalidMetricsPort_FailsValidation(string metricsPort)
{
var values = ValidSiteConfig();
values["ScadaBridge:Node:MetricsPort"] = metricsPort;
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("MetricsPort must be 1-65535", ex.Message);
}
[Fact]
public void Site_ValidMetricsPort_PassesValidation()
{
var values = ValidSiteConfig();
values["ScadaBridge:Node:MetricsPort"] = "8084";
var config = BuildConfig(values);
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
[Fact]
public void Site_MetricsPortEqualsRemotingPort_FailsValidation()
{
// Host-007 regression: the Kestrel metrics (HTTP/1.1) listener port must
// differ from RemotingPort. Identical values cause the metrics listener
// and Akka.Remote to contend for the same port at runtime.
var values = ValidSiteConfig();
values["ScadaBridge:Node:RemotingPort"] = "8082";
values["ScadaBridge:Node:MetricsPort"] = "8082";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("MetricsPort must differ from RemotingPort", ex.Message);
}
[Fact]
public void Site_MetricsPortEqualsGrpcPort_FailsValidation()
{
// Host-007 regression: the metrics listener port must differ from GrpcPort.
var values = ValidSiteConfig();
values["ScadaBridge:Node:GrpcPort"] = "8083";
values["ScadaBridge:Node:MetricsPort"] = "8083";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("MetricsPort must differ from GrpcPort", ex.Message);
}
[Fact]
public void Site_DefaultMetricsPortEqualsRemotingPort_FailsValidation()
{
// MetricsPort absent => NodeOptions default 8084. A site whose RemotingPort
// is also 8084 must still be rejected.
var values = ValidSiteConfig();
values["ScadaBridge:Node:RemotingPort"] = "8084";
// Keep GrpcPort distinct so only the metrics-vs-remoting rule fires.
values["ScadaBridge:Node:GrpcPort"] = "8083";
// Seed nodes default to the remoting port (8082) in ValidSiteConfig; realign
// them to 8084 so the seed-vs-grpc rule is not what trips here.
values["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@site-a-node1:8084";
values["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@site-a-node2:8084";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("MetricsPort must differ from RemotingPort", ex.Message);
}
[Fact]
public void Site_MetricsPortDiffersFromRemotingAndGrpc_PassesValidation()
{
var values = ValidSiteConfig();
values["ScadaBridge:Node:RemotingPort"] = "8082";
values["ScadaBridge:Node:GrpcPort"] = "8083";
values["ScadaBridge:Node:MetricsPort"] = "8084";
var config = BuildConfig(values);
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
[Fact]
public void Central_InvalidMetricsPort_NotValidated()
{
// The metrics-port rules apply to Site nodes only; a Central node runs no
// metrics listener, so an out-of-range MetricsPort must not fail startup.
var values = ValidCentralConfig();
values["ScadaBridge:Node:MetricsPort"] = "0";
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);
}
}