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:
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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 = [];
|
||||||
|
|||||||
+1
@@ -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);
|
||||||
|
|||||||
+8
-2
@@ -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))
|
||||||
|
|||||||
-4
@@ -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
|
||||||
|
|||||||
+2
-4
@@ -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);
|
||||||
|
|||||||
+1
@@ -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,
|
||||||
|
|||||||
+1
@@ -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 => session.ReadEventsAsync(ct).Select(mapper.MapEvent)</c> (or a
|
/// <c>ct => 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -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
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
+4
-6
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
+2
@@ -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
Reference in New Issue
Block a user