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() {