Compare commits

...

8 Commits

Author SHA1 Message Date
Joseph Doherty 42c120aa83 Preserve framed reads across fake worker emission gates
ci / windows-x86 (push) Successful in 1m20s
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m17s
ci / windows-x86 (pull_request) Successful in 1m25s
ci / nightly-windev (pull_request) Has been skipped
ci / java (pull_request) Successful in 2m27s
ci / portable (push) Successful in 9m43s
ci / portable (pull_request) Successful in 9m47s
2026-10-13 09:42:09 -04:00
dohertj2 19920b13da Merge pull request 'Allow redundant must_use only in generated Rust service traits' (#152) from fix/generated-rust-must-use into main
ci / windows-x86 (push) Successful in 1m25s
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m15s
ci / portable (push) Successful in 9m6s
2026-10-13 09:37:53 -04:00
Joseph Doherty a99070005f Fix Rust 1.99 lint for generated service traits
ci / windows-x86 (push) Successful in 1m27s
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m45s
ci / java (pull_request) Successful in 2m55s
ci / windows-x86 (pull_request) Successful in 1m20s
ci / nightly-windev (pull_request) Has been skipped
ci / portable (push) Successful in 10m40s
ci / portable (pull_request) Successful in 10m18s
2026-10-13 09:24:07 -04:00
Joseph Doherty f40409a688 docs: strip task-tracking IDs from code comments and fill missing XML docs
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m16s
ci / java (push) Successful in 2m5s
ci / portable (push) Failing after 5m9s
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
2026-10-07 15:34:32 -04:00
Joseph Doherty f2e0861709 docs: note that psbridge and winrm remote access to the wonder boxes are disabled (2026-09-11)
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m25s
ci / java (push) Successful in 3m34s
ci / portable (push) Successful in 11m28s
Claude-Session: https://claude.ai/code/session_01PpXxRCFznFqE65ay6wAJwe
2026-09-11 09:05:35 -04:00
Joseph Doherty 4f931e4b7e chore(deps): bump ZB.MOM.WW.Auth 0.2.1->0.3.0
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m20s
ci / java (push) Successful in 2m20s
ci / portable (push) Successful in 10m43s
Bumps ZB.MOM.WW.Auth.Abstractions, .ApiKeys, .AspNetCore, and .Ldap in
ZB.MOM.WW.MxGateway.Server.csproj and ZB.MOM.WW.MxGateway.IntegrationTests.csproj.
0.3.0 is additive (new IApiKeyStoreMigrator, SqliteAuthStoreMigrator implements it,
old ctors retained) — no source changes needed. Restore, Release build (0 warnings/
0 errors), and dotnet test on the NonWindows solution are unchanged versus the
pre-bump baseline: 1198 passed, 0 failed, 19 skipped (live-only tests, opt-in via
env vars).

Claude-Session: https://claude.ai/code/session_01Wzg6NUCWHipSexLs2cgRFv
2026-09-03 10:57:10 -04:00
Joseph Doherty d16ae4e267 docs(claude): note the umbrella index's short-row convention for cross-repo propagation
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m17s
ci / java (push) Successful in 2m34s
ci / portable (push) Successful in 9m14s
Claude-Session: https://claude.ai/code/session_01Wzg6NUCWHipSexLs2cgRFv
2026-09-03 10:47:48 -04:00
Joseph Doherty 74ba889dea docs: record cycle-3 client release
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m21s
ci / java (push) Successful in 2m29s
ci / portable (push) Successful in 9m46s
2026-08-25 12:47:54 -04:00
125 changed files with 833 additions and 491 deletions
+12 -1
View File
@@ -49,7 +49,18 @@ mxaccessgw changes here — remote/push status, the `.proto` contracts (`mxacces
`mxaccess_worker.proto`, `galaxy_repository.proto`), the two-process architecture, shared-lib `mxaccess_worker.proto`, `galaxy_repository.proto`), the two-process architecture, shared-lib
consumption, or per-project commands — update the **MxAccessGateway entry in consumption, or per-project commands — update the **MxAccessGateway entry in
[`../scadaproj/CLAUDE.md`](../scadaproj/CLAUDE.md)** in the same change so the umbrella index [`../scadaproj/CLAUDE.md`](../scadaproj/CLAUDE.md)** in the same change so the umbrella index
never drifts from this repo. (Mirrors the same rule in the peer repos.) never drifts from this repo. (Mirrors the same rule in the peer repos.) **Keep the index row to
a few lines** — since 2026-09-03 the umbrella keeps only short summary rows and parks long
per-project narrative in `../scadaproj/.claude/skills/sister-project-history/<project>.md`
(create `mxaccessgateway.md` there if this gateway ever needs one) and shared-library history
in `../scadaproj/<lib>/CLAUDE.md`.
> ⛔ **Wonder box remote access (2026-09-11): no `psbridge`, no `winrm`.** Both skills are retired/disabled —
> the WinRM remote-exec channel set off the security software on the wonder boxes (vd03/vq03/vq04). Do not
> run remote commands (service control, publish/swap, PowerShell) on those boxes and do not stand up a
> replacement channel until the user clears one. Still allowed: UNC/SMB file copies and logging directly
> into the SQL Servers. Deploy steps that need a command on the box are blocked; see the umbrella
> [`../scadaproj/CLAUDE.md`](../scadaproj/CLAUDE.md) for the current hold.
## Build, Test, Run ## Build, Test, Run
@@ -112,7 +112,7 @@ The continuation runs serially in the isolated `.worktrees/c3-p2-followups` work
| K — client alarm parity | CLI-47/48/49/50 | `gpt-5.6-terra` high | Done | All five client suites green | | K — client alarm parity | CLI-47/48/49/50 | `gpt-5.6-terra` high | Done | All five client suites green |
| L — live watch-list/proto wave | watch-list refresh follow-up + WRK-32 proto comment | `gpt-5.6-sol` high | Done | Codegen clean; portable alarm/contract tests green; windev Worker.Tests green | | L — live watch-list/proto wave | watch-list refresh follow-up + WRK-32 proto comment | `gpt-5.6-sol` high | Done | Codegen clean; portable alarm/contract tests green; windev Worker.Tests green |
| M — Windows gateway CI | TST-35 | `gpt-5.6-terra` medium | Done | windev `live` mode proves Worker, gateway, full-slnx, and live smoke tiers | | M — Windows gateway CI | TST-35 | `gpt-5.6-terra` medium | Done | windev `live` mode proves Worker, gateway, full-slnx, and live smoke tiers |
| N — integration and release | publish five clients + Contracts | `gpt-5.6-sol` high | In progress | Full local/windev gates; merged `main`; registry coordinates and Go tag verified | | N — integration and release | publish five clients + Contracts | `gpt-5.6-sol` high | Done | Full local/windev gates; merged `main`; registry coordinates and Go tag verified |
### Continuation tasklist ### Continuation tasklist
@@ -220,12 +220,12 @@ in 53 s; and the live-MXAccess smoke passed **8/8** in 1m46s. TST-35 commit:
- [x] Run `git diff --check`, codegen, client-version, README, NonWindows build, and full gateway suite gates. - [x] Run `git diff --check`, codegen, client-version, README, NonWindows build, and full gateway suite gates.
- [x] Run all five client suites on the final candidate. - [x] Run all five client suites on the final candidate.
- [ ] Run final windev `live` gate at the exact candidate SHA. - [x] Run final windev `live` gate at the exact candidate SHA.
- [ ] Merge/push the verified continuation to `main`; wait for green CI. - [x] Merge/push the verified continuation to `main`; wait for green CI.
- [ ] Re-query all package coordinates and Go tag immediately before publish; abort on collision. - [x] Re-query all package coordinates and Go tag immediately before publish; abort on collision.
- [ ] Publish .NET Client + Contracts 0.2.1, Python 0.2.1, Rust 0.2.1, Java 0.2.2. - [x] Publish .NET Client + Contracts 0.2.1, Python 0.2.1, Rust 0.2.1, Java 0.2.2.
- [ ] Create/push `clients/go/v0.2.1` at the exact release commit. - [x] Create/push `clients/go/v0.2.1` at the exact release commit.
- [ ] Verify every registry coordinate and Go module/tag, update `docs/ClientPackaging.md`, and record immutable release evidence here. - [x] Verify every registry coordinate and Go module/tag, update `docs/ClientPackaging.md`, and record immutable release evidence here.
Batch-N portable verification (2026-08-25): `git diff --check`, codegen freshness, Batch-N portable verification (2026-08-25): `git diff --check`, codegen freshness,
client-version freshness, and the generated README check all passed without changing client-version freshness, and the generated README check all passed without changing
@@ -236,3 +236,24 @@ green (respectively **134 passed / 1 live skip**, all Go packages built/tested w
clean `gofmt`, **169 passed / 1 opt-in TLS skip** from a clean virtual environment, clean `gofmt`, **169 passed / 1 opt-in TLS skip** from a clean virtual environment,
**108 passed** plus clean `cargo fmt`/Clippy, and **132 passed / 0 failed** with the **108 passed** plus clean `cargo fmt`/Clippy, and **132 passed / 0 failed** with the
generated Java tree unchanged). generated Java tree unchanged).
Batch-N integration/release verification (2026-08-25): `main` was integrated and
released at exact commit `5776021970cbbe59813ba57579abe00d9fc61a46`; Gitea CI
run **783** concluded success. Windev `live` mode at that SHA built the x86/net48
Worker with **0 warnings / 0 errors**, passed Worker.Tests **529 passed / 11
opt-in skips / 0 failed**, built the full Windows solution with **0 warnings / 0
errors**, passed the Windows gateway suite **1193/1193**, and passed the live
MXAccess smoke **8/8**. One preceding full-suite attempt hit a transient fake-worker
disconnect in the reconnect/replay test; the test then passed **50/50** isolated
macOS runs and **20/20** isolated windev runs before the clean full-gate pass.
Immediately before publication, all five package coordinates returned HTTP 404
and `clients/go/v0.2.1` was absent locally/remotely. The published coordinates are
NuGet `ZB.MOM.WW.MxGateway.Client` **0.2.1** and
`ZB.MOM.WW.MxGateway.Contracts` **0.2.1**, PyPI
`zb-mom-ww-mxaccess-gateway-client` **0.2.1**, Cargo
`zb-mom-ww-mxgateway-client` **0.2.1**, and Maven
`com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client` **0.2.2**. Each exact Gitea
package API coordinate returned HTTP 200 after publication. Annotated Go tag
`clients/go/v0.2.1` resolves locally, remotely, and through `go list -m` to
`5776021970cbbe59813ba57579abe00d9fc61a46`.
@@ -347,13 +347,13 @@ public static class MxGatewayClientCli
/// <summary> /// <summary>
/// Canonical CLI credential environment variable, shared by every official /// Canonical CLI credential environment variable, shared by every official
/// client CLI (CLI-45) so one exported variable drives the same operator /// client CLI so one exported variable drives the same operator
/// workflow in all five languages. /// workflow in all five languages.
/// </summary> /// </summary>
private const string DefaultVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_PASSWORD"; private const string DefaultVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_PASSWORD";
/// <summary> /// <summary>
/// Pre-CLI-45 environment variable, still honoured as a deprecated fallback /// Legacy environment variable, still honoured as a deprecated fallback
/// for one release so existing scripts keep working. /// for one release so existing scripts keep working.
/// </summary> /// </summary>
private const string LegacyVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_USER_PASSWORD"; private const string LegacyVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_USER_PASSWORD";
@@ -379,14 +379,14 @@ public static class MxGatewayClientCli
} }
/// <summary> /// <summary>
/// Resolves the effective MXAccess verify-user credential in the CLI-45 /// Resolves the effective MXAccess verify-user credential in this
/// order: <c>--password</c>, the deprecated <c>--verify-user-password</c> /// order: <c>--password</c>, the deprecated <c>--verify-user-password</c>
/// alias, the environment variable named by <c>--password-env</c> (default /// alias, the environment variable named by <c>--password-env</c> (default
/// <c>MXGATEWAY_VERIFY_PASSWORD</c>), then the deprecated /// <c>MXGATEWAY_VERIFY_PASSWORD</c>), then the deprecated
/// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c>. An empty value from any source is /// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c>. An empty value from any source is
/// treated as absent. The credential is never echoed; this resolver exists so /// treated as absent. The credential is never echoed; this resolver exists so
/// the error-redaction catch block can strip it from any surfaced error /// the error-redaction catch block can strip it from any surfaced error,
/// (CLI-04), mirroring <see cref="TryResolveApiKey" />. /// mirroring <see cref="TryResolveApiKey" />.
/// </summary> /// </summary>
private static string? TryResolveVerifyUserPassword(CliArguments arguments) private static string? TryResolveVerifyUserPassword(CliArguments arguments)
{ {
@@ -416,7 +416,7 @@ public static class MxGatewayClientCli
/// Resolves the verify-user credential for <c>authenticate-user</c>, throwing /// Resolves the verify-user credential for <c>authenticate-user</c>, throwing
/// a redaction-safe error when no source yields a non-empty value. Failing /// a redaction-safe error when no source yields a non-empty value. Failing
/// fast keeps a misconfigured environment from becoming a real MXAccess /// fast keeps a misconfigured environment from becoming a real MXAccess
/// authentication attempt with an empty credential (CLI-45); the thrown /// authentication attempt with an empty credential; the thrown
/// message names only the option/env var, never the value. /// message names only the option/env var, never the value.
/// </summary> /// </summary>
private static string ResolveVerifyUserPassword(CliArguments arguments) private static string ResolveVerifyUserPassword(CliArguments arguments)
@@ -758,9 +758,9 @@ public static class MxGatewayClientCli
{ {
// The credential is resolved from --password or its env var (default // The credential is resolved from --password or its env var (default
// MXGATEWAY_VERIFY_PASSWORD) and is never echoed; a missing or empty value // MXGATEWAY_VERIFY_PASSWORD) and is never echoed; a missing or empty value
// fails fast before the invoke rather than reaching the wire (CLI-45). // fails fast before the invoke rather than reaching the wire.
// On any surfaced error the RunCoreAsync catch block routes // On any surfaced error the RunCoreAsync catch block routes
// it through MxGatewayCliSecretRedactor so it cannot reach stderr (CLI-04). // it through MxGatewayCliSecretRedactor so it cannot reach stderr.
return InvokeAndWriteAsync( return InvokeAndWriteAsync(
arguments, arguments,
client, client,
@@ -1425,7 +1425,7 @@ public static class MxGatewayClientCli
else if (gatewayEvent.ReplayGap is { } replayGap) else if (gatewayEvent.ReplayGap is { } replayGap)
{ {
// Render the ReplayGap sentinel as the typed cross-CLI row instead of the raw // Render the ReplayGap sentinel as the typed cross-CLI row instead of the raw
// sentinel MxEvent (NEXT-02, mirroring the Go/Python/Rust CLIs). // sentinel MxEvent, mirroring the Go/Python/Rust CLIs.
output.WriteLine(FormatReplayGapRow(replayGap)); output.WriteLine(FormatReplayGapRow(replayGap));
} }
else else
@@ -1849,7 +1849,7 @@ public static class MxGatewayClientCli
} }
/// <summary> /// <summary>
/// Formats the typed ReplayGap row shared by the CLIs (NEXT-02). Hand-built so the /// Formats the typed ReplayGap row shared by the CLIs. Hand-built so the
/// cursors are JSON numbers like the Go/Python/Rust rows, not the protobuf JSON /// cursors are JSON numbers like the Go/Python/Rust rows, not the protobuf JSON
/// formatter's quoted uint64 strings. /// formatter's quoted uint64 strings.
/// </summary> /// </summary>
@@ -134,6 +134,7 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that write-secured builds a WriteSecured command with the value and user ids.</summary> /// <summary>Verifies that write-secured builds a WriteSecured command with the value and user ids.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_WriteSecured_BuildsWriteSecuredCommand() public async Task RunAsync_WriteSecured_BuildsWriteSecuredCommand()
{ {
@@ -178,6 +179,7 @@ public sealed class MxGatewayClientCliTests
/// Verifies that authenticate-user builds an AuthenticateUser command sourcing the /// Verifies that authenticate-user builds an AuthenticateUser command sourcing the
/// credential from the flag, and that the credential never appears in stdout/stderr. /// credential from the flag, and that the credential never appears in stdout/stderr.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_AuthenticateUser_BuildsCommandAndDoesNotEchoCredential() public async Task RunAsync_AuthenticateUser_BuildsCommandAndDoesNotEchoCredential()
{ {
@@ -218,9 +220,10 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary> /// <summary>
/// CLI-04: a surfaced error for authenticate-user must have the credential /// Verifies that a surfaced error for authenticate-user must have the credential
/// redacted (never echoed to stderr), mirroring the API-key redaction seam. /// redacted (never echoed to stderr), mirroring the API-key redaction seam.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_AuthenticateUser_ErrorOutput_RedactsCredential() public async Task RunAsync_AuthenticateUser_ErrorOutput_RedactsCredential()
{ {
@@ -248,7 +251,7 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary> /// <summary>
/// CLI-45: <c>--password</c> is the primary credential flag, matching the other /// <c>--password</c> is the primary credential flag, matching the other
/// four CLIs. The credential reaches the wire but never stdout/stderr. /// four CLIs. The credential reaches the wire but never stdout/stderr.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
@@ -290,10 +293,12 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary> /// <summary>
/// CLI-45: the credential is read from the environment variable named by /// the credential is read from the environment variable named by
/// <c>--password-env</c>, whose default is the canonical /// <c>--password-env</c>, whose default is the canonical
/// <c>MXGATEWAY_VERIFY_PASSWORD</c> shared by all five CLIs. /// <c>MXGATEWAY_VERIFY_PASSWORD</c> shared by all five CLIs.
/// </summary> /// </summary>
/// <param name="passwordEnvArgument">The value passed to <c>--password-env</c>, or null to use the default.</param>
/// <param name="environmentName">The environment variable that holds the credential.</param>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
[Theory] [Theory]
[InlineData(null, "MXGATEWAY_VERIFY_PASSWORD")] [InlineData(null, "MXGATEWAY_VERIFY_PASSWORD")]
@@ -340,7 +345,7 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary> /// <summary>
/// CLI-45: the pre-rename names stay usable for one release — the deprecated /// the pre-rename names stay usable for one release — the deprecated
/// <c>--verify-user-password</c> flag and the deprecated /// <c>--verify-user-password</c> flag and the deprecated
/// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c> environment variable both still resolve. /// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c> environment variable both still resolve.
/// </summary> /// </summary>
@@ -413,7 +418,7 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary> /// <summary>
/// CLI-45: a missing or empty credential fails fast before the invoke — the CLI /// a missing or empty credential fails fast before the invoke — the CLI
/// never sends a fabricated empty password to the wire. The error names the flag /// never sends a fabricated empty password to the wire. The error names the flag
/// and the environment variable, never a value. /// and the environment variable, never a value.
/// </summary> /// </summary>
@@ -463,6 +468,9 @@ public sealed class MxGatewayClientCliTests
private readonly string _name; private readonly string _name;
private readonly string? _original; private readonly string? _original;
/// <summary>Sets the environment variable and remembers its original value.</summary>
/// <param name="name">The environment variable name.</param>
/// <param name="value">The temporary value, or null to clear it.</param>
public EnvironmentVariableScope(string name, string? value) public EnvironmentVariableScope(string name, string? value)
{ {
_name = name; _name = name;
@@ -470,6 +478,7 @@ public sealed class MxGatewayClientCliTests
Environment.SetEnvironmentVariable(name, value); Environment.SetEnvironmentVariable(name, value);
} }
/// <summary>Restores the original environment variable value.</summary>
public void Dispose() public void Dispose()
{ {
Environment.SetEnvironmentVariable(_name, _original); Environment.SetEnvironmentVariable(_name, _original);
@@ -588,7 +597,7 @@ public sealed class MxGatewayClientCliTests
/// <summary> /// <summary>
/// Verifies stream-events renders the ReplayGap sentinel as the typed cross-CLI row — /// Verifies stream-events renders the ReplayGap sentinel as the typed cross-CLI row —
/// numeric cursors under a replayGap key — instead of the raw sentinel MxEvent (NEXT-02). /// numeric cursors under a replayGap key — instead of the raw sentinel MxEvent.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
@@ -228,6 +228,7 @@ public sealed class MxGatewayClientSessionTests
/// <see cref="MxEventStreamItem"/> with the gap populated, while normal /// <see cref="MxEventStreamItem"/> with the gap populated, while normal
/// events pass through unchanged with IsReplayGap false. /// events pass through unchanged with IsReplayGap false.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task StreamEventItemsAsync_SurfacesReplayGapSentinel() public async Task StreamEventItemsAsync_SurfacesReplayGapSentinel()
{ {
@@ -430,6 +431,7 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that unregister builds an unregister command with the server handle.</summary> /// <summary>Verifies that unregister builds an unregister command with the server handle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task UnregisterAsync_BuildsUnregisterCommand() public async Task UnregisterAsync_BuildsUnregisterCommand()
{ {
@@ -451,6 +453,7 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that advise-supervisory builds the supervisory advise command.</summary> /// <summary>Verifies that advise-supervisory builds the supervisory advise command.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AdviseSupervisoryAsync_BuildsAdviseSupervisoryCommand() public async Task AdviseSupervisoryAsync_BuildsAdviseSupervisoryCommand()
{ {
@@ -473,6 +476,7 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that add-buffered-item returns the item handle from the typed reply.</summary> /// <summary>Verifies that add-buffered-item returns the item handle from the typed reply.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AddBufferedItemAsync_BuildsCommandAndReturnsItemHandle() public async Task AddBufferedItemAsync_BuildsCommandAndReturnsItemHandle()
{ {
@@ -498,6 +502,7 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that set-buffered-update-interval builds the command with the interval.</summary> /// <summary>Verifies that set-buffered-update-interval builds the command with the interval.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task SetBufferedUpdateIntervalAsync_BuildsCommandWithInterval() public async Task SetBufferedUpdateIntervalAsync_BuildsCommandWithInterval()
{ {
@@ -520,6 +525,7 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that suspend builds the command and returns the reply status.</summary> /// <summary>Verifies that suspend builds the command and returns the reply status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task SuspendAsync_BuildsCommandAndReturnsStatus() public async Task SuspendAsync_BuildsCommandAndReturnsStatus()
{ {
@@ -545,6 +551,7 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that activate builds the command and returns the reply status.</summary> /// <summary>Verifies that activate builds the command and returns the reply status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ActivateAsync_BuildsCommandAndReturnsStatus() public async Task ActivateAsync_BuildsCommandAndReturnsStatus()
{ {
@@ -568,6 +575,7 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that write-secured builds a WriteSecured command with value and user ids.</summary> /// <summary>Verifies that write-secured builds a WriteSecured command with value and user ids.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteSecuredAsync_BuildsWriteSecuredCommand() public async Task WriteSecuredAsync_BuildsWriteSecuredCommand()
{ {
@@ -598,6 +606,7 @@ public sealed class MxGatewayClientSessionTests
/// natively. The client must surface that scripted failure (as an /// natively. The client must surface that scripted failure (as an
/// <see cref="MxAccessException"/>) rather than pre-validating it away. /// <see cref="MxAccessException"/>) rather than pre-validating it away.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteSecuredAsync_SurfacesNativeFailureWhenNotAuthenticated() public async Task WriteSecuredAsync_SurfacesNativeFailureWhenNotAuthenticated()
{ {
@@ -621,6 +630,7 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that write-secured2 builds a WriteSecured2 command with value and timestamp.</summary> /// <summary>Verifies that write-secured2 builds a WriteSecured2 command with value and timestamp.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteSecured2Async_BuildsWriteSecured2Command() public async Task WriteSecured2Async_BuildsWriteSecured2Command()
{ {
@@ -647,6 +657,7 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that authenticate-user builds the command and returns the resolved user id.</summary> /// <summary>Verifies that authenticate-user builds the command and returns the resolved user id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AuthenticateUserAsync_BuildsCommandAndReturnsUserId() public async Task AuthenticateUserAsync_BuildsCommandAndReturnsUserId()
{ {
@@ -675,6 +686,7 @@ public sealed class MxGatewayClientSessionTests
/// must never contain the credential — the error path is built only from /// must never contain the credential — the error path is built only from
/// reply-derived diagnostics, not the request payload. /// reply-derived diagnostics, not the request payload.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AuthenticateUserAsync_FailureDoesNotLeakCredentialInErrorMessage() public async Task AuthenticateUserAsync_FailureDoesNotLeakCredentialInErrorMessage()
{ {
@@ -698,6 +710,7 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that archestra-user-to-id builds the command and returns the resolved user id.</summary> /// <summary>Verifies that archestra-user-to-id builds the command and returns the resolved user id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ArchestraUserToIdAsync_BuildsCommandAndReturnsUserId() public async Task ArchestraUserToIdAsync_BuildsCommandAndReturnsUserId()
{ {
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// </summary> /// </summary>
public sealed class MxGatewaySecretRedactionTests public sealed class MxGatewaySecretRedactionTests
{ {
/// <summary>Test: Redact replaces every occurrence of secret.</summary>
[Fact] [Fact]
public void Redact_ReplacesEveryOccurrenceOfSecret() public void Redact_ReplacesEveryOccurrenceOfSecret()
{ {
@@ -19,6 +20,7 @@ public sealed class MxGatewaySecretRedactionTests
Assert.Equal("pw=<redacted> retry pw=<redacted> again <redacted>", result); Assert.Equal("pw=<redacted> retry pw=<redacted> again <redacted>", result);
} }
/// <summary>Test: Redact scrubs both secrets when one is substring of the other.</summary>
[Fact] [Fact]
public void Redact_ScrubsBothSecretsWhenOneIsSubstringOfTheOther() public void Redact_ScrubsBothSecretsWhenOneIsSubstringOfTheOther()
{ {
@@ -33,6 +35,7 @@ public sealed class MxGatewaySecretRedactionTests
Assert.DoesNotContain("secret", result, StringComparison.Ordinal); Assert.DoesNotContain("secret", result, StringComparison.Ordinal);
} }
/// <summary>Test: Redact with null secrets array returns message unchanged.</summary>
[Fact] [Fact]
public void Redact_WithNullSecretsArray_ReturnsMessageUnchanged() public void Redact_WithNullSecretsArray_ReturnsMessageUnchanged()
{ {
@@ -43,6 +46,7 @@ public sealed class MxGatewaySecretRedactionTests
Assert.Equal(message, result); Assert.Equal(message, result);
} }
/// <summary>Test: Redact with empty secrets array returns message unchanged.</summary>
[Fact] [Fact]
public void Redact_WithEmptySecretsArray_ReturnsMessageUnchanged() public void Redact_WithEmptySecretsArray_ReturnsMessageUnchanged()
{ {
@@ -53,6 +57,7 @@ public sealed class MxGatewaySecretRedactionTests
Assert.Equal(message, result); Assert.Equal(message, result);
} }
/// <summary>Test: Redact ignores whitespace only secret.</summary>
[Fact] [Fact]
public void Redact_IgnoresWhitespaceOnlySecret() public void Redact_IgnoresWhitespaceOnlySecret()
{ {
@@ -64,6 +69,7 @@ public sealed class MxGatewaySecretRedactionTests
Assert.Equal(message, result); Assert.Equal(message, result);
} }
/// <summary>Test: Redacted preserves concrete subtype and does not chain secret bearing original.</summary>
[Fact] [Fact]
public void Redacted_PreservesConcreteSubtypeAndDoesNotChainSecretBearingOriginal() public void Redacted_PreservesConcreteSubtypeAndDoesNotChainSecretBearingOriginal()
{ {
@@ -4,15 +4,16 @@ using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client.Tests; namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary> /// <summary>
/// Tests for the credential-scrub (CLI-40) and malformed-reply (CLI-41) contracts on the /// Tests for the credential-scrub and malformed-reply contracts on the
/// credential and id-returning session helpers, driven from shared behavior fixtures. /// credential and id-returning session helpers, driven from shared behavior fixtures.
/// </summary> /// </summary>
public sealed class MxGatewaySessionReplyContractTests public sealed class MxGatewaySessionReplyContractTests
{ {
/// <summary> /// <summary>
/// CLI-40: when MXAccess echoes the submitted credential back in its failure diagnostic, /// Verifies that when MXAccess echoes the submitted credential back in its failure diagnostic,
/// the surfaced exception message must scrub it to the library redaction marker. /// the surfaced exception message must scrub it to the library redaction marker.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AuthenticateUserAsync_RedactsEchoedCredentialInFailureMessage() public async Task AuthenticateUserAsync_RedactsEchoedCredentialInFailureMessage()
{ {
@@ -33,13 +34,14 @@ public sealed class MxGatewaySessionReplyContractTests
} }
/// <summary> /// <summary>
/// CLI-40: the redacted exception must not leak the echoed credential through any structured /// Verifies that the redacted exception must not leak the echoed credential through any structured
/// accessor either — <see cref="MxAccessException.Reply"/> (protocol message, diagnostic /// accessor either — <see cref="MxAccessException.Reply"/> (protocol message, diagnostic
/// message, and each MXSTATUS_PROXY diagnostic text) and <see cref="MxGatewayException.Statuses"/> /// message, and each MXSTATUS_PROXY diagnostic text) and <see cref="MxGatewayException.Statuses"/>
/// all carry the server-echoed credential verbatim before the fix. Both the OK+negative-HRESULT /// all carry the server-echoed credential verbatim before the fix. Both the OK+negative-HRESULT
/// and the MXACCESS_FAILURE reply route to <see cref="MxAccessException"/>, so both must scrub. /// and the MXACCESS_FAILURE reply route to <see cref="MxAccessException"/>, so both must scrub.
/// </summary> /// </summary>
/// <param name="fixture">The echoed-credential reply fixture to drive.</param> /// <param name="fixture">The echoed-credential reply fixture to drive.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory] [Theory]
[InlineData("authenticate-user.echoed-credential.reply.json")] [InlineData("authenticate-user.echoed-credential.reply.json")]
[InlineData("authenticate-user.echoed-credential-mxaccess-failure.reply.json")] [InlineData("authenticate-user.echoed-credential-mxaccess-failure.reply.json")]
@@ -71,9 +73,10 @@ public sealed class MxGatewaySessionReplyContractTests
} }
/// <summary> /// <summary>
/// CLI-41: an OK reply that carries neither the typed AuthenticateUser payload nor an /// Verifies that an OK reply that carries neither the typed AuthenticateUser payload nor an
/// int32 return_value is a malformed reply, surfaced as a typed exception rather than an NRE. /// int32 return_value is a malformed reply, surfaced as a typed exception rather than an NRE.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AuthenticateUserAsync_MissingPayloadAndReturnValue_ThrowsMalformedReply() public async Task AuthenticateUserAsync_MissingPayloadAndReturnValue_ThrowsMalformedReply()
{ {
@@ -87,9 +90,10 @@ public sealed class MxGatewaySessionReplyContractTests
} }
/// <summary> /// <summary>
/// CLI-41: an OK reply that omits the typed payload but carries an int32 return_value /// Verifies that an OK reply that omits the typed payload but carries an int32 return_value
/// resolves to that return value. /// resolves to that return value.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AuthenticateUserAsync_ReturnValueOnly_ResolvesReturnValue() public async Task AuthenticateUserAsync_ReturnValueOnly_ResolvesReturnValue()
{ {
@@ -104,9 +108,10 @@ public sealed class MxGatewaySessionReplyContractTests
} }
/// <summary> /// <summary>
/// CLI-41: the AddBufferedItem fallback shares the malformed-reply contract — an OK reply /// Verifies that the AddBufferedItem fallback shares the malformed-reply contract — an OK reply
/// with neither a typed item handle nor an int32 return_value throws the typed exception. /// with neither a typed item handle nor an int32 return_value throws the typed exception.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AddBufferedItemAsync_MissingPayloadAndReturnValue_ThrowsMalformedReply() public async Task AddBufferedItemAsync_MissingPayloadAndReturnValue_ThrowsMalformedReply()
{ {
@@ -32,6 +32,9 @@ public sealed class MxStatusProxyExtensionsTests
} }
/// <summary>Verifies that the raw success member never overrides the authoritative category.</summary> /// <summary>Verifies that the raw success member never overrides the authoritative category.</summary>
/// <param name="category">The status category under test.</param>
/// <param name="success">The raw success member, which must not influence the result.</param>
/// <param name="expected">The expected <c>IsSuccess</c> result.</param>
[Theory] [Theory]
[InlineData(MxStatusCategory.Ok, 0, true)] [InlineData(MxStatusCategory.Ok, 0, true)]
[InlineData(MxStatusCategory.Ok, 1, true)] [InlineData(MxStatusCategory.Ok, 1, true)]
@@ -96,7 +96,8 @@ internal sealed class WireFakeGatewayServer : IAsyncDisposable
DefaultCallTimeout = TimeSpan.FromSeconds(30), DefaultCallTimeout = TimeSpan.FromSeconds(30),
}); });
/// <inheritdoc /> /// <summary>Stops and disposes the underlying web application.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
await _app.StopAsync().ConfigureAwait(false); await _app.StopAsync().ConfigureAwait(false);
@@ -854,6 +854,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// </summary> /// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param> /// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task UnregisterAsync( public async Task UnregisterAsync(
int serverHandle, int serverHandle,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -888,6 +889,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <param name="serverHandle">The ServerHandle from register.</param> /// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param> /// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task AdviseSupervisoryAsync( public async Task AdviseSupervisoryAsync(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -985,6 +987,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <param name="serverHandle">The ServerHandle from register.</param> /// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="updateIntervalMilliseconds">The buffered update interval, in milliseconds.</param> /// <param name="updateIntervalMilliseconds">The buffered update interval, in milliseconds.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task SetBufferedUpdateIntervalAsync( public async Task SetBufferedUpdateIntervalAsync(
int serverHandle, int serverHandle,
int updateIntervalMilliseconds, int updateIntervalMilliseconds,
@@ -1125,6 +1128,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <param name="currentUserId">The current operator user id.</param> /// <param name="currentUserId">The current operator user id.</param>
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param> /// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task WriteSecuredAsync( public async Task WriteSecuredAsync(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -1204,6 +1208,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <param name="currentUserId">The current operator user id.</param> /// <param name="currentUserId">The current operator user id.</param>
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param> /// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task WriteSecured2Async( public async Task WriteSecured2Async(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
+6
View File
@@ -14,6 +14,9 @@ pub mod mxaccess_gateway {
/// gateway to language clients. /// gateway to language clients.
pub mod v1 { pub mod v1 {
#![allow(clippy::large_enum_variant, clippy::result_large_err)] #![allow(clippy::large_enum_variant, clippy::result_large_err)]
// tonic's generated server traits use async_trait, whose expansion adds
// redundant must_use attributes to Future returns under Rust 1.99.
#![allow(clippy::double_must_use)]
tonic::include_proto!("mxaccess_gateway.v1"); tonic::include_proto!("mxaccess_gateway.v1");
} }
@@ -36,6 +39,9 @@ pub mod galaxy_repository {
/// discovery and deploy-event watch RPCs. /// discovery and deploy-event watch RPCs.
pub mod v1 { pub mod v1 {
#![allow(clippy::large_enum_variant, clippy::result_large_err)] #![allow(clippy::large_enum_variant, clippy::result_large_err)]
// tonic's generated server traits use async_trait, whose expansion adds
// redundant must_use attributes to Future returns under Rust 1.99.
#![allow(clippy::double_must_use)]
tonic::include_proto!("galaxy_repository.v1"); tonic::include_proto!("galaxy_repository.v1");
} }
+10 -7
View File
@@ -140,13 +140,16 @@ publish while the version constants still read the already-published
0.2.0/0.2.1 numbers above — see `clients/python/README.md` for the 0.2.0/0.2.1 numbers above — see `clients/python/README.md` for the
user-facing warning. **0.2.0 is superseded by 0.2.1; do not install it.** user-facing warning. **0.2.0 is superseded by 0.2.1; do not install it.**
As of this remediation (CLI-46), the in-source version constants for the On 2026-08-25 the superseding release shipped from commit
.NET, Go, Python, and Rust clients (plus Contracts) read **0.2.1**, and the `5776021970cbbe59813ba57579abe00d9fc61a46`. Published coordinates on
Java client reads **0.2.2**. These are not yet published — the numbers `gitea.dohertylan.com` are `nuget` `ZB.MOM.WW.MxGateway.Client` **0.2.1** and
above (0.2.0 four clients + Contracts, 0.2.1 Java) remain the live registry `ZB.MOM.WW.MxGateway.Contracts` **0.2.1**, `pypi`
state until the next `pack-clients.ps1` / `tag-go-module.ps1` run. Once that `zb-mom-ww-mxaccess-gateway-client` **0.2.1**, `cargo`
publish lands, update this section with the new coordinates and commit(s), `zb-mom-ww-mxgateway-client` **0.2.1**, and `maven`
the same way the 2026-08-07 record above was written after the fact. `com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client` **0.2.2**. The Go module
tag `clients/go/v0.2.1` points to that same commit. Every exact package
coordinate returned HTTP 200 through the Gitea package API after publication,
and `go list -m` resolved `v0.2.1` to the tagged commit.
## .NET ## .NET
@@ -394,12 +394,12 @@ Query every exact registry coordinate for HTTP 200 and resolve the Go module/tag
## Final Tasklist Exit Gate ## Final Tasklist Exit Gate
- [ ] All 16 open P2 findings are Done with commit/test evidence. - [x] All 16 open P2 findings are Done with commit/test evidence.
- [ ] WRK-34 is corrected to Done (`3679e59`). - [x] WRK-34 is corrected to Done (`3679e59`).
- [ ] Live watch-list refresh passes portable and windev tests. - [x] Live watch-list refresh passes portable and windev tests.
- [ ] WRK-32 public proto comment is regenerated through every client surface. - [x] WRK-32 public proto comment is regenerated through every client surface.
- [ ] TST-35 nightly Windows gateway suite is proven on windev. - [x] TST-35 nightly Windows gateway suite is proven on windev.
- [ ] Full NonWindows gateway suite and all five client suites pass. - [x] Full NonWindows gateway suite and all five client suites pass.
- [ ] Worker.Tests and Windows gateway suite pass at the integrated release SHA. - [x] Worker.Tests and Windows gateway suite pass at the integrated release SHA.
- [ ] Five client releases plus Contracts are confirmed in registries; Go tag resolves. - [x] Five client releases plus Contracts are confirmed in registries; Go tag resolves.
- [ ] `archreview/2026-08-19/remediation/00-tracking.md` contains no stale Deferred row for completed work. - [x] `archreview/2026-08-19/remediation/00-tracking.md` contains no stale Deferred row for completed work.
@@ -25,7 +25,7 @@ public static class GatewayContractInfo
/// (<c>WorkerPipeSession.CreateDrainEventsReply</c>, the backstop that also interprets /// (<c>WorkerPipeSession.CreateDrainEventsReply</c>, the backstop that also interprets
/// <c>max_events = 0</c>) cannot drift apart. A count cap alone is necessary but not /// <c>max_events = 0</c>) cannot drift apart. A count cap alone is necessary but not
/// sufficient: the worker additionally caps the reply by serialized bytes against the /// sufficient: the worker additionally caps the reply by serialized bytes against the
/// negotiated frame maximum (WRK-21), so a reply may carry fewer events than this ceiling /// negotiated frame maximum, so a reply may carry fewer events than this ceiling
/// and fewer than are queued. Callers drain iteratively until an empty reply. /// and fewer than are queued. Callers drain iteratively until an empty reply.
/// This is a documented behavioral bound, not wire schema — it is deliberately a C# /// This is a documented behavioral bound, not wire schema — it is deliberately a C#
/// constant and not a <c>.proto</c> field. /// constant and not a <c>.proto</c> field.
@@ -27,7 +27,7 @@ public sealed class DashboardLdapLiveTests
private const string SharedDirectoryPassword = "password"; private const string SharedDirectoryPassword = "password";
/// <summary> /// <summary>
/// Dashboard visibility tags (SEC-25) used by the ACL scenarios below. They are operator-chosen /// Dashboard visibility tags used by the ACL scenarios below. They are operator-chosen
/// labels that exist only in this fixture's configuration and in the fake sessions' owner-tag /// labels that exist only in this fixture's configuration and in the fake sessions' owner-tag
/// list — nothing in the shared directory carries them. /// list — nothing in the shared directory carries them.
/// </summary> /// </summary>
@@ -168,7 +168,7 @@ public sealed class DashboardLdapLiveTests
} }
/// <summary> /// <summary>
/// Verifies the SEC-25 tag grant end-to-end from a real LDAP bind: <c>gw-viewer</c>'s only /// Verifies the tag grant end-to-end from a real LDAP bind: <c>gw-viewer</c>'s only
/// group (GwReader) is mapped to the <c>team-a</c> visibility tag by <c>Dashboard:GroupToTag</c>, /// group (GwReader) is mapped to the <c>team-a</c> visibility tag by <c>Dashboard:GroupToTag</c>,
/// and the principal that bind produces is admitted by <see cref="IDashboardSessionAcl"/> for a /// and the principal that bind produces is admitted by <see cref="IDashboardSessionAcl"/> for a
/// <c>team-a</c>-tagged session but refused for a <c>team-b</c>-tagged one. /// <c>team-a</c>-tagged session but refused for a <c>team-b</c>-tagged one.
@@ -275,7 +275,7 @@ public sealed class DashboardLdapLiveTests
}; };
/// <summary> /// <summary>
/// The SEC-25 fixture map: GwReader is admitted as a Viewer and granted <c>team-a</c>. Both keys /// The tagged fixture map: GwReader is admitted as a Viewer and granted <c>team-a</c>. Both keys
/// name groups that already exist in the shared directory — the tag layer is config-only. /// name groups that already exist in the shared directory — the tag layer is config-only.
/// </summary> /// </summary>
private static DashboardOptions TaggedDashboardOptions() => new() private static DashboardOptions TaggedDashboardOptions() => new()
@@ -526,7 +526,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
// Assert. But a silent fall-through to "operator only" (currentUserId 0) // Assert. But a silent fall-through to "operator only" (currentUserId 0)
// quietly demotes the rest of this test from an authenticated round-trip // quietly demotes the rest of this test from an authenticated round-trip
// to a bare failure-reply-shape check, which a stale rig credential (e.g. // to a bare failure-reply-shape check, which a stale rig credential (e.g.
// TST-31) would otherwise hide indefinitely in nightly runs. Log the // one that has expired) would otherwise hide indefinitely in nightly runs. Log the
// outcome loudly — never the password (verifyUser only) — so that // outcome loudly — never the password (verifyUser only) — so that
// degradation is visible in the test output even though it never fails // degradation is visible in the test output even though it never fails
// the test by itself. // the test by itself.
@@ -22,8 +22,8 @@
(IntegrationTests-028). (IntegrationTests-028).
--> -->
<ItemGroup> <ItemGroup>
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.2.1" /> <PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.3.0" />
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.2.1" /> <PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.3.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.7" />
</ItemGroup> </ItemGroup>
@@ -19,7 +19,7 @@ public static class AlarmProviderReasons
/// <summary> /// <summary>
/// Reason prefix set when the effective mode can need the subtag watch-list /// Reason prefix set when the effective mode can need the subtag watch-list
/// (<c>Auto</c> or <c>ForceSubtag</c>) but the list resolved to zero targets, so the /// (<c>Auto</c> or <c>ForceSubtag</c>) but the list resolved to zero targets, so the
/// subtag provider covers no alarms (GWC-37). In <c>Auto</c> this is a warning about the /// subtag provider covers no alarms. In <c>Auto</c> this is a warning about the
/// standby, not about the currently-serving alarm manager, which is why the dashboard /// standby, not about the currently-serving alarm manager, which is why the dashboard
/// renders it against the <c>ALARMMGR</c> mode as a coverage badge rather than a failover. /// renders it against the <c>ALARMMGR</c> mode as a coverage badge rather than a failover.
/// </summary> /// </summary>
@@ -29,7 +29,7 @@ public static class AlarmProviderReasons
/// <summary> /// <summary>
/// Reason prefix set when Galaxy Repository alarm discovery was enabled but failed, so /// Reason prefix set when Galaxy Repository alarm discovery was enabled but failed, so
/// the watch-list carries only what configuration contributed and may be incomplete /// the watch-list carries only what configuration contributed and may be incomplete
/// (GWC-37). Distinct from <see cref="SubtagWatchListEmpty"/>: discovery failing with a /// . Distinct from <see cref="SubtagWatchListEmpty"/>: discovery failing with a
/// usable config-only list is a completeness warning, not a total loss of coverage. /// usable config-only list is a completeness warning, not a total loss of coverage.
/// </summary> /// </summary>
public const string SubtagDiscoveryFailed = public const string SubtagDiscoveryFailed =
@@ -37,7 +37,7 @@ public static class AlarmProviderReasons
/// <summary> /// <summary>
/// Reason prefix set when a re-resolution at the failover edge produced a different /// Reason prefix set when a re-resolution at the failover edge produced a different
/// watch-list than the one sent at <c>SubscribeAlarms</c> (GWC-37). The worker keeps the /// watch-list than the one sent at <c>SubscribeAlarms</c>. The worker keeps the
/// list it was given for the life of the subscription, so the difference is reported /// list it was given for the life of the subscription, so the difference is reported
/// rather than applied; a monitor restart re-resolves and re-sends. /// rather than applied; a monitor restart re-resolves and re-sends.
/// </summary> /// </summary>
@@ -5,8 +5,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Alarms;
/// <summary> /// <summary>
/// The outcome of one subtag watch-list resolution: the composed targets plus enough /// The outcome of one subtag watch-list resolution: the composed targets plus enough
/// provenance for the caller to tell an empty list that means "nothing was configured" /// provenance for the caller to tell an empty list that means "nothing was configured"
/// from one that means "discovery was configured and silently produced nothing" /// from one that means "discovery was configured and silently produced nothing".
/// (GWC-37). <see cref="IAlarmWatchListResolver.ResolveAsync"/> deliberately never throws /// <see cref="IAlarmWatchListResolver.ResolveAsync"/> deliberately never throws
/// on a discovery failure, so without this the two are indistinguishable at the call site /// on a discovery failure, so without this the two are indistinguishable at the call site
/// and a Galaxy Repository outage at startup can only be found in a log line. /// and a Galaxy Repository outage at startup can only be found in a log line.
/// </summary> /// </summary>
@@ -39,7 +39,7 @@ public sealed class AlarmWatchListResolver : IAlarmWatchListResolver
AlarmDiscoveryOptions discovery = options.Fallback.Discovery; AlarmDiscoveryOptions discovery = options.Fallback.Discovery;
// GWC-37: the caller cannot grade an empty watch-list without knowing whether discovery // The caller cannot grade an empty watch-list without knowing whether discovery
// was asked to contribute and whether it failed while doing so, because the failure is // was asked to contribute and whether it failed while doing so, because the failure is
// deliberately swallowed below. // deliberately swallowed below.
string? discoveryFailureMessage = null; string? discoveryFailureMessage = null;
@@ -41,7 +41,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
// every path that writes _alarms must null this under _sync, or readers keep a stale set. // every path that writes _alarms must null this under _sync, or readers keep a stale set.
private ActiveAlarmSnapshot[]? _currentAlarmsProjection; private ActiveAlarmSnapshot[]? _currentAlarmsProjection;
// NEXT-03 dedup tombstones, guarded by _sync: alarm instances whose Clear was synthesized by // Dedup tombstones, guarded by _sync: alarm instances whose Clear was synthesized by
// the most recent reconcile pass, keyed by reference with the instance's original raise // the most recent reconcile pass, keyed by reference with the instance's original raise
// timestamp as the identity marker. A buffered live Clear for the same instance is a duplicate // timestamp as the identity marker. A buffered live Clear for the same instance is a duplicate
// of the repair and is suppressed. One generation deep: each reconcile pass replaces the map, // of the repair and is suppressed. One generation deep: each reconcile pass replaces the map,
@@ -57,7 +57,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
private string _providerReason = string.Empty; private string _providerReason = string.Empty;
private DateTimeOffset _providerSince = DateTimeOffset.UtcNow; private DateTimeOffset _providerSince = DateTimeOffset.UtcNow;
// GWC-37 subtag-coverage verdict, guarded by _sync: whether the watch-list the worker is // Subtag-coverage verdict, guarded by _sync: whether the watch-list the worker is
// using can actually cover anything, and why not. Independent of _providerDegraded (which // using can actually cover anything, and why not. Independent of _providerDegraded (which
// means "the subtag provider is the one serving"): in Auto mode the alarm manager can be // means "the subtag provider is the one serving"): in Auto mode the alarm manager can be
// serving perfectly while the standby behind it covers zero alarms, and that is invisible // serving perfectly while the standby behind it covers zero alarms, and that is invisible
@@ -258,7 +258,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
try try
{ {
// Attach the internal distributor subscriber BEFORE subscribing (GWC-26). The pump // Attach the internal distributor subscriber BEFORE subscribing. The pump
// has been running since MarkReady started the dashboard mirror, and the distributor // has been running since MarkReady started the dashboard mirror, and the distributor
// only fans to subscribers registered at the time of the fan-out, so a subscriber // only fans to subscribers registered at the time of the fan-out, so a subscriber
// taken after SubscribeAlarms + the first reconcile would silently lose every // taken after SubscribeAlarms + the first reconcile would silently lose every
@@ -351,7 +351,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
AlarmProviderMode forcedMode = MapForcedMode(_options.Fallback.Mode); AlarmProviderMode forcedMode = MapForcedMode(_options.Fallback.Mode);
// GWC-37: grade the coverage before the command goes out, so a Galaxy Repository outage at // Grade the coverage before the command goes out, so a Galaxy Repository outage at
// startup is a standing status on the feed instead of one log line an operator reads weeks // startup is a standing status on the feed instead of one log line an operator reads weeks
// later — when the failover that needed the watch-list finds it empty. Broadcast because // later — when the failover that needed the watch-list finds it empty. Broadcast because
// feed subscribers outlive the monitor's worker session and may already be attached. // feed subscribers outlive the monitor's worker session and may already be attached.
@@ -507,7 +507,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
} }
} }
// NEXT-03: best-effort dedup of the reconcile/live race. A reconcile that already synthesized // Best-effort dedup of the reconcile/live race. A reconcile that already synthesized
// this transition as a feed repair left the cache carrying the worker's transition timestamp // this transition as a feed repair left the cache carrying the worker's transition timestamp
// and resulting state — both derived from the same worker-side value the live transition // and resulting state — both derived from the same worker-side value the live transition
// carries — so an exact (timestamp, state) match means this live transition's outcome has // carries — so an exact (timestamp, state) match means this live transition's outcome has
@@ -521,7 +521,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
&& existing.CurrentState == incoming.CurrentState; && existing.CurrentState == incoming.CurrentState;
} }
// NEXT-03, the Clear leg. A reconcile Clear repair removes the cache entry before the buffered // The Clear leg of the dedup. A reconcile Clear repair removes the cache entry before the buffered
// live Clear drains, so there is no cached state to compare against; the tombstone recorded by // live Clear drains, so there is no cached state to compare against; the tombstone recorded by
// ApplyReconcile identifies the cleared instance by its original raise timestamp instead. The // ApplyReconcile identifies the cleared instance by its original raise timestamp instead. The
// match consumes the tombstone, so a genuinely new raise/clear cycle (which carries a newer // match consumes the tombstone, so a genuinely new raise/clear cycle (which carries a newer
@@ -803,7 +803,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
// Caller holds _sync. Builds an AlarmProviderStatus snapshot of the current state. // Caller holds _sync. Builds an AlarmProviderStatus snapshot of the current state.
// //
// GWC-37: degraded is the OR of "the subtag provider is the one serving" and "the subtag // Degraded is the OR of "the subtag provider is the one serving" and "the subtag
// watch-list cannot cover anything". The second is what makes an empty watch-list visible in // watch-list cannot cover anything". The second is what makes an empty watch-list visible in
// Auto mode, where the alarm manager is serving happily and the standby behind it is empty. // Auto mode, where the alarm manager is serving happily and the standby behind it is empty.
// The reasons are concatenated when both apply, which is the worst case worth naming loudly: // The reasons are concatenated when both apply, which is the worst case worth naming loudly:
@@ -857,7 +857,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
// Replaces the cache with the worker's authoritative snapshot, broadcasting // Replaces the cache with the worker's authoritative snapshot, broadcasting
// a synthetic transition for any alarm the live stream missed. // a synthetic transition for any alarm the live stream missed.
// //
// Repair scope (GWC-26): presence deltas (Clear/Raise) plus acked-state deltas. These are // Repair scope: presence deltas (Clear/Raise) plus acked-state deltas. These are
// ALARM FEED transitions (AlarmFeedMessage on the StreamAlarms/dashboard surface), rebuilt // ALARM FEED transitions (AlarmFeedMessage on the StreamAlarms/dashboard surface), rebuilt
// from the worker's own authoritative snapshot to repair what the live feed missed. They are // from the worker's own authoritative snapshot to repair what the live feed missed. They are
// not MxEvents and never reach the gRPC StreamEvents path, so this feed-level repair does not // not MxEvents and never reach the gRPC StreamEvents path, so this feed-level repair does not
@@ -867,7 +867,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
// reads the worker's current state while the corresponding live transition may still be // reads the worker's current state while the corresponding live transition may still be
// buffered in the alarm lease's channel; both would then broadcast, and the two are // buffered in the alarm lease's channel; both would then broadcast, and the two are
// indistinguishable on the feed, since nothing serializes a reconcile against the in-flight // indistinguishable on the feed, since nothing serializes a reconcile against the in-flight
// live stream. ApplyTransition narrows that window with a best-effort dedup (NEXT-03): a live // live stream. ApplyTransition narrows that window with a best-effort dedup: a live
// transition whose worker timestamp and resulting state the cache already carries — or whose // transition whose worker timestamp and resulting state the cache already carries — or whose
// Clear matches a tombstone recorded below — was already broadcast as a repair and is // Clear matches a tombstone recorded below — was already broadcast as a repair and is
// suppressed. The dedup fires only on a positive marker match, so the contract stays // suppressed. The dedup fires only on a positive marker match, so the contract stays
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Configuration;
/// <c>ZB.MOM.WW.GalaxyRepository</c> package binds the options but deliberately ships no validator /// <c>ZB.MOM.WW.GalaxyRepository</c> package binds the options but deliberately ships no validator
/// (see <c>A2-galaxyrepository-adoption-handoff.md</c>); the gateway owns the rule because it is the /// (see <c>A2-galaxyrepository-adoption-handoff.md</c>); the gateway owns the rule because it is the
/// process that writes the snapshot. When persistence is on, the snapshot path must be a valid, /// process that writes the snapshot. When persistence is on, the snapshot path must be a valid,
/// rooted path on the running host for the same reason the auth DB path must be (SEC-33): a /// rooted path on the running host for the same reason the auth DB path must be: a
/// non-rooted value silently resolves against the launch working directory. /// non-rooted value silently resolves against the launch working directory.
/// </summary> /// </summary>
public sealed class GalaxyRepositoryOptionsValidator : OptionsValidatorBase<GalaxyRepositoryOptions> public sealed class GalaxyRepositoryOptionsValidator : OptionsValidatorBase<GalaxyRepositoryOptions>
@@ -18,7 +18,7 @@ internal static class GatewayConfigPathRules
/// against the launch working directory, so the store moves with the CWD and can leak into the /// against the launch working directory, so the store moves with the CWD and can leak into the
/// source tree. Rooting is checked with <see cref="Path.IsPathRooted(string)"/> — the current /// source tree. Rooting is checked with <see cref="Path.IsPathRooted(string)"/> — the current
/// OS — so a Windows drive/UNC literal on a Unix host fails fast at startup rather than being /// OS — so a Windows drive/UNC literal on a Unix host fails fast at startup rather than being
/// blessed and then written as a junk-named relative file (the SEC-01/SEC-33 mechanism). Reject /// blessed and then written as a junk-named relative file. Reject
/// rather than auto-root; silent relocation of a credential store is worse than a boot error. /// rather than auto-root; silent relocation of a credential store is worse than a boot error.
/// Blank is handled by the caller's required-field check and is not treated as non-rooted here. /// Blank is handled by the caller's required-field check and is not treated as non-rooted here.
/// </summary> /// </summary>
@@ -113,7 +113,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
"MxGateway:Security:AuditRetentionDays must be greater than zero (at least one day of audit history is retained).", "MxGateway:Security:AuditRetentionDays must be greater than zero (at least one day of audit history is retained).",
builder); builder);
// The two-layer limiter knobs (SEC-31) accept 0 as "disable this layer": a zero aggregate // The two-layer limiter knobs accept 0 as "disable this layer": a zero aggregate
// limit turns off cross-peer counting, and a zero probe interval restores absolute blocking. // limit turns off cross-peer counting, and a zero probe interval restores absolute blocking.
// Negatives express no intent. // Negatives express no intent.
AddIfNegative( AddIfNegative(
@@ -341,7 +341,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
} }
// QueueCapacity flows into WorkerClient as EventChannelCapacity, where the staging channel is // QueueCapacity flows into WorkerClient as EventChannelCapacity, where the staging channel is
// sized checked(2 * EventChannelCapacity) (GWC-24). Cap it at int.MaxValue/2 so that doubling // sized checked(2 * EventChannelCapacity). Cap it at int.MaxValue/2 so that doubling
// cannot overflow and throw OverflowException at session creation; mirrors the MaxSparseArrayLength // cannot overflow and throw OverflowException at session creation; mirrors the MaxSparseArrayLength
// upper-bound pattern. // upper-bound pattern.
private const int MaximumEventQueueCapacity = int.MaxValue / 2; private const int MaximumEventQueueCapacity = int.MaxValue / 2;
@@ -607,7 +607,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
// at startup rather than mid-traffic. Only checked when both knobs are themselves in range so the // at startup rather than mid-traffic. Only checked when both knobs are themselves in range so the
// message is not doubled up with the individual range errors. // message is not doubled up with the individual range errors.
// //
// IPC-33: the coupling also has an upper half. The worker budgets a DrainEvents reply against the // The coupling also has an upper half. The worker budgets a DrainEvents reply against the
// negotiated frame maximum less two reserves, and that same MxCommandReply is then returned // negotiated frame maximum less two reserves, and that same MxCommandReply is then returned
// verbatim through Invoke, where the gateway's gRPC MaxSendMessageSize (= MaxGrpcMessageBytes) // verbatim through Invoke, where the gateway's gRPC MaxSendMessageSize (= MaxGrpcMessageBytes)
// applies. Letting the pipe maximum run arbitrarily far above the gRPC cap lets the worker pack a // applies. Letting the pipe maximum run arbitrarily far above the gRPC cap lets the worker pack a
@@ -671,7 +671,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
// Path rooting/validity rules are shared with GalaxyRepositoryOptionsValidator (both write a // Path rooting/validity rules are shared with GalaxyRepositoryOptionsValidator (both write a
// host file) so the two validators cannot drift; see GatewayConfigPathRules. Rooting is checked // host file) so the two validators cannot drift; see GatewayConfigPathRules. Rooting is checked
// against the running OS via Path.IsPathRooted — a Windows drive/UNC literal on a Unix host now // against the running OS via Path.IsPathRooted — a Windows drive/UNC literal on a Unix host now
// fails fast instead of being blessed and written as a junk-named relative file (SEC-33). // fails fast instead of being blessed and written as a junk-named relative file.
private static void AddIfNotRooted(string? value, string message, ValidationBuilder builder) private static void AddIfNotRooted(string? value, string message, ValidationBuilder builder)
=> GatewayConfigPathRules.AddIfNotRooted(value, message, builder); => GatewayConfigPathRules.AddIfNotRooted(value, message, builder);
@@ -61,7 +61,7 @@ public sealed class WorkerOptions
/// still fits one worker frame, and at most <em>twice</em> that reserve above it so a /// still fits one worker frame, and at most <em>twice</em> that reserve above it so a
/// <c>DrainEvents</c> reply budgeted against this maximum (frame max less two reserves) still /// <c>DrainEvents</c> reply budgeted against this maximum (frame max less two reserves) still
/// fits the gRPC send cap it is returned through — otherwise the worker can pack a reply the /// fits the gRPC send cap it is returned through — otherwise the worker can pack a reply the
/// pipe carries and the gRPC layer refuses, losing the already-drained events (IPC-33). The /// pipe carries and the gRPC layer refuses, losing the already-drained events. The
/// gateway conveys this value to the worker in the handshake /// gateway conveys this value to the worker in the handshake
/// (<c>GatewayHello.max_frame_bytes</c>). Default is the 16 MB public gRPC cap plus one reserve. /// (<c>GatewayHello.max_frame_bytes</c>). Default is the 16 MB public gRPC cap plus one reserve.
/// </summary> /// </summary>
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary> /// <summary>
/// Handler for <see cref="AuthenticatedDashboardRoleRequirement"/>: succeeds only when the caller /// Handler for <see cref="AuthenticatedDashboardRoleRequirement"/>: succeeds only when the caller
/// is authenticated AND holds one of the requirement's roles. It reads no options at all, which /// is authenticated AND holds one of the requirement's roles. It reads no options at all, which
/// is the point — there is no configuration value that can turn this into a bypass (SEC-40). /// is the point — there is no configuration value that can turn this into a bypass.
/// </summary> /// </summary>
public sealed class AuthenticatedDashboardRoleHandler public sealed class AuthenticatedDashboardRoleHandler
: AuthorizationHandler<AuthenticatedDashboardRoleRequirement> : AuthorizationHandler<AuthenticatedDashboardRoleRequirement>
@@ -13,9 +13,9 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// into this one by accident. /// into this one by accident.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Introduced for SEC-40 to gate the alarm feed (<c>AlarmsHub</c> and the <c>/alarms</c> page), /// Introduced to gate the alarm feed (<c>AlarmsHub</c> and the <c>/alarms</c> page),
/// which is gateway-central and session-less and therefore carries no per-session provenance the /// which is gateway-central and session-less and therefore carries no per-session provenance the
/// SEC-25 tag ACL could scope it by. Requiring a real dashboard role claim is the narrowing that /// tag ACL could scope it by. Requiring a real dashboard role claim is the narrowing that
/// is actually available: it keeps galaxy-wide alarm metadata — alarm reference (a full tag path), /// is actually available: it keeps galaxy-wide alarm metadata — alarm reference (a full tag path),
/// state, severity, category, area, acknowledging operator — away from the read-only environment /// state, severity, category, area, acknowledging operator — away from the read-only environment
/// bypasses, which exist for local development and unauthenticated deployments rather than as a /// bypasses, which exist for local development and unauthenticated deployments rather than as a
@@ -32,7 +32,7 @@ public sealed record DashboardAlarmProviderStatus(
/// <summary> /// <summary>
/// Badge label shown when the alarm manager is serving normally but the subtag standby /// Badge label shown when the alarm manager is serving normally but the subtag standby
/// behind it covers nothing — an empty or undiscoverable watch-list (GWC-37). The feed is /// behind it covers nothing — an empty or undiscoverable watch-list. The feed is
/// healthy today; what is degraded is the fallback that would take over tomorrow, so the /// healthy today; what is degraded is the fallback that would take over tomorrow, so the
/// badge names the standby rather than claiming the feed has failed over. /// badge names the standby rather than claiming the feed has failed over.
/// </summary> /// </summary>
@@ -77,7 +77,7 @@ public sealed record DashboardAlarmProviderStatus(
&& status.Mode == AlarmProviderMode.Subtag && status.Mode == AlarmProviderMode.Subtag
&& string.Equals(reason, AlarmProviderReasons.ForcedSubtag, StringComparison.Ordinal); && string.Equals(reason, AlarmProviderReasons.ForcedSubtag, StringComparison.Ordinal);
// GWC-37: degraded WITHOUT subtag mode means the alarm manager is still serving and the // Degraded WITHOUT subtag mode means the alarm manager is still serving and the
// standby is the unsound half. "Subtag monitoring (degraded)" would misreport which // standby is the unsound half. "Subtag monitoring (degraded)" would misreport which
// provider is live, so this state gets its own label; the amber badge is shared because // provider is live, so this state gets its own label; the amber badge is shared because
// the operator action — fix discovery — is equally overdue in both. // the operator action — fix discovery — is equally overdue in both.
@@ -35,7 +35,7 @@ public static class DashboardAuthenticationDefaults
/// <summary> /// <summary>
/// Policy guarding the alarm feed — the <c>AlarmsHub</c> SignalR surface and the /// Policy guarding the alarm feed — the <c>AlarmsHub</c> SignalR surface and the
/// <c>/alarms</c> page that reads the same central monitor in process (SEC-40). Accepts the /// <c>/alarms</c> page that reads the same central monitor in process. Accepts the
/// same two schemes as <see cref="HubClientsPolicy"/> but carries /// same two schemes as <see cref="HubClientsPolicy"/> but carries
/// <see cref="AuthenticatedDashboardRoleRequirement"/>, a requirement type /// <see cref="AuthenticatedDashboardRoleRequirement"/>, a requirement type
/// <see cref="DashboardAuthorizationHandler"/> does not handle, so the environment bypasses /// <see cref="DashboardAuthorizationHandler"/> does not handle, so the environment bypasses
@@ -49,10 +49,10 @@ public static class DashboardAuthenticationDefaults
public const string KeyPrefixClaimType = "mxgateway:key_prefix"; public const string KeyPrefixClaimType = "mxgateway:key_prefix";
/// <summary> /// <summary>
/// Claim carrying one dashboard event-visibility tag the caller is granted (SEC-25). Stamped /// Claim carrying one dashboard event-visibility tag the caller is granted. Stamped
/// at cookie login by <see cref="DashboardAuthenticator"/> and at hub-token mint by /// at cookie login by <see cref="DashboardAuthenticator"/> and at hub-token mint by
/// <see cref="HubTokenService"/>, both resolving the caller's LDAP groups through /// <see cref="HubTokenService"/>, both resolving the caller's LDAP groups through
/// <c>MxGateway:Dashboard:GroupToTag</c>. Since SEC-41 <see cref="IDashboardSessionAcl"/> reads /// <c>MxGateway:Dashboard:GroupToTag</c>. <see cref="IDashboardSessionAcl"/> reads
/// this claim only as a FALLBACK, for a principal that carries no /// this claim only as a FALLBACK, for a principal that carries no
/// <see cref="LdapGroupClaimType"/> claims to re-resolve from (the hub-token principal, whose /// <see cref="LdapGroupClaimType"/> claims to re-resolve from (the hub-token principal, whose
/// payload carries tags but not groups); a cookie principal is re-resolved against current /// payload carries tags but not groups); a cookie principal is re-resolved against current
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <param name="roleMapper">Maps LDAP groups to dashboard roles.</param> /// <param name="roleMapper">Maps LDAP groups to dashboard roles.</param>
/// <param name="options"> /// <param name="options">
/// Gateway options supplying <c>MxGateway:Dashboard:GroupToTag</c>, the map that turns the user's /// Gateway options supplying <c>MxGateway:Dashboard:GroupToTag</c>, the map that turns the user's
/// LDAP groups into the dashboard visibility tags stamped on the cookie principal (SEC-25). /// LDAP groups into the dashboard visibility tags stamped on the cookie principal.
/// </param> /// </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(
@@ -142,7 +142,7 @@ public sealed class DashboardAuthenticator(
claims.AddRange(groupNames.Select(group => new Claim( claims.AddRange(groupNames.Select(group => new Claim(
DashboardAuthenticationDefaults.LdapGroupClaimType, DashboardAuthenticationDefaults.LdapGroupClaimType,
group))); group)));
// Dashboard event-visibility tags (SEC-25). Visibility only — never a data-access grant — // Dashboard event-visibility tags. Visibility only — never a data-access grant —
// and never logged: only the decision, never the tag values, reaches diagnostics. // and never logged: only the decision, never the tag values, reaches diagnostics.
claims.AddRange(DashboardGroupTagMapping claims.AddRange(DashboardGroupTagMapping
.MapGroupsToTags(groupNames, groupToTag) .MapGroupsToTags(groupNames, groupToTag)
@@ -107,7 +107,7 @@ public static class DashboardEndpointRouteBuilderExtensions
// SignalR hubs. Authorization is enforced on the hub class via [Authorize], so both // SignalR hubs. Authorization is enforced on the hub class via [Authorize], so both
// policies below accept either the dashboard cookie or a HubToken bearer. Snapshot and // policies below accept either the dashboard cookie or a HubToken bearer. Snapshot and
// events use HubClientsPolicy; AlarmsHub uses AlarmsPolicy, which additionally requires a // events use HubClientsPolicy; AlarmsHub uses AlarmsPolicy, which additionally requires a
// real authenticated dashboard role (SEC-40) — see AlarmsHub for why the alarm feed is // real authenticated dashboard role — see AlarmsHub for why the alarm feed is
// gated harder than the session-scoped ones. // gated harder than the session-scoped ones.
endpoints.MapHub<DashboardSnapshotHub>("/hubs/snapshot"); endpoints.MapHub<DashboardSnapshotHub>("/hubs/snapshot");
endpoints.MapHub<AlarmsHub>("/hubs/alarms"); endpoints.MapHub<AlarmsHub>("/hubs/alarms");
@@ -177,7 +177,7 @@ public static class DashboardServiceCollectionExtensions
DashboardAuthenticationDefaults.AuthenticationScheme, DashboardAuthenticationDefaults.AuthenticationScheme,
DashboardAuthenticationDefaults.HubAuthenticationScheme) DashboardAuthenticationDefaults.HubAuthenticationScheme)
.AddRequirements(DashboardAuthorizationRequirement.AnyDashboardRole)); .AddRequirements(DashboardAuthorizationRequirement.AnyDashboardRole));
// SEC-40. Same two schemes as HubClientsPolicy (the alarm feed has an in-page reader // Same two schemes as HubClientsPolicy (the alarm feed has an in-page reader
// and an external hub reader), but a requirement type DashboardAuthorizationHandler // and an external hub reader), but a requirement type DashboardAuthorizationHandler
// does not handle, so neither Authentication:Mode=Disabled nor anonymous localhost // does not handle, so neither Authentication:Mode=Disabled nor anonymous localhost
// can satisfy it. Galaxy-wide alarm metadata therefore needs a real dashboard role. // can satisfy it. Galaxy-wide alarm metadata therefore needs a real dashboard role.
@@ -26,7 +26,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// (ordinal-ignore-case).</description></item> /// (ordinal-ignore-case).</description></item>
/// </list> /// </list>
/// <para> /// <para>
/// <strong>Where the grant comes from (SEC-41).</strong> The grant is re-resolved on every call /// <strong>Where the grant comes from.</strong> The grant is re-resolved on every call
/// from the caller's <see cref="DashboardAuthenticationDefaults.LdapGroupClaimType"/> claims /// from the caller's <see cref="DashboardAuthenticationDefaults.LdapGroupClaimType"/> claims
/// through <c>MxGateway:Dashboard:GroupToTag</c> as it stands in the currently bound options — /// through <c>MxGateway:Dashboard:GroupToTag</c> as it stands in the currently bound options —
/// the same single <see cref="DashboardGroupTagMapping.MapGroupsToTags"/> call /// the same single <see cref="DashboardGroupTagMapping.MapGroupsToTags"/> call
@@ -120,7 +120,7 @@ public sealed class DashboardSessionAcl(
if (groups is not null) if (groups is not null)
{ {
// SEC-41: current options, not the login-time snapshot. MapGroupsToTags returns an // Current options, not the login-time snapshot. MapGroupsToTags returns an
// OrdinalIgnoreCase set, so probing it with the session's tags carries the // OrdinalIgnoreCase set, so probing it with the session's tags carries the
// case-insensitive comparison in the same direction the claim scan below does. // case-insensitive comparison in the same direction the claim scan below does.
IReadOnlySet<string> granted = DashboardGroupTagMapping.MapGroupsToTags( IReadOnlySet<string> granted = DashboardGroupTagMapping.MapGroupsToTags(
@@ -39,7 +39,7 @@ public sealed class HubTokenService
// revocable. A short lifetime bounds the exposure window of a token captured from a proxy // revocable. A short lifetime bounds the exposure window of a token captured from a proxy
// or log after logout (the cookie is cleared on logout, but outstanding tokens are not), and // or log after logout (the cookie is cleared on logout, but outstanding tokens are not), and
// bounds how long a stale role set survives a role change. It now bounds a stale *tag* grant // bounds how long a stale role set survives a role change. It now bounds a stale *tag* grant
// the same way (SEC-25): the token carries the tags resolved from the caller's LDAP groups at // the same way: the token carries the tags resolved from the caller's LDAP groups at
// mint time, so revoking a GroupToTag entry takes effect for token-authenticated hub // mint time, so revoking a GroupToTag entry takes effect for token-authenticated hub
// connections within one lifetime — the natural place the deferred "tokens gain session // connections within one lifetime — the natural place the deferred "tokens gain session
// binding" note landed. Five minutes is transparent to clients that re-fetch from /hubs/token // binding" note landed. Five minutes is transparent to clients that re-fetch from /hubs/token
@@ -11,9 +11,9 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The feed is galaxy-wide: the alarm monitor is gateway-central and its messages carry no /// The feed is galaxy-wide: the alarm monitor is gateway-central and its messages carry no
/// session provenance, so the SEC-25 per-session tag ACL that scopes <c>EventsHub</c> has nothing /// session provenance, so the per-session tag ACL that scopes <c>EventsHub</c> has nothing
/// to intersect here and is deliberately not applied — every authenticated Viewer sees the whole /// to intersect here and is deliberately not applied — every authenticated Viewer sees the whole
/// subscription expression's alarm metadata. What SEC-40 tightened instead is the entry gate: /// subscription expression's alarm metadata. What is tightened instead is the entry gate:
/// <see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/> rather than the /// <see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/> rather than the
/// <see cref="DashboardAuthenticationDefaults.HubClientsPolicy"/> the other hubs use, so the /// <see cref="DashboardAuthenticationDefaults.HubClientsPolicy"/> the other hubs use, so the
/// read-only environment bypasses (<c>Authentication:Mode=Disabled</c>, anonymous localhost) no /// read-only environment bypasses (<c>Authentication:Mode=Disabled</c>, anonymous localhost) no
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// <c>StreamAlarms</c> subscribers and the alarms page as well, and none of /// <c>StreamAlarms</c> subscribers and the alarms page as well, and none of
/// those audiences is subject to this dashboard-display flag. /// those audiences is subject to this dashboard-display flag.
/// <para> /// <para>
/// The broadcast stays deliberately unfiltered (SEC-40). Audience is decided once, at hub /// The broadcast stays deliberately unfiltered. Audience is decided once, at hub
/// admission — <see cref="AlarmsHub"/> carries /// admission — <see cref="AlarmsHub"/> carries
/// <see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/>, so only an authenticated caller /// <see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/>, so only an authenticated caller
/// with a real dashboard role is ever in <see cref="AlarmsHub.AllAlarmsGroup"/> — and the alarm /// with a real dashboard role is ever in <see cref="AlarmsHub.AllAlarmsGroup"/> — and the alarm
@@ -39,8 +39,8 @@ public sealed class EventsHub(
/// The hub-level <see cref="AuthorizeAttribute"/> (<c>HubClientsPolicy</c>) /// The hub-level <see cref="AuthorizeAttribute"/> (<c>HubClientsPolicy</c>)
/// only checks that the caller carries one of the dashboard roles, which by /// only checks that the caller carries one of the dashboard roles, which by
/// itself would let any Viewer subscribe to any session id they name. The /// itself would let any Viewer subscribe to any session id they name. The
/// per-session decision is <see cref="IDashboardSessionAcl"/>'s /// per-session decision is <see cref="IDashboardSessionAcl"/>'s.
/// (SEC-25 / TST-15). The admin bypass is evaluated first, so an /// The admin bypass is evaluated first, so an
/// Administrator joins any session id they name; every check below it /// Administrator joins any session id they name; every check below it
/// applies to non-Admin callers only. For those: a Viewer sees a session /// applies to non-Admin callers only. For those: a Viewer sees a session
/// only when its tags intersect their granted tags, an untagged session /// only when its tags intersect their granted tags, an untagged session
@@ -95,16 +95,12 @@ public sealed class EventsHub(
viewerRegistry.RemoveViewer(Context.ConnectionId, sessionId); viewerRegistry.RemoveViewer(Context.ConnectionId, sessionId);
} }
/// <summary> /// <inheritdoc />
/// Releases every session subscription the dropped connection held. A browser
/// tab that closes never calls <see cref="UnsubscribeSession"/>, so without
/// this the session would look watched forever and the mirror would keep
/// cloning and sending events to an empty group.
/// </summary>
/// <param name="exception">The exception that terminated the connection, if any.</param>
/// <returns>A task representing the disconnect handling.</returns>
public override Task OnDisconnectedAsync(Exception? exception) public override Task OnDisconnectedAsync(Exception? exception)
{ {
// Release every session subscription the dropped connection held. A browser tab that
// closes never calls UnsubscribeSession, so without this the session would look watched
// forever and the mirror would keep cloning and sending events to an empty group.
viewerRegistry.ReleaseConnection(Context.ConnectionId); viewerRegistry.ReleaseConnection(Context.ConnectionId);
return base.OnDisconnectedAsync(exception); return base.OnDisconnectedAsync(exception);
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary> /// <summary>
/// Decides whether a dashboard principal may observe one session's mirrored /// Decides whether a dashboard principal may observe one session's mirrored
/// event stream (SEC-25 / TST-15). Consulted at every subscribe seam: the /// event stream. Consulted at every subscribe seam: the
/// SignalR <c>EventsHub.SubscribeSession</c> join and the in-process /// SignalR <c>EventsHub.SubscribeSession</c> join and the in-process
/// <c>IDashboardSessionEventSubscriber.Subscribe</c> used by the /// <c>IDashboardSessionEventSubscriber.Subscribe</c> used by the
/// session-details page. /// session-details page.
@@ -154,8 +154,8 @@ public static class GatewayApplication
// having pushed the ambient identity before the library's galaxy service runs. That // having pushed the ambient identity before the library's galaxy service runs. That
// ordering is still required, but it is no longer trusted silently: with // ordering is still required, but it is no longer trusted silently: with
// Authentication:Mode=ApiKey the provider denies with PermissionDenied and logs the broken // Authentication:Mode=ApiKey the provider denies with PermissionDenied and logs the broken
// invariant when the identity is absent, instead of resolving to the full hierarchy // invariant when the identity is absent, instead of resolving to the full hierarchy.
// (GWC-38). Under Mode=Disabled identity-absent is expected and stays unscoped. // Under Mode=Disabled identity-absent is expected and stays unscoped.
builder.Services.AddSingleton<ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider, builder.Services.AddSingleton<ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider,
Security.Authorization.GatewayBrowseScopeProvider>(); Security.Authorization.GatewayBrowseScopeProvider>();
@@ -164,7 +164,7 @@ public static class GatewayApplication
// snapshot. GalaxyRepositoryOptions.SnapshotCachePath is init-only, so the default cannot be // snapshot. GalaxyRepositoryOptions.SnapshotCachePath is init-only, so the default cannot be
// applied via PostConfigure — supply it as a configuration value (before the bind) when the // applied via PostConfigure — supply it as a configuration value (before the bind) when the
// shipped config leaves it blank. It resolves to the per-OS CommonApplicationData location, // shipped config leaves it blank. It resolves to the per-OS CommonApplicationData location,
// byte-identical to the removed appsettings literal on Windows (SEC-33). // byte-identical to the removed appsettings literal on Windows.
if (string.IsNullOrWhiteSpace(builder.Configuration["MxGateway:Galaxy:SnapshotCachePath"])) if (string.IsNullOrWhiteSpace(builder.Configuration["MxGateway:Galaxy:SnapshotCachePath"]))
{ {
builder.Configuration["MxGateway:Galaxy:SnapshotCachePath"] = Path.Combine( builder.Configuration["MxGateway:Galaxy:SnapshotCachePath"] = Path.Combine(
@@ -175,7 +175,7 @@ public static class GatewayApplication
builder.Services.AddZbGalaxyRepository(builder.Configuration, "MxGateway:Galaxy"); builder.Services.AddZbGalaxyRepository(builder.Configuration, "MxGateway:Galaxy");
// Validate that persistence has a valid, host-rooted snapshot path (SEC-33). // Validate that persistence has a valid, host-rooted snapshot path.
builder.Services.AddSingleton< builder.Services.AddSingleton<
Microsoft.Extensions.Options.IValidateOptions<ZB.MOM.WW.GalaxyRepository.GalaxyRepositoryOptions>, Microsoft.Extensions.Options.IValidateOptions<ZB.MOM.WW.GalaxyRepository.GalaxyRepositoryOptions>,
Configuration.GalaxyRepositoryOptionsValidator>(); Configuration.GalaxyRepositoryOptionsValidator>();
@@ -224,7 +224,7 @@ public static class GatewayApplication
/// </para> /// </para>
/// <para> /// <para>
/// The location mirrors <c>AuthenticationOptions.SqlitePath</c> so both gateway stores sit /// The location mirrors <c>AuthenticationOptions.SqlitePath</c> so both gateway stores sit
/// together, and the mechanism is the one SEC-33 already used for /// together, and the mechanism is the same mechanism already used for
/// <c>MxGateway:Galaxy:SnapshotCachePath</c> below — same problem, same fix, same file. It also /// <c>MxGateway:Galaxy:SnapshotCachePath</c> below — same problem, same fix, same file. It also
/// matches what <c>docs/GatewayConfiguration.md</c> already tells operators to /// matches what <c>docs/GatewayConfiguration.md</c> already tells operators to
/// pass to the <c>secret</c> CLI — an absolute default also removes the CLI/gateway divergence /// pass to the <c>secret</c> CLI — an absolute default also removes the CLI/gateway divergence
@@ -116,7 +116,7 @@ public sealed class EventStreamService(
// its `yield return` is what feeds the gRPC writer. // its `yield return` is what feeds the gRPC writer.
ChannelReader<MxEvent> reader = subscriber.Reader; ChannelReader<MxEvent> reader = subscriber.Reader;
// GWC-15: register this subscriber's channel as a live backlog source instead of // Register this subscriber's channel as a live backlog source instead of
// reconciling the queue-depth gauge on every event. The gauge previously read the // reconciling the queue-depth gauge on every event. The gauge previously read the
// bounded channel's Count (which takes the channel's internal lock) and adjusted the // bounded channel's Count (which takes the channel's internal lock) and adjusted the
// metric under its own lock on every streamed event. Now the metric reads Count only // metric under its own lock on every streamed event. Now the metric reads Count only
@@ -197,7 +197,7 @@ public sealed class EventStreamService(
} }
// The queue-depth gauge is maintained lazily via the backlog registration // The queue-depth gauge is maintained lazily via the backlog registration
// above (GWC-15): the metric reads this subscriber's channel Count only when // above: the metric reads this subscriber's channel Count only when
// scraped, so there is no per-event gauge bookkeeping on this hot path. // scraped, so there is no per-event gauge bookkeeping on this hot path.
yield return mxEvent; yield return mxEvent;
} }
@@ -35,7 +35,7 @@ public sealed class MxAccessGatewayService(
// The session's owner id and its dashboard-visibility tags both come from the resolved // The session's owner id and its dashboard-visibility tags both come from the resolved
// API key identity, never from the request: the key is the tenant principal, so a // API key identity, never from the request: the key is the tenant principal, so a
// client cannot label its own session with another tenant's tag (SEC-25). // client cannot label its own session with another tenant's tag.
ApiKeyIdentity? owner = identityAccessor.Current; ApiKeyIdentity? owner = identityAccessor.Current;
GatewaySession session = await sessionManager GatewaySession session = await sessionManager
.OpenSessionAsync( .OpenSessionAsync(
@@ -130,7 +130,7 @@ public sealed class MxAccessGatewayService(
// Map from the command alone: cloning the whole request only to overwrite its command with // Map from the command alone: cloning the whole request only to overwrite its command with
// commandToInvoke deep-cloned the (potentially large) original payload for nothing, since // commandToInvoke deep-cloned the (potentially large) original payload for nothing, since
// MapCommand reads nothing but the command (GWC-29). The one clone that matters still // MapCommand reads nothing but the command. The one clone that matters still
// happens inside MapCommand, which is what keeps the worker-bound graph unaliased from // happens inside MapCommand, which is what keeps the worker-bound graph unaliased from
// commandToInvoke — the caller still reads it below via TrackCommandReply. // commandToInvoke — the caller still reads it below via TrackCommandReply.
WorkerCommand workerCommand = mapper.MapCommand(commandToInvoke); WorkerCommand workerCommand = mapper.MapCommand(commandToInvoke);
@@ -167,7 +167,7 @@ public sealed class MxAccessGatewayService(
.WithCancellation(context.CancellationToken) .WithCancellation(context.CancellationToken)
.ConfigureAwait(false)) .ConfigureAwait(false))
{ {
// GWC-06: measure send latency with the allocation-free timestamp API rather // Measure send latency with the allocation-free timestamp API rather
// than allocating a Stopwatch object per event per subscriber on the highest- // than allocating a Stopwatch object per event per subscriber on the highest-
// volume gateway path. Stopwatch.GetTimestamp/GetElapsedTime measure the exact // volume gateway path. Stopwatch.GetTimestamp/GetElapsedTime measure the exact
// same wall-clock span the former Stopwatch.StartNew()/.Elapsed did. // same wall-clock span the former Stopwatch.StartNew()/.Elapsed did.
@@ -185,15 +185,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)
@@ -35,7 +35,7 @@ public sealed class MxAccessGrpcMapper
/// <summary> /// <summary>
/// Maps a gRPC MX command to a worker command. Callers that already hold the command — including /// Maps a gRPC MX command to a worker command. Callers that already hold the command — including
/// the constraint pipeline, whose rewritten command is not the one on the request — use this /// the constraint pipeline, whose rewritten command is not the one on the request — use this
/// overload rather than cloning a whole request to carry a single field (GWC-29); nothing outside /// overload rather than cloning a whole request to carry a single field; nothing outside
/// the command is read here. /// the command is read here.
/// </summary> /// </summary>
/// <param name="command">Command payload.</param> /// <param name="command">Command payload.</param>
@@ -71,7 +71,7 @@ public sealed class MxAccessGrpcMapper
}; };
} }
// GWC-07 / IPC-05: ownership transfer, not a deep clone — the same rule MapEvent follows, // ownership transfer, not a deep clone — the same rule MapEvent follows,
// applied to the other (and larger, on bulk reads) hot-path message. The enclosing // applied to the other (and larger, on bulk reads) hot-path message. The enclosing
// WorkerCommandReply is parsed fresh from a single pipe frame in WorkerClient's read loop // WorkerCommandReply is parsed fresh from a single pipe frame in WorkerClient's read loop
// and is single-consumer by construction: CompleteCommand's TryRemove hands it to exactly // and is single-consumer by construction: CompleteCommand's TryRemove hands it to exactly
@@ -93,10 +93,10 @@ public sealed class MxAccessGrpcMapper
{ {
ArgumentNullException.ThrowIfNull(workerEvent); ArgumentNullException.ThrowIfNull(workerEvent);
// GWC-07 / IPC-05: ownership transfer, not a deep clone. The enclosing WorkerEvent is // ownership transfer, not a deep clone. The enclosing WorkerEvent is
// parsed fresh from a single pipe frame in WorkerClient's read loop and is discarded // parsed fresh from a single pipe frame in WorkerClient's read loop and is discarded
// immediately after this mapping — the SessionEventDistributor pump is its single // immediately after this mapping — the SessionEventDistributor pump is its single
// consumer (GWC-01 claims the worker event channel as single-reader), so nothing else // consumer (it claims the worker event channel as single-reader), so nothing else
// aliases or mutates workerEvent.Event. We therefore move the inner MxEvent into the // aliases or mutates workerEvent.Event. We therefore move the inner MxEvent into the
// outbound graph instead of cloning it. Downstream the pump fans this one MxEvent to // outbound graph instead of cloning it. Downstream the pump fans this one MxEvent to
// every subscriber and retains it in the replay ring, but that sharing is READ-ONLY // every subscriber and retains it in the replay ring, but that sharing is READ-ONLY
@@ -35,7 +35,7 @@ public sealed class GatewayMetrics : IDisposable
private readonly ConcurrentDictionary<string, long> _eventsBySession = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, long> _eventsBySession = new(StringComparer.Ordinal);
private readonly Dictionary<string, long> _retryAttemptsByArea = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, long> _retryAttemptsByArea = new(StringComparer.OrdinalIgnoreCase);
// GWC-15: live backlog sources for the gRPC event-stream queue-depth gauge. Each active // live backlog sources for the gRPC event-stream queue-depth gauge. Each active
// StreamEvents subscriber registers a delegate returning its channel's current backlog; the // StreamEvents subscriber registers a delegate returning its channel's current backlog; the
// gauge sums them ONLY when scraped or projected (GetGrpcEventStreamQueueDepth), so nothing is // gauge sums them ONLY when scraped or projected (GetGrpcEventStreamQueueDepth), so nothing is
// read on the per-event streaming hot path. Concurrent because subscribers register/unregister // read on the per-event streaming hot path. Concurrent because subscribers register/unregister
@@ -43,7 +43,7 @@ public sealed class GatewayMetrics : IDisposable
private readonly ConcurrentDictionary<long, Func<int>> _eventStreamBacklogSources = new(); private readonly ConcurrentDictionary<long, Func<int>> _eventStreamBacklogSources = new();
private long _nextEventStreamBacklogSourceId; private long _nextEventStreamBacklogSourceId;
// PERF-02: the same pull model for the worker event queue depth. It replaces a pushed scalar that // the same pull model for the worker event queue depth. It replaces a pushed scalar that
// every WorkerClient wrote twice per event (staged, consumed) under _syncRoot — a process-wide // every WorkerClient wrote twice per event (staged, consumed) under _syncRoot — a process-wide
// lock on the hottest path, and last-writer-wins across sessions, so the gauge reported one // lock on the hottest path, and last-writer-wins across sessions, so the gauge reported one
// arbitrary session's backlog instead of the gateway's. Each client registers a source returning // arbitrary session's backlog instead of the gateway's. Each client registers a source returning
@@ -210,7 +210,7 @@ public sealed class GatewayMetrics : IDisposable
/// <param name="method">Name of the command method.</param> /// <param name="method">Name of the command method.</param>
public void CommandStarted(string method) public void CommandStarted(string method)
{ {
// PERF-02: the three command counters run two-to-three times per gRPC call, so they use // the three command counters run two-to-three times per gRPC call, so they use
// Interlocked rather than _syncRoot — the same idiom as EventReceived. Nothing here needs a // Interlocked rather than _syncRoot — the same idiom as EventReceived. Nothing here needs a
// consistent multi-field view; GetSnapshot reads each with Interlocked.Read. // consistent multi-field view; GetSnapshot reads each with Interlocked.Read.
Interlocked.Increment(ref _commandsStarted); Interlocked.Increment(ref _commandsStarted);
@@ -281,7 +281,7 @@ public sealed class GatewayMetrics : IDisposable
/// Registers a live depth source for the worker event queue-depth gauge and returns a handle /// Registers a live depth source for the worker event queue-depth gauge and returns a handle
/// that removes it when disposed. Each <c>WorkerClient</c> registers once and reports its own /// that removes it when disposed. Each <c>WorkerClient</c> registers once and reports its own
/// undelivered (staged + queued) event count, so the gauge is the gateway-wide sum instead of /// undelivered (staged + queued) event count, so the gauge is the gateway-wide sum instead of
/// the last value any one session happened to push (PERF-02). /// the last value any one session happened to push.
/// </summary> /// </summary>
/// <param name="depth"> /// <param name="depth">
/// Returns this worker client's current undelivered event count. Invoked only at collection /// Returns this worker client's current undelivered event count. Invoked only at collection
@@ -303,7 +303,7 @@ public sealed class GatewayMetrics : IDisposable
/// returns a handle that removes it when disposed. The gauge sums every registered /// returns a handle that removes it when disposed. The gauge sums every registered
/// source's current value on demand (when scraped or via <see cref="GetSnapshot"/>), /// source's current value on demand (when scraped or via <see cref="GetSnapshot"/>),
/// rather than tracking a pushed running total, so the streaming hot path does no /// rather than tracking a pushed running total, so the streaming hot path does no
/// per-event gauge bookkeeping (GWC-15). /// per-event gauge bookkeeping.
/// </summary> /// </summary>
/// <param name="backlog"> /// <param name="backlog">
/// Returns this subscriber's current channel backlog. Invoked only at collection time; /// Returns this subscriber's current channel backlog. Invoked only at collection time;
@@ -351,7 +351,7 @@ public sealed class GatewayMetrics : IDisposable
{ {
// Tagged by stage only. Neither the key id nor the peer address may become a tag: key ids // Tagged by stage only. Neither the key id nor the peer address may become a tag: key ids
// are credential material and peer addresses are unbounded cardinality, and /metrics is not // are credential material and peer addresses are unbounded cardinality, and /metrics is not
// authenticated (open SEC-14), so anything tagged here is world-readable. // authenticated, so anything tagged here is world-readable.
_authThrottledCounter.Add(1, new KeyValuePair<string, object?>("stage", stage)); _authThrottledCounter.Add(1, new KeyValuePair<string, object?>("stage", stage));
} }
@@ -460,7 +460,7 @@ public sealed class GatewayMetrics : IDisposable
{ {
// Compute the live queue depths outside _syncRoot: the sources are the subscriber channels' // Compute the live queue depths outside _syncRoot: the sources are the subscriber channels'
// Count (their own locks) and the worker clients' interlocked counters, neither of which may // Count (their own locks) and the worker clients' interlocked counters, neither of which may
// run under this lock. GWC-15, PERF-02. // run under this lock.
int workerEventQueueDepth = GetWorkerEventQueueDepth(); int workerEventQueueDepth = GetWorkerEventQueueDepth();
int grpcEventStreamQueueDepth = GetGrpcEventStreamQueueDepth(); int grpcEventStreamQueueDepth = GetGrpcEventStreamQueueDepth();
lock (_syncRoot) lock (_syncRoot)
@@ -521,7 +521,7 @@ public sealed class GatewayMetrics : IDisposable
} }
} }
// Sums the undelivered event backlog across every live worker client (PERF-02). // Sums the undelivered event backlog across every live worker client.
private int GetWorkerEventQueueDepth() => SumSources(_workerEventQueueDepthSources); private int GetWorkerEventQueueDepth() => SumSources(_workerEventQueueDepthSources);
// Sums the live backlog across every registered event-stream subscriber. // Sums the live backlog across every registered event-stream subscriber.
@@ -571,6 +571,7 @@ public sealed class GatewayMetrics : IDisposable
{ {
private int _disposed; private int _disposed;
/// <summary>Removes the source from its gauge; subsequent calls are no-ops.</summary>
public void Dispose() public void Dispose()
{ {
if (Interlocked.Exchange(ref _disposed, 1) == 0) if (Interlocked.Exchange(ref _disposed, 1) == 0)
@@ -39,12 +39,7 @@ public sealed class AuditDrainService(
/// <summary>Upper bound on how long shutdown waits for the remaining buffered events.</summary> /// <summary>Upper bound on how long shutdown waits for the remaining buffered events.</summary>
private static readonly TimeSpan ShutdownDrainCap = TimeSpan.FromSeconds(2); private static readonly TimeSpan ShutdownDrainCap = TimeSpan.FromSeconds(2);
/// <summary> /// <inheritdoc />
/// Bootstraps the audit table, runs one retention sweep, then attaches the drain so the
/// writer switches from synchronous write-through to enqueueing.
/// </summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public override async Task StartAsync(CancellationToken cancellationToken) public override async Task StartAsync(CancellationToken cancellationToken)
{ {
try try
@@ -64,12 +59,7 @@ public sealed class AuditDrainService(
await base.StartAsync(cancellationToken).ConfigureAwait(false); await base.StartAsync(cancellationToken).ConfigureAwait(false);
} }
/// <summary> /// <inheritdoc />
/// Detaches the drain (so late writes go straight to the sink) and gives the buffered
/// events a bounded window to reach the store.
/// </summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public override async Task StopAsync(CancellationToken cancellationToken) public override async Task StopAsync(CancellationToken cancellationToken)
{ {
writer.DetachDrain(); writer.DetachDrain();
@@ -87,11 +87,9 @@ public sealed class SqliteCanonicalAuditStore(
} }
/// <inheritdoc /> /// <inheritdoc />
/// <remarks> // One connection, one transaction and one prepared command for the whole batch: the drain
/// One connection, one transaction and one prepared command for the whole batch: the drain // pays a single commit for up to AuditDrainService.MaxBatchSize events rather
/// pays a single commit for up to <see cref="AuditDrainService.MaxBatchSize"/> events rather // than one round-trip per event.
/// than one round-trip per event.
/// </remarks>
public async Task InsertBatchAsync(IReadOnlyList<AuditEvent> auditEvents, CancellationToken cancellationToken) public async Task InsertBatchAsync(IReadOnlyList<AuditEvent> auditEvents, CancellationToken cancellationToken)
{ {
ArgumentNullException.ThrowIfNull(auditEvents); ArgumentNullException.ThrowIfNull(auditEvents);
@@ -148,17 +146,15 @@ public sealed class SqliteCanonicalAuditStore(
} }
/// <inheritdoc /> /// <inheritdoc />
/// <remarks> // The comparison goes through SQLite's datetime() rather than comparing the stored
/// The comparison goes through SQLite's <c>datetime()</c> rather than comparing the stored // ISO-8601 text directly. Text comparison is only correct while every row is UTC-normalized
/// ISO-8601 text directly. Text comparison is only correct while every row is UTC-normalized // ISO-8601 — which AuditEvent.OccurredAtUtc guarantees for rows written through
/// ISO-8601 — which <see cref="AuditEvent.OccurredAtUtc"/> guarantees for rows written through // this store, but not for rows that entered the table any other way (a repair script, an
/// this store, but not for rows that entered the table any other way (a repair script, an // older schema, a future producer). On a mixed-format column a text comparison silently
/// older schema, a future producer). On a mixed-format column a text comparison silently // deletes live audit: 2026-05-17T09:00:00-05:00 is two hours AFTER a
/// deletes live audit: <c>2026-05-17T09:00:00-05:00</c> is two hours AFTER a // 2026-05-17T12:00:00+00:00 cutoff yet sorts before it. Comparing instants is correct
/// <c>2026-05-17T12:00:00+00:00</c> cutoff yet sorts before it. Comparing instants is correct // regardless of how the text got there, and anything datetime() cannot parse yields
/// regardless of how the text got there, and anything <c>datetime()</c> cannot parse yields // NULL and is therefore never deleted — audit that cannot be dated is kept, not swept.
/// NULL and is therefore never deleted — audit that cannot be dated is kept, not swept.
/// </remarks>
public async Task<int> DeleteOlderThanAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken) public async Task<int> DeleteOlderThanAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken)
{ {
await using SqliteConnection connection = await using SqliteConnection connection =
@@ -13,7 +13,7 @@ public sealed record ApiKeyConstraints(
private readonly IReadOnlyList<string> _dashboardTags = Array.Empty<string>(); private readonly IReadOnlyList<string> _dashboardTags = Array.Empty<string>();
/// <summary> /// <summary>
/// Gets the dashboard event-visibility tags granted to this key (SEC-25). /// Gets the dashboard event-visibility tags granted to this key.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
@@ -44,7 +44,7 @@ public interface IApiKeyCacheInvalidator
/// evicting so an in-flight verification that started under the old generation discards its own /// evicting so an in-flight verification that started under the old generation discards its own
/// repopulation (set-then-recheck in <c>VerifyAsync</c>) — making the "gateway-initiated mutations /// repopulation (set-then-recheck in <c>VerifyAsync</c>) — making the "gateway-initiated mutations
/// take effect immediately" contract true even against a verify that was already in the inner library /// take effect immediately" contract true even against a verify that was already in the inner library
/// when the revoke landed (SEC-34 window 3). /// when the revoke landed.
/// </para> /// </para>
/// <para> /// <para>
/// The short TTL is the backstop for two remaining bounded-staleness windows. (1) Out-of-band /// The short TTL is the backstop for two remaining bounded-staleness windows. (1) Out-of-band
@@ -141,7 +141,7 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
} }
// Caches a successful verification unless the key was invalidated while the inner verify was in // Caches a successful verification unless the key was invalidated while the inner verify was in
// flight (SEC-34 window 3, detected via the generation snapshot). The entry lifetime stays the // flight (detected via the generation snapshot). The entry lifetime stays the
// TTL: the library verification identity carries no expiry, so the cache cannot cap at the key's // TTL: the library verification identity carries no expiry, so the cache cannot cap at the key's
// ExpiresUtc — that window remains TTL-bounded and documented in the class remarks. // ExpiresUtc — that window remains TTL-bounded and documented in the class remarks.
private void TryCacheSuccess( private void TryCacheSuccess(
@@ -202,11 +202,14 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
? generation ? generation
: 0; : 0;
// Parses the key id out of a "Bearer mxgw_<keyId>_<secret>" header without any store access — /// <summary>
// the same split the authorization interceptor does. Returns null for a header this cache cannot /// Parses the key id out of a <c>Bearer mxgw_&lt;keyId&gt;_&lt;secret&gt;</c> header without any store access,
// attribute to a key id (in which case the generation race-guard is simply not applied). /// using the same split the authorization interceptor does.
// /// </summary>
// Correctness of the SEC-34 generation guard rests on parts[1] being the FULL key id: the '_' /// <param name="authorizationHeader">The raw authorization header value, or <see langword="null"/>.</param>
/// <returns>The full key id, or <see langword="null"/> for a header this cache cannot attribute to a key id (the generation race-guard is then not applied).</returns>
// Implementation note:
// Correctness of the generation guard rests on parts[1] being the FULL key id: the '_'
// split would truncate a key id that itself contained '_', silently disarming the guard for that // split would truncate a key id that itself contained '_', silently disarming the guard for that
// key. This is safe because '_' is the token's field delimiter and both — and the only — key // key. This is safe because '_' is the token's field delimiter and both — and the only — key
// creation paths in the gateway forbid it: ApiKeyAdminCommandLineParser.IsValidKeyId and // creation paths in the gateway forbid it: ApiKeyAdminCommandLineParser.IsValidKeyId and
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
/// source-rotating sprayer that never trips any single partition. /// source-rotating sprayer that never trips any single partition.
/// </para> /// </para>
/// <para> /// <para>
/// An over-limit state is a valve, not a wall (SEC-31): at most one request per /// An over-limit state is a valve, not a wall: at most one request per
/// <see cref="SecurityOptions.ApiKeyFailureProbeIntervalSeconds"/> is admitted through to the real /// <see cref="SecurityOptions.ApiKeyFailureProbeIntervalSeconds"/> is admitted through to the real
/// verifier, and a successful verification resets both layers. The reset path therefore stays /// verifier, and a successful verification resets both layers. The reset path therefore stays
/// reachable while throttled — the earlier design keyed solely on the attacker-supplied key id and /// reachable while throttled — the earlier design keyed solely on the attacker-supplied key id and
@@ -29,8 +29,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
/// The tracked partition set is a bounded LRU (<see cref="SecurityOptions.ApiKeyFailureTrackedPeers"/>). /// The tracked partition set is a bounded LRU (<see cref="SecurityOptions.ApiKeyFailureTrackedPeers"/>).
/// Because only a validly shaped token mints a key-id partition and each transport peer may mint at /// Because only a validly shaped token mints a key-id partition and each transport peer may mint at
/// most <see cref="MaxKeyIdPartitionsPerPeer"/> of them (the peer-layer overflow collapses onto that /// most <see cref="MaxKeyIdPartitionsPerPeer"/> of them (the peer-layer overflow collapses onto that
/// address's fallback partition), the map bounds memory <em>and</em> resists a junk-token flush /// address's fallback partition), the map bounds memory <em>and</em> resists a junk-token flush.
/// (SEC-32). A validly shaped presented key id still feeds its cross-peer aggregate when its peer /// A validly shaped presented key id still feeds its cross-peer aggregate when its peer
/// layer uses the fallback, so exhausting the per-peer cap cannot bypass distributed-spray /// layer uses the fallback, so exhausting the per-peer cap cannot bypass distributed-spray
/// protection; the aggregate map remains bounded by the same capped eviction policy: /// protection; the aggregate map remains bounded by the same capped eviction policy:
/// eviction prefers fully expired windows, never removes an over-limit partition below a transient /// eviction prefers fully expired windows, never removes an over-limit partition below a transient
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
/// <summary>Scopes Galaxy browse results to the calling API key's BrowseSubtrees constraint.</summary> /// <summary>Scopes Galaxy browse results to the calling API key's BrowseSubtrees constraint.</summary>
/// <remarks> /// <remarks>
/// Distinguishes two states the pre-GWC-38 code collapsed into one: "authenticated, no /// Distinguishes two states the earlier code collapsed into one: "authenticated, no
/// BrowseSubtrees constraint" (legitimately unscoped — the common case) and "no ambient identity /// BrowseSubtrees constraint" (legitimately unscoped — the common case) and "no ambient identity
/// at all" (the interceptor-ordering invariant below is broken). The second used to resolve to the /// at all" (the interceptor-ordering invariant below is broken). The second used to resolve to the
/// full hierarchy silently, which made a scoping bypass invisible; it now denies loudly. /// full hierarchy silently, which made a scoping bypass invisible; it now denies loudly.
@@ -140,12 +140,17 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
// Token shape mxgw_<keyId>_<secret>: the key id is the second underscore-delimited segment (the // Token shape mxgw_<keyId>_<secret>: the key id is the second underscore-delimited segment (the
// secret may itself contain underscores, which does not affect the key id). The shape is checked // secret may itself contain underscores, which does not affect the key id). The shape is checked
// before a key-id partition is minted so a spray of invented tokens cannot mint one tracked // before a key-id partition is minted so a spray of invented tokens cannot mint one tracked
// partition each and flush the limiter's bounded map (SEC-32). Anything that fails the check // partition each and flush the limiter's bounded map. Anything that fails the check
// falls back to the sender's transport-peer partition. // falls back to the sender's transport-peer partition.
// //
// Internal rather than private so the parse rules can be pinned directly by test; the shape // Internal rather than private so the parse rules can be pinned directly by test; the shape
// check is a security boundary (SEC-32) and is worth asserting without routing every case // check is a security boundary and is worth asserting without routing every case
// through a full RPC. // through a full RPC.
/// <summary>
/// Extracts the API key id from a bearer authorization header.
/// </summary>
/// <param name="authorizationHeader">The raw <c>authorization</c> header value, or <see langword="null"/>.</param>
/// <returns>The key id when the header has the <c>mxgw_&lt;keyId&gt;_&lt;secret&gt;</c> shape; otherwise <see langword="null"/>.</returns>
internal static string? TryResolveKeyId(string? authorizationHeader) internal static string? TryResolveKeyId(string? authorizationHeader)
{ {
if (string.IsNullOrWhiteSpace(authorizationHeader)) if (string.IsNullOrWhiteSpace(authorizationHeader))
@@ -39,7 +39,7 @@ public sealed class GatewaySession
private bool _everHadEventSubscriber; private bool _everHadEventSubscriber;
private SessionEventDistributor? _eventDistributor; private SessionEventDistributor? _eventDistributor;
private bool _eventDistributorStarted; private bool _eventDistributorStarted;
// GWC-34: set under _syncRoot when DisposeAsync nulls _eventDistributor, making the // Set under _syncRoot when DisposeAsync nulls _eventDistributor, making the
// "one distributor per session, never reset" lifecycle symmetric at both ends. Without it // "one distributor per session, never reset" lifecycle symmetric at both ends. Without it
// EnsureDistributorCreated reads the null as "not created yet" and builds a REPLACEMENT // EnsureDistributorCreated reads the null as "not created yet" and builds a REPLACEMENT
// distributor after teardown — one whose pump can never start (_eventDistributorStarted is a // distributor after teardown — one whose pump can never start (_eventDistributorStarted is a
@@ -163,7 +163,7 @@ public sealed class GatewaySession
/// is unit-testable. /// is unit-testable.
/// </param> /// </param>
/// <param name="ownerDashboardTags"> /// <param name="ownerDashboardTags">
/// Dashboard event-visibility tags inherited from the owning API key (SEC-25). Copied into /// Dashboard event-visibility tags inherited from the owning API key. Copied into
/// the immutable <see cref="Tags"/> set; <see langword="null"/> or empty means untagged. /// the immutable <see cref="Tags"/> set; <see langword="null"/> or empty means untagged.
/// The tags come from the owner key, never from the client's wire request, so a client /// The tags come from the owner key, never from the client's wire request, so a client
/// cannot label its own session with another tenant's tag. /// cannot label its own session with another tenant's tag.
@@ -265,8 +265,8 @@ public sealed class GatewaySession
public string? OwnerKeyId { get; } public string? OwnerKeyId { get; }
/// <summary> /// <summary>
/// Gets the dashboard event-visibility tags this session inherited from its owning API key /// Gets the dashboard event-visibility tags this session inherited from its owning API key.
/// (SEC-25). An empty set means untagged. /// An empty set means untagged.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Immutable for the session's life — assigned once at construction from the owner key's /// Immutable for the session's life — assigned once at construction from the owner key's
@@ -541,7 +541,7 @@ public sealed class GatewaySession
// concurrent callers (e.g. MarkReady's dashboard mirror and a racing first // concurrent callers (e.g. MarkReady's dashboard mirror and a racing first
// AttachEventSubscriber) agree on a single distributor and a single start. // AttachEventSubscriber) agree on a single distributor and a single start.
// //
// GWC-34: after DisposeAsync the null distributor means "gone", not "not built yet", so this // After DisposeAsync the null distributor means "gone", not "not built yet", so this
// throws instead of constructing a replacement nothing will ever pump. The attach paths already // throws instead of constructing a replacement nothing will ever pump. The attach paths already
// roll back and surface the exception, which is the same SessionNotReady the readiness gates // roll back and surface the exception, which is the same SessionNotReady the readiness gates
// raise — a caller that loses the race to teardown now fails immediately instead of hanging. // raise — a caller that loses the race to teardown now fails immediately instead of hanging.
@@ -610,7 +610,7 @@ public sealed class GatewaySession
/// </exception> /// </exception>
public IEventSubscriberLease AttachInternalEventSubscriber() public IEventSubscriberLease AttachInternalEventSubscriber()
{ {
// Readiness gate, mirroring AttachEventSubscriber (GWC-27). It must run BEFORE // Readiness gate, mirroring AttachEventSubscriber. It must run BEFORE
// EnsureDistributorCreated: a premature attach would construct the distributor and start // EnsureDistributorCreated: a premature attach would construct the distributor and start
// its pump against a not-yet-Ready worker, the pump source would throw SessionNotReady, // its pump against a not-yet-Ready worker, the pump source would throw SessionNotReady,
// PumpAsync would complete every subscriber with that error and latch _completed, and // PumpAsync would complete every subscriber with that error and latch _completed, and
@@ -694,7 +694,7 @@ public sealed class GatewaySession
// These three calls are OUTSIDE _syncRoot to avoid holding it across // These three calls are OUTSIDE _syncRoot to avoid holding it across
// EnsureDistributorCreated's own lock and StartAsync's Task.Run. // EnsureDistributorCreated's own lock and StartAsync's Task.Run.
// //
// GWC-34: the state check above can be overtaken by a close+dispose before we get here, and // The state check above can be overtaken by a close+dispose before we get here, and
// EnsureDistributorCreated now says so instead of building an orphan distributor. The // EnsureDistributorCreated now says so instead of building an orphan distributor. The
// dashboard mirror is a best-effort observability attachment on a session that is already // dashboard mirror is a best-effort observability attachment on a session that is already
// gone, so it is abandoned here rather than propagated out of MarkReady — the mirror-started // gone, so it is abandoned here rather than propagated out of MarkReady — the mirror-started
@@ -1918,7 +1918,7 @@ public sealed class GatewaySession
{ {
distributor = _eventDistributor; distributor = _eventDistributor;
_eventDistributor = null; _eventDistributor = null;
// GWC-34: latch the null-out so EnsureDistributorCreated cannot read it as // Latch the null-out so EnsureDistributorCreated cannot read it as
// "not created yet" and hand a racing attach a distributor that will never pump. // "not created yet" and hand a racing attach a distributor that will never pump.
_eventDistributorDisposed = true; _eventDistributorDisposed = true;
} }
@@ -19,7 +19,7 @@ public interface ISessionManager
/// <summary> /// <summary>
/// Opens a new gateway session, stamping the owning API key's dashboard event-visibility /// Opens a new gateway session, stamping the owning API key's dashboard event-visibility
/// tags onto it (SEC-25). /// tags onto it.
/// </summary> /// </summary>
/// <param name="request">Request payload.</param> /// <param name="request">Request payload.</param>
/// <param name="clientIdentity">Client identity string.</param> /// <param name="clientIdentity">Client identity string.</param>
@@ -520,7 +520,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
{ {
gap = _anyEventSeen && afterSequence < _highestSequenceSeen; gap = _anyEventSeen && afterSequence < _highestSequenceSeen;
// GWC-25: nothing is retained, but a gapped client still needs a usable resume // Nothing is retained, but a gapped client still needs a usable resume
// anchor. The documented client formula is // anchor. The documented client formula is
// after_worker_sequence = oldest_available_sequence - 1, so reporting 0 here made // after_worker_sequence = oldest_available_sequence - 1, so reporting 0 here made
// an unsigned client compute ulong.MaxValue: the follow-up resume then replayed // an unsigned client compute ulong.MaxValue: the follow-up resume then replayed
@@ -532,7 +532,7 @@ public sealed class SessionManager : ISessionManager
// The short prefix and bare guid keep the pipe's Unix-domain-socket path // The short prefix and bare guid keep the pipe's Unix-domain-socket path
// (TMPDIR + "CoreFxPipe_" + name) inside the 104-byte sun_path limit on // (TMPDIR + "CoreFxPipe_" + name) inside the 104-byte sun_path limit on
// macOS, whose default per-user TMPDIR is ~49 chars; the gateway PID keeps // macOS, whose default per-user TMPDIR is ~49 chars; the gateway PID keeps
// the name collision-free across gateway restarts (NEXT-01). // the name collision-free across gateway restarts.
string pipeName = $"mxgw-{Environment.ProcessId}-{sessionUid}"; string pipeName = $"mxgw-{Environment.ProcessId}-{sessionUid}";
string nonce = CreateNonce(); string nonce = CreateNonce();
DateTimeOffset openedAt = _timeProvider.GetUtcNow(); DateTimeOffset openedAt = _timeProvider.GetUtcNow();
@@ -28,7 +28,7 @@ public sealed class WorkerClient : IWorkerClient
// Staging hand-off between the read loop and the dedicated event writer. The read loop writes // Staging hand-off between the read loop and the dedicated event writer. The read loop writes
// here with a non-blocking TryWrite so a full consumer channel (_events) can never stall the read // here with a non-blocking TryWrite so a full consumer channel (_events) can never stall the read
// loop behind an event — replies and heartbeats keep flowing. Bounded at 2 × EventChannelCapacity // loop behind an event — replies and heartbeats keep flowing. Bounded at 2 × EventChannelCapacity
// (GWC-24): an unbounded staging channel let a consumer that drains slower than the worker // because an unbounded staging channel let a consumer that drains slower than the worker
// produces grow gateway memory without limit and without a fault, because each individual timed // produces grow gateway memory without limit and without a fault, because each individual timed
// write into _events still completed inside EventChannelFullModeTimeout. A rejected TryWrite here // write into _events still completed inside EventChannelFullModeTimeout. A rejected TryWrite here
// is the sustained-slow-drain signal and faults the client immediately (ProtocolViolation), which // is the sustained-slow-drain signal and faults the client immediately (ProtocolViolation), which
@@ -41,7 +41,7 @@ public sealed class WorkerClient : IWorkerClient
private readonly SemaphoreSlim _pendingCommandSlots; private readonly SemaphoreSlim _pendingCommandSlots;
private readonly CancellationTokenSource _stopCts = new(); private readonly CancellationTokenSource _stopCts = new();
// PERF-02: this client's contribution to the gateway-wide worker queue-depth gauge. Registered // this client's contribution to the gateway-wide worker queue-depth gauge. Registered
// once here and read only when the gauge is scraped, so staging and consuming an event cost an // once here and read only when the gauge is scraped, so staging and consuming an event cost an
// Interlocked on _eventQueueDepth and nothing else — previously each of those two hot-path steps // Interlocked on _eventQueueDepth and nothing else — previously each of those two hot-path steps
// called into GatewayMetrics and took its process-wide lock. Null when metrics are disabled. // called into GatewayMetrics and took its process-wide lock. Null when metrics are disabled.
@@ -233,13 +233,13 @@ public sealed class WorkerClient : IWorkerClient
// size the caller controls; checking here keeps a MessageTooLarge in the write loop a // size the caller controls; checking here keeps a MessageTooLarge in the write loop a
// genuine desync signal. // genuine desync signal.
// //
// PERF-07: this size cannot be handed to WorkerFrameWriter to spare its own // this size cannot be handed to WorkerFrameWriter to spare its own
// CalculateSize. WriteLoopAsync stamps envelope.Sequence immediately before the write // CalculateSize. WriteLoopAsync stamps envelope.Sequence immediately before the write
// (GWC-28), and a non-zero varint field grows the encoding — so the number computed here // and a non-zero varint field grows the encoding — so the number computed here
// is a lower bound on the frame the writer actually emits, never the frame length. Passing // is a lower bound on the frame the writer actually emits, never the frame length. Passing
// it as a knownSize would under-length the prefix and desync the worker's framing. // it as a knownSize would under-length the prefix and desync the worker's framing.
// //
// IPC-39: being a lower bound is conservative for the knownSize question but the wrong // being a lower bound is conservative for the knownSize question but the wrong
// direction for this pre-check, which must not admit an envelope the writer will then // direction for this pre-check, which must not admit an envelope the writer will then
// reject with a session-fatal MessageTooLarge. Compare against the frame maximum less the // reject with a session-fatal MessageTooLarge. Compare against the frame maximum less the
// MaxSequenceEncodingBytes the stamp can add, so every envelope this check accepts still // MaxSequenceEncodingBytes the stamp can add, so every envelope this check accepts still
@@ -257,7 +257,7 @@ public sealed class WorkerClient : IWorkerClient
await EnqueueAsync(commandEnvelope, cancellationToken).ConfigureAwait(false); await EnqueueAsync(commandEnvelope, cancellationToken).ConfigureAwait(false);
// PERF-07: one pooled timer instead of a linked CTS + Task.Delay + WhenAny per command. // one pooled timer instead of a linked CTS + Task.Delay + WhenAny per command.
// Task.WaitAsync arms a TimerQueueTimer on the shared timer queue and cancels it when the // Task.WaitAsync arms a TimerQueueTimer on the shared timer queue and cancels it when the
// reply lands, so the steady-state cost of a command that replies in time is a single // reply lands, so the steady-state cost of a command that replies in time is a single
// continuation — the old shape allocated a linked CancellationTokenSource, its // continuation — the old shape allocated a linked CancellationTokenSource, its
@@ -354,7 +354,7 @@ public sealed class WorkerClient : IWorkerClient
{ {
await foreach (WorkerEvent workerEvent in _events.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) await foreach (WorkerEvent workerEvent in _events.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{ {
// No metrics call on the hot path: the gauge pulls _eventQueueDepth when scraped (PERF-02). // No metrics call on the hot path: the gauge pulls _eventQueueDepth when scraped.
Interlocked.Decrement(ref _eventQueueDepth); Interlocked.Decrement(ref _eventQueueDepth);
yield return workerEvent; yield return workerEvent;
} }
@@ -420,7 +420,7 @@ public sealed class WorkerClient : IWorkerClient
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
// GWC-32: disposal is the third channel-completing path, and it must enter a terminal state // disposal is the third channel-completing path, and it must enter a terminal state
// like the other two (MarkClosed, SetFaulted) BEFORE the channels are completed. Otherwise a // like the other two (MarkClosed, SetFaulted) BEFORE the channels are completed. Otherwise a
// client disposed while still Ready keeps reporting IsTerminalState() == false, and a read // client disposed while still Ready keeps reporting IsTerminalState() == false, and a read
// loop already inside DispatchEnvelope (the _stopCts.Cancel below only interrupts the NEXT // loop already inside DispatchEnvelope (the _stopCts.Cancel below only interrupts the NEXT
@@ -490,7 +490,7 @@ public sealed class WorkerClient : IWorkerClient
/// Worst-case number of bytes <see cref="WriteLoopAsync"/>'s sequence stamp adds to an /// Worst-case number of bytes <see cref="WriteLoopAsync"/>'s sequence stamp adds to an
/// envelope that was sized before it: one tag byte plus a ten-byte varint, the maximum /// envelope that was sized before it: one tag byte plus a ten-byte varint, the maximum
/// encoding of a 64-bit protobuf field. The <c>CommandTooLarge</c> pre-check subtracts this /// encoding of a 64-bit protobuf field. The <c>CommandTooLarge</c> pre-check subtracts this
/// from the negotiated frame maximum (IPC-39) because it measures the envelope while /// from the negotiated frame maximum because it measures the envelope while
/// <c>Sequence</c> is still 0 — an unset field costs nothing to encode, so the measured size /// <c>Sequence</c> is still 0 — an unset field costs nothing to encode, so the measured size
/// is a lower bound on the frame the writer emits, and without the allowance an envelope in /// is a lower bound on the frame the writer emits, and without the allowance an envelope in
/// the last eleven bytes of the range would pass the pre-check and then fault the session on /// the last eleven bytes of the range would pass the pre-check and then fault the session on
@@ -505,12 +505,12 @@ public sealed class WorkerClient : IWorkerClient
{ {
await foreach (WorkerEnvelope envelope in _outboundEnvelopes.Reader.ReadAllAsync(_stopCts.Token).ConfigureAwait(false)) await foreach (WorkerEnvelope envelope in _outboundEnvelopes.Reader.ReadAllAsync(_stopCts.Token).ConfigureAwait(false))
{ {
// GWC-28: stamp the sequence at the point of writing, not when the envelope is built. // stamp the sequence at the point of writing, not when the envelope is built.
// Stamping at construction let two concurrent InvokeAsync callers take 1 and 2 and then // Stamping at construction let two concurrent InvokeAsync callers take 1 and 2 and then
// enqueue in the order 2, 1 — non-monotonic on the wire, breaking gateway.md's // enqueue in the order 2, 1 — non-monotonic on the wire, breaking gateway.md's
// "monotonic per sender" contract. This loop is the channel's single consumer // "monotonic per sender" contract. This loop is the channel's single consumer
// (SingleReader = true), so wire order and stamp order are the same thing here and // (SingleReader = true), so wire order and stamp order are the same thing here and
// _nextSequence needs no interlocking. Mirrors the worker's WRK-04 fix. // _nextSequence needs no interlocking. Mirrors the worker's equivalent fix.
envelope.Sequence = unchecked(++_nextSequence); envelope.Sequence = unchecked(++_nextSequence);
await _writer.WriteAsync(envelope, _stopCts.Token).ConfigureAwait(false); await _writer.WriteAsync(envelope, _stopCts.Token).ConfigureAwait(false);
} }
@@ -678,11 +678,11 @@ public sealed class WorkerClient : IWorkerClient
/// faults regardless of how backed up the event path is. It returns <c>false</c> in two cases: /// faults regardless of how backed up the event path is. It returns <c>false</c> in two cases:
/// the staging channel has been completed by one of the three completing paths — /// the staging channel has been completed by one of the three completing paths —
/// <see cref="MarkClosed"/>, <see cref="SetFaulted"/> or <see cref="DisposeAsync"/>, each of /// <see cref="MarkClosed"/>, <see cref="SetFaulted"/> or <see cref="DisposeAsync"/>, each of
/// which enters a terminal state before completing the channels (GWC-32), so the event is /// which enters a terminal state before completing the channels, so the event is
/// safely dropped because the client is already terminal — or the channel is full at its /// safely dropped because the client is already terminal — or the channel is full at its
/// 2 × <see cref="WorkerClientOptions.EventChannelCapacity"/> bound. The latter means the /// 2 × <see cref="WorkerClientOptions.EventChannelCapacity"/> bound. The latter means the
/// consumer has been draining slower than the worker produces for the whole time it took the /// consumer has been draining slower than the worker produces for the whole time it took the
/// worker to emit that many further events, so the client is faulted immediately (GWC-24) — /// worker to emit that many further events, so the client is faulted immediately —
/// <see cref="SetFaulted"/> is non-blocking, so the read loop still never awaits here. The /// <see cref="SetFaulted"/> is non-blocking, so the read loop still never awaits here. The
/// complementary full-stall case (a consumer that stops entirely) is caught earlier by the /// complementary full-stall case (a consumer that stops entirely) is caught earlier by the
/// timed write in <see cref="EnqueueWorkerEventAsync"/>. /// timed write in <see cref="EnqueueWorkerEventAsync"/>.
@@ -753,7 +753,7 @@ public sealed class WorkerClient : IWorkerClient
/// the actionable fix (attach <c>StreamEvents</c> or raise /// the actionable fix (attach <c>StreamEvents</c> or raise
/// <c>MxGateway:Events:QueueCapacity</c>). This is the full-stall half of /// <c>MxGateway:Events:QueueCapacity</c>). This is the full-stall half of
/// the backpressure policy; a consumer that merely drains too slowly is /// the backpressure policy; a consumer that merely drains too slowly is
/// caught by the staging bound in <see cref="StageWorkerEvent"/> (GWC-24). /// caught by the staging bound in <see cref="StageWorkerEvent"/>.
/// Queue depth is not counted here — the event was already counted when it /// Queue depth is not counted here — the event was already counted when it
/// was staged, so moving it between the two channels changes nothing. /// was staged, so moving it between the two channels changes nothing.
/// </summary> /// </summary>
@@ -851,7 +851,7 @@ public sealed class WorkerClient : IWorkerClient
/// <summary> /// <summary>
/// Fails the pending command for a caller whose own token fired — a client deadline shorter /// Fails the pending command for a caller whose own token fired — a client deadline shorter
/// than the gateway's command timeout, or a disconnect that cancelled the RPC's token — and /// than the gateway's command timeout, or a disconnect that cancelled the RPC's token — and
/// forwards a <c>WorkerCancel</c> for the correlation the caller has abandoned (IPC-34). /// forwards a <c>WorkerCancel</c> for the correlation the caller has abandoned.
/// <para> /// <para>
/// Caller cancellation is the common abandonment path, not the rare one, and it leaves the /// Caller cancellation is the common abandonment path, not the rare one, and it leaves the
/// worker in exactly the state the timeout arm's cancel exists for: the correlation is still /// worker in exactly the state the timeout arm's cancel exists for: the correlation is still
@@ -982,7 +982,7 @@ public sealed class WorkerClient : IWorkerClient
{ {
WorkerEnvelope envelope = await _reader.ReadAsync(cancellationToken).ConfigureAwait(false); WorkerEnvelope envelope = await _reader.ReadAsync(cancellationToken).ConfigureAwait(false);
// IPC-35: a fault frame during the handshake is the worker reporting a structured startup // a fault frame during the handshake is the worker reporting a structured startup
// failure — most often MXAccess COM creation (not installed, not registered, access denied) // failure — most often MXAccess COM creation (not installed, not registered, access denied)
// — not a protocol problem. Flattening it to ProtocolViolation discarded the category and // — not a protocol problem. Flattening it to ProtocolViolation discarded the category and
// diagnostic exactly where they matter most: first session open on a freshly deployed host, // diagnostic exactly where they matter most: first session open on a freshly deployed host,
@@ -1303,7 +1303,7 @@ public sealed class WorkerClient : IWorkerClient
string correlationId, string correlationId,
WorkerCommand command) WorkerCommand command)
{ {
// IPC-05: no second clone. MxAccessGrpcMapper.MapCommand already deep-cloned the command // no second clone. MxAccessGrpcMapper.MapCommand already deep-cloned the command
// out of the caller-owned gRPC MxCommandRequest, so this WorkerCommand is a fresh graph // out of the caller-owned gRPC MxCommandRequest, so this WorkerCommand is a fresh graph
// owned by the invoke pipeline and referenced by no other consumer. The envelope is built // owned by the invoke pipeline and referenced by no other consumer. The envelope is built
// and owned entirely inside WorkerClient and the command is not touched again after this // and owned entirely inside WorkerClient and the command is not touched again after this
@@ -1340,7 +1340,7 @@ public sealed class WorkerClient : IWorkerClient
{ {
// Sequence is deliberately left unset here: WriteLoopAsync stamps it immediately before the // Sequence is deliberately left unset here: WriteLoopAsync stamps it immediately before the
// frame goes out, so the numbers are monotonic in wire order however the callers interleave // frame goes out, so the numbers are monotonic in wire order however the callers interleave
// between construction and enqueue (GWC-28, mirroring the worker's WRK-04 fix). // between construction and enqueue (mirroring the worker's fix).
WorkerEnvelope envelope = new() WorkerEnvelope envelope = new()
{ {
ProtocolVersion = _connection.FrameOptions.ProtocolVersion, ProtocolVersion = _connection.FrameOptions.ProtocolVersion,
@@ -19,7 +19,7 @@ public sealed class WorkerFrameReader
private readonly WorkerFrameProtocolOptions _options; private readonly WorkerFrameProtocolOptions _options;
private readonly Stream _stream; private readonly Stream _stream;
// Reused across frames rather than allocated per read (GWC-30). Safe because ReadAsync is // Reused across frames rather than allocated per read. Safe because ReadAsync is
// single-consumer by construction; the prefix is fully overwritten by every read. // single-consumer by construction; the prefix is fully overwritten by every read.
private readonly byte[] _lengthPrefix = new byte[sizeof(uint)]; private readonly byte[] _lengthPrefix = new byte[sizeof(uint)];
@@ -61,8 +61,8 @@ public sealed class WorkerFrameReader
} }
// Rent the payload buffer from the shared pool rather than allocating a fresh byte[] per // Rent the payload buffer from the shared pool rather than allocating a fresh byte[] per
// frame; large event frames near the cap would otherwise allocate an LOH buffer each time // frame; large event frames near the cap would otherwise allocate an LOH buffer each time.
// (IPC-14). ParseFrom copies whatever it needs into the parsed message, so the rented buffer // ParseFrom copies whatever it needs into the parsed message, so the rented buffer
// can be returned as soon as parsing completes without the envelope aliasing it. The rented // can be returned as soon as parsing completes without the envelope aliasing it. The rented
// buffer may be larger than requested, so the read and parse are bounded to length. // buffer may be larger than requested, so the read and parse are bounded to length.
int length = checked((int)payloadLength); int length = checked((int)payloadLength);
@@ -57,7 +57,7 @@ public sealed class WorkerFrameWriter
// Serialize once into a single pooled buffer that carries the 4-byte little-endian length // Serialize once into a single pooled buffer that carries the 4-byte little-endian length
// prefix followed by the payload, then issue one stream write. This avoids a second // prefix followed by the payload, then issue one stream write. This avoids a second
// serialization pass (ToByteArray re-runs CalculateSize), a separate prefix array and its // serialization pass (ToByteArray re-runs CalculateSize), a separate prefix array and its
// own write, and any per-frame heap allocation (GWC-08, IPC-13). The rented buffer may be // own write, and any per-frame heap allocation. The rented buffer may be
// larger than requested, so only the first frameLength bytes are ever written. // larger than requested, so only the first frameLength bytes are ever written.
int frameLength = sizeof(uint) + payloadLength; int frameLength = sizeof(uint) + payloadLength;
byte[] frame = ArrayPool<byte>.Shared.Rent(frameLength); byte[] frame = ArrayPool<byte>.Shared.Rent(frameLength);
@@ -10,10 +10,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" /> <PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.2.1" /> <PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.3.0" />
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.2.1" /> <PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.3.0" />
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.2.1" /> <PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.3.0" />
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.2.1" /> <PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.3.0" />
<PackageReference Include="ZB.MOM.WW.Audit" Version="0.1.0" /> <PackageReference Include="ZB.MOM.WW.Audit" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Theme" Version="0.4.1" /> <PackageReference Include="ZB.MOM.WW.Theme" Version="0.4.1" />
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" /> <PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
@@ -19,7 +19,7 @@ public sealed class AlarmWatchListResolverTests
/// <summary> /// <summary>
/// Resolves and projects to the target list. These cases assert composition rules, which /// Resolves and projects to the target list. These cases assert composition rules, which
/// live in <see cref="AlarmWatchListResolution.Targets"/>; the discovery-provenance fields /// live in <see cref="AlarmWatchListResolution.Targets"/>; the discovery-provenance fields
/// the resolution also carries (GWC-37) are asserted by their own cases below. /// the resolution also carries are asserted by their own cases below.
/// </summary> /// </summary>
/// <param name="resolver">The resolver under test.</param> /// <param name="resolver">The resolver under test.</param>
/// <param name="options">Alarm options to resolve against.</param> /// <param name="options">Alarm options to resolve against.</param>
@@ -259,7 +259,7 @@ public sealed class AlarmWatchListResolverTests
} }
/// <summary> /// <summary>
/// GWC-37: swallowing the discovery failure is right for the resolver but leaves the /// Swallowing the discovery failure is right for the resolver but leaves the
/// caller unable to tell "discovery produced nothing" from "nothing was configured", which /// caller unable to tell "discovery produced nothing" from "nothing was configured", which
/// is why the empty watch-list could sit unnoticed for a monitor's whole life. The /// is why the empty watch-list could sit unnoticed for a monitor's whole life. The
/// resolution therefore reports whether discovery was attempted, whether it failed, and /// resolution therefore reports whether discovery was attempted, whether it failed, and
@@ -284,7 +284,7 @@ public sealed class AlarmWatchListResolverTests
/// <summary> /// <summary>
/// The complement of the case above: discovery disabled is not discovery failed. An empty /// The complement of the case above: discovery disabled is not discovery failed. An empty
/// watch-list here means the deployment did not ask for subtag coverage, which the monitor /// watch-list here means the deployment did not ask for subtag coverage, which the monitor
/// must not report as a degraded standby (GWC-37). /// must not report as a degraded standby.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
@@ -13,7 +13,7 @@ using ZB.MOM.WW.MxGateway.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Tests.Alarms; namespace ZB.MOM.WW.MxGateway.Tests.Alarms;
/// <summary> /// <summary>
/// GWC-26 regression tests for the alarm monitor's startup ordering. Unlike the /// Regression tests for the alarm monitor's startup ordering. Unlike the
/// sibling alarm-monitor tests, the session manager here hands the monitor a REAL /// sibling alarm-monitor tests, the session manager here hands the monitor a REAL
/// <see cref="GatewaySession"/> that is driven to Ready with a dashboard mirror, so the /// <see cref="GatewaySession"/> that is driven to Ready with a dashboard mirror, so the
/// distributor pump is already running when the monitor attaches — the production /// distributor pump is already running when the monitor attaches — the production
@@ -156,7 +156,7 @@ public sealed class GatewayAlarmMonitorAttachOrderTests
} }
/// <summary> /// <summary>
/// GWC-35: an acknowledged cached alarm followed by an active snapshot with a newer /// An acknowledged cached alarm followed by an active snapshot with a newer
/// original-raise timestamp is a new alarm instance, not an acknowledgement reversal. The /// original-raise timestamp is a new alarm instance, not an acknowledgement reversal. The
/// reconcile repair must broadcast a Raise carrying the incoming instance identity. /// reconcile repair must broadcast a Raise carrying the incoming instance identity.
/// </summary> /// </summary>
@@ -216,7 +216,7 @@ public sealed class GatewayAlarmMonitorAttachOrderTests
} }
/// <summary> /// <summary>
/// NEXT-03. A reconcile Raise repair applied while the matching live Raise is still /// A reconcile Raise repair applied while the matching live Raise is still
/// buffered must not double-broadcast: the live transition carrying the same worker /// buffered must not double-broadcast: the live transition carrying the same worker
/// timestamp and resulting state the cache already holds is a duplicate and is suppressed. /// timestamp and resulting state the cache already holds is a duplicate and is suppressed.
/// </summary> /// </summary>
@@ -275,7 +275,7 @@ public sealed class GatewayAlarmMonitorAttachOrderTests
} }
/// <summary> /// <summary>
/// NEXT-03, the Clear leg. A reconcile Clear repair removes the cache entry, so the /// The Clear leg of the dedup. A reconcile Clear repair removes the cache entry, so the
/// buffered live Clear is deduped through the tombstone keyed on the instance's original /// buffered live Clear is deduped through the tombstone keyed on the instance's original
/// raise timestamp — and a genuinely new raise/clear cycle is never swallowed. /// raise timestamp — and a genuinely new raise/clear cycle is never swallowed.
/// </summary> /// </summary>
@@ -490,7 +490,7 @@ public sealed class GatewayAlarmMonitorAttachOrderTests
SourceProvider = AlarmProviderMode.Alarmmgr, SourceProvider = AlarmProviderMode.Alarmmgr,
}; };
// Snapshot carrying the worker-side identity markers the NEXT-03 dedup compares on. // Snapshot carrying the worker-side identity markers the dedup compares on.
private static ActiveAlarmSnapshot SnapshotAt( private static ActiveAlarmSnapshot SnapshotAt(
AlarmConditionState state, AlarmConditionState state,
Timestamp lastTransition, Timestamp lastTransition,
@@ -502,7 +502,7 @@ public sealed class GatewayAlarmMonitorAttachOrderTests
return snapshot; return snapshot;
} }
// Live transition with explicit worker timestamps, for driving the NEXT-03 dedup. // Live transition with explicit worker timestamps, for driving the dedup.
private static MxEvent TransitionAt( private static MxEvent TransitionAt(
ulong sequence, ulong sequence,
AlarmTransitionKind kind, AlarmTransitionKind kind,
@@ -624,7 +624,7 @@ public sealed class GatewayAlarmMonitorProviderModeTests
} }
/// <summary> /// <summary>
/// GWC-37: a Galaxy Repository outage at startup used to leave one <c>LogWarning</c> and an /// A Galaxy Repository outage at startup used to leave one <c>LogWarning</c> and an
/// empty subtag watch-list that stayed empty for the monitor's whole life — invisible until /// empty subtag watch-list that stayed empty for the monitor's whole life — invisible until
/// a failover found the standby covering nothing. The condition is now a standing verdict /// a failover found the standby covering nothing. The condition is now a standing verdict
/// on the provider status, so it reaches the alarm feed, the dashboard badge and every /// on the provider status, so it reaches the alarm feed, the dashboard badge and every
@@ -661,7 +661,7 @@ public sealed class GatewayAlarmMonitorProviderModeTests
} }
/// <summary> /// <summary>
/// GWC-37: the failover edge is the moment the watch-list stops being theoretical, so the /// The failover edge is the moment the watch-list stops being theoretical, so the
/// monitor re-resolves it there, hot-swaps the recovered list through /// monitor re-resolves it there, hot-swaps the recovered list through
/// <c>UpdateAlarmWatchList</c>, and publishes the acknowledged verdict on the same status /// <c>UpdateAlarmWatchList</c>, and publishes the acknowledged verdict on the same status
/// frame the failover produces. The update must precede the status frame so the frame never /// frame the failover produces. The update must precede the status frame so the frame never
@@ -748,6 +748,7 @@ public sealed class GatewayAlarmMonitorProviderModeTests
} }
/// <summary>Periodic reconciliation applies a recovered watch list once and suppresses an unchanged resolution on the next cadence.</summary> /// <summary>Periodic reconciliation applies a recovered watch list once and suppresses an unchanged resolution on the next cadence.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WatchListRefresh_PeriodicRecovery_SendsExactlyOneMaterialUpdate() public async Task WatchListRefresh_PeriodicRecovery_SendsExactlyOneMaterialUpdate()
{ {
@@ -774,6 +775,7 @@ public sealed class GatewayAlarmMonitorProviderModeTests
} }
/// <summary>A rejected refresh keeps the old fingerprint and degraded verdict so the same material change is retried.</summary> /// <summary>A rejected refresh keeps the old fingerprint and degraded verdict so the same material change is retried.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WatchListRefresh_WorkerRejects_RetainsFingerprintAndRetriesSameTargets() public async Task WatchListRefresh_WorkerRejects_RetainsFingerprintAndRetriesSameTargets()
{ {
@@ -937,7 +939,7 @@ public sealed class GatewayAlarmMonitorProviderModeTests
/// <see cref="IAlarmWatchListResolver"/> that hands out a scripted sequence of /// <see cref="IAlarmWatchListResolver"/> that hands out a scripted sequence of
/// resolutions — one per call, the last repeating — and counts the calls, so a test can /// resolutions — one per call, the last repeating — and counts the calls, so a test can
/// drive "discovery was down at subscribe and back up by the failover" and assert that the /// drive "discovery was down at subscribe and back up by the failover" and assert that the
/// second resolution happened at all (GWC-37). /// second resolution happened at all.
/// </summary> /// </summary>
/// <param name="script">Resolutions to return, in call order.</param> /// <param name="script">Resolutions to return, in call order.</param>
private sealed class ScriptedWatchListResolver(params AlarmWatchListResolution[] script) : IAlarmWatchListResolver private sealed class ScriptedWatchListResolver(params AlarmWatchListResolution[] script) : IAlarmWatchListResolver
@@ -997,6 +999,8 @@ public sealed class GatewayAlarmMonitorProviderModeTests
_events.Writer.TryWrite(new WorkerEvent { Event = mxEvent }); _events.Writer.TryWrite(new WorkerEvent { Event = mxEvent });
/// <summary>Pushes a provider-mode change onto the worker event stream.</summary> /// <summary>Pushes a provider-mode change onto the worker event stream.</summary>
/// <param name="mode">The provider mode being reported.</param>
/// <param name="reason">The reason for the mode change.</param>
public void EmitModeChange(AlarmProviderMode mode, string reason) => EmitEvent(new MxEvent public void EmitModeChange(AlarmProviderMode mode, string reason) => EmitEvent(new MxEvent
{ {
OnAlarmProviderModeChanged = new OnAlarmProviderModeChangedEvent OnAlarmProviderModeChanged = new OnAlarmProviderModeChangedEvent
@@ -1008,6 +1012,9 @@ public sealed class GatewayAlarmMonitorProviderModeTests
}); });
/// <summary>Waits until at least the requested number of update commands have arrived.</summary> /// <summary>Waits until at least the requested number of update commands have arrived.</summary>
/// <param name="count">The minimum number of update commands to wait for.</param>
/// <param name="timeout">The maximum time to wait.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task WaitForUpdateCountAsync(int count, TimeSpan timeout) public async Task WaitForUpdateCountAsync(int count, TimeSpan timeout)
{ {
await WaitUntilAsync(() => UpdateCommands.Count >= count, timeout); await WaitUntilAsync(() => UpdateCommands.Count >= count, timeout);
@@ -8,7 +8,7 @@ using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Tests.Configuration; namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
/// <summary> /// <summary>
/// Gateway-owned validation of the shared <see cref="GalaxyRepositoryOptions"/> (SEC-33): the Galaxy /// Gateway-owned validation of the shared <see cref="GalaxyRepositoryOptions"/>: the Galaxy
/// package binds the options but ships no validator, so the gateway rejects a persisted snapshot /// package binds the options but ships no validator, so the gateway rejects a persisted snapshot
/// whose path is blank, invalid, or non-rooted on the running host, and supplies a rooted per-OS /// whose path is blank, invalid, or non-rooted on the running host, and supplies a rooted per-OS
/// default when the shipped config leaves the path blank. /// default when the shipped config leaves the path blank.
@@ -109,7 +109,7 @@ public sealed class GalaxyRepositoryOptionsValidatorTests
/// <summary> /// <summary>
/// Verifies the gateway supplies a rooted per-OS default when the shipped config leaves /// Verifies the gateway supplies a rooted per-OS default when the shipped config leaves
/// SnapshotCachePath blank, so the removed appsettings literal is not needed and validation /// SnapshotCachePath blank, so the removed appsettings literal is not needed and validation
/// passes on any host (SEC-33). Resolving the options triggers the registered validator. /// passes on any host. Resolving the options triggers the registered validator.
/// </summary> /// </summary>
[Fact] [Fact]
public void Build_DefaultsBlankSnapshotCachePathToRootedDefault() public void Build_DefaultsBlankSnapshotCachePathToRootedDefault()
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The projection is hand-written member by member, so an option that exists and validates can /// The projection is hand-written member by member, so an option that exists and validates can
/// still be invisible on the settings page — which is exactly what happened to the two SEC-25 /// still be invisible on the settings page — which is exactly what happened to the two
/// dashboard-ACL options. These tests pin the dashboard-tag members to the options they come /// dashboard-ACL options. These tests pin the dashboard-tag members to the options they come
/// from; the redaction assertions pin the opposite invariant, that the two secret-bearing members /// from; the redaction assertions pin the opposite invariant, that the two secret-bearing members
/// are the only ones masked. /// are the only ones masked.
@@ -86,7 +86,7 @@ public sealed class GatewayOptionsTests
["MxGateway:Events:QueueCapacity"] = "256", ["MxGateway:Events:QueueCapacity"] = "256",
["MxGateway:Dashboard:Enabled"] = "false", ["MxGateway:Dashboard:Enabled"] = "false",
["MxGateway:Protocol:MaxGrpcMessageBytes"] = "8388608", ["MxGateway:Protocol:MaxGrpcMessageBytes"] = "8388608",
// The frame-headroom rule is two-sided (IPC-33): the worker-frame maximum must sit // The frame-headroom rule is two-sided: the worker-frame maximum must sit
// between one and two 64 KiB envelope reserves above the gRPC cap, so moving the // between one and two 64 KiB envelope reserves above the gRPC cap, so moving the
// gRPC cap without moving this one no longer validates. Kept at one reserve above, // gRPC cap without moving this one no longer validates. Kept at one reserve above,
// the same shape as the shipped default. // the same shape as the shipped default.
@@ -175,7 +175,7 @@ public sealed class GatewayOptionsTests
/// but only the binder decides whether that instance is populated in place or replaced by a /// but only the binder decides whether that instance is populated in place or replaced by a
/// default-comparer one. Asserting the comparer on a hand-constructed <see cref="DashboardOptions"/> /// default-comparer one. Asserting the comparer on a hand-constructed <see cref="DashboardOptions"/>
/// would prove nothing about the configured path; a mis-cased LDAP group name from the directory /// would prove nothing about the configured path; a mis-cased LDAP group name from the directory
/// would then silently grant no tags, and the SEC-25 ACL would deny with no diagnostic. /// would then silently grant no tags, and the dashboard session ACL would deny with no diagnostic.
/// </remarks> /// </remarks>
[Fact] [Fact]
public void DashboardOptions_GroupToTag_BindsCaseInsensitively() public void DashboardOptions_GroupToTag_BindsCaseInsensitively()
@@ -778,7 +778,7 @@ public sealed class GatewayOptionsValidatorTests
} }
/// <summary> /// <summary>
/// Verifies rooting is host-meaningful (SEC-33): a Windows drive-qualified literal fails on a /// Verifies rooting is host-meaningful: a Windows drive-qualified literal fails on a
/// Unix host (where it is not rooted) rather than being blessed and written as a junk-named /// Unix host (where it is not rooted) rather than being blessed and written as a junk-named
/// relative file. On Windows the same literal is genuinely rooted and passes. /// relative file. On Windows the same literal is genuinely rooted and passes.
/// </summary> /// </summary>
@@ -805,7 +805,7 @@ public sealed class GatewayOptionsValidatorTests
} }
/// <summary> /// <summary>
/// Verifies MxGateway:Events:QueueCapacity above int.MaxValue/2 fails (GWC-24 rider): the value /// Verifies MxGateway:Events:QueueCapacity above int.MaxValue/2 fails: the value
/// flows into WorkerClient as checked(2 * EventChannelCapacity), which would otherwise overflow. /// flows into WorkerClient as checked(2 * EventChannelCapacity), which would otherwise overflow.
/// </summary> /// </summary>
[Fact] [Fact]
@@ -1078,7 +1078,7 @@ public sealed class GatewayOptionsValidatorTests
result.Failures!, result.Failures!,
f => f.Contains("MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.")); f => f.Contains("MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled."));
// SEC-36: the message must steer the operator to the two supported out-of-band channels // The message must steer the operator to the two supported out-of-band channels
// (dev user-secrets, deployed env var) so a blanked/unresolved credential never gets // (dev user-secrets, deployed env var) so a blanked/unresolved credential never gets
// "fixed" by re-committing a value. // "fixed" by re-committing a value.
Assert.Contains("dotnet user-secrets set", failure); Assert.Contains("dotnet user-secrets set", failure);
@@ -1258,7 +1258,7 @@ public sealed class GatewayOptionsValidatorTests
} }
/// <summary> /// <summary>
/// Verifies the headroom rule's upper half (IPC-33): a worker-frame maximum more than two /// Verifies the headroom rule's upper half: a worker-frame maximum more than two
/// envelope reserves above the gRPC cap is rejected. The worker budgets a DrainEvents reply at /// envelope reserves above the gRPC cap is rejected. The worker budgets a DrainEvents reply at
/// the frame maximum less two reserves and the gateway returns that same reply through /// the frame maximum less two reserves and the gateway returns that same reply through
/// <c>Invoke</c>, so a wider gap lets the worker pack a reply the pipe carries and the gRPC send /// <c>Invoke</c>, so a wider gap lets the worker pack a reply the pipe carries and the gRPC send
@@ -18,6 +18,10 @@ namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
/// </summary> /// </summary>
public sealed class PreHostSecretExpansionTests public sealed class PreHostSecretExpansionTests
{ {
/// <summary>
/// A seeded secret reference in configuration resolves to its plaintext.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ExpandConfiguration_SeededReference_ResolvesToPlaintext() public async Task ExpandConfiguration_SeededReference_ResolvesToPlaintext()
{ {
@@ -37,6 +41,7 @@ public sealed class PreHostSecretExpansionTests
/// <c>${secret:ldap/mxgateway/bind}</c>. Proves that exact reference at that exact key resolves to /// <c>${secret:ldap/mxgateway/bind}</c>. Proves that exact reference at that exact key resolves to
/// the seeded plaintext through the same pre-host expander the host runs. /// the seeded plaintext through the same pre-host expander the host runs.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ExpandConfiguration_LdapBindReference_ResolvesToSeededPassword() public async Task ExpandConfiguration_LdapBindReference_ResolvesToSeededPassword()
{ {
@@ -52,6 +57,10 @@ public sealed class PreHostSecretExpansionTests
Assert.Equal(bindPassword, config["MxGateway:Ldap:ServiceAccountPassword"]); Assert.Equal(bindPassword, config["MxGateway:Ldap:ServiceAccountPassword"]);
} }
/// <summary>
/// A reference to an absent secret throws instead of leaving the token in place.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ExpandConfiguration_MissingReference_ThrowsFailClosed() public async Task ExpandConfiguration_MissingReference_ThrowsFailClosed()
{ {
@@ -81,8 +90,11 @@ public sealed class PreHostSecretExpansionTests
_envVarName = envVarName; _envVarName = envVarName;
} }
/// <summary>Gets the secret resolver from the fixture's DI graph.</summary>
public ISecretResolver Resolver => _provider.GetRequiredService<ISecretResolver>(); public ISecretResolver Resolver => _provider.GetRequiredService<ISecretResolver>();
/// <summary>Creates a fixture with a unique SQLite store and master-key environment variable.</summary>
/// <returns>The migrated fixture.</returns>
public static SecretsFixture Create() public static SecretsFixture Create()
{ {
string unique = Guid.NewGuid().ToString("N"); string unique = Guid.NewGuid().ToString("N");
@@ -111,8 +123,13 @@ public sealed class PreHostSecretExpansionTests
return new SecretsFixture(provider, sqlitePath, envVarName); return new SecretsFixture(provider, sqlitePath, envVarName);
} }
// Seals a plaintext value under a fresh DEK and upserts it — the same store/cipher pair the /// <summary>
// CLI 'secret set' verb uses. /// Seals a plaintext value under a fresh DEK and upserts it, using the same store/cipher pair
/// the CLI 'secret set' verb uses.
/// </summary>
/// <param name="name">The secret name.</param>
/// <param name="plaintext">The plaintext value to store.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task SeedAsync(string name, string plaintext) public async Task SeedAsync(string name, string plaintext)
{ {
var secretName = new SecretName(name); var secretName = new SecretName(name);
@@ -122,11 +139,15 @@ public sealed class PreHostSecretExpansionTests
await store.UpsertAsync(sealed_, default); await store.UpsertAsync(sealed_, default);
} }
/// <summary>Builds an in-memory configuration from the given key/value pairs.</summary>
/// <param name="entries">The configuration entries.</param>
/// <returns>The built configuration root.</returns>
public IConfigurationRoot BuildConfig(params (string Key, string Value)[] entries) => public IConfigurationRoot BuildConfig(params (string Key, string Value)[] entries) =>
new ConfigurationBuilder() new ConfigurationBuilder()
.AddInMemoryCollection(entries.ToDictionary(e => e.Key, e => (string?)e.Value)) .AddInMemoryCollection(entries.ToDictionary(e => e.Key, e => (string?)e.Value))
.Build(); .Build();
/// <summary>Disposes the service provider and clears the test environment variable.</summary>
public void Dispose() public void Dispose()
{ {
_provider.Dispose(); _provider.Dispose();
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
/// <summary> /// <summary>
/// Renders <see cref="MainLayout"/> and asserts which policy-gated links the side rail emits — /// Renders <see cref="MainLayout"/> and asserts which policy-gated links the side rail emits —
/// Secrets (<c>secrets:manage</c>) and, since SEC-40, Alarms /// Secrets (<c>secrets:manage</c>) and Alarms
/// (<see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/>). /// (<see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/>).
/// </summary> /// </summary>
/// <remarks> /// <remarks>
@@ -100,7 +100,7 @@ public sealed class SecretsNavRenderTests
} }
/// <summary> /// <summary>
/// SEC-40's nav half: an anonymous caller — the read-only localhost path — is not offered the /// The nav half: an anonymous caller — the read-only localhost path — is not offered the
/// Alarms link, because the page and hub now refuse them. Same reasoning as the Secrets /// Alarms link, because the page and hub now refuse them. Same reasoning as the Secrets
/// negative above: this is the assertion that distinguishes a gate from an inert one. /// negative above: this is the assertion that distinguishes a gate from an inert one.
/// </summary> /// </summary>
@@ -114,7 +114,7 @@ public sealed class SecretsNavRenderTests
} }
/// <summary> /// <summary>
/// The control for that negative: an authenticated Viewer still gets the Alarms link. SEC-40 /// The control for that negative: an authenticated Viewer still gets the Alarms link. The Alarms gate
/// excludes the environment bypasses, not Viewers — alarm metadata stays galaxy-wide for a /// excludes the environment bypasses, not Viewers — alarm metadata stays galaxy-wide for a
/// real dashboard role because the central monitor carries no per-session provenance. /// real dashboard role because the central monitor carries no per-session provenance.
/// </summary> /// </summary>
@@ -135,7 +135,7 @@ public sealed class SecretsNavRenderTests
{ {
options.AddSecretsAuthorization(); options.AddSecretsAuthorization();
// The rail's Alarms AuthorizeView resolves this by name; an unregistered policy throws // The rail's Alarms AuthorizeView resolves this by name; an unregistered policy throws
// rather than denying, so it must be present for any rail render (SEC-40). // rather than denying, so it must be present for any rail render.
options.AddPolicy( options.AddPolicy(
DashboardAuthenticationDefaults.AlarmsPolicy, DashboardAuthenticationDefaults.AlarmsPolicy,
policy => policy.AddRequirements(AuthenticatedDashboardRoleRequirement.AnyDashboardRole)); policy => policy.AddRequirements(AuthenticatedDashboardRoleRequirement.AnyDashboardRole));
@@ -163,6 +163,7 @@ public sealed class SecretsNavRenderTests
// vacuously. // vacuously.
private sealed class StubAuthenticationStateProvider(string[] roles) : AuthenticationStateProvider private sealed class StubAuthenticationStateProvider(string[] roles) : AuthenticationStateProvider
{ {
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync() public override Task<AuthenticationState> GetAuthenticationStateAsync()
{ {
ClaimsIdentity identity = roles.Length == 0 ClaimsIdentity identity = roles.Length == 0
@@ -181,6 +182,7 @@ public sealed class SecretsNavRenderTests
// under static rendering. // under static rendering.
private sealed class StubNavigationManager : NavigationManager private sealed class StubNavigationManager : NavigationManager
{ {
/// <summary>Initializes the stub with a fixed localhost base URI.</summary>
public StubNavigationManager() => Initialize("https://localhost/", "https://localhost/"); public StubNavigationManager() => Initialize("https://localhost/", "https://localhost/");
} }
} }
@@ -11,7 +11,7 @@ using HtmlRenderer = Microsoft.AspNetCore.Components.Web.HtmlRenderer;
namespace ZB.MOM.WW.MxGateway.Tests.Dashboard; namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
/// <summary> /// <summary>
/// Renders <see cref="SettingsPage"/> and asserts the two SEC-25 dashboard-ACL options — /// Renders <see cref="SettingsPage"/> and asserts the two dashboard-ACL options —
/// <c>Dashboard:GroupToTag</c> and <c>Dashboard:UntaggedSessionVisibility</c> — reach the page. /// <c>Dashboard:GroupToTag</c> and <c>Dashboard:UntaggedSessionVisibility</c> — reach the page.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
@@ -16,7 +16,7 @@ using AuthenticationOptions = ZB.MOM.WW.MxGateway.Server.Configuration.Authentic
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary> /// <summary>
/// Covers SEC-40: the alarm feed requires a real authenticated dashboard role, so the read-only /// Covers the rule that the alarm feed requires a real authenticated dashboard role, so the read-only
/// environment bypasses (<c>Authentication:Mode=Disabled</c>, anonymous localhost) no longer /// environment bypasses (<c>Authentication:Mode=Disabled</c>, anonymous localhost) no longer
/// receive galaxy-wide alarm metadata. /// receive galaxy-wide alarm metadata.
/// </summary> /// </summary>
@@ -65,7 +65,7 @@ public sealed class AlarmFeedAuthorizationTests
/// <summary> /// <summary>
/// An authenticated Viewer — the ordinary LDAP-logged-in dashboard user — does satisfy the /// An authenticated Viewer — the ordinary LDAP-logged-in dashboard user — does satisfy the
/// alarm requirement: SEC-40 excludes the environment bypasses, not Viewers. Alarm metadata /// alarm requirement: the rule excludes the environment bypasses, not Viewers. Alarm metadata
/// stays galaxy-wide for them because the monitor is gateway-central and carries no /// stays galaxy-wide for them because the monitor is gateway-central and carries no
/// per-session provenance to tag-scope by. /// per-session provenance to tag-scope by.
/// </summary> /// </summary>
@@ -11,7 +11,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary> /// <summary>
/// Verifies that <see cref="AlarmsHubPublisher"/> honours /// Verifies that <see cref="AlarmsHubPublisher"/> honours
/// <c>MxGateway:Dashboard:ShowTagValues</c> (TST-16): the alarm value fields of /// <c>MxGateway:Dashboard:ShowTagValues</c>: the alarm value fields of
/// both value-bearing payload arms are stripped from the copy broadcast to /// both value-bearing payload arms are stripped from the copy broadcast to
/// browser clients when the flag is off, present when it is on, and the source /// browser clients when the flag is off, present when it is on, and the source
/// <see cref="AlarmFeedMessage"/> — shared with the gRPC <c>StreamAlarms</c> /// <see cref="AlarmFeedMessage"/> — shared with the gRPC <c>StreamAlarms</c>
@@ -286,22 +286,48 @@ public sealed class AlarmsHubPublisherTests
/// <summary>Gets the capturing client proxy shared by this fake.</summary> /// <summary>Gets the capturing client proxy shared by this fake.</summary>
public CapturingClientProxy GroupProxy { get; } = new(); public CapturingClientProxy GroupProxy { get; } = new();
/// <summary>Gets the shared capturing proxy for all clients.</summary>
public IClientProxy All => GroupProxy; public IClientProxy All => GroupProxy;
/// <summary>Returns the shared capturing proxy, ignoring the exclusions.</summary>
/// <param name="excludedConnectionIds">Connection ids to exclude.</param>
/// <returns>The shared capturing proxy.</returns>
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => GroupProxy; public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => GroupProxy;
/// <summary>Returns the shared capturing proxy for one connection.</summary>
/// <param name="connectionId">The target connection id.</param>
/// <returns>The shared capturing proxy.</returns>
public IClientProxy Client(string connectionId) => GroupProxy; public IClientProxy Client(string connectionId) => GroupProxy;
/// <summary>Returns the shared capturing proxy for several connections.</summary>
/// <param name="connectionIds">The target connection ids.</param>
/// <returns>The shared capturing proxy.</returns>
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => GroupProxy; public IClientProxy Clients(IReadOnlyList<string> connectionIds) => GroupProxy;
/// <summary>Returns the shared capturing proxy for one group.</summary>
/// <param name="groupName">The target group name.</param>
/// <returns>The shared capturing proxy.</returns>
public IClientProxy Group(string groupName) => GroupProxy; public IClientProxy Group(string groupName) => GroupProxy;
/// <summary>Returns the shared capturing proxy for a group, ignoring the exclusions.</summary>
/// <param name="groupName">The target group name.</param>
/// <param name="excludedConnectionIds">Connection ids to exclude.</param>
/// <returns>The shared capturing proxy.</returns>
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => GroupProxy; public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => GroupProxy;
/// <summary>Returns the shared capturing proxy for several groups.</summary>
/// <param name="groupNames">The target group names.</param>
/// <returns>The shared capturing proxy.</returns>
public IClientProxy Groups(IReadOnlyList<string> groupNames) => GroupProxy; public IClientProxy Groups(IReadOnlyList<string> groupNames) => GroupProxy;
/// <summary>Returns the shared capturing proxy for one user.</summary>
/// <param name="userId">The target user id.</param>
/// <returns>The shared capturing proxy.</returns>
public IClientProxy User(string userId) => GroupProxy; public IClientProxy User(string userId) => GroupProxy;
/// <summary>Returns the shared capturing proxy for several users.</summary>
/// <param name="userIds">The target user ids.</param>
/// <returns>The shared capturing proxy.</returns>
public IClientProxy Users(IReadOnlyList<string> userIds) => GroupProxy; public IClientProxy Users(IReadOnlyList<string> userIds) => GroupProxy;
} }
@@ -342,9 +368,19 @@ public sealed class AlarmsHubPublisherTests
private sealed class NoopGroupManager : IGroupManager private sealed class NoopGroupManager : IGroupManager
{ {
/// <summary>Ignores the membership change and completes synchronously.</summary>
/// <param name="connectionId">The connection being added.</param>
/// <param name="groupName">The group the connection joins.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A completed task.</returns>
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
=> Task.CompletedTask; => Task.CompletedTask;
/// <summary>Ignores the removal and completes synchronously.</summary>
/// <param name="connectionId">The connection being removed.</param>
/// <param name="groupName">The group the connection leaves.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A completed task.</returns>
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
=> Task.CompletedTask; => Task.CompletedTask;
} }
@@ -180,7 +180,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
/// <summary> /// <summary>
/// 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 map to the /// <c>Degraded || Mode==Subtag</c> guard's second, independent branch) must map to the
/// amber badge, but not to the failover label: GWC-37 gives that combination a meaning — /// amber badge, but not to the failover label: that combination has its own meaning —
/// the alarm manager is serving and the subtag standby behind it covers nothing — so /// the alarm manager is serving and the subtag standby behind it covers nothing — so
/// reporting "Subtag monitoring (degraded)" would name the wrong live provider. /// reporting "Subtag monitoring (degraded)" would name the wrong live provider.
/// </summary> /// </summary>
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary> /// <summary>
/// Verifies that <see cref="DashboardEventBroadcaster"/> honours /// Verifies that <see cref="DashboardEventBroadcaster"/> honours
/// <c>MxGateway:Dashboard:ShowTagValues</c> (SEC-25): tag values are stripped /// <c>MxGateway:Dashboard:ShowTagValues</c>: tag values are stripped
/// from the mirrored copy when the flag is off, present when it is on, and the /// from the mirrored copy when the flag is off, present when it is on, and the
/// shared source event is never mutated. Also verifies the viewer gate — the /// shared source event is never mutated. Also verifies the viewer gate — the
/// mirror does no work at all for a session nobody is watching. /// mirror does no work at all for a session nobody is watching.
@@ -325,22 +325,48 @@ public sealed class DashboardEventBroadcasterTests
/// <summary>Gets the capturing client proxy shared by this fake.</summary> /// <summary>Gets the capturing client proxy shared by this fake.</summary>
public CapturingClientProxy GroupProxy { get; } = new(); public CapturingClientProxy GroupProxy { get; } = new();
/// <summary>Gets the shared capturing proxy for all connections.</summary>
public IClientProxy All => GroupProxy; public IClientProxy All => GroupProxy;
/// <summary>Returns the shared capturing proxy, ignoring exclusions.</summary>
/// <param name="excludedConnectionIds">Ignored.</param>
/// <returns>The shared capturing proxy.</returns>
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => GroupProxy; public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => GroupProxy;
/// <summary>Returns the shared capturing proxy for any connection.</summary>
/// <param name="connectionId">Ignored.</param>
/// <returns>The shared capturing proxy.</returns>
public IClientProxy Client(string connectionId) => GroupProxy; public IClientProxy Client(string connectionId) => GroupProxy;
/// <summary>Returns the shared capturing proxy for any connection set.</summary>
/// <param name="connectionIds">Ignored.</param>
/// <returns>The shared capturing proxy.</returns>
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => GroupProxy; public IClientProxy Clients(IReadOnlyList<string> connectionIds) => GroupProxy;
/// <summary>Returns the shared capturing proxy for any group.</summary>
/// <param name="groupName">Ignored.</param>
/// <returns>The shared capturing proxy.</returns>
public IClientProxy Group(string groupName) => GroupProxy; public IClientProxy Group(string groupName) => GroupProxy;
/// <summary>Returns the shared capturing proxy for any group, ignoring exclusions.</summary>
/// <param name="groupName">Ignored.</param>
/// <param name="excludedConnectionIds">Ignored.</param>
/// <returns>The shared capturing proxy.</returns>
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => GroupProxy; public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => GroupProxy;
/// <summary>Returns the shared capturing proxy for any group set.</summary>
/// <param name="groupNames">Ignored.</param>
/// <returns>The shared capturing proxy.</returns>
public IClientProxy Groups(IReadOnlyList<string> groupNames) => GroupProxy; public IClientProxy Groups(IReadOnlyList<string> groupNames) => GroupProxy;
/// <summary>Returns the shared capturing proxy for any user.</summary>
/// <param name="userId">Ignored.</param>
/// <returns>The shared capturing proxy.</returns>
public IClientProxy User(string userId) => GroupProxy; public IClientProxy User(string userId) => GroupProxy;
/// <summary>Returns the shared capturing proxy for any user set.</summary>
/// <param name="userIds">Ignored.</param>
/// <returns>The shared capturing proxy.</returns>
public IClientProxy Users(IReadOnlyList<string> userIds) => GroupProxy; public IClientProxy Users(IReadOnlyList<string> userIds) => GroupProxy;
} }
@@ -367,9 +393,19 @@ public sealed class DashboardEventBroadcasterTests
private sealed class NoopGroupManager : IGroupManager private sealed class NoopGroupManager : IGroupManager
{ {
/// <summary>Ignores the group membership change.</summary>
/// <param name="connectionId">Ignored.</param>
/// <param name="groupName">Ignored.</param>
/// <param name="cancellationToken">Ignored.</param>
/// <returns>A completed task.</returns>
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
=> Task.CompletedTask; => Task.CompletedTask;
/// <summary>Ignores the group membership change.</summary>
/// <param name="connectionId">Ignored.</param>
/// <param name="groupName">Ignored.</param>
/// <param name="cancellationToken">Ignored.</param>
/// <returns>A completed task.</returns>
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
=> Task.CompletedTask; => Task.CompletedTask;
} }
@@ -20,6 +20,7 @@ public sealed class DashboardLiveDataServiceTests
/// <summary> /// <summary>
/// Verifies a tag already in the advise set is not subscribed again on a later read. /// Verifies a tag already in the advise set is not subscribed again on a later read.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ReadAsync_WhenTagAlreadySubscribed_DoesNotResubscribe() public async Task ReadAsync_WhenTagAlreadySubscribed_DoesNotResubscribe()
{ {
@@ -41,6 +42,7 @@ public sealed class DashboardLiveDataServiceTests
/// Verifies the advise set is capped: subscribing past the cap unadvises the /// Verifies the advise set is capped: subscribing past the cap unadvises the
/// least-recently-read tag on the worker and leaves the re-read tag advised. /// least-recently-read tag on the worker and leaves the re-read tag advised.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ReadAsync_PastCap_EvictsLeastRecentlyReadTag() public async Task ReadAsync_PastCap_EvictsLeastRecentlyReadTag()
{ {
@@ -78,6 +80,7 @@ public sealed class DashboardLiveDataServiceTests
/// than the cap keeps them all (a read never evicts a tag it is about to return), /// than the cap keeps them all (a read never evicts a tag it is about to return),
/// and the next read that subscribes anything squeezes the overshoot back out. /// and the next read that subscribes anything squeezes the overshoot back out.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ReadAsync_WithMoreDistinctTagsThanCap_KeepsThemAllThenSelfCorrects() public async Task ReadAsync_WithMoreDistinctTagsThanCap_KeepsThemAllThenSelfCorrects()
{ {
@@ -108,6 +111,7 @@ public sealed class DashboardLiveDataServiceTests
/// touches nearly the whole advise set evicts only the untouched remainder, ends over /// touches nearly the whole advise set evicts only the untouched remainder, ends over
/// the cap, and the following read trims it back. /// the cap, and the following read trims it back.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ReadAsync_WhenTouchedTagsFillTheCap_EvictsOnlyUntouchedTags() public async Task ReadAsync_WhenTouchedTagsFillTheCap_EvictsOnlyUntouchedTags()
{ {
@@ -135,6 +139,7 @@ public sealed class DashboardLiveDataServiceTests
/// Verifies a tag the worker failed to advise still occupies a slot but is evicted /// Verifies a tag the worker failed to advise still occupies a slot but is evicted
/// without any unsubscribe command — there is no item handle to unadvise. /// without any unsubscribe command — there is no item handle to unadvise.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ReadAsync_WhenAdviseFailed_EvictsTagWithoutUnsubscribing() public async Task ReadAsync_WhenAdviseFailed_EvictsTagWithoutUnsubscribing()
{ {
@@ -165,6 +170,7 @@ public sealed class DashboardLiveDataServiceTests
/// Verifies a failed unadvise of an evicted tag does not fail the read, and the /// Verifies a failed unadvise of an evicted tag does not fail the read, and the
/// evicted tag is dropped from tracking anyway. /// evicted tag is dropped from tracking anyway.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ReadAsync_WhenEvictionUnsubscribeFails_StillCompletesRead() public async Task ReadAsync_WhenEvictionUnsubscribeFails_StillCompletesRead()
{ {
@@ -190,10 +196,11 @@ public sealed class DashboardLiveDataServiceTests
/// <summary> /// <summary>
/// Verifies the <c>/browse</c> live-value seam honours /// Verifies the <c>/browse</c> live-value seam honours
/// <c>MxGateway:Dashboard:ShowTagValues</c> (TST-16): with the flag off — the /// <c>MxGateway:Dashboard:ShowTagValues</c>: with the flag off — the
/// default — the value text the page renders is the redaction placeholder, /// default — the value text the page renders is the redaction placeholder,
/// never the formatted tag value. /// never the formatted tag value.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ReadAsync_WhenShowTagValuesFalse_RedactsValueTextButKeepsMetadata() public async Task ReadAsync_WhenShowTagValuesFalse_RedactsValueTextButKeepsMetadata()
{ {
@@ -220,6 +227,7 @@ public sealed class DashboardLiveDataServiceTests
} }
/// <summary>Verifies the formatted value is served when the flag is on.</summary> /// <summary>Verifies the formatted value is served when the flag is on.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ReadAsync_WhenShowTagValuesTrue_ServesFormattedValue() public async Task ReadAsync_WhenShowTagValuesTrue_ServesFormattedValue()
{ {
@@ -243,6 +251,7 @@ public sealed class DashboardLiveDataServiceTests
/// withheld, and its diagnostic still reaches the panel. Redacting it would /// withheld, and its diagnostic still reaches the panel. Redacting it would
/// contradict the error the same row displays. /// contradict the error the same row displays.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task ReadAsync_WhenReadFailedAndShowTagValuesFalse_LeavesPlaceholderUnredacted() public async Task ReadAsync_WhenReadFailedAndShowTagValuesFalse_LeavesPlaceholderUnredacted()
{ {
@@ -489,7 +498,8 @@ public sealed class DashboardLiveDataServiceTests
{ {
} }
/// <inheritdoc /> /// <summary>Completes immediately because the fake holds no resources.</summary>
/// <returns>A completed value task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;
private BulkSubscribeReply Subscribe(IEnumerable<string> tagAddresses) private BulkSubscribeReply Subscribe(IEnumerable<string> tagAddresses)
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary> /// <summary>
/// Covers <see cref="DashboardSessionAcl"/>, the single decision both dashboard subscribe seams /// Covers <see cref="DashboardSessionAcl"/>, the single decision both dashboard subscribe seams
/// consult (SEC-25 / TST-15). /// consult.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Every branch is asserted in its denying direction as well as its allowing one, because the /// Every branch is asserted in its denying direction as well as its allowing one, because the
@@ -77,6 +77,7 @@ public sealed class DashboardSessionAclTests
} }
/// <summary>A blank session id is denied without consulting anything.</summary> /// <summary>A blank session id is denied without consulting anything.</summary>
/// <param name="sessionId">A blank or whitespace-only session id.</param>
[Theory] [Theory]
[InlineData("")] [InlineData("")]
[InlineData(" ")] [InlineData(" ")]
@@ -146,8 +147,8 @@ public sealed class DashboardSessionAclTests
} }
/// <summary> /// <summary>
/// A principal carrying no tag claims — the anonymous-localhost / empty-grant Viewer of /// A principal carrying no tag claims — the anonymous-localhost / empty-grant Viewer —
/// SEC-02 — sees a tagged session never, and an untagged one only when the operator opted /// sees a tagged session never, and an untagged one only when the operator opted
/// into <see cref="UntaggedSessionVisibility.AllViewers"/>. /// into <see cref="UntaggedSessionVisibility.AllViewers"/>.
/// </summary> /// </summary>
[Fact] [Fact]
@@ -179,7 +180,7 @@ public sealed class DashboardSessionAclTests
} }
/// <summary> /// <summary>
/// SEC-41, the load-bearing regression: a cookie-style principal whose tag claim was stamped /// The load-bearing regression: a cookie-style principal whose tag claim was stamped
/// at login under an older <c>GroupToTag</c> map is DENIED once that map no longer grants the /// at login under an older <c>GroupToTag</c> map is DENIED once that map no longer grants the
/// tag to any group the principal belongs to. The auth cookie survives the restart that /// tag to any group the principal belongs to. The auth cookie survives the restart that
/// rebinds options, so trusting the stamped claim let a config-revoked grant live on for the /// rebinds options, so trusting the stamped claim let a config-revoked grant live on for the
@@ -224,7 +225,7 @@ public sealed class DashboardSessionAclTests
/// <summary> /// <summary>
/// Re-resolution is ordinal-ignore-case on both sides — the group key against the map (which /// Re-resolution is ordinal-ignore-case on both sides — the group key against the map (which
/// <see cref="DashboardGroupTagMapping"/> owns) and the resolved tag against the session's tag /// <see cref="DashboardGroupTagMapping"/> owns) and the resolved tag against the session's tag
/// set. Pinned because the pre-SEC-41 claim scan got the second comparison from /// set. Pinned because the earlier claim scan got the second comparison from
/// <c>Session.Tags</c>'s own comparer, and the re-resolution arm probes the grant set in the /// <c>Session.Tags</c>'s own comparer, and the re-resolution arm probes the grant set in the
/// opposite direction. /// opposite direction.
/// </summary> /// </summary>
@@ -294,7 +295,7 @@ public sealed class DashboardSessionAclTests
} }
/// <summary> /// <summary>
/// Builds a dashboard principal. <paramref name="groups"/> is the SEC-41 discriminator: a /// Builds a dashboard principal. <paramref name="groups"/> is the discriminator: a
/// principal WITH group claims is re-resolved against the current map (the cookie seam), one /// principal WITH group claims is re-resolved against the current map (the cookie seam), one
/// WITHOUT falls back to its stamped tag claims (the hub-token seam). Existing cases pass /// WITHOUT falls back to its stamped tag claims (the hub-token seam). Existing cases pass
/// tags only, so they continue to exercise the fallback arm exactly as before. /// tags only, so they continue to exercise the fallback arm exactly as before.
@@ -7,7 +7,7 @@ using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary> /// <summary>
/// Covers the ACL gate on <see cref="EventsHub.SubscribeSession"/> (SEC-25 / TST-15). /// Covers the ACL gate on <see cref="EventsHub.SubscribeSession"/> .
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The denial assertions are the load-bearing ones — before the gate existed every caller was /// The denial assertions are the load-bearing ones — before the gate existed every caller was
@@ -131,7 +131,11 @@ public sealed class EventsHubTests
/// <summary>Gets the (connection id, group name) pairs added, in order.</summary> /// <summary>Gets the (connection id, group name) pairs added, in order.</summary>
public List<(string ConnectionId, string GroupName)> Added { get; } = []; public List<(string ConnectionId, string GroupName)> Added { get; } = [];
/// <inheritdoc /> /// <summary>Records the group membership and completes synchronously.</summary>
/// <param name="connectionId">The connection being added.</param>
/// <param name="groupName">The group the connection joins.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A completed task.</returns>
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
{ {
Added.Add((connectionId, groupName)); Added.Add((connectionId, groupName));
@@ -139,7 +143,11 @@ public sealed class EventsHubTests
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <inheritdoc /> /// <summary>Ignores the removal and completes synchronously.</summary>
/// <param name="connectionId">The connection being removed.</param>
/// <param name="groupName">The group the connection leaves.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A completed task.</returns>
public Task RemoveFromGroupAsync( public Task RemoveFromGroupAsync(
string connectionId, string connectionId,
string groupName, string groupName,
@@ -129,6 +129,8 @@ public sealed class EventsHubViewerRegistryTests
} }
/// <summary>Blank connection or session ids are ignored rather than tracked.</summary> /// <summary>Blank connection or session ids are ignored rather than tracked.</summary>
/// <param name="connectionId">The hub connection id, possibly blank.</param>
/// <param name="sessionId">The session id, possibly blank.</param>
[Theory] [Theory]
[InlineData("", "session-1")] [InlineData("", "session-1")]
[InlineData(" ", "session-1")] [InlineData(" ", "session-1")]
@@ -178,7 +178,7 @@ public sealed class HubTokenServiceTests
} }
/// <summary> /// <summary>
/// The dashboard visibility grant (SEC-25) survives the mint/validate round-trip: tags are /// The dashboard visibility grant survives the mint/validate round-trip: tags are
/// resolved from the caller's LDAP-group claims through <c>Dashboard:GroupToTag</c> at /// resolved from the caller's LDAP-group claims through <c>Dashboard:GroupToTag</c> at
/// <see cref="HubTokenService.Issue(ClaimsPrincipal)"/> and rehydrated as /// <see cref="HubTokenService.Issue(ClaimsPrincipal)"/> and rehydrated as
/// <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/> claims on the principal /// <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/> claims on the principal
@@ -16,7 +16,7 @@ using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary> /// <summary>
/// Covers the ACL gate on the session-details page's in-process subscribe seam (SEC-25 / TST-15). /// Covers the ACL gate on the session-details page's in-process subscribe seam.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
@@ -214,7 +214,7 @@ public sealed class SessionDetailsPageEventAclTests
/// <inheritdoc /> /// <inheritdoc />
public ChannelReader<MxEvent> Reader => _channel.Reader; public ChannelReader<MxEvent> Reader => _channel.Reader;
/// <inheritdoc /> /// <summary>Marks the subscription as released and completes its channel.</summary>
public void Dispose() public void Dispose()
{ {
IsDisposed = true; IsDisposed = true;
@@ -81,7 +81,7 @@ public sealed class GatewayApplicationTests
{ {
// Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any // Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any
// started-host test must avoid a fixed port to prevent a bind collision. The auth SQLite // started-host test must avoid a fixed port to prevent a bind collision. The auth SQLite
// store path is isolated to a per-process temp file by TestHostEnvironmentInitializer (SEC-33). // store path is isolated to a per-process temp file by TestHostEnvironmentInitializer.
await using WebApplication app = GatewayApplication.Build(["--urls=http://127.0.0.1:0"]); await using WebApplication app = GatewayApplication.Build(["--urls=http://127.0.0.1:0"]);
await app.StartAsync(); await app.StartAsync();
try try
@@ -266,7 +266,7 @@ public sealed class GatewayApplicationTests
{ {
// Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any // Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any
// WebApplication-building test must avoid a fixed port to prevent a bind collision. The auth // WebApplication-building test must avoid a fixed port to prevent a bind collision. The auth
// store path is isolated by TestHostEnvironmentInitializer (SEC-33), so startup opens a // store path is isolated by TestHostEnvironmentInitializer, so startup opens a
// writable store and the injected misconfiguration is what fails validation. // writable store and the injected misconfiguration is what fails validation.
await using WebApplication app = GatewayApplication.Build( await using WebApplication app = GatewayApplication.Build(
[$"--{key}={value}", "--urls=http://127.0.0.1:0"]); [$"--{key}={value}", "--urls=http://127.0.0.1:0"]);
@@ -685,19 +685,19 @@ public sealed class GatewayEndToEndMultiSubscriberTests
int advisedItemHandle = 0; int advisedItemHandle = 0;
int emittedCount = 0; int emittedCount = 0;
// Read envelopes one at a time. Between each envelope, if we have a await using GatedWorkerEnvelopeReader incoming = new(
// subscription and the gate is ready, emit an event before reading the harness.ReadGatewayEnvelopeAsync, _emitGate, cancellationToken);
// next envelope. When _stopEmitting is set, drain the gate and read
// remaining envelopes (including shutdown) without emitting.
while (!cancellationToken.IsCancellationRequested) while (!cancellationToken.IsCancellationRequested)
{ {
// While subscribed and not stopped, try to emit gated events using WorkerEnvelope? envelope = await incoming.ReadOrGateAsync(
// a short-timeout peek at the gate — yield to incoming envelopes to advisedServerHandle != 0 && !_stopEmitting).ConfigureAwait(false);
// avoid starving shutdown processing. if (envelope is null)
while (advisedServerHandle != 0
&& !_stopEmitting
&& await _emitGate.WaitAsync(millisecondsTimeout: 0).ConfigureAwait(false))
{ {
if (_stopEmitting)
{
continue;
}
int index = ++emittedCount; int index = ++emittedCount;
await harness.EmitEventAsync( await harness.EmitEventAsync(
MxEventFamily.OnDataChange, MxEventFamily.OnDataChange,
@@ -714,20 +714,6 @@ public sealed class GatewayEndToEndMultiSubscriberTests
}; };
mxEvent.OnDataChange = new OnDataChangeEvent(); mxEvent.OnDataChange = new OnDataChangeEvent();
}).ConfigureAwait(false); }).ConfigureAwait(false);
}
// Use a short timeout so the emit loop above is re-evaluated
// periodically — but long enough not to spam.
WorkerEnvelope? envelope = null;
try
{
using CancellationTokenSource readCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
readCts.CancelAfter(TimeSpan.FromMilliseconds(50));
envelope = await harness.ReadGatewayEnvelopeAsync(readCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Timed out waiting for an envelope — loop back to check gate / emit.
continue; continue;
} }
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway;
/// <summary> /// <summary>
/// End-to-end reconnect/replay tests through the real gRPC <c>StreamEvents</c> path via the /// End-to-end reconnect/replay tests through the real gRPC <c>StreamEvents</c> path via the
/// fake worker harness (TST-01, server half). A single subscriber detaches (cancel + await /// fake worker harness (server half). A single subscriber detaches (cancel + await
/// the stream task so its lease is fully disposed), the session is retained by detach-grace /// the stream task so its lease is fully disposed), the session is retained by detach-grace
/// while the distributor pump keeps appending worker events to the replay ring, and a second /// while the distributor pump keeps appending worker events to the replay ring, and a second
/// stream reconnects with <see cref="StreamEventsRequest.AfterWorkerSequence"/>. Covers both /// stream reconnects with <see cref="StreamEventsRequest.AfterWorkerSequence"/>. Covers both
@@ -233,7 +233,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
} }
/// <summary> /// <summary>
/// GWC-25 regression, end to end: when age eviction has emptied the replay ring, the /// Regression, end to end: when age eviction has emptied the replay ring, the
/// <c>ReplayGap</c> sentinel carries the next deliverable sequence (highest observed + 1), /// <c>ReplayGap</c> sentinel carries the next deliverable sequence (highest observed + 1),
/// so a client applying the documented /// so a client applying the documented
/// <c>after_worker_sequence = oldest_available_sequence - 1</c> formula on its follow-up /// <c>after_worker_sequence = oldest_available_sequence - 1</c> formula on its follow-up
@@ -305,7 +305,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
Assert.NotNull(sentinel.ReplayGap); Assert.NotNull(sentinel.ReplayGap);
Assert.Equal(staleCursor, sentinel.ReplayGap.RequestedAfterSequence); Assert.Equal(staleCursor, sentinel.ReplayGap.RequestedAfterSequence);
// The whole point of GWC-25: the sentinel anchors the resume at the next sequence that // The whole point of the fix: the sentinel anchors the resume at the next sequence that
// can still be delivered, not at 0. // can still be delivered, not at 0.
Assert.Equal(highestSeen + 1, sentinel.ReplayGap.OldestAvailableSequence); Assert.Equal(highestSeen + 1, sentinel.ReplayGap.OldestAvailableSequence);
@@ -697,14 +697,19 @@ public sealed class GatewayEndToEndReconnectReplayTests
int advisedItemHandle = 0; int advisedItemHandle = 0;
int emittedCount = 0; int emittedCount = 0;
await using GatedWorkerEnvelopeReader incoming = new(
harness.ReadGatewayEnvelopeAsync, _emitGate, cancellationToken);
while (!cancellationToken.IsCancellationRequested) while (!cancellationToken.IsCancellationRequested)
{ {
// While subscribed and not stopped, emit gated events using a non-blocking peek WorkerEnvelope? envelope = await incoming.ReadOrGateAsync(
// at the gate so incoming envelopes (including shutdown) are never starved. advisedServerHandle != 0 && !_stopEmitting).ConfigureAwait(false);
while (advisedServerHandle != 0 if (envelope is null)
&& !_stopEmitting
&& await _emitGate.WaitAsync(millisecondsTimeout: 0).ConfigureAwait(false))
{ {
if (_stopEmitting)
{
continue;
}
int index = ++emittedCount; int index = ++emittedCount;
await harness.EmitEventAsync( await harness.EmitEventAsync(
MxEventFamily.OnDataChange, MxEventFamily.OnDataChange,
@@ -721,18 +726,6 @@ public sealed class GatewayEndToEndReconnectReplayTests
}; };
mxEvent.OnDataChange = new OnDataChangeEvent(); mxEvent.OnDataChange = new OnDataChangeEvent();
}).ConfigureAwait(false); }).ConfigureAwait(false);
}
WorkerEnvelope? envelope;
try
{
using CancellationTokenSource readCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
readCts.CancelAfter(TimeSpan.FromMilliseconds(50));
envelope = await harness.ReadGatewayEnvelopeAsync(readCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Timed out waiting for an envelope — loop back to check the gate / emit.
continue; continue;
} }
@@ -28,7 +28,7 @@ public sealed class GalaxyRepositoryHostWiringTests
/// Verifies the full production authz-filtering chain end-to-end. /// Verifies the full production authz-filtering chain end-to-end.
/// Phase 1: an identity carrying no <c>BrowseSubtrees</c> pushed → <c>BrowseChildren</c> /// Phase 1: an identity carrying no <c>BrowseSubtrees</c> pushed → <c>BrowseChildren</c>
/// returns both children (unconstrained). That, not an absent identity, is production's /// returns both children (unconstrained). That, not an absent identity, is production's
/// unscoped case since GWC-38 — with <c>Authentication:Mode=ApiKey</c> an absent identity /// unscoped case — with <c>Authentication:Mode=ApiKey</c> an absent identity
/// now denies, which the isolated provider test pins. /// now denies, which the isolated provider test pins.
/// Phase 2: identity with <c>BrowseSubtrees = ["NonExistent"]</c> /// Phase 2: identity with <c>BrowseSubtrees = ["NonExistent"]</c>
/// pushed via <see cref="GatewayRequestIdentityAccessor"/> → the real /// pushed via <see cref="GatewayRequestIdentityAccessor"/> → the real
@@ -52,7 +52,7 @@ public sealed class MxAccessGatewayServiceTests
/// <summary> /// <summary>
/// Verifies OpenSession forwards the calling key's dashboard-visibility tags, so the /// Verifies OpenSession forwards the calling key's dashboard-visibility tags, so the
/// session's tags are derived from the owning API key rather than the wire request (SEC-25). /// session's tags are derived from the owning API key rather than the wire request.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
@@ -41,7 +41,7 @@ public sealed class MxAccessGrpcMapperTests
/// <summary> /// <summary>
/// The command-only overload exists so <c>Invoke</c> does not deep-clone the whole request just to /// The command-only overload exists so <c>Invoke</c> does not deep-clone the whole request just to
/// overwrite and discard its command (GWC-29). It must still perform the one clone that keeps the /// overwrite and discard its command. It must still perform the one clone that keeps the
/// worker-bound graph unaliased from the caller-owned gRPC command, and must produce the same /// worker-bound graph unaliased from the caller-owned gRPC command, and must produce the same
/// <see cref="WorkerCommand"/> as the request overload. /// <see cref="WorkerCommand"/> as the request overload.
/// </summary> /// </summary>
@@ -145,7 +145,7 @@ public sealed class MxAccessGrpcMapperTests
} }
/// <summary> /// <summary>
/// Verifies MapEvent transfers ownership of the inner MxEvent (GWC-07 / IPC-05): the /// Verifies MapEvent transfers ownership of the inner MxEvent: the
/// returned reference is the same instance carried by the WorkerEvent, not a clone. The /// returned reference is the same instance carried by the WorkerEvent, not a clone. The
/// WorkerEvent is discarded after mapping and the distributor pump is its single consumer, /// WorkerEvent is discarded after mapping and the distributor pump is its single consumer,
/// so moving the inner event out is safe and avoids a per-event deep copy. /// so moving the inner event out is safe and avoids a per-event deep copy.
@@ -669,7 +669,7 @@ public sealed class GatewaySessionTests
} }
/// <summary> /// <summary>
/// GWC-27: <see cref="GatewaySession.AttachInternalEventSubscriber"/> must refuse to /// <see cref="GatewaySession.AttachInternalEventSubscriber"/> must refuse to
/// attach before the session is Ready. Without the gate the attach would construct and /// attach before the session is Ready. Without the gate the attach would construct and
/// start the distributor against a not-yet-Ready worker; the pump source throws /// start the distributor against a not-yet-Ready worker; the pump source throws
/// <c>SessionNotReady</c>, every subscriber is completed with that error, and the /// <c>SessionNotReady</c>, every subscriber is completed with that error, and the
@@ -720,7 +720,7 @@ public sealed class GatewaySessionTests
} }
/// <summary> /// <summary>
/// GWC-34, the disposal-side twin of the GWC-27 premature-attach poison: an attach that /// The disposal-side twin of the premature-attach poison: an attach that
/// passed its readiness gate and then lost the CPU until the session was torn down used to /// passed its readiness gate and then lost the CPU until the session was torn down used to
/// find <c>_eventDistributor</c> null, read that as "not created yet" and build a /// find <c>_eventDistributor</c> null, read that as "not created yet" and build a
/// REPLACEMENT distributor. Its pump could never start (the started flag is a one-way latch) /// REPLACEMENT distributor. Its pump could never start (the started flag is a one-way latch)
@@ -843,7 +843,7 @@ public sealed class SessionEventDistributorTests
} }
/// <summary> /// <summary>
/// GWC-25: when age eviction has emptied the replay ring, a resume behind the highest /// When age eviction has emptied the replay ring, a resume behind the highest
/// observed sequence still reports a gap, and the reported oldest-available sequence is /// observed sequence still reports a gap, and the reported oldest-available sequence is
/// the next sequence that can possibly be delivered (highest seen + 1) — never <c>0</c>, /// the next sequence that can possibly be delivered (highest seen + 1) — never <c>0</c>,
/// which would make the client's documented <c>oldest - 1</c> resume formula wrap to /// which would make the client's documented <c>oldest - 1</c> resume formula wrap to
@@ -889,7 +889,7 @@ public sealed class SessionEventDistributorTests
} }
/// <summary> /// <summary>
/// GWC-25: the same next-deliverable-sequence rule applies when retention is disabled /// The same next-deliverable-sequence rule applies when retention is disabled
/// outright (replay capacity 0) — the ring is always empty, so a behind cursor must still /// outright (replay capacity 0) — the ring is always empty, so a behind cursor must still
/// get a usable resume anchor rather than <c>0</c>. /// get a usable resume anchor rather than <c>0</c>.
/// </summary> /// </summary>
@@ -924,7 +924,7 @@ public sealed class SessionEventDistributorTests
} }
/// <summary> /// <summary>
/// GWC-25 regression: a client that applies the documented /// Regression: a client that applies the documented
/// <c>after_worker_sequence = oldest_available_sequence - 1</c> formula to an empty-ring /// <c>after_worker_sequence = oldest_available_sequence - 1</c> formula to an empty-ring
/// ReplayGap resumes with a live watermark equal to the highest observed sequence, so the /// ReplayGap resumes with a live watermark equal to the highest observed sequence, so the
/// caller's live filter passes every subsequent event. Under the pre-fix <c>0</c> the /// caller's live filter passes every subsequent event. Under the pre-fix <c>0</c> the
@@ -41,7 +41,7 @@ public sealed class SessionManagerTests
/// Verifies the pipe name stays short enough that its Unix-domain-socket path /// Verifies the pipe name stays short enough that its Unix-domain-socket path
/// (TMPDIR + "CoreFxPipe_" + name) fits the 104-byte macOS sun_path limit under the /// (TMPDIR + "CoreFxPipe_" + name) fits the 104-byte macOS sun_path limit under the
/// default per-user TMPDIR (~49 chars), and keeps the pid + session-guid uniqueness /// default per-user TMPDIR (~49 chars), and keeps the pid + session-guid uniqueness
/// contract (NEXT-01). /// contract.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
@@ -110,7 +110,7 @@ public sealed class SessionManagerTests
} }
/// <summary> /// <summary>
/// Verifies a session inherits the owning API key's dashboard-visibility tags (SEC-25), /// Verifies a session inherits the owning API key's dashboard-visibility tags,
/// compared ordinal-ignore-case so a differently cased grant still matches. /// compared ordinal-ignore-case so a differently cased grant still matches.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
@@ -478,24 +478,42 @@ public sealed class SessionManagerTests
[Fact] [Fact]
public async Task InvokeAsync_WhenWorkerHandshakingThenReadyWithinTimeout_Succeeds() public async Task InvokeAsync_WhenWorkerHandshakingThenReadyWithinTimeout_Succeeds()
{ {
FakeTimeProvider clock = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
FakeWorkerClient workerClient = new() { State = WorkerClientState.Handshaking }; FakeWorkerClient workerClient = new() { State = WorkerClientState.Handshaking };
SessionManager manager = CreateManager( SessionManager manager = CreateManager(
new FakeSessionWorkerClientFactory(workerClient), new FakeSessionWorkerClientFactory(workerClient),
options: CreateOptions(workerReadyWaitTimeoutMs: 500)); options: CreateOptions(workerReadyWaitTimeoutMs: 500),
timeProvider: clock);
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None); GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
Assert.Equal(SessionState.Ready, session.State); Assert.Equal(SessionState.Ready, session.State);
// Flip the worker to Ready shortly after the invoke starts waiting. TaskCompletionSource handshakingObserved = new(TaskCreationOptions.RunContinuationsAsynchronously);
_ = Task.Run(async () => TaskCompletionSource allowReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
workerClient.OnStateRead = state =>
{ {
await Task.Delay(50, CancellationToken.None); if (state == WorkerClientState.Handshaking)
workerClient.State = WorkerClientState.Ready; {
}); handshakingObserved.TrySetResult();
}
WorkerCommandReply reply = await manager.InvokeAsync( };
Task transition = TransitionAsync();
Task<WorkerCommandReply> invoke = manager.InvokeAsync(
session.SessionId, session.SessionId,
CreateCommand(MxCommandKind.Ping), CreateCommand(MxCommandKind.Ping),
CancellationToken.None); CancellationToken.None);
await handshakingObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.False(invoke.IsCompleted);
Assert.Equal(0, workerClient.InvokeCount);
allowReady.SetResult();
await transition.WaitAsync(TimeSpan.FromSeconds(5));
clock.Advance(TimeSpan.FromMilliseconds(50));
WorkerCommandReply reply = await invoke.WaitAsync(TimeSpan.FromSeconds(5));
async Task TransitionAsync()
{
await allowReady.Task;
workerClient.State = WorkerClientState.Ready;
}
Assert.NotNull(reply); Assert.NotNull(reply);
Assert.Equal(1, workerClient.InvokeCount); Assert.Equal(1, workerClient.InvokeCount);
@@ -1406,14 +1424,28 @@ public sealed class SessionManagerTests
private sealed class FakeWorkerClient : IWorkerClient private sealed class FakeWorkerClient : IWorkerClient
{ {
private WorkerClientState _state = WorkerClientState.Ready;
/// <inheritdoc /> /// <inheritdoc />
public string SessionId { get; init; } = "session-1"; public string SessionId { get; init; } = "session-1";
/// <inheritdoc /> /// <inheritdoc />
public int? ProcessId { get; init; } = 1234; public int? ProcessId { get; init; } = 1234;
/// <summary>Gets or sets the optional state-observation gate.</summary>
public Action<WorkerClientState>? OnStateRead { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public WorkerClientState State { get; set; } = WorkerClientState.Ready; public WorkerClientState State
{
get
{
OnStateRead?.Invoke(_state);
return _state;
}
set => _state = value;
}
/// <inheritdoc /> /// <inheritdoc />
public DateTimeOffset LastHeartbeatAt { get; init; } = DateTimeOffset.UtcNow; public DateTimeOffset LastHeartbeatAt { get; init; } = DateTimeOffset.UtcNow;
@@ -24,7 +24,10 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncLifetime
private readonly List<IWorkerTaskLauncher> _launchers = []; private readonly List<IWorkerTaskLauncher> _launchers = [];
/// <inheritdoc /> /// <summary>
/// Completes immediately; no asynchronous setup is required before each test.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task InitializeAsync() => Task.CompletedTask; public Task InitializeAsync() => Task.CompletedTask;
/// <summary> /// <summary>
@@ -0,0 +1,83 @@
using System.IO.Pipelines;
using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Workers;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Workers;
/// <summary>Protects fake worker framing while the emission gate wakes its loop.</summary>
public sealed class GatedWorkerEnvelopeReaderTests
{
/// <summary>A gate wakeup must preserve a partially consumed frame and the following frame.</summary>
/// <returns>The asynchronous test operation.</returns>
[Fact]
public async Task ReadOrGateAsync_WhenGateOpensDuringSegmentedFrame_PreservesBothFrames()
{
WorkerFrameProtocolOptions options = new("segmented-session");
WorkerEnvelope first = new()
{
SessionId = "segmented-session",
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
Sequence = 1,
WorkerShutdown = new WorkerShutdown(),
};
WorkerEnvelope second = first.Clone();
second.Sequence = 2;
await using MemoryStream encoded = new();
WorkerFrameWriter writer = new(encoded, options);
await writer.WriteAsync(first);
await writer.WriteAsync(second);
byte[] bytes = encoded.ToArray();
Pipe pipe = new();
await using Stream input = pipe.Reader.AsStream();
WorkerFrameReader reader = new(input, options);
using SemaphoreSlim gate = new(0);
using CancellationTokenSource lifetime = new(TimeSpan.FromSeconds(5));
await using GatedWorkerEnvelopeReader selector = new(token => reader.ReadAsync(token).AsTask(), gate, lifetime.Token);
// The length and first payload byte are consumed before the remaining bytes exist.
await pipe.Writer.WriteAsync(bytes.AsMemory(0, 5));
Task<WorkerEnvelope?> pending = selector.ReadOrGateAsync();
Assert.False(pending.IsCompleted);
gate.Release();
Assert.Null(await pending);
await pipe.Writer.WriteAsync(bytes.AsMemory(5));
await pipe.Writer.CompleteAsync();
Assert.Equal(first, await selector.ReadOrGateAsync());
Assert.Equal(second, await selector.ReadOrGateAsync());
}
/// <summary>Disposal or lifetime cancellation drains the retained read and gate wait.</summary>
/// <param name="cancelLifetime">Whether cancellation precedes disposal.</param>
/// <returns>The asynchronous test operation.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task DisposeAsync_WithIncompleteFrame_CancelsRetainedRead(bool cancelLifetime)
{
Pipe pipe = new();
await using Stream input = pipe.Reader.AsStream();
WorkerFrameReader reader = new(input, new WorkerFrameProtocolOptions("cancel-session"));
using SemaphoreSlim gate = new(0);
using CancellationTokenSource lifetime = new();
Task<WorkerEnvelope>? frameRead = null;
GatedWorkerEnvelopeReader selector = new(
token => frameRead = reader.ReadAsync(token).AsTask(), gate, lifetime.Token);
Task<WorkerEnvelope?> pending = selector.ReadOrGateAsync();
gate.Release();
Assert.Null(await pending.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.NotNull(frameRead);
Assert.False(frameRead.IsCompleted);
if (cancelLifetime)
{
await lifetime.CancelAsync();
}
await selector.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => frameRead);
await pipe.Writer.CompleteAsync();
}
}
@@ -36,10 +36,10 @@ public sealed class WorkerClientTests
/// <summary> /// <summary>
/// The <c>GatewayHello</c> carries the negotiated worker-frame maximum so the worker adopts the /// The <c>GatewayHello</c> carries the negotiated worker-frame maximum so the worker adopts the
/// configured limit instead of its own default (IPC-02); a regression to sending <c>0</c> means /// configured limit instead of its own default; a regression to sending <c>0</c> means
/// "older gateway, use default" to the worker and would silently downgrade the negotiated limit. /// "older gateway, use default" to the worker and would silently downgrade the negotiated limit.
/// The adoption half is asserted only in the Windows-only worker suite, so the gateway half is /// The adoption half is asserted only in the Windows-only worker suite, so the gateway half is
/// pinned here, in the portable suite (TST-28). Both the default and an override are covered so /// pinned here, in the portable suite. Both the default and an override are covered so
/// the assertion tracks configuration rather than a constant. /// the assertion tracks configuration rather than a constant.
/// </summary> /// </summary>
/// <param name="maxMessageBytes">Configured worker-frame maximum to negotiate.</param> /// <param name="maxMessageBytes">Configured worker-frame maximum to negotiate.</param>
@@ -164,7 +164,7 @@ public sealed class WorkerClientTests
TestTimeout, TestTimeout,
CancellationToken.None); CancellationToken.None);
// The timeout also emits a WorkerCancel for the abandoned correlation (PERF-07), which sits // The timeout also emits a WorkerCancel for the abandoned correlation, which sits
// ahead of the second command on the FIFO pipe; skip it rather than mistaking it for the // ahead of the second command on the FIFO pipe; skip it rather than mistaking it for the
// command this assertion is about. // command this assertion is about.
WorkerEnvelope secondCommand = await ReadNextCommandAsync(pipePair, timedOutCommand.CorrelationId); WorkerEnvelope secondCommand = await ReadNextCommandAsync(pipePair, timedOutCommand.CorrelationId);
@@ -180,7 +180,7 @@ public sealed class WorkerClientTests
/// <summary> /// <summary>
/// A command timeout abandons the gateway-side wait, but the worker keeps the correlation on /// A command timeout abandons the gateway-side wait, but the worker keeps the correlation on
/// its single STA queue and would still run it — so the gateway forwards a <c>WorkerCancel</c> /// its single STA queue and would still run it — so the gateway forwards a <c>WorkerCancel</c>
/// for the abandoned correlation id (PERF-07). Without it, a client that retries after a /// for the abandoned correlation id. Without it, a client that retries after a
/// timeout stacks work the worker still intends to execute. Asserted on the wire because the /// timeout stacks work the worker still intends to execute. Asserted on the wire because the
/// cancel is protocol behavior the worker depends on, not an internal detail. /// cancel is protocol behavior the worker depends on, not an internal detail.
/// </summary> /// </summary>
@@ -223,7 +223,7 @@ public sealed class WorkerClientTests
/// <summary> /// <summary>
/// Caller cancellation abandons the correlation exactly as a gateway timeout does — a client /// Caller cancellation abandons the correlation exactly as a gateway timeout does — a client
/// deadline shorter than the gateway's command timeout, or a disconnect, is the common /// deadline shorter than the gateway's command timeout, or a disconnect, is the common
/// abandonment path — so it forwards the same <c>WorkerCancel</c> (IPC-34). Without it a /// abandonment path — so it forwards the same <c>WorkerCancel</c>. Without it a
/// retry storm of deadline-cancelled calls leaves the worker's single STA working through /// retry storm of deadline-cancelled calls leaves the worker's single STA working through
/// commands nobody is waiting for while live callers queue behind them. Twin of /// commands nobody is waiting for while live callers queue behind them. Twin of
/// <see cref="InvokeAsync_WhenCommandTimesOut_SendsWorkerCancelForThatCorrelation"/>, driven /// <see cref="InvokeAsync_WhenCommandTimesOut_SendsWorkerCancelForThatCorrelation"/>, driven
@@ -271,7 +271,7 @@ public sealed class WorkerClientTests
/// <summary> /// <summary>
/// The cancel is forwarded for a caller that abandoned its command, never for the client's own /// The cancel is forwarded for a caller that abandoned its command, never for the client's own
/// teardown (IPC-34): once the stop token has fired the outbound channel is being completed and /// teardown: once the stop token has fired the outbound channel is being completed and
/// every pending command is failed anyway, so a cancel would at best be pointless and at worst /// every pending command is failed anyway, so a cancel would at best be pointless and at worst
/// a write racing teardown. Asserted on the wire — the guard in the cancellation arm is the /// a write racing teardown. Asserted on the wire — the guard in the cancellation arm is the
/// code-level expression of it, and the completed outbound channel is the second line of /// code-level expression of it, and the completed outbound channel is the second line of
@@ -330,8 +330,7 @@ public sealed class WorkerClientTests
/// A <c>WorkerFault</c> arriving in place of a handshake frame is the worker reporting a /// A <c>WorkerFault</c> arriving in place of a handshake frame is the worker reporting a
/// structured startup failure — most often MXAccess COM creation — not a protocol problem, so /// structured startup failure — most often MXAccess COM creation — not a protocol problem, so
/// the gateway surfaces <see cref="WorkerClientErrorCode.WorkerFaulted"/> with the fault's /// the gateway surfaces <see cref="WorkerClientErrorCode.WorkerFaulted"/> with the fault's
/// category and diagnostic rather than a generic protocol violation naming only the body type /// category and diagnostic rather than a generic protocol violation naming only the body type. This is the window where the information matters most: first session open on a
/// (IPC-35). This is the window where the information matters most: first session open on a
/// freshly deployed host, where the message reaches the operator log and the client's /// freshly deployed host, where the message reaches the operator log and the client's
/// <c>OpenFailed</c> detail. /// <c>OpenFailed</c> detail.
/// </summary> /// </summary>
@@ -368,8 +367,7 @@ public sealed class WorkerClientTests
/// <summary> /// <summary>
/// The envelope <c>sequence</c> is a monotonic per-sender counter (gateway.md), so the values /// The envelope <c>sequence</c> is a monotonic per-sender counter (gateway.md), so the values
/// observed on the pipe must be strictly increasing in wire order. Stamping the sequence when /// observed on the pipe must be strictly increasing in wire order. Stamping the sequence when
/// the envelope is constructed lets two concurrent invokes stamp 1 and 2 but enqueue 2 then 1 /// the envelope is constructed lets two concurrent invokes stamp 1 and 2 but enqueue 2 then 1; stamping on the single-consumer write loop makes wire order and sequence order the
/// (GWC-28); stamping on the single-consumer write loop makes wire order and sequence order the
/// same thing by construction. /// same thing by construction.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
@@ -710,12 +708,12 @@ public sealed class WorkerClientTests
} }
/// <summary> /// <summary>
/// GWC-32: disposing a still-<c>Ready</c> client while the worker is emitting events must be /// Disposing a still-<c>Ready</c> client while the worker is emitting events must be
/// an ordinary teardown, not a fault. <c>DisposeAsync</c> completes the same three channels /// an ordinary teardown, not a fault. <c>DisposeAsync</c> completes the same three channels
/// <c>MarkClosed</c> and <c>SetFaulted</c> do, so it must enter a terminal state first; /// <c>MarkClosed</c> and <c>SetFaulted</c> do, so it must enter a terminal state first;
/// otherwise the read loop — already inside <c>DispatchEnvelope</c> when the staging channel /// otherwise the read loop — already inside <c>DispatchEnvelope</c> when the staging channel
/// is completed, or reading a stream that is torn down underneath it — misdiagnoses the /// is completed, or reading a stream that is torn down underneath it — misdiagnoses the
/// completed channel as the GWC-24 sustained-backpressure overflow and raises a spurious /// completed channel as the sustained-backpressure overflow and raises a spurious
/// <c>ProtocolViolation</c> fault, a <c>worker-event-staging</c> overflow metric and a /// <c>ProtocolViolation</c> fault, a <c>worker-event-staging</c> overflow metric and a
/// redundant kill during an intentional dispose. The event channel is sized far above the /// redundant kill during an intentional dispose. The event channel is sized far above the
/// burst so a genuine overflow is impossible, and <c>DisposeAsync</c> awaits its background /// burst so a genuine overflow is impossible, and <c>DisposeAsync</c> awaits its background
@@ -726,7 +724,7 @@ public sealed class WorkerClientTests
public async Task DisposeAsync_WhileWorkerIsEmittingEvents_ClosesWithoutFaultingOrOverflowing() public async Task DisposeAsync_WhileWorkerIsEmittingEvents_ClosesWithoutFaultingOrOverflowing()
{ {
// The burst is capped at EmittedEventCeiling and the staging channel holds 2 x capacity, so // The burst is capped at EmittedEventCeiling and the staging channel holds 2 x capacity, so
// even if every emitted event were staged and none consumed the GWC-24 bound is unreachable: // even if every emitted event were staged and none consumed the staging bound is unreachable:
// an overflow metric in this test can only be the dispose-race misdiagnosis. // an overflow metric in this test can only be the dispose-race misdiagnosis.
const int capacity = 2048; const int capacity = 2048;
using GatewayMetrics metrics = new(); using GatewayMetrics metrics = new();
@@ -1007,13 +1005,13 @@ public sealed class WorkerClientTests
} }
/// <summary> /// <summary>
/// GWC-24: the staging channel between the read loop and the event writer is bounded at /// The staging channel between the read loop and the event writer is bounded at
/// 2 × <see cref="WorkerClientOptions.EventChannelCapacity"/>, so a consumer that drains /// 2 × <see cref="WorkerClientOptions.EventChannelCapacity"/>, so a consumer that drains
/// slower than the worker produces faults the client at the bound instead of growing /// slower than the worker produces faults the client at the bound instead of growing
/// gateway memory silently. The full-mode timeout here is far longer than the test could /// gateway memory silently. The full-mode timeout here is far longer than the test could
/// ever wait, which proves the fault came from the staging bound and not from the timed /// ever wait, which proves the fault came from the staging bound and not from the timed
/// write in <c>EnqueueWorkerEventAsync</c>. A command reply interleaved before the fault /// write in <c>EnqueueWorkerEventAsync</c>. A command reply interleaved before the fault
/// must still complete: the read loop never blocks behind events (the GWC-04 guarantee). /// must still complete: the read loop never blocks behind events (the read-loop guarantee).
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
@@ -1094,7 +1092,7 @@ public sealed class WorkerClientTests
} }
/// <summary> /// <summary>
/// GWC-24: the worker event queue-depth gauge counts staged *and* queued events, so a /// The worker event queue-depth gauge counts staged *and* queued events, so a
/// backlog held in the staging channel is visible rather than invisible. Depth is /// backlog held in the staging channel is visible rather than invisible. Depth is
/// incremented at staging and decremented when the consumer reads, so the single counter /// incremented at staging and decremented when the consumer reads, so the single counter
/// reports total undelivered events and returns to zero once drained. /// reports total undelivered events and returns to zero once drained.
@@ -1148,7 +1146,7 @@ public sealed class WorkerClientTests
} }
/// <summary> /// <summary>
/// GWC-33: a faulted client can remain retained for diagnostics, but its abandoned event /// A faulted client can remain retained for diagnostics, but its abandoned event
/// backlog must stop contributing to the gateway-wide queue-depth gauge as soon as the /// backlog must stop contributing to the gateway-wide queue-depth gauge as soon as the
/// client enters a terminal state rather than waiting for disposal. /// client enters a terminal state rather than waiting for disposal.
/// </summary> /// </summary>
@@ -57,7 +57,7 @@ public sealed class WorkerFrameProtocolTests
/// <summary> /// <summary>
/// One reader instance reads many frames in sequence. The reader reuses a single length-prefix /// One reader instance reads many frames in sequence. The reader reuses a single length-prefix
/// scratch buffer across calls (GWC-30), so varying the payload length frame to frame proves the /// scratch buffer across calls, so varying the payload length frame to frame proves the
/// reused buffer is fully overwritten each time rather than carrying a stale prefix forward. /// reused buffer is fully overwritten each time rather than carrying a stale prefix forward.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
@@ -19,11 +19,11 @@ public sealed class GatewayMetricsTests
metrics.CommandFailed("WriteSecured", "AuthorizationFailed", TimeSpan.FromMilliseconds(12)); metrics.CommandFailed("WriteSecured", "AuthorizationFailed", TimeSpan.FromMilliseconds(12));
metrics.EventReceived("session-1", "OnDataChange"); metrics.EventReceived("session-1", "OnDataChange");
metrics.EventReceived("session-1", "OnDataChange"); metrics.EventReceived("session-1", "OnDataChange");
// PERF-02: the worker queue-depth gauge sums one live source per worker client, so the two // The worker queue-depth gauge sums one live source per worker client, so the two
// registrations below stand in for two concurrent sessions holding 3 and 4 events. // registrations below stand in for two concurrent sessions holding 3 and 4 events.
using IDisposable workerDepthSourceA = metrics.RegisterWorkerEventQueueDepthSource(static () => 3); using IDisposable workerDepthSourceA = metrics.RegisterWorkerEventQueueDepthSource(static () => 3);
using IDisposable workerDepthSourceB = metrics.RegisterWorkerEventQueueDepthSource(static () => 4); using IDisposable workerDepthSourceB = metrics.RegisterWorkerEventQueueDepthSource(static () => 4);
// GWC-15: the gRPC stream queue-depth gauge sums live backlog sources at collection time // The gRPC stream queue-depth gauge sums live backlog sources at collection time
// rather than tracking a pushed running total. Register a source reporting 3. // rather than tracking a pushed running total. Register a source reporting 3.
using IDisposable backlogSource = metrics.RegisterEventStreamBacklogSource(static () => 3); using IDisposable backlogSource = metrics.RegisterEventStreamBacklogSource(static () => 3);
metrics.QueueOverflow("session-events"); metrics.QueueOverflow("session-events");
@@ -58,7 +58,7 @@ public sealed class GatewayMetricsTests
} }
/// <summary> /// <summary>
/// PERF-02: the worker queue-depth gauge sums every registered source rather than holding a /// The worker queue-depth gauge sums every registered source rather than holding a
/// single pushed scalar, so concurrent sessions add up instead of overwriting one another, /// single pushed scalar, so concurrent sessions add up instead of overwriting one another,
/// and a disposed registration (a worker client going away) drops out of the sum. Disposal /// and a disposed registration (a worker client going away) drops out of the sum. Disposal
/// is idempotent because a client's dispose path can run twice. /// is idempotent because a client's dispose path can run twice.
@@ -1,7 +1,9 @@
namespace ZB.MOM.WW.MxGateway.Tests.ProjectStructure; namespace ZB.MOM.WW.MxGateway.Tests.ProjectStructure;
/// <summary>Pins the structure of the windev worker CI script.</summary>
public sealed class WindevCiContractTests public sealed class WindevCiContractTests
{ {
/// <summary>Verifies live mode runs the Windows test tiers in the documented order.</summary>
[Fact] [Fact]
public void LiveMode_RunsWindowsTiersInDocumentedOrder() public void LiveMode_RunsWindowsTiersInDocumentedOrder()
{ {
@@ -238,6 +238,7 @@ public sealed class ApiKeyAdminCommandLineParserTests
/// Verifies an empty tag segment is rejected rather than dropped: a stray comma must not /// Verifies an empty tag segment is rejected rather than dropped: a stray comma must not
/// silently persist a grant the operator did not write. /// silently persist a grant the operator did not write.
/// </summary> /// </summary>
/// <param name="tags">The raw dashboard tag list containing an empty segment.</param>
[Theory] [Theory]
[InlineData("team-a,,team-b")] [InlineData("team-a,,team-b")]
[InlineData("team-a, ")] [InlineData("team-a, ")]
@@ -100,7 +100,7 @@ public sealed class CachingApiKeyVerifierTests
} }
/// <summary> /// <summary>
/// SEC-34 window 3: an <see cref="IApiKeyCacheInvalidator.Invalidate"/> that lands while a /// Window 3: an <see cref="IApiKeyCacheInvalidator.Invalidate"/> that lands while a
/// verification is in flight in the inner library must discard that verification's repopulation, /// verification is in flight in the inner library must discard that verification's repopulation,
/// so the very next request re-verifies (revoke takes effect immediately, not after the TTL). /// so the very next request re-verifies (revoke takes effect immediately, not after the TTL).
/// </summary> /// </summary>
@@ -4,7 +4,7 @@ using ZB.MOM.WW.MxGateway.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization; namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization;
/// <summary> /// <summary>
/// Unit tests for the two-layer API-key failure limiter (SEC-31 / SEC-32): composite /// Unit tests for the two-layer API-key failure limiter: composite
/// <c>(transport peer, key id)</c> partitions, the cross-peer per-key-id aggregate, probe /// <c>(transport peer, key id)</c> partitions, the cross-peer per-key-id aggregate, probe
/// admission, the per-peer key-id partition cap, and the eviction preference order. /// admission, the per-peer key-id partition cap, and the eviction preference order.
/// </summary> /// </summary>
@@ -58,7 +58,7 @@ public sealed class ApiKeyFailureLimiterTests
/// <summary> /// <summary>
/// The composite partition binds a throttle to the failing address: the same key id presented /// The composite partition binds a throttle to the failing address: the same key id presented
/// from a different transport peer is unaffected. This is the structural half of the SEC-31 fix. /// from a different transport peer is unaffected. This is the structural half of the per-address throttle.
/// </summary> /// </summary>
[Fact] [Fact]
public void CompositePartition_ThrottleDoesNotFollowKeyIdToAnotherPeer() public void CompositePartition_ThrottleDoesNotFollowKeyIdToAnotherPeer()
@@ -102,7 +102,7 @@ public sealed class ApiKeyFailureLimiterTests
} }
/// <summary> /// <summary>
/// SEC-38: a valid key id still feeds and consults its cross-peer aggregate when each /// A valid key id still feeds and consults its cross-peer aggregate when each
/// attacking peer has exhausted its 32 dedicated key-id partitions and therefore records /// attacking peer has exhausted its 32 dedicated key-id partitions and therefore records
/// the peer layer through the fallback bucket. /// the peer layer through the fallback bucket.
/// </summary> /// </summary>
@@ -372,7 +372,7 @@ public sealed class ApiKeyFailureLimiterTests
} }
/// <summary> /// <summary>
/// SEC-39: a request with no effective key id does not own the peer's shared fallback /// A request with no effective key id does not own the peer's shared fallback
/// partition. Resetting after such a request must leave other fallback failures tracked. /// partition. Resetting after such a request must leave other fallback failures tracked.
/// </summary> /// </summary>
[Fact] [Fact]
@@ -395,7 +395,7 @@ public sealed class ApiKeyFailureLimiterTests
} }
/// <summary> /// <summary>
/// SEC-32: a spray of unique junk partitions (junk tokens resolve to the sender's fallback /// A spray of unique junk partitions (junk tokens resolve to the sender's fallback
/// partition, one per address) must not evict a partition that is currently throttled — the /// partition, one per address) must not evict a partition that is currently throttled — the
/// LRU cap bounds memory, it must not be a reset button for the block. /// LRU cap bounds memory, it must not be a reset button for the block.
/// </summary> /// </summary>
@@ -420,7 +420,7 @@ public sealed class ApiKeyFailureLimiterTests
} }
/// <summary> /// <summary>
/// SEC-32: one address may mint at most <see cref="ApiKeyFailureLimiter.MaxKeyIdPartitionsPerPeer"/> /// One address may mint at most <see cref="ApiKeyFailureLimiter.MaxKeyIdPartitionsPerPeer"/>
/// key-id partitions; the overflow collapses into that address's fallback partition, which then /// key-id partitions; the overflow collapses into that address's fallback partition, which then
/// throttles the address wholesale instead of letting the spray mint unbounded state. /// throttles the address wholesale instead of letting the spray mint unbounded state.
/// </summary> /// </summary>
@@ -445,7 +445,7 @@ public sealed class ApiKeyFailureLimiterTests
} }
/// <summary> /// <summary>
/// SEC-32 eviction preference: with the map at capacity a new failure evicts an entry whose /// Eviction preference: with the map at capacity a new failure evicts an entry whose
/// window has fully expired rather than an entry that is still counting. /// window has fully expired rather than an entry that is still counting.
/// </summary> /// </summary>
[Fact] [Fact]
@@ -32,8 +32,8 @@ public sealed class GatewayBrowseScopeProviderTests
/// <summary> /// <summary>
/// An authenticated key with no BrowseSubtrees constraint is legitimately unscoped: the /// An authenticated key with no BrowseSubtrees constraint is legitimately unscoped: the
/// empty list is what the library reads as "no scoping". This is the state GWC-38 required /// empty list is what the library reads as "no scoping". This is the state that must
/// to stay distinguishable from an absent identity, so it is asserted alongside its /// stay distinguishable from an absent identity, so it is asserted alongside its
/// denying twin below rather than left implicit. /// denying twin below rather than left implicit.
/// </summary> /// </summary>
[Fact] [Fact]
@@ -49,7 +49,7 @@ public sealed class GatewayBrowseScopeProviderTests
} }
/// <summary> /// <summary>
/// GWC-38: with API-key authentication enabled, an absent ambient identity means the /// With API-key authentication enabled, an absent ambient identity means the
/// interceptor-ordering invariant is broken — the request's browse scope cannot be /// interceptor-ordering invariant is broken — the request's browse scope cannot be
/// established. The provider must DENY rather than resolve to the full hierarchy, because /// established. The provider must DENY rather than resolve to the full hierarchy, because
/// a silently-unscoped browse is an invisible authorization bypass. The status code is /// a silently-unscoped browse is an invisible authorization bypass. The status code is
@@ -365,7 +365,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
} }
/// <summary> /// <summary>
/// SEC-31: once an attacking peer has exceeded the failure limit for a key id, the interceptor /// Once an attacking peer has exceeded the failure limit for a key id, the interceptor
/// short-circuits with <see cref="StatusCode.ResourceExhausted"/> BEFORE calling the verifier, so /// short-circuits with <see cref="StatusCode.ResourceExhausted"/> BEFORE calling the verifier, so
/// an online guessing loop stops spending a store read per attempt. The composite /// an online guessing loop stops spending a store read per attempt. The composite
/// <c>(peer, key id)</c> partition keeps that bound per attacking address. /// <c>(peer, key id)</c> partition keeps that bound per attacking address.
@@ -407,7 +407,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
} }
/// <summary> /// <summary>
/// SEC-31 (the lockout inversion): an attacker who floods failures for a victim's key id from its /// Lockout inversion: an attacker who floods failures for a victim's key id from its
/// own address must not deny that key to the legitimate holder. The holder presents the correct /// own address must not deny that key to the legitimate holder. The holder presents the correct
/// secret from a different transport peer and authenticates on the first attempt — the verifier /// secret from a different transport peer and authenticates on the first attempt — the verifier
/// is reached and the RPC succeeds. /// is reached and the RPC succeeds.
@@ -454,7 +454,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
} }
/// <summary> /// <summary>
/// SEC-31 layer 2: failures for one key id sprayed across more distinct peers than /// Layer 2: failures for one key id sprayed across more distinct peers than
/// <c>ApiKeyFailureAggregateLimit</c> put that key id into probe mode globally, so a /// <c>ApiKeyFailureAggregateLimit</c> put that key id into probe mode globally, so a
/// rotating-source attacker gets at most one verifier call per probe interval. /// rotating-source attacker gets at most one verifier call per probe interval.
/// </summary> /// </summary>
@@ -515,7 +515,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
} }
/// <summary> /// <summary>
/// SEC-31: the success-reset path stays reachable while throttled. A throttled partition admits /// The success-reset path stays reachable while throttled. A throttled partition admits
/// one probe per interval; the correct secret rides that slot, authenticates, and fully clears /// one probe per interval; the correct secret rides that slot, authenticates, and fully clears
/// both limiter layers, so the next wrong attempt is <see cref="StatusCode.Unauthenticated"/> /// both limiter layers, so the next wrong attempt is <see cref="StatusCode.Unauthenticated"/>
/// rather than <see cref="StatusCode.ResourceExhausted"/>. /// rather than <see cref="StatusCode.ResourceExhausted"/>.
@@ -572,7 +572,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
} }
/// <summary> /// <summary>
/// SEC-32: only a validly shaped <c>mxgw_&lt;keyId&gt;_&lt;secret&gt;</c> token mints a key-id /// Only a validly shaped <c>mxgw_&lt;keyId&gt;_&lt;secret&gt;</c> token mints a key-id
/// partition. Junk tokens of varied shapes all collapse onto the sender's transport-peer fallback /// partition. Junk tokens of varied shapes all collapse onto the sender's transport-peer fallback
/// partition, so a spray cannot mint one tracked entry per invented token. /// partition, so a spray cannot mint one tracked entry per invented token.
/// </summary> /// </summary>
@@ -61,6 +61,7 @@ public sealed class ChannelWorkerClient(string sessionId, ChannelReader<WorkerEv
{ {
} }
/// <inheritdoc /> /// <summary>Completes immediately because the fake holds no resources.</summary>
/// <returns>A completed value task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;
} }
@@ -0,0 +1,69 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Tests.TestSupport;
/// <summary>Coordinates a fake worker's incoming frames and event emission gate.</summary>
internal sealed class GatedWorkerEnvelopeReader(
Func<CancellationToken, Task<WorkerEnvelope>> read,
SemaphoreSlim gate,
CancellationToken cancellationToken) : IAsyncDisposable
{
private readonly CancellationTokenSource _lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
private Task<WorkerEnvelope>? _pendingRead;
private Task? _pendingGate;
/// <summary>Reads an envelope, or returns null when emission should be checked.</summary>
/// <param name="watchGate">Whether the worker is currently subscribed and emitting.</param>
/// <returns>The next complete envelope, or null for an emission signal.</returns>
public async Task<WorkerEnvelope?> ReadOrGateAsync(bool watchGate = true)
{
// A gate wakeup must never cancel or replace a partially consumed frame.
_pendingRead ??= read(_lifetime.Token);
if (watchGate)
{
_pendingGate ??= gate.WaitAsync(_lifetime.Token);
await Task.WhenAny(_pendingRead, _pendingGate).ConfigureAwait(false);
if (!_pendingRead.IsCompleted)
{
await _pendingGate.ConfigureAwait(false);
_pendingGate = null;
return null;
}
}
WorkerEnvelope envelope = await _pendingRead.ConfigureAwait(false);
_pendingRead = null;
return envelope;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await _lifetime.CancelAsync().ConfigureAwait(false);
try
{
if (_pendingRead is not null)
{
await _pendingRead.ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
{
}
finally
{
try
{
if (_pendingGate is not null)
{
await _pendingGate.ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
{
}
_lifetime.Dispose();
}
}
}

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