Issue #3: add gateway configuration and validation #54
@@ -710,6 +710,18 @@ Suggested configuration shape:
|
||||
|
||||
Do not scatter connection or path constants through implementation code.
|
||||
|
||||
`MxGateway.Server` binds this section to `GatewayOptions` at startup and
|
||||
registers validation with `ValidateOnStart()`. Startup fails before the gateway
|
||||
begins serving traffic when required authentication settings are missing,
|
||||
timeouts or queue sizes are not positive, dashboard settings are malformed, or
|
||||
the configured worker protocol version does not match the contract version.
|
||||
|
||||
The gateway exposes read-only effective settings through
|
||||
`IGatewayConfigurationProvider`. This projection is for dashboard settings and
|
||||
diagnostics, so it redacts secret-related fields such as
|
||||
`Authentication:PepperSecretName` and does not include raw API keys or key
|
||||
material.
|
||||
|
||||
## Galaxy Repository Metadata
|
||||
|
||||
Galaxy hierarchy and tag metadata can be discovered through SQL Server when
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public enum AuthenticationMode
|
||||
{
|
||||
ApiKey,
|
||||
Disabled
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class AuthenticationOptions
|
||||
{
|
||||
public AuthenticationMode Mode { get; init; } = AuthenticationMode.ApiKey;
|
||||
|
||||
public string SqlitePath { get; init; } = @"C:\ProgramData\MxGateway\gateway-auth.db";
|
||||
|
||||
public string PepperSecretName { get; init; } = "MxGateway:ApiKeyPepper";
|
||||
|
||||
public bool RunMigrationsOnStartup { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class DashboardOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
public string PathBase { get; init; } = "/dashboard";
|
||||
|
||||
public bool RequireAdminScope { get; init; } = true;
|
||||
|
||||
public bool AllowAnonymousLocalhost { get; init; }
|
||||
|
||||
public int SnapshotIntervalMilliseconds { get; init; } = 1_000;
|
||||
|
||||
public int RecentFaultLimit { get; init; } = 100;
|
||||
|
||||
public int RecentSessionLimit { get; init; } = 200;
|
||||
|
||||
public bool ShowTagValues { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveAuthenticationConfiguration(
|
||||
string Mode,
|
||||
string SqlitePath,
|
||||
string PepperSecretName,
|
||||
bool RunMigrationsOnStartup);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveDashboardConfiguration(
|
||||
bool Enabled,
|
||||
string PathBase,
|
||||
bool RequireAdminScope,
|
||||
bool AllowAnonymousLocalhost,
|
||||
int SnapshotIntervalMilliseconds,
|
||||
int RecentFaultLimit,
|
||||
int RecentSessionLimit,
|
||||
bool ShowTagValues);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveEventConfiguration(
|
||||
int QueueCapacity,
|
||||
string BackpressurePolicy);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveGatewayConfiguration(
|
||||
EffectiveAuthenticationConfiguration Authentication,
|
||||
EffectiveWorkerConfiguration Worker,
|
||||
EffectiveSessionConfiguration Sessions,
|
||||
EffectiveEventConfiguration Events,
|
||||
EffectiveDashboardConfiguration Dashboard,
|
||||
EffectiveProtocolConfiguration Protocol);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveProtocolConfiguration(uint WorkerProtocolVersion);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveSessionConfiguration(
|
||||
int DefaultCommandTimeoutSeconds,
|
||||
int MaxSessions,
|
||||
bool AllowMultipleEventSubscribers);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveWorkerConfiguration(
|
||||
string ExecutablePath,
|
||||
string? WorkingDirectory,
|
||||
string RequiredArchitecture,
|
||||
int StartupTimeoutSeconds,
|
||||
int ShutdownTimeoutSeconds,
|
||||
int HeartbeatIntervalSeconds,
|
||||
int HeartbeatGraceSeconds,
|
||||
int MaxMessageBytes);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public enum EventBackpressurePolicy
|
||||
{
|
||||
FailFast
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class EventOptions
|
||||
{
|
||||
public int QueueCapacity { get; init; } = 10_000;
|
||||
|
||||
public EventBackpressurePolicy BackpressurePolicy { get; init; } = EventBackpressurePolicy.FailFast;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> options) : IGatewayConfigurationProvider
|
||||
{
|
||||
public const string RedactedValue = "[redacted]";
|
||||
|
||||
public EffectiveGatewayConfiguration GetEffectiveConfiguration()
|
||||
{
|
||||
GatewayOptions value = options.Value;
|
||||
|
||||
return new EffectiveGatewayConfiguration(
|
||||
Authentication: new EffectiveAuthenticationConfiguration(
|
||||
Mode: value.Authentication.Mode.ToString(),
|
||||
SqlitePath: value.Authentication.SqlitePath,
|
||||
PepperSecretName: RedactedValue,
|
||||
RunMigrationsOnStartup: value.Authentication.RunMigrationsOnStartup),
|
||||
Worker: new EffectiveWorkerConfiguration(
|
||||
ExecutablePath: value.Worker.ExecutablePath,
|
||||
WorkingDirectory: value.Worker.WorkingDirectory,
|
||||
RequiredArchitecture: value.Worker.RequiredArchitecture.ToString(),
|
||||
StartupTimeoutSeconds: value.Worker.StartupTimeoutSeconds,
|
||||
ShutdownTimeoutSeconds: value.Worker.ShutdownTimeoutSeconds,
|
||||
HeartbeatIntervalSeconds: value.Worker.HeartbeatIntervalSeconds,
|
||||
HeartbeatGraceSeconds: value.Worker.HeartbeatGraceSeconds,
|
||||
MaxMessageBytes: value.Worker.MaxMessageBytes),
|
||||
Sessions: new EffectiveSessionConfiguration(
|
||||
DefaultCommandTimeoutSeconds: value.Sessions.DefaultCommandTimeoutSeconds,
|
||||
MaxSessions: value.Sessions.MaxSessions,
|
||||
AllowMultipleEventSubscribers: value.Sessions.AllowMultipleEventSubscribers),
|
||||
Events: new EffectiveEventConfiguration(
|
||||
QueueCapacity: value.Events.QueueCapacity,
|
||||
BackpressurePolicy: value.Events.BackpressurePolicy.ToString()),
|
||||
Dashboard: new EffectiveDashboardConfiguration(
|
||||
Enabled: value.Dashboard.Enabled,
|
||||
PathBase: value.Dashboard.PathBase,
|
||||
RequireAdminScope: value.Dashboard.RequireAdminScope,
|
||||
AllowAnonymousLocalhost: value.Dashboard.AllowAnonymousLocalhost,
|
||||
SnapshotIntervalMilliseconds: value.Dashboard.SnapshotIntervalMilliseconds,
|
||||
RecentFaultLimit: value.Dashboard.RecentFaultLimit,
|
||||
RecentSessionLimit: value.Dashboard.RecentSessionLimit,
|
||||
ShowTagValues: value.Dashboard.ShowTagValues),
|
||||
Protocol: new EffectiveProtocolConfiguration(value.Protocol.WorkerProtocolVersion));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public static class GatewayConfigurationServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddGatewayConfiguration(this IServiceCollection services)
|
||||
{
|
||||
services
|
||||
.AddOptions<GatewayOptions>()
|
||||
.BindConfiguration(GatewayOptions.SectionName)
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSingleton<IValidateOptions<GatewayOptions>, GatewayOptionsValidator>();
|
||||
services.AddSingleton<IGatewayConfigurationProvider, GatewayConfigurationProvider>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class GatewayOptions
|
||||
{
|
||||
public const string SectionName = "MxGateway";
|
||||
|
||||
public AuthenticationOptions Authentication { get; init; } = new();
|
||||
|
||||
public WorkerOptions Worker { get; init; } = new();
|
||||
|
||||
public SessionOptions Sessions { get; init; } = new();
|
||||
|
||||
public EventOptions Events { get; init; } = new();
|
||||
|
||||
public DashboardOptions Dashboard { get; init; } = new();
|
||||
|
||||
public ProtocolOptions Protocol { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using MxGateway.Contracts;
|
||||
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
||||
{
|
||||
private const int MinimumMaxMessageBytes = 1024;
|
||||
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
|
||||
|
||||
public ValidateOptionsResult Validate(string? name, GatewayOptions options)
|
||||
{
|
||||
List<string> failures = [];
|
||||
|
||||
ValidateAuthentication(options.Authentication, failures);
|
||||
ValidateWorker(options.Worker, failures);
|
||||
ValidateSessions(options.Sessions, failures);
|
||||
ValidateEvents(options.Events, failures);
|
||||
ValidateDashboard(options.Dashboard, failures);
|
||||
ValidateProtocol(options.Protocol, failures);
|
||||
|
||||
return failures.Count == 0
|
||||
? ValidateOptionsResult.Success
|
||||
: ValidateOptionsResult.Fail(failures);
|
||||
}
|
||||
|
||||
private static void ValidateAuthentication(AuthenticationOptions options, List<string> failures)
|
||||
{
|
||||
if (!Enum.IsDefined(options.Mode))
|
||||
{
|
||||
failures.Add("MxGateway:Authentication:Mode must be a supported authentication mode.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.Mode == AuthenticationMode.ApiKey)
|
||||
{
|
||||
AddIfBlank(
|
||||
options.SqlitePath,
|
||||
"MxGateway:Authentication:SqlitePath is required when API-key authentication is enabled.",
|
||||
failures);
|
||||
AddIfInvalidPath(
|
||||
options.SqlitePath,
|
||||
"MxGateway:Authentication:SqlitePath must be a valid filesystem path.",
|
||||
failures);
|
||||
AddIfBlank(
|
||||
options.PepperSecretName,
|
||||
"MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.",
|
||||
failures);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateWorker(WorkerOptions options, List<string> failures)
|
||||
{
|
||||
AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", failures);
|
||||
AddIfInvalidPath(
|
||||
options.ExecutablePath,
|
||||
"MxGateway:Worker:ExecutablePath must be a valid filesystem path.",
|
||||
failures);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ExecutablePath)
|
||||
&& !string.Equals(Path.GetExtension(options.ExecutablePath), ".exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
failures.Add("MxGateway:Worker:ExecutablePath must point to a .exe file.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.WorkingDirectory))
|
||||
{
|
||||
AddIfInvalidPath(
|
||||
options.WorkingDirectory,
|
||||
"MxGateway:Worker:WorkingDirectory must be a valid filesystem path.",
|
||||
failures);
|
||||
}
|
||||
|
||||
if (!Enum.IsDefined(options.RequiredArchitecture))
|
||||
{
|
||||
failures.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture.");
|
||||
}
|
||||
|
||||
AddIfNotPositive(
|
||||
options.StartupTimeoutSeconds,
|
||||
"MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(
|
||||
options.ShutdownTimeoutSeconds,
|
||||
"MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(
|
||||
options.HeartbeatIntervalSeconds,
|
||||
"MxGateway:Worker:HeartbeatIntervalSeconds must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(
|
||||
options.HeartbeatGraceSeconds,
|
||||
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than zero.",
|
||||
failures);
|
||||
|
||||
if (options.HeartbeatGraceSeconds < options.HeartbeatIntervalSeconds)
|
||||
{
|
||||
failures.Add(
|
||||
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds.");
|
||||
}
|
||||
|
||||
if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
||||
{
|
||||
failures.Add(
|
||||
$"MxGateway:Worker:MaxMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSessions(SessionOptions options, List<string> failures)
|
||||
{
|
||||
AddIfNotPositive(
|
||||
options.DefaultCommandTimeoutSeconds,
|
||||
"MxGateway:Sessions:DefaultCommandTimeoutSeconds must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", failures);
|
||||
}
|
||||
|
||||
private static void ValidateEvents(EventOptions options, List<string> failures)
|
||||
{
|
||||
AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", failures);
|
||||
|
||||
if (!Enum.IsDefined(options.BackpressurePolicy))
|
||||
{
|
||||
failures.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateDashboard(DashboardOptions options, List<string> failures)
|
||||
{
|
||||
if (options.Enabled)
|
||||
{
|
||||
AddIfBlank(options.PathBase, "MxGateway:Dashboard:PathBase is required when the dashboard is enabled.", failures);
|
||||
if (!string.IsNullOrWhiteSpace(options.PathBase) && !options.PathBase.StartsWith('/'))
|
||||
{
|
||||
failures.Add("MxGateway:Dashboard:PathBase must start with '/'.");
|
||||
}
|
||||
}
|
||||
|
||||
AddIfNotPositive(
|
||||
options.SnapshotIntervalMilliseconds,
|
||||
"MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.",
|
||||
failures);
|
||||
AddIfNegative(
|
||||
options.RecentFaultLimit,
|
||||
"MxGateway:Dashboard:RecentFaultLimit must be greater than or equal to zero.",
|
||||
failures);
|
||||
AddIfNegative(
|
||||
options.RecentSessionLimit,
|
||||
"MxGateway:Dashboard:RecentSessionLimit must be greater than or equal to zero.",
|
||||
failures);
|
||||
}
|
||||
|
||||
private static void ValidateProtocol(ProtocolOptions options, List<string> failures)
|
||||
{
|
||||
if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion)
|
||||
{
|
||||
failures.Add(
|
||||
$"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIfBlank(string? value, string message, List<string> failures)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
failures.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIfNotPositive(int value, string message, List<string> failures)
|
||||
{
|
||||
if (value <= 0)
|
||||
{
|
||||
failures.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIfNegative(int value, string message, List<string> failures)
|
||||
{
|
||||
if (value < 0)
|
||||
{
|
||||
failures.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIfInvalidPath(string? value, string message, List<string> failures)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_ = Path.GetFullPath(value);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
failures.Add(message);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
failures.Add(message);
|
||||
}
|
||||
catch (PathTooLongException)
|
||||
{
|
||||
failures.Add(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public interface IGatewayConfigurationProvider
|
||||
{
|
||||
EffectiveGatewayConfiguration GetEffectiveConfiguration();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using MxGateway.Contracts;
|
||||
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class ProtocolOptions
|
||||
{
|
||||
public uint WorkerProtocolVersion { get; init; } = GatewayContractInfo.WorkerProtocolVersion;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class SessionOptions
|
||||
{
|
||||
public int DefaultCommandTimeoutSeconds { get; init; } = 30;
|
||||
|
||||
public int MaxSessions { get; init; } = 64;
|
||||
|
||||
public bool AllowMultipleEventSubscribers { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public enum WorkerArchitecture
|
||||
{
|
||||
X86,
|
||||
X64
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class WorkerOptions
|
||||
{
|
||||
public string ExecutablePath { get; init; } =
|
||||
@"src\MxGateway.Worker\bin\x86\Release\MxGateway.Worker.exe";
|
||||
|
||||
public string? WorkingDirectory { get; init; }
|
||||
|
||||
public WorkerArchitecture RequiredArchitecture { get; init; } = WorkerArchitecture.X86;
|
||||
|
||||
public int StartupTimeoutSeconds { get; init; } = 30;
|
||||
|
||||
public int ShutdownTimeoutSeconds { get; init; } = 10;
|
||||
|
||||
public int HeartbeatIntervalSeconds { get; init; } = 5;
|
||||
|
||||
public int HeartbeatGraceSeconds { get; init; } = 15;
|
||||
|
||||
public int MaxMessageBytes { get; init; } = 16 * 1024 * 1024;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using MxGateway.Contracts;
|
||||
using MxGateway.Server.Configuration;
|
||||
|
||||
namespace MxGateway.Server;
|
||||
|
||||
@@ -18,6 +19,7 @@ public static class GatewayApplication
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddGatewayConfiguration();
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
return builder;
|
||||
|
||||
@@ -5,5 +5,44 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"MxGateway": {
|
||||
"Authentication": {
|
||||
"Mode": "ApiKey",
|
||||
"SqlitePath": "C:\\ProgramData\\MxGateway\\gateway-auth.db",
|
||||
"PepperSecretName": "MxGateway:ApiKeyPepper",
|
||||
"RunMigrationsOnStartup": true
|
||||
},
|
||||
"Worker": {
|
||||
"ExecutablePath": "src\\MxGateway.Worker\\bin\\x86\\Release\\MxGateway.Worker.exe",
|
||||
"RequiredArchitecture": "X86",
|
||||
"StartupTimeoutSeconds": 30,
|
||||
"ShutdownTimeoutSeconds": 10,
|
||||
"HeartbeatIntervalSeconds": 5,
|
||||
"HeartbeatGraceSeconds": 15,
|
||||
"MaxMessageBytes": 16777216
|
||||
},
|
||||
"Sessions": {
|
||||
"DefaultCommandTimeoutSeconds": 30,
|
||||
"MaxSessions": 64,
|
||||
"AllowMultipleEventSubscribers": false
|
||||
},
|
||||
"Events": {
|
||||
"QueueCapacity": 10000,
|
||||
"BackpressurePolicy": "FailFast"
|
||||
},
|
||||
"Dashboard": {
|
||||
"Enabled": true,
|
||||
"PathBase": "/dashboard",
|
||||
"RequireAdminScope": true,
|
||||
"AllowAnonymousLocalhost": false,
|
||||
"SnapshotIntervalMilliseconds": 1000,
|
||||
"RecentFaultLimit": 100,
|
||||
"RecentSessionLimit": 200,
|
||||
"ShowTagValues": false
|
||||
},
|
||||
"Protocol": {
|
||||
"WorkerProtocolVersion": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MxGateway.Server.Configuration;
|
||||
|
||||
namespace MxGateway.Tests.Configuration;
|
||||
|
||||
public sealed class GatewayOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void OptionsBinding_UsesDesignDefaults()
|
||||
{
|
||||
GatewayOptions options = BindOptions(new Dictionary<string, string?>());
|
||||
|
||||
Assert.Equal(AuthenticationMode.ApiKey, options.Authentication.Mode);
|
||||
Assert.Equal(@"C:\ProgramData\MxGateway\gateway-auth.db", options.Authentication.SqlitePath);
|
||||
Assert.Equal("MxGateway:ApiKeyPepper", options.Authentication.PepperSecretName);
|
||||
Assert.True(options.Authentication.RunMigrationsOnStartup);
|
||||
|
||||
Assert.Equal(@"src\MxGateway.Worker\bin\x86\Release\MxGateway.Worker.exe", options.Worker.ExecutablePath);
|
||||
Assert.Equal(WorkerArchitecture.X86, options.Worker.RequiredArchitecture);
|
||||
Assert.Equal(30, options.Worker.StartupTimeoutSeconds);
|
||||
Assert.Equal(10, options.Worker.ShutdownTimeoutSeconds);
|
||||
Assert.Equal(5, options.Worker.HeartbeatIntervalSeconds);
|
||||
Assert.Equal(15, options.Worker.HeartbeatGraceSeconds);
|
||||
Assert.Equal(16 * 1024 * 1024, options.Worker.MaxMessageBytes);
|
||||
|
||||
Assert.Equal(30, options.Sessions.DefaultCommandTimeoutSeconds);
|
||||
Assert.Equal(64, options.Sessions.MaxSessions);
|
||||
Assert.False(options.Sessions.AllowMultipleEventSubscribers);
|
||||
|
||||
Assert.Equal(10_000, options.Events.QueueCapacity);
|
||||
Assert.Equal(EventBackpressurePolicy.FailFast, options.Events.BackpressurePolicy);
|
||||
|
||||
Assert.True(options.Dashboard.Enabled);
|
||||
Assert.Equal("/dashboard", options.Dashboard.PathBase);
|
||||
Assert.True(options.Dashboard.RequireAdminScope);
|
||||
Assert.False(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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionsBinding_AppliesConfigurationOverrides()
|
||||
{
|
||||
GatewayOptions options = BindOptions(
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["MxGateway:Authentication:Mode"] = "Disabled",
|
||||
["MxGateway:Worker:ExecutablePath"] = @"C:\Gateway\MxGateway.Worker.exe",
|
||||
["MxGateway:Sessions:MaxSessions"] = "12",
|
||||
["MxGateway:Events:QueueCapacity"] = "256",
|
||||
["MxGateway:Dashboard:Enabled"] = "false"
|
||||
});
|
||||
|
||||
Assert.Equal(AuthenticationMode.Disabled, options.Authentication.Mode);
|
||||
Assert.Equal(@"C:\Gateway\MxGateway.Worker.exe", options.Worker.ExecutablePath);
|
||||
Assert.Equal(12, options.Sessions.MaxSessions);
|
||||
Assert.Equal(256, options.Events.QueueCapacity);
|
||||
Assert.False(options.Dashboard.Enabled);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("MxGateway:Worker:ExecutablePath", "worker.dll", "MxGateway:Worker:ExecutablePath must point to a .exe file.")]
|
||||
[InlineData("MxGateway:Events:QueueCapacity", "0", "MxGateway:Events:QueueCapacity must be greater than zero.")]
|
||||
[InlineData("MxGateway:Authentication:PepperSecretName", "", "MxGateway:Authentication:PepperSecretName is required")]
|
||||
[InlineData("MxGateway:Dashboard:PathBase", "dashboard", "MxGateway:Dashboard:PathBase must start with '/'.")]
|
||||
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));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffectiveConfiguration_RedactsPepperSecretName()
|
||||
{
|
||||
using ServiceProvider services = BuildServices(
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["MxGateway:Authentication:PepperSecretName"] = "RawPepperSecretName"
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
return services.BuildServiceProvider(validateScopes: true);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MxGateway.Server;
|
||||
|
||||
namespace MxGateway.Tests.Gateway;
|
||||
@@ -19,4 +20,37 @@ public sealed class GatewayApplicationTests
|
||||
|
||||
Assert.Equal("LiveHealth", endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(
|
||||
"MxGateway:Worker:ExecutablePath",
|
||||
"worker.dll",
|
||||
"MxGateway:Worker:ExecutablePath must point to a .exe file.")]
|
||||
[InlineData(
|
||||
"MxGateway:Events:QueueCapacity",
|
||||
"0",
|
||||
"MxGateway:Events:QueueCapacity must be greater than zero.")]
|
||||
[InlineData(
|
||||
"MxGateway:Authentication:PepperSecretName",
|
||||
"",
|
||||
"MxGateway:Authentication:PepperSecretName is required")]
|
||||
[InlineData(
|
||||
"MxGateway:Dashboard:PathBase",
|
||||
"dashboard",
|
||||
"MxGateway:Dashboard:PathBase must start with '/'.")]
|
||||
public async Task StartAsync_InvalidGatewayConfiguration_FailsStartup(
|
||||
string key,
|
||||
string value,
|
||||
string expectedFailure)
|
||||
{
|
||||
await using WebApplication app = GatewayApplication.Build(
|
||||
[$"--{key}={value}", "--urls=http://127.0.0.1:0"]);
|
||||
|
||||
OptionsValidationException exception = await Assert.ThrowsAsync<OptionsValidationException>(
|
||||
() => app.StartAsync());
|
||||
|
||||
Assert.Contains(
|
||||
exception.Failures,
|
||||
failure => failure.Contains(expectedFailure, StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user