From 76198b36e3d64ad1fa622119ce0db067cb13376d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 05:41:25 -0400 Subject: [PATCH] fix(host): add MachineDataDb startup validation for Central (reverts Host-008, M2.9 #17) REQ-HOST-3/REQ-HOST-4 require a MachineDataDb connection string for Central nodes. The shipped docker appsettings (docker/central-node-a/appsettings.Central.json and central-node-b) already carry the key. Host-008 had removed the fail-fast Require because MachineDataDb had no consumer yet; this commit reverses that decision so a misconfigured or missing connection string is caught at startup with a clear error. Changes: - DatabaseOptions: add MachineDataDb property with XML doc comment - StartupValidator: add .Require for ScadaBridge:Database:MachineDataDb inside the existing Central .When block, immediately after the ConfigurationDb Require - StartupValidatorTests: rename Central_MissingMachineDataDb_PassesValidation -> FailsValidation and flip to Assert.Throws; update comment to cite REQ-HOST-3/4, shipped docker appsettings, and the Host-008 reversal; add MachineDataDb to ValidCentralConfig() so all other Central tests remain green - CentralDbTestEnvironment: supply ScadaBridge__Database__MachineDataDb env var (mirrors ConfigurationDb pattern) so HostStartupTests, HealthCheckTests, and MetricsEndpointTests pass through the new Require - CompositionRootTests, AkkaHostedServiceAuditWiringTests, ActorPathTests: set ScadaBridge__Database__MachineDataDb env var alongside the pepper env var and clear it in Dispose, matching the existing pepper handling pattern Build: 0 warnings, 0 errors. dotnet test Host.Tests: 233/233 passed. --- .../DatabaseOptions.cs | 2 ++ .../StartupValidator.cs | 3 +++ .../ActorPathTests.cs | 6 ++++++ .../AkkaHostedServiceAuditWiringTests.cs | 6 ++++++ .../CentralDbTestEnvironment.cs | 18 ++++++++++++++++-- .../CompositionRootTests.cs | 6 ++++++ .../StartupValidatorTests.cs | 15 +++++++++------ 7 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/DatabaseOptions.cs b/src/ZB.MOM.WW.ScadaBridge.Host/DatabaseOptions.cs index acf11b1f..5fdefc37 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/DatabaseOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/DatabaseOptions.cs @@ -7,6 +7,8 @@ public class DatabaseOptions { /// Connection string for the central configuration SQL Server database. public string? ConfigurationDb { get; set; } + /// Connection string for the central machine-data SQL Server database. + public string? MachineDataDb { get; set; } /// File system path to the site-local SQLite database directory. public string? SiteDbPath { get; set; } } diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs b/src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs index 00f0e8fb..8c14eb47 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs @@ -60,6 +60,9 @@ public static class StartupValidator .Require("ScadaBridge:Database:ConfigurationDb", _ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Database")["ConfigurationDb"]), "connection string required for Central") + .Require("ScadaBridge:Database:MachineDataDb", + _ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Database")["MachineDataDb"]), + "connection string required for Central") // Task 1.4: the LDAP server key moved into the nested Security:Ldap // sub-section (bound to the shared LdapOptions). Validate the nested key so // the pre-host preflight still fails fast on a missing LDAP server for diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs index 8d4bceb1..4bd84ab9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs @@ -35,6 +35,11 @@ public class CentralActorPathTests : IAsyncLifetime // env var is visible to StartupValidator.Validate() at Program.cs line 42. Environment.SetEnvironmentVariable("ScadaBridge__InboundApi__ApiKeyPepper", CentralDbTestEnvironment.TestPepper); + // Supply MachineDataDb so the reverted Host-008 Require (REQ-HOST-3/4, M2.9 #17) + // passes for Central-role StartupValidator. A non-empty placeholder satisfies + // the preflight; the DI override below replaces the real DbContext anyway. + Environment.SetEnvironmentVariable("ScadaBridge__Database__MachineDataDb", + "Server=localhost;Database=MachineData;"); _factory = new WebApplicationFactory() .WithWebHostBuilder(builder => @@ -94,6 +99,7 @@ public class CentralActorPathTests : IAsyncLifetime _factory?.Dispose(); Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv); Environment.SetEnvironmentVariable("ScadaBridge__InboundApi__ApiKeyPepper", null); + Environment.SetEnvironmentVariable("ScadaBridge__Database__MachineDataDb", null); await Task.CompletedTask; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AkkaHostedServiceAuditWiringTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AkkaHostedServiceAuditWiringTests.cs index 7855d876..9e9cbf61 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AkkaHostedServiceAuditWiringTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AkkaHostedServiceAuditWiringTests.cs @@ -101,6 +101,11 @@ public class CentralAuditWiringTests : IDisposable // runs before WithWebHostBuilder.ConfigureAppConfiguration applies DI config. Environment.SetEnvironmentVariable("ScadaBridge__InboundApi__ApiKeyPepper", CentralDbTestEnvironment.TestPepper); + // Supply MachineDataDb so the reverted Host-008 Require (REQ-HOST-3/4, M2.9 #17) + // passes for Central-role StartupValidator. A non-empty placeholder satisfies + // the preflight; the DI override below replaces the real DbContext anyway. + Environment.SetEnvironmentVariable("ScadaBridge__Database__MachineDataDb", + "Server=localhost;Database=MachineData;"); _factory = new WebApplicationFactory() .WithWebHostBuilder(builder => @@ -156,6 +161,7 @@ public class CentralAuditWiringTests : IDisposable _factory.Dispose(); Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv); Environment.SetEnvironmentVariable("ScadaBridge__InboundApi__ApiKeyPepper", null); + Environment.SetEnvironmentVariable("ScadaBridge__Database__MachineDataDb", null); } [Fact] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralDbTestEnvironment.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralDbTestEnvironment.cs index bc107767..7f3b0171 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralDbTestEnvironment.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralDbTestEnvironment.cs @@ -10,8 +10,12 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// /// Also supplies ScadaBridge__InboundApi__ApiKeyPepper so the Central-role /// StartupValidator preflight (added in 1fcc4f5) does not fail for tests that set -/// DOTNET_ENVIRONMENT=Central without an explicit pepper env var. Both vars -/// are restored on Dispose so tests stay isolated. +/// DOTNET_ENVIRONMENT=Central without an explicit pepper env var. +/// +/// Also supplies ScadaBridge__Database__MachineDataDb so the Central-role +/// StartupValidator preflight (reverts Host-008, REQ-HOST-3/4, M2.9 #17) does not +/// fail for tests that set DOTNET_ENVIRONMENT=Central without an explicit +/// MachineDataDb env var. All vars are restored on Dispose so tests stay isolated. /// internal sealed class CentralDbTestEnvironment : IDisposable { @@ -22,6 +26,11 @@ internal sealed class CentralDbTestEnvironment : IDisposable private const string ConfigKey = "ScadaBridge__Database__ConfigurationDb"; + private const string MachineDataDb = + "Server=localhost,1433;Database=ScadaBridgeMachineData;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"; + + private const string MachineDataKey = "ScadaBridge__Database__MachineDataDb"; + // Test-only pepper — satisfies the ≥16-char StartupValidator requirement without // committing a real secret. The env-var name uses the double-underscore delimiter // so AddEnvironmentVariables() maps it to ScadaBridge:InboundApi:ApiKeyPepper. @@ -29,6 +38,7 @@ internal sealed class CentralDbTestEnvironment : IDisposable private const string PepperKey = "ScadaBridge__InboundApi__ApiKeyPepper"; private readonly string? _previousConfig; + private readonly string? _previousMachineData; private readonly string? _previousPepper; public CentralDbTestEnvironment() @@ -36,6 +46,9 @@ internal sealed class CentralDbTestEnvironment : IDisposable _previousConfig = Environment.GetEnvironmentVariable(ConfigKey); Environment.SetEnvironmentVariable(ConfigKey, ConfigurationDb); + _previousMachineData = Environment.GetEnvironmentVariable(MachineDataKey); + Environment.SetEnvironmentVariable(MachineDataKey, MachineDataDb); + _previousPepper = Environment.GetEnvironmentVariable(PepperKey); Environment.SetEnvironmentVariable(PepperKey, TestPepper); } @@ -43,6 +56,7 @@ internal sealed class CentralDbTestEnvironment : IDisposable public void Dispose() { Environment.SetEnvironmentVariable(ConfigKey, _previousConfig); + Environment.SetEnvironmentVariable(MachineDataKey, _previousMachineData); Environment.SetEnvironmentVariable(PepperKey, _previousPepper); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs index 96a9f21c..15750328 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs @@ -95,6 +95,11 @@ public class CentralCompositionRootTests : IDisposable // runs before WithWebHostBuilder.ConfigureAppConfiguration applies DI config. Environment.SetEnvironmentVariable("ScadaBridge__InboundApi__ApiKeyPepper", CentralDbTestEnvironment.TestPepper); + // Supply MachineDataDb so the reverted Host-008 Require (REQ-HOST-3/4, M2.9 #17) + // passes for Central-role StartupValidator. A non-empty placeholder satisfies + // the preflight; the DI override below replaces the real DbContext anyway. + Environment.SetEnvironmentVariable("ScadaBridge__Database__MachineDataDb", + "Server=localhost;Database=MachineData;"); _factory = new WebApplicationFactory() .WithWebHostBuilder(builder => @@ -159,6 +164,7 @@ public class CentralCompositionRootTests : IDisposable _factory.Dispose(); Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv); Environment.SetEnvironmentVariable("ScadaBridge__InboundApi__ApiKeyPepper", null); + Environment.SetEnvironmentVariable("ScadaBridge__Database__MachineDataDb", null); } // --- Singletons --- diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs index 16518d0a..109d9db7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs @@ -20,6 +20,7 @@ public class StartupValidatorTests ["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", @@ -152,17 +153,19 @@ public class StartupValidatorTests } [Fact] - public void Central_MissingMachineDataDb_PassesValidation() + public void Central_MissingMachineDataDb_FailsValidation() { - // 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. + // 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 = Record.Exception(() => StartupValidator.Validate(config)); - Assert.Null(ex); + var ex = Assert.Throws(() => StartupValidator.Validate(config)); + Assert.Contains("MachineDataDb connection string required for Central", ex.Message); } [Fact]