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
{
// 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 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 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 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);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled."));
}
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"));
}
}