docs(src): add missing XML docs and strip tracking-ID comments

Sweep of 203 source files resolving CommentChecker findings: add
<summary>/<param>/<returns>/<inheritdoc> where missing, and remove
resolved task/issue tracking markers (Tests-NNN, Worker-NNN, Server-NNN,
Task N) from code comments. Comment/doc-only — no logic changes.
Server+Tests build clean under TreatWarningsAsErrors.
This commit is contained in:
Joseph Doherty
2026-07-07 14:09:49 -04:00
parent 8914472706
commit fca978de07
203 changed files with 1834 additions and 1383 deletions
@@ -23,7 +23,7 @@ public static class GatewayContractInfo
/// <c>ZB.MOM.WW.MxGateway.IntegrationTests.LiveMxAccessFactAttribute</c> and
/// <c>ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport.LiveMxAccessFactAttribute</c>
/// so any future opt-in tweak does not silently leave one project
/// behind — see Worker.Tests-025.
/// behind.
/// </summary>
public const string LiveMxAccessOptInVariableName = "MXGATEWAY_RUN_LIVE_MXACCESS_TESTS";
}
@@ -15,6 +15,7 @@ namespace ZB.MOM.WW.MxGateway.IntegrationTests;
public sealed class DashboardLdapLiveTests
{
/// <summary>Verifies that an admin user in the GwAdmin group authenticates successfully.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact]
public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds()
{
@@ -32,17 +33,13 @@ public sealed class DashboardLdapLiveTests
claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType
&& claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase));
// IntegrationTests-023: DashboardAuthenticator builds the principal with a
// ClaimTypes.Role claim resolved from the LDAP groups via the
// DashboardGroupRoleMapper. The seeded GroupToRole map (GwAdmin -> Admin)
// means the admin principal must carry Role=Admin alongside the raw LDAP-group
// claim. A regression in the group→role mapping would fail this assertion.
Assert.Contains(result.Principal.Claims, claim =>
claim.Type == ClaimTypes.Role
&& claim.Value == DashboardRoles.Admin);
}
/// <summary>Verifies that a readonly user without GwAdmin group fails to authenticate.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact]
public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails()
{
@@ -59,6 +56,7 @@ public sealed class DashboardLdapLiveTests
}
/// <summary>Verifies that authentication with wrong password fails without leaking the password.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact]
public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
{
@@ -78,6 +76,7 @@ public sealed class DashboardLdapLiveTests
}
/// <summary>Verifies that authentication with unknown username fails.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact]
public async Task AuthenticateAsync_UnknownUsername_Fails()
{
@@ -95,6 +94,7 @@ public sealed class DashboardLdapLiveTests
}
/// <summary>Verifies that authentication fails gracefully when the server is unreachable.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact]
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
{
@@ -141,7 +141,7 @@ public sealed class DashboardLdapLiveTests
/// Builds the shared library <see cref="LibraryLdapOptions"/> by binding the real
/// <c>MxGateway:Ldap</c> configuration section the same way production does in
/// <c>AddZbLdapAuth(configuration, "MxGateway:Ldap")</c>, rather than hand-copying the
/// gateway shadow <c>LdapOptions</c> defaults field by field (IntegrationTests-028).
/// gateway shadow <c>LdapOptions</c> defaults field by field.
/// Binding the section directly onto the shared type means the live tests exercise the
/// exact option-binding path production uses, pick up every shared field (including
/// <see cref="LibraryLdapOptions.ConnectionTimeoutMs"/>, which governs the
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.MxGateway.IntegrationTests.Galaxy;
public sealed class GalaxyRepositoryLiveTests
{
/// <summary>Verifies that the Galaxy Repository can establish a live connection to the ZB database.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveGalaxyRepositoryFact]
public async Task TestConnection_AgainstZb_Succeeds()
{
@@ -18,6 +19,7 @@ public sealed class GalaxyRepositoryLiveTests
}
/// <summary>Verifies that the last deploy time can be retrieved from the ZB database.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveGalaxyRepositoryFact]
public async Task GetLastDeployTime_AgainstZb_ReturnsTimestamp()
{
@@ -29,6 +31,7 @@ public sealed class GalaxyRepositoryLiveTests
}
/// <summary>Verifies that the hierarchy can be retrieved from the ZB database.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveGalaxyRepositoryFact]
public async Task GetHierarchy_AgainstZb_ReturnsObjects()
{
@@ -46,6 +49,7 @@ public sealed class GalaxyRepositoryLiveTests
}
/// <summary>Verifies that object attributes can be retrieved from the ZB database.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveGalaxyRepositoryFact]
public async Task GetAttributes_AgainstZb_ReturnsAtLeastOneAttribute()
{
@@ -7,8 +7,7 @@ public static class IntegrationTestEnvironment
/// <summary>
/// Sourced from <see cref="GatewayContractInfo.LiveMxAccessOptInVariableName"/>
/// so the env-var literal is shared with
/// <c>ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport.LiveMxAccessFactAttribute</c>
/// (Worker.Tests-025).
/// <c>ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport.LiveMxAccessFactAttribute</c>.
/// </summary>
public const string LiveMxAccessVariableName = GatewayContractInfo.LiveMxAccessOptInVariableName;
public const string LiveMxAccessWorkerExecutableVariableName = "MXGATEWAY_LIVE_MXACCESS_WORKER_EXE";
@@ -104,7 +103,7 @@ public static class IntegrationTestEnvironment
/// when no root is found so a misconfigured run fails fast with an actionable
/// message rather than silently falling back to the current working directory
/// (which previously produced a misleading "worker exe not found" pointing at
/// a fabricated path — see IntegrationTests-022). The
/// a fabricated path). The
/// <see cref="LiveMxAccessWorkerExecutableVariableName"/> environment variable
/// remains the escape hatch for unusual deployments.
/// </summary>
@@ -115,7 +114,7 @@ public static class IntegrationTestEnvironment
/// ancestors above it. Tests pass an isolated boundary so the walker cannot
/// leak into ambient ancestors (a redirected <c>TMP</c>, a co-located checkout
/// at <c>C:\src</c>, an enclosing CI workspace, etc.) that would silently
/// satisfy <see cref="IsRepositoryRoot"/> — see IntegrationTests-025.
/// satisfy <see cref="IsRepositoryRoot"/>.
/// Production callers pass <see langword="null"/> so the walk continues to the
/// drive root as before.
/// </param>
@@ -34,7 +34,7 @@ public sealed class IntegrationTestEnvironmentTests
File.WriteAllText(Path.Combine(temporaryRoot, ".git"), "gitdir: ../.git/worktrees/test");
// Pass temporaryRoot as the stop-boundary so the walker can never leak
// into ambient ancestors of Path.GetTempPath() (IntegrationTests-025).
// into ambient ancestors of Path.GetTempPath().
string repositoryRoot = IntegrationTestEnvironment.ResolveRepositoryRoot(
nestedDirectory,
stopBoundary: temporaryRoot);
@@ -54,13 +54,13 @@ public sealed class IntegrationTestEnvironmentTests
/// Verifies that <see cref="IntegrationTestEnvironment.ResolveRepositoryRoot"/>
/// throws <see cref="InvalidOperationException"/> with a diagnostic message when
/// the walk exhausts without finding a repository root. The previous silent
/// fallback to <c>Directory.GetCurrentDirectory()</c> masked misconfiguration
/// (IntegrationTests-022); operators get a clear, actionable failure instead.
/// fallback to <c>Directory.GetCurrentDirectory()</c> masked misconfiguration;
/// operators get a clear, actionable failure instead.
/// The <c>stopBoundary</c> isolates the walker from ambient ancestors of
/// <see cref="Path.GetTempPath"/> (a redirected <c>TMP</c>, a co-located checkout
/// at <c>C:\src</c>, etc.) that could otherwise satisfy
/// <c>IsRepositoryRoot</c> and make this assertion flake on contributor or CI
/// boxes — see IntegrationTests-025.
/// boxes.
/// </summary>
[Fact]
public void ResolveRepositoryRoot_NoMarkers_ThrowsInvalidOperationExceptionNamingStartAndMarkers()
@@ -97,9 +97,8 @@ public sealed class IntegrationTestEnvironmentTests
/// <summary>
/// Verifies the <c>stopBoundary</c> parameter on
/// <see cref="IntegrationTestEnvironment.ResolveRepositoryRoot"/> isolates the
/// walker from ambient ancestors that happen to satisfy <c>IsRepositoryRoot</c>
/// — the precise failure mode IntegrationTests-025 describes. The test
/// deliberately constructs an outer directory that *does* carry repository-root
/// walker from ambient ancestors that happen to satisfy <c>IsRepositoryRoot</c>.
/// The test deliberately constructs an outer directory that *does* carry repository-root
/// markers (<c>src/</c> + <c>.git</c>) and an inner isolated chain that does
/// not. Without the boundary the walker would happily stop at the outer
/// directory; with the boundary it must throw because the chain it can see
@@ -11,7 +11,7 @@ namespace ZB.MOM.WW.MxGateway.IntegrationTests.TestSupport;
/// The unit-test project owns a parallel copy under
/// <c>ZB.MOM.WW.MxGateway.Tests/TestSupport/</c>; IntegrationTests keeps its
/// own copy here so the two test projects stay independently buildable
/// without a shared test-helpers project (IntegrationTests-024).
/// without a shared test-helpers project.
/// </summary>
public sealed class NullDashboardEventBroadcaster : IDashboardEventBroadcaster
{
@@ -30,6 +30,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// <summary>
/// Verifies that a gateway session can register, add item, advise, and stream events from live MXAccess.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_RegistersAdvisesStreamsDataAndCloses()
{
@@ -119,6 +120,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// and that the worker emits a matching <see cref="MxEventFamily.OnWriteComplete"/> event
/// — the proof of round-trip the cross-language client e2e runner relies on.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_WritesValueToAdvisedItem()
{
@@ -235,6 +237,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// Verifies that an AddItem against an invalid server handle surfaces the MXAccess failure
/// without faulting the gateway transport, exercising the invalid-handle parity path.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_InvalidHandleCommand_SurfacesFailureWithoutTransportFault()
{
@@ -293,6 +296,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// OnDataChange events for the un-advised item. Exercises the lifecycle-ordering
/// parity CLAUDE.md singles out as a "do not synthesize" rule.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_UnadviseRemoveItemUnregister_TeardownOrderingParity()
{
@@ -391,7 +395,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
// only constrains events generated AFTER the teardown returned. So the
// "before" baseline is taken *after* a first settle window drains those
// in-flight events, not before UnAdvise was issued (which races against
// the round-trip + STA dispatch + pipe send window — see IntegrationTests-017).
// the round-trip + STA dispatch + pipe send window).
//
// RecordingServerStreamWriter.Messages returns a snapshot copy under its
// own lock, so iterating after each settle window is safe without external
@@ -437,6 +441,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// parity surface the gateway must not "fix" — the test asserts the reply kind and
/// protocol status, not a fabricated outcome.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_WriteSecured_AuthenticatedRoundTripParity()
{
@@ -445,7 +450,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
File.Exists(workerExecutablePath),
$"Live MXAccess worker executable was not found at {workerExecutablePath}. Build the worker or set {IntegrationTestEnvironment.LiveMxAccessWorkerExecutableVariableName}.");
// IntegrationTests-019: CLAUDE.md's credential-redaction rule covers every log
// CLAUDE.md's credential-redaction rule covers every log
// surface the test sees, not just the reply's DiagnosticMessage. Wire a buffering
// wrapper around output and route the worker stdout/stderr echo and the gateway
// ILogger sink through it so the post-run assertion covers the accumulated test
@@ -573,6 +578,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// assert the reply kind plus a non-INVALID_REQUEST protocol status, and log the
/// HResult for the record.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_NewComCommands_RoundTripWithRealReplies()
{
@@ -726,6 +732,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// to be non-empty and internally consistent (no crash, no dropped payload).
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_BufferedItem_AddsSetsIntervalAndAttemptsCapture()
{
@@ -908,6 +915,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// must observe the abnormal exit, transition the session, and surface a non-empty
/// fault description rather than hanging or crashing.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_AbnormalWorkerExit_MarksSessionFaulted()
{
@@ -981,7 +989,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
// message (they all begin with "Worker"); tighten to the pipe/disconnect/
// end-of-stream classifications that match THIS path, so a regression that
// routed an unrelated fault here would surface as a test failure rather
// than silently passing (see IntegrationTests-020). "heartbeat" is dropped
// than silently passing. "heartbeat" is dropped
// because HeartbeatGraceSeconds (15s) exceeds the StreamShutdownTimeout
// (10s) poll window, so a heartbeat-expired transition can never be
// observed inside this test.
@@ -991,7 +999,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
$"Fault description '{observedFault}' did not match a known abnormal-exit classification "
+ "(expected 'pipe disconnected' or 'end of stream' from WorkerClient's EndOfStream path).");
// IntegrationTests-021: also assert the StreamEvents call observed the fault
// Also assert the StreamEvents call observed the fault
// — the chain that puts the session into Faulted goes through ReadEventsAsync
// propagating a WorkerClientException into EventStreamService, which calls
// session.MarkFaulted. The gateway then maps the WorkerClientException to an
@@ -1037,7 +1045,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// test fails on a silent stream-task exception (the Write parity test relies on this so
/// stream-side defects in event delivery are visible). When <see langword="false"/>, all
/// cleanup exceptions are logged and swallowed so a real test-body assertion failure is not
/// masked by a shutdown timeout (the original IntegrationTests-004 fix).
/// masked by a shutdown timeout.
/// </param>
private async Task ShutDownAsync(
GatewayServiceFixture fixture,
@@ -1607,6 +1615,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// </summary>
/// <param name="sessionId">The session identifier.</param>
/// <param name="session">The session if found; otherwise null.</param>
/// <returns><see langword="true"/> if a session with the given id was found; otherwise <see langword="false"/>.</returns>
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
{
return _registry.TryGet(sessionId, out session);
@@ -1615,6 +1624,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// <summary>
/// Disposes the fixture resources and closes all sessions.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
foreach (GatewaySession session in _registry.Snapshot())
@@ -1685,6 +1695,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// Records the message and signals any pending waiter.
/// </summary>
/// <param name="message">The message to write.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task WriteAsync(T message)
{
lock (syncRoot)
@@ -1867,7 +1878,11 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
return workerProcess;
}
/// <inheritdoc />
/// <summary>
/// Waits for every recorded worker process to exit, up to the specified timeout per process.
/// </summary>
/// <param name="timeout">The maximum time to wait for each process.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task WaitForProcessesAsync(TimeSpan timeout)
{
foreach (TestWorkerProcess process in processes)
@@ -1947,7 +1962,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
process.Kill(entireProcessTree);
}
/// <inheritdoc />
/// <summary>Disposes the wrapped process.</summary>
public void Dispose()
{
process.Dispose();
@@ -1959,13 +1974,15 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// </summary>
private sealed class TestOutputLoggerProvider(ITestOutputHelper output) : ILoggerProvider
{
/// <inheritdoc />
/// <summary>Creates a logger that writes to the test output helper for the given category.</summary>
/// <param name="categoryName">Category name for the logger.</param>
/// <returns>The created logger.</returns>
public ILogger CreateLogger(string categoryName)
{
return new TestOutputLogger(output, categoryName);
}
/// <inheritdoc />
/// <summary>No resources to release; provided to satisfy <see cref="ILoggerProvider"/>.</summary>
public void Dispose()
{
}
@@ -1978,20 +1995,31 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
ITestOutputHelper output,
string categoryName) : ILogger
{
/// <inheritdoc />
/// <summary>Not supported; this test logger does not track scopes.</summary>
/// <typeparam name="TState">The type of the state to begin the scope for.</typeparam>
/// <param name="state">The identifier for the scope.</param>
/// <returns>Always <see langword="null"/>.</returns>
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull
{
return null;
}
/// <inheritdoc />
/// <summary>Determines whether the given log level is enabled.</summary>
/// <param name="logLevel">The log level to check.</param>
/// <returns><see langword="true"/> if <paramref name="logLevel"/> is at least <see cref="LogLevel.Information"/>; otherwise <see langword="false"/>.</returns>
public bool IsEnabled(LogLevel logLevel)
{
return logLevel >= LogLevel.Information;
}
/// <inheritdoc />
/// <summary>Writes the formatted log message and any exception to the test output helper.</summary>
/// <typeparam name="TState">The type of the object to be logged.</typeparam>
/// <param name="logLevel">The severity of the log entry.</param>
/// <param name="eventId">The event id associated with the log entry.</param>
/// <param name="state">The entry to be logged, which can be an object or a message string.</param>
/// <param name="exception">The exception related to this entry, if any.</param>
/// <param name="formatter">Function that creates the log message from <paramref name="state"/> and <paramref name="exception"/>.</param>
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
@@ -2015,7 +2043,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// <summary>
/// Buffering wrapper around an <see cref="ITestOutputHelper"/> that mirrors every line
/// written through it into a <see cref="StringBuilder"/> the test owns. The WriteSecured
/// parity test (IntegrationTests-019) uses this to make CLAUDE.md's "passwords and
/// parity test uses this to make CLAUDE.md's "passwords and
/// <c>WriteSecured</c> payloads must never reach logs" rule a property of the entire
/// test output stream — gateway <see cref="ILogger"/> entries (echoed via
/// <see cref="TestOutputLoggerProvider"/>), worker stdout/stderr (echoed via
@@ -10,14 +10,6 @@ namespace ZB.MOM.WW.MxGateway.Server.Alarms;
/// and composes the per-attribute subtag item addresses from the configured
/// subtag names.
/// </summary>
// NOTE: The exact subtag names and the canonical AlarmFullReference shape
// ("Galaxy!{area}.{reference}") are validated against a live Galaxy in the
// Task 17 live smoke test. The config Subtags block exists precisely so these
// names are not hard-coded here. The {area} is the alarm object's REAL Galaxy
// area discovered via gobject.area_gobject_id (the alarm group the native
// alarmmgr emits), giving exact reference parity with wnwrap. The configured
// Discovery.Area/DefaultArea is only the fallback for explicit IncludeAttributes
// entries, which carry no discovered area.
public sealed class AlarmWatchListResolver : IAlarmWatchListResolver
{
private const string ProviderLiteral = "Galaxy";
@@ -912,6 +912,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
/// <summary>Determines whether the alarm reference matches this subscriber's filter.</summary>
/// <param name="reference">The alarm reference to match.</param>
/// <returns><see langword="true"/> if the reference matches this subscriber's prefix filter.</returns>
public bool Matches(string reference)
{
return prefix.Length == 0 || reference.StartsWith(prefix, StringComparison.Ordinal);
@@ -46,6 +46,7 @@ public interface IGatewayAlarmService
/// </summary>
/// <param name="alarmFilterPrefix">Optional alarm-reference prefix scoping the feed.</param>
/// <param name="cancellationToken">Token that ends the subscription.</param>
/// <returns>An asynchronous stream of alarm feed messages.</returns>
IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
string? alarmFilterPrefix,
CancellationToken cancellationToken);
@@ -57,6 +58,7 @@ public interface IGatewayAlarmService
/// </summary>
/// <param name="request">The acknowledge request.</param>
/// <param name="cancellationToken">Token to cancel the call.</param>
/// <returns>The acknowledge reply, carrying the outcome in its protocol status.</returns>
Task<AcknowledgeAlarmReply> AcknowledgeAsync(
AcknowledgeAlarmRequest request,
CancellationToken cancellationToken);
@@ -9,11 +9,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
private const int MinimumMaxMessageBytes = 1024;
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
/// <summary>
/// Validates gateway configuration options.
/// </summary>
/// <param name="builder">The accumulator to record failures on.</param>
/// <param name="options">Gateway options to validate.</param>
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, GatewayOptions options)
{
ValidateAuthentication(options.Authentication, builder);
@@ -8,5 +8,6 @@ public interface IGatewayConfigurationProvider
/// <summary>
/// Returns the validated and effective gateway configuration.
/// </summary>
/// <returns>The validated and effective gateway configuration.</returns>
EffectiveGatewayConfiguration GetEffectiveConfiguration();
}
@@ -33,7 +33,7 @@ public sealed class SessionOptions
/// external subscriber, so a session whose only remaining subscriber is the dashboard
/// mirror still enters detach-grace. A value of <c>0</c> disables retention: the
/// session reverts to the original behavior of lingering only until its normal lease
/// expires. The reconnect/replay itself is implemented separately (Task 12); this
/// expires. The reconnect/replay itself is implemented separately; this
/// option controls retention and expiry only.
/// </summary>
/// <remarks>
@@ -38,7 +38,8 @@ public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
await ConnectHubAsync().ConfigureAwait(false);
}
/// <inheritdoc />
/// <summary>Disposes the SignalR hub connection created for this page, tolerating disposal-time errors.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_hub is not null)
@@ -6,6 +6,7 @@ public sealed class DashboardApiKeyAuthorization
{
/// <summary>Determines whether the user can manage API keys.</summary>
/// <param name="user">The authenticated user principal.</param>
/// <returns><see langword="true"/> if the user is authenticated and holds the <see cref="DashboardRoles.Admin"/> role; otherwise <see langword="false"/>.</returns>
public bool CanManage(ClaimsPrincipal user)
{
if (user.Identity?.IsAuthenticated != true)
@@ -20,17 +20,13 @@ public sealed class DashboardApiKeyManagementService(
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
private const string PepperUnavailableMarker = "pepper unavailable";
/// <summary>Determines whether the user can manage API keys.</summary>
/// <param name="user">The authenticated user principal.</param>
/// <inheritdoc />
public bool CanManage(ClaimsPrincipal user)
{
return authorization.CanManage(user);
}
/// <summary>Creates an API key asynchronously.</summary>
/// <param name="user">The authenticated user principal.</param>
/// <param name="request">The request payload.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <inheritdoc />
public async Task<DashboardApiKeyManagementResult> CreateAsync(
ClaimsPrincipal user,
DashboardApiKeyManagementRequest request,
@@ -82,10 +78,7 @@ public sealed class DashboardApiKeyManagementService(
}
}
/// <summary>Revokes an API key asynchronously.</summary>
/// <param name="user">The authenticated user principal.</param>
/// <param name="keyId">The API key identifier.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <inheritdoc />
public async Task<DashboardApiKeyManagementResult> RevokeAsync(
ClaimsPrincipal user,
string keyId,
@@ -120,10 +113,7 @@ public sealed class DashboardApiKeyManagementService(
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
}
/// <summary>Rotates an API key secret asynchronously.</summary>
/// <param name="user">The authenticated user principal.</param>
/// <param name="keyId">The API key identifier.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <inheritdoc />
public async Task<DashboardApiKeyManagementResult> RotateAsync(
ClaimsPrincipal user,
string keyId,
@@ -170,10 +160,7 @@ public sealed class DashboardApiKeyManagementService(
}
}
/// <summary>Deletes a revoked API key asynchronously.</summary>
/// <param name="user">The authenticated user principal.</param>
/// <param name="keyId">The API key identifier.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <inheritdoc />
public async Task<DashboardApiKeyManagementResult> DeleteAsync(
ClaimsPrincipal user,
string keyId,
@@ -242,15 +229,15 @@ public sealed class DashboardApiKeyManagementService(
/// <summary>
/// Emits the dashboard's own canonical <see cref="AuditEvent"/> for a <c>dashboard-*</c> op
/// directly through the best-effort <see cref="IAuditWriter"/> (Task 2.3 #6). This is in
/// directly through the best-effort <see cref="IAuditWriter"/>. This is in
/// addition to the <c>create/revoke/rotate-key</c> event that <see cref="ApiKeyAdminCommands"/>
/// emits via the canonical-forwarding <c>IApiKeyAuditStore</c> adapter — the doubled-audit
/// behaviour is preserved, both rows now land in the canonical <c>audit_event</c> store.
/// </summary>
/// <remarks>
/// Phase 3 (Actor = operator principal): <c>Actor</c> is the LDAP operator who performed the
/// <c>Actor</c> is the LDAP operator who performed the
/// action (resolved from the <paramref name="user"/> principal); <c>Target</c> is the managed
/// API key id. This fixes the pre-Phase-3 semantic gap where both fields held the keyId.
/// API key id. This fixes an earlier semantic gap where both fields held the keyId.
/// </remarks>
private async Task WriteDashboardAuditAsync(
ClaimsPrincipal user,
@@ -23,6 +23,7 @@ public sealed record DashboardAuthenticationResult(
/// Creates a successful authentication result.
/// </summary>
/// <param name="principal">Authenticated principal.</param>
/// <returns>A successful <see cref="DashboardAuthenticationResult"/> wrapping <paramref name="principal"/>.</returns>
public static DashboardAuthenticationResult Success(ClaimsPrincipal principal)
{
return new DashboardAuthenticationResult(true, principal, null);
@@ -32,6 +33,7 @@ public sealed record DashboardAuthenticationResult(
/// Creates a failed authentication result.
/// </summary>
/// <param name="failureMessage">Diagnostic message describing the failure.</param>
/// <returns>A failed <see cref="DashboardAuthenticationResult"/> carrying <paramref name="failureMessage"/>.</returns>
public static DashboardAuthenticationResult Fail(string failureMessage)
{
return new DashboardAuthenticationResult(false, null, failureMessage);
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// shaped exactly as before (see <see cref="CreatePrincipal"/>).
/// </summary>
/// <param name="ldapAuthService">Shared LDAP bind-then-search provider.</param>
/// <param name="roleMapper">Maps LDAP groups to dashboard roles (Task 1.1 seam).</param>
/// <param name="roleMapper">Maps LDAP groups to dashboard roles.</param>
/// <param name="logger">Logger for diagnostic, credential-free login outcomes.</param>
public sealed class DashboardAuthenticator(
ILdapAuthService ldapAuthService,
@@ -107,11 +107,11 @@ public sealed class DashboardAuthenticator(
[
// Keep NameIdentifier so any existing read-site that uses it continues to work.
new Claim(ClaimTypes.NameIdentifier, username),
// Canonical login-username claim (Task 1.5).
// Canonical login-username claim.
new Claim(ZbClaimTypes.Username, username),
// ZbClaimTypes.Name == ClaimTypes.Name — drives Identity.Name resolution.
new Claim(ZbClaimTypes.Name, displayName),
// Canonical display-name claim for cross-app consistency (Task 1.5).
// Canonical display-name claim for cross-app consistency.
new Claim(ZbClaimTypes.DisplayName, displayName),
];
@@ -6,6 +6,7 @@ public static class DashboardConnectionStringDisplay
{
/// <summary>Returns a sanitized Galaxy Repository connection string for display.</summary>
/// <param name="connectionString">The connection string to sanitize.</param>
/// <returns>A connection string with only display-safe fields, or a placeholder if it cannot be parsed.</returns>
public static string GalaxyRepositoryConnectionString(string connectionString)
{
try
@@ -17,7 +17,10 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class DashboardGroupRoleMapper(IOptions<GatewayOptions> options)
: IGroupRoleMapper<string>
{
/// <inheritdoc />
/// <summary>Maps the supplied dashboard LDAP group memberships to a dashboard role.</summary>
/// <param name="groups">The user's directory group memberships.</param>
/// <param name="ct">A token to request cancellation of the operation.</param>
/// <returns>The dashboard roles granted; scope is unused and always <see langword="null"/>.</returns>
public Task<GroupRoleMapping<string>> MapAsync(
IReadOnlyList<string> groups,
CancellationToken ct)
@@ -16,6 +16,7 @@ internal static class DashboardGroupRoleMapping
/// </summary>
/// <param name="groups">The collection of LDAP groups the user belongs to.</param>
/// <param name="groupToRole">The mapping from group names to dashboard role names.</param>
/// <returns>The distinct dashboard roles the groups map to, or an empty list if none match.</returns>
internal static IReadOnlyList<string> MapGroupsToRoles(
IEnumerable<string> groups,
IReadOnlyDictionary<string, string> groupToRole)
@@ -30,25 +31,6 @@ internal static class DashboardGroupRoleMapping
{
string normalizedGroup = group.Trim();
// Lookup precedence (Server-040): the full literal group string is
// tried first; only if that misses do we fall back to the leading
// RDN value (e.g. "GwAdmin" extracted from
// "ou=GwAdmin,ou=groups,..."). The map's comparer is
// OrdinalIgnoreCase (see DashboardOptions.GroupToRole), so e.g.
// "GwAdmin" and "gwadmin" both match.
//
// Review C1: with the shared ZB.MOM.WW.Auth.Ldap provider, groups
// arrive here already stripped to short RDN names (the library calls
// FirstRdnValue before returning them). So through the live login path
// the full-string branch only ever sees short names and the RDN
// fallback is effectively a no-op — they collapse to the same key.
// The fallback is retained because this mapping is also reachable
// directly via the IGroupRoleMapper<string> seam (DashboardGroupRoleMapper),
// where a caller could still pass a full DN. CONSEQUENCE: configuring a
// full-DN GroupToRole *key* (e.g. "ou=GwAdmin,ou=groups,...") is
// UNSUPPORTED with the shared library — the incoming group is a short
// name, so it will never equal a full-DN key. Keep GroupToRole keys as
// short group names.
if (groupToRole.TryGetValue(normalizedGroup, out string? mapped)
|| groupToRole.TryGetValue(ExtractFirstRdnValue(normalizedGroup), out mapped))
{
@@ -61,6 +43,7 @@ internal static class DashboardGroupRoleMapping
/// <summary>Extracts the first RDN value from a distinguished name.</summary>
/// <param name="distinguishedName">The LDAP distinguished name.</param>
/// <returns>The value of the first RDN component, or the input unchanged if it contains no '=' separator.</returns>
internal static string ExtractFirstRdnValue(string distinguishedName)
{
int equalsIndex = distinguishedName.IndexOf('=');
@@ -193,7 +193,8 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
}
}
/// <inheritdoc />
/// <summary>Closes the underlying gateway session, if one is open.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_disposed)
@@ -8,7 +8,7 @@ public static class DashboardRoles
{
/// <summary>
/// Read-write access: API-key CRUD, settings, any state-changing UI.
/// Canonical role value (Task 1.7); formerly <c>"Admin"</c> — pure value
/// Canonical role value; formerly <c>"Admin"</c> — pure value
/// rename, the operations this role authorizes are unchanged.
/// </summary>
public const string Admin = "Administrator";
@@ -25,6 +25,7 @@ public static class DashboardServiceCollectionExtensions
/// from the <c>MxGateway:Ldap</c> section. Also read to select the dashboard
/// authentication scheme via the <c>MxGateway:Dashboard:DisableLogin</c> dev flag.
/// </param>
/// <returns>The service collection, for chaining.</returns>
public static IServiceCollection AddGatewayDashboard(
this IServiceCollection services,
IConfiguration configuration)
@@ -6,6 +6,7 @@ public sealed record DashboardSessionAdminResult(
{
/// <summary>Creates a successful result with the given message.</summary>
/// <param name="message">The result message.</param>
/// <returns>A successful <see cref="DashboardSessionAdminResult"/>.</returns>
public static DashboardSessionAdminResult Success(string message)
{
return new DashboardSessionAdminResult(true, message);
@@ -13,6 +14,7 @@ public sealed record DashboardSessionAdminResult(
/// <summary>Creates a failed result with the given message.</summary>
/// <param name="message">The result message.</param>
/// <returns>A failed <see cref="DashboardSessionAdminResult"/>.</returns>
public static DashboardSessionAdminResult Fail(string message)
{
return new DashboardSessionAdminResult(false, message);
@@ -93,10 +93,6 @@ public sealed class DashboardSessionAdminService(
}
catch (Exception exception)
{
// Server-050: any non-SessionManagerException (e.g. an IOException or
// InvalidOperationException from the session DisposeAsync / pipe teardown
// path) used to propagate raw into Blazor's error boundary. Convert it to
// a friendly failure so the Razor pages see only DashboardSessionAdminResult.
_logger.LogWarning(
exception,
"Dashboard admin {Actor} close failed unexpectedly for session {SessionId}.",
@@ -162,12 +158,6 @@ public sealed class DashboardSessionAdminService(
}
catch (Exception exception)
{
// Server-050: any non-SessionManagerException (e.g. an IOException from
// worker pipe teardown surfacing through session.DisposeAsync, or an
// InvalidOperationException from a corrupted worker handle) used to
// propagate raw into Blazor's error boundary. Convert it to a friendly
// failure so the page renders the ResultMessage rather than the circuit
// error page.
_logger.LogWarning(
exception,
"Dashboard admin {Actor} kill failed unexpectedly for session {SessionId}.",
@@ -35,7 +35,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
// sequence means the breakdown is unchanged and can be reused — keeping the ~1s snapshot tick
// O(1) for Galaxy. The summary's cheap volatile fields (status, timestamps, last error, counts)
// are NOT memoized: the library mutates them in place at the same sequence on steady-state ticks
// and on refresh failure, so they are copied fresh on every snapshot (see Server-059).
// and on refresh failure, so they are copied fresh on every snapshot.
private GalaxyBreakdownCache? _galaxyBreakdownCache;
/// <summary>Initializes a new instance of the DashboardSnapshotService class.</summary>
@@ -72,10 +72,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
_logger = logger ?? NullLogger<DashboardSnapshotService>.Instance;
}
/// <summary>
/// Gets a current dashboard snapshot of gateway state.
/// </summary>
/// <returns>Dashboard snapshot.</returns>
/// <inheritdoc />
public DashboardSnapshot GetSnapshot()
{
DateTimeOffset generatedAt = _timeProvider.GetUtcNow();
@@ -133,11 +130,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
private sealed record GalaxyBreakdownCache(long Sequence, GalaxyObjectBreakdown Breakdown);
/// <summary>
/// Watches dashboard snapshots at regular intervals asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of dashboard snapshots.</returns>
/// <inheritdoc />
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// protector; no separate signing keys are configured.
/// </summary>
/// <remarks>
/// Server-043: this service is registered as a singleton in
/// This service is registered as a singleton in
/// <see cref="DashboardServiceCollectionExtensions.AddGatewayDashboard"/> and
/// is shared by two consumer scopes: <c>DashboardHubConnectionFactory</c>
/// (scoped, per-circuit; calls <see cref="Issue"/> from the cookie-authenticated
@@ -40,6 +40,7 @@ public sealed class HubTokenService
/// <summary>Issues a bearer token carrying the user's identity and roles.</summary>
/// <param name="user">The claims principal representing the user.</param>
/// <returns>The data-protected bearer token string.</returns>
public string Issue(ClaimsPrincipal user)
{
ArgumentNullException.ThrowIfNull(user);
@@ -52,6 +53,7 @@ public sealed class HubTokenService
/// <summary>Validates a token and returns the equivalent claims principal; null when invalid or expired.</summary>
/// <param name="token">The token string to validate.</param>
/// <returns>The reconstructed <see cref="ClaimsPrincipal"/>, or <see langword="null"/> when the token is missing, invalid, or expired.</returns>
public ClaimsPrincipal? Validate(string? token)
{
if (string.IsNullOrEmpty(token))
@@ -67,11 +69,6 @@ public sealed class HubTokenService
return null;
}
// Server-039: reject a token whose payload carries no caller
// identity. A null/empty Name AND NameIdentifier would otherwise
// produce a principal that satisfies IsAuthenticated and IsInRole
// checks without any associated user, because the AuthenticationType
// (the HubToken scheme) is non-empty.
if (string.IsNullOrEmpty(payload.Name) && string.IsNullOrEmpty(payload.NameIdentifier))
{
return null;
@@ -14,9 +14,7 @@ public sealed class DashboardEventBroadcaster(
IHubContext<EventsHub> hubContext,
ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster
{
/// <summary>Publishes an MX event to connected dashboard clients.</summary>
/// <param name="sessionId">The session identifier.</param>
/// <param name="mxEvent">The MX event to publish.</param>
/// <inheritdoc />
public void Publish(string sessionId, MxEvent mxEvent)
{
if (string.IsNullOrEmpty(sessionId) || mxEvent is null)
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// gateway process; clients listen via the hub.
/// </summary>
/// <remarks>
/// Server-042: <see cref="ExecuteAsync"/> wraps the snapshot subscription in
/// <see cref="ExecuteAsync"/> wraps the snapshot subscription in
/// a reconnect loop with a configurable retry delay (5s by default,
/// mirroring <see cref="AlarmsHubPublisher"/>). A transient failure inside
/// <see cref="IDashboardSnapshotService.WatchSnapshotsAsync"/> — e.g. a
@@ -27,7 +27,7 @@ public sealed class EventsHub : Hub
/// client.
/// </summary>
/// <remarks>
/// Server-038: in v1 the hub-level <see cref="AuthorizeAttribute"/>
/// In v1 the hub-level <see cref="AuthorizeAttribute"/>
/// (<c>HubClientsPolicy</c>) only checks that the caller carries one of
/// the dashboard roles (Admin or Viewer); both roles may subscribe to
/// any session id they choose. This is acceptable today because (a) the
@@ -43,6 +43,7 @@ public sealed class EventsHub : Hub
/// dedicated authorization policy applied to the hub method itself.
/// </remarks>
/// <param name="sessionId">Session id to subscribe the caller to.</param>
/// <returns>A task representing the subscription operation.</returns>
public Task SubscribeSession(string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId))
@@ -11,6 +11,7 @@ public interface IDashboardAuthenticator
/// <param name="username">Username to authenticate.</param>
/// <param name="password">Password to authenticate.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The authentication result.</returns>
Task<DashboardAuthenticationResult> AuthenticateAsync(
string? username,
string? password,
@@ -12,11 +12,13 @@ public interface IDashboardBrowseService
{
/// <summary>Returns root browse nodes (objects with no parent).</summary>
/// <param name="filter">Filter arguments forwarded to the projector.</param>
/// <returns>The root-level browse nodes and the cache sequence they were projected from.</returns>
BrowseLevelResult GetRoots(BrowseFilterArgs filter);
/// <summary>Returns the direct children of the given parent gobject id.</summary>
/// <param name="parentGobjectId">The Galaxy gobject id of the parent to expand.</param>
/// <param name="filter">Filter arguments forwarded to the projector.</param>
/// <returns>The child browse nodes for the requested parent and the cache sequence they were projected from.</returns>
BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter);
/// <summary>Current Galaxy cache sequence. Bumps after each successful refresh.</summary>
@@ -8,11 +8,13 @@ public interface IDashboardSnapshotService
/// <summary>
/// Gets the current dashboard snapshot.
/// </summary>
/// <returns>The current dashboard snapshot.</returns>
DashboardSnapshot GetSnapshot();
/// <summary>
/// Watches for changes to the dashboard state as an async enumerable.
/// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>An asynchronous stream of dashboard snapshots.</returns>
IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken);
}
@@ -12,9 +12,15 @@ public sealed class AuthStoreHealthCheck : IHealthCheck
{
private readonly AuthSqliteConnectionFactory _connectionFactory;
/// <summary>Initializes a new instance of the <see cref="AuthStoreHealthCheck"/> class.</summary>
/// <param name="connectionFactory">Factory used to open connections to the SQLite auth store.</param>
public AuthStoreHealthCheck(AuthSqliteConnectionFactory connectionFactory) =>
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
/// <summary>Verifies the SQLite auth store is reachable by executing a trivial query.</summary>
/// <param name="context">The health check context.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Healthy when the store responds; otherwise Unhealthy with the underlying exception.</returns>
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
@@ -19,6 +19,7 @@ public static class GatewayLogRedactor
/// Determines whether a command method bears credentials.
/// </summary>
/// <param name="commandMethod">The command method name to check.</param>
/// <returns><see langword="true"/> if the command method carries credentials; otherwise <see langword="false"/>.</returns>
public static bool IsCredentialBearingCommand(string? commandMethod)
{
return commandMethod is not null
@@ -29,6 +30,7 @@ public static class GatewayLogRedactor
/// Redacts the API key secret portion of a Bearer authorization header.
/// </summary>
/// <param name="authorizationHeader">The authorization header value to redact.</param>
/// <returns>The header with the secret portion redacted, or the original value when it is null, blank, or not a Bearer header.</returns>
public static string? RedactApiKey(string? authorizationHeader)
{
if (string.IsNullOrWhiteSpace(authorizationHeader))
@@ -62,6 +64,7 @@ public static class GatewayLogRedactor
/// Redacts the client identity if it contains an API key.
/// </summary>
/// <param name="clientIdentity">The client identity string to redact.</param>
/// <returns>The redacted client identity, or the original value when it contains no API key.</returns>
public static string? RedactClientIdentity(string? clientIdentity)
{
if (string.IsNullOrWhiteSpace(clientIdentity))
@@ -80,6 +83,7 @@ public static class GatewayLogRedactor
/// <param name="commandMethod">The command method name to check for credentials.</param>
/// <param name="value">The command value to redact.</param>
/// <param name="valueLoggingEnabled">Whether value logging is enabled.</param>
/// <returns>The redacted placeholder, the original value, or <see langword="null"/> when <paramref name="value"/> is null.</returns>
public static object? RedactCommandValue(
string? commandMethod,
object? value,
@@ -8,6 +8,7 @@ public sealed record GatewayLogScope(
string? ClientIdentity = null)
{
/// <summary>Converts the log scope to a dictionary with redacted sensitive fields.</summary>
/// <returns>A dictionary containing only the scope's present fields, with sensitive values redacted.</returns>
public IReadOnlyDictionary<string, object?> ToDictionary()
{
Dictionary<string, object?> values = [];
@@ -19,6 +19,7 @@ public static class GatewayRequestLoggingMiddlewareExtensions
/// <summary>Adds gateway request logging scope middleware that reads correlation headers and redacts sensitive data.</summary>
/// <param name="app">Application builder.</param>
/// <returns>The same <see cref="IApplicationBuilder"/>, for chaining.</returns>
public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app)
{
ArgumentNullException.ThrowIfNull(app);
@@ -13,13 +13,10 @@ public sealed class EventStreamService(
IOptions<GatewayOptions> options,
GatewayMetrics metrics) : IEventStreamService
{
/// <summary>
/// Streams events from a session to the client asynchronously.
/// </summary>
/// <inheritdoc />
/// <remarks>
/// <para>
/// Task 4 rewired this from a per-RPC channel that drained the session directly
/// to reading the subscriber's lease channel fed by the session's single
/// This reads the subscriber's lease channel fed by the session's single
/// <see cref="SessionEventDistributor"/> pump. The pump owns the single drain of
/// the worker event stream and the worker→public mapping (mirroring the former
/// <c>ProduceEventsAsync</c>); this loop is the per-subscriber boundary that
@@ -27,33 +24,28 @@ public sealed class EventStreamService(
/// and the backpressure/overflow policy.
/// </para>
/// <para>
/// Task 6 moved the dashboard mirror OFF this per-RPC loop. The dashboard is now a
/// The dashboard mirror runs OFF this per-RPC loop. The dashboard is a
/// first-class internal subscriber on the session's
/// <see cref="SessionEventDistributor"/> (see <c>GatewaySession.StartDashboardMirror</c>),
/// so it receives session events even when no gRPC client is streaming. This loop no
/// longer mirrors to the dashboard. One deliberate consequence: the dashboard now sees
/// so it receives session events even when no gRPC client is streaming. This loop
/// does not mirror to the dashboard. One deliberate consequence: the dashboard sees
/// RAW session events, not the per-gRPC-subscriber <c>AfterWorkerSequence</c>-filtered
/// view this loop applies — the dashboard is a separate LDAP-authenticated monitoring
/// view that should see the session's full event activity (per-session dashboard ACL is
/// the separate Task 18).
/// view that should see the session's full event activity.
/// </para>
/// <para>
/// Overflow handling (Task 5): the distributor's per-subscriber channel is bounded
/// Overflow handling: the distributor's per-subscriber channel is bounded
/// and the pump writes non-blocking. When this subscriber's channel is full the pump
/// applies the per-subscriber backpressure policy and completes this subscriber's
/// channel with a <see cref="SessionManagerException"/>
/// (<see cref="SessionManagerErrorCode.EventQueueOverflow"/>). That terminal fault
/// surfaces here when the reader's <c>MoveNextAsync</c> throws, and — like the
/// pre-epic per-RPC overflow — it propagates to the gRPC client unchanged. The
/// overflow metric, and (in the legacy single-subscriber FailFast case) the session
/// fault + fault metric, are recorded by the distributor's overflow handler so the
/// session, the pump, and other subscribers are isolated from this subscriber's
/// slowness.
/// surfaces here when the reader's <c>MoveNextAsync</c> throws, and it propagates to
/// the gRPC client unchanged. The overflow metric, and (in the legacy
/// single-subscriber FailFast case) the session fault + fault metric, are recorded by
/// the distributor's overflow handler so the session, the pump, and other subscribers
/// are isolated from this subscriber's slowness.
/// </para>
/// </remarks>
/// <param name="request">Stream events request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of MX events.</returns>
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
@@ -72,7 +64,7 @@ public sealed class EventStreamService(
// the session's own SessionEventStreaming.AllowMultipleEventSubscribers field — the
// same source the distributor uses — so the two cannot diverge.
//
// Reconnect/resume (Task 12): when AfterWorkerSequence > 0 the client is resuming, so
// Reconnect/resume: when AfterWorkerSequence > 0 the client is resuming, so
// attach via the replay variant that atomically snapshots the replay ring AND registers
// the live subscriber under one lock. That single critical section is the crux of the
// no-gap/no-duplicate handoff: every replayed event has sequence <= LiveResumeSequence
@@ -81,7 +73,7 @@ public sealed class EventStreamService(
// channel is dropped exactly once, while no newer event is skipped. See
// SessionEventDistributor.RegisterWithReplay for the full argument.
//
// AfterWorkerSequence == 0 (fresh stream, not a resume) keeps the pre-Task-12 behavior:
// AfterWorkerSequence == 0 (fresh stream, not a resume) keeps the original behavior:
// a plain attach, no replay, no sentinel, and the live filter watermark stays 0.
ulong afterWorkerSequence = request.AfterWorkerSequence;
IEventSubscriberLease subscriber;
@@ -157,7 +149,7 @@ public sealed class EventStreamService(
{
// The distributor pump completes every subscriber channel with the source
// fault when the worker event stream terminates abnormally; that surfaces
// here. Mirror the pre-Task-4 ProduceEventsAsync behavior: fault the
// here. Mirror the original ProduceEventsAsync behavior: fault the
// session and record the metric, then propagate the terminal fault to the
// gRPC client.
session.MarkFaulted(workerException.Message);
@@ -175,7 +167,7 @@ public sealed class EventStreamService(
// Queue-depth gauge tracks events the pump has fanned into this subscriber's
// channel but the client has not yet consumed — the same "buffered, not yet
// delivered" quantity the pre-Task-4 per-RPC channel reported. The bounded
// delivered" quantity the original per-RPC channel reported. The bounded
// subscriber channel supports counting, so reconcile the gauge to the current
// backlog; falling back to a no-op delta if a channel ever cannot count.
int backlog = subscriber.Reader.CanCount ? subscriber.Reader.Count : streamQueueDepth;
@@ -12,6 +12,7 @@ public interface IEventStreamService
/// </summary>
/// <param name="request">Request payload.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The events emitted for the requested session.</returns>
IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CancellationToken cancellationToken);
@@ -164,15 +164,6 @@ public sealed class MxAccessGatewayService(
}
/// <inheritdoc />
/// <remarks>
/// Surfaces the public AcknowledgeAlarm RPC. Acknowledgement is
/// session-less: the gateway routes it through the always-on
/// <see cref="IGatewayAlarmService"/> monitor session. An
/// <c>alarm_full_reference</c> that parses as a canonical GUID forwards
/// to <c>AcknowledgeAlarmCommand</c>; a <c>Provider!Group.Tag</c>
/// reference forwards to <c>AcknowledgeAlarmByNameCommand</c>; anything
/// else returns an <c>InvalidRequest</c> diagnostic in the reply.
/// </remarks>
public override async Task<AcknowledgeAlarmReply> AcknowledgeAlarm(
AcknowledgeAlarmRequest request,
ServerCallContext context)
@@ -195,14 +186,6 @@ public sealed class MxAccessGatewayService(
}
/// <inheritdoc />
/// <remarks>
/// Surfaces the public StreamAlarms RPC — the session-less central
/// alarm feed. The stream opens with one <c>active_alarm</c> per
/// currently-active alarm, then a single <c>snapshot_complete</c>, then
/// a <c>transition</c> for every subsequent change. Served by the
/// gateway's always-on <see cref="IGatewayAlarmService"/> monitor; any
/// number of clients fan out from the single monitor.
/// </remarks>
public override async Task StreamAlarms(
StreamAlarmsRequest request,
IServerStreamWriter<AlarmFeedMessage> responseStream,
@@ -226,12 +209,6 @@ public sealed class MxAccessGatewayService(
}
/// <inheritdoc />
/// <remarks>
/// Snapshot of the active-alarm cache maintained by the gateway's
/// always-on alarm monitor. Streams one <see cref="ActiveAlarmSnapshot"/>
/// per currently-active alarm and completes — no transitions are
/// emitted. Use <c>StreamAlarms</c> for a live transition feed.
/// </remarks>
public override async Task QueryActiveAlarms(
QueryActiveAlarmsRequest request,
IServerStreamWriter<ActiveAlarmSnapshot> responseStream,
@@ -23,6 +23,7 @@ public sealed class MxAccessGrpcMapper
/// Maps a gRPC MX command request to a worker command.
/// </summary>
/// <param name="request">Request payload.</param>
/// <returns>The mapped <see cref="WorkerCommand"/> ready for worker dispatch.</returns>
public WorkerCommand MapCommand(MxCommandRequest request)
{
ArgumentNullException.ThrowIfNull(request);
@@ -39,6 +40,7 @@ public sealed class MxAccessGrpcMapper
/// Maps a worker command reply to a gRPC MX command reply.
/// </summary>
/// <param name="reply">Worker command reply.</param>
/// <returns>The mapped <see cref="MxCommandReply"/>, or a protocol-violation reply if the worker reply carried no public payload.</returns>
public MxCommandReply MapCommandReply(WorkerCommandReply reply)
{
ArgumentNullException.ThrowIfNull(reply);
@@ -58,6 +60,7 @@ public sealed class MxAccessGrpcMapper
/// Maps a worker event to a gRPC MX event.
/// </summary>
/// <param name="workerEvent">Worker event to map.</param>
/// <returns>The mapped <see cref="MxEvent"/>, or an unspecified-family event if the worker event carried no public payload.</returns>
public MxEvent MapEvent(WorkerEvent workerEvent)
{
ArgumentNullException.ThrowIfNull(workerEvent);
@@ -73,6 +76,7 @@ public sealed class MxAccessGrpcMapper
/// Creates an OK protocol status.
/// </summary>
/// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.Ok"/>.</returns>
public static ProtocolStatus Ok(string message = "OK")
{
return new ProtocolStatus
@@ -86,6 +90,7 @@ public sealed class MxAccessGrpcMapper
/// Creates an InvalidRequest protocol status.
/// </summary>
/// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.InvalidRequest"/>.</returns>
public static ProtocolStatus InvalidRequest(string message)
{
return new ProtocolStatus
@@ -99,6 +104,7 @@ public sealed class MxAccessGrpcMapper
/// Creates a SessionNotFound protocol status.
/// </summary>
/// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.SessionNotFound"/>.</returns>
public static ProtocolStatus SessionNotFound(string message)
{
return new ProtocolStatus
@@ -112,6 +118,7 @@ public sealed class MxAccessGrpcMapper
/// Creates a SessionNotReady protocol status.
/// </summary>
/// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.SessionNotReady"/>.</returns>
public static ProtocolStatus SessionNotReady(string message)
{
return new ProtocolStatus
@@ -125,6 +132,7 @@ public sealed class MxAccessGrpcMapper
/// Creates a WorkerUnavailable protocol status.
/// </summary>
/// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.WorkerUnavailable"/>.</returns>
public static ProtocolStatus WorkerUnavailable(string message)
{
return new ProtocolStatus
@@ -138,6 +146,7 @@ public sealed class MxAccessGrpcMapper
/// Creates a Timeout protocol status.
/// </summary>
/// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.Timeout"/>.</returns>
public static ProtocolStatus Timeout(string message)
{
return new ProtocolStatus
@@ -151,6 +160,7 @@ public sealed class MxAccessGrpcMapper
/// Creates a Canceled protocol status.
/// </summary>
/// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.Canceled"/>.</returns>
public static ProtocolStatus Canceled(string message)
{
return new ProtocolStatus
@@ -164,6 +174,7 @@ public sealed class MxAccessGrpcMapper
/// Creates a ProtocolViolation protocol status.
/// </summary>
/// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.ProtocolViolation"/>.</returns>
public static ProtocolStatus ProtocolViolation(string message)
{
return new ProtocolStatus
@@ -89,7 +89,7 @@ public sealed class GatewayMetrics : IDisposable
/// (via <c>InternalsVisibleTo</c>) so a <see cref="MeterListener"/> can filter measurements by
/// <see cref="object.ReferenceEquals"/> against this specific instance rather than by the
/// process-shared <see cref="MeterName"/>, which would cross-talk between parallel tests that
/// each build their own <see cref="GatewayMetrics"/> (Tests-027).
/// each build their own <see cref="GatewayMetrics"/>.
/// </summary>
internal Meter Meter => _meter;
@@ -413,6 +413,7 @@ public sealed class GatewayMetrics : IDisposable
};
/// <summary>Sets the current alarm provider-mode gauge without recording a switch (e.g. startup baseline).</summary>
/// <param name="mode">The alarm provider mode value to record.</param>
public void SetAlarmProviderMode(int mode)
{
lock (_syncRoot) { _alarmProviderMode = mode; }
@@ -421,6 +422,7 @@ public sealed class GatewayMetrics : IDisposable
/// <summary>
/// Returns a snapshot of all current metric values.
/// </summary>
/// <returns>The current metrics snapshot.</returns>
public GatewayMetricsSnapshot GetSnapshot()
{
lock (_syncRoot)
@@ -19,7 +19,13 @@ public sealed class CanonicalAuditWriter(
SqliteCanonicalAuditStore store,
ILogger<CanonicalAuditWriter> logger) : IAuditWriter
{
/// <inheritdoc />
/// <summary>
/// Persists a canonical audit event to the underlying <see cref="SqliteCanonicalAuditStore"/>.
/// Any failure is caught, logged, and swallowed rather than propagated to the caller.
/// </summary>
/// <param name="auditEvent">The canonical audit event to persist.</param>
/// <param name="cancellationToken">Token to cancel the underlying store write.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(auditEvent);
@@ -43,7 +43,10 @@ public sealed class CanonicalForwardingApiKeyAuditStore(
/// <summary>The library's keyless schema-init event type.</summary>
private const string InitDbEventType = "init-db";
/// <inheritdoc />
/// <summary>Canonicalizes a library-emitted API-key audit entry onto <see cref="AuditEvent"/> and writes it via <see cref="IAuditWriter"/>.</summary>
/// <param name="entry">The library's API-key audit entry to forward.</param>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(entry);
@@ -71,7 +74,10 @@ public sealed class CanonicalForwardingApiKeyAuditStore(
await auditWriter.WriteAsync(auditEvent, ct).ConfigureAwait(false);
}
/// <inheritdoc />
/// <summary>Reads back the most recent entries from the canonical audit store and maps each one to an <see cref="ApiKeyAuditEntry"/>.</summary>
/// <param name="limit">The maximum number of entries to return.</param>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>Up to <paramref name="limit"/> recent API-key audit entries.</returns>
public async Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
{
IReadOnlyList<AuditEvent> events = await store.ListRecentAsync(limit, ct).ConfigureAwait(false);
@@ -43,6 +43,7 @@ public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connec
/// <summary>Inserts a canonical audit event into the <c>audit_event</c> table.</summary>
/// <param name="auditEvent">The canonical event to persist.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(auditEvent);
@@ -79,6 +80,7 @@ public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connec
/// <summary>Returns the most recent canonical audit events, newest first.</summary>
/// <param name="limit">Maximum number of events to return.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The most recent audit events, up to <paramref name="limit"/>, newest first.</returns>
public async Task<IReadOnlyList<AuditEvent>> ListRecentAsync(int limit, CancellationToken cancellationToken)
{
if (limit <= 0)
@@ -26,6 +26,7 @@ public sealed class ApiKeyAdminCliRunner(ApiKeyAdminCommands commands)
/// <param name="command">API key administration command to execute.</param>
/// <param name="output">Text writer for command output.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The process exit code for the executed command.</returns>
public async Task<int> RunAsync(
ApiKeyAdminCommand command,
TextWriter output,
@@ -6,6 +6,7 @@ public sealed record ApiKeyAdminParseResult(
string? Error)
{
/// <summary>Returns a result indicating the input was not an API key command.</summary>
/// <returns>A result with <see cref="IsApiKeyCommand"/> set to <see langword="false"/>.</returns>
public static ApiKeyAdminParseResult NotApiKeyCommand()
{
return new ApiKeyAdminParseResult(false, null, null);
@@ -13,6 +14,7 @@ public sealed record ApiKeyAdminParseResult(
/// <summary>Returns a successful parse result with the parsed API key command.</summary>
/// <param name="command">Parsed API key administration command.</param>
/// <returns>A successful <see cref="ApiKeyAdminParseResult"/> wrapping <paramref name="command"/>.</returns>
public static ApiKeyAdminParseResult Success(ApiKeyAdminCommand command)
{
return new ApiKeyAdminParseResult(true, command, null);
@@ -20,6 +22,7 @@ public sealed record ApiKeyAdminParseResult(
/// <summary>Returns a parse result with the specified error message.</summary>
/// <param name="error">Error message describing the parse failure.</param>
/// <returns>A failed <see cref="ApiKeyAdminParseResult"/> carrying <paramref name="error"/>.</returns>
public static ApiKeyAdminParseResult Fail(string error)
{
return new ApiKeyAdminParseResult(true, null, error);
@@ -12,6 +12,7 @@ public static class ApiKeyConstraintSerializer
/// <summary>Serializes API key constraints to JSON, or returns null if the constraints are empty.</summary>
/// <param name="constraints">The constraints to serialize.</param>
/// <returns>The serialized JSON, or <see langword="null"/> when the constraints are empty.</returns>
public static string? Serialize(ApiKeyConstraints constraints)
{
ArgumentNullException.ThrowIfNull(constraints);
@@ -20,6 +21,7 @@ public static class ApiKeyConstraintSerializer
/// <summary>Deserializes API key constraints from JSON, or returns empty constraints if JSON is null or whitespace.</summary>
/// <param name="json">The JSON string to deserialize.</param>
/// <returns>The deserialized constraints, or <see cref="ApiKeyConstraints.Empty"/> when <paramref name="json"/> is null/whitespace.</returns>
public static ApiKeyConstraints Deserialize(string? json)
{
if (string.IsNullOrWhiteSpace(json))
@@ -66,10 +66,6 @@ public static class AuthStoreServiceCollectionExtensions
// migrator and the migration hosted service.
services.AddZbApiKeyAuth(effectiveConfig, AuthenticationSectionPath);
// Canonical audit (Task 2.3 — DEEP adopt ZB.MOM.WW.Audit). All MxGateway audit flows as a
// canonical AuditEvent through the library IAuditWriter, persisted in a NEW gateway-owned
// audit_event table that lives in the SAME SQLite DB file as the api-key stores (it reuses
// the library's AuthSqliteConnectionFactory, registered by AddZbApiKeyAuth above).
services.AddSingleton(sp =>
new SqliteCanonicalAuditStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
// Resolve the logger defensively: the production host always registers ILogger<T>, but the
@@ -16,10 +16,7 @@ public sealed class ConstraintEnforcer(
IGalaxyHierarchyCache cache,
IAuditWriter auditWriter) : IConstraintEnforcer
{
/// <summary>Checks read constraints on a tag address.</summary>
/// <param name="identity">The API key identity to check constraints for.</param>
/// <param name="tagAddress">Tag address to validate.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <inheritdoc />
public Task<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity,
string tagAddress,
@@ -34,12 +31,7 @@ public sealed class ConstraintEnforcer(
return Task.FromResult(CheckReadTarget(constraints, tagAddress));
}
/// <summary>Checks read constraints on a server and item handle.</summary>
/// <param name="identity">The API key identity to check constraints for.</param>
/// <param name="session">The gateway session containing handle registrations.</param>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <inheritdoc />
public Task<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
@@ -61,12 +53,7 @@ public sealed class ConstraintEnforcer(
return Task.FromResult(CheckReadTarget(constraints, registration.TagAddress));
}
/// <summary>Checks write constraints on a server and item handle.</summary>
/// <param name="identity">The API key identity to check constraints for.</param>
/// <param name="session">The gateway session containing handle registrations.</param>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <inheritdoc />
public Task<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
@@ -115,18 +102,7 @@ public sealed class ConstraintEnforcer(
return Task.FromResult<ConstraintFailure?>(null);
}
/// <summary>Records a constraint denial audit entry.</summary>
/// <param name="identity">The API key identity that was denied.</param>
/// <param name="commandKind">The command type (e.g., read, write).</param>
/// <param name="target">The target being accessed (tag address or handle).</param>
/// <param name="failure">The constraint failure details.</param>
/// <param name="correlationId">
/// The per-request client correlation id, if any. Persisted as the audit record's typed
/// <c>CorrelationId</c> when it parses as a GUID; a non-GUID value leaves that column null.
/// The raw string is always preserved in <c>DetailsJson["clientCorrelationId"]</c> so a
/// non-GUID id (e.g. from Rust/Python/Java clients) is never silently lost.
/// </param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <inheritdoc />
public async Task RecordDenialAsync(
ApiKeyIdentity? identity,
string commandKind,
@@ -135,8 +111,8 @@ public sealed class ConstraintEnforcer(
string? correlationId,
CancellationToken cancellationToken)
{
// Emit a canonical Denied AuditEvent directly through the best-effort IAuditWriter
// (Task 2.3 #6): structured Target ("<commandKind>:<target>") and a richer DetailsJson
// Emit a canonical Denied AuditEvent directly through the best-effort IAuditWriter:
// structured Target ("<commandKind>:<target>") and a richer DetailsJson
// envelope carrying constraint/message/commandKind/target.
AuditEvent auditEvent = new()
{
@@ -8,7 +8,9 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
public sealed class GatewayBrowseScopeProvider(IGatewayRequestIdentityAccessor identityAccessor)
: IGalaxyBrowseScopeProvider
{
/// <inheritdoc />
/// <summary>Resolves the Galaxy browse subtrees the calling API key is scoped to, from the ambient request identity.</summary>
/// <param name="context">The gRPC server call context for the in-flight browse request.</param>
/// <returns>The caller's configured browse subtrees, or <see langword="null"/>/empty when the caller is unscoped (full hierarchy).</returns>
public IReadOnlyList<string>? ResolveBrowseSubtrees(ServerCallContext context)
{
// Invariant: the caller identity is the ambient one pushed by
@@ -6,12 +6,10 @@ public sealed class GatewayRequestIdentityAccessor : IGatewayRequestIdentityAcce
{
private readonly AsyncLocal<ApiKeyIdentity?> currentIdentity = new();
/// <summary>Gets the current request identity.</summary>
/// <inheritdoc />
public ApiKeyIdentity? Current => currentIdentity.Value;
/// <summary>Sets the current identity and returns a scope that restores the previous identity.</summary>
/// <param name="identity">The identity to push.</param>
/// <returns>Disposable scope.</returns>
/// <inheritdoc />
public IDisposable Push(ApiKeyIdentity identity)
{
ArgumentNullException.ThrowIfNull(identity);
@@ -13,6 +13,7 @@ public static class GrpcAuthorizationServiceCollectionExtensions
/// Registers gRPC authorization middleware and scope resolver.
/// </summary>
/// <param name="services">Service collection to register dependencies into.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddGatewayGrpcAuthorization(this IServiceCollection services)
{
services.AddSingleton<GatewayGrpcScopeResolver>();
@@ -9,6 +9,7 @@ public interface IConstraintEnforcer
/// <param name="identity">The API key identity.</param>
/// <param name="tagAddress">Tag address to check.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The constraint failure details if denied; otherwise null.</returns>
Task<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity,
string tagAddress,
@@ -20,6 +21,7 @@ public interface IConstraintEnforcer
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The constraint failure details if denied; otherwise null.</returns>
Task<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
@@ -33,6 +35,7 @@ public interface IConstraintEnforcer
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The constraint failure details if denied; otherwise null.</returns>
Task<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
@@ -50,6 +53,7 @@ public interface IConstraintEnforcer
/// <c>CorrelationId</c> when it parses as a GUID; otherwise left null.
/// </param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task RecordDenialAsync(
ApiKeyIdentity? identity,
string commandKind,
@@ -10,5 +10,6 @@ public interface IGatewayRequestIdentityAccessor
/// <summary>Temporarily pushes an identity onto the scope stack, returning a handle to restore the previous state.</summary>
/// <param name="identity">API key identity to push.</param>
/// <returns>A disposable handle that restores the previous identity when disposed.</returns>
IDisposable Push(ApiKeyIdentity identity);
}
@@ -16,6 +16,8 @@ public static class KestrelTlsInspector
/// <c>Certificate:Thumbprint</c>), meaning the gateway must supply a
/// generated fallback certificate.
/// </summary>
/// <param name="configuration">The application configuration to inspect for Kestrel certificate settings.</param>
/// <returns><see langword="true"/> if the gateway must supply a generated fallback certificate; otherwise <see langword="false"/>.</returns>
public static bool RequiresGeneratedCertificate(IConfiguration configuration)
{
// A Kestrel default certificate applies to every endpoint that lacks its own.
@@ -20,6 +20,10 @@ public sealed class SelfSignedCertificateProvider
private readonly ILogger<SelfSignedCertificateProvider> _logger;
private readonly TimeProvider _timeProvider;
/// <summary>Initializes a new instance of the <see cref="SelfSignedCertificateProvider"/> class.</summary>
/// <param name="options">TLS options controlling certificate generation and persistence.</param>
/// <param name="logger">Logger for certificate generation and load diagnostics.</param>
/// <param name="timeProvider">Time provider used for certificate validity and expiration checks.</param>
public SelfSignedCertificateProvider(
TlsOptions options,
ILogger<SelfSignedCertificateProvider> logger,
@@ -31,6 +35,7 @@ public sealed class SelfSignedCertificateProvider
}
/// <summary>Creates a fresh in-memory ECDSA P-256 self-signed certificate.</summary>
/// <returns>The generated self-signed certificate.</returns>
public X509Certificate2 GenerateCertificate()
{
using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
@@ -89,6 +94,7 @@ public sealed class SelfSignedCertificateProvider
/// <summary>Loads the persisted certificate, regenerating when missing,
/// expired (and allowed), or unreadable.</summary>
/// <returns>The loaded or newly generated and persisted certificate.</returns>
public X509Certificate2 LoadOrCreate()
{
string path = _options.SelfSignedCertPath;
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Sessions;
/// <summary>
/// The result of a reconnect/resume attach
/// (<see cref="GatewaySession.AttachEventSubscriberWithReplay"/>, Task 12): the live
/// (<see cref="GatewaySession.AttachEventSubscriberWithReplay"/>): the live
/// subscriber lease plus the replay batch and resume watermarks snapshotted atomically
/// with the registration, so the replay→live handoff has no gap and no duplicate.
/// </summary>
@@ -28,8 +28,7 @@ public sealed class GatewaySession
// True once at least one external subscriber attached SUCCESSFULLY. Detach-grace's
// "last subscriber dropped" stamp (see DetachEventSubscriber) is gated on this so a
// FAILED first attach — which still runs the rollback DetachEventSubscriber from the
// attach catch path — does not push a never-subscribed session into the grace window
// (Server-055).
// attach catch path — does not push a never-subscribed session into the grace window.
private bool _everHadEventSubscriber;
private SessionEventDistributor? _eventDistributor;
private bool _eventDistributorStarted;
@@ -115,7 +114,7 @@ public sealed class GatewaySession
/// </param>
/// <param name="detachGrace">
/// Retention window kept after the last external (gRPC) event subscriber drops, so a
/// client can reconnect (Task 12). When the window is positive and the active external
/// client can reconnect. When the window is positive and the active external
/// subscriber count falls to zero, the session stays <see cref="SessionState.Ready"/>
/// and records a detached timestamp; the lease monitor closes it once the window
/// elapses with no subscriber having re-attached. <see cref="TimeSpan.Zero"/> (the
@@ -389,7 +388,7 @@ public sealed class GatewaySession
/// session by walking it back to <see cref="SessionState.Ready"/> or any earlier
/// state. Both close-related writes (<c>Closing</c> and <c>Closed</c>) go through
/// <c>_syncRoot</c> just like every other state read/write, closing the split-lock
/// race called out in Server-015.
/// race.
/// </remarks>
public void TransitionTo(SessionState nextState)
{
@@ -420,13 +419,13 @@ public sealed class GatewaySession
/// Transitions the session to the Ready state.
/// </summary>
/// <remarks>
/// On becoming Ready the session starts its internal dashboard mirror (Task 6) when a
/// On becoming Ready the session starts its internal dashboard mirror when a
/// dashboard broadcaster was supplied. The mirror registers an internal subscriber on
/// the distributor and starts the pump <em>before</em> any gRPC client attaches, so the
/// dashboard EventsHub receives session events even with no gRPC subscriber streaming —
/// fixing the "dark feed" where the dashboard only saw events while a gRPC client was
/// actively streaming. Registering the internal subscriber BEFORE
/// <see cref="SessionEventDistributor.StartAsync"/> also avoids the Task 4 hazard where
/// <see cref="SessionEventDistributor.StartAsync"/> also avoids the hazard where
/// starting the pump at Ready with zero subscribers drained a fast-completing worker
/// stream into nothing and left a later subscriber hanging: there is now always a
/// subscriber (the dashboard one) registered before the pump starts.
@@ -444,7 +443,7 @@ public sealed class GatewaySession
// single drain to first-attach preserves that "events start flowing on subscribe"
// behavior and avoids draining a fast-completing source into the void before any
// subscriber exists. The source factory mirrors the mapping/ordering/start that
// EventStreamService.ProduceEventsAsync used before Task 4: it drains the worker event
// EventStreamService.ProduceEventsAsync previously used: it drains the worker event
// stream in source order and maps each WorkerEvent to the public MxEvent with the same
// mapper, with no skip/filter — per-RPC filtering (e.g. AfterWorkerSequence) stays at the
// subscriber boundary in EventStreamService. Returns a registered lease atomically with
@@ -462,7 +461,7 @@ public sealed class GatewaySession
return lease;
}
// Reconnect/resume variant of StartDistributorAndRegister (Task 12). Snapshots the replay
// Reconnect/resume variant of StartDistributorAndRegister. Snapshots the replay
// ring for events newer than afterSequence AND registers the live subscriber atomically
// under the distributor's replay lock, so the replay→live handoff has no gap and no
// duplicate (see SessionEventDistributor.RegisterWithReplay). The pump is started after
@@ -542,7 +541,7 @@ public sealed class GatewaySession
// once when the session becomes Ready (idempotent). The internal subscriber is registered
// BEFORE the pump starts (see StartDistributorAndRegister / EnsureDistributorCreated), so
// a subscriber is always present at pump start — the dashboard receives events with no
// gRPC subscriber attached, and the Task 4 "zero-subscriber drain into the void" hang
// gRPC subscriber attached, and the "zero-subscriber drain into the void" hang
// cannot occur. No-op when no dashboard broadcaster was supplied (unit tests).
//
// Race-safety (Issue 1): _dashboardMirrorLease and _dashboardMirrorTask are published
@@ -605,14 +604,14 @@ public sealed class GatewaySession
}
// Reads the internal dashboard subscriber's channel and publishes each RAW fanned event
// to the dashboard broadcaster. The dashboard is a first-class distributor subscriber
// (Task 6), so it sees the session's full raw event activity — NOT the per-gRPC-subscriber
// to the dashboard broadcaster. The dashboard is a first-class distributor subscriber,
// so it sees the session's full raw event activity — NOT the per-gRPC-subscriber
// AfterWorkerSequence filtering that EventStreamService applies at its own boundary. This
// is intentional: the dashboard is a separate LDAP-authenticated monitoring view (per-
// session dashboard ACL is the separate Task 18). Publish is best-effort / never-throw, so
// session dashboard ACL is a separate concern). Publish is best-effort / never-throw, so
// a slow or broken dashboard cannot fault the session or stall the pump; the bounded
// internal subscriber channel (Task 5 per-subscriber isolation) only disconnects THIS
// mirror on overflow, leaving the session and other subscribers untouched.
// internal subscriber channel only disconnects THIS mirror on overflow, leaving the
// session and other subscribers untouched.
private async Task RunDashboardMirrorAsync(
IDashboardEventBroadcaster broadcaster,
IEventSubscriberLease lease,
@@ -757,6 +756,7 @@ public sealed class GatewaySession
/// Determines whether the session lease has expired.
/// </summary>
/// <param name="now">Current timestamp for comparison.</param>
/// <returns><see langword="true"/> if the lease has expired with no active event subscriber; otherwise <see langword="false"/>.</returns>
public bool IsLeaseExpired(DateTimeOffset now)
{
lock (_syncRoot)
@@ -778,6 +778,7 @@ public sealed class GatewaySession
/// window).
/// </summary>
/// <param name="now">Current timestamp for comparison.</param>
/// <returns><see langword="true"/> if the detach-grace window has elapsed with no re-attached subscriber; otherwise <see langword="false"/>.</returns>
public bool IsDetachGraceExpired(DateTimeOffset now)
{
lock (_syncRoot)
@@ -813,6 +814,7 @@ public sealed class GatewaySession
/// succeed past it. On distributor-register failure the count is rolled back (see the
/// catch below).
/// </remarks>
/// <returns>A lease that reads the fanned public events for this subscriber.</returns>
public IEventSubscriberLease AttachEventSubscriber(int maxSubscribers)
{
// Derive the mode from the same source the distributor uses so the two can never
@@ -868,7 +870,7 @@ public sealed class GatewaySession
}
/// <summary>
/// Reconnect/resume variant of <see cref="AttachEventSubscriber"/> (Task 12). Attaches
/// Reconnect/resume variant of <see cref="AttachEventSubscriber"/>. Attaches
/// an event subscriber AND atomically snapshots the session replay ring for events newer
/// than <paramref name="afterSequence"/>, so a resuming client can replay what it missed
/// before live delivery resumes — with no gap and no duplicate across the handoff.
@@ -939,7 +941,7 @@ public sealed class GatewaySession
// Records that an external subscriber attached successfully. Gates the detach-grace
// "last subscriber dropped" stamp so a FAILED first attach (which still rolls back via
// DetachEventSubscriber) never pushes a never-subscribed session into grace (Server-055).
// DetachEventSubscriber) never pushes a never-subscribed session into grace.
private void MarkEventSubscriberAttached()
{
lock (_syncRoot)
@@ -953,6 +955,7 @@ public sealed class GatewaySession
/// </summary>
/// <param name="command">Worker command to invoke.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The worker's reply to the command.</returns>
public async Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
CancellationToken cancellationToken)
@@ -969,7 +972,7 @@ public sealed class GatewaySession
return await workerClient.InvokeAsync(command, CommandTimeout, cancellationToken).ConfigureAwait(false);
}
// Single outbound choke point for the two array-write ergonomics shims (Task 3):
// Single outbound choke point for the two array-write ergonomics shims:
// 1. AddItem/AddItem2 array addresses gain the writable "[]" suffix when Galaxy metadata
// reports them as arrays, so the worker registers a write-capable handle. The mutation
// lands on the same MxCommand instance forwarded to the worker.
@@ -1063,6 +1066,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="registration">The item registration if found.</param>
/// <returns><see langword="true"/> if a registration was found for the handle pair; otherwise <see langword="false"/>.</returns>
public bool TryGetItemRegistration(
int serverHandle,
int itemHandle,
@@ -1136,6 +1140,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="tagAddresses">Tag addresses to add.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-address subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> AddItemBulkAsync(
int serverHandle,
IReadOnlyList<string> tagAddresses,
@@ -1161,6 +1166,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="itemHandles">Item handles to advise.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-handle subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> AdviseItemBulkAsync(
int serverHandle,
IReadOnlyList<int> itemHandles,
@@ -1186,6 +1192,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="itemHandles">Item handles to remove.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-handle subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> RemoveItemBulkAsync(
int serverHandle,
IReadOnlyList<int> itemHandles,
@@ -1211,6 +1218,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="itemHandles">Item handles to un-advise.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-handle subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> UnAdviseItemBulkAsync(
int serverHandle,
IReadOnlyList<int> itemHandles,
@@ -1236,6 +1244,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="tagAddresses">Tag addresses to subscribe to.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-address subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
int serverHandle,
IReadOnlyList<string> tagAddresses,
@@ -1261,6 +1270,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="itemHandles">Item handles to unsubscribe from.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-handle subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> UnsubscribeBulkAsync(
int serverHandle,
IReadOnlyList<int> itemHandles,
@@ -1284,6 +1294,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-entry write results.</returns>
public Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync(
int serverHandle,
IReadOnlyList<WriteBulkEntry> entries,
@@ -1307,6 +1318,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-entry write results.</returns>
public Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync(
int serverHandle,
IReadOnlyList<Write2BulkEntry> entries,
@@ -1330,6 +1342,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-entry write results.</returns>
public Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync(
int serverHandle,
IReadOnlyList<WriteSecuredBulkEntry> entries,
@@ -1353,6 +1366,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-entry write results.</returns>
public Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync(
int serverHandle,
IReadOnlyList<WriteSecured2BulkEntry> entries,
@@ -1380,6 +1394,7 @@ public sealed class GatewaySession
/// <param name="tagAddresses">Tag addresses to read.</param>
/// <param name="timeout">Timeout for the read operation.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-address read results.</returns>
public Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync(
int serverHandle,
IReadOnlyList<string> tagAddresses,
@@ -1437,6 +1452,7 @@ public sealed class GatewaySession
/// <see cref="TransitionTo"/> / <see cref="MarkFaulted"/> and a concurrent
/// <c>TransitionTo(Ready)</c> cannot race past a <c>Closing</c> write.
/// </remarks>
/// <returns>The outcome of the close operation.</returns>
public async Task<SessionCloseResult> CloseAsync(
string reason,
CancellationToken cancellationToken)
@@ -1613,7 +1629,7 @@ public sealed class GatewaySession
/// Mirrors <see cref="CloseAsync"/>'s use of <c>_closeLock</c> so that
/// a Close in flight from one caller and a Kill from another do not
/// race on the "was the session already closed" observation that
/// drives metric increments (Server-045).
/// drives metric increments.
/// </remarks>
/// <param name="reason">Reason for killing the worker.</param>
/// <param name="cancellationToken">Cancellation token.</param>
@@ -1652,6 +1668,7 @@ public sealed class GatewaySession
/// The acquire is best-effort: a non-cancellable wait that swallows
/// <see cref="ObjectDisposedException"/> so double-dispose still completes.
/// </remarks>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
try
@@ -1991,7 +2008,7 @@ public sealed class GatewaySession
// session instead of letting it linger only on the (long) lease: stamp the detached
// time so the lease monitor can close it once the grace window elapses. The session
// stays in its current (Ready) state and remains usable, so a reconnecting subscriber
// (Task 12) re-attaches normally. The gateway-owned internal dashboard subscriber is
// re-attaches normally. The gateway-owned internal dashboard subscriber is
// NOT counted in _activeEventSubscriberCount (it registers on the distributor with
// isInternal: true), so a session whose only remaining subscriber is the dashboard
// mirror still enters grace. Only stamp while the session is alive — once
@@ -2001,7 +2018,7 @@ public sealed class GatewaySession
// Only stamp a detach that mirrors a prior SUCCESSFUL attach. The attach catch path
// calls this same method to roll back a reserved slot when the FIRST attach failed
// before any subscriber registered; that never-subscribed session must not enter the
// grace window (Server-055).
// grace window.
if (_everHadEventSubscriber
&& _detachGrace > TimeSpan.Zero
&& _activeEventSubscriberCount == 0
@@ -74,5 +74,6 @@ public interface ISessionManager
/// <summary>Shuts down all sessions and the session manager.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task ShutdownAsync(CancellationToken cancellationToken);
}
@@ -14,12 +14,12 @@ namespace ZB.MOM.WW.MxGateway.Server.Sessions;
/// </summary>
/// <param name="isOnlySubscriber">
/// <see langword="true"/> when FailFast is allowed to fault the whole session for this
/// overflow. As of Task 8 this is gated on the SESSION MODE, not a live count: it is
/// overflow. This is gated on the SESSION MODE, not a live count: it is
/// <see langword="true"/> only for an external subscriber in single-subscriber mode
/// (<c>AllowMultipleEventSubscribers == false</c>), where at most one external subscriber
/// can ever exist. In multi-subscriber mode it is always <see langword="false"/>, so
/// FailFast degrades to a per-subscriber disconnect and one slow consumer never faults a
/// session shared by others; gating on the fixed mode also removes the Task 5 race where a
/// session shared by others; gating on the fixed mode also removes the race where a
/// concurrent registration could make a count snapshot falsely report a sole subscriber.
/// Always <see langword="false"/> for internal subscribers (the dashboard mirror) so a
/// slow/broken dashboard can never fault the session.
@@ -38,15 +38,14 @@ public delegate void SubscriberOverflowHandler(bool isOnlySubscriber, bool isInt
/// </summary>
/// <remarks>
/// <para>
/// Introduced by Task 2 of the Session Resilience epic; the bounded replay ring
/// buffer was added by Task 3, it was wired into <c>GatewaySession</c> and
/// <c>EventStreamService</c> by Task 4, and the per-subscriber backpressure-isolation
/// policy (Task 5) is implemented here: a slow subscriber overflows only its own
/// bounded channel and the pump applies the policy to that subscriber alone (see
/// The bounded replay ring buffer is wired into <c>GatewaySession</c> and
/// <c>EventStreamService</c>. The per-subscriber backpressure-isolation policy is
/// implemented here: a slow subscriber overflows only its own bounded channel and the
/// pump applies the policy to that subscriber alone (see
/// <see cref="SubscriberOverflowHandler"/> and <c>OnSubscriberOverflow</c>), leaving
/// the pump, the session, and other subscribers running. Task 8 made the
/// FailFast-faults-session decision mode-gated: it fires only in single-subscriber
/// mode (<c>singleSubscriberMode</c>), so multi-subscriber FailFast always degrades to
/// the pump, the session, and other subscribers running. The FailFast-faults-session
/// decision is mode-gated: it fires only in single-subscriber mode
/// (<c>singleSubscriberMode</c>), so multi-subscriber FailFast always degrades to
/// a per-subscriber disconnect — see <c>OnSubscriberOverflow</c>. The ring buffer supports capacity
/// eviction (oldest entry dropped when the count exceeds
/// <c>replayBufferCapacity</c>) and age eviction (entries older than
@@ -58,7 +57,7 @@ public delegate void SubscriberOverflowHandler(bool isOnlySubscriber, bool isInt
/// <see cref="Func{T, TResult}"/> producing an
/// <see cref="IAsyncEnumerable{T}"/> of already-mapped public
/// <see cref="MxEvent"/>s, given a <see cref="CancellationToken"/>. This is the
/// cleanest seam for Task 4: it can pass
/// cleanest seam: it can pass
/// <c>ct =&gt; session.ReadEventsAsync(ct).Select(mapper.MapEvent)</c> (or a
/// channel reader's <c>ReadAllAsync</c>), while unit tests pass a plain
/// channel reader's <c>ReadAllAsync</c> with no real session. The pump owns the
@@ -133,7 +132,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// <param name="sessionId">Owning session id, used only for logging context.</param>
/// <param name="eventSourceFactory">
/// Factory producing the session's event stream given a cancellation token.
/// The pump consumes this exactly once. See the type remarks for the seam Task 4
/// The pump consumes this exactly once. See the type remarks for the seam this
/// plugs into.
/// </param>
/// <param name="subscriberQueueCapacity">
@@ -141,10 +140,18 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// queue capacity shape used today.
/// </param>
/// <param name="logger">Logger for pump lifecycle diagnostics.</param>
/// <param name="overflowHandler">
/// Optional per-subscriber backpressure handler invoked when a subscriber's bounded
/// channel is full. See the primary constructor overload for the full contract.
/// </param>
/// <param name="singleSubscriberMode">
/// <see langword="true"/> when the owning session is in single-subscriber mode. See
/// the primary constructor overload for the full contract.
/// </param>
/// <remarks>
/// This overload disables the replay ring buffer (capacity 0). Use the overload
/// taking replay parameters to retain events for reconnect/reattach replay.
/// Kept <c>internal</c> so production wiring (Task 4) cannot accidentally use
/// Kept <c>internal</c> so production wiring cannot accidentally use
/// the no-replay path; tests reach it via <c>InternalsVisibleTo</c>.
/// </remarks>
internal SessionEventDistributor(
@@ -173,7 +180,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// <param name="sessionId">Owning session id, used only for logging context.</param>
/// <param name="eventSourceFactory">
/// Factory producing the session's event stream given a cancellation token.
/// The pump consumes this exactly once. See the type remarks for the seam Task 4
/// The pump consumes this exactly once. See the type remarks for the seam this
/// plugs into.
/// </param>
/// <param name="subscriberQueueCapacity">
@@ -208,9 +215,9 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// overflows reports <c>isOnlySubscriber == true</c> (legacy FailFast faults the
/// session) ONLY in single-subscriber mode. In multi-subscriber mode it is always
/// <see langword="false"/>, so FailFast degrades to a per-subscriber disconnect and a
/// transient registration race can never falsely fault a shared session (Task 8;
/// resolves the Task 5 REVISIT race). Defaults to <see langword="true"/> so existing
/// call sites and unit tests keep legacy single-subscriber FailFast behavior.
/// transient registration race can never falsely fault a shared session. Defaults to
/// <see langword="true"/> so existing call sites and unit tests keep legacy
/// single-subscriber FailFast behavior.
/// </param>
public SessionEventDistributor(
string sessionId,
@@ -259,6 +266,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// Starts the background pump. Idempotent — a second call is a no-op.
/// </summary>
/// <param name="cancellationToken">Token observed only while starting.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task StartAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -285,7 +293,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// subscribers.
/// </summary>
/// <param name="isInternal">
/// <see langword="true"/> for a gateway-owned internal subscriber (Task 6: the
/// <see langword="true"/> for a gateway-owned internal subscriber (the
/// session's dashboard mirror) that must NOT participate in the single-subscriber
/// overflow accounting. An internal subscriber is excluded from the
/// <c>isOnlySubscriber</c> count, so a lone external gRPC subscriber still reports
@@ -296,6 +304,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// disconnected from the mirror. Defaults to <see langword="false"/> (external
/// subscriber) so every existing call site is unchanged.
/// </param>
/// <returns>The lease for the newly-registered subscriber.</returns>
public IEventSubscriberLease Register(bool isInternal = false)
{
Channel<MxEvent> channel = CreateSubscriberChannel();
@@ -355,7 +364,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// <summary>
/// Atomically snapshots the replay ring for events newer than
/// <paramref name="afterSequence"/> AND registers a live subscriber, so the
/// replay→live handoff has no gap and no duplicate (Task 12 reconnect/resume).
/// replay→live handoff has no gap and no duplicate (reconnect/resume).
/// </summary>
/// <param name="afterSequence">
/// The last worker sequence the reconnecting client already observed. Replay returns
@@ -389,6 +398,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// <see langword="true"/> for a gateway-owned internal subscriber. See
/// <see cref="Register"/>.
/// </param>
/// <returns>The lease for the newly-registered subscriber.</returns>
/// <remarks>
/// <para>
/// <b>Why this is atomic and the handoff is correct.</b> The replay snapshot and the
@@ -488,6 +498,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// <summary>
/// Stops the pump and completes all subscriber channels. Idempotent.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
Task? pumpTask;
@@ -603,22 +614,6 @@ public sealed class SessionEventDistributor : IAsyncDisposable
{
// Decide whether FailFast may fault the whole session for this overflow. This is the
// "isOnlySubscriber" signal the legacy single-subscriber FailFast path keys on.
//
// Task 8 resolution of the Task 5/7 REVISIT race: gate this on the SESSION MODE
// (_singleSubscriberMode), NOT on a live count snapshot. The old
// `CountExternalSubscribers() == 1` snapshot raced once multi-subscriber became real —
// a concurrent second registration/unregistration could make the count read as 1 with
// two subscribers actually present, producing a false FailFast that faults a shared
// session. The mode is fixed for the session's lifetime, so reading it is race-free:
// - single-subscriber mode: at most one external subscriber can ever exist (the
// AttachEventSubscriber guard enforces it), so an overflowing external subscriber
// IS the sole subscriber — preserve the legacy FailFast session-fault behavior.
// - multi-subscriber mode: never fault the shared session; FailFast degrades to a
// per-subscriber disconnect so one slow consumer cannot punish healthy ones.
//
// Task 6: the gateway-owned internal dashboard subscriber is excluded — an internal
// subscriber that overflows is NEVER the "only subscriber", so a slow/broken dashboard
// can only disconnect its own mirror and never fault the session.
bool isOnlySubscriber = !subscriber.IsInternal && _singleSubscriberMode;
_logger.LogDebug(
@@ -816,12 +811,17 @@ public sealed class SessionEventDistributor : IAsyncDisposable
private sealed class Subscriber(long id, Channel<MxEvent> channel, bool isInternal)
{
/// <summary>Gets the subscriber's monotonic id, assigned at registration.</summary>
public long Id { get; } = id;
/// <summary>Gets the subscriber's own bounded event channel, written by the pump.</summary>
public Channel<MxEvent> Channel { get; } = channel;
// True for the gateway-owned internal dashboard subscriber. Excluded from the
// single-subscriber overflow accounting so it cannot fault the session.
/// <summary>
/// Gets a value indicating whether this is the gateway-owned internal dashboard
/// subscriber. Excluded from the single-subscriber overflow accounting so it cannot
/// fault the session.
/// </summary>
public bool IsInternal { get; } = isInternal;
}
@@ -830,8 +830,13 @@ public sealed class SessionEventDistributor : IAsyncDisposable
{
private int _leaseDisposed;
/// <inheritdoc />
public ChannelReader<MxEvent> Reader => subscriber.Channel.Reader;
/// <summary>
/// Unregisters the subscriber from the distributor and completes its channel.
/// Safe to call more than once; only the first call takes effect.
/// </summary>
public void Dispose()
{
// Atomic check-and-set so concurrent Dispose calls unregister at most once.
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Sessions;
/// <param name="Mapper">
/// Maps worker IPC <c>WorkerEvent</c> frames to public <c>MxEvent</c>s. The distributor
/// pump applies this once per event in worker order, mirroring the mapping
/// <c>EventStreamService.ProduceEventsAsync</c> used before Task 4.
/// <c>EventStreamService.ProduceEventsAsync</c> used previously.
/// </param>
/// <param name="EventOptions">
/// Supplies the distributor's per-subscriber queue capacity and replay ring-buffer
@@ -32,20 +32,20 @@ namespace ZB.MOM.WW.MxGateway.Server.Sessions;
/// preserving the observability the pre-epic per-RPC overflow path emitted.
/// </param>
/// <param name="DashboardBroadcaster">
/// Sink the session's internal dashboard mirror loop (Task 6) publishes raw session
/// Sink the session's internal dashboard mirror loop publishes raw session
/// <c>MxEvent</c>s to. When non-null the session registers an internal distributor
/// subscriber on becoming Ready and mirrors every fanned event to the dashboard
/// EventsHub group regardless of whether a gRPC client is streaming. When null
/// (unit tests that don't exercise the dashboard mirror) no mirror is started.
/// </param>
/// <param name="AllowMultipleEventSubscribers">
/// The session's effective multi-subscriber mode (Task 8). Carried here so the session
/// The session's effective multi-subscriber mode. Carried here so the session
/// can pass it to its <see cref="SessionEventDistributor"/> at construction — the
/// distributor is created at <c>MarkReady</c> (for the dashboard mirror) before any gRPC
/// subscriber attaches, so the mode cannot be learned from a later
/// <c>AttachEventSubscriber</c> call. The distributor gates its FailFast session-fault
/// decision on this mode (single-subscriber only) instead of a live count snapshot,
/// closing the Task 5 false-FailFast race. Defaults to <see langword="false"/>
/// closing the false-FailFast race. Defaults to <see langword="false"/>
/// (single-subscriber) so existing call sites and unit tests are unchanged.
/// </param>
public sealed record SessionEventStreaming(
@@ -44,7 +44,7 @@ public sealed class SessionManager : ISessionManager
/// <param name="distributorLogger">Logger passed to each session's event distributor pump.</param>
/// <param name="dashboardEventBroadcaster">
/// Dashboard SignalR fan-out sink. Each session registers an internal distributor
/// subscriber (Task 6) that mirrors raw session events to this broadcaster, so the
/// subscriber that mirrors raw session events to this broadcaster, so the
/// dashboard receives events regardless of whether a gRPC client is streaming. Null in
/// unit tests that do not exercise the dashboard mirror.
/// </param>
@@ -79,14 +79,7 @@ public sealed class SessionManager : ISessionManager
_sessionSlots = new SemaphoreSlim(_options.Sessions.MaxSessions, _options.Sessions.MaxSessions);
}
/// <summary>
/// Opens a new gateway session and connects to the worker.
/// </summary>
/// <param name="request">Session open request.</param>
/// <param name="clientIdentity">Client authentication identity.</param>
/// <param name="ownerKeyId">API key identifier of the caller creating the session.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Opened gateway session.</returns>
/// <inheritdoc />
public async Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
@@ -151,12 +144,7 @@ public sealed class SessionManager : ISessionManager
}
}
/// <summary>
/// Attempts to retrieve a session by ID.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="session">The session if found.</param>
/// <returns>True if session found; otherwise false.</returns>
/// <inheritdoc />
public bool TryGetSession(
string sessionId,
[MaybeNullWhen(false)] out GatewaySession session)
@@ -164,13 +152,7 @@ public sealed class SessionManager : ISessionManager
return _registry.TryGet(sessionId, out session);
}
/// <summary>
/// Invokes a worker command on a session asynchronously.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="command">Worker command.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Command reply.</returns>
/// <inheritdoc />
public async Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
@@ -197,12 +179,7 @@ public sealed class SessionManager : ISessionManager
}
}
/// <summary>
/// Reads events from a session's event stream asynchronously.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of worker events.</returns>
/// <inheritdoc />
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken)
@@ -212,12 +189,7 @@ public sealed class SessionManager : ISessionManager
return session.ReadEventsAsync(cancellationToken);
}
/// <summary>
/// Closes a gateway session asynchronously.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Session close result.</returns>
/// <inheritdoc />
public async Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken)
@@ -231,16 +203,12 @@ public sealed class SessionManager : ISessionManager
return result;
}
/// <summary>
/// Forcefully terminates a session's worker without attempting graceful shutdown.
/// <inheritdoc />
/// <remarks>
/// Mirrors the registry/metrics cleanup that <see cref="CloseSessionCoreAsync"/>
/// performs after a successful close, but skips the <c>WorkerClient.ShutdownAsync</c>
/// step that <see cref="GatewaySession.CloseAsync"/> would otherwise attempt.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="reason">Reason recorded for the kill.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Session close result.</returns>
/// </remarks>
public async Task<SessionCloseResult> KillWorkerAsync(
string sessionId,
string reason,
@@ -251,10 +219,6 @@ public sealed class SessionManager : ISessionManager
GatewaySession session = GetRequiredSession(sessionId);
// Serialize concurrent kill/close attempts on this session by routing through the
// per-session close lock (Server-045). Returns whether the session was already in
// Closed state when the lock was acquired so the metric counter is incremented at
// most once across concurrent callers.
bool wasClosed;
try
{
@@ -265,10 +229,6 @@ public sealed class SessionManager : ISessionManager
session.MarkFaulted(exception.Message);
_metrics.Fault(SessionManagerErrorCode.CloseFailed.ToString());
// Server-044: the open-session gauge was incremented in OpenSessionAsync;
// every session reaching KillWorkerAsync had SessionOpened recorded. If the
// kill path throws, decrement the gauge here so mxgateway.sessions.open
// does not leak — mirroring the Server-006 fix on OpenSessionAsync.
_metrics.SessionRemoved();
await RemoveSessionAsync(session).ConfigureAwait(false);
throw new SessionManagerException(
@@ -291,12 +251,7 @@ public sealed class SessionManager : ISessionManager
return new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: wasClosed);
}
/// <summary>
/// Closes all sessions with expired leases asynchronously.
/// </summary>
/// <param name="now">Current time for lease expiration check.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Count of sessions closed.</returns>
/// <inheritdoc />
public async Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken)
@@ -341,11 +296,7 @@ public sealed class SessionManager : ISessionManager
return closedCount;
}
/// <summary>
/// Shuts down all active sessions gracefully asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
foreach (GatewaySession session in _registry.Snapshot())
@@ -361,10 +312,6 @@ public sealed class SessionManager : ISessionManager
"Graceful shutdown failed for session {SessionId}; killing worker.",
session.SessionId);
// Defensive fallback: CloseSessionCoreAsync's inner SessionCloseStartedException
// catch normally removes the session and accounts the close (Server-046). The
// outer fallback only fires for sessions still in the registry — route through
// KillWorkerAsync so the bookkeeping is identical to the dashboard kill path.
if (_registry.TryGet(session.SessionId, out _))
{
try
@@ -409,11 +356,6 @@ public sealed class SessionManager : ISessionManager
session.MarkFaulted(exception.Message);
if (!wasClosed)
{
// Server-046: account the close as a SessionClosed (decrements the open-session
// gauge AND increments the sessions.closed counter), not just SessionRemoved.
// The session is being removed from the registry below; treating this as a
// half-finished close that only decremented the gauge under-counted the closed
// counter.
_metrics.SessionClosed();
}
@@ -11,6 +11,7 @@ public sealed record SessionOpenRequest(
{
/// <summary>Creates a SessionOpenRequest from a gRPC OpenSessionRequest contract.</summary>
/// <param name="request">Request payload.</param>
/// <returns>The equivalent <see cref="SessionOpenRequest"/>.</returns>
public static SessionOpenRequest FromContract(OpenSessionRequest request)
{
ArgumentNullException.ThrowIfNull(request);
@@ -11,20 +11,13 @@ public sealed class SessionRegistry : ISessionRegistry
{
private readonly ConcurrentDictionary<string, GatewaySession> _sessions = new(StringComparer.Ordinal);
/// <summary>
/// Gets the total count of sessions in the registry.
/// </summary>
/// <inheritdoc />
public int Count => _sessions.Count;
/// <summary>
/// Gets the count of non-closed sessions.
/// </summary>
/// <inheritdoc />
public int ActiveCount => _sessions.Values.Count(session => session.State is not SessionState.Closed);
/// <summary>
/// Adds a session to the registry.
/// </summary>
/// <param name="session">Gateway session to add.</param>
/// <inheritdoc />
public bool TryAdd(GatewaySession session)
{
ArgumentNullException.ThrowIfNull(session);
@@ -32,11 +25,7 @@ public sealed class SessionRegistry : ISessionRegistry
return _sessions.TryAdd(session.SessionId, session);
}
/// <summary>
/// Retrieves a session by identifier.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="session">The retrieved session if found.</param>
/// <inheritdoc />
public bool TryGet(
string sessionId,
[MaybeNullWhen(false)] out GatewaySession session)
@@ -44,11 +33,7 @@ public sealed class SessionRegistry : ISessionRegistry
return _sessions.TryGetValue(sessionId, out session);
}
/// <summary>
/// Removes a session from the registry by identifier.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="session">The removed session if found.</param>
/// <inheritdoc />
public bool TryRemove(
string sessionId,
[MaybeNullWhen(false)] out GatewaySession session)
@@ -56,9 +41,7 @@ public sealed class SessionRegistry : ISessionRegistry
return _sessions.TryRemove(sessionId, out session);
}
/// <summary>
/// Returns a snapshot of all sessions in the registry.
/// </summary>
/// <inheritdoc />
public IReadOnlyCollection<GatewaySession> Snapshot()
{
return _sessions.Values.ToArray();
@@ -8,13 +8,17 @@ public sealed class SessionShutdownHostedService(
ISessionManager sessionManager,
ILogger<SessionShutdownHostedService> logger) : IHostedService
{
/// <inheritdoc />
/// <summary>No-op startup hook; this service only has work to do on shutdown.</summary>
/// <param name="cancellationToken">Token that signals startup should be aborted.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task StartAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc />
/// <summary>Shuts down all gateway sessions as the host stops, logging (without throwing) if the host's shutdown timeout cancels the operation first.</summary>
/// <param name="cancellationToken">Token that signals the host's shutdown timeout has elapsed.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task StopAsync(CancellationToken cancellationToken)
{
try
@@ -39,10 +39,7 @@ public sealed class SessionWorkerClientFactory : ISessionWorkerClientFactory
_options = options.Value;
}
/// <summary>Creates a worker client and launches the worker process.</summary>
/// <param name="session">The gateway session.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created worker client.</returns>
/// <inheritdoc />
public async Task<IWorkerClient> CreateAsync(
GatewaySession session,
CancellationToken cancellationToken)
@@ -19,12 +19,14 @@ public interface IWorkerClient : IAsyncDisposable
/// <summary>Initiates the handshake and enters ready state.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task StartAsync(CancellationToken cancellationToken);
/// <summary>Sends a command to the worker and waits for a reply.</summary>
/// <param name="command">Worker command to invoke.</param>
/// <param name="timeout">Timeout for waiting for the reply.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The worker's reply to the command.</returns>
Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
@@ -32,11 +34,13 @@ public interface IWorkerClient : IAsyncDisposable
/// <summary>Reads events from the worker as they arrive.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>An asynchronous stream of worker events.</returns>
IAsyncEnumerable<WorkerEvent> ReadEventsAsync(CancellationToken cancellationToken);
/// <summary>Gracefully shuts down the worker by closing the connection.</summary>
/// <param name="timeout">Timeout for shutdown.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken);
/// <summary>Terminates the worker process immediately with a diagnostic reason.</summary>
@@ -24,6 +24,7 @@ public interface IWorkerProcess : IDisposable
/// Waits for the process to exit with the specified cancellation token.
/// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
ValueTask WaitForExitAsync(CancellationToken cancellationToken);
/// <summary>
@@ -8,7 +8,9 @@ public sealed class OrphanWorkerCleanupHostedService(
OrphanWorkerTerminator terminator,
ILogger<OrphanWorkerCleanupHostedService> logger) : IHostedService
{
/// <inheritdoc />
/// <summary>Terminates leftover orphan MXAccess worker processes once, best-effort, before the gateway starts accepting sessions.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task StartAsync(CancellationToken cancellationToken)
{
try
@@ -25,6 +27,8 @@ public sealed class OrphanWorkerCleanupHostedService(
return Task.CompletedTask;
}
/// <inheritdoc />
/// <summary>No-op; this service has no ongoing work to stop.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -28,7 +28,7 @@ internal sealed class SystemWorkerProcess(Process process) : IWorkerProcess
process.Kill(entireProcessTree);
}
/// <inheritdoc />
/// <summary>Disposes the wrapped <see cref="Process"/> instance.</summary>
public void Dispose()
{
process.Dispose();
@@ -78,10 +78,10 @@ public sealed class WorkerClient : IWorkerClient
_lastHeartbeatAt = _timeProvider.GetUtcNow();
}
/// <summary>Gets the worker's session ID.</summary>
/// <inheritdoc />
public string SessionId => _connection.SessionId;
/// <summary>Gets the worker process ID.</summary>
/// <inheritdoc />
public int? ProcessId
{
get
@@ -93,7 +93,7 @@ public sealed class WorkerClient : IWorkerClient
}
}
/// <summary>Gets the current client state.</summary>
/// <inheritdoc />
public WorkerClientState State
{
get
@@ -105,7 +105,7 @@ public sealed class WorkerClient : IWorkerClient
}
}
/// <summary>Gets the timestamp of the last received heartbeat.</summary>
/// <inheritdoc />
public DateTimeOffset LastHeartbeatAt
{
get
@@ -117,8 +117,7 @@ public sealed class WorkerClient : IWorkerClient
}
}
/// <summary>Starts the worker client and completes the handshake.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
ThrowIfDisposed();
@@ -141,11 +140,7 @@ public sealed class WorkerClient : IWorkerClient
_heartbeatLoopTask = Task.Run(HeartbeatLoopAsync);
}
/// <summary>Invokes a command on the worker and waits for reply.</summary>
/// <param name="command">The command to invoke.</param>
/// <param name="timeout">Command timeout.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The command reply.</returns>
/// <inheritdoc />
public async Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
@@ -228,9 +223,7 @@ public sealed class WorkerClient : IWorkerClient
}
}
/// <summary>Reads events from the worker as an async stream.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of worker events.</returns>
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -242,9 +235,7 @@ public sealed class WorkerClient : IWorkerClient
}
}
/// <summary>Shuts down the worker gracefully.</summary>
/// <param name="timeout">Shutdown timeout.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <inheritdoc />
public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
ThrowIfDisposed();
@@ -289,8 +280,7 @@ public sealed class WorkerClient : IWorkerClient
}
}
/// <summary>Terminates the worker process immediately.</summary>
/// <param name="reason">Reason for termination.</param>
/// <inheritdoc />
public void Kill(string reason)
{
ThrowIfDisposed();
@@ -302,6 +292,7 @@ public sealed class WorkerClient : IWorkerClient
}
/// <summary>Disposes the worker client and releases resources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_disposed)
@@ -390,13 +381,13 @@ public sealed class WorkerClient : IWorkerClient
}
/// <summary>
/// Monitors worker heartbeat and detects stale sessions. Mirrors
/// Worker-023 on the worker side: while a command is in flight on the
/// Monitors worker heartbeat and detects stale sessions. Mirrors the
/// worker-side watchdog: while a command is in flight on the
/// gateway↔worker pipe, the heartbeat watchdog is suppressed up to
/// <see cref="WorkerClientOptions.HeartbeatStuckCeiling"/> — the worker
/// may be busy executing a slow STA command and the heartbeat write may
/// be queued behind a long event-drain burst (Server-031), neither of
/// which indicates the worker is actually hung. Once the oldest pending
/// be queued behind a long event-drain burst, neither of which
/// indicates the worker is actually hung. Once the oldest pending
/// command exceeds the ceiling, the fault fires anyway so a truly stuck
/// COM call doesn't hide the worker forever.
/// </summary>
@@ -419,11 +410,6 @@ public sealed class WorkerClient : IWorkerClient
continue;
}
// Server-031: if a command is in flight and hasn't yet exceeded
// the stuck-command ceiling, the gap is more likely caused by
// pipe-write contention (event drain holding the writer lock)
// or a legitimately slow STA command than by a hung worker.
// Wait for the ceiling before faulting on heartbeat alone.
if (TryGetOldestPendingCommandAge(out TimeSpan oldestCommandAge)
&& oldestCommandAge <= _options.HeartbeatStuckCeiling)
{
@@ -446,7 +432,7 @@ public sealed class WorkerClient : IWorkerClient
/// Returns the age of the oldest pending command on the worker pipe,
/// measured via <see cref="TimeProvider.GetElapsedTime(long)"/> against
/// <see cref="PendingCommand.StartTimestamp"/>, or <c>false</c> when no
/// commands are pending. Used by the heartbeat watchdog (Server-031)
/// commands are pending. Used by the heartbeat watchdog
/// to decide whether a heartbeat gap is plausibly the result of
/// pipe-write contention rather than a hung worker.
/// </summary>
@@ -508,12 +494,12 @@ public sealed class WorkerClient : IWorkerClient
}
/// <summary>
/// Enqueues a worker event for client consumption. Server-032: the
/// channel is configured with <see cref="BoundedChannelFullMode.Wait"/>
/// and a brief consumer hiccup is now tolerated for up to
/// Enqueues a worker event for client consumption. The channel is
/// configured with <see cref="BoundedChannelFullMode.Wait"/>
/// and a brief consumer hiccup is tolerated for up to
/// <see cref="WorkerClientOptions.EventChannelFullModeTimeout"/>
/// (default 5s) before the worker is faulted. Pre-Server-032 the code
/// used <c>TryWrite</c> (non-blocking) which never honored the
/// (default 5s) before the worker is faulted. The channel previously
/// used <c>TryWrite</c> (non-blocking), which never honored the
/// configured <c>FullModeTimeout</c> — the worker faulted on the first
/// missed slot even though the wait-mode channel would have absorbed
/// the burst. The diagnostic now names the capacity, current depth, and
@@ -538,8 +524,6 @@ public sealed class WorkerClient : IWorkerClient
return;
}
// Channel is full; honor the configured wait timeout before declaring
// the consumer dead and faulting the worker (Server-032).
using CancellationTokenSource fullModeCts = CancellationTokenSource
.CreateLinkedTokenSource(cancellationToken);
fullModeCts.CancelAfter(_options.EventChannelFullModeTimeout);
@@ -15,7 +15,7 @@ public sealed class WorkerClientOptions
/// <summary>
/// Default ceiling on the in-flight-command heartbeat skip. Mirrors
/// <see cref="ZB.MOM.WW.MxGateway.Worker.Ipc.WorkerPipeSessionOptions.DefaultHeartbeatStuckCeiling"/>
/// on the worker side (Worker-023). When a command has been in flight
/// on the worker side. When a command has been in flight
/// longer than this, the gateway-side heartbeat watchdog fires
/// regardless of pending commands — a truly stuck COM call shouldn't
/// hide the worker forever.
@@ -47,8 +47,7 @@ public sealed class WorkerClientOptions
/// faulting the worker. Honored by <c>EnqueueWorkerEventAsync</c> via
/// <c>WriteAsync</c>; with the channel configured for
/// <c>BoundedChannelFullMode.Wait</c>, a transient backlog only faults
/// after the configured timeout has elapsed (Server-032). Pre-Server-032
/// the field was declared but unused — overflow faulted immediately.
/// after the configured timeout has elapsed.
/// </summary>
public TimeSpan EventChannelFullModeTimeout { get; init; }
@@ -56,12 +55,12 @@ public sealed class WorkerClientOptions
public int MaxPendingCommands { get; init; }
/// <summary>
/// Server-031: ceiling on the in-flight-command heartbeat-skip. When
/// Ceiling on the in-flight-command heartbeat-skip. When
/// a command has been pending on the gateway↔worker pipe for longer
/// than this, the gateway-side <c>HeartbeatLoopAsync</c> fires the
/// <c>HeartbeatExpired</c> fault even if commands are still pending;
/// a truly stuck COM call shouldn't keep the watchdog suppressed
/// indefinitely. Mirrors Worker-023's <c>HeartbeatStuckCeiling</c> on
/// indefinitely. Mirrors <c>HeartbeatStuckCeiling</c> on
/// the worker side.
/// </summary>
public TimeSpan HeartbeatStuckCeiling { get; init; }
@@ -30,6 +30,7 @@ public sealed class WorkerFrameWriter
/// </summary>
/// <param name="envelope">Worker envelope message to write.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask WriteAsync(
WorkerEnvelope envelope,
CancellationToken cancellationToken = default)
@@ -30,7 +30,7 @@ public sealed class WorkerProcessHandle : IDisposable
/// <summary>Gets the time when the process was launched.</summary>
public DateTimeOffset LaunchedAt { get; }
/// <inheritdoc />
/// <summary>Disposes the underlying worker process.</summary>
public void Dispose()
{
Process.Dispose();
@@ -58,12 +58,7 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
_logger = logger ?? NullLogger<WorkerProcessLauncher>.Instance;
}
/// <summary>
/// Launches a worker process and waits for startup.
/// </summary>
/// <param name="request">Request payload.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Handle to the launched worker process.</returns>
/// <inheritdoc />
public async Task<WorkerProcessHandle> LaunchAsync(
WorkerProcessLaunchRequest request,
CancellationToken cancellationToken = default)
@@ -2,11 +2,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Workers;
public sealed class WorkerProcessStartedProbe : IWorkerStartupProbe
{
/// <summary>Verifies that the worker process has started and has not exited.</summary>
/// <param name="process">Worker process to verify.</param>
/// <param name="request">Process launch request.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Completed task if process is running.</returns>
/// <inheritdoc />
public Task WaitUntilReadyAsync(
IWorkerProcess process,
WorkerProcessLaunchRequest request,
@@ -5,6 +5,7 @@ public static class WorkerServiceCollectionExtensions
{
/// <summary>Registers worker process launcher and factory services.</summary>
/// <param name="services">Service collection to register services.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddWorkerProcessLauncher(this IServiceCollection services)
{
services.AddSingleton<IWorkerProcessFactory, SystemWorkerProcessFactory>();
@@ -32,6 +32,8 @@ public sealed class AlarmFailoverEndToEndTests
{
private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(30);
/// <summary>Verifies the alarm feed reflects each stage of a full alarmmgr-to-subtag failover and failback lifecycle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ProviderFailoverAndFailback_FullLifecycle_ReflectedInFeed()
{
@@ -227,6 +229,8 @@ public sealed class AlarmFailoverEndToEndTests
await monitor.StopAsync(CancellationToken.None);
}
/// <summary>Verifies a degraded transition cached before a new subscriber connects is replayed with its degraded flag and source provider intact.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DegradedTransition_CachedThenReplayed_CarriesDegradedAndSourceProviderToNewSubscriber()
{
@@ -400,10 +404,13 @@ public sealed class AlarmFailoverEndToEndTests
public SubscribeAlarmsCommand? LastSubscribeCommand { get; private set; }
/// <summary>Pushes a worker event onto the monitor's event stream.</summary>
/// <param name="mxEvent">The worker event to push.</param>
public void EmitEvent(MxEvent mxEvent) =>
_events.Writer.TryWrite(new WorkerEvent { Event = mxEvent });
/// <summary>Completes once the monitor has issued its SubscribeAlarms command.</summary>
/// <param name="timeout">The maximum time to wait.</param>
/// <returns>A task that completes once the SubscribeAlarms command has been issued.</returns>
public Task WaitForSubscribeAsync(TimeSpan timeout) => _subscribed.Task.WaitAsync(timeout);
/// <inheritdoc />
@@ -39,6 +39,12 @@ public sealed class AlarmWatchListResolverTests
},
};
/// <summary>
/// Verifies Galaxy Repository rows and config includes are unioned, configured
/// excludes are removed, and duplicate references (case-insensitive) collapse
/// to a single entry.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveAsync_UnionsGalaxyRowsAndIncludes_RemovesExcludes_AndDeduplicates()
{
@@ -63,6 +69,8 @@ public sealed class AlarmWatchListResolverTests
Assert.Equal(3, result.Count);
}
/// <summary>Verifies subtag item addresses are composed using the configured subtag names.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveAsync_ComposesSubtagAddressesFromConfigNames()
{
@@ -89,6 +97,8 @@ public sealed class AlarmWatchListResolverTests
Assert.Equal("Tank01.Level.HiHi.AckCmt", target.AckCommentSubtag);
}
/// <summary>Verifies that configuring empty Priority and AckComment subtag names leaves those target fields empty.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveAsync_EmptyPriorityAndAckComment_LeaveThoseFieldsEmpty()
{
@@ -110,6 +120,8 @@ public sealed class AlarmWatchListResolverTests
Assert.Equal(string.Empty, target.AckCommentSubtag);
}
/// <summary>Verifies the canonical full reference uses the row's discovered Galaxy area, not the configured Discovery.Area.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveAsync_ComposesCanonicalFullReference_FromRealGalaxyArea_NotConfigArea()
{
@@ -135,6 +147,8 @@ public sealed class AlarmWatchListResolverTests
Assert.Equal("Galaxy!TestArea.TestMachine_001.TestAlarm001", target.AlarmFullReference);
}
/// <summary>Verifies the canonical full reference omits the area segment when neither a discovered nor a configured area is present.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveAsync_ComposesCanonicalFullReference_WithoutArea()
{
@@ -153,6 +167,8 @@ public sealed class AlarmWatchListResolverTests
Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference);
}
/// <summary>Verifies a config include entry with no discovered area falls back to the configured Discovery.Area.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveAsync_ConfigInclude_UsesDiscoveryAreaFallback()
{
@@ -170,6 +186,8 @@ public sealed class AlarmWatchListResolverTests
Assert.Equal("Galaxy!Site_A.Tank01.Level.HiHi", target.AlarmFullReference);
}
/// <summary>Verifies a config include entry falls back to DefaultArea when Discovery.Area is empty.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveAsync_ConfigInclude_FallsBackToDefaultArea_WhenDiscoveryAreaEmpty()
{
@@ -187,6 +205,8 @@ public sealed class AlarmWatchListResolverTests
Assert.Equal("Galaxy!Plant.Tank01.Level.HiHi", target.AlarmFullReference);
}
/// <summary>Verifies that disabling Galaxy Repository discovery skips the repository call and resolves the watch list from configured includes only.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveAsync_UseGalaxyRepositoryFalse_DoesNotCallRepository_UsesIncludesOnly()
{
@@ -206,6 +226,8 @@ public sealed class AlarmWatchListResolverTests
Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference);
}
/// <summary>Verifies that a repository exception during discovery is logged and swallowed, returning the config-only watch list instead of throwing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveAsync_RepositoryThrows_LogsAndReturnsConfigOnlySet()
{
@@ -221,6 +243,8 @@ public sealed class AlarmWatchListResolverTests
Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference);
}
/// <summary>Verifies the source object reference is derived from a config include entry, using the portion before the first dot when present.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveAsync_DerivesSourceObjectForConfigEntry()
{
@@ -240,6 +264,7 @@ public sealed class AlarmWatchListResolverTests
/// Fix 1: ExcludeAttributes must be ignored when UseGalaxyRepository is false.
/// A config-only include must survive even when the same path appears in ExcludeAttributes.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveAsync_ExcludeIgnored_WhenGalaxyRepositoryDisabled()
{
@@ -262,6 +287,7 @@ public sealed class AlarmWatchListResolverTests
/// Fix 1 (GR-on path): ExcludeAttributes still prunes GR rows when
/// UseGalaxyRepository is true.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveAsync_ExcludeApplied_WhenGalaxyRepositoryEnabled()
{
@@ -286,6 +312,7 @@ public sealed class AlarmWatchListResolverTests
/// Fix 1: A whitespace-only ExcludeAttributes entry must be skipped and must
/// not accidentally exclude any real reference.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveAsync_WhitespaceOnlyExcludeEntry_IsSkipped()
{
@@ -307,11 +334,12 @@ public sealed class AlarmWatchListResolverTests
}
/// <summary>
/// Server-051: a cancellation triggered while Galaxy Repository discovery is
/// A cancellation triggered while Galaxy Repository discovery is
/// awaiting must propagate as <see cref="OperationCanceledException"/>, not be
/// swallowed into a config-only watch-list, per the <see cref="IAlarmWatchListResolver"/>
/// contract.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveAsync_RepositoryCancelled_PropagatesOperationCanceled()
{
@@ -329,11 +357,12 @@ public sealed class AlarmWatchListResolverTests
}
/// <summary>
/// Server-052 item 2 / Server-053: an entry that appears in both
/// An entry that appears in both
/// <c>IncludeAttributes</c> and <c>ExcludeAttributes</c> is removed — excludes
/// win over explicit includes (the documented "excludes also suppress matching
/// explicit includes" behaviour).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolveAsync_ExcludeAlsoSuppressesMatchingExplicitInclude()
{
@@ -362,22 +391,32 @@ public sealed class AlarmWatchListResolverTests
/// <summary>Gets the number of times <see cref="GetAlarmAttributesAsync"/> was called.</summary>
public int GetAlarmAttributesCount { get; private set; }
/// <inheritdoc />
/// <summary>Always reports a successful connection.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying <see langword="true"/>.</returns>
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
/// <inheritdoc />
/// <summary>Reports no known deploy time.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying <see langword="null"/>.</returns>
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
Task.FromResult<DateTime?>(null);
/// <inheritdoc />
/// <summary>Not exercised by these tests; returns an empty hierarchy.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying an empty list.</returns>
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyHierarchyRow>());
/// <inheritdoc />
/// <summary>Not exercised by these tests; returns an empty attribute set.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying an empty list.</returns>
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyAttributeRow>());
/// <inheritdoc />
/// <summary>Returns the fixed alarm rowset supplied at construction, tracking how many times it was called.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying the configured alarm attribute rows.</returns>
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
{
GetAlarmAttributesCount++;
@@ -388,22 +427,32 @@ public sealed class AlarmWatchListResolverTests
/// <summary><see cref="IGalaxyRepository"/> whose alarm-attribute query throws.</summary>
private sealed class ThrowingGalaxyRepository(Exception toThrow) : IGalaxyRepository
{
/// <inheritdoc />
/// <summary>Always reports a successful connection; only alarm-attribute discovery fails for this fake.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying <see langword="true"/>.</returns>
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
/// <inheritdoc />
/// <summary>Reports no known deploy time.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying <see langword="null"/>.</returns>
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
Task.FromResult<DateTime?>(null);
/// <inheritdoc />
/// <summary>Not exercised by these tests; returns an empty hierarchy.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying an empty list.</returns>
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyHierarchyRow>());
/// <inheritdoc />
/// <summary>Not exercised by these tests; returns an empty attribute set.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying an empty list.</returns>
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyAttributeRow>());
/// <inheritdoc />
/// <summary>Simulates the live SQL path failing by faulting with the configured exception.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A faulted task carrying the configured exception.</returns>
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default) =>
Task.FromException<List<GalaxyAlarmAttributeRow>>(toThrow);
}
@@ -415,22 +464,32 @@ public sealed class AlarmWatchListResolverTests
/// </summary>
private sealed class CancellingGalaxyRepository(CancellationTokenSource source) : IGalaxyRepository
{
/// <inheritdoc />
/// <summary>Always reports a successful connection; only alarm-attribute discovery cancels for this fake.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying <see langword="true"/>.</returns>
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
/// <inheritdoc />
/// <summary>Reports no known deploy time.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying <see langword="null"/>.</returns>
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
Task.FromResult<DateTime?>(null);
/// <inheritdoc />
/// <summary>Not exercised by these tests; returns an empty hierarchy.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying an empty list.</returns>
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyHierarchyRow>());
/// <inheritdoc />
/// <summary>Not exercised by these tests; returns an empty attribute set.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A completed task carrying an empty list.</returns>
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyAttributeRow>());
/// <inheritdoc />
/// <summary>Cancels the supplied source and throws, mirroring the live SQL path being cancelled mid-await.</summary>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>Never returns normally; always throws <see cref="OperationCanceledException"/>.</returns>
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
{
source.Cancel();
@@ -24,6 +24,8 @@ public sealed class GatewayAlarmMonitorProviderModeTests
{
private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(15);
/// <summary>A worker-reported provider-mode change to subtag broadcasts a degraded status message and increments the provider-switch metric.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ProviderModeChange_BroadcastsDegradedStatus_AndIncrementsSwitchMetric()
{
@@ -121,12 +123,13 @@ public sealed class GatewayAlarmMonitorProviderModeTests
}
/// <summary>
/// Server-053: a redundant <c>OnAlarmProviderModeChanged</c> event whose target
/// A redundant <c>OnAlarmProviderModeChanged</c> event whose target
/// mode equals the current mode still records a provider switch. The worker is the
/// authority on when a mode change occurred; the gateway does not second-guess it,
/// so each event the worker emits increments <c>provider_switches</c> (no from==to
/// suppression). This test pins that semantics so it cannot drift silently.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ProviderModeChange_RepeatedSameMode_RecordsASwitchForEachEvent()
{
@@ -219,7 +222,7 @@ public sealed class GatewayAlarmMonitorProviderModeTests
}
/// <summary>
/// Tests-032: pins the monitor's <c>toMode → AlarmProviderSwitchReason</c>
/// Pins the monitor's <c>toMode → AlarmProviderSwitchReason</c>
/// derivation (<c>GatewayAlarmMonitor.ApplyProviderModeChangeAsync</c>): an
/// alarmmgr→subtag change must emit <c>reason=failover</c> and a subtag→alarmmgr
/// change must emit <c>reason=failback</c>. Captures the <c>reason</c> tag off the
@@ -227,6 +230,7 @@ public sealed class GatewayAlarmMonitorProviderModeTests
/// the Failover/Failback arms or collapsed them to Unknown would be caught here,
/// whereas the count-only tests above would still pass.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ProviderModeChange_FailoverThenFailback_RecordsCorrectReasonTags()
{
@@ -338,6 +342,8 @@ public sealed class GatewayAlarmMonitorProviderModeTests
await monitor.StopAsync(CancellationToken.None);
}
/// <summary>A subscriber that joins the alarm feed receives the current provider status as its first message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NewSubscriber_ReceivesProviderStatusAsFirstMessage()
{
@@ -374,6 +380,8 @@ public sealed class GatewayAlarmMonitorProviderModeTests
await monitor.StopAsync(CancellationToken.None);
}
/// <summary>With the fallback mode forced to subtag, the initial provider status baselines to degraded subtag without recording a provider switch.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ForceSubtagConfig_BaselinesProviderStatusToSubtagDegraded_WithoutSwitch()
{
@@ -448,6 +456,8 @@ public sealed class GatewayAlarmMonitorProviderModeTests
await monitor.StopAsync(CancellationToken.None);
}
/// <summary>With the fallback mode forced to alarm manager, the initial provider status baselines to non-degraded alarmmgr without recording a provider switch.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ForceAlarmManagerConfig_BaselinesProviderStatusToAlarmmgr_WithoutSwitch()
{
@@ -519,6 +529,8 @@ public sealed class GatewayAlarmMonitorProviderModeTests
await monitor.StopAsync(CancellationToken.None);
}
/// <summary>The monitor's SubscribeAlarms command carries the forced provider mode, failover settings, and watch list resolved from configuration.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SubscribeAlarms_SendsForcedModeAndWatchList_FromConfiguration()
{
@@ -565,6 +577,10 @@ public sealed class GatewayAlarmMonitorProviderModeTests
await monitor.StopAsync(CancellationToken.None);
}
/// <summary>The configured fallback mode string maps to the expected <see cref="AlarmProviderMode"/> sent in the SubscribeAlarms command.</summary>
/// <param name="mode">The configured fallback mode string.</param>
/// <param name="expected">The expected forced <see cref="AlarmProviderMode"/>.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("ForceAlarmManager", AlarmProviderMode.Alarmmgr)]
[InlineData("forcealarmmanager", AlarmProviderMode.Alarmmgr)]
@@ -701,10 +717,13 @@ public sealed class GatewayAlarmMonitorProviderModeTests
public SubscribeAlarmsCommand? LastSubscribeCommand { get; private set; }
/// <summary>Pushes a worker event onto the monitor's event stream.</summary>
/// <param name="mxEvent">The worker event to push.</param>
public void EmitEvent(MxEvent mxEvent) =>
_events.Writer.TryWrite(new WorkerEvent { Event = mxEvent });
/// <summary>Completes once the monitor has issued its SubscribeAlarms command.</summary>
/// <param name="timeout">The maximum time to wait.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task WaitForSubscribeAsync(TimeSpan timeout) => _subscribed.Task.WaitAsync(timeout);
/// <inheritdoc />
@@ -123,12 +123,14 @@ public sealed class GatewayOptionsTests
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()
{
@@ -46,6 +46,7 @@ public sealed class GatewayOptionsValidatorTests
Tls = tls,
};
/// <summary>Verifies default TLS options pass validation.</summary>
[Fact]
public void Validate_Succeeds_WithDefaultTlsOptions()
{
@@ -53,6 +54,7 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Succeeded);
}
/// <summary>Verifies a zero <see cref="TlsOptions.ValidityYears"/> fails validation.</summary>
[Fact]
public void Validate_Fails_WhenTlsValidityYearsOutOfRange()
{
@@ -62,6 +64,7 @@ public sealed class GatewayOptionsValidatorTests
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()
{
@@ -71,6 +74,7 @@ public sealed class GatewayOptionsValidatorTests
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()
{
@@ -80,6 +84,7 @@ public sealed class GatewayOptionsValidatorTests
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()
{
@@ -89,6 +94,7 @@ public sealed class GatewayOptionsValidatorTests
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()
{
@@ -100,6 +106,7 @@ public sealed class GatewayOptionsValidatorTests
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()
{
@@ -111,6 +118,7 @@ public sealed class GatewayOptionsValidatorTests
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()
{
@@ -144,6 +152,7 @@ public sealed class GatewayOptionsValidatorTests
Tls = source.Tls,
};
/// <summary>Verifies an invalid fallback mode is not validated when alarms are disabled.</summary>
[Fact]
public void Validate_Succeeds_WhenAlarmsDisabled_FallbackNotValidated()
{
@@ -159,6 +168,7 @@ public sealed class GatewayOptionsValidatorTests
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()
{
@@ -170,6 +180,8 @@ public sealed class GatewayOptionsValidatorTests
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")]
@@ -184,6 +196,7 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Succeeded);
}
/// <summary>Verifies an unrecognised fallback mode fails validation when alarms are enabled.</summary>
[Fact]
public void Validate_Fails_WhenAlarmsEnabled_InvalidMode()
{
@@ -195,6 +208,7 @@ public sealed class GatewayOptionsValidatorTests
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()
{
@@ -217,6 +231,7 @@ public sealed class GatewayOptionsValidatorTests
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()
{
@@ -236,6 +251,7 @@ public sealed class GatewayOptionsValidatorTests
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()
{
@@ -251,6 +267,9 @@ public sealed class GatewayOptionsValidatorTests
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))]
@@ -264,6 +283,9 @@ public sealed class GatewayOptionsValidatorTests
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))]
@@ -277,6 +299,9 @@ public sealed class GatewayOptionsValidatorTests
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))]
@@ -308,6 +333,7 @@ public sealed class GatewayOptionsValidatorTests
Tls = source.Tls,
};
/// <summary>Verifies <see cref="SessionOptions.AllowMultipleEventSubscribers"/> set to <see langword="true"/> passes validation.</summary>
[Fact]
public void Validate_Succeeds_WhenAllowMultipleEventSubscribersIsTrue()
{
@@ -319,6 +345,8 @@ public sealed class GatewayOptionsValidatorTests
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)]
@@ -334,6 +362,8 @@ public sealed class GatewayOptionsValidatorTests
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)]
@@ -347,6 +377,7 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Succeeded);
}
/// <summary>Verifies default <see cref="SessionOptions"/> pass validation.</summary>
[Fact]
public void Validate_Succeeds_WithDefaultSessionOptions()
{
@@ -361,6 +392,7 @@ public sealed class GatewayOptionsValidatorTests
// WorkerReadyWaitTimeoutMs validation
// -------------------------------------------------------------------------
/// <summary>Verifies a negative <see cref="SessionOptions.WorkerReadyWaitTimeoutMs"/> fails validation.</summary>
[Fact]
public void Validate_Fails_WhenWorkerReadyWaitTimeoutMsIsNegative()
{
@@ -374,6 +406,7 @@ public sealed class GatewayOptionsValidatorTests
f => f.Contains("MxGateway:Sessions:WorkerReadyWaitTimeoutMs"));
}
/// <summary>Verifies a zero <see cref="SessionOptions.WorkerReadyWaitTimeoutMs"/> passes validation.</summary>
[Fact]
public void Validate_Succeeds_WhenWorkerReadyWaitTimeoutMsIsZero()
{
@@ -384,6 +417,7 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Succeeded);
}
/// <summary>Verifies a positive <see cref="SessionOptions.WorkerReadyWaitTimeoutMs"/> passes validation.</summary>
[Fact]
public void Validate_Succeeds_WhenWorkerReadyWaitTimeoutMsIsPositive()
{
@@ -394,6 +428,7 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Succeeded);
}
/// <summary>Verifies a negative <see cref="SessionOptions.DetachGraceSeconds"/> fails validation.</summary>
[Fact]
public void Validate_Fails_WhenDetachGraceSecondsIsNegative()
{
@@ -407,6 +442,7 @@ public sealed class GatewayOptionsValidatorTests
f => f.Contains("MxGateway:Sessions:DetachGraceSeconds"));
}
/// <summary>Verifies a zero <see cref="SessionOptions.DetachGraceSeconds"/> passes validation.</summary>
[Fact]
public void Validate_Succeeds_WhenDetachGraceSecondsIsZero()
{
@@ -435,6 +471,7 @@ public sealed class GatewayOptionsValidatorTests
Tls = source.Tls,
};
/// <summary>Verifies a negative <see cref="EventOptions.ReplayBufferCapacity"/> fails validation.</summary>
[Fact]
public void Validate_Fails_WhenReplayBufferCapacityIsNegative()
{
@@ -448,6 +485,7 @@ public sealed class GatewayOptionsValidatorTests
f => f.Contains("MxGateway:Events:ReplayBufferCapacity"));
}
/// <summary>Verifies a zero <see cref="EventOptions.ReplayBufferCapacity"/> passes validation.</summary>
[Fact]
public void Validate_Succeeds_WhenReplayBufferCapacityIsZero()
{
@@ -458,6 +496,7 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Succeeded);
}
/// <summary>Verifies a negative <see cref="EventOptions.ReplayRetentionSeconds"/> fails validation.</summary>
[Fact]
public void Validate_Fails_WhenReplayRetentionSecondsIsNegative()
{
@@ -471,6 +510,7 @@ public sealed class GatewayOptionsValidatorTests
f => f.Contains("MxGateway:Events:ReplayRetentionSeconds"));
}
/// <summary>Verifies a zero <see cref="EventOptions.ReplayRetentionSeconds"/> passes validation.</summary>
[Fact]
public void Validate_Succeeds_WhenReplayRetentionSecondsIsZero()
{
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
public sealed class TlsOptionsBindingTests
{
/// <summary>Verifies that <see cref="TlsOptions"/> applies its documented defaults when no configuration section is bound.</summary>
[Fact]
public void Defaults_AreApplied_WhenSectionAbsent()
{
@@ -16,6 +17,7 @@ public sealed class TlsOptionsBindingTests
Assert.False(string.IsNullOrWhiteSpace(options.SelfSignedCertPath));
}
/// <summary>Verifies that <see cref="TlsOptions"/> values bind correctly from the <c>MxGateway:Tls</c> configuration section.</summary>
[Fact]
public void Binds_FromMxGatewayTlsSection()
{
@@ -347,8 +347,8 @@ public sealed class ProtobufContractRoundTripTests
/// by-name-specific payload case and instead reuses the
/// <c>acknowledge_alarm</c> (<see cref="AcknowledgeAlarmReplyPayload"/>)
/// case. A future change that adds a separate by-name reply case — or
/// drops the reuse — breaks this test. See Contracts-002 and
/// docs/AlarmClientDiscovery.md section 4.
/// drops the reuse — breaks this test. See docs/AlarmClientDiscovery.md
/// section 4.
/// </summary>
[Fact]
public void MxCommandReply_AcknowledgeAlarmByName_ReusesAcknowledgeAlarmPayloadCase()
@@ -1015,8 +1015,6 @@ public sealed class ProtobufContractRoundTripTests
/// Verifies that a <see cref="WriteSecuredBulkCommand"/> round-trips,
/// pinning the credential-bearing entry shape
/// (<c>current_user_id</c>, <c>verifier_user_id</c>, <c>value</c>).
/// See Contracts-011 for the credential-sensitivity comment on
/// <c>WriteSecuredBulkEntry.value</c>.
/// </summary>
[Fact]
public void WriteSecuredBulkCommand_RoundTripsCredentialBearingEntries()
@@ -1597,7 +1595,7 @@ public sealed class ProtobufContractRoundTripTests
/// <see cref="MxSparseArray"/> with at least one <see cref="MxSparseElement"/>
/// so a future renumber or type-narrowing is caught at the contract level.
/// Also verifies that an all-defaults <see cref="MxSparseArray"/> (no elements)
/// is not a proto-level error. See Contracts-023.
/// is not a proto-level error.
/// </summary>
[Fact]
public void MxValue_RoundTripsSparseArrayValueAndPinsFieldNumbers()
@@ -142,10 +142,14 @@ public sealed class DashboardBrowseServiceTests
/// <summary>Mutable so a single test can swap the entry mid-flight.</summary>
public GalaxyHierarchyCacheEntry Current { get; set; } = initial;
/// <inheritdoc />
/// <summary>Does nothing; the stub never refreshes.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
/// <summary>Does nothing; the stub is always considered loaded.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
@@ -12,6 +12,8 @@ public sealed class AuthStoreHealthCheckTests
return new AuthSqliteConnectionFactory(sqlitePath);
}
/// <summary>The health check reports healthy when the auth store database is reachable.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Healthy_WhenStoreReachable()
{
@@ -25,6 +27,8 @@ public sealed class AuthStoreHealthCheckTests
finally { if (File.Exists(path)) File.Delete(path); }
}
/// <summary>The health check reports unhealthy when the database path cannot be opened.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Unhealthy_WhenPathUnusable()
{
@@ -4,6 +4,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Diagnostics;
public sealed class GatewayLogRedactorSeamTests
{
/// <summary>Redacting a log property containing a bearer API key masks the secret while preserving the key id prefix.</summary>
[Fact]
public void Redact_MasksApiKeyInClientIdentity()
{
@@ -9,16 +9,6 @@ namespace ZB.MOM.WW.MxGateway.Tests.Galaxy;
/// <summary>
/// Adversarial-input coverage for the Galaxy Repository browse filter layer.
/// <para>
/// Re-triage note (finding Tests-002): the Galaxy Repository's SQL surface
/// (<c>HierarchySql</c>, <c>AttributesSql</c>, <c>SELECT 1</c>,
/// <c>SELECT time_of_last_deploy FROM galaxy</c>) is entirely constant — no
/// <see cref="DiscoverHierarchyRequest"/> field is ever concatenated into a SQL
/// string. All filters (<c>TagNameGlob</c>, <c>RootTagName</c>, category ids,
/// template-chain filters, contained-path roots) are applied in memory by
/// <see cref="GalaxyHierarchyProjector"/> against the cached snapshot, so there is
/// no SQL-injection surface and no <c>LIKE</c>-escaping helper to test.
/// </para>
/// <para>
/// The genuine, testable concern is that adversarial filter strings — SQL
/// metacharacters (<c>'</c>, <c>;</c>) and <c>LIKE</c>-wildcards (<c>%</c>,
/// <c>_</c>) — are treated as opaque literals by the in-memory filter layer:
@@ -41,6 +31,7 @@ public sealed class GalaxyFilterInputSafetyTests
];
/// <summary>Returns adversarial input cases for theory tests.</summary>
/// <returns>Theory data of adversarial input strings.</returns>
public static TheoryData<string> AdversarialInputCases()
{
TheoryData<string> data = [];
@@ -88,7 +79,7 @@ public sealed class GalaxyFilterInputSafetyTests
}
/// <summary>
/// Regression guard for finding Server-008: <see cref="GalaxyGlobMatcher"/> caches
/// <see cref="GalaxyGlobMatcher"/> caches
/// the compiled regex per glob pattern. Repeated calls with the same pattern, and
/// interleaved calls with different patterns, must keep returning the correct
/// literal-vs-wildcard result rather than a stale cached match.
@@ -229,6 +220,7 @@ public sealed class GalaxyFilterInputSafetyTests
/// zero matches rather than returning the whole hierarchy or faulting.
/// </summary>
/// <param name="glob">An adversarial glob containing SQL metacharacters or LIKE wildcards.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[MemberData(nameof(AdversarialInputCases))]
public async Task DiscoverHierarchy_WithAdversarialTagNameGlob_ReturnsZeroMatches(string glob)
@@ -249,6 +241,7 @@ public sealed class GalaxyFilterInputSafetyTests
/// a query fragment or matching unrelated objects.
/// </summary>
/// <param name="rootTagName">An adversarial tag name containing SQL metacharacters or LIKE wildcards.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[MemberData(nameof(AdversarialInputCases))]
public async Task DiscoverHierarchy_WithAdversarialRootTagName_ReturnsNotFound(string rootTagName)
@@ -327,13 +320,17 @@ public sealed class GalaxyFilterInputSafetyTests
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{
/// <inheritdoc />
/// <summary>Gets the fixed cache entry returned to callers.</summary>
public GalaxyHierarchyCacheEntry Current { get; } = current;
/// <inheritdoc />
/// <summary>Does nothing; the stub never refreshes.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
/// <summary>Does nothing; the stub is always considered loaded.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -29,6 +29,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
private readonly List<TempDatabaseDirectory> _tempDirectories = [];
/// <summary>Verifies that unauthorized users cannot create API keys.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CreateAsync_UnauthorizedUser_DoesNotCreate()
{
@@ -45,6 +46,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
}
/// <summary>Verifies that authorized users create a verifiable, constrained key and audit it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CreateAsync_AuthorizedUser_CreatesVerifiableKeyAndAudits()
{
@@ -79,6 +81,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
}
/// <summary>Verifies that creating a key whose id already exists is rejected.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CreateAsync_DuplicateKeyId_ReportsConflict()
{
@@ -96,6 +99,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
}
/// <summary>Verifies that authorized users can revoke keys with audit trail.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits()
{
@@ -121,6 +125,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
}
/// <summary>Verifies that authorized users can rotate a key's secret with audit trail.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RotateAsync_AuthorizedUser_RotatesAndAudits()
{
@@ -153,6 +158,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
}
/// <summary>Verifies that authorized users can delete revoked keys with audit trail.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteAsync_AuthorizedUser_DeletesRevokedKeyAndAudits()
{
@@ -181,6 +187,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
/// <c>dashboard-delete-key</c> audit entry with <c>Details = "not-found-or-active"</c> is still
/// written — audit completeness for refused deletes.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteAsync_ActiveKey_ReportsFriendlyErrorAndAudits()
{
@@ -206,6 +213,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
/// <summary>A blank key id fails validation before any store or audit call runs.</summary>
/// <param name="blankKeyId">A blank or whitespace key identifier.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("")]
[InlineData(" ")]
@@ -225,10 +233,11 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
}
/// <summary>
/// Server-004 regression: the dashboard create path must reject a request carrying a
/// The dashboard create path must reject a request carrying a
/// non-canonical scope string rather than persisting a key whose scope the authorization
/// resolver never matches.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CreateAsync_UnknownScope_DoesNotCreate()
{
@@ -255,6 +264,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
/// Phase 3 canonical audit shape: the dashboard-create-key canonical AuditEvent records
/// the operator username as Actor and the managed keyId as Target.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CreateAsync_AuthorizedUser_CanonicalAuditEventHasOperatorAsActorAndKeyIdAsTarget()
{
@@ -386,7 +396,10 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
/// <summary>Gets the recorded canonical audit events.</summary>
public List<ZB.MOM.WW.Audit.AuditEvent> Events { get; } = [];
/// <inheritdoc />
/// <summary>Records the audit event in memory instead of writing it to a real sink.</summary>
/// <param name="auditEvent">The audit event to record.</param>
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task WriteAsync(ZB.MOM.WW.Audit.AuditEvent auditEvent, CancellationToken cancellationToken = default)
{
Events.Add(auditEvent);
@@ -20,6 +20,8 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardAuthenticatorTests
{
/// <summary>A blank username is rejected without touching the LDAP provider.</summary>
/// <param name="username">The blank/whitespace/null username under test.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -40,6 +42,7 @@ public sealed class DashboardAuthenticatorTests
}
/// <summary>A blank password is rejected without touching the LDAP provider.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuthenticateAsync_BlankPassword_FailsWithoutCallingLdap()
{
@@ -60,6 +63,8 @@ public sealed class DashboardAuthenticatorTests
/// A failed LDAP outcome (any failure bucket, including <see cref="LdapAuthFailure.Disabled"/>)
/// maps to the generic dashboard failure without leaking the raw password.
/// </summary>
/// <param name="failure">The LDAP failure bucket under test.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(LdapAuthFailure.Disabled)]
[InlineData(LdapAuthFailure.BadCredentials)]
@@ -87,6 +92,7 @@ public sealed class DashboardAuthenticatorTests
/// name (ClaimTypes.Name), and the username (ClaimTypes.NameIdentifier), under the
/// dashboard authentication scheme.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuthenticateAsync_Success_BuildsPrincipalWithExpectedClaims()
{
@@ -127,11 +133,12 @@ public sealed class DashboardAuthenticatorTests
}
/// <summary>
/// Task 1.5: the principal emits canonical ZbClaimTypes.Username ("zb:username") with
/// The principal emits canonical ZbClaimTypes.Username ("zb:username") with
/// the login username, ZbClaimTypes.Role (= ClaimTypes.Role) for each resolved role, and
/// ZbClaimTypes.DisplayName ("zb:displayname") with the display name — while keeping
/// ClaimTypes.NameIdentifier, ClaimTypes.Name, and mxgateway:ldap_group claims intact.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuthenticateAsync_Success_EmitsCanonicalZbClaims()
{
@@ -183,6 +190,7 @@ public sealed class DashboardAuthenticatorTests
/// When the user authenticates but none of their groups map to a dashboard role,
/// the login is denied (the long-standing "no roles matched → denied" rule).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuthenticateAsync_NoRolesMatched_DeniesLogin()
{
@@ -216,6 +224,7 @@ public sealed class DashboardAuthenticatorTests
/// for the realistic (already-short) group shape.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuthenticateAsync_GroupAsDistinguishedNameFromService_ResolvesRoleAndSurfacesServiceValue()
{
@@ -243,6 +252,7 @@ public sealed class DashboardAuthenticatorTests
}
/// <summary>The (already-trimmed) username from the LDAP result flows onto the principal.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuthenticateAsync_UsesUsernameFromLdapResult()
{
@@ -296,10 +306,17 @@ public sealed class DashboardAuthenticatorTests
/// </summary>
private sealed class FakeLdapAuthService(LdapAuthResult result) : ILdapAuthService
{
/// <summary>Gets a value indicating whether <see cref="AuthenticateAsync"/> was invoked.</summary>
public bool WasCalled { get; private set; }
/// <summary>Gets the username passed to the most recent <see cref="AuthenticateAsync"/> call.</summary>
public string? LastUsername { get; private set; }
/// <summary>Records the call and returns the configured fixed result.</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password to authenticate.</param>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>The fixed <see cref="LdapAuthResult"/> configured for this fake.</returns>
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
{
WasCalled = true;
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardAuthorizationHandlerTests
{
/// <summary>Verifies that unauthenticated remote requests fail authorization.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task HandleAsync_UnauthenticatedRemoteRequest_DoesNotSucceed()
{
@@ -24,6 +25,7 @@ public sealed class DashboardAuthorizationHandlerTests
}
/// <summary>Verifies that anonymous localhost access succeeds when allowed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task HandleAsync_AnonymousLocalhostAllowed_Succeeds()
{
@@ -40,6 +42,7 @@ public sealed class DashboardAuthorizationHandlerTests
/// Verifies that the anonymous-localhost bypass is denied when <c>AllowAnonymousLocalhost</c>
/// is off, even on a loopback connection — the misconfiguration must not expose the dashboard.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task HandleAsync_AnonymousLocalhostDisallowed_DoesNotSucceed()
{
@@ -56,6 +59,7 @@ public sealed class DashboardAuthorizationHandlerTests
/// Verifies that the anonymous-localhost bypass stays scoped to loopback: an anonymous
/// request from a non-loopback address is denied even when <c>AllowAnonymousLocalhost</c> is on.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task HandleAsync_AnonymousLocalhostAllowedFromRemoteAddress_DoesNotSucceed()
{
@@ -69,6 +73,7 @@ public sealed class DashboardAuthorizationHandlerTests
}
/// <summary>Verifies that an authenticated user without any dashboard role fails the viewer requirement.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task HandleAsync_AuthenticatedWithoutDashboardRole_DoesNotSucceed()
{
@@ -82,6 +87,7 @@ public sealed class DashboardAuthorizationHandlerTests
}
/// <summary>Verifies that a Viewer satisfies the viewer-or-admin requirement.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task HandleAsync_ViewerRole_SatisfiesViewerPolicy()
{
@@ -95,6 +101,7 @@ public sealed class DashboardAuthorizationHandlerTests
}
/// <summary>Verifies that an Admin satisfies the admin-only requirement.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task HandleAsync_AdminRole_SatisfiesAdminPolicy()
{
@@ -108,6 +115,7 @@ public sealed class DashboardAuthorizationHandlerTests
}
/// <summary>Verifies that a Viewer does NOT satisfy the admin-only requirement.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task HandleAsync_ViewerRole_DoesNotSatisfyAdminPolicy()
{
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardAutoLoginAuthenticationHandlerTests
{
/// <summary>The default auto-login user mints an authenticated principal with both dashboard roles.</summary>
[Fact]
public void CreatePrincipal_MintsAuthenticatedMultiRoleUser()
{
@@ -16,6 +17,8 @@ public sealed class DashboardAutoLoginAuthenticationHandlerTests
Assert.True(principal.IsInRole(DashboardRoles.Viewer));
}
/// <summary>A null, empty, or whitespace-only user falls back to the configured default user.</summary>
/// <param name="user">The blank/whitespace/null user under test.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -27,6 +30,7 @@ public sealed class DashboardAutoLoginAuthenticationHandlerTests
Assert.Equal(DashboardAutoLoginAuthenticationHandler.DefaultUser, principal.Identity!.Name);
}
/// <summary>Leading/trailing whitespace around the user name is trimmed before minting the principal.</summary>
[Fact]
public void CreatePrincipal_TrimsUser()
{
@@ -35,6 +39,7 @@ public sealed class DashboardAutoLoginAuthenticationHandlerTests
Assert.Equal("multi-role", principal.Identity!.Name);
}
/// <summary>A custom user name preserves its identity name and still receives both dashboard roles.</summary>
[Fact]
public void CreatePrincipal_CustomUser_PreservesNameAndRoles()
{
@@ -171,13 +171,11 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.True(model.IsDegraded);
Assert.Contains("bg-warning", model.BadgeCssClass, StringComparison.Ordinal);
Assert.Equal("x", model.Reason);
// Tests-033: pin the amber label text, not just the CSS class — a label swap
// would otherwise pass this test.
Assert.Equal(DashboardAlarmProviderStatus.DegradedLabel, model.Label);
}
/// <summary>
/// Tests-033: an explicitly-degraded status whose mode is still Alarmmgr (the
/// An explicitly-degraded status whose mode is still Alarmmgr (the
/// <c>Degraded || Mode==Subtag</c> guard's second, independent branch) must still
/// map to the degraded amber badge.
/// </summary>
@@ -199,7 +197,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
}
/// <summary>
/// Tests-033: the <c>SinceUtc</c> field must carry the protobuf <c>Since</c>
/// The <c>SinceUtc</c> field must carry the protobuf <c>Since</c>
/// timestamp converted to a <see cref="DateTimeOffset" />.
/// </summary>
[Fact]
@@ -220,7 +218,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
}
/// <summary>
/// Tests-033: <see cref="DashboardAlarmProviderStatus.FromFeed" /> — the entry the
/// <see cref="DashboardAlarmProviderStatus.FromFeed" /> — the entry the
/// dashboard SignalR snapshot path actually calls — projects a provider-status
/// feed message into the badge model.
/// </summary>
@@ -246,7 +244,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
}
/// <summary>
/// Tests-033: <see cref="DashboardAlarmProviderStatus.FromFeed" /> throws
/// <see cref="DashboardAlarmProviderStatus.FromFeed" /> throws
/// <see cref="ArgumentException" /> when the feed message does not carry a
/// provider-status payload.
/// </summary>
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardCookieOptionsTests
{
/// <summary>Verifies that the application configures secure dashboard authentication cookies.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Build_ConfiguresSecureDashboardCookie()
{
@@ -36,6 +37,7 @@ public sealed class DashboardCookieOptionsTests
/// relaxes the cookie to <see cref="CookieSecurePolicy.SameAsRequest"/> so
/// the dashboard can be reached over plain HTTP in dev.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Build_WithRequireHttpsCookieFalse_UsesSameAsRequest()
{
@@ -55,6 +57,7 @@ public sealed class DashboardCookieOptionsTests
/// cookie name, so a gateway instance sharing a hostname with another can be given a
/// distinct name (browser cookies are scoped by host+path, not port).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Build_WithCookieNameOverride_UsesConfiguredName()
{
@@ -11,6 +11,8 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardDisableLoginTests
{
/// <summary>Verifies that when dashboard login is not disabled, the dashboard authentication scheme resolves to the cookie authentication handler.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DisableLoginOff_CookieSchemeUsesCookieHandler()
{
@@ -25,6 +27,8 @@ public sealed class DashboardDisableLoginTests
Assert.Equal(typeof(CookieAuthenticationHandler), scheme!.HandlerType);
}
/// <summary>Verifies that when <c>MxGateway:Dashboard:DisableLogin</c> is enabled, the dashboard authentication scheme resolves to the auto-login handler instead of the cookie handler.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DisableLoginOn_CookieSchemeUsesAutoLoginHandler()
{
@@ -40,6 +44,8 @@ public sealed class DashboardDisableLoginTests
Assert.Equal(typeof(DashboardAutoLoginAuthenticationHandler), scheme!.HandlerType);
}
/// <summary>Verifies that the auto-login principal created when login is disabled satisfies the Admin, Viewer, and hub-clients authorization policies.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DisableLoginOn_AutoLoginPrincipalSatisfiesAdminAndViewerPolicies()
{
@@ -130,6 +130,8 @@ public sealed class DashboardGalaxySummaryProjectorTests
/// Verifies that the cache status enum is faithfully mapped to the dashboard
/// status enum across every defined value.
/// </summary>
/// <param name="cacheStatus">The Galaxy cache status to project.</param>
/// <param name="expected">The expected dashboard status for the given cache status.</param>
[Theory]
[InlineData(GalaxyCacheStatus.Healthy, DashboardGalaxyStatus.Healthy)]
[InlineData(GalaxyCacheStatus.Stale, DashboardGalaxyStatus.Stale)]
@@ -35,6 +35,7 @@ public sealed class DashboardGroupRoleMapperTests
/// <summary>Verifies full-DN match, leading-RDN fallback, case-insensitivity, and unmapped → empty.</summary>
/// <param name="ldapGroup">The LDAP group name or distinguished name.</param>
/// <param name="expectedRole">The expected role or null if no match.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("GwAdmin", DashboardRoles.Admin)]
[InlineData("gwadmin", DashboardRoles.Admin)]
@@ -61,6 +62,7 @@ public sealed class DashboardGroupRoleMapperTests
}
/// <summary>Verifies that admin and viewer roles are both emitted when both groups are present.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MapAsync_AdminPlusViewer_BothRolesEmitted()
{
@@ -75,6 +77,7 @@ public sealed class DashboardGroupRoleMapperTests
}
/// <summary>Verifies that an empty GroupToRole map yields no roles.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MapAsync_EmptyMapping_ReturnsNoRoles()
{
@@ -86,12 +89,13 @@ public sealed class DashboardGroupRoleMapperTests
}
/// <summary>
/// Task 1.7 (canonical roles): an LDAP user in the admin group must resolve
/// Canonical roles: an LDAP user in the admin group must resolve
/// to the canonical role value <c>"Administrator"</c> (not the legacy
/// <c>"Admin"</c>), and the reader group to <c>"Viewer"</c>. Asserted with
/// string LITERALS — independent of <see cref="DashboardRoles"/> — so a
/// regression on the constant's value is caught here.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MapAsync_AdminGroup_ResolvesToCanonicalAdministratorValue()
{
@@ -105,7 +109,7 @@ public sealed class DashboardGroupRoleMapperTests
}
/// <summary>
/// Task 1.7: the canonical admin value (<c>"Administrator"</c>) passes the
/// The canonical admin value (<c>"Administrator"</c>) passes the
/// admin-only gate while a <c>"Viewer"</c> fails it — asserted with literals,
/// proving enforcement is bound to the new value and the legacy <c>"Admin"</c>
/// string is no longer what authorizes admin actions.
@@ -124,6 +128,7 @@ public sealed class DashboardGroupRoleMapperTests
/// Verifies the extracted shared helper is the single source of truth: it
/// produces the same roles the mapper does for the same inputs.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SharedHelper_MatchesMapperOutput()
{
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardHubsRegistrationTests
{
/// <summary>Verifies that dashboard build maps all three hubs and token endpoint.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Build_WhenDashboardEnabled_MapsAllThreeHubsAndTokenEndpoint()
{
@@ -27,6 +28,7 @@ public sealed class DashboardHubsRegistrationTests
}
/// <summary>Verifies that dashboard build registers hub token service and connection factory.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Build_WhenDashboardEnabled_RegistersHubTokenServiceAndConnectionFactory()
{

Some files were not shown because too many files have changed in this diff Show More