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.IntegrationTests.LiveMxAccessFactAttribute</c> and
/// <c>ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport.LiveMxAccessFactAttribute</c> /// <c>ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport.LiveMxAccessFactAttribute</c>
/// so any future opt-in tweak does not silently leave one project /// so any future opt-in tweak does not silently leave one project
/// behind — see Worker.Tests-025. /// behind.
/// </summary> /// </summary>
public const string LiveMxAccessOptInVariableName = "MXGATEWAY_RUN_LIVE_MXACCESS_TESTS"; public const string LiveMxAccessOptInVariableName = "MXGATEWAY_RUN_LIVE_MXACCESS_TESTS";
} }
@@ -15,6 +15,7 @@ namespace ZB.MOM.WW.MxGateway.IntegrationTests;
public sealed class DashboardLdapLiveTests public sealed class DashboardLdapLiveTests
{ {
/// <summary>Verifies that an admin user in the GwAdmin group authenticates successfully.</summary> /// <summary>Verifies that an admin user in the GwAdmin group authenticates successfully.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds() public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds()
{ {
@@ -32,17 +33,13 @@ public sealed class DashboardLdapLiveTests
claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType
&& claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase)); && 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 => Assert.Contains(result.Principal.Claims, claim =>
claim.Type == ClaimTypes.Role claim.Type == ClaimTypes.Role
&& claim.Value == DashboardRoles.Admin); && claim.Value == DashboardRoles.Admin);
} }
/// <summary>Verifies that a readonly user without GwAdmin group fails to authenticate.</summary> /// <summary>Verifies that a readonly user without GwAdmin group fails to authenticate.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails() 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> /// <summary>Verifies that authentication with wrong password fails without leaking the password.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword() public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
{ {
@@ -78,6 +76,7 @@ public sealed class DashboardLdapLiveTests
} }
/// <summary>Verifies that authentication with unknown username fails.</summary> /// <summary>Verifies that authentication with unknown username fails.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_UnknownUsername_Fails() 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> /// <summary>Verifies that authentication fails gracefully when the server is unreachable.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing() 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 /// Builds the shared library <see cref="LibraryLdapOptions"/> by binding the real
/// <c>MxGateway:Ldap</c> configuration section the same way production does in /// <c>MxGateway:Ldap</c> configuration section the same way production does in
/// <c>AddZbLdapAuth(configuration, "MxGateway:Ldap")</c>, rather than hand-copying the /// <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 /// 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 /// exact option-binding path production uses, pick up every shared field (including
/// <see cref="LibraryLdapOptions.ConnectionTimeoutMs"/>, which governs the /// <see cref="LibraryLdapOptions.ConnectionTimeoutMs"/>, which governs the
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.MxGateway.IntegrationTests.Galaxy;
public sealed class GalaxyRepositoryLiveTests public sealed class GalaxyRepositoryLiveTests
{ {
/// <summary>Verifies that the Galaxy Repository can establish a live connection to the ZB database.</summary> /// <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] [LiveGalaxyRepositoryFact]
public async Task TestConnection_AgainstZb_Succeeds() 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> /// <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] [LiveGalaxyRepositoryFact]
public async Task GetLastDeployTime_AgainstZb_ReturnsTimestamp() 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> /// <summary>Verifies that the hierarchy can be retrieved from the ZB database.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveGalaxyRepositoryFact] [LiveGalaxyRepositoryFact]
public async Task GetHierarchy_AgainstZb_ReturnsObjects() 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> /// <summary>Verifies that object attributes can be retrieved from the ZB database.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveGalaxyRepositoryFact] [LiveGalaxyRepositoryFact]
public async Task GetAttributes_AgainstZb_ReturnsAtLeastOneAttribute() public async Task GetAttributes_AgainstZb_ReturnsAtLeastOneAttribute()
{ {
@@ -7,8 +7,7 @@ public static class IntegrationTestEnvironment
/// <summary> /// <summary>
/// Sourced from <see cref="GatewayContractInfo.LiveMxAccessOptInVariableName"/> /// Sourced from <see cref="GatewayContractInfo.LiveMxAccessOptInVariableName"/>
/// so the env-var literal is shared with /// so the env-var literal is shared with
/// <c>ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport.LiveMxAccessFactAttribute</c> /// <c>ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport.LiveMxAccessFactAttribute</c>.
/// (Worker.Tests-025).
/// </summary> /// </summary>
public const string LiveMxAccessVariableName = GatewayContractInfo.LiveMxAccessOptInVariableName; public const string LiveMxAccessVariableName = GatewayContractInfo.LiveMxAccessOptInVariableName;
public const string LiveMxAccessWorkerExecutableVariableName = "MXGATEWAY_LIVE_MXACCESS_WORKER_EXE"; 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 /// 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 /// message rather than silently falling back to the current working directory
/// (which previously produced a misleading "worker exe not found" pointing at /// (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 /// <see cref="LiveMxAccessWorkerExecutableVariableName"/> environment variable
/// remains the escape hatch for unusual deployments. /// remains the escape hatch for unusual deployments.
/// </summary> /// </summary>
@@ -115,7 +114,7 @@ public static class IntegrationTestEnvironment
/// ancestors above it. Tests pass an isolated boundary so the walker cannot /// 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 /// 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 /// 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 /// Production callers pass <see langword="null"/> so the walk continues to the
/// drive root as before. /// drive root as before.
/// </param> /// </param>
@@ -34,7 +34,7 @@ public sealed class IntegrationTestEnvironmentTests
File.WriteAllText(Path.Combine(temporaryRoot, ".git"), "gitdir: ../.git/worktrees/test"); File.WriteAllText(Path.Combine(temporaryRoot, ".git"), "gitdir: ../.git/worktrees/test");
// Pass temporaryRoot as the stop-boundary so the walker can never leak // 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( string repositoryRoot = IntegrationTestEnvironment.ResolveRepositoryRoot(
nestedDirectory, nestedDirectory,
stopBoundary: temporaryRoot); stopBoundary: temporaryRoot);
@@ -54,13 +54,13 @@ public sealed class IntegrationTestEnvironmentTests
/// Verifies that <see cref="IntegrationTestEnvironment.ResolveRepositoryRoot"/> /// Verifies that <see cref="IntegrationTestEnvironment.ResolveRepositoryRoot"/>
/// throws <see cref="InvalidOperationException"/> with a diagnostic message when /// throws <see cref="InvalidOperationException"/> with a diagnostic message when
/// the walk exhausts without finding a repository root. The previous silent /// the walk exhausts without finding a repository root. The previous silent
/// fallback to <c>Directory.GetCurrentDirectory()</c> masked misconfiguration /// fallback to <c>Directory.GetCurrentDirectory()</c> masked misconfiguration;
/// (IntegrationTests-022); operators get a clear, actionable failure instead. /// operators get a clear, actionable failure instead.
/// The <c>stopBoundary</c> isolates the walker from ambient ancestors of /// The <c>stopBoundary</c> isolates the walker from ambient ancestors of
/// <see cref="Path.GetTempPath"/> (a redirected <c>TMP</c>, a co-located checkout /// <see cref="Path.GetTempPath"/> (a redirected <c>TMP</c>, a co-located checkout
/// at <c>C:\src</c>, etc.) that could otherwise satisfy /// at <c>C:\src</c>, etc.) that could otherwise satisfy
/// <c>IsRepositoryRoot</c> and make this assertion flake on contributor or CI /// <c>IsRepositoryRoot</c> and make this assertion flake on contributor or CI
/// boxes — see IntegrationTests-025. /// boxes.
/// </summary> /// </summary>
[Fact] [Fact]
public void ResolveRepositoryRoot_NoMarkers_ThrowsInvalidOperationExceptionNamingStartAndMarkers() public void ResolveRepositoryRoot_NoMarkers_ThrowsInvalidOperationExceptionNamingStartAndMarkers()
@@ -97,9 +97,8 @@ public sealed class IntegrationTestEnvironmentTests
/// <summary> /// <summary>
/// Verifies the <c>stopBoundary</c> parameter on /// Verifies the <c>stopBoundary</c> parameter on
/// <see cref="IntegrationTestEnvironment.ResolveRepositoryRoot"/> isolates the /// <see cref="IntegrationTestEnvironment.ResolveRepositoryRoot"/> isolates the
/// walker from ambient ancestors that happen to satisfy <c>IsRepositoryRoot</c> /// walker from ambient ancestors that happen to satisfy <c>IsRepositoryRoot</c>.
/// — the precise failure mode IntegrationTests-025 describes. The test /// The test deliberately constructs an outer directory that *does* carry repository-root
/// deliberately constructs an outer directory that *does* carry repository-root
/// markers (<c>src/</c> + <c>.git</c>) and an inner isolated chain that does /// 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 /// 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 /// 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 /// The unit-test project owns a parallel copy under
/// <c>ZB.MOM.WW.MxGateway.Tests/TestSupport/</c>; IntegrationTests keeps its /// <c>ZB.MOM.WW.MxGateway.Tests/TestSupport/</c>; IntegrationTests keeps its
/// own copy here so the two test projects stay independently buildable /// 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> /// </summary>
public sealed class NullDashboardEventBroadcaster : IDashboardEventBroadcaster public sealed class NullDashboardEventBroadcaster : IDashboardEventBroadcaster
{ {
@@ -30,6 +30,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// <summary> /// <summary>
/// Verifies that a gateway session can register, add item, advise, and stream events from live MXAccess. /// Verifies that a gateway session can register, add item, advise, and stream events from live MXAccess.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact] [LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_RegistersAdvisesStreamsDataAndCloses() 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 /// 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. /// — the proof of round-trip the cross-language client e2e runner relies on.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact] [LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_WritesValueToAdvisedItem() 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 /// Verifies that an AddItem against an invalid server handle surfaces the MXAccess failure
/// without faulting the gateway transport, exercising the invalid-handle parity path. /// without faulting the gateway transport, exercising the invalid-handle parity path.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact] [LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_InvalidHandleCommand_SurfacesFailureWithoutTransportFault() 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 /// OnDataChange events for the un-advised item. Exercises the lifecycle-ordering
/// parity CLAUDE.md singles out as a "do not synthesize" rule. /// parity CLAUDE.md singles out as a "do not synthesize" rule.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact] [LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_UnadviseRemoveItemUnregister_TeardownOrderingParity() 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 // only constrains events generated AFTER the teardown returned. So the
// "before" baseline is taken *after* a first settle window drains those // "before" baseline is taken *after* a first settle window drains those
// in-flight events, not before UnAdvise was issued (which races against // 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 // RecordingServerStreamWriter.Messages returns a snapshot copy under its
// own lock, so iterating after each settle window is safe without external // 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 /// parity surface the gateway must not "fix" — the test asserts the reply kind and
/// protocol status, not a fabricated outcome. /// protocol status, not a fabricated outcome.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact] [LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_WriteSecured_AuthenticatedRoundTripParity() public async Task GatewaySession_WithLiveWorker_WriteSecured_AuthenticatedRoundTripParity()
{ {
@@ -445,7 +450,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
File.Exists(workerExecutablePath), File.Exists(workerExecutablePath),
$"Live MXAccess worker executable was not found at {workerExecutablePath}. Build the worker or set {IntegrationTestEnvironment.LiveMxAccessWorkerExecutableVariableName}."); $"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 // 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 // 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 // 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 /// assert the reply kind plus a non-INVALID_REQUEST protocol status, and log the
/// HResult for the record. /// HResult for the record.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact] [LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_NewComCommands_RoundTripWithRealReplies() 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). /// to be non-empty and internally consistent (no crash, no dropped payload).
/// </para> /// </para>
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact] [LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_BufferedItem_AddsSetsIntervalAndAttemptsCapture() 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 /// must observe the abnormal exit, transition the session, and surface a non-empty
/// fault description rather than hanging or crashing. /// fault description rather than hanging or crashing.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact] [LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_AbnormalWorkerExit_MarksSessionFaulted() 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/ // message (they all begin with "Worker"); tighten to the pipe/disconnect/
// end-of-stream classifications that match THIS path, so a regression that // end-of-stream classifications that match THIS path, so a regression that
// routed an unrelated fault here would surface as a test failure rather // 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 // because HeartbeatGraceSeconds (15s) exceeds the StreamShutdownTimeout
// (10s) poll window, so a heartbeat-expired transition can never be // (10s) poll window, so a heartbeat-expired transition can never be
// observed inside this test. // 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 " $"Fault description '{observedFault}' did not match a known abnormal-exit classification "
+ "(expected 'pipe disconnected' or 'end of stream' from WorkerClient's EndOfStream path)."); + "(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 // — the chain that puts the session into Faulted goes through ReadEventsAsync
// propagating a WorkerClientException into EventStreamService, which calls // propagating a WorkerClientException into EventStreamService, which calls
// session.MarkFaulted. The gateway then maps the WorkerClientException to an // 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 /// 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 /// 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 /// 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> /// </param>
private async Task ShutDownAsync( private async Task ShutDownAsync(
GatewayServiceFixture fixture, GatewayServiceFixture fixture,
@@ -1607,6 +1615,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// </summary> /// </summary>
/// <param name="sessionId">The session identifier.</param> /// <param name="sessionId">The session identifier.</param>
/// <param name="session">The session if found; otherwise null.</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) public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
{ {
return _registry.TryGet(sessionId, out session); return _registry.TryGet(sessionId, out session);
@@ -1615,6 +1624,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// <summary> /// <summary>
/// Disposes the fixture resources and closes all sessions. /// Disposes the fixture resources and closes all sessions.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
foreach (GatewaySession session in _registry.Snapshot()) foreach (GatewaySession session in _registry.Snapshot())
@@ -1685,6 +1695,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// Records the message and signals any pending waiter. /// Records the message and signals any pending waiter.
/// </summary> /// </summary>
/// <param name="message">The message to write.</param> /// <param name="message">The message to write.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task WriteAsync(T message) public Task WriteAsync(T message)
{ {
lock (syncRoot) lock (syncRoot)
@@ -1867,7 +1878,11 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
return workerProcess; 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) public async Task WaitForProcessesAsync(TimeSpan timeout)
{ {
foreach (TestWorkerProcess process in processes) foreach (TestWorkerProcess process in processes)
@@ -1947,7 +1962,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
process.Kill(entireProcessTree); process.Kill(entireProcessTree);
} }
/// <inheritdoc /> /// <summary>Disposes the wrapped process.</summary>
public void Dispose() public void Dispose()
{ {
process.Dispose(); process.Dispose();
@@ -1959,13 +1974,15 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// </summary> /// </summary>
private sealed class TestOutputLoggerProvider(ITestOutputHelper output) : ILoggerProvider 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) public ILogger CreateLogger(string categoryName)
{ {
return new TestOutputLogger(output, categoryName); return new TestOutputLogger(output, categoryName);
} }
/// <inheritdoc /> /// <summary>No resources to release; provided to satisfy <see cref="ILoggerProvider"/>.</summary>
public void Dispose() public void Dispose()
{ {
} }
@@ -1978,20 +1995,31 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
ITestOutputHelper output, ITestOutputHelper output,
string categoryName) : ILogger 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) public IDisposable? BeginScope<TState>(TState state)
where TState : notnull where TState : notnull
{ {
return null; 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) public bool IsEnabled(LogLevel logLevel)
{ {
return logLevel >= LogLevel.Information; 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>( public void Log<TState>(
LogLevel logLevel, LogLevel logLevel,
EventId eventId, EventId eventId,
@@ -2015,7 +2043,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// <summary> /// <summary>
/// Buffering wrapper around an <see cref="ITestOutputHelper"/> that mirrors every line /// Buffering wrapper around an <see cref="ITestOutputHelper"/> that mirrors every line
/// written through it into a <see cref="StringBuilder"/> the test owns. The WriteSecured /// 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 /// <c>WriteSecured</c> payloads must never reach logs" rule a property of the entire
/// test output stream — gateway <see cref="ILogger"/> entries (echoed via /// test output stream — gateway <see cref="ILogger"/> entries (echoed via
/// <see cref="TestOutputLoggerProvider"/>), worker stdout/stderr (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 /// and composes the per-attribute subtag item addresses from the configured
/// subtag names. /// subtag names.
/// </summary> /// </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 public sealed class AlarmWatchListResolver : IAlarmWatchListResolver
{ {
private const string ProviderLiteral = "Galaxy"; 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> /// <summary>Determines whether the alarm reference matches this subscriber's filter.</summary>
/// <param name="reference">The alarm reference to match.</param> /// <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) public bool Matches(string reference)
{ {
return prefix.Length == 0 || reference.StartsWith(prefix, StringComparison.Ordinal); return prefix.Length == 0 || reference.StartsWith(prefix, StringComparison.Ordinal);
@@ -46,6 +46,7 @@ public interface IGatewayAlarmService
/// </summary> /// </summary>
/// <param name="alarmFilterPrefix">Optional alarm-reference prefix scoping the feed.</param> /// <param name="alarmFilterPrefix">Optional alarm-reference prefix scoping the feed.</param>
/// <param name="cancellationToken">Token that ends the subscription.</param> /// <param name="cancellationToken">Token that ends the subscription.</param>
/// <returns>An asynchronous stream of alarm feed messages.</returns>
IAsyncEnumerable<AlarmFeedMessage> StreamAsync( IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
string? alarmFilterPrefix, string? alarmFilterPrefix,
CancellationToken cancellationToken); CancellationToken cancellationToken);
@@ -57,6 +58,7 @@ public interface IGatewayAlarmService
/// </summary> /// </summary>
/// <param name="request">The acknowledge request.</param> /// <param name="request">The acknowledge request.</param>
/// <param name="cancellationToken">Token to cancel the call.</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( Task<AcknowledgeAlarmReply> AcknowledgeAsync(
AcknowledgeAlarmRequest request, AcknowledgeAlarmRequest request,
CancellationToken cancellationToken); CancellationToken cancellationToken);
@@ -9,11 +9,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
private const int MinimumMaxMessageBytes = 1024; private const int MinimumMaxMessageBytes = 1024;
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024; private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
/// <summary> /// <inheritdoc />
/// Validates gateway configuration options.
/// </summary>
/// <param name="builder">The accumulator to record failures on.</param>
/// <param name="options">Gateway options to validate.</param>
protected override void Validate(ValidationBuilder builder, GatewayOptions options) protected override void Validate(ValidationBuilder builder, GatewayOptions options)
{ {
ValidateAuthentication(options.Authentication, builder); ValidateAuthentication(options.Authentication, builder);
@@ -8,5 +8,6 @@ public interface IGatewayConfigurationProvider
/// <summary> /// <summary>
/// Returns the validated and effective gateway configuration. /// Returns the validated and effective gateway configuration.
/// </summary> /// </summary>
/// <returns>The validated and effective gateway configuration.</returns>
EffectiveGatewayConfiguration GetEffectiveConfiguration(); EffectiveGatewayConfiguration GetEffectiveConfiguration();
} }
@@ -33,7 +33,7 @@ public sealed class SessionOptions
/// external subscriber, so a session whose only remaining subscriber is the dashboard /// 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 /// 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 /// 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. /// option controls retention and expiry only.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
@@ -38,7 +38,8 @@ public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
await ConnectHubAsync().ConfigureAwait(false); 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() public async ValueTask DisposeAsync()
{ {
if (_hub is not null) if (_hub is not null)
@@ -6,6 +6,7 @@ public sealed class DashboardApiKeyAuthorization
{ {
/// <summary>Determines whether the user can manage API keys.</summary> /// <summary>Determines whether the user can manage API keys.</summary>
/// <param name="user">The authenticated user principal.</param> /// <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) public bool CanManage(ClaimsPrincipal user)
{ {
if (user.Identity?.IsAuthenticated != true) 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 UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
private const string PepperUnavailableMarker = "pepper unavailable"; private const string PepperUnavailableMarker = "pepper unavailable";
/// <summary>Determines whether the user can manage API keys.</summary> /// <inheritdoc />
/// <param name="user">The authenticated user principal.</param>
public bool CanManage(ClaimsPrincipal user) public bool CanManage(ClaimsPrincipal user)
{ {
return authorization.CanManage(user); return authorization.CanManage(user);
} }
/// <summary>Creates an API key asynchronously.</summary> /// <inheritdoc />
/// <param name="user">The authenticated user principal.</param>
/// <param name="request">The request payload.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public async Task<DashboardApiKeyManagementResult> CreateAsync( public async Task<DashboardApiKeyManagementResult> CreateAsync(
ClaimsPrincipal user, ClaimsPrincipal user,
DashboardApiKeyManagementRequest request, DashboardApiKeyManagementRequest request,
@@ -82,10 +78,7 @@ public sealed class DashboardApiKeyManagementService(
} }
} }
/// <summary>Revokes an API key asynchronously.</summary> /// <inheritdoc />
/// <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>
public async Task<DashboardApiKeyManagementResult> RevokeAsync( public async Task<DashboardApiKeyManagementResult> RevokeAsync(
ClaimsPrincipal user, ClaimsPrincipal user,
string keyId, string keyId,
@@ -120,10 +113,7 @@ public sealed class DashboardApiKeyManagementService(
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked."); : DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
} }
/// <summary>Rotates an API key secret asynchronously.</summary> /// <inheritdoc />
/// <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>
public async Task<DashboardApiKeyManagementResult> RotateAsync( public async Task<DashboardApiKeyManagementResult> RotateAsync(
ClaimsPrincipal user, ClaimsPrincipal user,
string keyId, string keyId,
@@ -170,10 +160,7 @@ public sealed class DashboardApiKeyManagementService(
} }
} }
/// <summary>Deletes a revoked API key asynchronously.</summary> /// <inheritdoc />
/// <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>
public async Task<DashboardApiKeyManagementResult> DeleteAsync( public async Task<DashboardApiKeyManagementResult> DeleteAsync(
ClaimsPrincipal user, ClaimsPrincipal user,
string keyId, string keyId,
@@ -242,15 +229,15 @@ public sealed class DashboardApiKeyManagementService(
/// <summary> /// <summary>
/// Emits the dashboard's own canonical <see cref="AuditEvent"/> for a <c>dashboard-*</c> op /// 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"/> /// 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 /// 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. /// behaviour is preserved, both rows now land in the canonical <c>audit_event</c> store.
/// </summary> /// </summary>
/// <remarks> /// <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 /// 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> /// </remarks>
private async Task WriteDashboardAuditAsync( private async Task WriteDashboardAuditAsync(
ClaimsPrincipal user, ClaimsPrincipal user,
@@ -23,6 +23,7 @@ public sealed record DashboardAuthenticationResult(
/// Creates a successful authentication result. /// Creates a successful authentication result.
/// </summary> /// </summary>
/// <param name="principal">Authenticated principal.</param> /// <param name="principal">Authenticated principal.</param>
/// <returns>A successful <see cref="DashboardAuthenticationResult"/> wrapping <paramref name="principal"/>.</returns>
public static DashboardAuthenticationResult Success(ClaimsPrincipal principal) public static DashboardAuthenticationResult Success(ClaimsPrincipal principal)
{ {
return new DashboardAuthenticationResult(true, principal, null); return new DashboardAuthenticationResult(true, principal, null);
@@ -32,6 +33,7 @@ public sealed record DashboardAuthenticationResult(
/// Creates a failed authentication result. /// Creates a failed authentication result.
/// </summary> /// </summary>
/// <param name="failureMessage">Diagnostic message describing the failure.</param> /// <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) public static DashboardAuthenticationResult Fail(string failureMessage)
{ {
return new DashboardAuthenticationResult(false, null, 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"/>). /// shaped exactly as before (see <see cref="CreatePrincipal"/>).
/// </summary> /// </summary>
/// <param name="ldapAuthService">Shared LDAP bind-then-search provider.</param> /// <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> /// <param name="logger">Logger for diagnostic, credential-free login outcomes.</param>
public sealed class DashboardAuthenticator( public sealed class DashboardAuthenticator(
ILdapAuthService ldapAuthService, ILdapAuthService ldapAuthService,
@@ -107,11 +107,11 @@ public sealed class DashboardAuthenticator(
[ [
// Keep NameIdentifier so any existing read-site that uses it continues to work. // Keep NameIdentifier so any existing read-site that uses it continues to work.
new Claim(ClaimTypes.NameIdentifier, username), new Claim(ClaimTypes.NameIdentifier, username),
// Canonical login-username claim (Task 1.5). // Canonical login-username claim.
new Claim(ZbClaimTypes.Username, username), new Claim(ZbClaimTypes.Username, username),
// ZbClaimTypes.Name == ClaimTypes.Name — drives Identity.Name resolution. // ZbClaimTypes.Name == ClaimTypes.Name — drives Identity.Name resolution.
new Claim(ZbClaimTypes.Name, displayName), 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), new Claim(ZbClaimTypes.DisplayName, displayName),
]; ];
@@ -6,6 +6,7 @@ public static class DashboardConnectionStringDisplay
{ {
/// <summary>Returns a sanitized Galaxy Repository connection string for display.</summary> /// <summary>Returns a sanitized Galaxy Repository connection string for display.</summary>
/// <param name="connectionString">The connection string to sanitize.</param> /// <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) public static string GalaxyRepositoryConnectionString(string connectionString)
{ {
try try
@@ -17,7 +17,10 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class DashboardGroupRoleMapper(IOptions<GatewayOptions> options) public sealed class DashboardGroupRoleMapper(IOptions<GatewayOptions> options)
: IGroupRoleMapper<string> : 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( public Task<GroupRoleMapping<string>> MapAsync(
IReadOnlyList<string> groups, IReadOnlyList<string> groups,
CancellationToken ct) CancellationToken ct)
@@ -16,6 +16,7 @@ internal static class DashboardGroupRoleMapping
/// </summary> /// </summary>
/// <param name="groups">The collection of LDAP groups the user belongs to.</param> /// <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> /// <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( internal static IReadOnlyList<string> MapGroupsToRoles(
IEnumerable<string> groups, IEnumerable<string> groups,
IReadOnlyDictionary<string, string> groupToRole) IReadOnlyDictionary<string, string> groupToRole)
@@ -30,25 +31,6 @@ internal static class DashboardGroupRoleMapping
{ {
string normalizedGroup = group.Trim(); 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) if (groupToRole.TryGetValue(normalizedGroup, out string? mapped)
|| groupToRole.TryGetValue(ExtractFirstRdnValue(normalizedGroup), out 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> /// <summary>Extracts the first RDN value from a distinguished name.</summary>
/// <param name="distinguishedName">The LDAP distinguished name.</param> /// <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) internal static string ExtractFirstRdnValue(string distinguishedName)
{ {
int equalsIndex = distinguishedName.IndexOf('='); 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() public async ValueTask DisposeAsync()
{ {
if (_disposed) if (_disposed)
@@ -8,7 +8,7 @@ public static class DashboardRoles
{ {
/// <summary> /// <summary>
/// Read-write access: API-key CRUD, settings, any state-changing UI. /// 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. /// rename, the operations this role authorizes are unchanged.
/// </summary> /// </summary>
public const string Admin = "Administrator"; 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 /// from the <c>MxGateway:Ldap</c> section. Also read to select the dashboard
/// authentication scheme via the <c>MxGateway:Dashboard:DisableLogin</c> dev flag. /// authentication scheme via the <c>MxGateway:Dashboard:DisableLogin</c> dev flag.
/// </param> /// </param>
/// <returns>The service collection, for chaining.</returns>
public static IServiceCollection AddGatewayDashboard( public static IServiceCollection AddGatewayDashboard(
this IServiceCollection services, this IServiceCollection services,
IConfiguration configuration) IConfiguration configuration)
@@ -6,6 +6,7 @@ public sealed record DashboardSessionAdminResult(
{ {
/// <summary>Creates a successful result with the given message.</summary> /// <summary>Creates a successful result with the given message.</summary>
/// <param name="message">The result message.</param> /// <param name="message">The result message.</param>
/// <returns>A successful <see cref="DashboardSessionAdminResult"/>.</returns>
public static DashboardSessionAdminResult Success(string message) public static DashboardSessionAdminResult Success(string message)
{ {
return new DashboardSessionAdminResult(true, message); return new DashboardSessionAdminResult(true, message);
@@ -13,6 +14,7 @@ public sealed record DashboardSessionAdminResult(
/// <summary>Creates a failed result with the given message.</summary> /// <summary>Creates a failed result with the given message.</summary>
/// <param name="message">The result message.</param> /// <param name="message">The result message.</param>
/// <returns>A failed <see cref="DashboardSessionAdminResult"/>.</returns>
public static DashboardSessionAdminResult Fail(string message) public static DashboardSessionAdminResult Fail(string message)
{ {
return new DashboardSessionAdminResult(false, message); return new DashboardSessionAdminResult(false, message);
@@ -93,10 +93,6 @@ public sealed class DashboardSessionAdminService(
} }
catch (Exception exception) 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( _logger.LogWarning(
exception, exception,
"Dashboard admin {Actor} close failed unexpectedly for session {SessionId}.", "Dashboard admin {Actor} close failed unexpectedly for session {SessionId}.",
@@ -162,12 +158,6 @@ public sealed class DashboardSessionAdminService(
} }
catch (Exception exception) 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( _logger.LogWarning(
exception, exception,
"Dashboard admin {Actor} kill failed unexpectedly for session {SessionId}.", "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 // 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) // 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 // 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; private GalaxyBreakdownCache? _galaxyBreakdownCache;
/// <summary>Initializes a new instance of the DashboardSnapshotService class.</summary> /// <summary>Initializes a new instance of the DashboardSnapshotService class.</summary>
@@ -72,10 +72,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
_logger = logger ?? NullLogger<DashboardSnapshotService>.Instance; _logger = logger ?? NullLogger<DashboardSnapshotService>.Instance;
} }
/// <summary> /// <inheritdoc />
/// Gets a current dashboard snapshot of gateway state.
/// </summary>
/// <returns>Dashboard snapshot.</returns>
public DashboardSnapshot GetSnapshot() public DashboardSnapshot GetSnapshot()
{ {
DateTimeOffset generatedAt = _timeProvider.GetUtcNow(); DateTimeOffset generatedAt = _timeProvider.GetUtcNow();
@@ -133,11 +130,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
private sealed record GalaxyBreakdownCache(long Sequence, GalaxyObjectBreakdown Breakdown); private sealed record GalaxyBreakdownCache(long Sequence, GalaxyObjectBreakdown Breakdown);
/// <summary> /// <inheritdoc />
/// Watches dashboard snapshots at regular intervals asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of dashboard snapshots.</returns>
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync( public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken) [EnumeratorCancellation] CancellationToken cancellationToken)
{ {
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// protector; no separate signing keys are configured. /// protector; no separate signing keys are configured.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Server-043: this service is registered as a singleton in /// This service is registered as a singleton in
/// <see cref="DashboardServiceCollectionExtensions.AddGatewayDashboard"/> and /// <see cref="DashboardServiceCollectionExtensions.AddGatewayDashboard"/> and
/// is shared by two consumer scopes: <c>DashboardHubConnectionFactory</c> /// is shared by two consumer scopes: <c>DashboardHubConnectionFactory</c>
/// (scoped, per-circuit; calls <see cref="Issue"/> from the cookie-authenticated /// (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> /// <summary>Issues a bearer token carrying the user's identity and roles.</summary>
/// <param name="user">The claims principal representing the user.</param> /// <param name="user">The claims principal representing the user.</param>
/// <returns>The data-protected bearer token string.</returns>
public string Issue(ClaimsPrincipal user) public string Issue(ClaimsPrincipal user)
{ {
ArgumentNullException.ThrowIfNull(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> /// <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> /// <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) public ClaimsPrincipal? Validate(string? token)
{ {
if (string.IsNullOrEmpty(token)) if (string.IsNullOrEmpty(token))
@@ -67,11 +69,6 @@ public sealed class HubTokenService
return null; 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)) if (string.IsNullOrEmpty(payload.Name) && string.IsNullOrEmpty(payload.NameIdentifier))
{ {
return null; return null;
@@ -14,9 +14,7 @@ public sealed class DashboardEventBroadcaster(
IHubContext<EventsHub> hubContext, IHubContext<EventsHub> hubContext,
ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster
{ {
/// <summary>Publishes an MX event to connected dashboard clients.</summary> /// <inheritdoc />
/// <param name="sessionId">The session identifier.</param>
/// <param name="mxEvent">The MX event to publish.</param>
public void Publish(string sessionId, MxEvent mxEvent) public void Publish(string sessionId, MxEvent mxEvent)
{ {
if (string.IsNullOrEmpty(sessionId) || mxEvent is null) 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. /// gateway process; clients listen via the hub.
/// </summary> /// </summary>
/// <remarks> /// <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, /// a reconnect loop with a configurable retry delay (5s by default,
/// mirroring <see cref="AlarmsHubPublisher"/>). A transient failure inside /// mirroring <see cref="AlarmsHubPublisher"/>). A transient failure inside
/// <see cref="IDashboardSnapshotService.WatchSnapshotsAsync"/> — e.g. a /// <see cref="IDashboardSnapshotService.WatchSnapshotsAsync"/> — e.g. a
@@ -27,7 +27,7 @@ public sealed class EventsHub : Hub
/// client. /// client.
/// </summary> /// </summary>
/// <remarks> /// <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 /// (<c>HubClientsPolicy</c>) only checks that the caller carries one of
/// the dashboard roles (Admin or Viewer); both roles may subscribe to /// the dashboard roles (Admin or Viewer); both roles may subscribe to
/// any session id they choose. This is acceptable today because (a) the /// 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. /// dedicated authorization policy applied to the hub method itself.
/// </remarks> /// </remarks>
/// <param name="sessionId">Session id to subscribe the caller to.</param> /// <param name="sessionId">Session id to subscribe the caller to.</param>
/// <returns>A task representing the subscription operation.</returns>
public Task SubscribeSession(string sessionId) public Task SubscribeSession(string sessionId)
{ {
if (string.IsNullOrWhiteSpace(sessionId)) if (string.IsNullOrWhiteSpace(sessionId))
@@ -11,6 +11,7 @@ public interface IDashboardAuthenticator
/// <param name="username">Username to authenticate.</param> /// <param name="username">Username to authenticate.</param>
/// <param name="password">Password to authenticate.</param> /// <param name="password">Password to authenticate.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The authentication result.</returns>
Task<DashboardAuthenticationResult> AuthenticateAsync( Task<DashboardAuthenticationResult> AuthenticateAsync(
string? username, string? username,
string? password, string? password,
@@ -12,11 +12,13 @@ public interface IDashboardBrowseService
{ {
/// <summary>Returns root browse nodes (objects with no parent).</summary> /// <summary>Returns root browse nodes (objects with no parent).</summary>
/// <param name="filter">Filter arguments forwarded to the projector.</param> /// <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); BrowseLevelResult GetRoots(BrowseFilterArgs filter);
/// <summary>Returns the direct children of the given parent gobject id.</summary> /// <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="parentGobjectId">The Galaxy gobject id of the parent to expand.</param>
/// <param name="filter">Filter arguments forwarded to the projector.</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); BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter);
/// <summary>Current Galaxy cache sequence. Bumps after each successful refresh.</summary> /// <summary>Current Galaxy cache sequence. Bumps after each successful refresh.</summary>
@@ -8,11 +8,13 @@ public interface IDashboardSnapshotService
/// <summary> /// <summary>
/// Gets the current dashboard snapshot. /// Gets the current dashboard snapshot.
/// </summary> /// </summary>
/// <returns>The current dashboard snapshot.</returns>
DashboardSnapshot GetSnapshot(); DashboardSnapshot GetSnapshot();
/// <summary> /// <summary>
/// Watches for changes to the dashboard state as an async enumerable. /// Watches for changes to the dashboard state as an async enumerable.
/// </summary> /// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>An asynchronous stream of dashboard snapshots.</returns>
IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken); IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken);
} }
@@ -12,9 +12,15 @@ public sealed class AuthStoreHealthCheck : IHealthCheck
{ {
private readonly AuthSqliteConnectionFactory _connectionFactory; 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) => public AuthStoreHealthCheck(AuthSqliteConnectionFactory connectionFactory) =>
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(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( public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, HealthCheckContext context,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -19,6 +19,7 @@ public static class GatewayLogRedactor
/// Determines whether a command method bears credentials. /// Determines whether a command method bears credentials.
/// </summary> /// </summary>
/// <param name="commandMethod">The command method name to check.</param> /// <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) public static bool IsCredentialBearingCommand(string? commandMethod)
{ {
return commandMethod is not null return commandMethod is not null
@@ -29,6 +30,7 @@ public static class GatewayLogRedactor
/// Redacts the API key secret portion of a Bearer authorization header. /// Redacts the API key secret portion of a Bearer authorization header.
/// </summary> /// </summary>
/// <param name="authorizationHeader">The authorization header value to redact.</param> /// <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) public static string? RedactApiKey(string? authorizationHeader)
{ {
if (string.IsNullOrWhiteSpace(authorizationHeader)) if (string.IsNullOrWhiteSpace(authorizationHeader))
@@ -62,6 +64,7 @@ public static class GatewayLogRedactor
/// Redacts the client identity if it contains an API key. /// Redacts the client identity if it contains an API key.
/// </summary> /// </summary>
/// <param name="clientIdentity">The client identity string to redact.</param> /// <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) public static string? RedactClientIdentity(string? clientIdentity)
{ {
if (string.IsNullOrWhiteSpace(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="commandMethod">The command method name to check for credentials.</param>
/// <param name="value">The command value to redact.</param> /// <param name="value">The command value to redact.</param>
/// <param name="valueLoggingEnabled">Whether value logging is enabled.</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( public static object? RedactCommandValue(
string? commandMethod, string? commandMethod,
object? value, object? value,
@@ -8,6 +8,7 @@ public sealed record GatewayLogScope(
string? ClientIdentity = null) string? ClientIdentity = null)
{ {
/// <summary>Converts the log scope to a dictionary with redacted sensitive fields.</summary> /// <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() public IReadOnlyDictionary<string, object?> ToDictionary()
{ {
Dictionary<string, object?> values = []; 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> /// <summary>Adds gateway request logging scope middleware that reads correlation headers and redacts sensitive data.</summary>
/// <param name="app">Application builder.</param> /// <param name="app">Application builder.</param>
/// <returns>The same <see cref="IApplicationBuilder"/>, for chaining.</returns>
public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app) public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app)
{ {
ArgumentNullException.ThrowIfNull(app); ArgumentNullException.ThrowIfNull(app);
@@ -13,13 +13,10 @@ public sealed class EventStreamService(
IOptions<GatewayOptions> options, IOptions<GatewayOptions> options,
GatewayMetrics metrics) : IEventStreamService GatewayMetrics metrics) : IEventStreamService
{ {
/// <summary> /// <inheritdoc />
/// Streams events from a session to the client asynchronously.
/// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Task 4 rewired this from a per-RPC channel that drained the session directly /// This reads the subscriber's lease channel fed by the session's single
/// to reading the subscriber's lease channel fed by the session's single
/// <see cref="SessionEventDistributor"/> pump. The pump owns the single drain of /// <see cref="SessionEventDistributor"/> pump. The pump owns the single drain of
/// the worker event stream and the worker→public mapping (mirroring the former /// the worker event stream and the worker→public mapping (mirroring the former
/// <c>ProduceEventsAsync</c>); this loop is the per-subscriber boundary that /// <c>ProduceEventsAsync</c>); this loop is the per-subscriber boundary that
@@ -27,33 +24,28 @@ public sealed class EventStreamService(
/// and the backpressure/overflow policy. /// and the backpressure/overflow policy.
/// </para> /// </para>
/// <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 /// first-class internal subscriber on the session's
/// <see cref="SessionEventDistributor"/> (see <c>GatewaySession.StartDashboardMirror</c>), /// <see cref="SessionEventDistributor"/> (see <c>GatewaySession.StartDashboardMirror</c>),
/// so it receives session events even when no gRPC client is streaming. This loop no /// so it receives session events even when no gRPC client is streaming. This loop
/// longer mirrors to the dashboard. One deliberate consequence: the dashboard now sees /// does not mirror to the dashboard. One deliberate consequence: the dashboard sees
/// RAW session events, not the per-gRPC-subscriber <c>AfterWorkerSequence</c>-filtered /// 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 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 /// view that should see the session's full event activity.
/// the separate Task 18).
/// </para> /// </para>
/// <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 /// 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 /// applies the per-subscriber backpressure policy and completes this subscriber's
/// channel with a <see cref="SessionManagerException"/> /// channel with a <see cref="SessionManagerException"/>
/// (<see cref="SessionManagerErrorCode.EventQueueOverflow"/>). That terminal fault /// (<see cref="SessionManagerErrorCode.EventQueueOverflow"/>). That terminal fault
/// surfaces here when the reader's <c>MoveNextAsync</c> throws, and — like the /// surfaces here when the reader's <c>MoveNextAsync</c> throws, and it propagates to
/// pre-epic per-RPC overflow — it propagates to the gRPC client unchanged. The /// the gRPC client unchanged. The overflow metric, and (in the legacy
/// overflow metric, and (in the legacy single-subscriber FailFast case) the session /// single-subscriber FailFast case) the session fault + fault metric, are recorded by
/// fault + fault metric, are recorded by the distributor's overflow handler so the /// the distributor's overflow handler so the session, the pump, and other subscribers
/// session, the pump, and other subscribers are isolated from this subscriber's /// are isolated from this subscriber's slowness.
/// slowness.
/// </para> /// </para>
/// </remarks> /// </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( public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request, StreamEventsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken) [EnumeratorCancellation] CancellationToken cancellationToken)
@@ -72,7 +64,7 @@ public sealed class EventStreamService(
// the session's own SessionEventStreaming.AllowMultipleEventSubscribers field — the // the session's own SessionEventStreaming.AllowMultipleEventSubscribers field — the
// same source the distributor uses — so the two cannot diverge. // 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 // 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 // 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 // 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 // channel is dropped exactly once, while no newer event is skipped. See
// SessionEventDistributor.RegisterWithReplay for the full argument. // 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. // a plain attach, no replay, no sentinel, and the live filter watermark stays 0.
ulong afterWorkerSequence = request.AfterWorkerSequence; ulong afterWorkerSequence = request.AfterWorkerSequence;
IEventSubscriberLease subscriber; IEventSubscriberLease subscriber;
@@ -157,7 +149,7 @@ public sealed class EventStreamService(
{ {
// The distributor pump completes every subscriber channel with the source // The distributor pump completes every subscriber channel with the source
// fault when the worker event stream terminates abnormally; that surfaces // 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 // session and record the metric, then propagate the terminal fault to the
// gRPC client. // gRPC client.
session.MarkFaulted(workerException.Message); 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 // 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 // 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 // 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. // backlog; falling back to a no-op delta if a channel ever cannot count.
int backlog = subscriber.Reader.CanCount ? subscriber.Reader.Count : streamQueueDepth; int backlog = subscriber.Reader.CanCount ? subscriber.Reader.Count : streamQueueDepth;
@@ -12,6 +12,7 @@ public interface IEventStreamService
/// </summary> /// </summary>
/// <param name="request">Request payload.</param> /// <param name="request">Request payload.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The events emitted for the requested session.</returns>
IAsyncEnumerable<MxEvent> StreamEventsAsync( IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request, StreamEventsRequest request,
CancellationToken cancellationToken); CancellationToken cancellationToken);
@@ -164,15 +164,6 @@ public sealed class MxAccessGatewayService(
} }
/// <inheritdoc /> /// <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( public override async Task<AcknowledgeAlarmReply> AcknowledgeAlarm(
AcknowledgeAlarmRequest request, AcknowledgeAlarmRequest request,
ServerCallContext context) ServerCallContext context)
@@ -195,14 +186,6 @@ public sealed class MxAccessGatewayService(
} }
/// <inheritdoc /> /// <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( public override async Task StreamAlarms(
StreamAlarmsRequest request, StreamAlarmsRequest request,
IServerStreamWriter<AlarmFeedMessage> responseStream, IServerStreamWriter<AlarmFeedMessage> responseStream,
@@ -226,12 +209,6 @@ public sealed class MxAccessGatewayService(
} }
/// <inheritdoc /> /// <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( public override async Task QueryActiveAlarms(
QueryActiveAlarmsRequest request, QueryActiveAlarmsRequest request,
IServerStreamWriter<ActiveAlarmSnapshot> responseStream, IServerStreamWriter<ActiveAlarmSnapshot> responseStream,
@@ -23,6 +23,7 @@ public sealed class MxAccessGrpcMapper
/// Maps a gRPC MX command request to a worker command. /// Maps a gRPC MX command request to a worker command.
/// </summary> /// </summary>
/// <param name="request">Request payload.</param> /// <param name="request">Request payload.</param>
/// <returns>The mapped <see cref="WorkerCommand"/> ready for worker dispatch.</returns>
public WorkerCommand MapCommand(MxCommandRequest request) public WorkerCommand MapCommand(MxCommandRequest request)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
@@ -39,6 +40,7 @@ public sealed class MxAccessGrpcMapper
/// Maps a worker command reply to a gRPC MX command reply. /// Maps a worker command reply to a gRPC MX command reply.
/// </summary> /// </summary>
/// <param name="reply">Worker command reply.</param> /// <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) public MxCommandReply MapCommandReply(WorkerCommandReply reply)
{ {
ArgumentNullException.ThrowIfNull(reply); ArgumentNullException.ThrowIfNull(reply);
@@ -58,6 +60,7 @@ public sealed class MxAccessGrpcMapper
/// Maps a worker event to a gRPC MX event. /// Maps a worker event to a gRPC MX event.
/// </summary> /// </summary>
/// <param name="workerEvent">Worker event to map.</param> /// <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) public MxEvent MapEvent(WorkerEvent workerEvent)
{ {
ArgumentNullException.ThrowIfNull(workerEvent); ArgumentNullException.ThrowIfNull(workerEvent);
@@ -73,6 +76,7 @@ public sealed class MxAccessGrpcMapper
/// Creates an OK protocol status. /// Creates an OK protocol status.
/// </summary> /// </summary>
/// <param name="message">Status message.</param> /// <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") public static ProtocolStatus Ok(string message = "OK")
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -86,6 +90,7 @@ public sealed class MxAccessGrpcMapper
/// Creates an InvalidRequest protocol status. /// Creates an InvalidRequest protocol status.
/// </summary> /// </summary>
/// <param name="message">Status message.</param> /// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.InvalidRequest"/>.</returns>
public static ProtocolStatus InvalidRequest(string message) public static ProtocolStatus InvalidRequest(string message)
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -99,6 +104,7 @@ public sealed class MxAccessGrpcMapper
/// Creates a SessionNotFound protocol status. /// Creates a SessionNotFound protocol status.
/// </summary> /// </summary>
/// <param name="message">Status message.</param> /// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.SessionNotFound"/>.</returns>
public static ProtocolStatus SessionNotFound(string message) public static ProtocolStatus SessionNotFound(string message)
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -112,6 +118,7 @@ public sealed class MxAccessGrpcMapper
/// Creates a SessionNotReady protocol status. /// Creates a SessionNotReady protocol status.
/// </summary> /// </summary>
/// <param name="message">Status message.</param> /// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.SessionNotReady"/>.</returns>
public static ProtocolStatus SessionNotReady(string message) public static ProtocolStatus SessionNotReady(string message)
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -125,6 +132,7 @@ public sealed class MxAccessGrpcMapper
/// Creates a WorkerUnavailable protocol status. /// Creates a WorkerUnavailable protocol status.
/// </summary> /// </summary>
/// <param name="message">Status message.</param> /// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.WorkerUnavailable"/>.</returns>
public static ProtocolStatus WorkerUnavailable(string message) public static ProtocolStatus WorkerUnavailable(string message)
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -138,6 +146,7 @@ public sealed class MxAccessGrpcMapper
/// Creates a Timeout protocol status. /// Creates a Timeout protocol status.
/// </summary> /// </summary>
/// <param name="message">Status message.</param> /// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.Timeout"/>.</returns>
public static ProtocolStatus Timeout(string message) public static ProtocolStatus Timeout(string message)
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -151,6 +160,7 @@ public sealed class MxAccessGrpcMapper
/// Creates a Canceled protocol status. /// Creates a Canceled protocol status.
/// </summary> /// </summary>
/// <param name="message">Status message.</param> /// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.Canceled"/>.</returns>
public static ProtocolStatus Canceled(string message) public static ProtocolStatus Canceled(string message)
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -164,6 +174,7 @@ public sealed class MxAccessGrpcMapper
/// Creates a ProtocolViolation protocol status. /// Creates a ProtocolViolation protocol status.
/// </summary> /// </summary>
/// <param name="message">Status message.</param> /// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.ProtocolViolation"/>.</returns>
public static ProtocolStatus ProtocolViolation(string message) public static ProtocolStatus ProtocolViolation(string message)
{ {
return new ProtocolStatus 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 /// (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 /// <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 /// 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> /// </summary>
internal Meter Meter => _meter; 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> /// <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) public void SetAlarmProviderMode(int mode)
{ {
lock (_syncRoot) { _alarmProviderMode = mode; } lock (_syncRoot) { _alarmProviderMode = mode; }
@@ -421,6 +422,7 @@ public sealed class GatewayMetrics : IDisposable
/// <summary> /// <summary>
/// Returns a snapshot of all current metric values. /// Returns a snapshot of all current metric values.
/// </summary> /// </summary>
/// <returns>The current metrics snapshot.</returns>
public GatewayMetricsSnapshot GetSnapshot() public GatewayMetricsSnapshot GetSnapshot()
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -19,7 +19,13 @@ public sealed class CanonicalAuditWriter(
SqliteCanonicalAuditStore store, SqliteCanonicalAuditStore store,
ILogger<CanonicalAuditWriter> logger) : IAuditWriter 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) public async Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
{ {
ArgumentNullException.ThrowIfNull(auditEvent); ArgumentNullException.ThrowIfNull(auditEvent);
@@ -43,7 +43,10 @@ public sealed class CanonicalForwardingApiKeyAuditStore(
/// <summary>The library's keyless schema-init event type.</summary> /// <summary>The library's keyless schema-init event type.</summary>
private const string InitDbEventType = "init-db"; 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) public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct)
{ {
ArgumentNullException.ThrowIfNull(entry); ArgumentNullException.ThrowIfNull(entry);
@@ -71,7 +74,10 @@ public sealed class CanonicalForwardingApiKeyAuditStore(
await auditWriter.WriteAsync(auditEvent, ct).ConfigureAwait(false); 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) public async Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
{ {
IReadOnlyList<AuditEvent> events = await store.ListRecentAsync(limit, ct).ConfigureAwait(false); 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> /// <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="auditEvent">The canonical event to persist.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</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) public async Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken)
{ {
ArgumentNullException.ThrowIfNull(auditEvent); ArgumentNullException.ThrowIfNull(auditEvent);
@@ -79,6 +80,7 @@ public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connec
/// <summary>Returns the most recent canonical audit events, newest first.</summary> /// <summary>Returns the most recent canonical audit events, newest first.</summary>
/// <param name="limit">Maximum number of events to return.</param> /// <param name="limit">Maximum number of events to return.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</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) public async Task<IReadOnlyList<AuditEvent>> ListRecentAsync(int limit, CancellationToken cancellationToken)
{ {
if (limit <= 0) 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="command">API key administration command to execute.</param>
/// <param name="output">Text writer for command output.</param> /// <param name="output">Text writer for command output.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</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( public async Task<int> RunAsync(
ApiKeyAdminCommand command, ApiKeyAdminCommand command,
TextWriter output, TextWriter output,
@@ -6,6 +6,7 @@ public sealed record ApiKeyAdminParseResult(
string? Error) string? Error)
{ {
/// <summary>Returns a result indicating the input was not an API key command.</summary> /// <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() public static ApiKeyAdminParseResult NotApiKeyCommand()
{ {
return new ApiKeyAdminParseResult(false, null, null); 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> /// <summary>Returns a successful parse result with the parsed API key command.</summary>
/// <param name="command">Parsed API key administration command.</param> /// <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) public static ApiKeyAdminParseResult Success(ApiKeyAdminCommand command)
{ {
return new ApiKeyAdminParseResult(true, command, null); 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> /// <summary>Returns a parse result with the specified error message.</summary>
/// <param name="error">Error message describing the parse failure.</param> /// <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) public static ApiKeyAdminParseResult Fail(string error)
{ {
return new ApiKeyAdminParseResult(true, null, 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> /// <summary>Serializes API key constraints to JSON, or returns null if the constraints are empty.</summary>
/// <param name="constraints">The constraints to serialize.</param> /// <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) public static string? Serialize(ApiKeyConstraints constraints)
{ {
ArgumentNullException.ThrowIfNull(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> /// <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> /// <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) public static ApiKeyConstraints Deserialize(string? json)
{ {
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
@@ -66,10 +66,6 @@ public static class AuthStoreServiceCollectionExtensions
// migrator and the migration hosted service. // migrator and the migration hosted service.
services.AddZbApiKeyAuth(effectiveConfig, AuthenticationSectionPath); 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 => services.AddSingleton(sp =>
new SqliteCanonicalAuditStore(sp.GetRequiredService<AuthSqliteConnectionFactory>())); new SqliteCanonicalAuditStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
// Resolve the logger defensively: the production host always registers ILogger<T>, but the // Resolve the logger defensively: the production host always registers ILogger<T>, but the
@@ -16,10 +16,7 @@ public sealed class ConstraintEnforcer(
IGalaxyHierarchyCache cache, IGalaxyHierarchyCache cache,
IAuditWriter auditWriter) : IConstraintEnforcer IAuditWriter auditWriter) : IConstraintEnforcer
{ {
/// <summary>Checks read constraints on a tag address.</summary> /// <inheritdoc />
/// <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>
public Task<ConstraintFailure?> CheckReadTagAsync( public Task<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
string tagAddress, string tagAddress,
@@ -34,12 +31,7 @@ public sealed class ConstraintEnforcer(
return Task.FromResult(CheckReadTarget(constraints, tagAddress)); return Task.FromResult(CheckReadTarget(constraints, tagAddress));
} }
/// <summary>Checks read constraints on a server and item handle.</summary> /// <inheritdoc />
/// <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>
public Task<ConstraintFailure?> CheckReadHandleAsync( public Task<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
GatewaySession session, GatewaySession session,
@@ -61,12 +53,7 @@ public sealed class ConstraintEnforcer(
return Task.FromResult(CheckReadTarget(constraints, registration.TagAddress)); return Task.FromResult(CheckReadTarget(constraints, registration.TagAddress));
} }
/// <summary>Checks write constraints on a server and item handle.</summary> /// <inheritdoc />
/// <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>
public Task<ConstraintFailure?> CheckWriteHandleAsync( public Task<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
GatewaySession session, GatewaySession session,
@@ -115,18 +102,7 @@ public sealed class ConstraintEnforcer(
return Task.FromResult<ConstraintFailure?>(null); return Task.FromResult<ConstraintFailure?>(null);
} }
/// <summary>Records a constraint denial audit entry.</summary> /// <inheritdoc />
/// <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>
public async Task RecordDenialAsync( public async Task RecordDenialAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
string commandKind, string commandKind,
@@ -135,8 +111,8 @@ public sealed class ConstraintEnforcer(
string? correlationId, string? correlationId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
// Emit a canonical Denied AuditEvent directly through the best-effort IAuditWriter // Emit a canonical Denied AuditEvent directly through the best-effort IAuditWriter:
// (Task 2.3 #6): structured Target ("<commandKind>:<target>") and a richer DetailsJson // structured Target ("<commandKind>:<target>") and a richer DetailsJson
// envelope carrying constraint/message/commandKind/target. // envelope carrying constraint/message/commandKind/target.
AuditEvent auditEvent = new() AuditEvent auditEvent = new()
{ {
@@ -8,7 +8,9 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
public sealed class GatewayBrowseScopeProvider(IGatewayRequestIdentityAccessor identityAccessor) public sealed class GatewayBrowseScopeProvider(IGatewayRequestIdentityAccessor identityAccessor)
: IGalaxyBrowseScopeProvider : 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) public IReadOnlyList<string>? ResolveBrowseSubtrees(ServerCallContext context)
{ {
// Invariant: the caller identity is the ambient one pushed by // 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(); private readonly AsyncLocal<ApiKeyIdentity?> currentIdentity = new();
/// <summary>Gets the current request identity.</summary> /// <inheritdoc />
public ApiKeyIdentity? Current => currentIdentity.Value; public ApiKeyIdentity? Current => currentIdentity.Value;
/// <summary>Sets the current identity and returns a scope that restores the previous identity.</summary> /// <inheritdoc />
/// <param name="identity">The identity to push.</param>
/// <returns>Disposable scope.</returns>
public IDisposable Push(ApiKeyIdentity identity) public IDisposable Push(ApiKeyIdentity identity)
{ {
ArgumentNullException.ThrowIfNull(identity); ArgumentNullException.ThrowIfNull(identity);
@@ -13,6 +13,7 @@ public static class GrpcAuthorizationServiceCollectionExtensions
/// Registers gRPC authorization middleware and scope resolver. /// Registers gRPC authorization middleware and scope resolver.
/// </summary> /// </summary>
/// <param name="services">Service collection to register dependencies into.</param> /// <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) public static IServiceCollection AddGatewayGrpcAuthorization(this IServiceCollection services)
{ {
services.AddSingleton<GatewayGrpcScopeResolver>(); services.AddSingleton<GatewayGrpcScopeResolver>();
@@ -9,6 +9,7 @@ public interface IConstraintEnforcer
/// <param name="identity">The API key identity.</param> /// <param name="identity">The API key identity.</param>
/// <param name="tagAddress">Tag address to check.</param> /// <param name="tagAddress">Tag address to check.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param> /// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The constraint failure details if denied; otherwise null.</returns>
Task<ConstraintFailure?> CheckReadTagAsync( Task<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
string tagAddress, string tagAddress,
@@ -20,6 +21,7 @@ public interface IConstraintEnforcer
/// <param name="serverHandle">The MXAccess server handle.</param> /// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param> /// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param> /// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The constraint failure details if denied; otherwise null.</returns>
Task<ConstraintFailure?> CheckReadHandleAsync( Task<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
GatewaySession session, GatewaySession session,
@@ -33,6 +35,7 @@ public interface IConstraintEnforcer
/// <param name="serverHandle">The MXAccess server handle.</param> /// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param> /// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param> /// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The constraint failure details if denied; otherwise null.</returns>
Task<ConstraintFailure?> CheckWriteHandleAsync( Task<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
GatewaySession session, GatewaySession session,
@@ -50,6 +53,7 @@ public interface IConstraintEnforcer
/// <c>CorrelationId</c> when it parses as a GUID; otherwise left null. /// <c>CorrelationId</c> when it parses as a GUID; otherwise left null.
/// </param> /// </param>
/// <param name="cancellationToken">Token to observe for cancellation.</param> /// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task RecordDenialAsync( Task RecordDenialAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
string commandKind, 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> /// <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> /// <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); IDisposable Push(ApiKeyIdentity identity);
} }
@@ -16,6 +16,8 @@ public static class KestrelTlsInspector
/// <c>Certificate:Thumbprint</c>), meaning the gateway must supply a /// <c>Certificate:Thumbprint</c>), meaning the gateway must supply a
/// generated fallback certificate. /// generated fallback certificate.
/// </summary> /// </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) public static bool RequiresGeneratedCertificate(IConfiguration configuration)
{ {
// A Kestrel default certificate applies to every endpoint that lacks its own. // 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 ILogger<SelfSignedCertificateProvider> _logger;
private readonly TimeProvider _timeProvider; 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( public SelfSignedCertificateProvider(
TlsOptions options, TlsOptions options,
ILogger<SelfSignedCertificateProvider> logger, ILogger<SelfSignedCertificateProvider> logger,
@@ -31,6 +35,7 @@ public sealed class SelfSignedCertificateProvider
} }
/// <summary>Creates a fresh in-memory ECDSA P-256 self-signed certificate.</summary> /// <summary>Creates a fresh in-memory ECDSA P-256 self-signed certificate.</summary>
/// <returns>The generated self-signed certificate.</returns>
public X509Certificate2 GenerateCertificate() public X509Certificate2 GenerateCertificate()
{ {
using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256); using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
@@ -89,6 +94,7 @@ public sealed class SelfSignedCertificateProvider
/// <summary>Loads the persisted certificate, regenerating when missing, /// <summary>Loads the persisted certificate, regenerating when missing,
/// expired (and allowed), or unreadable.</summary> /// expired (and allowed), or unreadable.</summary>
/// <returns>The loaded or newly generated and persisted certificate.</returns>
public X509Certificate2 LoadOrCreate() public X509Certificate2 LoadOrCreate()
{ {
string path = _options.SelfSignedCertPath; string path = _options.SelfSignedCertPath;
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Sessions;
/// <summary> /// <summary>
/// The result of a reconnect/resume attach /// 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 /// 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. /// with the registration, so the replay→live handoff has no gap and no duplicate.
/// </summary> /// </summary>
@@ -28,8 +28,7 @@ public sealed class GatewaySession
// True once at least one external subscriber attached SUCCESSFULLY. Detach-grace's // True once at least one external subscriber attached SUCCESSFULLY. Detach-grace's
// "last subscriber dropped" stamp (see DetachEventSubscriber) is gated on this so a // "last subscriber dropped" stamp (see DetachEventSubscriber) is gated on this so a
// FAILED first attach — which still runs the rollback DetachEventSubscriber from the // 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 // attach catch path — does not push a never-subscribed session into the grace window.
// (Server-055).
private bool _everHadEventSubscriber; private bool _everHadEventSubscriber;
private SessionEventDistributor? _eventDistributor; private SessionEventDistributor? _eventDistributor;
private bool _eventDistributorStarted; private bool _eventDistributorStarted;
@@ -115,7 +114,7 @@ public sealed class GatewaySession
/// </param> /// </param>
/// <param name="detachGrace"> /// <param name="detachGrace">
/// Retention window kept after the last external (gRPC) event subscriber drops, so a /// 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"/> /// 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 /// 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 /// 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 /// 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 /// 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 /// <c>_syncRoot</c> just like every other state read/write, closing the split-lock
/// race called out in Server-015. /// race.
/// </remarks> /// </remarks>
public void TransitionTo(SessionState nextState) public void TransitionTo(SessionState nextState)
{ {
@@ -420,13 +419,13 @@ public sealed class GatewaySession
/// Transitions the session to the Ready state. /// Transitions the session to the Ready state.
/// </summary> /// </summary>
/// <remarks> /// <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 /// 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 /// 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 — /// 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 /// fixing the "dark feed" where the dashboard only saw events while a gRPC client was
/// actively streaming. Registering the internal subscriber BEFORE /// 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 /// 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 /// stream into nothing and left a later subscriber hanging: there is now always a
/// subscriber (the dashboard one) registered before the pump starts. /// 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" // 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 // behavior and avoids draining a fast-completing source into the void before any
// subscriber exists. The source factory mirrors the mapping/ordering/start that // 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 // 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 // mapper, with no skip/filter — per-RPC filtering (e.g. AfterWorkerSequence) stays at the
// subscriber boundary in EventStreamService. Returns a registered lease atomically with // subscriber boundary in EventStreamService. Returns a registered lease atomically with
@@ -462,7 +461,7 @@ public sealed class GatewaySession
return lease; 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 // 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 // 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 // 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 // once when the session becomes Ready (idempotent). The internal subscriber is registered
// BEFORE the pump starts (see StartDistributorAndRegister / EnsureDistributorCreated), so // BEFORE the pump starts (see StartDistributorAndRegister / EnsureDistributorCreated), so
// a subscriber is always present at pump start — the dashboard receives events with no // 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). // cannot occur. No-op when no dashboard broadcaster was supplied (unit tests).
// //
// Race-safety (Issue 1): _dashboardMirrorLease and _dashboardMirrorTask are published // 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 // 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 // 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 // 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 // AfterWorkerSequence filtering that EventStreamService applies at its own boundary. This
// is intentional: the dashboard is a separate LDAP-authenticated monitoring view (per- // 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 // 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 // internal subscriber channel only disconnects THIS mirror on overflow, leaving the
// mirror on overflow, leaving the session and other subscribers untouched. // session and other subscribers untouched.
private async Task RunDashboardMirrorAsync( private async Task RunDashboardMirrorAsync(
IDashboardEventBroadcaster broadcaster, IDashboardEventBroadcaster broadcaster,
IEventSubscriberLease lease, IEventSubscriberLease lease,
@@ -757,6 +756,7 @@ public sealed class GatewaySession
/// Determines whether the session lease has expired. /// Determines whether the session lease has expired.
/// </summary> /// </summary>
/// <param name="now">Current timestamp for comparison.</param> /// <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) public bool IsLeaseExpired(DateTimeOffset now)
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -778,6 +778,7 @@ public sealed class GatewaySession
/// window). /// window).
/// </summary> /// </summary>
/// <param name="now">Current timestamp for comparison.</param> /// <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) public bool IsDetachGraceExpired(DateTimeOffset now)
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -813,6 +814,7 @@ public sealed class GatewaySession
/// succeed past it. On distributor-register failure the count is rolled back (see the /// succeed past it. On distributor-register failure the count is rolled back (see the
/// catch below). /// catch below).
/// </remarks> /// </remarks>
/// <returns>A lease that reads the fanned public events for this subscriber.</returns>
public IEventSubscriberLease AttachEventSubscriber(int maxSubscribers) public IEventSubscriberLease AttachEventSubscriber(int maxSubscribers)
{ {
// Derive the mode from the same source the distributor uses so the two can never // Derive the mode from the same source the distributor uses so the two can never
@@ -868,7 +870,7 @@ public sealed class GatewaySession
} }
/// <summary> /// <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 /// 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 /// 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. /// 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 // 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 // "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() private void MarkEventSubscriberAttached()
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -953,6 +955,7 @@ public sealed class GatewaySession
/// </summary> /// </summary>
/// <param name="command">Worker command to invoke.</param> /// <param name="command">Worker command to invoke.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</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( public async Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command, WorkerCommand command,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -969,7 +972,7 @@ public sealed class GatewaySession
return await workerClient.InvokeAsync(command, CommandTimeout, cancellationToken).ConfigureAwait(false); 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 // 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 // reports them as arrays, so the worker registers a write-capable handle. The mutation
// lands on the same MxCommand instance forwarded to the worker. // 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="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param> /// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="registration">The item registration if found.</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( public bool TryGetItemRegistration(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -1136,6 +1140,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param> /// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="tagAddresses">Tag addresses to add.</param> /// <param name="tagAddresses">Tag addresses to add.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-address subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> AddItemBulkAsync( public Task<IReadOnlyList<SubscribeResult>> AddItemBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<string> tagAddresses, IReadOnlyList<string> tagAddresses,
@@ -1161,6 +1166,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param> /// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="itemHandles">Item handles to advise.</param> /// <param name="itemHandles">Item handles to advise.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-handle subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> AdviseItemBulkAsync( public Task<IReadOnlyList<SubscribeResult>> AdviseItemBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<int> itemHandles, IReadOnlyList<int> itemHandles,
@@ -1186,6 +1192,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param> /// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="itemHandles">Item handles to remove.</param> /// <param name="itemHandles">Item handles to remove.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-handle subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> RemoveItemBulkAsync( public Task<IReadOnlyList<SubscribeResult>> RemoveItemBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<int> itemHandles, IReadOnlyList<int> itemHandles,
@@ -1211,6 +1218,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param> /// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="itemHandles">Item handles to un-advise.</param> /// <param name="itemHandles">Item handles to un-advise.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-handle subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> UnAdviseItemBulkAsync( public Task<IReadOnlyList<SubscribeResult>> UnAdviseItemBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<int> itemHandles, IReadOnlyList<int> itemHandles,
@@ -1236,6 +1244,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param> /// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="tagAddresses">Tag addresses to subscribe to.</param> /// <param name="tagAddresses">Tag addresses to subscribe to.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-address subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync( public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<string> tagAddresses, IReadOnlyList<string> tagAddresses,
@@ -1261,6 +1270,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param> /// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="itemHandles">Item handles to unsubscribe from.</param> /// <param name="itemHandles">Item handles to unsubscribe from.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-handle subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> UnsubscribeBulkAsync( public Task<IReadOnlyList<SubscribeResult>> UnsubscribeBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<int> itemHandles, IReadOnlyList<int> itemHandles,
@@ -1284,6 +1294,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param> /// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param> /// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-entry write results.</returns>
public Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync( public Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<WriteBulkEntry> entries, IReadOnlyList<WriteBulkEntry> entries,
@@ -1307,6 +1318,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param> /// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param> /// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-entry write results.</returns>
public Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync( public Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<Write2BulkEntry> entries, IReadOnlyList<Write2BulkEntry> entries,
@@ -1330,6 +1342,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param> /// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param> /// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-entry write results.</returns>
public Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync( public Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<WriteSecuredBulkEntry> entries, IReadOnlyList<WriteSecuredBulkEntry> entries,
@@ -1353,6 +1366,7 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param> /// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param> /// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-entry write results.</returns>
public Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync( public Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<WriteSecured2BulkEntry> entries, IReadOnlyList<WriteSecured2BulkEntry> entries,
@@ -1380,6 +1394,7 @@ public sealed class GatewaySession
/// <param name="tagAddresses">Tag addresses to read.</param> /// <param name="tagAddresses">Tag addresses to read.</param>
/// <param name="timeout">Timeout for the read operation.</param> /// <param name="timeout">Timeout for the read operation.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The per-address read results.</returns>
public Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync( public Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<string> tagAddresses, IReadOnlyList<string> tagAddresses,
@@ -1437,6 +1452,7 @@ public sealed class GatewaySession
/// <see cref="TransitionTo"/> / <see cref="MarkFaulted"/> and a concurrent /// <see cref="TransitionTo"/> / <see cref="MarkFaulted"/> and a concurrent
/// <c>TransitionTo(Ready)</c> cannot race past a <c>Closing</c> write. /// <c>TransitionTo(Ready)</c> cannot race past a <c>Closing</c> write.
/// </remarks> /// </remarks>
/// <returns>The outcome of the close operation.</returns>
public async Task<SessionCloseResult> CloseAsync( public async Task<SessionCloseResult> CloseAsync(
string reason, string reason,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -1613,7 +1629,7 @@ public sealed class GatewaySession
/// Mirrors <see cref="CloseAsync"/>'s use of <c>_closeLock</c> so that /// 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 /// a Close in flight from one caller and a Kill from another do not
/// race on the "was the session already closed" observation that /// race on the "was the session already closed" observation that
/// drives metric increments (Server-045). /// drives metric increments.
/// </remarks> /// </remarks>
/// <param name="reason">Reason for killing the worker.</param> /// <param name="reason">Reason for killing the worker.</param>
/// <param name="cancellationToken">Cancellation token.</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 /// The acquire is best-effort: a non-cancellable wait that swallows
/// <see cref="ObjectDisposedException"/> so double-dispose still completes. /// <see cref="ObjectDisposedException"/> so double-dispose still completes.
/// </remarks> /// </remarks>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
try try
@@ -1991,7 +2008,7 @@ public sealed class GatewaySession
// session instead of letting it linger only on the (long) lease: stamp the detached // 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 // 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 // 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 // NOT counted in _activeEventSubscriberCount (it registers on the distributor with
// isInternal: true), so a session whose only remaining subscriber is the dashboard // isInternal: true), so a session whose only remaining subscriber is the dashboard
// mirror still enters grace. Only stamp while the session is alive — once // 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 // 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 // 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 // before any subscriber registered; that never-subscribed session must not enter the
// grace window (Server-055). // grace window.
if (_everHadEventSubscriber if (_everHadEventSubscriber
&& _detachGrace > TimeSpan.Zero && _detachGrace > TimeSpan.Zero
&& _activeEventSubscriberCount == 0 && _activeEventSubscriberCount == 0
@@ -74,5 +74,6 @@ public interface ISessionManager
/// <summary>Shuts down all sessions and the session manager.</summary> /// <summary>Shuts down all sessions and the session manager.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task ShutdownAsync(CancellationToken cancellationToken); Task ShutdownAsync(CancellationToken cancellationToken);
} }
@@ -14,12 +14,12 @@ namespace ZB.MOM.WW.MxGateway.Server.Sessions;
/// </summary> /// </summary>
/// <param name="isOnlySubscriber"> /// <param name="isOnlySubscriber">
/// <see langword="true"/> when FailFast is allowed to fault the whole session for this /// <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 /// <see langword="true"/> only for an external subscriber in single-subscriber mode
/// (<c>AllowMultipleEventSubscribers == false</c>), where at most one external subscriber /// (<c>AllowMultipleEventSubscribers == false</c>), where at most one external subscriber
/// can ever exist. In multi-subscriber mode it is always <see langword="false"/>, so /// 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 /// 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. /// concurrent registration could make a count snapshot falsely report a sole subscriber.
/// Always <see langword="false"/> for internal subscribers (the dashboard mirror) so a /// Always <see langword="false"/> for internal subscribers (the dashboard mirror) so a
/// slow/broken dashboard can never fault the session. /// slow/broken dashboard can never fault the session.
@@ -38,15 +38,14 @@ public delegate void SubscriberOverflowHandler(bool isOnlySubscriber, bool isInt
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Introduced by Task 2 of the Session Resilience epic; the bounded replay ring /// The bounded replay ring buffer is wired into <c>GatewaySession</c> and
/// buffer was added by Task 3, it was wired into <c>GatewaySession</c> and /// <c>EventStreamService</c>. The per-subscriber backpressure-isolation policy is
/// <c>EventStreamService</c> by Task 4, and the per-subscriber backpressure-isolation /// implemented here: a slow subscriber overflows only its own bounded channel and the
/// policy (Task 5) is implemented here: a slow subscriber overflows only its own /// pump applies the policy to that subscriber alone (see
/// bounded channel and the pump applies the policy to that subscriber alone (see
/// <see cref="SubscriberOverflowHandler"/> and <c>OnSubscriberOverflow</c>), leaving /// <see cref="SubscriberOverflowHandler"/> and <c>OnSubscriberOverflow</c>), leaving
/// the pump, the session, and other subscribers running. Task 8 made the /// the pump, the session, and other subscribers running. The FailFast-faults-session
/// FailFast-faults-session decision mode-gated: it fires only in single-subscriber /// decision is mode-gated: it fires only in single-subscriber mode
/// mode (<c>singleSubscriberMode</c>), so multi-subscriber FailFast always degrades to /// (<c>singleSubscriberMode</c>), so multi-subscriber FailFast always degrades to
/// a per-subscriber disconnect — see <c>OnSubscriberOverflow</c>. The ring buffer supports capacity /// a per-subscriber disconnect — see <c>OnSubscriberOverflow</c>. The ring buffer supports capacity
/// eviction (oldest entry dropped when the count exceeds /// eviction (oldest entry dropped when the count exceeds
/// <c>replayBufferCapacity</c>) and age eviction (entries older than /// <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="Func{T, TResult}"/> producing an
/// <see cref="IAsyncEnumerable{T}"/> of already-mapped public /// <see cref="IAsyncEnumerable{T}"/> of already-mapped public
/// <see cref="MxEvent"/>s, given a <see cref="CancellationToken"/>. This is the /// <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 /// <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>), while unit tests pass a plain
/// channel reader's <c>ReadAllAsync</c> with no real session. The pump owns the /// 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="sessionId">Owning session id, used only for logging context.</param>
/// <param name="eventSourceFactory"> /// <param name="eventSourceFactory">
/// Factory producing the session's event stream given a cancellation token. /// 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. /// plugs into.
/// </param> /// </param>
/// <param name="subscriberQueueCapacity"> /// <param name="subscriberQueueCapacity">
@@ -141,10 +140,18 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// queue capacity shape used today. /// queue capacity shape used today.
/// </param> /// </param>
/// <param name="logger">Logger for pump lifecycle diagnostics.</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> /// <remarks>
/// This overload disables the replay ring buffer (capacity 0). Use the overload /// This overload disables the replay ring buffer (capacity 0). Use the overload
/// taking replay parameters to retain events for reconnect/reattach replay. /// 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>. /// the no-replay path; tests reach it via <c>InternalsVisibleTo</c>.
/// </remarks> /// </remarks>
internal SessionEventDistributor( 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="sessionId">Owning session id, used only for logging context.</param>
/// <param name="eventSourceFactory"> /// <param name="eventSourceFactory">
/// Factory producing the session's event stream given a cancellation token. /// 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. /// plugs into.
/// </param> /// </param>
/// <param name="subscriberQueueCapacity"> /// <param name="subscriberQueueCapacity">
@@ -208,9 +215,9 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// overflows reports <c>isOnlySubscriber == true</c> (legacy FailFast faults the /// overflows reports <c>isOnlySubscriber == true</c> (legacy FailFast faults the
/// session) ONLY in single-subscriber mode. In multi-subscriber mode it is always /// 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 /// <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; /// transient registration race can never falsely fault a shared session. Defaults to
/// resolves the Task 5 REVISIT race). Defaults to <see langword="true"/> so existing /// <see langword="true"/> so existing call sites and unit tests keep legacy
/// call sites and unit tests keep legacy single-subscriber FailFast behavior. /// single-subscriber FailFast behavior.
/// </param> /// </param>
public SessionEventDistributor( public SessionEventDistributor(
string sessionId, string sessionId,
@@ -259,6 +266,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// Starts the background pump. Idempotent — a second call is a no-op. /// Starts the background pump. Idempotent — a second call is a no-op.
/// </summary> /// </summary>
/// <param name="cancellationToken">Token observed only while starting.</param> /// <param name="cancellationToken">Token observed only while starting.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task StartAsync(CancellationToken cancellationToken) public Task StartAsync(CancellationToken cancellationToken)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
@@ -285,7 +293,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// subscribers. /// subscribers.
/// </summary> /// </summary>
/// <param name="isInternal"> /// <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 /// session's dashboard mirror) that must NOT participate in the single-subscriber
/// overflow accounting. An internal subscriber is excluded from the /// overflow accounting. An internal subscriber is excluded from the
/// <c>isOnlySubscriber</c> count, so a lone external gRPC subscriber still reports /// <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 /// disconnected from the mirror. Defaults to <see langword="false"/> (external
/// subscriber) so every existing call site is unchanged. /// subscriber) so every existing call site is unchanged.
/// </param> /// </param>
/// <returns>The lease for the newly-registered subscriber.</returns>
public IEventSubscriberLease Register(bool isInternal = false) public IEventSubscriberLease Register(bool isInternal = false)
{ {
Channel<MxEvent> channel = CreateSubscriberChannel(); Channel<MxEvent> channel = CreateSubscriberChannel();
@@ -355,7 +364,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// <summary> /// <summary>
/// Atomically snapshots the replay ring for events newer than /// Atomically snapshots the replay ring for events newer than
/// <paramref name="afterSequence"/> AND registers a live subscriber, so the /// <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> /// </summary>
/// <param name="afterSequence"> /// <param name="afterSequence">
/// The last worker sequence the reconnecting client already observed. Replay returns /// 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 langword="true"/> for a gateway-owned internal subscriber. See
/// <see cref="Register"/>. /// <see cref="Register"/>.
/// </param> /// </param>
/// <returns>The lease for the newly-registered subscriber.</returns>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// <b>Why this is atomic and the handoff is correct.</b> The replay snapshot and the /// <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> /// <summary>
/// Stops the pump and completes all subscriber channels. Idempotent. /// Stops the pump and completes all subscriber channels. Idempotent.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
Task? pumpTask; 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 // Decide whether FailFast may fault the whole session for this overflow. This is the
// "isOnlySubscriber" signal the legacy single-subscriber FailFast path keys on. // "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; bool isOnlySubscriber = !subscriber.IsInternal && _singleSubscriberMode;
_logger.LogDebug( _logger.LogDebug(
@@ -816,12 +811,17 @@ public sealed class SessionEventDistributor : IAsyncDisposable
private sealed class Subscriber(long id, Channel<MxEvent> channel, bool isInternal) 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; 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; public Channel<MxEvent> Channel { get; } = channel;
// True for the gateway-owned internal dashboard subscriber. Excluded from the /// <summary>
// single-subscriber overflow accounting so it cannot fault the session. /// 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; public bool IsInternal { get; } = isInternal;
} }
@@ -830,8 +830,13 @@ public sealed class SessionEventDistributor : IAsyncDisposable
{ {
private int _leaseDisposed; private int _leaseDisposed;
/// <inheritdoc />
public ChannelReader<MxEvent> Reader => subscriber.Channel.Reader; 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() public void Dispose()
{ {
// Atomic check-and-set so concurrent Dispose calls unregister at most once. // 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"> /// <param name="Mapper">
/// Maps worker IPC <c>WorkerEvent</c> frames to public <c>MxEvent</c>s. The distributor /// 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 /// 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>
/// <param name="EventOptions"> /// <param name="EventOptions">
/// Supplies the distributor's per-subscriber queue capacity and replay ring-buffer /// 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. /// preserving the observability the pre-epic per-RPC overflow path emitted.
/// </param> /// </param>
/// <param name="DashboardBroadcaster"> /// <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 /// <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 /// subscriber on becoming Ready and mirrors every fanned event to the dashboard
/// EventsHub group regardless of whether a gRPC client is streaming. When null /// 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. /// (unit tests that don't exercise the dashboard mirror) no mirror is started.
/// </param> /// </param>
/// <param name="AllowMultipleEventSubscribers"> /// <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 /// 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 /// 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 /// subscriber attaches, so the mode cannot be learned from a later
/// <c>AttachEventSubscriber</c> call. The distributor gates its FailFast session-fault /// <c>AttachEventSubscriber</c> call. The distributor gates its FailFast session-fault
/// decision on this mode (single-subscriber only) instead of a live count snapshot, /// 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. /// (single-subscriber) so existing call sites and unit tests are unchanged.
/// </param> /// </param>
public sealed record SessionEventStreaming( 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="distributorLogger">Logger passed to each session's event distributor pump.</param>
/// <param name="dashboardEventBroadcaster"> /// <param name="dashboardEventBroadcaster">
/// Dashboard SignalR fan-out sink. Each session registers an internal distributor /// 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 /// dashboard receives events regardless of whether a gRPC client is streaming. Null in
/// unit tests that do not exercise the dashboard mirror. /// unit tests that do not exercise the dashboard mirror.
/// </param> /// </param>
@@ -79,14 +79,7 @@ public sealed class SessionManager : ISessionManager
_sessionSlots = new SemaphoreSlim(_options.Sessions.MaxSessions, _options.Sessions.MaxSessions); _sessionSlots = new SemaphoreSlim(_options.Sessions.MaxSessions, _options.Sessions.MaxSessions);
} }
/// <summary> /// <inheritdoc />
/// 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>
public async Task<GatewaySession> OpenSessionAsync( public async Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request, SessionOpenRequest request,
string? clientIdentity, string? clientIdentity,
@@ -151,12 +144,7 @@ public sealed class SessionManager : ISessionManager
} }
} }
/// <summary> /// <inheritdoc />
/// 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>
public bool TryGetSession( public bool TryGetSession(
string sessionId, string sessionId,
[MaybeNullWhen(false)] out GatewaySession session) [MaybeNullWhen(false)] out GatewaySession session)
@@ -164,13 +152,7 @@ public sealed class SessionManager : ISessionManager
return _registry.TryGet(sessionId, out session); return _registry.TryGet(sessionId, out session);
} }
/// <summary> /// <inheritdoc />
/// 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>
public async Task<WorkerCommandReply> InvokeAsync( public async Task<WorkerCommandReply> InvokeAsync(
string sessionId, string sessionId,
WorkerCommand command, WorkerCommand command,
@@ -197,12 +179,7 @@ public sealed class SessionManager : ISessionManager
} }
} }
/// <summary> /// <inheritdoc />
/// 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>
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync( public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId, string sessionId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -212,12 +189,7 @@ public sealed class SessionManager : ISessionManager
return session.ReadEventsAsync(cancellationToken); return session.ReadEventsAsync(cancellationToken);
} }
/// <summary> /// <inheritdoc />
/// Closes a gateway session asynchronously.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Session close result.</returns>
public async Task<SessionCloseResult> CloseSessionAsync( public async Task<SessionCloseResult> CloseSessionAsync(
string sessionId, string sessionId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -231,16 +203,12 @@ public sealed class SessionManager : ISessionManager
return result; return result;
} }
/// <summary> /// <inheritdoc />
/// Forcefully terminates a session's worker without attempting graceful shutdown. /// <remarks>
/// Mirrors the registry/metrics cleanup that <see cref="CloseSessionCoreAsync"/> /// Mirrors the registry/metrics cleanup that <see cref="CloseSessionCoreAsync"/>
/// performs after a successful close, but skips the <c>WorkerClient.ShutdownAsync</c> /// performs after a successful close, but skips the <c>WorkerClient.ShutdownAsync</c>
/// step that <see cref="GatewaySession.CloseAsync"/> would otherwise attempt. /// step that <see cref="GatewaySession.CloseAsync"/> would otherwise attempt.
/// </summary> /// </remarks>
/// <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>
public async Task<SessionCloseResult> KillWorkerAsync( public async Task<SessionCloseResult> KillWorkerAsync(
string sessionId, string sessionId,
string reason, string reason,
@@ -251,10 +219,6 @@ public sealed class SessionManager : ISessionManager
GatewaySession session = GetRequiredSession(sessionId); 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; bool wasClosed;
try try
{ {
@@ -265,10 +229,6 @@ public sealed class SessionManager : ISessionManager
session.MarkFaulted(exception.Message); session.MarkFaulted(exception.Message);
_metrics.Fault(SessionManagerErrorCode.CloseFailed.ToString()); _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(); _metrics.SessionRemoved();
await RemoveSessionAsync(session).ConfigureAwait(false); await RemoveSessionAsync(session).ConfigureAwait(false);
throw new SessionManagerException( throw new SessionManagerException(
@@ -291,12 +251,7 @@ public sealed class SessionManager : ISessionManager
return new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: wasClosed); return new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: wasClosed);
} }
/// <summary> /// <inheritdoc />
/// 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>
public async Task<int> CloseExpiredLeasesAsync( public async Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now, DateTimeOffset now,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -341,11 +296,7 @@ public sealed class SessionManager : ISessionManager
return closedCount; return closedCount;
} }
/// <summary> /// <inheritdoc />
/// Shuts down all active sessions gracefully asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
public async Task ShutdownAsync(CancellationToken cancellationToken) public async Task ShutdownAsync(CancellationToken cancellationToken)
{ {
foreach (GatewaySession session in _registry.Snapshot()) foreach (GatewaySession session in _registry.Snapshot())
@@ -361,10 +312,6 @@ public sealed class SessionManager : ISessionManager
"Graceful shutdown failed for session {SessionId}; killing worker.", "Graceful shutdown failed for session {SessionId}; killing worker.",
session.SessionId); 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 _)) if (_registry.TryGet(session.SessionId, out _))
{ {
try try
@@ -409,11 +356,6 @@ public sealed class SessionManager : ISessionManager
session.MarkFaulted(exception.Message); session.MarkFaulted(exception.Message);
if (!wasClosed) 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(); _metrics.SessionClosed();
} }
@@ -11,6 +11,7 @@ public sealed record SessionOpenRequest(
{ {
/// <summary>Creates a SessionOpenRequest from a gRPC OpenSessionRequest contract.</summary> /// <summary>Creates a SessionOpenRequest from a gRPC OpenSessionRequest contract.</summary>
/// <param name="request">Request payload.</param> /// <param name="request">Request payload.</param>
/// <returns>The equivalent <see cref="SessionOpenRequest"/>.</returns>
public static SessionOpenRequest FromContract(OpenSessionRequest request) public static SessionOpenRequest FromContract(OpenSessionRequest request)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
@@ -11,20 +11,13 @@ public sealed class SessionRegistry : ISessionRegistry
{ {
private readonly ConcurrentDictionary<string, GatewaySession> _sessions = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, GatewaySession> _sessions = new(StringComparer.Ordinal);
/// <summary> /// <inheritdoc />
/// Gets the total count of sessions in the registry.
/// </summary>
public int Count => _sessions.Count; public int Count => _sessions.Count;
/// <summary> /// <inheritdoc />
/// Gets the count of non-closed sessions.
/// </summary>
public int ActiveCount => _sessions.Values.Count(session => session.State is not SessionState.Closed); public int ActiveCount => _sessions.Values.Count(session => session.State is not SessionState.Closed);
/// <summary> /// <inheritdoc />
/// Adds a session to the registry.
/// </summary>
/// <param name="session">Gateway session to add.</param>
public bool TryAdd(GatewaySession session) public bool TryAdd(GatewaySession session)
{ {
ArgumentNullException.ThrowIfNull(session); ArgumentNullException.ThrowIfNull(session);
@@ -32,11 +25,7 @@ public sealed class SessionRegistry : ISessionRegistry
return _sessions.TryAdd(session.SessionId, session); return _sessions.TryAdd(session.SessionId, session);
} }
/// <summary> /// <inheritdoc />
/// Retrieves a session by identifier.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="session">The retrieved session if found.</param>
public bool TryGet( public bool TryGet(
string sessionId, string sessionId,
[MaybeNullWhen(false)] out GatewaySession session) [MaybeNullWhen(false)] out GatewaySession session)
@@ -44,11 +33,7 @@ public sealed class SessionRegistry : ISessionRegistry
return _sessions.TryGetValue(sessionId, out session); return _sessions.TryGetValue(sessionId, out session);
} }
/// <summary> /// <inheritdoc />
/// 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>
public bool TryRemove( public bool TryRemove(
string sessionId, string sessionId,
[MaybeNullWhen(false)] out GatewaySession session) [MaybeNullWhen(false)] out GatewaySession session)
@@ -56,9 +41,7 @@ public sealed class SessionRegistry : ISessionRegistry
return _sessions.TryRemove(sessionId, out session); return _sessions.TryRemove(sessionId, out session);
} }
/// <summary> /// <inheritdoc />
/// Returns a snapshot of all sessions in the registry.
/// </summary>
public IReadOnlyCollection<GatewaySession> Snapshot() public IReadOnlyCollection<GatewaySession> Snapshot()
{ {
return _sessions.Values.ToArray(); return _sessions.Values.ToArray();
@@ -8,13 +8,17 @@ public sealed class SessionShutdownHostedService(
ISessionManager sessionManager, ISessionManager sessionManager,
ILogger<SessionShutdownHostedService> logger) : IHostedService 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) public Task StartAsync(CancellationToken cancellationToken)
{ {
return Task.CompletedTask; 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) public async Task StopAsync(CancellationToken cancellationToken)
{ {
try try
@@ -39,10 +39,7 @@ public sealed class SessionWorkerClientFactory : ISessionWorkerClientFactory
_options = options.Value; _options = options.Value;
} }
/// <summary>Creates a worker client and launches the worker process.</summary> /// <inheritdoc />
/// <param name="session">The gateway session.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created worker client.</returns>
public async Task<IWorkerClient> CreateAsync( public async Task<IWorkerClient> CreateAsync(
GatewaySession session, GatewaySession session,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -19,12 +19,14 @@ public interface IWorkerClient : IAsyncDisposable
/// <summary>Initiates the handshake and enters ready state.</summary> /// <summary>Initiates the handshake and enters ready state.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task StartAsync(CancellationToken cancellationToken); Task StartAsync(CancellationToken cancellationToken);
/// <summary>Sends a command to the worker and waits for a reply.</summary> /// <summary>Sends a command to the worker and waits for a reply.</summary>
/// <param name="command">Worker command to invoke.</param> /// <param name="command">Worker command to invoke.</param>
/// <param name="timeout">Timeout for waiting for the reply.</param> /// <param name="timeout">Timeout for waiting for the reply.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The worker's reply to the command.</returns>
Task<WorkerCommandReply> InvokeAsync( Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command, WorkerCommand command,
TimeSpan timeout, TimeSpan timeout,
@@ -32,11 +34,13 @@ public interface IWorkerClient : IAsyncDisposable
/// <summary>Reads events from the worker as they arrive.</summary> /// <summary>Reads events from the worker as they arrive.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>An asynchronous stream of worker events.</returns>
IAsyncEnumerable<WorkerEvent> ReadEventsAsync(CancellationToken cancellationToken); IAsyncEnumerable<WorkerEvent> ReadEventsAsync(CancellationToken cancellationToken);
/// <summary>Gracefully shuts down the worker by closing the connection.</summary> /// <summary>Gracefully shuts down the worker by closing the connection.</summary>
/// <param name="timeout">Timeout for shutdown.</param> /// <param name="timeout">Timeout for shutdown.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</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); Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken);
/// <summary>Terminates the worker process immediately with a diagnostic reason.</summary> /// <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. /// Waits for the process to exit with the specified cancellation token.
/// </summary> /// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
ValueTask WaitForExitAsync(CancellationToken cancellationToken); ValueTask WaitForExitAsync(CancellationToken cancellationToken);
/// <summary> /// <summary>
@@ -8,7 +8,9 @@ public sealed class OrphanWorkerCleanupHostedService(
OrphanWorkerTerminator terminator, OrphanWorkerTerminator terminator,
ILogger<OrphanWorkerCleanupHostedService> logger) : IHostedService 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) public Task StartAsync(CancellationToken cancellationToken)
{ {
try try
@@ -25,6 +27,8 @@ public sealed class OrphanWorkerCleanupHostedService(
return Task.CompletedTask; 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; public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
} }
@@ -28,7 +28,7 @@ internal sealed class SystemWorkerProcess(Process process) : IWorkerProcess
process.Kill(entireProcessTree); process.Kill(entireProcessTree);
} }
/// <inheritdoc /> /// <summary>Disposes the wrapped <see cref="Process"/> instance.</summary>
public void Dispose() public void Dispose()
{ {
process.Dispose(); process.Dispose();
@@ -78,10 +78,10 @@ public sealed class WorkerClient : IWorkerClient
_lastHeartbeatAt = _timeProvider.GetUtcNow(); _lastHeartbeatAt = _timeProvider.GetUtcNow();
} }
/// <summary>Gets the worker's session ID.</summary> /// <inheritdoc />
public string SessionId => _connection.SessionId; public string SessionId => _connection.SessionId;
/// <summary>Gets the worker process ID.</summary> /// <inheritdoc />
public int? ProcessId public int? ProcessId
{ {
get get
@@ -93,7 +93,7 @@ public sealed class WorkerClient : IWorkerClient
} }
} }
/// <summary>Gets the current client state.</summary> /// <inheritdoc />
public WorkerClientState State public WorkerClientState State
{ {
get get
@@ -105,7 +105,7 @@ public sealed class WorkerClient : IWorkerClient
} }
} }
/// <summary>Gets the timestamp of the last received heartbeat.</summary> /// <inheritdoc />
public DateTimeOffset LastHeartbeatAt public DateTimeOffset LastHeartbeatAt
{ {
get get
@@ -117,8 +117,7 @@ public sealed class WorkerClient : IWorkerClient
} }
} }
/// <summary>Starts the worker client and completes the handshake.</summary> /// <inheritdoc />
/// <param name="cancellationToken">Cancellation token.</param>
public async Task StartAsync(CancellationToken cancellationToken) public async Task StartAsync(CancellationToken cancellationToken)
{ {
ThrowIfDisposed(); ThrowIfDisposed();
@@ -141,11 +140,7 @@ public sealed class WorkerClient : IWorkerClient
_heartbeatLoopTask = Task.Run(HeartbeatLoopAsync); _heartbeatLoopTask = Task.Run(HeartbeatLoopAsync);
} }
/// <summary>Invokes a command on the worker and waits for reply.</summary> /// <inheritdoc />
/// <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>
public async Task<WorkerCommandReply> InvokeAsync( public async Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command, WorkerCommand command,
TimeSpan timeout, TimeSpan timeout,
@@ -228,9 +223,7 @@ public sealed class WorkerClient : IWorkerClient
} }
} }
/// <summary>Reads events from the worker as an async stream.</summary> /// <inheritdoc />
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of worker events.</returns>
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync( public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken) [EnumeratorCancellation] CancellationToken cancellationToken)
{ {
@@ -242,9 +235,7 @@ public sealed class WorkerClient : IWorkerClient
} }
} }
/// <summary>Shuts down the worker gracefully.</summary> /// <inheritdoc />
/// <param name="timeout">Shutdown timeout.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
{ {
ThrowIfDisposed(); ThrowIfDisposed();
@@ -289,8 +280,7 @@ public sealed class WorkerClient : IWorkerClient
} }
} }
/// <summary>Terminates the worker process immediately.</summary> /// <inheritdoc />
/// <param name="reason">Reason for termination.</param>
public void Kill(string reason) public void Kill(string reason)
{ {
ThrowIfDisposed(); ThrowIfDisposed();
@@ -302,6 +292,7 @@ public sealed class WorkerClient : IWorkerClient
} }
/// <summary>Disposes the worker client and releases resources.</summary> /// <summary>Disposes the worker client and releases resources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (_disposed) if (_disposed)
@@ -390,13 +381,13 @@ public sealed class WorkerClient : IWorkerClient
} }
/// <summary> /// <summary>
/// Monitors worker heartbeat and detects stale sessions. Mirrors /// Monitors worker heartbeat and detects stale sessions. Mirrors the
/// Worker-023 on the worker side: while a command is in flight on the /// worker-side watchdog: while a command is in flight on the
/// gateway↔worker pipe, the heartbeat watchdog is suppressed up to /// gateway↔worker pipe, the heartbeat watchdog is suppressed up to
/// <see cref="WorkerClientOptions.HeartbeatStuckCeiling"/> — the worker /// <see cref="WorkerClientOptions.HeartbeatStuckCeiling"/> — the worker
/// may be busy executing a slow STA command and the heartbeat write may /// 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 /// be queued behind a long event-drain burst, neither of which
/// which indicates the worker is actually hung. Once the oldest pending /// indicates the worker is actually hung. Once the oldest pending
/// command exceeds the ceiling, the fault fires anyway so a truly stuck /// command exceeds the ceiling, the fault fires anyway so a truly stuck
/// COM call doesn't hide the worker forever. /// COM call doesn't hide the worker forever.
/// </summary> /// </summary>
@@ -419,11 +410,6 @@ public sealed class WorkerClient : IWorkerClient
continue; 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) if (TryGetOldestPendingCommandAge(out TimeSpan oldestCommandAge)
&& oldestCommandAge <= _options.HeartbeatStuckCeiling) && oldestCommandAge <= _options.HeartbeatStuckCeiling)
{ {
@@ -446,7 +432,7 @@ public sealed class WorkerClient : IWorkerClient
/// Returns the age of the oldest pending command on the worker pipe, /// Returns the age of the oldest pending command on the worker pipe,
/// measured via <see cref="TimeProvider.GetElapsedTime(long)"/> against /// measured via <see cref="TimeProvider.GetElapsedTime(long)"/> against
/// <see cref="PendingCommand.StartTimestamp"/>, or <c>false</c> when no /// <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 /// to decide whether a heartbeat gap is plausibly the result of
/// pipe-write contention rather than a hung worker. /// pipe-write contention rather than a hung worker.
/// </summary> /// </summary>
@@ -508,12 +494,12 @@ public sealed class WorkerClient : IWorkerClient
} }
/// <summary> /// <summary>
/// Enqueues a worker event for client consumption. Server-032: the /// Enqueues a worker event for client consumption. The channel is
/// channel is configured with <see cref="BoundedChannelFullMode.Wait"/> /// configured with <see cref="BoundedChannelFullMode.Wait"/>
/// and a brief consumer hiccup is now tolerated for up to /// and a brief consumer hiccup is tolerated for up to
/// <see cref="WorkerClientOptions.EventChannelFullModeTimeout"/> /// <see cref="WorkerClientOptions.EventChannelFullModeTimeout"/>
/// (default 5s) before the worker is faulted. Pre-Server-032 the code /// (default 5s) before the worker is faulted. The channel previously
/// used <c>TryWrite</c> (non-blocking) which never honored the /// used <c>TryWrite</c> (non-blocking), which never honored the
/// configured <c>FullModeTimeout</c> — the worker faulted on the first /// configured <c>FullModeTimeout</c> — the worker faulted on the first
/// missed slot even though the wait-mode channel would have absorbed /// missed slot even though the wait-mode channel would have absorbed
/// the burst. The diagnostic now names the capacity, current depth, and /// the burst. The diagnostic now names the capacity, current depth, and
@@ -538,8 +524,6 @@ public sealed class WorkerClient : IWorkerClient
return; return;
} }
// Channel is full; honor the configured wait timeout before declaring
// the consumer dead and faulting the worker (Server-032).
using CancellationTokenSource fullModeCts = CancellationTokenSource using CancellationTokenSource fullModeCts = CancellationTokenSource
.CreateLinkedTokenSource(cancellationToken); .CreateLinkedTokenSource(cancellationToken);
fullModeCts.CancelAfter(_options.EventChannelFullModeTimeout); fullModeCts.CancelAfter(_options.EventChannelFullModeTimeout);
@@ -15,7 +15,7 @@ public sealed class WorkerClientOptions
/// <summary> /// <summary>
/// Default ceiling on the in-flight-command heartbeat skip. Mirrors /// Default ceiling on the in-flight-command heartbeat skip. Mirrors
/// <see cref="ZB.MOM.WW.MxGateway.Worker.Ipc.WorkerPipeSessionOptions.DefaultHeartbeatStuckCeiling"/> /// <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 /// longer than this, the gateway-side heartbeat watchdog fires
/// regardless of pending commands — a truly stuck COM call shouldn't /// regardless of pending commands — a truly stuck COM call shouldn't
/// hide the worker forever. /// hide the worker forever.
@@ -47,8 +47,7 @@ public sealed class WorkerClientOptions
/// faulting the worker. Honored by <c>EnqueueWorkerEventAsync</c> via /// faulting the worker. Honored by <c>EnqueueWorkerEventAsync</c> via
/// <c>WriteAsync</c>; with the channel configured for /// <c>WriteAsync</c>; with the channel configured for
/// <c>BoundedChannelFullMode.Wait</c>, a transient backlog only faults /// <c>BoundedChannelFullMode.Wait</c>, a transient backlog only faults
/// after the configured timeout has elapsed (Server-032). Pre-Server-032 /// after the configured timeout has elapsed.
/// the field was declared but unused — overflow faulted immediately.
/// </summary> /// </summary>
public TimeSpan EventChannelFullModeTimeout { get; init; } public TimeSpan EventChannelFullModeTimeout { get; init; }
@@ -56,12 +55,12 @@ public sealed class WorkerClientOptions
public int MaxPendingCommands { get; init; } public int MaxPendingCommands { get; init; }
/// <summary> /// <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 /// a command has been pending on the gateway↔worker pipe for longer
/// than this, the gateway-side <c>HeartbeatLoopAsync</c> fires the /// than this, the gateway-side <c>HeartbeatLoopAsync</c> fires the
/// <c>HeartbeatExpired</c> fault even if commands are still pending; /// <c>HeartbeatExpired</c> fault even if commands are still pending;
/// a truly stuck COM call shouldn't keep the watchdog suppressed /// 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. /// the worker side.
/// </summary> /// </summary>
public TimeSpan HeartbeatStuckCeiling { get; init; } public TimeSpan HeartbeatStuckCeiling { get; init; }
@@ -30,6 +30,7 @@ public sealed class WorkerFrameWriter
/// </summary> /// </summary>
/// <param name="envelope">Worker envelope message to write.</param> /// <param name="envelope">Worker envelope message to write.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask WriteAsync( public async ValueTask WriteAsync(
WorkerEnvelope envelope, WorkerEnvelope envelope,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -30,7 +30,7 @@ public sealed class WorkerProcessHandle : IDisposable
/// <summary>Gets the time when the process was launched.</summary> /// <summary>Gets the time when the process was launched.</summary>
public DateTimeOffset LaunchedAt { get; } public DateTimeOffset LaunchedAt { get; }
/// <inheritdoc /> /// <summary>Disposes the underlying worker process.</summary>
public void Dispose() public void Dispose()
{ {
Process.Dispose(); Process.Dispose();
@@ -58,12 +58,7 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
_logger = logger ?? NullLogger<WorkerProcessLauncher>.Instance; _logger = logger ?? NullLogger<WorkerProcessLauncher>.Instance;
} }
/// <summary> /// <inheritdoc />
/// 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>
public async Task<WorkerProcessHandle> LaunchAsync( public async Task<WorkerProcessHandle> LaunchAsync(
WorkerProcessLaunchRequest request, WorkerProcessLaunchRequest request,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -2,11 +2,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Workers;
public sealed class WorkerProcessStartedProbe : IWorkerStartupProbe public sealed class WorkerProcessStartedProbe : IWorkerStartupProbe
{ {
/// <summary>Verifies that the worker process has started and has not exited.</summary> /// <inheritdoc />
/// <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>
public Task WaitUntilReadyAsync( public Task WaitUntilReadyAsync(
IWorkerProcess process, IWorkerProcess process,
WorkerProcessLaunchRequest request, WorkerProcessLaunchRequest request,
@@ -5,6 +5,7 @@ public static class WorkerServiceCollectionExtensions
{ {
/// <summary>Registers worker process launcher and factory services.</summary> /// <summary>Registers worker process launcher and factory services.</summary>
/// <param name="services">Service collection to register services.</param> /// <param name="services">Service collection to register services.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddWorkerProcessLauncher(this IServiceCollection services) public static IServiceCollection AddWorkerProcessLauncher(this IServiceCollection services)
{ {
services.AddSingleton<IWorkerProcessFactory, SystemWorkerProcessFactory>(); services.AddSingleton<IWorkerProcessFactory, SystemWorkerProcessFactory>();
@@ -32,6 +32,8 @@ public sealed class AlarmFailoverEndToEndTests
{ {
private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(30); 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] [Fact]
public async Task ProviderFailoverAndFailback_FullLifecycle_ReflectedInFeed() public async Task ProviderFailoverAndFailback_FullLifecycle_ReflectedInFeed()
{ {
@@ -227,6 +229,8 @@ public sealed class AlarmFailoverEndToEndTests
await monitor.StopAsync(CancellationToken.None); 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] [Fact]
public async Task DegradedTransition_CachedThenReplayed_CarriesDegradedAndSourceProviderToNewSubscriber() public async Task DegradedTransition_CachedThenReplayed_CarriesDegradedAndSourceProviderToNewSubscriber()
{ {
@@ -400,10 +404,13 @@ public sealed class AlarmFailoverEndToEndTests
public SubscribeAlarmsCommand? LastSubscribeCommand { get; private set; } public SubscribeAlarmsCommand? LastSubscribeCommand { get; private set; }
/// <summary>Pushes a worker event onto the monitor's event stream.</summary> /// <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) => public void EmitEvent(MxEvent mxEvent) =>
_events.Writer.TryWrite(new WorkerEvent { Event = mxEvent }); _events.Writer.TryWrite(new WorkerEvent { Event = mxEvent });
/// <summary>Completes once the monitor has issued its SubscribeAlarms command.</summary> /// <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); public Task WaitForSubscribeAsync(TimeSpan timeout) => _subscribed.Task.WaitAsync(timeout);
/// <inheritdoc /> /// <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] [Fact]
public async Task ResolveAsync_UnionsGalaxyRowsAndIncludes_RemovesExcludes_AndDeduplicates() public async Task ResolveAsync_UnionsGalaxyRowsAndIncludes_RemovesExcludes_AndDeduplicates()
{ {
@@ -63,6 +69,8 @@ public sealed class AlarmWatchListResolverTests
Assert.Equal(3, result.Count); 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] [Fact]
public async Task ResolveAsync_ComposesSubtagAddressesFromConfigNames() public async Task ResolveAsync_ComposesSubtagAddressesFromConfigNames()
{ {
@@ -89,6 +97,8 @@ public sealed class AlarmWatchListResolverTests
Assert.Equal("Tank01.Level.HiHi.AckCmt", target.AckCommentSubtag); 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] [Fact]
public async Task ResolveAsync_EmptyPriorityAndAckComment_LeaveThoseFieldsEmpty() public async Task ResolveAsync_EmptyPriorityAndAckComment_LeaveThoseFieldsEmpty()
{ {
@@ -110,6 +120,8 @@ public sealed class AlarmWatchListResolverTests
Assert.Equal(string.Empty, target.AckCommentSubtag); 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] [Fact]
public async Task ResolveAsync_ComposesCanonicalFullReference_FromRealGalaxyArea_NotConfigArea() public async Task ResolveAsync_ComposesCanonicalFullReference_FromRealGalaxyArea_NotConfigArea()
{ {
@@ -135,6 +147,8 @@ public sealed class AlarmWatchListResolverTests
Assert.Equal("Galaxy!TestArea.TestMachine_001.TestAlarm001", target.AlarmFullReference); 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] [Fact]
public async Task ResolveAsync_ComposesCanonicalFullReference_WithoutArea() public async Task ResolveAsync_ComposesCanonicalFullReference_WithoutArea()
{ {
@@ -153,6 +167,8 @@ public sealed class AlarmWatchListResolverTests
Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference); 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] [Fact]
public async Task ResolveAsync_ConfigInclude_UsesDiscoveryAreaFallback() public async Task ResolveAsync_ConfigInclude_UsesDiscoveryAreaFallback()
{ {
@@ -170,6 +186,8 @@ public sealed class AlarmWatchListResolverTests
Assert.Equal("Galaxy!Site_A.Tank01.Level.HiHi", target.AlarmFullReference); 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] [Fact]
public async Task ResolveAsync_ConfigInclude_FallsBackToDefaultArea_WhenDiscoveryAreaEmpty() public async Task ResolveAsync_ConfigInclude_FallsBackToDefaultArea_WhenDiscoveryAreaEmpty()
{ {
@@ -187,6 +205,8 @@ public sealed class AlarmWatchListResolverTests
Assert.Equal("Galaxy!Plant.Tank01.Level.HiHi", target.AlarmFullReference); 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] [Fact]
public async Task ResolveAsync_UseGalaxyRepositoryFalse_DoesNotCallRepository_UsesIncludesOnly() public async Task ResolveAsync_UseGalaxyRepositoryFalse_DoesNotCallRepository_UsesIncludesOnly()
{ {
@@ -206,6 +226,8 @@ public sealed class AlarmWatchListResolverTests
Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference); 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] [Fact]
public async Task ResolveAsync_RepositoryThrows_LogsAndReturnsConfigOnlySet() public async Task ResolveAsync_RepositoryThrows_LogsAndReturnsConfigOnlySet()
{ {
@@ -221,6 +243,8 @@ public sealed class AlarmWatchListResolverTests
Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference); 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] [Fact]
public async Task ResolveAsync_DerivesSourceObjectForConfigEntry() public async Task ResolveAsync_DerivesSourceObjectForConfigEntry()
{ {
@@ -240,6 +264,7 @@ public sealed class AlarmWatchListResolverTests
/// Fix 1: ExcludeAttributes must be ignored when UseGalaxyRepository is false. /// Fix 1: ExcludeAttributes must be ignored when UseGalaxyRepository is false.
/// A config-only include must survive even when the same path appears in ExcludeAttributes. /// A config-only include must survive even when the same path appears in ExcludeAttributes.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ResolveAsync_ExcludeIgnored_WhenGalaxyRepositoryDisabled() 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 /// Fix 1 (GR-on path): ExcludeAttributes still prunes GR rows when
/// UseGalaxyRepository is true. /// UseGalaxyRepository is true.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ResolveAsync_ExcludeApplied_WhenGalaxyRepositoryEnabled() 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 /// Fix 1: A whitespace-only ExcludeAttributes entry must be skipped and must
/// not accidentally exclude any real reference. /// not accidentally exclude any real reference.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ResolveAsync_WhitespaceOnlyExcludeEntry_IsSkipped() public async Task ResolveAsync_WhitespaceOnlyExcludeEntry_IsSkipped()
{ {
@@ -307,11 +334,12 @@ public sealed class AlarmWatchListResolverTests
} }
/// <summary> /// <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 /// awaiting must propagate as <see cref="OperationCanceledException"/>, not be
/// swallowed into a config-only watch-list, per the <see cref="IAlarmWatchListResolver"/> /// swallowed into a config-only watch-list, per the <see cref="IAlarmWatchListResolver"/>
/// contract. /// contract.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ResolveAsync_RepositoryCancelled_PropagatesOperationCanceled() public async Task ResolveAsync_RepositoryCancelled_PropagatesOperationCanceled()
{ {
@@ -329,11 +357,12 @@ public sealed class AlarmWatchListResolverTests
} }
/// <summary> /// <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 /// <c>IncludeAttributes</c> and <c>ExcludeAttributes</c> is removed — excludes
/// win over explicit includes (the documented "excludes also suppress matching /// win over explicit includes (the documented "excludes also suppress matching
/// explicit includes" behaviour). /// explicit includes" behaviour).
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ResolveAsync_ExcludeAlsoSuppressesMatchingExplicitInclude() 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> /// <summary>Gets the number of times <see cref="GetAlarmAttributesAsync"/> was called.</summary>
public int GetAlarmAttributesCount { get; private set; } 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); 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) => public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
Task.FromResult<DateTime?>(null); 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) => public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyHierarchyRow>()); 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) => public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyAttributeRow>()); 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) public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
{ {
GetAlarmAttributesCount++; GetAlarmAttributesCount++;
@@ -388,22 +427,32 @@ public sealed class AlarmWatchListResolverTests
/// <summary><see cref="IGalaxyRepository"/> whose alarm-attribute query throws.</summary> /// <summary><see cref="IGalaxyRepository"/> whose alarm-attribute query throws.</summary>
private sealed class ThrowingGalaxyRepository(Exception toThrow) : IGalaxyRepository 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); 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) => public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
Task.FromResult<DateTime?>(null); 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) => public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyHierarchyRow>()); 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) => public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyAttributeRow>()); 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) => public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default) =>
Task.FromException<List<GalaxyAlarmAttributeRow>>(toThrow); Task.FromException<List<GalaxyAlarmAttributeRow>>(toThrow);
} }
@@ -415,22 +464,32 @@ public sealed class AlarmWatchListResolverTests
/// </summary> /// </summary>
private sealed class CancellingGalaxyRepository(CancellationTokenSource source) : IGalaxyRepository 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); 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) => public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
Task.FromResult<DateTime?>(null); 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) => public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyHierarchyRow>()); 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) => public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyAttributeRow>()); 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) public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
{ {
source.Cancel(); source.Cancel();
@@ -24,6 +24,8 @@ public sealed class GatewayAlarmMonitorProviderModeTests
{ {
private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(15); 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] [Fact]
public async Task ProviderModeChange_BroadcastsDegradedStatus_AndIncrementsSwitchMetric() public async Task ProviderModeChange_BroadcastsDegradedStatus_AndIncrementsSwitchMetric()
{ {
@@ -121,12 +123,13 @@ public sealed class GatewayAlarmMonitorProviderModeTests
} }
/// <summary> /// <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 /// 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, /// 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 /// 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. /// suppression). This test pins that semantics so it cannot drift silently.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ProviderModeChange_RepeatedSameMode_RecordsASwitchForEachEvent() public async Task ProviderModeChange_RepeatedSameMode_RecordsASwitchForEachEvent()
{ {
@@ -219,7 +222,7 @@ public sealed class GatewayAlarmMonitorProviderModeTests
} }
/// <summary> /// <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 /// derivation (<c>GatewayAlarmMonitor.ApplyProviderModeChangeAsync</c>): an
/// alarmmgr→subtag change must emit <c>reason=failover</c> and a subtag→alarmmgr /// 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 /// 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, /// the Failover/Failback arms or collapsed them to Unknown would be caught here,
/// whereas the count-only tests above would still pass. /// whereas the count-only tests above would still pass.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ProviderModeChange_FailoverThenFailback_RecordsCorrectReasonTags() public async Task ProviderModeChange_FailoverThenFailback_RecordsCorrectReasonTags()
{ {
@@ -338,6 +342,8 @@ public sealed class GatewayAlarmMonitorProviderModeTests
await monitor.StopAsync(CancellationToken.None); 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] [Fact]
public async Task NewSubscriber_ReceivesProviderStatusAsFirstMessage() public async Task NewSubscriber_ReceivesProviderStatusAsFirstMessage()
{ {
@@ -374,6 +380,8 @@ public sealed class GatewayAlarmMonitorProviderModeTests
await monitor.StopAsync(CancellationToken.None); 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] [Fact]
public async Task ForceSubtagConfig_BaselinesProviderStatusToSubtagDegraded_WithoutSwitch() public async Task ForceSubtagConfig_BaselinesProviderStatusToSubtagDegraded_WithoutSwitch()
{ {
@@ -448,6 +456,8 @@ public sealed class GatewayAlarmMonitorProviderModeTests
await monitor.StopAsync(CancellationToken.None); 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] [Fact]
public async Task ForceAlarmManagerConfig_BaselinesProviderStatusToAlarmmgr_WithoutSwitch() public async Task ForceAlarmManagerConfig_BaselinesProviderStatusToAlarmmgr_WithoutSwitch()
{ {
@@ -519,6 +529,8 @@ public sealed class GatewayAlarmMonitorProviderModeTests
await monitor.StopAsync(CancellationToken.None); 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] [Fact]
public async Task SubscribeAlarms_SendsForcedModeAndWatchList_FromConfiguration() public async Task SubscribeAlarms_SendsForcedModeAndWatchList_FromConfiguration()
{ {
@@ -565,6 +577,10 @@ public sealed class GatewayAlarmMonitorProviderModeTests
await monitor.StopAsync(CancellationToken.None); 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] [Theory]
[InlineData("ForceAlarmManager", AlarmProviderMode.Alarmmgr)] [InlineData("ForceAlarmManager", AlarmProviderMode.Alarmmgr)]
[InlineData("forcealarmmanager", AlarmProviderMode.Alarmmgr)] [InlineData("forcealarmmanager", AlarmProviderMode.Alarmmgr)]
@@ -701,10 +717,13 @@ public sealed class GatewayAlarmMonitorProviderModeTests
public SubscribeAlarmsCommand? LastSubscribeCommand { get; private set; } public SubscribeAlarmsCommand? LastSubscribeCommand { get; private set; }
/// <summary>Pushes a worker event onto the monitor's event stream.</summary> /// <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) => public void EmitEvent(MxEvent mxEvent) =>
_events.Writer.TryWrite(new WorkerEvent { Event = mxEvent }); _events.Writer.TryWrite(new WorkerEvent { Event = mxEvent });
/// <summary>Completes once the monitor has issued its SubscribeAlarms command.</summary> /// <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); public Task WaitForSubscribeAsync(TimeSpan timeout) => _subscribed.Task.WaitAsync(timeout);
/// <inheritdoc /> /// <inheritdoc />
@@ -123,12 +123,14 @@ public sealed class GatewayOptionsTests
StringComparison.Ordinal); StringComparison.Ordinal);
} }
/// <summary>Verifies that <see cref="DashboardOptions.DisableLogin"/> defaults to <see langword="false"/>.</summary>
[Fact] [Fact]
public void DashboardOptions_DisableLogin_DefaultsToFalse() public void DashboardOptions_DisableLogin_DefaultsToFalse()
{ {
Assert.False(new DashboardOptions().DisableLogin); Assert.False(new DashboardOptions().DisableLogin);
} }
/// <summary>Verifies that <see cref="DashboardOptions.AutoLoginUser"/> defaults to <see langword="null"/>.</summary>
[Fact] [Fact]
public void DashboardOptions_AutoLoginUser_DefaultsToNull() public void DashboardOptions_AutoLoginUser_DefaultsToNull()
{ {
@@ -46,6 +46,7 @@ public sealed class GatewayOptionsValidatorTests
Tls = tls, Tls = tls,
}; };
/// <summary>Verifies default TLS options pass validation.</summary>
[Fact] [Fact]
public void Validate_Succeeds_WithDefaultTlsOptions() public void Validate_Succeeds_WithDefaultTlsOptions()
{ {
@@ -53,6 +54,7 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Succeeded); Assert.True(result.Succeeded);
} }
/// <summary>Verifies a zero <see cref="TlsOptions.ValidityYears"/> fails validation.</summary>
[Fact] [Fact]
public void Validate_Fails_WhenTlsValidityYearsOutOfRange() public void Validate_Fails_WhenTlsValidityYearsOutOfRange()
{ {
@@ -62,6 +64,7 @@ public sealed class GatewayOptionsValidatorTests
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:ValidityYears")); 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] [Fact]
public void Validate_Fails_WhenTlsValidityYearsTooLarge() public void Validate_Fails_WhenTlsValidityYearsTooLarge()
{ {
@@ -71,6 +74,7 @@ public sealed class GatewayOptionsValidatorTests
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:ValidityYears")); Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:ValidityYears"));
} }
/// <summary>Verifies a blank entry in <see cref="TlsOptions.AdditionalDnsNames"/> fails validation.</summary>
[Fact] [Fact]
public void Validate_Fails_WhenAdditionalDnsNameBlank() public void Validate_Fails_WhenAdditionalDnsNameBlank()
{ {
@@ -80,6 +84,7 @@ public sealed class GatewayOptionsValidatorTests
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:AdditionalDnsNames")); Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:AdditionalDnsNames"));
} }
/// <summary>Verifies a blank <see cref="TlsOptions.SelfSignedCertPath"/> fails validation.</summary>
[Fact] [Fact]
public void Validate_Fails_WhenSelfSignedCertPathBlank() 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.")); 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] [Fact]
public void Validate_Fails_WhenLdapPortIsZero() 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)")); 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] [Fact]
public void Validate_Fails_WhenLdapPortExceedsMaximum() 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)")); 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] [Fact]
public void Validate_Succeeds_WhenLdapEnabledWithValidPort() public void Validate_Succeeds_WhenLdapEnabledWithValidPort()
{ {
@@ -144,6 +152,7 @@ public sealed class GatewayOptionsValidatorTests
Tls = source.Tls, Tls = source.Tls,
}; };
/// <summary>Verifies an invalid fallback mode is not validated when alarms are disabled.</summary>
[Fact] [Fact]
public void Validate_Succeeds_WhenAlarmsDisabled_FallbackNotValidated() public void Validate_Succeeds_WhenAlarmsDisabled_FallbackNotValidated()
{ {
@@ -159,6 +168,7 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Succeeded); Assert.True(result.Succeeded);
} }
/// <summary>Verifies the default "Auto" fallback mode passes validation when alarms are enabled.</summary>
[Fact] [Fact]
public void Validate_Succeeds_WhenAlarmsEnabled_DefaultAutoConfig() public void Validate_Succeeds_WhenAlarmsEnabled_DefaultAutoConfig()
{ {
@@ -170,6 +180,8 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Succeeded); 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] [Theory]
[InlineData("Auto")] [InlineData("Auto")]
[InlineData("ForceAlarmManager")] [InlineData("ForceAlarmManager")]
@@ -184,6 +196,7 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Succeeded); Assert.True(result.Succeeded);
} }
/// <summary>Verifies an unrecognised fallback mode fails validation when alarms are enabled.</summary>
[Fact] [Fact]
public void Validate_Fails_WhenAlarmsEnabled_InvalidMode() 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")); 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] [Fact]
public void Validate_Fails_WhenForceSubtag_NoGalaxyRepository_NoIncludes() public void Validate_Fails_WhenForceSubtag_NoGalaxyRepository_NoIncludes()
{ {
@@ -217,6 +231,7 @@ public sealed class GatewayOptionsValidatorTests
f => f.Contains("ForceSubtag") && f.Contains("Discovery")); f => f.Contains("ForceSubtag") && f.Contains("Discovery"));
} }
/// <summary>Verifies "ForceSubtag" passes validation without a Galaxy repository when include attributes are supplied.</summary>
[Fact] [Fact]
public void Validate_Succeeds_WhenForceSubtag_NoGalaxyRepository_WithIncludes() public void Validate_Succeeds_WhenForceSubtag_NoGalaxyRepository_WithIncludes()
{ {
@@ -236,6 +251,7 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Succeeded); Assert.True(result.Succeeded);
} }
/// <summary>Verifies "ForceSubtag" passes validation when a Galaxy repository is used, even without include attributes.</summary>
[Fact] [Fact]
public void Validate_Succeeds_WhenForceSubtag_WithGalaxyRepository() public void Validate_Succeeds_WhenForceSubtag_WithGalaxyRepository()
{ {
@@ -251,6 +267,9 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Succeeded); 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] [Theory]
[InlineData(0, nameof(AlarmFallbackOptions.ConsecutiveFailureThreshold))] [InlineData(0, nameof(AlarmFallbackOptions.ConsecutiveFailureThreshold))]
[InlineData(-1, nameof(AlarmFallbackOptions.ConsecutiveFailureThreshold))] [InlineData(-1, nameof(AlarmFallbackOptions.ConsecutiveFailureThreshold))]
@@ -264,6 +283,9 @@ public sealed class GatewayOptionsValidatorTests
Assert.Contains(result.Failures!, f => f.Contains(keyPart)); 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] [Theory]
[InlineData(0, nameof(AlarmFallbackOptions.FailbackProbeIntervalSeconds))] [InlineData(0, nameof(AlarmFallbackOptions.FailbackProbeIntervalSeconds))]
[InlineData(-5, nameof(AlarmFallbackOptions.FailbackProbeIntervalSeconds))] [InlineData(-5, nameof(AlarmFallbackOptions.FailbackProbeIntervalSeconds))]
@@ -277,6 +299,9 @@ public sealed class GatewayOptionsValidatorTests
Assert.Contains(result.Failures!, f => f.Contains(keyPart)); 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] [Theory]
[InlineData(0, nameof(AlarmFallbackOptions.FailbackStableProbes))] [InlineData(0, nameof(AlarmFallbackOptions.FailbackStableProbes))]
[InlineData(-1, nameof(AlarmFallbackOptions.FailbackStableProbes))] [InlineData(-1, nameof(AlarmFallbackOptions.FailbackStableProbes))]
@@ -308,6 +333,7 @@ public sealed class GatewayOptionsValidatorTests
Tls = source.Tls, Tls = source.Tls,
}; };
/// <summary>Verifies <see cref="SessionOptions.AllowMultipleEventSubscribers"/> set to <see langword="true"/> passes validation.</summary>
[Fact] [Fact]
public void Validate_Succeeds_WhenAllowMultipleEventSubscribersIsTrue() public void Validate_Succeeds_WhenAllowMultipleEventSubscribersIsTrue()
{ {
@@ -319,6 +345,8 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Succeeded); 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] [Theory]
[InlineData(0)] [InlineData(0)]
[InlineData(-1)] [InlineData(-1)]
@@ -334,6 +362,8 @@ public sealed class GatewayOptionsValidatorTests
f => f.Contains("MxGateway:Sessions:MaxEventSubscribersPerSession")); 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] [Theory]
[InlineData(1)] [InlineData(1)]
[InlineData(8)] [InlineData(8)]
@@ -347,6 +377,7 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Succeeded); Assert.True(result.Succeeded);
} }
/// <summary>Verifies default <see cref="SessionOptions"/> pass validation.</summary>
[Fact] [Fact]
public void Validate_Succeeds_WithDefaultSessionOptions() public void Validate_Succeeds_WithDefaultSessionOptions()
{ {
@@ -361,6 +392,7 @@ public sealed class GatewayOptionsValidatorTests
// WorkerReadyWaitTimeoutMs validation // WorkerReadyWaitTimeoutMs validation
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/// <summary>Verifies a negative <see cref="SessionOptions.WorkerReadyWaitTimeoutMs"/> fails validation.</summary>
[Fact] [Fact]
public void Validate_Fails_WhenWorkerReadyWaitTimeoutMsIsNegative() public void Validate_Fails_WhenWorkerReadyWaitTimeoutMsIsNegative()
{ {
@@ -374,6 +406,7 @@ public sealed class GatewayOptionsValidatorTests
f => f.Contains("MxGateway:Sessions:WorkerReadyWaitTimeoutMs")); f => f.Contains("MxGateway:Sessions:WorkerReadyWaitTimeoutMs"));
} }
/// <summary>Verifies a zero <see cref="SessionOptions.WorkerReadyWaitTimeoutMs"/> passes validation.</summary>
[Fact] [Fact]
public void Validate_Succeeds_WhenWorkerReadyWaitTimeoutMsIsZero() public void Validate_Succeeds_WhenWorkerReadyWaitTimeoutMsIsZero()
{ {
@@ -384,6 +417,7 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Succeeded); Assert.True(result.Succeeded);
} }
/// <summary>Verifies a positive <see cref="SessionOptions.WorkerReadyWaitTimeoutMs"/> passes validation.</summary>
[Fact] [Fact]
public void Validate_Succeeds_WhenWorkerReadyWaitTimeoutMsIsPositive() public void Validate_Succeeds_WhenWorkerReadyWaitTimeoutMsIsPositive()
{ {
@@ -394,6 +428,7 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Succeeded); Assert.True(result.Succeeded);
} }
/// <summary>Verifies a negative <see cref="SessionOptions.DetachGraceSeconds"/> fails validation.</summary>
[Fact] [Fact]
public void Validate_Fails_WhenDetachGraceSecondsIsNegative() public void Validate_Fails_WhenDetachGraceSecondsIsNegative()
{ {
@@ -407,6 +442,7 @@ public sealed class GatewayOptionsValidatorTests
f => f.Contains("MxGateway:Sessions:DetachGraceSeconds")); f => f.Contains("MxGateway:Sessions:DetachGraceSeconds"));
} }
/// <summary>Verifies a zero <see cref="SessionOptions.DetachGraceSeconds"/> passes validation.</summary>
[Fact] [Fact]
public void Validate_Succeeds_WhenDetachGraceSecondsIsZero() public void Validate_Succeeds_WhenDetachGraceSecondsIsZero()
{ {
@@ -435,6 +471,7 @@ public sealed class GatewayOptionsValidatorTests
Tls = source.Tls, Tls = source.Tls,
}; };
/// <summary>Verifies a negative <see cref="EventOptions.ReplayBufferCapacity"/> fails validation.</summary>
[Fact] [Fact]
public void Validate_Fails_WhenReplayBufferCapacityIsNegative() public void Validate_Fails_WhenReplayBufferCapacityIsNegative()
{ {
@@ -448,6 +485,7 @@ public sealed class GatewayOptionsValidatorTests
f => f.Contains("MxGateway:Events:ReplayBufferCapacity")); f => f.Contains("MxGateway:Events:ReplayBufferCapacity"));
} }
/// <summary>Verifies a zero <see cref="EventOptions.ReplayBufferCapacity"/> passes validation.</summary>
[Fact] [Fact]
public void Validate_Succeeds_WhenReplayBufferCapacityIsZero() public void Validate_Succeeds_WhenReplayBufferCapacityIsZero()
{ {
@@ -458,6 +496,7 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Succeeded); Assert.True(result.Succeeded);
} }
/// <summary>Verifies a negative <see cref="EventOptions.ReplayRetentionSeconds"/> fails validation.</summary>
[Fact] [Fact]
public void Validate_Fails_WhenReplayRetentionSecondsIsNegative() public void Validate_Fails_WhenReplayRetentionSecondsIsNegative()
{ {
@@ -471,6 +510,7 @@ public sealed class GatewayOptionsValidatorTests
f => f.Contains("MxGateway:Events:ReplayRetentionSeconds")); f => f.Contains("MxGateway:Events:ReplayRetentionSeconds"));
} }
/// <summary>Verifies a zero <see cref="EventOptions.ReplayRetentionSeconds"/> passes validation.</summary>
[Fact] [Fact]
public void Validate_Succeeds_WhenReplayRetentionSecondsIsZero() public void Validate_Succeeds_WhenReplayRetentionSecondsIsZero()
{ {
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
public sealed class TlsOptionsBindingTests public sealed class TlsOptionsBindingTests
{ {
/// <summary>Verifies that <see cref="TlsOptions"/> applies its documented defaults when no configuration section is bound.</summary>
[Fact] [Fact]
public void Defaults_AreApplied_WhenSectionAbsent() public void Defaults_AreApplied_WhenSectionAbsent()
{ {
@@ -16,6 +17,7 @@ public sealed class TlsOptionsBindingTests
Assert.False(string.IsNullOrWhiteSpace(options.SelfSignedCertPath)); 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] [Fact]
public void Binds_FromMxGatewayTlsSection() public void Binds_FromMxGatewayTlsSection()
{ {
@@ -347,8 +347,8 @@ public sealed class ProtobufContractRoundTripTests
/// by-name-specific payload case and instead reuses the /// by-name-specific payload case and instead reuses the
/// <c>acknowledge_alarm</c> (<see cref="AcknowledgeAlarmReplyPayload"/>) /// <c>acknowledge_alarm</c> (<see cref="AcknowledgeAlarmReplyPayload"/>)
/// case. A future change that adds a separate by-name reply case — or /// case. A future change that adds a separate by-name reply case — or
/// drops the reuse — breaks this test. See Contracts-002 and /// drops the reuse — breaks this test. See docs/AlarmClientDiscovery.md
/// docs/AlarmClientDiscovery.md section 4. /// section 4.
/// </summary> /// </summary>
[Fact] [Fact]
public void MxCommandReply_AcknowledgeAlarmByName_ReusesAcknowledgeAlarmPayloadCase() public void MxCommandReply_AcknowledgeAlarmByName_ReusesAcknowledgeAlarmPayloadCase()
@@ -1015,8 +1015,6 @@ public sealed class ProtobufContractRoundTripTests
/// Verifies that a <see cref="WriteSecuredBulkCommand"/> round-trips, /// Verifies that a <see cref="WriteSecuredBulkCommand"/> round-trips,
/// pinning the credential-bearing entry shape /// pinning the credential-bearing entry shape
/// (<c>current_user_id</c>, <c>verifier_user_id</c>, <c>value</c>). /// (<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> /// </summary>
[Fact] [Fact]
public void WriteSecuredBulkCommand_RoundTripsCredentialBearingEntries() public void WriteSecuredBulkCommand_RoundTripsCredentialBearingEntries()
@@ -1597,7 +1595,7 @@ public sealed class ProtobufContractRoundTripTests
/// <see cref="MxSparseArray"/> with at least one <see cref="MxSparseElement"/> /// <see cref="MxSparseArray"/> with at least one <see cref="MxSparseElement"/>
/// so a future renumber or type-narrowing is caught at the contract level. /// so a future renumber or type-narrowing is caught at the contract level.
/// Also verifies that an all-defaults <see cref="MxSparseArray"/> (no elements) /// 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> /// </summary>
[Fact] [Fact]
public void MxValue_RoundTripsSparseArrayValueAndPinsFieldNumbers() 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> /// <summary>Mutable so a single test can swap the entry mid-flight.</summary>
public GalaxyHierarchyCacheEntry Current { get; set; } = initial; 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; 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; public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
} }
} }
@@ -12,6 +12,8 @@ public sealed class AuthStoreHealthCheckTests
return new AuthSqliteConnectionFactory(sqlitePath); 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] [Fact]
public async Task Healthy_WhenStoreReachable() public async Task Healthy_WhenStoreReachable()
{ {
@@ -25,6 +27,8 @@ public sealed class AuthStoreHealthCheckTests
finally { if (File.Exists(path)) File.Delete(path); } 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] [Fact]
public async Task Unhealthy_WhenPathUnusable() public async Task Unhealthy_WhenPathUnusable()
{ {
@@ -4,6 +4,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Diagnostics;
public sealed class GatewayLogRedactorSeamTests 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] [Fact]
public void Redact_MasksApiKeyInClientIdentity() public void Redact_MasksApiKeyInClientIdentity()
{ {
@@ -9,16 +9,6 @@ namespace ZB.MOM.WW.MxGateway.Tests.Galaxy;
/// <summary> /// <summary>
/// Adversarial-input coverage for the Galaxy Repository browse filter layer. /// Adversarial-input coverage for the Galaxy Repository browse filter layer.
/// <para> /// <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 /// The genuine, testable concern is that adversarial filter strings — SQL
/// metacharacters (<c>'</c>, <c>;</c>) and <c>LIKE</c>-wildcards (<c>%</c>, /// 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: /// <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> /// <summary>Returns adversarial input cases for theory tests.</summary>
/// <returns>Theory data of adversarial input strings.</returns>
public static TheoryData<string> AdversarialInputCases() public static TheoryData<string> AdversarialInputCases()
{ {
TheoryData<string> data = []; TheoryData<string> data = [];
@@ -88,7 +79,7 @@ public sealed class GalaxyFilterInputSafetyTests
} }
/// <summary> /// <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 /// the compiled regex per glob pattern. Repeated calls with the same pattern, and
/// interleaved calls with different patterns, must keep returning the correct /// interleaved calls with different patterns, must keep returning the correct
/// literal-vs-wildcard result rather than a stale cached match. /// 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. /// zero matches rather than returning the whole hierarchy or faulting.
/// </summary> /// </summary>
/// <param name="glob">An adversarial glob containing SQL metacharacters or LIKE wildcards.</param> /// <param name="glob">An adversarial glob containing SQL metacharacters or LIKE wildcards.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory] [Theory]
[MemberData(nameof(AdversarialInputCases))] [MemberData(nameof(AdversarialInputCases))]
public async Task DiscoverHierarchy_WithAdversarialTagNameGlob_ReturnsZeroMatches(string glob) public async Task DiscoverHierarchy_WithAdversarialTagNameGlob_ReturnsZeroMatches(string glob)
@@ -249,6 +241,7 @@ public sealed class GalaxyFilterInputSafetyTests
/// a query fragment or matching unrelated objects. /// a query fragment or matching unrelated objects.
/// </summary> /// </summary>
/// <param name="rootTagName">An adversarial tag name containing SQL metacharacters or LIKE wildcards.</param> /// <param name="rootTagName">An adversarial tag name containing SQL metacharacters or LIKE wildcards.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory] [Theory]
[MemberData(nameof(AdversarialInputCases))] [MemberData(nameof(AdversarialInputCases))]
public async Task DiscoverHierarchy_WithAdversarialRootTagName_ReturnsNotFound(string rootTagName) public async Task DiscoverHierarchy_WithAdversarialRootTagName_ReturnsNotFound(string rootTagName)
@@ -327,13 +320,17 @@ public sealed class GalaxyFilterInputSafetyTests
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{ {
/// <inheritdoc /> /// <summary>Gets the fixed cache entry returned to callers.</summary>
public GalaxyHierarchyCacheEntry Current { get; } = current; 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; 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; public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
} }
@@ -29,6 +29,7 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
private readonly List<TempDatabaseDirectory> _tempDirectories = []; private readonly List<TempDatabaseDirectory> _tempDirectories = [];
/// <summary>Verifies that unauthorized users cannot create API keys.</summary> /// <summary>Verifies that unauthorized users cannot create API keys.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task CreateAsync_UnauthorizedUser_DoesNotCreate() 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> /// <summary>Verifies that authorized users create a verifiable, constrained key and audit it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task CreateAsync_AuthorizedUser_CreatesVerifiableKeyAndAudits() 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> /// <summary>Verifies that creating a key whose id already exists is rejected.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task CreateAsync_DuplicateKeyId_ReportsConflict() 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> /// <summary>Verifies that authorized users can revoke keys with audit trail.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits() 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> /// <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] [Fact]
public async Task RotateAsync_AuthorizedUser_RotatesAndAudits() 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> /// <summary>Verifies that authorized users can delete revoked keys with audit trail.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task DeleteAsync_AuthorizedUser_DeletesRevokedKeyAndAudits() 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 /// <c>dashboard-delete-key</c> audit entry with <c>Details = "not-found-or-active"</c> is still
/// written — audit completeness for refused deletes. /// written — audit completeness for refused deletes.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task DeleteAsync_ActiveKey_ReportsFriendlyErrorAndAudits() 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> /// <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> /// <param name="blankKeyId">A blank or whitespace key identifier.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory] [Theory]
[InlineData("")] [InlineData("")]
[InlineData(" ")] [InlineData(" ")]
@@ -225,10 +233,11 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
} }
/// <summary> /// <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 /// non-canonical scope string rather than persisting a key whose scope the authorization
/// resolver never matches. /// resolver never matches.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task CreateAsync_UnknownScope_DoesNotCreate() 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 /// Phase 3 canonical audit shape: the dashboard-create-key canonical AuditEvent records
/// the operator username as Actor and the managed keyId as Target. /// the operator username as Actor and the managed keyId as Target.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task CreateAsync_AuthorizedUser_CanonicalAuditEventHasOperatorAsActorAndKeyIdAsTarget() public async Task CreateAsync_AuthorizedUser_CanonicalAuditEventHasOperatorAsActorAndKeyIdAsTarget()
{ {
@@ -386,7 +396,10 @@ public sealed class DashboardApiKeyManagementServiceTests : IDisposable
/// <summary>Gets the recorded canonical audit events.</summary> /// <summary>Gets the recorded canonical audit events.</summary>
public List<ZB.MOM.WW.Audit.AuditEvent> Events { get; } = []; 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) public Task WriteAsync(ZB.MOM.WW.Audit.AuditEvent auditEvent, CancellationToken cancellationToken = default)
{ {
Events.Add(auditEvent); Events.Add(auditEvent);
@@ -20,6 +20,8 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardAuthenticatorTests public sealed class DashboardAuthenticatorTests
{ {
/// <summary>A blank username is rejected without touching the LDAP provider.</summary> /// <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] [Theory]
[InlineData(null)] [InlineData(null)]
[InlineData("")] [InlineData("")]
@@ -40,6 +42,7 @@ public sealed class DashboardAuthenticatorTests
} }
/// <summary>A blank password is rejected without touching the LDAP provider.</summary> /// <summary>A blank password is rejected without touching the LDAP provider.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AuthenticateAsync_BlankPassword_FailsWithoutCallingLdap() 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"/>) /// A failed LDAP outcome (any failure bucket, including <see cref="LdapAuthFailure.Disabled"/>)
/// maps to the generic dashboard failure without leaking the raw password. /// maps to the generic dashboard failure without leaking the raw password.
/// </summary> /// </summary>
/// <param name="failure">The LDAP failure bucket under test.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory] [Theory]
[InlineData(LdapAuthFailure.Disabled)] [InlineData(LdapAuthFailure.Disabled)]
[InlineData(LdapAuthFailure.BadCredentials)] [InlineData(LdapAuthFailure.BadCredentials)]
@@ -87,6 +92,7 @@ public sealed class DashboardAuthenticatorTests
/// name (ClaimTypes.Name), and the username (ClaimTypes.NameIdentifier), under the /// name (ClaimTypes.Name), and the username (ClaimTypes.NameIdentifier), under the
/// dashboard authentication scheme. /// dashboard authentication scheme.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AuthenticateAsync_Success_BuildsPrincipalWithExpectedClaims() public async Task AuthenticateAsync_Success_BuildsPrincipalWithExpectedClaims()
{ {
@@ -127,11 +133,12 @@ public sealed class DashboardAuthenticatorTests
} }
/// <summary> /// <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 /// the login username, ZbClaimTypes.Role (= ClaimTypes.Role) for each resolved role, and
/// ZbClaimTypes.DisplayName ("zb:displayname") with the display name — while keeping /// ZbClaimTypes.DisplayName ("zb:displayname") with the display name — while keeping
/// ClaimTypes.NameIdentifier, ClaimTypes.Name, and mxgateway:ldap_group claims intact. /// ClaimTypes.NameIdentifier, ClaimTypes.Name, and mxgateway:ldap_group claims intact.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AuthenticateAsync_Success_EmitsCanonicalZbClaims() 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, /// 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). /// the login is denied (the long-standing "no roles matched → denied" rule).
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AuthenticateAsync_NoRolesMatched_DeniesLogin() public async Task AuthenticateAsync_NoRolesMatched_DeniesLogin()
{ {
@@ -216,6 +224,7 @@ public sealed class DashboardAuthenticatorTests
/// for the realistic (already-short) group shape. /// for the realistic (already-short) group shape.
/// </para> /// </para>
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AuthenticateAsync_GroupAsDistinguishedNameFromService_ResolvesRoleAndSurfacesServiceValue() 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> /// <summary>The (already-trimmed) username from the LDAP result flows onto the principal.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AuthenticateAsync_UsesUsernameFromLdapResult() public async Task AuthenticateAsync_UsesUsernameFromLdapResult()
{ {
@@ -296,10 +306,17 @@ public sealed class DashboardAuthenticatorTests
/// </summary> /// </summary>
private sealed class FakeLdapAuthService(LdapAuthResult result) : ILdapAuthService 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; } 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; } 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) public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
{ {
WasCalled = true; WasCalled = true;
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardAuthorizationHandlerTests public sealed class DashboardAuthorizationHandlerTests
{ {
/// <summary>Verifies that unauthenticated remote requests fail authorization.</summary> /// <summary>Verifies that unauthenticated remote requests fail authorization.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task HandleAsync_UnauthenticatedRemoteRequest_DoesNotSucceed() public async Task HandleAsync_UnauthenticatedRemoteRequest_DoesNotSucceed()
{ {
@@ -24,6 +25,7 @@ public sealed class DashboardAuthorizationHandlerTests
} }
/// <summary>Verifies that anonymous localhost access succeeds when allowed.</summary> /// <summary>Verifies that anonymous localhost access succeeds when allowed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task HandleAsync_AnonymousLocalhostAllowed_Succeeds() 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> /// 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. /// is off, even on a loopback connection — the misconfiguration must not expose the dashboard.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task HandleAsync_AnonymousLocalhostDisallowed_DoesNotSucceed() 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 /// 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. /// request from a non-loopback address is denied even when <c>AllowAnonymousLocalhost</c> is on.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task HandleAsync_AnonymousLocalhostAllowedFromRemoteAddress_DoesNotSucceed() 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> /// <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] [Fact]
public async Task HandleAsync_AuthenticatedWithoutDashboardRole_DoesNotSucceed() 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> /// <summary>Verifies that a Viewer satisfies the viewer-or-admin requirement.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task HandleAsync_ViewerRole_SatisfiesViewerPolicy() 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> /// <summary>Verifies that an Admin satisfies the admin-only requirement.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task HandleAsync_AdminRole_SatisfiesAdminPolicy() 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> /// <summary>Verifies that a Viewer does NOT satisfy the admin-only requirement.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task HandleAsync_ViewerRole_DoesNotSatisfyAdminPolicy() public async Task HandleAsync_ViewerRole_DoesNotSatisfyAdminPolicy()
{ {
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardAutoLoginAuthenticationHandlerTests public sealed class DashboardAutoLoginAuthenticationHandlerTests
{ {
/// <summary>The default auto-login user mints an authenticated principal with both dashboard roles.</summary>
[Fact] [Fact]
public void CreatePrincipal_MintsAuthenticatedMultiRoleUser() public void CreatePrincipal_MintsAuthenticatedMultiRoleUser()
{ {
@@ -16,6 +17,8 @@ public sealed class DashboardAutoLoginAuthenticationHandlerTests
Assert.True(principal.IsInRole(DashboardRoles.Viewer)); 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] [Theory]
[InlineData(null)] [InlineData(null)]
[InlineData("")] [InlineData("")]
@@ -27,6 +30,7 @@ public sealed class DashboardAutoLoginAuthenticationHandlerTests
Assert.Equal(DashboardAutoLoginAuthenticationHandler.DefaultUser, principal.Identity!.Name); Assert.Equal(DashboardAutoLoginAuthenticationHandler.DefaultUser, principal.Identity!.Name);
} }
/// <summary>Leading/trailing whitespace around the user name is trimmed before minting the principal.</summary>
[Fact] [Fact]
public void CreatePrincipal_TrimsUser() public void CreatePrincipal_TrimsUser()
{ {
@@ -35,6 +39,7 @@ public sealed class DashboardAutoLoginAuthenticationHandlerTests
Assert.Equal("multi-role", principal.Identity!.Name); Assert.Equal("multi-role", principal.Identity!.Name);
} }
/// <summary>A custom user name preserves its identity name and still receives both dashboard roles.</summary>
[Fact] [Fact]
public void CreatePrincipal_CustomUser_PreservesNameAndRoles() public void CreatePrincipal_CustomUser_PreservesNameAndRoles()
{ {
@@ -171,13 +171,11 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.True(model.IsDegraded); Assert.True(model.IsDegraded);
Assert.Contains("bg-warning", model.BadgeCssClass, StringComparison.Ordinal); Assert.Contains("bg-warning", model.BadgeCssClass, StringComparison.Ordinal);
Assert.Equal("x", model.Reason); 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); Assert.Equal(DashboardAlarmProviderStatus.DegradedLabel, model.Label);
} }
/// <summary> /// <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 /// <c>Degraded || Mode==Subtag</c> guard's second, independent branch) must still
/// map to the degraded amber badge. /// map to the degraded amber badge.
/// </summary> /// </summary>
@@ -199,7 +197,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
} }
/// <summary> /// <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" />. /// timestamp converted to a <see cref="DateTimeOffset" />.
/// </summary> /// </summary>
[Fact] [Fact]
@@ -220,7 +218,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
} }
/// <summary> /// <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 /// dashboard SignalR snapshot path actually calls — projects a provider-status
/// feed message into the badge model. /// feed message into the badge model.
/// </summary> /// </summary>
@@ -246,7 +244,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
} }
/// <summary> /// <summary>
/// Tests-033: <see cref="DashboardAlarmProviderStatus.FromFeed" /> throws /// <see cref="DashboardAlarmProviderStatus.FromFeed" /> throws
/// <see cref="ArgumentException" /> when the feed message does not carry a /// <see cref="ArgumentException" /> when the feed message does not carry a
/// provider-status payload. /// provider-status payload.
/// </summary> /// </summary>
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardCookieOptionsTests public sealed class DashboardCookieOptionsTests
{ {
/// <summary>Verifies that the application configures secure dashboard authentication cookies.</summary> /// <summary>Verifies that the application configures secure dashboard authentication cookies.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Build_ConfiguresSecureDashboardCookie() public async Task Build_ConfiguresSecureDashboardCookie()
{ {
@@ -36,6 +37,7 @@ public sealed class DashboardCookieOptionsTests
/// relaxes the cookie to <see cref="CookieSecurePolicy.SameAsRequest"/> so /// relaxes the cookie to <see cref="CookieSecurePolicy.SameAsRequest"/> so
/// the dashboard can be reached over plain HTTP in dev. /// the dashboard can be reached over plain HTTP in dev.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Build_WithRequireHttpsCookieFalse_UsesSameAsRequest() 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 /// 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). /// distinct name (browser cookies are scoped by host+path, not port).
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Build_WithCookieNameOverride_UsesConfiguredName() public async Task Build_WithCookieNameOverride_UsesConfiguredName()
{ {
@@ -11,6 +11,8 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardDisableLoginTests 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] [Fact]
public async Task DisableLoginOff_CookieSchemeUsesCookieHandler() public async Task DisableLoginOff_CookieSchemeUsesCookieHandler()
{ {
@@ -25,6 +27,8 @@ public sealed class DashboardDisableLoginTests
Assert.Equal(typeof(CookieAuthenticationHandler), scheme!.HandlerType); 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] [Fact]
public async Task DisableLoginOn_CookieSchemeUsesAutoLoginHandler() public async Task DisableLoginOn_CookieSchemeUsesAutoLoginHandler()
{ {
@@ -40,6 +44,8 @@ public sealed class DashboardDisableLoginTests
Assert.Equal(typeof(DashboardAutoLoginAuthenticationHandler), scheme!.HandlerType); 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] [Fact]
public async Task DisableLoginOn_AutoLoginPrincipalSatisfiesAdminAndViewerPolicies() 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 /// Verifies that the cache status enum is faithfully mapped to the dashboard
/// status enum across every defined value. /// status enum across every defined value.
/// </summary> /// </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] [Theory]
[InlineData(GalaxyCacheStatus.Healthy, DashboardGalaxyStatus.Healthy)] [InlineData(GalaxyCacheStatus.Healthy, DashboardGalaxyStatus.Healthy)]
[InlineData(GalaxyCacheStatus.Stale, DashboardGalaxyStatus.Stale)] [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> /// <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="ldapGroup">The LDAP group name or distinguished name.</param>
/// <param name="expectedRole">The expected role or null if no match.</param> /// <param name="expectedRole">The expected role or null if no match.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory] [Theory]
[InlineData("GwAdmin", DashboardRoles.Admin)] [InlineData("GwAdmin", DashboardRoles.Admin)]
[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> /// <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] [Fact]
public async Task MapAsync_AdminPlusViewer_BothRolesEmitted() 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> /// <summary>Verifies that an empty GroupToRole map yields no roles.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task MapAsync_EmptyMapping_ReturnsNoRoles() public async Task MapAsync_EmptyMapping_ReturnsNoRoles()
{ {
@@ -86,12 +89,13 @@ public sealed class DashboardGroupRoleMapperTests
} }
/// <summary> /// <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 /// to the canonical role value <c>"Administrator"</c> (not the legacy
/// <c>"Admin"</c>), and the reader group to <c>"Viewer"</c>. Asserted with /// <c>"Admin"</c>), and the reader group to <c>"Viewer"</c>. Asserted with
/// string LITERALS — independent of <see cref="DashboardRoles"/> — so a /// string LITERALS — independent of <see cref="DashboardRoles"/> — so a
/// regression on the constant's value is caught here. /// regression on the constant's value is caught here.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task MapAsync_AdminGroup_ResolvesToCanonicalAdministratorValue() public async Task MapAsync_AdminGroup_ResolvesToCanonicalAdministratorValue()
{ {
@@ -105,7 +109,7 @@ public sealed class DashboardGroupRoleMapperTests
} }
/// <summary> /// <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, /// 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> /// proving enforcement is bound to the new value and the legacy <c>"Admin"</c>
/// string is no longer what authorizes admin actions. /// 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 /// Verifies the extracted shared helper is the single source of truth: it
/// produces the same roles the mapper does for the same inputs. /// produces the same roles the mapper does for the same inputs.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task SharedHelper_MatchesMapperOutput() public async Task SharedHelper_MatchesMapperOutput()
{ {
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardHubsRegistrationTests public sealed class DashboardHubsRegistrationTests
{ {
/// <summary>Verifies that dashboard build maps all three hubs and token endpoint.</summary> /// <summary>Verifies that dashboard build maps all three hubs and token endpoint.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Build_WhenDashboardEnabled_MapsAllThreeHubsAndTokenEndpoint() 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> /// <summary>Verifies that dashboard build registers hub token service and connection factory.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Build_WhenDashboardEnabled_RegistersHubTokenServiceAndConnectionFactory() public async Task Build_WhenDashboardEnabled_RegistersHubTokenServiceAndConnectionFactory()
{ {

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