Compare commits

..

18 Commits

Author SHA1 Message Date
Joseph Doherty 3ca2799c90 fix: tighten MxGateway Ldap:Port to 1-65535; catch IOException in path validation
Defect 1: ValidateLdap used AddIfNotPositive for Port, accepting any value
> 0 including 70000. Replaced with builder.Port() from the shared
ZB.MOM.WW.Configuration library, which enforces the 1-65535 TCP range and
emits "MxGateway:Ldap:Port must be between 1 and 65535 (was {value})".

Defect 2: AddIfInvalidPath only caught ArgumentException, NotSupportedException,
and PathTooLongException from Path.GetFullPath. On macOS/Linux a path containing
an embedded null throws IOException, which escaped the catch block and caused
Validate() to throw instead of returning a failure. Added catch (IOException).

Tests: added Validate_Fails_WhenLdapPortIsZero, Validate_Fails_WhenLdapPortExceedsMaximum,
and Validate_Succeeds_WhenLdapEnabledWithValidPort to cover the new range boundary.
2026-06-01 22:45:16 -04:00
Joseph Doherty 459a88b3e7 refactor: adopt ZB.MOM.WW.Configuration in MxGateway (behaviour-preserving) 2026-06-01 18:22:21 -04:00
Joseph Doherty 437ab65fc1 build: add ZB.MOM.WW.Configuration feed mapping + version pin 2026-06-01 18:10:27 -04:00
Joseph Doherty 679562e5ed Merge feat/telemetry-followons: telemetry follow-ons for MxAccessGateway
Metric normalization: meter MxGateway.Server -> ZB.MOM.WW.MxGateway and the 3
duration histograms ms -> s (safe: never Prometheus-exported before). Config-driven
OTLP exporter opt-in (default Prometheus). Metrics.md synced; doc-review artifacts
gitignored.
2026-06-01 17:17:31 -04:00
Joseph Doherty dbf550da8b docs(mxgateway): sync Metrics.md to renamed meter + seconds histogram units 2026-06-01 16:48:46 -04:00
Joseph Doherty 3965a7741e feat(mxgateway): config-driven OTLP exporter opt-in (default Prometheus) 2026-06-01 16:44:40 -04:00
Joseph Doherty abb2cfb84b feat(mxgateway): normalize metrics — meter ZB.MOM.WW.MxGateway + histograms in seconds 2026-06-01 16:39:56 -04:00
Joseph Doherty 4e0d8ccfed chore(mxgateway): gitignore CommentChecker doc-review artifacts 2026-06-01 16:34:46 -04:00
Joseph Doherty a935aa8b7c Merge feat/adopt-zb-telemetry: adopt ZB.MOM.WW.Telemetry across MxAccessGateway
Full MEL->Serilog migration via AddZbSerilog; GatewayLogRedactor exposed through
the shared ILogRedactor seam; GatewayMetrics now exports via AddZbTelemetry + new
/metrics (meter name MxGateway.Server + ms histogram units unchanged; rename/unit
conversion deferred). Behaviour-preserving.
2026-06-01 16:05:41 -04:00
Joseph Doherty 9912389fa1 feat(mxgateway): export GatewayMetrics via AddZbTelemetry + /metrics (name/units unchanged) 2026-06-01 15:53:46 -04:00
Joseph Doherty f1129b969d feat(mxgateway): expose GatewayLogRedactor via shared ILogRedactor seam 2026-06-01 15:49:32 -04:00
Joseph Doherty c51b6f9ce4 feat(mxgateway): adopt AddZbSerilog — MEL→Serilog provider swap (behaviour-preserving) 2026-06-01 15:43:10 -04:00
Joseph Doherty e39972357b config(mxgateway): translate MEL Logging section to Serilog 2026-06-01 15:32:38 -04:00
Joseph Doherty 9ad17e2964 build(mxgateway): reference ZB.MOM.WW.Telemetry + Serilog packages 2026-06-01 15:29:43 -04:00
Joseph Doherty ef0a883a81 Merge feat/adopt-zb-health: ZB.MOM.WW.Health adoption + TLS auto-cert/lenient-client-trust feature 2026-06-01 14:09:24 -04:00
Joseph Doherty 62ba5e9487 feat: map canonical ZB health tiers; replace bypassing /health/live 2026-06-01 13:44:13 -04:00
Joseph Doherty 136614be94 feat: add AuthStoreHealthCheck readiness probe 2026-06-01 13:33:54 -04:00
Joseph Doherty a912bffad5 build: reference ZB.MOM.WW.Health from the Gitea feed 2026-06-01 13:29:39 -04:00
19 changed files with 414 additions and 145 deletions
+5
View File
@@ -147,3 +147,8 @@ generated-scratch/
# Keep empty directories with .gitkeep files when needed # Keep empty directories with .gitkeep files when needed
!.gitkeep !.gitkeep
# Documentation review artifacts (CommentChecker output)
*-docs-issues.md
*-docs-fixed.md
*-docs-final.md
+6 -6
View File
@@ -4,7 +4,7 @@ The metrics subsystem exposes counters, histograms, and observable gauges that d
## Overview ## Overview
`GatewayMetrics` is a singleton (registered in `GatewayApplication.cs`) that owns a single `Meter` named `ZB.MOM.WW.MxGateway.Server` and a set of synchronised counters, histograms, and observable gauges. Subsystems call typed mutator methods (`SessionOpened`, `CommandFailed`, `EventReceived`, etc.) rather than touching the `Meter` directly, which keeps the OpenTelemetry instrument names and tag conventions in one place. A `lock (_syncRoot)` block guards the scalar fields used by `GetSnapshot`, while per-event maps use `ConcurrentDictionary<string, long>` so the hot event path avoids the lock. `GatewayMetrics` is a singleton (registered in `GatewayApplication.cs`) that owns a single `Meter` named `ZB.MOM.WW.MxGateway` and a set of synchronised counters, histograms, and observable gauges. Subsystems call typed mutator methods (`SessionOpened`, `CommandFailed`, `EventReceived`, etc.) rather than touching the `Meter` directly, which keeps the OpenTelemetry instrument names and tag conventions in one place. A `lock (_syncRoot)` block guards the scalar fields used by `GetSnapshot`, while per-event maps use `ConcurrentDictionary<string, long>` so the hot event path avoids the lock.
## Meter and OpenTelemetry Compatibility ## Meter and OpenTelemetry Compatibility
@@ -13,7 +13,7 @@ The meter name is exposed as a constant so that hosting code can register it wit
```csharp ```csharp
public sealed class GatewayMetrics : IDisposable public sealed class GatewayMetrics : IDisposable
{ {
public const string MeterName = "ZB.MOM.WW.MxGateway.Server"; public const string MeterName = "ZB.MOM.WW.MxGateway";
public GatewayMetrics() public GatewayMetrics()
{ {
@@ -50,12 +50,12 @@ All counters are `Counter<long>`. Tag values come from the call sites listed und
### Histograms ### Histograms
Histograms record durations in milliseconds (the `unit` argument on `CreateHistogram`): Histograms record durations in seconds (the `unit` argument on `CreateHistogram`):
```csharp ```csharp
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "ms"); _workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "s");
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "ms"); _commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "s");
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "ms"); _eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "s");
``` ```
| Instrument | Tags | What it measures | | Instrument | Tags | What it measures |
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="dohertj2-gitea" value="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json" />
</packageSources>
<!-- nuget.org serves everything; the Gitea feed serves only the ZB.MOM.WW.* shared libs.
Credentials are NOT committed: they are provided per-developer at the user level. -->
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
<packageSource key="dohertj2-gitea">
<package pattern="ZB.MOM.WW.Health" />
<package pattern="ZB.MOM.WW.Health.*" />
<package pattern="ZB.MOM.WW.Telemetry" />
<package pattern="ZB.MOM.WW.Telemetry.*" />
<package pattern="ZB.MOM.WW.Configuration" />
</packageSource>
</packageSourceMapping>
</configuration>
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Configuration; namespace ZB.MOM.WW.MxGateway.Server.Configuration;
@@ -6,15 +7,14 @@ public static class GatewayConfigurationServiceCollectionExtensions
{ {
/// <summary>Registers gateway configuration services in the dependency injection container.</summary> /// <summary>Registers gateway configuration services in the dependency injection container.</summary>
/// <param name="services">The service collection.</param> /// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration to bind gateway options from.</param>
/// <returns>The service collection for chaining.</returns> /// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddGatewayConfiguration(this IServiceCollection services) public static IServiceCollection AddGatewayConfiguration(
this IServiceCollection services, IConfiguration configuration)
{ {
services services.AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>(
.AddOptions<GatewayOptions>() configuration, GatewayOptions.SectionName);
.BindConfiguration(GatewayOptions.SectionName)
.ValidateOnStart();
services.AddSingleton<IValidateOptions<GatewayOptions>, GatewayOptionsValidator>();
services.AddSingleton<IGatewayConfigurationProvider, GatewayConfigurationProvider>(); services.AddSingleton<IGatewayConfigurationProvider, GatewayConfigurationProvider>();
return services; return services;
@@ -1,9 +1,9 @@
using Microsoft.Extensions.Options; using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Contracts;
namespace ZB.MOM.WW.MxGateway.Server.Configuration; namespace ZB.MOM.WW.MxGateway.Server.Configuration;
public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions> public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOptions>
{ {
private const int MinimumMaxMessageBytes = 1024; private const int MinimumMaxMessageBytes = 1024;
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024; private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
@@ -11,33 +11,26 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
/// <summary> /// <summary>
/// Validates gateway configuration options. /// Validates gateway configuration options.
/// </summary> /// </summary>
/// <param name="name">Options name.</param> /// <param name="builder">The accumulator to record failures on.</param>
/// <param name="options">Gateway options to validate.</param> /// <param name="options">Gateway options to validate.</param>
/// <returns>Validation result.</returns> protected override void Validate(ValidationBuilder builder, GatewayOptions options)
public ValidateOptionsResult Validate(string? name, GatewayOptions options)
{ {
List<string> failures = []; ValidateAuthentication(options.Authentication, builder);
ValidateLdap(options.Ldap, builder);
ValidateAuthentication(options.Authentication, failures); ValidateWorker(options.Worker, builder);
ValidateLdap(options.Ldap, failures); ValidateSessions(options.Sessions, builder);
ValidateWorker(options.Worker, failures); ValidateEvents(options.Events, builder);
ValidateSessions(options.Sessions, failures); ValidateDashboard(options.Dashboard, builder);
ValidateEvents(options.Events, failures); ValidateProtocol(options.Protocol, builder);
ValidateDashboard(options.Dashboard, failures); ValidateAlarms(options.Alarms, builder);
ValidateProtocol(options.Protocol, failures); ValidateTls(options.Tls, builder);
ValidateAlarms(options.Alarms, failures);
ValidateTls(options.Tls, failures);
return failures.Count == 0
? ValidateOptionsResult.Success
: ValidateOptionsResult.Fail(failures);
} }
private static void ValidateAuthentication(AuthenticationOptions options, List<string> failures) private static void ValidateAuthentication(AuthenticationOptions options, ValidationBuilder builder)
{ {
if (!Enum.IsDefined(options.Mode)) if (!Enum.IsDefined(options.Mode))
{ {
failures.Add("MxGateway:Authentication:Mode must be a supported authentication mode."); builder.Add("MxGateway:Authentication:Mode must be a supported authentication mode.");
return; return;
} }
@@ -46,67 +39,67 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
AddIfBlank( AddIfBlank(
options.SqlitePath, options.SqlitePath,
"MxGateway:Authentication:SqlitePath is required when API-key authentication is enabled.", "MxGateway:Authentication:SqlitePath is required when API-key authentication is enabled.",
failures); builder);
AddIfInvalidPath( AddIfInvalidPath(
options.SqlitePath, options.SqlitePath,
"MxGateway:Authentication:SqlitePath must be a valid filesystem path.", "MxGateway:Authentication:SqlitePath must be a valid filesystem path.",
failures); builder);
AddIfBlank( AddIfBlank(
options.PepperSecretName, options.PepperSecretName,
"MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.", "MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.",
failures); builder);
} }
} }
private static void ValidateLdap(LdapOptions options, List<string> failures) private static void ValidateLdap(LdapOptions options, ValidationBuilder builder)
{ {
if (!options.Enabled) if (!options.Enabled)
{ {
return; return;
} }
AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", failures); AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", builder);
AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", failures); AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", builder);
AddIfBlank( AddIfBlank(
options.ServiceAccountDn, options.ServiceAccountDn,
"MxGateway:Ldap:ServiceAccountDn is required when LDAP login is enabled.", "MxGateway:Ldap:ServiceAccountDn is required when LDAP login is enabled.",
failures); builder);
AddIfBlank( AddIfBlank(
options.ServiceAccountPassword, options.ServiceAccountPassword,
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.", "MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.",
failures); builder);
AddIfBlank( AddIfBlank(
options.UserNameAttribute, options.UserNameAttribute,
"MxGateway:Ldap:UserNameAttribute is required when LDAP login is enabled.", "MxGateway:Ldap:UserNameAttribute is required when LDAP login is enabled.",
failures); builder);
AddIfBlank( AddIfBlank(
options.DisplayNameAttribute, options.DisplayNameAttribute,
"MxGateway:Ldap:DisplayNameAttribute is required when LDAP login is enabled.", "MxGateway:Ldap:DisplayNameAttribute is required when LDAP login is enabled.",
failures); builder);
AddIfBlank( AddIfBlank(
options.GroupAttribute, options.GroupAttribute,
"MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.", "MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.",
failures); builder);
AddIfNotPositive(options.Port, "MxGateway:Ldap:Port must be greater than zero.", failures); builder.Port(options.Port, "MxGateway:Ldap:Port");
if (!options.UseTls && !options.AllowInsecureLdap) if (!options.UseTls && !options.AllowInsecureLdap)
{ {
failures.Add("MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false."); builder.Add("MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.");
} }
} }
private static void ValidateWorker(WorkerOptions options, List<string> failures) private static void ValidateWorker(WorkerOptions options, ValidationBuilder builder)
{ {
AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", failures); AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", builder);
AddIfInvalidPath( AddIfInvalidPath(
options.ExecutablePath, options.ExecutablePath,
"MxGateway:Worker:ExecutablePath must be a valid filesystem path.", "MxGateway:Worker:ExecutablePath must be a valid filesystem path.",
failures); builder);
if (!string.IsNullOrWhiteSpace(options.ExecutablePath) if (!string.IsNullOrWhiteSpace(options.ExecutablePath)
&& !string.Equals(Path.GetExtension(options.ExecutablePath), ".exe", StringComparison.OrdinalIgnoreCase)) && !string.Equals(Path.GetExtension(options.ExecutablePath), ".exe", StringComparison.OrdinalIgnoreCase))
{ {
failures.Add("MxGateway:Worker:ExecutablePath must point to a .exe file."); builder.Add("MxGateway:Worker:ExecutablePath must point to a .exe file.");
} }
if (!string.IsNullOrWhiteSpace(options.WorkingDirectory)) if (!string.IsNullOrWhiteSpace(options.WorkingDirectory))
@@ -114,94 +107,94 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
AddIfInvalidPath( AddIfInvalidPath(
options.WorkingDirectory, options.WorkingDirectory,
"MxGateway:Worker:WorkingDirectory must be a valid filesystem path.", "MxGateway:Worker:WorkingDirectory must be a valid filesystem path.",
failures); builder);
} }
if (!Enum.IsDefined(options.RequiredArchitecture)) if (!Enum.IsDefined(options.RequiredArchitecture))
{ {
failures.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture."); builder.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture.");
} }
AddIfNotPositive( AddIfNotPositive(
options.StartupTimeoutSeconds, options.StartupTimeoutSeconds,
"MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.", "MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.",
failures); builder);
AddIfNotPositive( AddIfNotPositive(
options.StartupProbeRetryAttempts, options.StartupProbeRetryAttempts,
"MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.", "MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.",
failures); builder);
AddIfNotPositive( AddIfNotPositive(
options.StartupProbeRetryDelayMilliseconds, options.StartupProbeRetryDelayMilliseconds,
"MxGateway:Worker:StartupProbeRetryDelayMilliseconds must be greater than zero.", "MxGateway:Worker:StartupProbeRetryDelayMilliseconds must be greater than zero.",
failures); builder);
AddIfNotPositive( AddIfNotPositive(
options.PipeConnectAttemptTimeoutMilliseconds, options.PipeConnectAttemptTimeoutMilliseconds,
"MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.", "MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.",
failures); builder);
AddIfNotPositive( AddIfNotPositive(
options.ShutdownTimeoutSeconds, options.ShutdownTimeoutSeconds,
"MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.", "MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.",
failures); builder);
AddIfNotPositive( AddIfNotPositive(
options.HeartbeatIntervalSeconds, options.HeartbeatIntervalSeconds,
"MxGateway:Worker:HeartbeatIntervalSeconds must be greater than zero.", "MxGateway:Worker:HeartbeatIntervalSeconds must be greater than zero.",
failures); builder);
AddIfNotPositive( AddIfNotPositive(
options.HeartbeatGraceSeconds, options.HeartbeatGraceSeconds,
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than zero.", "MxGateway:Worker:HeartbeatGraceSeconds must be greater than zero.",
failures); builder);
if (options.HeartbeatGraceSeconds < options.HeartbeatIntervalSeconds) if (options.HeartbeatGraceSeconds < options.HeartbeatIntervalSeconds)
{ {
failures.Add( builder.Add(
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds."); "MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds.");
} }
if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes) if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
{ {
failures.Add( builder.Add(
$"MxGateway:Worker:MaxMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}."); $"MxGateway:Worker:MaxMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
} }
} }
private static void ValidateSessions(SessionOptions options, List<string> failures) private static void ValidateSessions(SessionOptions options, ValidationBuilder builder)
{ {
AddIfNotPositive( AddIfNotPositive(
options.DefaultCommandTimeoutSeconds, options.DefaultCommandTimeoutSeconds,
"MxGateway:Sessions:DefaultCommandTimeoutSeconds must be greater than zero.", "MxGateway:Sessions:DefaultCommandTimeoutSeconds must be greater than zero.",
failures); builder);
AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", failures); AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", builder);
AddIfNotPositive( AddIfNotPositive(
options.MaxPendingCommandsPerSession, options.MaxPendingCommandsPerSession,
"MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.", "MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.",
failures); builder);
AddIfNotPositive( AddIfNotPositive(
options.DefaultLeaseSeconds, options.DefaultLeaseSeconds,
"MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.", "MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.",
failures); builder);
AddIfNotPositive( AddIfNotPositive(
options.LeaseSweepIntervalSeconds, options.LeaseSweepIntervalSeconds,
"MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.", "MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.",
failures); builder);
if (options.AllowMultipleEventSubscribers) if (options.AllowMultipleEventSubscribers)
{ {
failures.Add( builder.Add(
"MxGateway:Sessions:AllowMultipleEventSubscribers is not supported until event fan-out is implemented."); "MxGateway:Sessions:AllowMultipleEventSubscribers is not supported until event fan-out is implemented.");
} }
} }
private static void ValidateEvents(EventOptions options, List<string> failures) private static void ValidateEvents(EventOptions options, ValidationBuilder builder)
{ {
AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", failures); AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", builder);
if (!Enum.IsDefined(options.BackpressurePolicy)) if (!Enum.IsDefined(options.BackpressurePolicy))
{ {
failures.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy."); builder.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy.");
} }
} }
private static void ValidateDashboard(DashboardOptions options, List<string> failures) private static void ValidateDashboard(DashboardOptions options, ValidationBuilder builder)
{ {
// GroupToRole shape is validated even when the dashboard is disabled so // GroupToRole shape is validated even when the dashboard is disabled so
// misconfiguration surfaces at startup; emptiness is allowed, with the // misconfiguration surfaces at startup; emptiness is allowed, with the
@@ -212,13 +205,13 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
{ {
if (string.IsNullOrWhiteSpace(entry.Key)) if (string.IsNullOrWhiteSpace(entry.Key))
{ {
failures.Add("MxGateway:Dashboard:GroupToRole keys (LDAP group names) must be non-blank."); builder.Add("MxGateway:Dashboard:GroupToRole keys (LDAP group names) must be non-blank.");
} }
if (!string.Equals(entry.Value, Dashboard.DashboardRoles.Admin, StringComparison.Ordinal) if (!string.Equals(entry.Value, Dashboard.DashboardRoles.Admin, StringComparison.Ordinal)
&& !string.Equals(entry.Value, Dashboard.DashboardRoles.Viewer, StringComparison.Ordinal)) && !string.Equals(entry.Value, Dashboard.DashboardRoles.Viewer, StringComparison.Ordinal))
{ {
failures.Add( builder.Add(
$"MxGateway:Dashboard:GroupToRole['{entry.Key}'] must be '{Dashboard.DashboardRoles.Admin}' or '{Dashboard.DashboardRoles.Viewer}'."); $"MxGateway:Dashboard:GroupToRole['{entry.Key}'] must be '{Dashboard.DashboardRoles.Admin}' or '{Dashboard.DashboardRoles.Viewer}'.");
} }
} }
@@ -226,18 +219,18 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
AddIfNotPositive( AddIfNotPositive(
options.SnapshotIntervalMilliseconds, options.SnapshotIntervalMilliseconds,
"MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.", "MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.",
failures); builder);
AddIfNegative( AddIfNegative(
options.RecentFaultLimit, options.RecentFaultLimit,
"MxGateway:Dashboard:RecentFaultLimit must be greater than or equal to zero.", "MxGateway:Dashboard:RecentFaultLimit must be greater than or equal to zero.",
failures); builder);
AddIfNegative( AddIfNegative(
options.RecentSessionLimit, options.RecentSessionLimit,
"MxGateway:Dashboard:RecentSessionLimit must be greater than or equal to zero.", "MxGateway:Dashboard:RecentSessionLimit must be greater than or equal to zero.",
failures); builder);
} }
private static void ValidateAlarms(AlarmsOptions options, List<string> failures) private static void ValidateAlarms(AlarmsOptions options, ValidationBuilder builder)
{ {
if (!options.Enabled) if (!options.Enabled)
{ {
@@ -251,14 +244,14 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
if (string.IsNullOrWhiteSpace(options.SubscriptionExpression) if (string.IsNullOrWhiteSpace(options.SubscriptionExpression)
&& string.IsNullOrWhiteSpace(options.DefaultArea)) && string.IsNullOrWhiteSpace(options.DefaultArea))
{ {
failures.Add( builder.Add(
"MxGateway:Alarms requires either a non-blank SubscriptionExpression or a non-blank DefaultArea when Enabled is true."); "MxGateway:Alarms requires either a non-blank SubscriptionExpression or a non-blank DefaultArea when Enabled is true.");
} }
if (!string.IsNullOrWhiteSpace(options.SubscriptionExpression) if (!string.IsNullOrWhiteSpace(options.SubscriptionExpression)
&& !options.SubscriptionExpression.StartsWith(@"\\", StringComparison.Ordinal)) && !options.SubscriptionExpression.StartsWith(@"\\", StringComparison.Ordinal))
{ {
failures.Add( builder.Add(
@"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape)."); @"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape).");
} }
} }
@@ -266,11 +259,11 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
private const int MinimumCertValidityYears = 1; private const int MinimumCertValidityYears = 1;
private const int MaximumCertValidityYears = 100; private const int MaximumCertValidityYears = 100;
private static void ValidateTls(TlsOptions options, List<string> failures) private static void ValidateTls(TlsOptions options, ValidationBuilder builder)
{ {
if (options.ValidityYears is < MinimumCertValidityYears or > MaximumCertValidityYears) if (options.ValidityYears is < MinimumCertValidityYears or > MaximumCertValidityYears)
{ {
failures.Add( builder.Add(
$"MxGateway:Tls:ValidityYears must be between {MinimumCertValidityYears} and {MaximumCertValidityYears}."); $"MxGateway:Tls:ValidityYears must be between {MinimumCertValidityYears} and {MaximumCertValidityYears}.");
} }
@@ -278,61 +271,52 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
AddIfBlank( AddIfBlank(
options.SelfSignedCertPath, options.SelfSignedCertPath,
"MxGateway:Tls:SelfSignedCertPath must not be blank.", "MxGateway:Tls:SelfSignedCertPath must not be blank.",
failures); builder);
AddIfInvalidPath( AddIfInvalidPath(
options.SelfSignedCertPath, options.SelfSignedCertPath,
"MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.", "MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.",
failures); builder);
foreach (string dns in options.AdditionalDnsNames) foreach (string dns in options.AdditionalDnsNames)
{ {
if (string.IsNullOrWhiteSpace(dns)) if (string.IsNullOrWhiteSpace(dns))
{ {
failures.Add("MxGateway:Tls:AdditionalDnsNames entries must be non-blank."); builder.Add("MxGateway:Tls:AdditionalDnsNames entries must be non-blank.");
} }
} }
} }
private static void ValidateProtocol(ProtocolOptions options, List<string> failures) private static void ValidateProtocol(ProtocolOptions options, ValidationBuilder builder)
{ {
if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion) if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion)
{ {
failures.Add( builder.Add(
$"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}."); $"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}.");
} }
if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes) if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
{ {
failures.Add( builder.Add(
$"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}."); $"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
} }
} }
private static void AddIfBlank(string? value, string message, List<string> failures) private static void AddIfBlank(string? value, string message, ValidationBuilder builder)
{ {
if (string.IsNullOrWhiteSpace(value)) builder.RequireThat(!string.IsNullOrWhiteSpace(value), message);
{
failures.Add(message);
}
} }
private static void AddIfNotPositive(int value, string message, List<string> failures) private static void AddIfNotPositive(int value, string message, ValidationBuilder builder)
{ {
if (value <= 0) builder.RequireThat(value > 0, message);
{
failures.Add(message);
}
} }
private static void AddIfNegative(int value, string message, List<string> failures) private static void AddIfNegative(int value, string message, ValidationBuilder builder)
{ {
if (value < 0) builder.RequireThat(value >= 0, message);
{
failures.Add(message);
}
} }
private static void AddIfInvalidPath(string? value, string message, List<string> failures) private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
{ {
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value))
{ {
@@ -345,15 +329,19 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
} }
catch (ArgumentException) catch (ArgumentException)
{ {
failures.Add(message); builder.Add(message);
} }
catch (NotSupportedException) catch (NotSupportedException)
{ {
failures.Add(message); builder.Add(message);
} }
catch (PathTooLongException) catch (PathTooLongException)
{ {
failures.Add(message); builder.Add(message);
}
catch (IOException)
{
builder.Add(message);
} }
} }
} }
@@ -0,0 +1,40 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
/// <summary>
/// Readiness probe: verifies the SQLite authentication store is reachable. The gateway
/// authenticates every gRPC call against this store, so its reachability gates readiness.
/// </summary>
public sealed class AuthStoreHealthCheck : IHealthCheck
{
private readonly AuthSqliteConnectionFactory _connectionFactory;
public AuthStoreHealthCheck(AuthSqliteConnectionFactory connectionFactory) =>
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
await using SqliteConnection connection =
await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT 1;";
await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("Auth store is reachable.");
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Auth store is unreachable.", ex);
}
}
}
@@ -0,0 +1,28 @@
using ZB.MOM.WW.Telemetry.Serilog;
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
/// <summary>
/// Adapts the static <see cref="GatewayLogRedactor"/> to the shared <see cref="ILogRedactor"/> seam
/// so the telemetry RedactionEnricher masks API-key/credential material on every log event.
/// </summary>
public sealed class GatewayLogRedactorSeam : ILogRedactor
{
private static readonly string[] IdentityKeys = ["ClientIdentity", "authorization", "Authorization"];
/// <summary>
/// Masks API-key/credential material in known identity-bearing log properties.
/// </summary>
/// <param name="properties">The log event property dictionary to redact in place.</param>
public void Redact(IDictionary<string, object?> properties)
{
ArgumentNullException.ThrowIfNull(properties);
foreach (var key in IdentityKeys)
{
if (properties.TryGetValue(key, out var value) && value is string s)
{
properties[key] = GatewayLogRedactor.RedactClientIdentity(s);
}
}
}
}
@@ -2,6 +2,7 @@ using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.Hosting.StaticWebAssets; using Microsoft.AspNetCore.Hosting.StaticWebAssets;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Configuration; using Microsoft.Extensions.Logging.Configuration;
using ZB.MOM.WW.Health;
using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Server.Alarms; using ZB.MOM.WW.MxGateway.Server.Alarms;
using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Configuration;
@@ -14,6 +15,8 @@ using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization; using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Server.Sessions; using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Server.Workers; using ZB.MOM.WW.MxGateway.Server.Workers;
using ZB.MOM.WW.Telemetry;
using ZB.MOM.WW.Telemetry.Serilog;
namespace ZB.MOM.WW.MxGateway.Server; namespace ZB.MOM.WW.MxGateway.Server;
@@ -60,11 +63,28 @@ public static class GatewayApplication
ConfigureSelfSignedTls(builder); ConfigureSelfSignedTls(builder);
builder.Services.AddGatewayConfiguration(); builder.AddZbSerilog(o => o.ServiceName = "mxgateway");
builder.Services.AddGatewayConfiguration(builder.Configuration);
builder.Services.AddSqliteAuthStore(); builder.Services.AddSqliteAuthStore();
builder.Services.AddGatewayGrpcAuthorization(); builder.Services.AddGatewayGrpcAuthorization();
builder.Services.AddHealthChecks(); builder.Services.AddHealthChecks()
.AddTypeActivatedCheck<AuthStoreHealthCheck>(
"auth-store",
failureStatus: null,
tags: new[] { ZbHealthTags.Ready });
builder.Services.AddSingleton<GatewayMetrics>(); builder.Services.AddSingleton<GatewayMetrics>();
builder.AddZbTelemetry(o =>
{
o.ServiceName = "mxgateway";
o.Meters = [GatewayMetrics.MeterName]; // "MxGateway.Server" — name unchanged
if (Enum.TryParse<ZbExporter>(builder.Configuration["MxGateway:Telemetry:Exporter"], ignoreCase: true, out var exporter))
o.Exporter = exporter;
var otlp = builder.Configuration["MxGateway:Telemetry:OtlpEndpoint"];
if (!string.IsNullOrWhiteSpace(otlp))
o.OtlpEndpoint = otlp;
});
builder.Services.AddSingleton<ILogRedactor, GatewayLogRedactorSeam>();
builder.Services.AddSingleton<MxAccessGrpcMapper>(); builder.Services.AddSingleton<MxAccessGrpcMapper>();
builder.Services.AddSingleton<MxAccessGrpcRequestValidator>(); builder.Services.AddSingleton<MxAccessGrpcRequestValidator>();
builder.Services.AddSingleton<IEventStreamService, EventStreamService>(); builder.Services.AddSingleton<IEventStreamService, EventStreamService>();
@@ -169,13 +189,8 @@ public static class GatewayApplication
{ {
endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath()); endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath());
endpoints.MapGet( endpoints.MapZbHealth();
"/health/live", endpoints.MapZbMetrics();
() => Results.Ok(new GatewayHealthReply(
Status: "Healthy",
DefaultBackend: GatewayContractInfo.DefaultBackendName,
WorkerProtocolVersion: GatewayContractInfo.WorkerProtocolVersion)))
.WithName("LiveHealth");
endpoints.MapGrpcService<MxAccessGatewayService>(); endpoints.MapGrpcService<MxAccessGatewayService>();
endpoints.MapGrpcService<GalaxyRepositoryGrpcService>(); endpoints.MapGrpcService<GalaxyRepositoryGrpcService>();
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Metrics;
public sealed class GatewayMetrics : IDisposable public sealed class GatewayMetrics : IDisposable
{ {
public const string MeterName = "MxGateway.Server"; public const string MeterName = "ZB.MOM.WW.MxGateway";
private readonly object _syncRoot = new(); private readonly object _syncRoot = new();
private readonly Meter _meter; private readonly Meter _meter;
@@ -68,9 +68,9 @@ public sealed class GatewayMetrics : IDisposable
_heartbeatFailuresCounter = _meter.CreateCounter<long>("mxgateway.heartbeats.failed"); _heartbeatFailuresCounter = _meter.CreateCounter<long>("mxgateway.heartbeats.failed");
_streamDisconnectsCounter = _meter.CreateCounter<long>("mxgateway.grpc.streams.disconnected"); _streamDisconnectsCounter = _meter.CreateCounter<long>("mxgateway.grpc.streams.disconnected");
_retryAttemptsCounter = _meter.CreateCounter<long>("mxgateway.retries.attempted"); _retryAttemptsCounter = _meter.CreateCounter<long>("mxgateway.retries.attempted");
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "ms"); _workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "s");
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "ms"); _commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "s");
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "ms"); _eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "s");
_meter.CreateObservableGauge("mxgateway.sessions.open", GetOpenSessions); _meter.CreateObservableGauge("mxgateway.sessions.open", GetOpenSessions);
_meter.CreateObservableGauge("mxgateway.workers.running", GetWorkersRunning); _meter.CreateObservableGauge("mxgateway.workers.running", GetWorkersRunning);
@@ -144,7 +144,7 @@ public sealed class GatewayMetrics : IDisposable
_workersRunning++; _workersRunning++;
} }
_workerStartupLatencyHistogram.Record(startupDuration.TotalMilliseconds); _workerStartupLatencyHistogram.Record(startupDuration.TotalSeconds);
} }
/// <summary> /// <summary>
@@ -208,7 +208,7 @@ public sealed class GatewayMetrics : IDisposable
KeyValuePair<string, object?> methodTag = new("method", method); KeyValuePair<string, object?> methodTag = new("method", method);
_commandsSucceededCounter.Add(1, methodTag); _commandsSucceededCounter.Add(1, methodTag);
_commandLatencyHistogram.Record(duration.TotalMilliseconds, methodTag); _commandLatencyHistogram.Record(duration.TotalSeconds, methodTag);
} }
/// <summary> /// <summary>
@@ -228,7 +228,7 @@ public sealed class GatewayMetrics : IDisposable
KeyValuePair<string, object?> methodTag = new("method", method); KeyValuePair<string, object?> methodTag = new("method", method);
KeyValuePair<string, object?> categoryTag = new("category", category); KeyValuePair<string, object?> categoryTag = new("category", category);
_commandsFailedCounter.Add(1, methodTag, categoryTag); _commandsFailedCounter.Add(1, methodTag, categoryTag);
_commandLatencyHistogram.Record(duration.TotalMilliseconds, methodTag, categoryTag); _commandLatencyHistogram.Record(duration.TotalSeconds, methodTag, categoryTag);
} }
/// <summary> /// <summary>
@@ -255,7 +255,7 @@ public sealed class GatewayMetrics : IDisposable
public void RecordEventStreamSend(string family, TimeSpan duration) public void RecordEventStreamSend(string family, TimeSpan duration)
{ {
_eventStreamSendLatencyHistogram.Record( _eventStreamSendLatencyHistogram.Record(
duration.TotalMilliseconds, duration.TotalSeconds,
new KeyValuePair<string, object?>("family", family)); new KeyValuePair<string, object?>("family", family));
} }
@@ -6,6 +6,13 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" /> <PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Health" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
@@ -1,8 +1,13 @@
{ {
"Logging": { "Serilog": {
"LogLevel": { "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
"MinimumLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Override": { "Microsoft.AspNetCore": "Warning" }
} },
"WriteTo": [
{ "Name": "Console" },
{ "Name": "File", "Args": { "path": "logs/mxgateway-.log", "rollingInterval": "Day" } }
]
} }
} }
@@ -1,9 +1,14 @@
{ {
"Logging": { "Serilog": {
"LogLevel": { "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
"MinimumLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Override": { "Microsoft.AspNetCore": "Warning" }
} },
"WriteTo": [
{ "Name": "Console" },
{ "Name": "File", "Args": { "path": "logs/mxgateway-.log", "rollingInterval": "Day" } }
]
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"MxGateway": { "MxGateway": {
@@ -132,7 +132,7 @@ public sealed class GatewayOptionsTests
ServiceCollection services = new(); ServiceCollection services = new();
services.AddSingleton<IConfiguration>(configuration); services.AddSingleton<IConfiguration>(configuration);
services.AddGatewayConfiguration(); services.AddGatewayConfiguration(configuration);
return services.BuildServiceProvider(validateScopes: true); return services.BuildServiceProvider(validateScopes: true);
} }
@@ -9,6 +9,29 @@ public sealed class GatewayOptionsValidatorTests
// design-default values; those defaults are validated separately in GatewayOptionsTests. // design-default values; those defaults are validated separately in GatewayOptionsTests.
private static GatewayOptions ValidOptions() => new(); private static GatewayOptions ValidOptions() => new();
// Returns enabled LDAP options that pass all checks except Port.
// The class defaults already satisfy the blank-field checks; we only
// override Enabled (must be true to exercise the port check) and Port.
private static LdapOptions LdapOptionsWithPort(int port) => new()
{
Enabled = true,
Port = port,
};
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) private static GatewayOptions CloneWithTls(GatewayOptions source, TlsOptions tls)
=> new() => new()
{ {
@@ -65,4 +88,34 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Failed); Assert.True(result.Failed);
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:SelfSignedCertPath must not be blank.")); Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:SelfSignedCertPath must not be blank."));
} }
[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)"));
}
[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)"));
}
[Fact]
public void Validate_Succeeds_WhenLdapEnabledWithValidPort()
{
GatewayOptions options = CloneWithLdap(ValidOptions(), LdapOptionsWithPort(389));
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
} }
@@ -0,0 +1,49 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Diagnostics;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
namespace ZB.MOM.WW.MxGateway.Tests.Diagnostics;
public sealed class AuthStoreHealthCheckTests
{
private static AuthSqliteConnectionFactory FactoryFor(string sqlitePath)
{
// GatewayOptions.Authentication and AuthenticationOptions.SqlitePath are both
// init-only, so populate them through object initializers.
var options = new GatewayOptions
{
Authentication = new AuthenticationOptions { SqlitePath = sqlitePath },
};
return new AuthSqliteConnectionFactory(Options.Create(options));
}
[Fact]
public async Task Healthy_WhenStoreReachable()
{
var path = Path.Combine(Path.GetTempPath(), $"authcheck-{Guid.NewGuid():N}.db");
try
{
var check = new AuthStoreHealthCheck(FactoryFor(path));
var result = await check.CheckHealthAsync(new HealthCheckContext());
Assert.Equal(HealthStatus.Healthy, result.Status);
}
finally { if (File.Exists(path)) File.Delete(path); }
}
[Fact]
public async Task Unhealthy_WhenPathUnusable()
{
// A regular file used as a parent directory forces the open to fail.
var bogus = Path.Combine(Path.GetTempPath(), $"authcheck-{Guid.NewGuid():N}");
await File.WriteAllTextAsync(bogus, "x");
try
{
var check = new AuthStoreHealthCheck(FactoryFor(Path.Combine(bogus, "store.db")));
var result = await check.CheckHealthAsync(new HealthCheckContext());
Assert.Equal(HealthStatus.Unhealthy, result.Status);
}
finally { if (File.Exists(bogus)) File.Delete(bogus); }
}
}
@@ -0,0 +1,15 @@
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Server.Diagnostics;
using Xunit;
public class GatewayLogRedactorSeamTests
{
[Fact]
public void Redact_MasksApiKeyInClientIdentity()
{
var redactor = new GatewayLogRedactorSeam();
var props = new Dictionary<string, object?> { ["ClientIdentity"] = "Bearer mxgw_operator01_super-secret" };
redactor.Redact(props);
Assert.Equal("Bearer mxgw_operator01_[redacted]", props["ClientIdentity"]);
}
}
@@ -1,7 +1,10 @@
using System.Net;
using System.Net.Http;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server; using ZB.MOM.WW.MxGateway.Server;
using ZB.MOM.WW.MxGateway.Server.Dashboard; using ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -11,19 +14,31 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway;
public sealed class GatewayApplicationTests public sealed class GatewayApplicationTests
{ {
/// <summary>Verifies that Build maps the live health check endpoint.</summary> /// <summary>Verifies that Build maps the canonical three health tiers.</summary>
[Fact] [Fact]
public async Task Build_MapsLiveHealthEndpoint() public async Task Build_MapsCanonicalHealthEndpoints()
{ {
await using WebApplication app = GatewayApplication.Build([]); await using WebApplication app = GatewayApplication.Build([]);
RouteEndpoint endpoint = Assert.Single( var paths = ((IEndpointRouteBuilder)app).DataSources
((IEndpointRouteBuilder)app).DataSources .SelectMany(dataSource => dataSource.Endpoints)
.SelectMany(dataSource => dataSource.Endpoints) .OfType<RouteEndpoint>()
.OfType<RouteEndpoint>(), .Select(e => e.RoutePattern.RawText)
candidate => candidate.RoutePattern.RawText == "/health/live"); .ToHashSet();
Assert.Equal("LiveHealth", endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName); Assert.Contains("/health/ready", paths);
Assert.Contains("/health/active", paths);
Assert.Contains("/healthz", paths);
Assert.DoesNotContain("/health/live", paths);
}
/// <summary>Verifies that Build registers Serilog as the host logging provider.</summary>
[Fact]
public void Build_UsesSerilogLoggerProvider()
{
using var app = GatewayApplication.Build([]);
var factory = app.Services.GetRequiredService<ILoggerFactory>();
Assert.Equal("SerilogLoggerFactory", factory.GetType().Name);
} }
/// <summary>Verifies that Build registers the gateway metrics service.</summary> /// <summary>Verifies that Build registers the gateway metrics service.</summary>
@@ -37,6 +52,28 @@ public sealed class GatewayApplicationTests
Assert.NotNull(metrics); Assert.NotNull(metrics);
} }
/// <summary>Verifies that Build mounts the Prometheus /metrics scrape endpoint.</summary>
[Fact]
public async Task Build_MapsMetricsEndpoint()
{
// Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any
// started-host test must avoid a fixed port to prevent a bind collision.
await using WebApplication app = GatewayApplication.Build(["--urls=http://127.0.0.1:0"]);
await app.StartAsync();
try
{
using var client = new HttpClient { BaseAddress = new Uri(app.Urls.First()) };
using HttpResponseMessage response = await client.GetAsync("/metrics");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
finally
{
await app.StopAsync();
}
}
/// <summary>Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled.</summary> /// <summary>Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled.</summary>
[Fact] [Fact]
public async Task Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints() public async Task Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints()
@@ -245,7 +245,7 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
ServiceCollection services = new(); ServiceCollection services = new();
services.AddSingleton<IConfiguration>(configuration); services.AddSingleton<IConfiguration>(configuration);
services.AddGatewayConfiguration(); services.AddGatewayConfiguration(configuration);
services.AddSqliteAuthStore(); services.AddSqliteAuthStore();
return services.BuildServiceProvider(validateScopes: true); return services.BuildServiceProvider(validateScopes: true);
@@ -188,7 +188,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
ServiceCollection services = new(); ServiceCollection services = new();
services.AddSingleton<IConfiguration>(configuration); services.AddSingleton<IConfiguration>(configuration);
services.AddGatewayConfiguration(); services.AddGatewayConfiguration(configuration);
services.AddSqliteAuthStore(); services.AddSqliteAuthStore();
return services.BuildServiceProvider(validateScopes: true); return services.BuildServiceProvider(validateScopes: true);