using Microsoft.Extensions.Options; using ZB.MOM.WW.MxGateway.Server.Configuration; using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport; namespace ZB.MOM.WW.MxGateway.Tests.Configuration; public sealed class GatewayOptionsValidatorTests { // Constructs the minimal valid GatewayOptions by relying on each sub-option's // design-default values; those defaults are validated separately in GatewayOptionsTests. private static GatewayOptions ValidOptions() => new(); // Returns enabled LDAP options that pass all checks except Port. // The class defaults already satisfy the blank-field checks; we only // override Enabled (must be true to exercise the port check) and Port. private static LdapOptions LdapOptionsWithPort(int port) => new() { Enabled = true, Port = port, }; private static GatewayOptions CloneWithLdap(GatewayOptions source, LdapOptions ldap) => new() { Authentication = source.Authentication, Ldap = ldap, Worker = source.Worker, Sessions = source.Sessions, Events = source.Events, Dashboard = source.Dashboard, Protocol = source.Protocol, Alarms = source.Alarms, Tls = source.Tls, }; private static GatewayOptions CloneWithTls(GatewayOptions source, TlsOptions tls) => new() { Authentication = source.Authentication, Ldap = source.Ldap, Worker = source.Worker, Sessions = source.Sessions, Events = source.Events, Dashboard = source.Dashboard, Protocol = source.Protocol, Alarms = source.Alarms, Tls = tls, }; /// Verifies default TLS options pass validation. [Fact] public void Validate_Succeeds_WithDefaultTlsOptions() { ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, ValidOptions()); Assert.True(result.Succeeded); } /// Verifies a zero fails validation. [Fact] public void Validate_Fails_WhenTlsValidityYearsOutOfRange() { GatewayOptions withBadTls = CloneWithTls(ValidOptions(), new TlsOptions { ValidityYears = 0 }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, withBadTls); Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:ValidityYears")); } /// Verifies a above the allowed maximum fails validation. [Fact] public void Validate_Fails_WhenTlsValidityYearsTooLarge() { GatewayOptions withBadTls = CloneWithTls(ValidOptions(), new TlsOptions { ValidityYears = 101 }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, withBadTls); Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:ValidityYears")); } /// Verifies a blank entry in fails validation. [Fact] public void Validate_Fails_WhenAdditionalDnsNameBlank() { GatewayOptions options = CloneWithTls(ValidOptions(), new TlsOptions { AdditionalDnsNames = [" "] }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:AdditionalDnsNames")); } /// Verifies a blank fails validation. [Fact] public void Validate_Fails_WhenSelfSignedCertPathBlank() { GatewayOptions options = CloneWithTls(ValidOptions(), new TlsOptions { SelfSignedCertPath = " " }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:SelfSignedCertPath must not be blank.")); } /// Verifies an LDAP port of zero fails validation. [Fact] public void Validate_Fails_WhenLdapPortIsZero() { GatewayOptions options = CloneWithLdap(ValidOptions(), LdapOptionsWithPort(0)); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Ldap:Port must be between 1 and 65535 (was 0)")); } /// Verifies an LDAP port above the allowed maximum fails validation. [Fact] public void Validate_Fails_WhenLdapPortExceedsMaximum() { GatewayOptions options = CloneWithLdap(ValidOptions(), LdapOptionsWithPort(70000)); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Ldap:Port must be between 1 and 65535 (was 70000)")); } /// Verifies enabled LDAP with a valid port passes validation. [Fact] public void Validate_Succeeds_WhenLdapEnabledWithValidPort() { GatewayOptions options = CloneWithLdap(ValidOptions(), LdapOptionsWithPort(389)); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } // ------------------------------------------------------------------------- // AlarmFallbackOptions validation // ------------------------------------------------------------------------- private static AlarmsOptions EnabledAlarmsWithFallback(AlarmFallbackOptions fallback) => new() { Enabled = true, DefaultArea = "Galaxy", Fallback = fallback, }; private static GatewayOptions CloneWithAlarms(GatewayOptions source, AlarmsOptions alarms) => new() { Authentication = source.Authentication, Ldap = source.Ldap, Worker = source.Worker, Sessions = source.Sessions, Events = source.Events, Dashboard = source.Dashboard, Protocol = source.Protocol, Alarms = alarms, Tls = source.Tls, }; /// Verifies an invalid fallback mode is not validated when alarms are disabled. [Fact] public void Validate_Succeeds_WhenAlarmsDisabled_FallbackNotValidated() { // Even an invalid Mode is acceptable when Enabled = false. GatewayOptions options = CloneWithAlarms( ValidOptions(), new AlarmsOptions { Enabled = false, Fallback = new AlarmFallbackOptions { Mode = "InvalidMode" }, }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } /// Verifies the default "Auto" fallback mode passes validation when alarms are enabled. [Fact] public void Validate_Succeeds_WhenAlarmsEnabled_DefaultAutoConfig() { // Default AlarmFallbackOptions (Mode="Auto") must pass validation when alarms are enabled. GatewayOptions options = CloneWithAlarms( ValidOptions(), EnabledAlarmsWithFallback(new AlarmFallbackOptions())); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } /// Verifies each recognised (case-insensitive) fallback mode passes validation. /// Fallback mode value under test. [Theory] [InlineData("Auto")] [InlineData("ForceAlarmManager")] [InlineData("ForceSubtag")] [InlineData("auto")] [InlineData("FORCESUBTAG")] public void Validate_Succeeds_WhenAlarmsEnabled_RecognisedMode(string mode) { AlarmsOptions alarms = EnabledAlarmsWithFallback(new AlarmFallbackOptions { Mode = mode }); GatewayOptions options = CloneWithAlarms(ValidOptions(), alarms); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } /// Verifies an unrecognised fallback mode fails validation when alarms are enabled. [Fact] public void Validate_Fails_WhenAlarmsEnabled_InvalidMode() { GatewayOptions options = CloneWithAlarms( ValidOptions(), EnabledAlarmsWithFallback(new AlarmFallbackOptions { Mode = "InvalidMode" })); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Alarms:Fallback") && f.Contains("Mode")); } /// Verifies "ForceSubtag" fails validation without a Galaxy repository and without include attributes. [Fact] public void Validate_Fails_WhenForceSubtag_NoGalaxyRepository_NoIncludes() { // ForceSubtag without galaxy repository and without IncludeAttributes must fail. GatewayOptions options = CloneWithAlarms( ValidOptions(), EnabledAlarmsWithFallback(new AlarmFallbackOptions { Mode = "ForceSubtag", Discovery = new AlarmDiscoveryOptions { UseGalaxyRepository = false, IncludeAttributes = [], }, })); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("ForceSubtag") && f.Contains("Discovery")); } /// Verifies "ForceSubtag" passes validation without a Galaxy repository when include attributes are supplied. [Fact] public void Validate_Succeeds_WhenForceSubtag_NoGalaxyRepository_WithIncludes() { // ForceSubtag without galaxy repository is allowed when IncludeAttributes is non-empty. GatewayOptions options = CloneWithAlarms( ValidOptions(), EnabledAlarmsWithFallback(new AlarmFallbackOptions { Mode = "ForceSubtag", Discovery = new AlarmDiscoveryOptions { UseGalaxyRepository = false, IncludeAttributes = ["attr1"], }, })); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } /// Verifies "ForceSubtag" passes validation when a Galaxy repository is used, even without include attributes. [Fact] public void Validate_Succeeds_WhenForceSubtag_WithGalaxyRepository() { // ForceSubtag + UseGalaxyRepository=true (default) must pass even without IncludeAttributes. GatewayOptions options = CloneWithAlarms( ValidOptions(), EnabledAlarmsWithFallback(new AlarmFallbackOptions { Mode = "ForceSubtag", Discovery = new AlarmDiscoveryOptions { UseGalaxyRepository = true }, })); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } /// Verifies a non-positive fails validation. /// Threshold value under test. /// Configuration key fragment expected in the failure message. [Theory] [InlineData(0, nameof(AlarmFallbackOptions.ConsecutiveFailureThreshold))] [InlineData(-1, nameof(AlarmFallbackOptions.ConsecutiveFailureThreshold))] public void Validate_Fails_WhenConsecutiveFailureThresholdBelowOne(int value, string keyPart) { GatewayOptions options = CloneWithAlarms( ValidOptions(), EnabledAlarmsWithFallback(new AlarmFallbackOptions { ConsecutiveFailureThreshold = value })); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains(keyPart)); } /// Verifies a non-positive fails validation. /// Interval value under test. /// Configuration key fragment expected in the failure message. [Theory] [InlineData(0, nameof(AlarmFallbackOptions.FailbackProbeIntervalSeconds))] [InlineData(-5, nameof(AlarmFallbackOptions.FailbackProbeIntervalSeconds))] public void Validate_Fails_WhenFailbackProbeIntervalSecondsBelowOne(int value, string keyPart) { GatewayOptions options = CloneWithAlarms( ValidOptions(), EnabledAlarmsWithFallback(new AlarmFallbackOptions { FailbackProbeIntervalSeconds = value })); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains(keyPart)); } /// Verifies a non-positive fails validation. /// Probe count value under test. /// Configuration key fragment expected in the failure message. [Theory] [InlineData(0, nameof(AlarmFallbackOptions.FailbackStableProbes))] [InlineData(-1, nameof(AlarmFallbackOptions.FailbackStableProbes))] public void Validate_Fails_WhenFailbackStableProbesBelowOne(int value, string keyPart) { GatewayOptions options = CloneWithAlarms( ValidOptions(), EnabledAlarmsWithFallback(new AlarmFallbackOptions { FailbackStableProbes = value })); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains(keyPart)); } // ------------------------------------------------------------------------- // AllowMultipleEventSubscribers / MaxEventSubscribersPerSession validation // ------------------------------------------------------------------------- private static GatewayOptions CloneWithSessions(GatewayOptions source, SessionOptions sessions) => new() { Authentication = source.Authentication, Ldap = source.Ldap, Worker = source.Worker, Sessions = sessions, Events = source.Events, Dashboard = source.Dashboard, Protocol = source.Protocol, Alarms = source.Alarms, Tls = source.Tls, }; /// Verifies set to passes validation. [Fact] public void Validate_Succeeds_WhenAllowMultipleEventSubscribersIsTrue() { // AllowMultipleEventSubscribers=true must now validate cleanly (no longer rejected). GatewayOptions options = CloneWithSessions( ValidOptions(), new SessionOptions { AllowMultipleEventSubscribers = true }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } /// Verifies a non-positive fails validation. /// Subscriber cap value under test. [Theory] [InlineData(0)] [InlineData(-1)] public void Validate_Fails_WhenMaxEventSubscribersPerSessionBelowOne(int value) { GatewayOptions options = CloneWithSessions( ValidOptions(), new SessionOptions { MaxEventSubscribersPerSession = value }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Sessions:MaxEventSubscribersPerSession")); } /// Verifies a positive passes validation. /// Subscriber cap value under test. [Theory] [InlineData(1)] [InlineData(8)] [InlineData(32)] public void Validate_Succeeds_WhenMaxEventSubscribersPerSessionIsPositive(int value) { GatewayOptions options = CloneWithSessions( ValidOptions(), new SessionOptions { MaxEventSubscribersPerSession = value }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } /// Verifies default pass validation. [Fact] public void Validate_Succeeds_WithDefaultSessionOptions() { // Default SessionOptions (AllowMultipleEventSubscribers=false, MaxEventSubscribersPerSession=8) // must validate cleanly. GatewayOptions options = CloneWithSessions(ValidOptions(), new SessionOptions()); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } // ------------------------------------------------------------------------- // WorkerReadyWaitTimeoutMs validation // ------------------------------------------------------------------------- /// Verifies a negative fails validation. [Fact] public void Validate_Fails_WhenWorkerReadyWaitTimeoutMsIsNegative() { GatewayOptions options = CloneWithSessions( ValidOptions(), new SessionOptions { WorkerReadyWaitTimeoutMs = -1 }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Sessions:WorkerReadyWaitTimeoutMs")); } /// Verifies a zero passes validation. [Fact] public void Validate_Succeeds_WhenWorkerReadyWaitTimeoutMsIsZero() { GatewayOptions options = CloneWithSessions( ValidOptions(), new SessionOptions { WorkerReadyWaitTimeoutMs = 0 }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } /// Verifies a positive passes validation. [Fact] public void Validate_Succeeds_WhenWorkerReadyWaitTimeoutMsIsPositive() { GatewayOptions options = CloneWithSessions( ValidOptions(), new SessionOptions { WorkerReadyWaitTimeoutMs = 5000 }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } /// Verifies a negative fails validation. [Fact] public void Validate_Fails_WhenDetachGraceSecondsIsNegative() { GatewayOptions options = CloneWithSessions( ValidOptions(), new SessionOptions { DetachGraceSeconds = -1 }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Sessions:DetachGraceSeconds")); } /// Verifies a zero passes validation. [Fact] public void Validate_Succeeds_WhenDetachGraceSecondsIsZero() { GatewayOptions options = CloneWithSessions( ValidOptions(), new SessionOptions { DetachGraceSeconds = 0 }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } // ------------------------------------------------------------------------- // ReplayBufferCapacity / ReplayRetentionSeconds validation // ------------------------------------------------------------------------- private static GatewayOptions CloneWithEvents(GatewayOptions source, EventOptions events) => new() { Authentication = source.Authentication, Ldap = source.Ldap, Worker = source.Worker, Sessions = source.Sessions, Events = events, Dashboard = source.Dashboard, Protocol = source.Protocol, Alarms = source.Alarms, Tls = source.Tls, }; /// Verifies a negative fails validation. [Fact] public void Validate_Fails_WhenReplayBufferCapacityIsNegative() { GatewayOptions options = CloneWithEvents( ValidOptions(), new EventOptions { ReplayBufferCapacity = -1 }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Events:ReplayBufferCapacity")); } /// Verifies a zero passes validation. [Fact] public void Validate_Succeeds_WhenReplayBufferCapacityIsZero() { GatewayOptions options = CloneWithEvents( ValidOptions(), new EventOptions { ReplayBufferCapacity = 0 }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } /// Verifies a negative fails validation. [Fact] public void Validate_Fails_WhenReplayRetentionSecondsIsNegative() { GatewayOptions options = CloneWithEvents( ValidOptions(), new EventOptions { ReplayRetentionSeconds = -1 }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Events:ReplayRetentionSeconds")); } /// Verifies a zero passes validation. [Fact] public void Validate_Succeeds_WhenReplayRetentionSecondsIsZero() { GatewayOptions options = CloneWithEvents( ValidOptions(), new EventOptions { ReplayRetentionSeconds = 0 }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } /// Verifies a below one fails validation. /// Sparse-array cap under test. [Theory] [InlineData(0)] [InlineData(-1)] public void Validate_Fails_WhenMaxSparseArrayLengthBelowOne(int value) { GatewayOptions options = CloneWithEvents( ValidOptions(), new EventOptions { MaxSparseArrayLength = value }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Events:MaxSparseArrayLength")); } /// Verifies a positive within range passes validation. [Fact] public void Validate_Succeeds_WhenMaxSparseArrayLengthWithinRange() { GatewayOptions options = CloneWithEvents( ValidOptions(), new EventOptions { MaxSparseArrayLength = 1 }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } // ------------------------------------------------------------------------- // SEC-01: security-sensitive paths must be rooted (absolute) // ------------------------------------------------------------------------- private static GatewayOptions CloneWithAuthentication(GatewayOptions source, AuthenticationOptions authentication) => new() { Authentication = authentication, Ldap = source.Ldap, Worker = source.Worker, Sessions = source.Sessions, Events = source.Events, Dashboard = source.Dashboard, Protocol = source.Protocol, Alarms = source.Alarms, Tls = source.Tls, }; /// Verifies the default (CommonApplicationData-derived) auth DB path is rooted and passes validation. [Fact] public void Validate_Succeeds_WithDefaultRootedSqlitePath() { // The default AuthenticationOptions.SqlitePath is derived from CommonApplicationData, // which is rooted on every host OS. ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, ValidOptions()); Assert.True(result.Succeeded); } /// Verifies a non-rooted fails validation. [Fact] public void Validate_Fails_WhenSqlitePathNotRooted() { GatewayOptions options = CloneWithAuthentication( ValidOptions(), new AuthenticationOptions { SqlitePath = "gateway-auth.db" }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Authentication:SqlitePath") && f.Contains("rooted")); } /// Verifies a non-rooted fails validation. [Fact] public void Validate_Fails_WhenSelfSignedCertPathNotRooted() { GatewayOptions options = CloneWithTls( ValidOptions(), new TlsOptions { SelfSignedCertPath = "gateway-selfsigned.pfx" }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Tls:SelfSignedCertPath") && f.Contains("rooted")); } // ------------------------------------------------------------------------- // SEC-04: DisableLogin production guard // ------------------------------------------------------------------------- private static GatewayOptions CloneWithDashboard(GatewayOptions source, DashboardOptions dashboard) => new() { Authentication = source.Authentication, Ldap = source.Ldap, Worker = source.Worker, Sessions = source.Sessions, Events = source.Events, Dashboard = dashboard, Protocol = source.Protocol, Alarms = source.Alarms, Tls = source.Tls, }; /// Verifies set to aborts startup in Production. [Fact] public void Validate_Fails_WhenDisableLoginTrueInProduction() { GatewayOptions options = CloneWithDashboard( ValidOptions(), new DashboardOptions { DisableLogin = true }); ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, options); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Dashboard:DisableLogin") && f.Contains("Production")); } /// Verifies set to is accepted outside Production. [Fact] public void Validate_Succeeds_WhenDisableLoginTrueInDevelopment() { GatewayOptions options = CloneWithDashboard( ValidOptions(), new DashboardOptions { DisableLogin = true }); ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: false).Validate(null, options); Assert.True(result.Succeeded); } // ------------------------------------------------------------------------- // SEC-06: LDAP plaintext transport production guard // ------------------------------------------------------------------------- /// Verifies plaintext LDAP transport (None) aborts startup in Production. [Fact] public void Validate_Fails_WhenLdapTransportNoneInProduction() { // The class default LDAP options ship Transport=None + AllowInsecure=true (dev posture). ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, ValidOptions()); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Ldap:Transport") && f.Contains("Production")); } /// Verifies plaintext LDAP transport (None) is accepted outside Production. [Fact] public void Validate_Succeeds_WhenLdapTransportNoneInDevelopment() { ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: false).Validate(null, ValidOptions()); Assert.True(result.Succeeded); } /// Verifies secure LDAP transport (Ldaps) passes validation in Production. [Fact] public void Validate_Succeeds_WhenLdapTransportLdapsInProduction() { GatewayOptions options = CloneWithLdap( ValidOptions(), new LdapOptions { Enabled = true, Transport = LdapTransport.Ldaps, AllowInsecure = false }); ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, options); Assert.True(result.Succeeded); } }