From 7e25efa7902cf5baae595d9cb17c4daef9051df7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 2 Jun 2026 03:40:56 -0400 Subject: [PATCH] test(host): supply Central test ApiKeyPepper so StartupValidator preflight passes (fix pre-existing 1fcc4f5 red); lock pepper-required behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 1fcc4f5 added a Central-only Require for ScadaBridge:InboundApi:ApiKeyPepper (>=16 chars) to StartupValidator. That Require fires in Program.cs before WebApplicationFactory can apply any WithWebHostBuilder config overlays, so it must be satisfied via environment variables (which ARE in the pre-host AddEnvironmentVariables() pass). Fix (test-only, no src/ changes): - CentralDbTestEnvironment: add ScadaBridge__InboundApi__ApiKeyPepper env var (TestPepper constant, 23 chars) alongside the existing db connection string; restore on Dispose. Fixes HealthCheckTests, MetricsEndpointTests, and HostStartupTests.CentralRole_StartsWithoutError which all use CentralDbTestEnvironment. - CentralActorPathTests.InitializeAsync: set the pepper env var before WebApplicationFactory is constructed (the class uses IAsyncLifetime directly, not CentralDbTestEnvironment). - CentralCompositionRootTests ctor + Dispose: same env-var pattern; those tests already had the pepper in AddInMemoryCollection (DI-layer only, too late for pre-host validation). - CentralAuditWiringTests ctor + Dispose: same env-var pattern for the same reason. - StartupValidatorTests.ValidCentralConfig(): add pepper so the unit tests that call StartupValidator.Validate() directly with a Central config stop failing. - Add guard tests: Central_MissingApiKeyPepper_FailsValidation, Central_ShortApiKeyPepper_FailsValidation, Site_ApiKeyPepper_NotRequired — these lock the production behavior introduced by 1fcc4f5. --- .../ActorPathTests.cs | 8 ++++ .../AkkaHostedServiceAuditWiringTests.cs | 7 ++++ .../CentralDbTestEnvironment.cs | 16 ++++++- .../CompositionRootTests.cs | 7 ++++ .../StartupValidatorTests.cs | 42 +++++++++++++++++++ 5 files changed, 79 insertions(+), 1 deletion(-) diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs index ecf872f9..a0091ef6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs @@ -29,6 +29,13 @@ public class CentralActorPathTests : IAsyncLifetime _previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central"); + // Supply the pepper so the Central-role StartupValidator preflight (1fcc4f5) + // passes before WebApplicationFactory gets a chance to overlay DI config. + // The pre-host config builder includes AddEnvironmentVariables(), so this + // env var is visible to StartupValidator.Validate() at Program.cs line 42. + Environment.SetEnvironmentVariable("ScadaBridge__InboundApi__ApiKeyPepper", + CentralDbTestEnvironment.TestPepper); + _factory = new WebApplicationFactory() .WithWebHostBuilder(builder => { @@ -86,6 +93,7 @@ public class CentralActorPathTests : IAsyncLifetime { _factory?.Dispose(); Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv); + Environment.SetEnvironmentVariable("ScadaBridge__InboundApi__ApiKeyPepper", 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 69ec59a3..5e4fed4c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AkkaHostedServiceAuditWiringTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AkkaHostedServiceAuditWiringTests.cs @@ -95,6 +95,12 @@ public class CentralAuditWiringTests : IDisposable _previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central"); + // Supply the pepper so the Central-role StartupValidator preflight (1fcc4f5) + // passes. The pre-host config builder uses AddEnvironmentVariables(), which + // runs before WithWebHostBuilder.ConfigureAppConfiguration applies DI config. + Environment.SetEnvironmentVariable("ScadaBridge__InboundApi__ApiKeyPepper", + CentralDbTestEnvironment.TestPepper); + _factory = new WebApplicationFactory() .WithWebHostBuilder(builder => { @@ -148,6 +154,7 @@ public class CentralAuditWiringTests : IDisposable { _factory.Dispose(); Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv); + Environment.SetEnvironmentVariable("ScadaBridge__InboundApi__ApiKeyPepper", 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 2ec1cf61..bc107767 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralDbTestEnvironment.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralDbTestEnvironment.cs @@ -8,7 +8,10 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// environment variable (Program's configuration builder calls /// AddEnvironmentVariables()). /// -/// Dispose restores the previous value so tests stay isolated. +/// 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. /// internal sealed class CentralDbTestEnvironment : IDisposable { @@ -19,16 +22,27 @@ internal sealed class CentralDbTestEnvironment : IDisposable private const string ConfigKey = "ScadaBridge__Database__ConfigurationDb"; + // 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. + internal const string TestPepper = "test-pepper-01234567890"; + private const string PepperKey = "ScadaBridge__InboundApi__ApiKeyPepper"; + private readonly string? _previousConfig; + private readonly string? _previousPepper; public CentralDbTestEnvironment() { _previousConfig = Environment.GetEnvironmentVariable(ConfigKey); Environment.SetEnvironmentVariable(ConfigKey, ConfigurationDb); + + _previousPepper = Environment.GetEnvironmentVariable(PepperKey); + Environment.SetEnvironmentVariable(PepperKey, TestPepper); } public void Dispose() { Environment.SetEnvironmentVariable(ConfigKey, _previousConfig); + 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 104bfb19..12a7b829 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs @@ -90,6 +90,12 @@ public class CentralCompositionRootTests : IDisposable _previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central"); + // Supply the pepper so the Central-role StartupValidator preflight (1fcc4f5) + // passes. The pre-host config builder uses AddEnvironmentVariables(), which + // runs before WithWebHostBuilder.ConfigureAppConfiguration applies DI config. + Environment.SetEnvironmentVariable("ScadaBridge__InboundApi__ApiKeyPepper", + CentralDbTestEnvironment.TestPepper); + _factory = new WebApplicationFactory() .WithWebHostBuilder(builder => { @@ -151,6 +157,7 @@ public class CentralCompositionRootTests : IDisposable { _factory.Dispose(); Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv); + Environment.SetEnvironmentVariable("ScadaBridge__InboundApi__ApiKeyPepper", 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 8a5b7e7a..16518d0a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs @@ -24,6 +24,8 @@ public class StartupValidatorTests ["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 ValidSiteConfig() => new() @@ -187,6 +189,46 @@ public class StartupValidatorTests 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(() => 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(() => 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); + } + [Fact] public void Site_MissingSiteDbPath_FailsValidation() {