using Microsoft.Extensions.Options; using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Workers; using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport; namespace ZB.MOM.WW.MxGateway.Tests.Configuration; public sealed class GatewayOptionsValidatorTests { // A non-blank LDAP bind password for tests. The shipped design default is now blank // (string.Empty) so the secret-sourced value fails closed if unresolved; enabled-LDAP test // options must therefore supply an explicit password to pass the required-field check. private const string TestBindPassword = "test-bind-password"; // Constructs the minimal valid GatewayOptions by relying on each sub-option's // design-default values; those defaults are validated separately in GatewayOptionsTests. // The one exception is the LDAP bind password (blank by design default), which we supply. private static GatewayOptions ValidOptions() => new() { Ldap = ValidLdapOptions() }; // Enabled LDAP options with all class defaults plus the non-blank bind password. private static LdapOptions ValidLdapOptions() => new() { ServiceAccountPassword = TestBindPassword }; // Returns enabled LDAP options that pass all checks except Port. // The class defaults already satisfy the remaining blank-field checks; we override Enabled // (must be true to exercise the port check), Port, and the now-blank-by-default bind password. private static LdapOptions LdapOptionsWithPort(int port) => new() { Enabled = true, Port = port, ServiceAccountPassword = TestBindPassword, }; 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 the alarm poll cadence and per-fetch cap defaults pass validation. [Fact] public void Validate_Succeeds_WithDefaultAlarmPollCadenceAndFetchCap() { AlarmsOptions alarms = new(); Assert.Equal(500, alarms.PollIntervalMilliseconds); Assert.Equal(1024, alarms.MaxAlarmsPerFetch); ValidateOptionsResult result = new GatewayOptionsValidator() .Validate(null, CloneWithAlarms(ValidOptions(), alarms)); Assert.True(result.Succeeded); } /// /// A poll cadence outside the 100 ms – 1 h range must fail validation. /// Both values are stamped onto every worker launch environment, so /// they are validated whether or not the central alarm monitor is /// enabled. /// /// Cadence under test. /// Whether the central alarm monitor is on. [Theory] [InlineData(99, false)] [InlineData(0, false)] [InlineData(-1, false)] [InlineData(99, true)] // Above the one-hour ceiling the cadence stops being a cadence: int.MaxValue // milliseconds is ~24 days, which silently disables alarm polling. [InlineData(3_600_001, false)] [InlineData(int.MaxValue, false)] [InlineData(int.MaxValue, true)] public void Validate_Fails_WhenAlarmPollIntervalOutOfRange( int pollIntervalMilliseconds, bool alarmsEnabled) { GatewayOptions options = CloneWithAlarms( ValidOptions(), new AlarmsOptions { Enabled = alarmsEnabled, DefaultArea = "Galaxy", PollIntervalMilliseconds = pollIntervalMilliseconds, }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.False(result.Succeeded); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Alarms:PollIntervalMilliseconds", StringComparison.Ordinal)); } /// /// A per-fetch cap outside the 64 – 65,536 range must fail validation. /// The cap doubles as the truncation-detection threshold in the worker, /// so a tiny cap would make almost every fetch read as truncated; and /// the worker is a 32-bit process that materializes each reply as one /// BSTR plus a full XmlDocument, so an unbounded cap is an /// out-of-memory fault on the STA rather than a slow poll. /// /// Cap under test. /// Whether the central alarm monitor is on. [Theory] [InlineData(63, false)] [InlineData(0, false)] [InlineData(-1, false)] [InlineData(63, true)] [InlineData(65_537, false)] [InlineData(int.MaxValue, false)] [InlineData(int.MaxValue, true)] public void Validate_Fails_WhenMaxAlarmsPerFetchOutOfRange( int maxAlarmsPerFetch, bool alarmsEnabled) { GatewayOptions options = CloneWithAlarms( ValidOptions(), new AlarmsOptions { Enabled = alarmsEnabled, DefaultArea = "Galaxy", MaxAlarmsPerFetch = maxAlarmsPerFetch, }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.False(result.Succeeded); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Alarms:MaxAlarmsPerFetch", StringComparison.Ordinal)); } /// Verifies the boundary values themselves are accepted at both ends. /// Cadence under test. /// Cap under test. [Theory] [InlineData(100, 64)] // floors [InlineData(3_600_000, 65_536)] // ceilings public void Validate_Succeeds_AtAlarmPollCadenceAndFetchCapBoundaries( int pollIntervalMilliseconds, int maxAlarmsPerFetch) { GatewayOptions options = CloneWithAlarms( ValidOptions(), new AlarmsOptions { Enabled = true, DefaultArea = "Galaxy", PollIntervalMilliseconds = pollIntervalMilliseconds, MaxAlarmsPerFetch = maxAlarmsPerFetch, }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } /// 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); } 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 an absolute auth DB path inside the application directory fails. This is /// the gap the rooted check does not close: the path that lost every API key on a production /// host on 2026-08-09 was absolute and passed rooting cleanly — it simply lived in the directory /// the upgrade procedure renames away. /// [Fact] public void Validate_Fails_WhenSqlitePathIsUnderContentRoot() { string contentRoot = Path.Combine(Path.GetTempPath(), $"mxgw-root-{Guid.NewGuid():N}"); GatewayOptions options = CloneWithAuthentication( ValidOptions(), new AuthenticationOptions { SqlitePath = Path.Combine(contentRoot, "gateway-auth.db") }); ValidateOptionsResult result = new GatewayOptionsValidator(contentRootPath: contentRoot).Validate(null, options); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Authentication:SqlitePath") && f.Contains("must not be inside the application directory")); } /// /// Verifies the content-root rule is not a bare string prefix test: a sibling directory whose /// name merely begins with the content root's must still pass. /// [Fact] public void Validate_Succeeds_WhenSqlitePathIsSiblingOfContentRoot() { string contentRoot = Path.Combine(Path.GetTempPath(), $"mxgw-root-{Guid.NewGuid():N}"); GatewayOptions options = CloneWithAuthentication( ValidOptions(), new AuthenticationOptions { SqlitePath = contentRoot + "-data" + Path.DirectorySeparatorChar + "gateway-auth.db" }); ValidateOptionsResult result = new GatewayOptionsValidator(contentRootPath: contentRoot).Validate(null, options); Assert.True(result.Succeeded); } /// /// Verifies a store path outside the application directory passes — the rule must reject only /// the genuinely unsafe location, not every absolute path. /// [Fact] public void Validate_Succeeds_WhenSqlitePathIsOutsideContentRoot() { string contentRoot = Path.Combine(Path.GetTempPath(), $"mxgw-root-{Guid.NewGuid():N}"); GatewayOptions options = CloneWithAuthentication( ValidOptions(), new AuthenticationOptions { SqlitePath = Path.Combine(Path.GetTempPath(), $"mxgw-data-{Guid.NewGuid():N}", "gateway-auth.db"), }); ValidateOptionsResult result = new GatewayOptionsValidator(contentRootPath: contentRoot).Validate(null, options); Assert.True(result.Succeeded); } /// /// Verifies rooting is host-meaningful: a Windows drive-qualified literal fails on a /// Unix host (where it is not rooted) rather than being blessed and written as a junk-named /// relative file. On Windows the same literal is genuinely rooted and passes. /// [Fact] public void Validate_SqlitePath_RootingIsHostMeaningful() { const string windowsLiteral = @"C:\ProgramData\MxGateway\gateway-auth.db"; GatewayOptions options = CloneWithAuthentication( ValidOptions(), new AuthenticationOptions { SqlitePath = windowsLiteral }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); if (OperatingSystem.IsWindows()) { Assert.True(result.Succeeded); } else { Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Authentication:SqlitePath") && f.Contains("rooted")); } } /// /// Verifies MxGateway:Events:QueueCapacity above int.MaxValue/2 fails: the value /// flows into WorkerClient as checked(2 * EventChannelCapacity), which would otherwise overflow. /// [Fact] public void Validate_Fails_WhenQueueCapacityExceedsUpperBound() { GatewayOptions options = CloneWithEvents( ValidOptions(), new EventOptions { QueueCapacity = (int.MaxValue / 2) + 1 }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Events:QueueCapacity")); } /// Verifies MxGateway:Events:QueueCapacity at exactly int.MaxValue/2 passes (boundary). [Fact] public void Validate_Succeeds_WhenQueueCapacityAtUpperBound() { GatewayOptions options = CloneWithEvents( ValidOptions(), new EventOptions { QueueCapacity = int.MaxValue / 2 }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } /// 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")); } 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); } /// Verifies a populated GroupToTag map with well-formed tags passes validation. [Fact] public void Validate_Succeeds_WhenGroupToTagWellFormed() { GatewayOptions options = CloneWithDashboard( ValidOptions(), new DashboardOptions { GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["GwViewer"] = ["team-a"], ["TeamBViewers"] = ["team-b", "team-c"], }, }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } /// /// Verifies GroupToTag is not coupled to GroupToRole: a group that grants a tag /// but no role (and vice versa) is a legal configuration. /// [Fact] public void Validate_Succeeds_WhenGroupToTagAndGroupToRoleShareNoGroups() { GatewayOptions options = CloneWithDashboard( ValidOptions(), new DashboardOptions { GroupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["GwAdmin"] = "Administrator", }, GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["TeamBViewers"] = ["team-b"], }, }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } /// Verifies a blank GroupToTag key (LDAP group name) fails validation. [Fact] public void Validate_Fails_WhenGroupToTagKeyIsBlank() { GatewayOptions options = CloneWithDashboard( ValidOptions(), new DashboardOptions { GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) { [" "] = ["team-a"], }, }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Dashboard:GroupToTag") && f.Contains("non-blank")); } /// Verifies a blank tag entry fails validation. [Fact] public void Validate_Fails_WhenGroupToTagContainsBlankTag() { GatewayOptions options = CloneWithDashboard( ValidOptions(), new DashboardOptions { GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["GwViewer"] = ["team-a", " "], }, }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Dashboard:GroupToTag['GwViewer']") && f.Contains("non-blank")); } /// Verifies a null tag list (e.g. "GwViewer": null in JSON) fails validation. [Fact] public void Validate_Fails_WhenGroupToTagValueIsNull() { GatewayOptions options = CloneWithDashboard( ValidOptions(), new DashboardOptions { GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["GwViewer"] = null!, }, }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Dashboard:GroupToTag['GwViewer']") && f.Contains("null")); } /// Verifies both defined values pass validation. /// The visibility value under test. [Theory] [InlineData(UntaggedSessionVisibility.AdminOnly)] [InlineData(UntaggedSessionVisibility.AllViewers)] public void Validate_Succeeds_ForDefinedUntaggedSessionVisibility(UntaggedSessionVisibility visibility) { GatewayOptions options = CloneWithDashboard( ValidOptions(), new DashboardOptions { UntaggedSessionVisibility = visibility }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } /// Verifies an out-of-range fails validation. [Fact] public void Validate_Fails_WhenUntaggedSessionVisibilityUndefined() { GatewayOptions options = CloneWithDashboard( ValidOptions(), new DashboardOptions { UntaggedSessionVisibility = (UntaggedSessionVisibility)42 }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Dashboard:UntaggedSessionVisibility")); } /// Verifies the shipped default for untagged sessions is the strict AdminOnly. [Fact] public void DashboardOptions_UntaggedSessionVisibility_DefaultsToAdminOnly() { Assert.Equal(UntaggedSessionVisibility.AdminOnly, new DashboardOptions().UntaggedSessionVisibility); Assert.Empty(new DashboardOptions().GroupToTag); } /// 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, ServiceAccountPassword = TestBindPassword, }); ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, options); Assert.True(result.Succeeded); } /// /// Verifies enabled LDAP with a blank fails /// validation. Locks in the fail-closed design default (blank) introduced when the bind password /// moved to the encrypted secrets store (${secret:ldap/mxgateway/bind}): a blanked/unresolved /// password must abort startup rather than silently binding with an empty credential. /// [Fact] public void Validate_Fails_WhenLdapEnabledAndServiceAccountPasswordBlank() { GatewayOptions options = CloneWithLdap( ValidOptions(), new LdapOptions { Enabled = true, ServiceAccountPassword = string.Empty }); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Failed); string failure = Assert.Single( result.Failures!, f => f.Contains("MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.")); // The message must steer the operator to the two supported out-of-band channels // (dev user-secrets, deployed env var) so a blanked/unresolved credential never gets // "fixed" by re-committing a value. Assert.Contains("dotnet user-secrets set", failure); Assert.Contains("MxGateway__Ldap__ServiceAccountPassword", failure); } private static GatewayOptions WithSecurity(SecurityOptions security) => new() { Security = security, Ldap = ValidLdapOptions() }; /// Verifies the default security options pass validation. [Fact] public void Validate_Succeeds_WithDefaultSecurityOptions() { ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, WithSecurity(new SecurityOptions())); Assert.True(result.Succeeded); } /// Verifies a zero verification-cache TTL is allowed (disables caching). [Fact] public void Validate_Succeeds_WhenVerificationCacheSecondsZero() { ValidateOptionsResult result = new GatewayOptionsValidator().Validate( null, WithSecurity(new SecurityOptions { ApiKeyVerificationCacheSeconds = 0 })); Assert.True(result.Succeeded); } /// Verifies a negative verification-cache TTL fails validation. [Fact] public void Validate_Fails_WhenVerificationCacheSecondsNegative() { ValidateOptionsResult result = new GatewayOptionsValidator().Validate( null, WithSecurity(new SecurityOptions { ApiKeyVerificationCacheSeconds = -1 })); Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains("ApiKeyVerificationCacheSeconds")); } /// Verifies a negative last-used coalesce window fails validation. [Fact] public void Validate_Fails_WhenLastUsedCoalesceSecondsNegative() { ValidateOptionsResult result = new GatewayOptionsValidator().Validate( null, WithSecurity(new SecurityOptions { ApiKeyLastUsedCoalesceSeconds = -5 })); Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains("ApiKeyLastUsedCoalesceSeconds")); } /// Verifies a zero login rate-limit permit fails validation. [Fact] public void Validate_Fails_WhenLoginRateLimitPermitLimitZero() { ValidateOptionsResult result = new GatewayOptionsValidator().Validate( null, WithSecurity(new SecurityOptions { LoginRateLimitPermitLimit = 0 })); Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains("LoginRateLimitPermitLimit")); } /// Verifies a zero login rate-limit window fails validation. [Fact] public void Validate_Fails_WhenLoginRateLimitWindowSecondsZero() { ValidateOptionsResult result = new GatewayOptionsValidator().Validate( null, WithSecurity(new SecurityOptions { LoginRateLimitWindowSeconds = 0 })); Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains("LoginRateLimitWindowSeconds")); } /// Verifies a zero API-key failure limit fails validation. [Fact] public void Validate_Fails_WhenApiKeyFailureLimitZero() { ValidateOptionsResult result = new GatewayOptionsValidator().Validate( null, WithSecurity(new SecurityOptions { ApiKeyFailureLimit = 0 })); Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains("ApiKeyFailureLimit")); } /// Verifies a zero tracked-peer cap fails validation. [Fact] public void Validate_Fails_WhenApiKeyFailureTrackedPeersZero() { ValidateOptionsResult result = new GatewayOptionsValidator().Validate( null, WithSecurity(new SecurityOptions { ApiKeyFailureTrackedPeers = 0 })); Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains("ApiKeyFailureTrackedPeers")); } /// Verifies a negative per-key aggregate failure limit fails validation. [Fact] public void Validate_Fails_WhenApiKeyFailureAggregateLimitNegative() { ValidateOptionsResult result = new GatewayOptionsValidator().Validate( null, WithSecurity(new SecurityOptions { ApiKeyFailureAggregateLimit = -1 })); Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains("ApiKeyFailureAggregateLimit")); } /// Verifies a negative probe interval fails validation. [Fact] public void Validate_Fails_WhenApiKeyFailureProbeIntervalSecondsNegative() { ValidateOptionsResult result = new GatewayOptionsValidator().Validate( null, WithSecurity(new SecurityOptions { ApiKeyFailureProbeIntervalSeconds = -1 })); Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains("ApiKeyFailureProbeIntervalSeconds")); } /// /// Zero is a supported (documented) value for both new limiter knobs: it disables the aggregate /// layer and probe admission respectively, so validation must accept it. /// [Fact] public void Validate_Succeeds_WhenAggregateLimitAndProbeIntervalAreZero() { ValidateOptionsResult result = new GatewayOptionsValidator().Validate( null, WithSecurity(new SecurityOptions { ApiKeyFailureAggregateLimit = 0, ApiKeyFailureProbeIntervalSeconds = 0, })); Assert.True(result.Succeeded); } private static GatewayOptions WithWorkerAndProtocol(WorkerOptions worker, ProtocolOptions protocol) { GatewayOptions source = ValidOptions(); return new GatewayOptions { Authentication = source.Authentication, Ldap = source.Ldap, Worker = worker, Sessions = source.Sessions, Events = source.Events, Dashboard = source.Dashboard, Protocol = protocol, Alarms = source.Alarms, Tls = source.Tls, }; } /// /// Verifies the default worker-frame maximum keeps the required envelope-overhead reserve above /// the default public gRPC cap, so a stock configuration passes the headroom check. /// [Fact] public void Validate_Succeeds_WhenWorkerFrameMaxHasEnvelopeHeadroom() { ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, ValidOptions()); Assert.True(result.Succeeded); } /// /// Verifies that a worker-frame maximum equal to the gRPC cap (zero headroom) fails validation: /// a maximally-sized accepted gRPC payload would not fit one worker frame once wrapped in a /// WorkerEnvelope, faulting the whole session on the outbound write. /// [Fact] public void Validate_Fails_WhenWorkerFrameMaxEqualsGrpcMaxWithoutHeadroom() { const int grpcMax = 16 * 1024 * 1024; ValidateOptionsResult result = new GatewayOptionsValidator().Validate( null, WithWorkerAndProtocol( new WorkerOptions { MaxMessageBytes = grpcMax }, new ProtocolOptions { MaxGrpcMessageBytes = grpcMax })); Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains("MaxMessageBytes") && f.Contains("reserve")); } /// /// Verifies the headroom rule's upper half: a worker-frame maximum more than two /// envelope reserves above the gRPC cap is rejected. The worker budgets a DrainEvents reply at /// the frame maximum less two reserves and the gateway returns that same reply through /// Invoke, so a wider gap lets the worker pack a reply the pipe carries and the gRPC send /// cap refuses — with the drained events already removed from the worker queue. /// [Fact] public void Validate_Fails_WhenWorkerFrameMaxExceedsGrpcMaxByMoreThanTwoReserves() { const int grpcMax = 16 * 1024 * 1024; int tooLarge = grpcMax + (2 * WorkerFrameProtocolOptions.EnvelopeOverheadReserveBytes) + 1; ValidateOptionsResult result = new GatewayOptionsValidator().Validate( null, WithWorkerAndProtocol( new WorkerOptions { MaxMessageBytes = tooLarge }, new ProtocolOptions { MaxGrpcMessageBytes = grpcMax })); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MaxMessageBytes", StringComparison.Ordinal) && f.Contains("at most", StringComparison.Ordinal) && f.Contains("DrainEvents", StringComparison.Ordinal)); } /// /// Verifies both edges of the two-sided headroom rule are themselves accepted: exactly one /// reserve above the gRPC cap (the shipped default's shape) and exactly two reserves above it /// (the widest legal gap). Pinning the boundaries keeps a future retune of the reserve from /// silently making the shipped default illegal. /// /// Number of envelope reserves between the gRPC cap and the frame maximum. [Theory] [InlineData(1)] [InlineData(2)] public void Validate_Succeeds_AtBothEdgesOfTheFrameHeadroomBand(int reserveMultiple) { const int grpcMax = 16 * 1024 * 1024; int workerMax = grpcMax + (reserveMultiple * WorkerFrameProtocolOptions.EnvelopeOverheadReserveBytes); ValidateOptionsResult result = new GatewayOptionsValidator().Validate( null, WithWorkerAndProtocol( new WorkerOptions { MaxMessageBytes = workerMax }, new ProtocolOptions { MaxGrpcMessageBytes = grpcMax })); Assert.True(result.Succeeded); } /// /// Verifies the shipped worker event-queue capacity default passes validation and still matches /// the worker-side MxAccessEventQueue.DefaultCapacity the environment variable falls back /// to when the launcher value is unusable. /// [Fact] public void Validate_Succeeds_WithDefaultEventQueueCapacity() { Assert.Equal(10000, new WorkerOptions().EventQueueCapacity); ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, ValidOptions()); Assert.True(result.Succeeded); } /// /// Verifies a worker event-queue capacity outside the supported range fails validation. Too /// small leaves no burst headroom (an overflow faults the whole session); too large commits the /// 32-bit worker to an outsized pre-allocation. /// /// Capacity under test. [Theory] [InlineData(0)] [InlineData(-1)] [InlineData(999)] [InlineData(1_000_001)] public void Validate_Fails_WhenEventQueueCapacityOutOfRange(int eventQueueCapacity) { ValidateOptionsResult result = new GatewayOptionsValidator().Validate( null, WithWorkerAndProtocol( new WorkerOptions { EventQueueCapacity = eventQueueCapacity }, new ProtocolOptions())); Assert.True(result.Failed); Assert.Contains( result.Failures!, f => f.Contains("MxGateway:Worker:EventQueueCapacity", StringComparison.Ordinal)); } /// Verifies the worker event-queue capacity bounds themselves are accepted. /// Capacity under test. [Theory] [InlineData(1000)] [InlineData(1_000_000)] public void Validate_Succeeds_AtEventQueueCapacityBounds(int eventQueueCapacity) { ValidateOptionsResult result = new GatewayOptionsValidator().Validate( null, WithWorkerAndProtocol( new WorkerOptions { EventQueueCapacity = eventQueueCapacity }, new ProtocolOptions())); Assert.True(result.Succeeded); } }