Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs
T
Joseph Doherty f40409a688
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m16s
ci / java (push) Successful in 2m5s
ci / portable (push) Failing after 5m9s
docs: strip task-tracking IDs from code comments and fill missing XML docs
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
2026-10-07 15:34:32 -04:00

1366 lines
58 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
/// <summary>Verifies default TLS options pass validation.</summary>
[Fact]
public void Validate_Succeeds_WithDefaultTlsOptions()
{
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, ValidOptions());
Assert.True(result.Succeeded);
}
/// <summary>Verifies a zero <see cref="TlsOptions.ValidityYears"/> fails validation.</summary>
[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"));
}
/// <summary>Verifies a <see cref="TlsOptions.ValidityYears"/> above the allowed maximum fails validation.</summary>
[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"));
}
/// <summary>Verifies a blank entry in <see cref="TlsOptions.AdditionalDnsNames"/> fails validation.</summary>
[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"));
}
/// <summary>Verifies a blank <see cref="TlsOptions.SelfSignedCertPath"/> fails validation.</summary>
[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."));
}
/// <summary>Verifies an LDAP port of zero fails validation.</summary>
[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)"));
}
/// <summary>Verifies an LDAP port above the allowed maximum fails validation.</summary>
[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)"));
}
/// <summary>Verifies enabled LDAP with a valid port passes validation.</summary>
[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,
};
/// <summary>Verifies the alarm poll cadence and per-fetch cap defaults pass validation.</summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
/// <param name="pollIntervalMilliseconds">Cadence under test.</param>
/// <param name="alarmsEnabled">Whether the central alarm monitor is on.</param>
[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));
}
/// <summary>
/// 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.
/// </summary>
/// <param name="maxAlarmsPerFetch">Cap under test.</param>
/// <param name="alarmsEnabled">Whether the central alarm monitor is on.</param>
[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));
}
/// <summary>Verifies the boundary values themselves are accepted at both ends.</summary>
/// <param name="pollIntervalMilliseconds">Cadence under test.</param>
/// <param name="maxAlarmsPerFetch">Cap under test.</param>
[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);
}
/// <summary>Verifies an invalid fallback mode is not validated when alarms are disabled.</summary>
[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);
}
/// <summary>Verifies the default "Auto" fallback mode passes validation when alarms are enabled.</summary>
[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);
}
/// <summary>Verifies each recognised (case-insensitive) fallback mode passes validation.</summary>
/// <param name="mode">Fallback mode value under test.</param>
[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);
}
/// <summary>Verifies an unrecognised fallback mode fails validation when alarms are enabled.</summary>
[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"));
}
/// <summary>Verifies "ForceSubtag" fails validation without a Galaxy repository and without include attributes.</summary>
[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"));
}
/// <summary>Verifies "ForceSubtag" passes validation without a Galaxy repository when include attributes are supplied.</summary>
[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);
}
/// <summary>Verifies "ForceSubtag" passes validation when a Galaxy repository is used, even without include attributes.</summary>
[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);
}
/// <summary>Verifies a non-positive <see cref="AlarmFallbackOptions.ConsecutiveFailureThreshold"/> fails validation.</summary>
/// <param name="value">Threshold value under test.</param>
/// <param name="keyPart">Configuration key fragment expected in the failure message.</param>
[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));
}
/// <summary>Verifies a non-positive <see cref="AlarmFallbackOptions.FailbackProbeIntervalSeconds"/> fails validation.</summary>
/// <param name="value">Interval value under test.</param>
/// <param name="keyPart">Configuration key fragment expected in the failure message.</param>
[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));
}
/// <summary>Verifies a non-positive <see cref="AlarmFallbackOptions.FailbackStableProbes"/> fails validation.</summary>
/// <param name="value">Probe count value under test.</param>
/// <param name="keyPart">Configuration key fragment expected in the failure message.</param>
[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,
};
/// <summary>Verifies <see cref="SessionOptions.AllowMultipleEventSubscribers"/> set to <see langword="true"/> passes validation.</summary>
[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);
}
/// <summary>Verifies a non-positive <see cref="SessionOptions.MaxEventSubscribersPerSession"/> fails validation.</summary>
/// <param name="value">Subscriber cap value under test.</param>
[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"));
}
/// <summary>Verifies a positive <see cref="SessionOptions.MaxEventSubscribersPerSession"/> passes validation.</summary>
/// <param name="value">Subscriber cap value under test.</param>
[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);
}
/// <summary>Verifies default <see cref="SessionOptions"/> pass validation.</summary>
[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
// -------------------------------------------------------------------------
/// <summary>Verifies a negative <see cref="SessionOptions.WorkerReadyWaitTimeoutMs"/> fails validation.</summary>
[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"));
}
/// <summary>Verifies a zero <see cref="SessionOptions.WorkerReadyWaitTimeoutMs"/> passes validation.</summary>
[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);
}
/// <summary>Verifies a positive <see cref="SessionOptions.WorkerReadyWaitTimeoutMs"/> passes validation.</summary>
[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);
}
/// <summary>Verifies a negative <see cref="SessionOptions.DetachGraceSeconds"/> fails validation.</summary>
[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"));
}
/// <summary>Verifies a zero <see cref="SessionOptions.DetachGraceSeconds"/> passes validation.</summary>
[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,
};
/// <summary>Verifies a negative <see cref="EventOptions.ReplayBufferCapacity"/> fails validation.</summary>
[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"));
}
/// <summary>Verifies a zero <see cref="EventOptions.ReplayBufferCapacity"/> passes validation.</summary>
[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);
}
/// <summary>Verifies a negative <see cref="EventOptions.ReplayRetentionSeconds"/> fails validation.</summary>
[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"));
}
/// <summary>Verifies a zero <see cref="EventOptions.ReplayRetentionSeconds"/> passes validation.</summary>
[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);
}
/// <summary>Verifies a <see cref="EventOptions.MaxSparseArrayLength"/> below one fails validation.</summary>
/// <param name="value">Sparse-array cap under test.</param>
[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"));
}
/// <summary>Verifies a positive <see cref="EventOptions.MaxSparseArrayLength"/> within range passes validation.</summary>
[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,
};
/// <summary>Verifies the default (CommonApplicationData-derived) auth DB path is rooted and passes validation.</summary>
[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);
}
/// <summary>Verifies a non-rooted <see cref="AuthenticationOptions.SqlitePath"/> fails validation.</summary>
[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"));
}
/// <summary>
/// Verifies an absolute auth DB path <em>inside</em> 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.
/// </summary>
[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"));
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// Verifies a store path outside the application directory passes — the rule must reject only
/// the genuinely unsafe location, not every absolute path.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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"));
}
}
/// <summary>
/// Verifies MxGateway:Events:QueueCapacity above int.MaxValue/2 fails: the value
/// flows into WorkerClient as checked(2 * EventChannelCapacity), which would otherwise overflow.
/// </summary>
[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"));
}
/// <summary>Verifies MxGateway:Events:QueueCapacity at exactly int.MaxValue/2 passes (boundary).</summary>
[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);
}
/// <summary>Verifies a non-rooted <see cref="TlsOptions.SelfSignedCertPath"/> fails validation.</summary>
[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,
};
/// <summary>Verifies <see cref="DashboardOptions.DisableLogin"/> set to <see langword="true"/> aborts startup in Production.</summary>
[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"));
}
/// <summary>Verifies <see cref="DashboardOptions.DisableLogin"/> set to <see langword="true"/> is accepted outside Production.</summary>
[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);
}
/// <summary>Verifies a populated GroupToTag map with well-formed tags passes validation.</summary>
[Fact]
public void Validate_Succeeds_WhenGroupToTagWellFormed()
{
GatewayOptions options = CloneWithDashboard(
ValidOptions(),
new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["GwViewer"] = ["team-a"],
["TeamBViewers"] = ["team-b", "team-c"],
},
});
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
/// <summary>
/// Verifies GroupToTag is not coupled to GroupToRole: a group that grants a tag
/// but no role (and vice versa) is a legal configuration.
/// </summary>
[Fact]
public void Validate_Succeeds_WhenGroupToTagAndGroupToRoleShareNoGroups()
{
GatewayOptions options = CloneWithDashboard(
ValidOptions(),
new DashboardOptions
{
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = "Administrator",
},
GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["TeamBViewers"] = ["team-b"],
},
});
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
/// <summary>Verifies a blank GroupToTag key (LDAP group name) fails validation.</summary>
[Fact]
public void Validate_Fails_WhenGroupToTagKeyIsBlank()
{
GatewayOptions options = CloneWithDashboard(
ValidOptions(),
new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(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"));
}
/// <summary>Verifies a blank tag entry fails validation.</summary>
[Fact]
public void Validate_Fails_WhenGroupToTagContainsBlankTag()
{
GatewayOptions options = CloneWithDashboard(
ValidOptions(),
new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(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"));
}
/// <summary>Verifies a null tag list (e.g. <c>"GwViewer": null</c> in JSON) fails validation.</summary>
[Fact]
public void Validate_Fails_WhenGroupToTagValueIsNull()
{
GatewayOptions options = CloneWithDashboard(
ValidOptions(),
new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(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"));
}
/// <summary>Verifies both defined <see cref="UntaggedSessionVisibility"/> values pass validation.</summary>
/// <param name="visibility">The visibility value under test.</param>
[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);
}
/// <summary>Verifies an out-of-range <see cref="UntaggedSessionVisibility"/> fails validation.</summary>
[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"));
}
/// <summary>Verifies the shipped default for untagged sessions is the strict AdminOnly.</summary>
[Fact]
public void DashboardOptions_UntaggedSessionVisibility_DefaultsToAdminOnly()
{
Assert.Equal(UntaggedSessionVisibility.AdminOnly, new DashboardOptions().UntaggedSessionVisibility);
Assert.Empty(new DashboardOptions().GroupToTag);
}
/// <summary>Verifies plaintext LDAP transport (None) aborts startup in Production.</summary>
[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"));
}
/// <summary>Verifies plaintext LDAP transport (None) is accepted outside Production.</summary>
[Fact]
public void Validate_Succeeds_WhenLdapTransportNoneInDevelopment()
{
ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: false).Validate(null, ValidOptions());
Assert.True(result.Succeeded);
}
/// <summary>Verifies secure LDAP transport (Ldaps) passes validation in Production.</summary>
[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);
}
/// <summary>
/// Verifies enabled LDAP with a blank <see cref="LdapOptions.ServiceAccountPassword"/> fails
/// validation. Locks in the fail-closed design default (blank) introduced when the bind password
/// moved to the encrypted secrets store (<c>${secret:ldap/mxgateway/bind}</c>): a blanked/unresolved
/// password must abort startup rather than silently binding with an empty credential.
/// </summary>
[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() };
/// <summary>Verifies the default security options pass validation.</summary>
[Fact]
public void Validate_Succeeds_WithDefaultSecurityOptions()
{
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, WithSecurity(new SecurityOptions()));
Assert.True(result.Succeeded);
}
/// <summary>Verifies a zero verification-cache TTL is allowed (disables caching).</summary>
[Fact]
public void Validate_Succeeds_WhenVerificationCacheSecondsZero()
{
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(
null,
WithSecurity(new SecurityOptions { ApiKeyVerificationCacheSeconds = 0 }));
Assert.True(result.Succeeded);
}
/// <summary>Verifies a negative verification-cache TTL fails validation.</summary>
[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"));
}
/// <summary>Verifies a negative last-used coalesce window fails validation.</summary>
[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"));
}
/// <summary>Verifies a zero login rate-limit permit fails validation.</summary>
[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"));
}
/// <summary>Verifies a zero login rate-limit window fails validation.</summary>
[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"));
}
/// <summary>Verifies a zero API-key failure limit fails validation.</summary>
[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"));
}
/// <summary>Verifies a zero tracked-peer cap fails validation.</summary>
[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"));
}
/// <summary>Verifies a negative per-key aggregate failure limit fails validation.</summary>
[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"));
}
/// <summary>Verifies a negative probe interval fails validation.</summary>
[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"));
}
/// <summary>
/// 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.
/// </summary>
[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,
};
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void Validate_Succeeds_WhenWorkerFrameMaxHasEnvelopeHeadroom()
{
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, ValidOptions());
Assert.True(result.Succeeded);
}
/// <summary>
/// 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.
/// </summary>
[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"));
}
/// <summary>
/// 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
/// <c>Invoke</c>, 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.
/// </summary>
[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));
}
/// <summary>
/// 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.
/// </summary>
/// <param name="reserveMultiple">Number of envelope reserves between the gRPC cap and the frame maximum.</param>
[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);
}
/// <summary>
/// Verifies the shipped worker event-queue capacity default passes validation and still matches
/// the worker-side <c>MxAccessEventQueue.DefaultCapacity</c> the environment variable falls back
/// to when the launcher value is unusable.
/// </summary>
[Fact]
public void Validate_Succeeds_WithDefaultEventQueueCapacity()
{
Assert.Equal(10000, new WorkerOptions().EventQueueCapacity);
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, ValidOptions());
Assert.True(result.Succeeded);
}
/// <summary>
/// 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.
/// </summary>
/// <param name="eventQueueCapacity">Capacity under test.</param>
[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));
}
/// <summary>Verifies the worker event-queue capacity bounds themselves are accepted.</summary>
/// <param name="eventQueueCapacity">Capacity under test.</param>
[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);
}
}