Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs
T
Joseph Doherty 577a0db862 fix(IPC-33): bound the DrainEvents byte budget against the gRPC send cap, not just the pipe frame max
The worker budgeted a DrainEvents reply against the negotiated pipe frame
maximum less one 64 KiB reserve, but the gateway returns that same
MxCommandReply verbatim through Invoke, where MaxSendMessageSize applies. The
two limits were coupled only from below (pipe >= gRPC + reserve), so any
configuration the validator permitted with a wider gap let the worker pack a
reply the pipe carried and the gRPC layer then refused with ResourceExhausted --
after Drain had already removed the events from the worker queue, losing them to
both the caller and the event stream.

Option (a) from the finding: close the coupling at both ends.

- GatewayOptionsValidator adds the upper half of the headroom rule
  (Worker:MaxMessageBytes <= Protocol:MaxGrpcMessageBytes + 2 x reserve), with a
  message naming the DrainEvents budget as the reason and the two knobs to move.
- WorkerPipeSession.ResolveDrainReplyByteBudget subtracts the reserve twice --
  once for the pipe frame, once so the MxCommandReply wrapper clears the gRPC
  send cap -- keeping the MaxMessageBytes/2 floor that makes small frame maxima
  usable.

The two changes are load-bearing for each other: the validator band is what
makes "frame max - 2 x reserve" provably at or below the gRPC cap, and the
doubled subtraction is what makes the band sufficient.

Docs updated in the same change (GatewayConfiguration.md both option rows plus
the headroom prose, WorkerFrameProtocol.md drain budget, gateway.md DrainEvents
byte axis). Tests: new GatewayOptionsValidatorTests cases for the upper bound
(reject) and both edges of the band (accept); GatewayOptionsTests' override case
now moves both message-size knobs together, since the band makes moving one
alone illegal.

The worker half cannot be built on macOS; verified on windev separately.
2026-08-20 09:30:39 -04:00

