f40409a688
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
1366 lines
58 KiB
C#
1366 lines
58 KiB
C#
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);
|
||
}
|
||
}
|