882c7ca3cd
Rooted is not the same as safe, and the gap between the two cost a production
host every one of its API keys on 2026-08-09.
MxGateway:Authentication:SqlitePath was set to an absolute path inside the
directory the upgrade procedure renames to Server.bak.*. That passes the
existing rooted check cleanly. The deploy renamed the directory away, the store
went with it, and the gateway created a fresh empty one at the same path — no
error, no log line. No gRPC consumer could authenticate for two days. The deploy
itself was correct: the binaries were the point of the rename and the store was
collateral.
GatewayConfigPathRules gains AddIfUnderContentRoot, applied to the auth store
and the Galaxy snapshot. Both are written by the running process and both are
lost the same way. The rule compares resolved full paths and requires a
directory-separator boundary, so a sibling directory whose name merely starts
with the content root's ("/srv/app-data" against "/srv/app") is not treated as
inside it — on a fail-closed startup rule, that false positive would be a
gateway that refuses to boot on a legitimate path. Case sensitivity follows the
running OS rather than assuming case-insensitivity everywhere, which would
reject /srv/App as under /srv/app on Linux where they are different directories.
The rule is not exempted in Development. An environment-conditional guard is
never exercised where the mistake is made, and what failed in production was a
config that looked fine.
Secrets:SqlitePath is the same defect one layer down: it shipped as a bare
relative "mxgateway-secrets.db", which is how a stray database landed in
src/…Server/ and tripped the repository's tree-hygiene test. It is bound by the
shared ZB.MOM.WW.Secrets package, so appsettings.json now ships no value and the
default is computed from CommonApplicationData in code — the same mechanism
SEC-33 already used for the Galaxy snapshot, ten lines away, for the same reason.
Setting a default for an unset key is deliberately not the same act as
relocating a value someone configured, which these rules still refuse to do.
Note the migration edge this creates: a host relying on the old repo default now
looks somewhere new, finds nothing, and creates an empty store — this bug
re-introduced by its own fix. Deployed hosts are safe because they set the path
explicitly, in appsettings copied forward or in the service environment. The
latter is the more robust of the two, since it cannot be lost by a missed
preserve step.
1006 lines
44 KiB
C#
1006 lines
44 KiB
C#
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
|
using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
|
|
|
|
public sealed class GatewayOptionsValidatorTests
|
|
{
|
|
// A non-blank LDAP bind password for tests. The shipped design default is now blank
|
|
// (string.Empty) so the secret-sourced value fails closed if unresolved; enabled-LDAP test
|
|
// options must therefore supply an explicit password to pass the required-field check.
|
|
private const string TestBindPassword = "test-bind-password";
|
|
|
|
// Constructs the minimal valid GatewayOptions by relying on each sub-option's
|
|
// design-default values; those defaults are validated separately in GatewayOptionsTests.
|
|
// The one exception is the LDAP bind password (blank by design default), which we supply.
|
|
private static GatewayOptions ValidOptions() => new() { Ldap = ValidLdapOptions() };
|
|
|
|
// Enabled LDAP options with all class defaults plus the non-blank bind password.
|
|
private static LdapOptions ValidLdapOptions() => new() { ServiceAccountPassword = TestBindPassword };
|
|
|
|
// Returns enabled LDAP options that pass all checks except Port.
|
|
// The class defaults already satisfy the remaining blank-field checks; we override Enabled
|
|
// (must be true to exercise the port check), Port, and the now-blank-by-default bind password.
|
|
private static LdapOptions LdapOptionsWithPort(int port) => new()
|
|
{
|
|
Enabled = true,
|
|
Port = port,
|
|
ServiceAccountPassword = TestBindPassword,
|
|
};
|
|
|
|
private static GatewayOptions CloneWithLdap(GatewayOptions source, LdapOptions ldap)
|
|
=> new()
|
|
{
|
|
Authentication = source.Authentication,
|
|
Ldap = ldap,
|
|
Worker = source.Worker,
|
|
Sessions = source.Sessions,
|
|
Events = source.Events,
|
|
Dashboard = source.Dashboard,
|
|
Protocol = source.Protocol,
|
|
Alarms = source.Alarms,
|
|
Tls = source.Tls,
|
|
};
|
|
|
|
private static GatewayOptions CloneWithTls(GatewayOptions source, TlsOptions tls)
|
|
=> new()
|
|
{
|
|
Authentication = source.Authentication,
|
|
Ldap = source.Ldap,
|
|
Worker = source.Worker,
|
|
Sessions = source.Sessions,
|
|
Events = source.Events,
|
|
Dashboard = source.Dashboard,
|
|
Protocol = source.Protocol,
|
|
Alarms = source.Alarms,
|
|
Tls = tls,
|
|
};
|
|
|
|
/// <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 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 (SEC-33): 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 (GWC-24 rider): 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 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."));
|
|
|
|
// SEC-36: 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"));
|
|
}
|
|
}
|