216 lines
12 KiB
C#

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
public sealed class GatewayOptionsTests
{
/// <summary>Verifies that options binding uses design defaults when no configuration is provided.</summary>
[Fact]
public void OptionsBinding_UsesDesignDefaults()
{
// The LDAP bind password now defaults to blank (sourced from ${secret:ldap/mxgateway/bind}
// at runtime), so enabled-LDAP options fail validation until a value is supplied. Seed one
// so binding validates; the blank design default itself is asserted below.
GatewayOptions options = BindOptions(new Dictionary<string, string?>
{
["MxGateway:Ldap:ServiceAccountPassword"] = "test-bind-password",
});
// The bind password is no longer a leaked plaintext literal; its design default is blank.
Assert.Equal(string.Empty, new LdapOptions().ServiceAccountPassword);
Assert.Equal(AuthenticationMode.ApiKey, options.Authentication.Mode);
// The default is derived from CommonApplicationData (C:\ProgramData on Windows,
// /usr/share on Unix) rather than a Windows literal, so assert against the same derivation
// to stay platform-correct.
Assert.Equal(
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"MxGateway",
"gateway-auth.db"),
options.Authentication.SqlitePath);
Assert.Equal("MxGateway:ApiKeyPepper", options.Authentication.PepperSecretName);
Assert.True(options.Authentication.RunMigrationsOnStartup);
Assert.Equal(@"src\ZB.MOM.WW.MxGateway.Worker\bin\x86\Release\ZB.MOM.WW.MxGateway.Worker.exe", options.Worker.ExecutablePath);
Assert.Equal(WorkerArchitecture.X86, options.Worker.RequiredArchitecture);
Assert.Equal(30, options.Worker.StartupTimeoutSeconds);
Assert.Equal(3, options.Worker.StartupProbeRetryAttempts);
Assert.Equal(250, options.Worker.StartupProbeRetryDelayMilliseconds);
Assert.Equal(2000, options.Worker.PipeConnectAttemptTimeoutMilliseconds);
Assert.Equal(1500, options.Worker.WriteCompletionWaitMilliseconds);
Assert.Equal(10, options.Worker.ShutdownTimeoutSeconds);
Assert.Equal(5, options.Worker.HeartbeatIntervalSeconds);
Assert.Equal(15, options.Worker.HeartbeatGraceSeconds);
// 16 MiB public gRPC cap plus the 64 KiB worker-frame envelope reserve for headroom.
Assert.Equal((16 * 1024 * 1024) + (64 * 1024), options.Worker.MaxMessageBytes);
Assert.Equal(30, options.Sessions.DefaultCommandTimeoutSeconds);
Assert.Equal(64, options.Sessions.MaxSessions);
Assert.Equal(128, options.Sessions.MaxPendingCommandsPerSession);
Assert.Equal(1800, options.Sessions.DefaultLeaseSeconds);
Assert.Equal(30, options.Sessions.LeaseSweepIntervalSeconds);
Assert.Equal(30, options.Sessions.DetachGraceSeconds);
Assert.False(options.Sessions.AllowMultipleEventSubscribers);
Assert.Equal(8, options.Sessions.MaxEventSubscribersPerSession);
Assert.Equal(0, options.Sessions.WorkerReadyWaitTimeoutMs);
Assert.Equal(10_000, options.Events.QueueCapacity);
Assert.Equal(EventBackpressurePolicy.FailFast, options.Events.BackpressurePolicy);
Assert.True(options.Dashboard.Enabled);
Assert.True(options.Dashboard.AllowAnonymousLocalhost);
Assert.Equal(1_000, options.Dashboard.SnapshotIntervalMilliseconds);
Assert.Equal(100, options.Dashboard.RecentFaultLimit);
Assert.Equal(200, options.Dashboard.RecentSessionLimit);
Assert.False(options.Dashboard.ShowTagValues);
Assert.Equal(1u, options.Protocol.WorkerProtocolVersion);
Assert.Equal(16 * 1024 * 1024, options.Protocol.MaxGrpcMessageBytes);
}
/// <summary>Verifies that options binding applies configuration overrides.</summary>
[Fact]
public void OptionsBinding_AppliesConfigurationOverrides()
{
GatewayOptions options = BindOptions(
new Dictionary<string, string?>
{
["MxGateway:Authentication:Mode"] = "Disabled",
["MxGateway:Worker:ExecutablePath"] = @"C:\Gateway\ZB.MOM.WW.MxGateway.Worker.exe",
["MxGateway:Sessions:MaxSessions"] = "12",
["MxGateway:Sessions:DefaultLeaseSeconds"] = "900",
["MxGateway:Events:QueueCapacity"] = "256",
["MxGateway:Dashboard:Enabled"] = "false",
["MxGateway:Protocol:MaxGrpcMessageBytes"] = "8388608",
// The frame-headroom rule is two-sided (IPC-33): the worker-frame maximum must sit
// between one and two 64 KiB envelope reserves above the gRPC cap, so moving the
// gRPC cap without moving this one no longer validates. Kept at one reserve above,
// the same shape as the shipped default.
["MxGateway:Worker:MaxMessageBytes"] = "8454144",
// Blank-by-default bind password must be supplied so enabled LDAP validates on bind.
["MxGateway:Ldap:ServiceAccountPassword"] = "test-bind-password"
});
Assert.Equal(AuthenticationMode.Disabled, options.Authentication.Mode);
Assert.Equal(@"C:\Gateway\ZB.MOM.WW.MxGateway.Worker.exe", options.Worker.ExecutablePath);
Assert.Equal(12, options.Sessions.MaxSessions);
Assert.Equal(900, options.Sessions.DefaultLeaseSeconds);
Assert.Equal(256, options.Events.QueueCapacity);
Assert.False(options.Dashboard.Enabled);
Assert.Equal(8 * 1024 * 1024, options.Protocol.MaxGrpcMessageBytes);
}
/// <summary>Verifies that invalid configuration values fail with expected error messages.</summary>
/// <param name="key">Configuration key being validated.</param>
/// <param name="value">Configuration value being tested.</param>
/// <param name="expectedFailure">Expected validation error message.</param>
[Theory]
[InlineData("MxGateway:Worker:ExecutablePath", "worker.dll", "MxGateway:Worker:ExecutablePath must point to a .exe file.")]
[InlineData("MxGateway:Worker:StartupProbeRetryAttempts", "0", "MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.")]
[InlineData("MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds", "0", "MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.")]
[InlineData("MxGateway:Worker:WriteCompletionWaitMilliseconds", "-1", "MxGateway:Worker:WriteCompletionWaitMilliseconds must be greater than or equal to zero.")]
[InlineData("MxGateway:Sessions:DefaultLeaseSeconds", "0", "MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.")]
[InlineData("MxGateway:Sessions:LeaseSweepIntervalSeconds", "0", "MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.")]
[InlineData("MxGateway:Sessions:DetachGraceSeconds", "-1", "MxGateway:Sessions:DetachGraceSeconds must be zero or greater (0 disables detach-grace retention).")]
[InlineData("MxGateway:Sessions:WorkerReadyWaitTimeoutMs", "-1", "MxGateway:Sessions:WorkerReadyWaitTimeoutMs must be greater than or equal to zero.")]
[InlineData("MxGateway:Events:QueueCapacity", "0", "MxGateway:Events:QueueCapacity must be greater than zero.")]
[InlineData("MxGateway:Protocol:MaxGrpcMessageBytes", "0", "MxGateway:Protocol:MaxGrpcMessageBytes must be between")]
[InlineData("MxGateway:Authentication:PepperSecretName", "", "MxGateway:Authentication:PepperSecretName is required")]
[InlineData("MxGateway:Dashboard:GroupToRole:GwAdmin", "Sysadmin", "MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Administrator' or 'Viewer'.")]
public void Validation_InvalidConfiguration_FailsClearly(string key, string value, string expectedFailure)
{
OptionsValidationException exception = Assert.Throws<OptionsValidationException>(() =>
_ = BindOptions(new Dictionary<string, string?> { [key] = value }));
Assert.Contains(exception.Failures, failure => failure.Contains(expectedFailure, StringComparison.Ordinal));
}
/// <summary>Verifies that pepper secret names are redacted in the effective configuration.</summary>
[Fact]
public void EffectiveConfiguration_RedactsPepperSecretName()
{
using ServiceProvider services = BuildServices(
new Dictionary<string, string?>
{
["MxGateway:Authentication:PepperSecretName"] = "RawPepperSecretName",
// Blank-by-default bind password must be supplied so enabled LDAP validates on bind.
["MxGateway:Ldap:ServiceAccountPassword"] = "test-bind-password"
});
IGatewayConfigurationProvider provider = services.GetRequiredService<IGatewayConfigurationProvider>();
EffectiveGatewayConfiguration configuration = provider.GetEffectiveConfiguration();
Assert.Equal(GatewayConfigurationProvider.RedactedValue, configuration.Authentication.PepperSecretName);
Assert.DoesNotContain(
"RawPepperSecretName",
System.Text.Json.JsonSerializer.Serialize(configuration),
StringComparison.Ordinal);
}
/// <summary>Verifies that <see cref="DashboardOptions.DisableLogin"/> defaults to <see langword="false"/>.</summary>
[Fact]
public void DashboardOptions_DisableLogin_DefaultsToFalse()
{
Assert.False(new DashboardOptions().DisableLogin);
}
/// <summary>Verifies that <see cref="DashboardOptions.AutoLoginUser"/> defaults to <see langword="null"/>.</summary>
[Fact]
public void DashboardOptions_AutoLoginUser_DefaultsToNull()
{
Assert.Null(new DashboardOptions().AutoLoginUser);
}
/// <summary>
/// Verifies that <c>Dashboard:GroupToTag</c> keeps its ordinal-ignore-case group lookup after
/// configuration binding, and that <c>UntaggedSessionVisibility</c> binds from its string form.
/// </summary>
/// <remarks>
/// The property initializer seeds the dictionary with <see cref="StringComparer.OrdinalIgnoreCase"/>,
/// but only the binder decides whether that instance is populated in place or replaced by a
/// default-comparer one. Asserting the comparer on a hand-constructed <see cref="DashboardOptions"/>
/// would prove nothing about the configured path; a mis-cased LDAP group name from the directory
/// would then silently grant no tags, and the SEC-25 ACL would deny with no diagnostic.
/// </remarks>
[Fact]
public void DashboardOptions_GroupToTag_BindsCaseInsensitively()
{
GatewayOptions options = BindOptions(new Dictionary<string, string?>
{
["MxGateway:Dashboard:GroupToTag:GwReader:0"] = "team-a",
["MxGateway:Dashboard:GroupToTag:GwReader:1"] = "team-b",
["MxGateway:Dashboard:UntaggedSessionVisibility"] = "AllViewers",
["MxGateway:Ldap:ServiceAccountPassword"] = "test-bind-password",
});
Assert.True(options.Dashboard.GroupToTag.TryGetValue("gwREADER", out string[]? tags));
Assert.Equal(["team-a", "team-b"], tags);
Assert.Equal(UntaggedSessionVisibility.AllViewers, options.Dashboard.UntaggedSessionVisibility);
}
private static GatewayOptions BindOptions(IReadOnlyDictionary<string, string?> configurationValues)
{
using ServiceProvider services = BuildServices(configurationValues);
return services.GetRequiredService<IOptions<GatewayOptions>>().Value;
}
private static ServiceProvider BuildServices(IReadOnlyDictionary<string, string?> configurationValues)
{
IConfigurationRoot configuration = new ConfigurationBuilder()
.AddInMemoryCollection(configurationValues)
.Build();
ServiceCollection services = new();
services.AddSingleton<IConfiguration>(configuration);
services.AddGatewayConfiguration(configuration);
return services.BuildServiceProvider(validateScopes: true);
}
}