Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c095b9a56 | |||
| 6c82de55c6 | |||
| 6f4c7c3bf1 | |||
| 6affbd57ea | |||
| 6fc423f608 | |||
| 83aa76c33d | |||
| d779daf6af | |||
| 42c120aa83 | |||
| 19920b13da | |||
| a99070005f | |||
| f40409a688 | |||
| f2e0861709 | |||
| 4f931e4b7e | |||
| d16ae4e267 | |||
| 74ba889dea |
@@ -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
|
||||
consumption, or per-project commands — update the **MxAccessGateway entry in
|
||||
[`../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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@@ -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 all five client suites on the final candidate.
|
||||
- [ ] Run final windev `live` gate at the exact candidate SHA.
|
||||
- [ ] 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.
|
||||
- [ ] 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.
|
||||
- [ ] Verify every registry coordinate and Go module/tag, update `docs/ClientPackaging.md`, and record immutable release evidence here.
|
||||
- [x] Run final windev `live` gate at the exact candidate SHA.
|
||||
- [x] Merge/push the verified continuation to `main`; wait for green CI.
|
||||
- [x] Re-query all package coordinates and Go tag immediately before publish; abort on collision.
|
||||
- [x] Publish .NET Client + Contracts 0.2.1, Python 0.2.1, Rust 0.2.1, Java 0.2.2.
|
||||
- [x] Create/push `clients/go/v0.2.1` at the exact release commit.
|
||||
- [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,
|
||||
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,
|
||||
**108 passed** plus clean `cargo fmt`/Clippy, and **132 passed / 0 failed** with the
|
||||
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>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private const string DefaultVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_PASSWORD";
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
private const string LegacyVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_USER_PASSWORD";
|
||||
@@ -379,14 +379,14 @@ public static class MxGatewayClientCli
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// alias, the environment variable named by <c>--password-env</c> (default
|
||||
/// <c>MXGATEWAY_VERIFY_PASSWORD</c>), then the deprecated
|
||||
/// <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
|
||||
/// the error-redaction catch block can strip it from any surfaced error
|
||||
/// (CLI-04), mirroring <see cref="TryResolveApiKey" />.
|
||||
/// the error-redaction catch block can strip it from any surfaced error,
|
||||
/// mirroring <see cref="TryResolveApiKey" />.
|
||||
/// </summary>
|
||||
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
|
||||
/// a redaction-safe error when no source yields a non-empty value. Failing
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
||||
// 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
|
||||
// it through MxGatewayCliSecretRedactor so it cannot reach stderr (CLI-04).
|
||||
// it through MxGatewayCliSecretRedactor so it cannot reach stderr.
|
||||
return InvokeAndWriteAsync(
|
||||
arguments,
|
||||
client,
|
||||
@@ -1425,7 +1425,7 @@ public static class MxGatewayClientCli
|
||||
else if (gatewayEvent.ReplayGap is { } replayGap)
|
||||
{
|
||||
// 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));
|
||||
}
|
||||
else
|
||||
@@ -1849,7 +1849,7 @@ public static class MxGatewayClientCli
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// formatter's quoted uint64 strings.
|
||||
/// </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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_WriteSecured_BuildsWriteSecuredCommand()
|
||||
{
|
||||
@@ -178,6 +179,7 @@ public sealed class MxGatewayClientCliTests
|
||||
/// Verifies that authenticate-user builds an AuthenticateUser command sourcing the
|
||||
/// credential from the flag, and that the credential never appears in stdout/stderr.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_AuthenticateUser_BuildsCommandAndDoesNotEchoCredential()
|
||||
{
|
||||
@@ -218,9 +220,10 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_AuthenticateUser_ErrorOutput_RedactsCredential()
|
||||
{
|
||||
@@ -248,7 +251,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
@@ -290,10 +293,12 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <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>MXGATEWAY_VERIFY_PASSWORD</c> shared by all five CLIs.
|
||||
/// </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>
|
||||
[Theory]
|
||||
[InlineData(null, "MXGATEWAY_VERIFY_PASSWORD")]
|
||||
@@ -340,7 +345,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <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>MXGATEWAY_VERIFY_USER_PASSWORD</c> environment variable both still resolve.
|
||||
/// </summary>
|
||||
@@ -413,7 +418,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// and the environment variable, never a value.
|
||||
/// </summary>
|
||||
@@ -463,6 +468,9 @@ public sealed class MxGatewayClientCliTests
|
||||
private readonly string _name;
|
||||
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)
|
||||
{
|
||||
_name = name;
|
||||
@@ -470,6 +478,7 @@ public sealed class MxGatewayClientCliTests
|
||||
Environment.SetEnvironmentVariable(name, value);
|
||||
}
|
||||
|
||||
/// <summary>Restores the original environment variable value.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.SetEnvironmentVariable(_name, _original);
|
||||
@@ -588,7 +597,7 @@ public sealed class MxGatewayClientCliTests
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
|
||||
@@ -228,6 +228,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
/// <see cref="MxEventStreamItem"/> with the gap populated, while normal
|
||||
/// events pass through unchanged with IsReplayGap false.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnregisterAsync_BuildsUnregisterCommand()
|
||||
{
|
||||
@@ -451,6 +453,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that advise-supervisory builds the supervisory advise command.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecuredAsync_BuildsWriteSecuredCommand()
|
||||
{
|
||||
@@ -598,6 +606,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
/// natively. The client must surface that scripted failure (as an
|
||||
/// <see cref="MxAccessException"/>) rather than pre-validating it away.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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
|
||||
/// reply-derived diagnostics, not the request payload.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ArchestraUserToIdAsync_BuildsCommandAndReturnsUserId()
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
/// </summary>
|
||||
public sealed class MxGatewaySecretRedactionTests
|
||||
{
|
||||
/// <summary>Test: Redact replaces every occurrence of secret.</summary>
|
||||
[Fact]
|
||||
public void Redact_ReplacesEveryOccurrenceOfSecret()
|
||||
{
|
||||
@@ -19,6 +20,7 @@ public sealed class MxGatewaySecretRedactionTests
|
||||
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]
|
||||
public void Redact_ScrubsBothSecretsWhenOneIsSubstringOfTheOther()
|
||||
{
|
||||
@@ -33,6 +35,7 @@ public sealed class MxGatewaySecretRedactionTests
|
||||
Assert.DoesNotContain("secret", result, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Test: Redact with null secrets array returns message unchanged.</summary>
|
||||
[Fact]
|
||||
public void Redact_WithNullSecretsArray_ReturnsMessageUnchanged()
|
||||
{
|
||||
@@ -43,6 +46,7 @@ public sealed class MxGatewaySecretRedactionTests
|
||||
Assert.Equal(message, result);
|
||||
}
|
||||
|
||||
/// <summary>Test: Redact with empty secrets array returns message unchanged.</summary>
|
||||
[Fact]
|
||||
public void Redact_WithEmptySecretsArray_ReturnsMessageUnchanged()
|
||||
{
|
||||
@@ -53,6 +57,7 @@ public sealed class MxGatewaySecretRedactionTests
|
||||
Assert.Equal(message, result);
|
||||
}
|
||||
|
||||
/// <summary>Test: Redact ignores whitespace only secret.</summary>
|
||||
[Fact]
|
||||
public void Redact_IgnoresWhitespaceOnlySecret()
|
||||
{
|
||||
@@ -64,6 +69,7 @@ public sealed class MxGatewaySecretRedactionTests
|
||||
Assert.Equal(message, result);
|
||||
}
|
||||
|
||||
/// <summary>Test: Redacted preserves concrete subtype and does not chain secret bearing original.</summary>
|
||||
[Fact]
|
||||
public void Redacted_PreservesConcreteSubtypeAndDoesNotChainSecretBearingOriginal()
|
||||
{
|
||||
|
||||
+11
-6
@@ -4,15 +4,16 @@ using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public sealed class MxGatewaySessionReplyContractTests
|
||||
{
|
||||
/// <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.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateUserAsync_RedactsEchoedCredentialInFailureMessage()
|
||||
{
|
||||
@@ -33,13 +34,14 @@ public sealed class MxGatewaySessionReplyContractTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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
|
||||
/// and the MXACCESS_FAILURE reply route to <see cref="MxAccessException"/>, so both must scrub.
|
||||
/// </summary>
|
||||
/// <param name="fixture">The echoed-credential reply fixture to drive.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("authenticate-user.echoed-credential.reply.json")]
|
||||
[InlineData("authenticate-user.echoed-credential-mxaccess-failure.reply.json")]
|
||||
@@ -71,9 +73,10 @@ public sealed class MxGatewaySessionReplyContractTests
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateUserAsync_MissingPayloadAndReturnValue_ThrowsMalformedReply()
|
||||
{
|
||||
@@ -87,9 +90,10 @@ public sealed class MxGatewaySessionReplyContractTests
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateUserAsync_ReturnValueOnly_ResolvesReturnValue()
|
||||
{
|
||||
@@ -104,9 +108,10 @@ public sealed class MxGatewaySessionReplyContractTests
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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>
|
||||
/// <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]
|
||||
[InlineData(MxStatusCategory.Ok, 0, true)]
|
||||
[InlineData(MxStatusCategory.Ok, 1, true)]
|
||||
|
||||
@@ -96,7 +96,8 @@ internal sealed class WireFakeGatewayServer : IAsyncDisposable
|
||||
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()
|
||||
{
|
||||
await _app.StopAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -854,6 +854,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task UnregisterAsync(
|
||||
int serverHandle,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -888,6 +889,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task AdviseSupervisoryAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
@@ -985,6 +987,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="updateIntervalMilliseconds">The buffered update interval, in milliseconds.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task SetBufferedUpdateIntervalAsync(
|
||||
int serverHandle,
|
||||
int updateIntervalMilliseconds,
|
||||
@@ -1125,6 +1128,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="currentUserId">The current operator user id.</param>
|
||||
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task WriteSecuredAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
@@ -1204,6 +1208,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="currentUserId">The current operator user id.</param>
|
||||
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task WriteSecured2Async(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
|
||||
@@ -14,6 +14,9 @@ pub mod mxaccess_gateway {
|
||||
/// gateway to language clients.
|
||||
pub mod v1 {
|
||||
#![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");
|
||||
}
|
||||
@@ -36,6 +39,9 @@ pub mod galaxy_repository {
|
||||
/// discovery and deploy-event watch RPCs.
|
||||
pub mod v1 {
|
||||
#![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");
|
||||
}
|
||||
|
||||
@@ -793,6 +793,55 @@ in the codebase for the forward-compat shape, but the gateway-side
|
||||
`AcknowledgeAlarmByName` when the public RPC supplies a recognizable
|
||||
`Provider!Group.Tag` reference.
|
||||
|
||||
**Opt-in managed GUID ack — does not work on this rig (live-tested
|
||||
2026-10-18).** Setting `MXGATEWAY_USE_MANAGED_ALARM_ACK=1` in the
|
||||
gateway's environment (workers inherit it) makes the worker build an
|
||||
`AaManagedAlarmAckConsumer`, which drives
|
||||
`aaAlarmManagedClient.AlarmClient` by reflection (its members are
|
||||
non-public) and is meant to take GUID-keyed acks in place of the
|
||||
wnwrap method. The live test shows it cannot do that as built, so leave
|
||||
the flag off.
|
||||
|
||||
The test raised `TestMachine_001.TestAlarm001` with a secured write,
|
||||
read its GUID from the wnwrap XML snapshot (the public API exposes no
|
||||
alarm GUIDs), and acknowledged it through the `AcknowledgeAlarm` RPC
|
||||
with the flag on:
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| RPC ack by GUID, flag off (control) | `NotImplementedException`; `.Acked` stays `False` |
|
||||
| RPC ack by GUID, flag on | Same `NotImplementedException`; `.Acked` `False`, `.AckMsg` empty |
|
||||
| `AlarmClient.RegisterConsumer` in a process with a registered wnwrap consumer | `-65` — and wnwrap gets `-65` if the managed client registered first; a real `hWnd` does not help |
|
||||
| Managed client alone in its own process, records loaded | `AlarmAckByGUID` → `-1`, `SelectByGUID` → `-1` for the wnwrap GUID in both byte orders, `AlarmAckSelected` → `-57` |
|
||||
|
||||
Three separate problems follow:
|
||||
|
||||
1. **The two libraries cannot share a process.** The worker always runs
|
||||
a wnwrap reader, so the managed `RegisterConsumer` fails with `-65`.
|
||||
The dispatcher is built to drop back to the wnwrap ack path on a
|
||||
lifecycle failure (so alarm reads keep working), and it does so
|
||||
silently — which is why the flag-on RPC is indistinguishable from the
|
||||
control.
|
||||
2. **The managed client does not recognise the wnwrap GUID.** `-1` from
|
||||
`SelectByGUID` is "not found", not a security refusal, even with the
|
||||
client holding records. Getting the client's own identifier for the
|
||||
alarm is not reliable either: `GetHighPriAlarm` mostly throws the
|
||||
FILETIME `ArgumentOutOfRangeException` described above. The
|
||||
`MxSecurityOperate` question was therefore never reached.
|
||||
3. **The gateway does not send the identity fields.** Its GUID arm fills
|
||||
`OperatorNode`, `OperatorDomain` and `OperatorFullName` with empty
|
||||
strings, so the domain and full name the managed method carries
|
||||
would not arrive through the public RPC even if 1 and 2 were solved.
|
||||
|
||||
A working managed ack would need the client hosted out of process, a way
|
||||
to resolve the alarm to the identifier the managed client uses, and the
|
||||
identity fields plumbed through the gateway. Routing rules the worker
|
||||
already applies stay correct and would carry over: subtag-mode acks
|
||||
(synthetic GUIDs) stay on the subtag consumer, and by-name acks stay on
|
||||
wnwrap because `AlarmClient` has no by-name ack. The skip-gated
|
||||
`AlarmManagedGuidAckLiveSmokeTests` exercises the in-process
|
||||
arrangement and hits problem 1.
|
||||
|
||||
### 5. STA / threading — resolved
|
||||
|
||||
The wnwrap COM is `ThreadingModel=Apartment`. The consumer's
|
||||
|
||||
+10
-7
@@ -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
|
||||
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
|
||||
.NET, Go, Python, and Rust clients (plus Contracts) read **0.2.1**, and the
|
||||
Java client reads **0.2.2**. These are not yet published — the numbers
|
||||
above (0.2.0 four clients + Contracts, 0.2.1 Java) remain the live registry
|
||||
state until the next `pack-clients.ps1` / `tag-go-module.ps1` run. Once that
|
||||
publish lands, update this section with the new coordinates and commit(s),
|
||||
the same way the 2026-08-07 record above was written after the fact.
|
||||
On 2026-08-25 the superseding release shipped from commit
|
||||
`5776021970cbbe59813ba57579abe00d9fc61a46`. Published coordinates on
|
||||
`gitea.dohertylan.com` 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**. 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
|
||||
|
||||
|
||||
@@ -446,6 +446,14 @@ behavior.
|
||||
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
|
||||
`StreamAlarms` are session-less RPCs served by the monitor.
|
||||
|
||||
The worker also reads `MXGATEWAY_USE_MANAGED_ALARM_ACK` (inherited from the
|
||||
gateway process). Setting it to `1` is meant to route GUID-keyed acknowledges
|
||||
through AVEVA's managed alarm client, but a live test (2026-10-18) showed the
|
||||
path cannot work as built: the managed client fails to register alongside the
|
||||
worker's wnwrap reader and the worker silently falls back to wnwrap. Leave it
|
||||
unset. It has no `MxGateway:*` option. See
|
||||
[AlarmClientDiscovery](./AlarmClientDiscovery.md) ("Opt-in managed GUID ack").
|
||||
|
||||
### Alarm fallback options
|
||||
|
||||
The `Fallback` sub-section controls how the alarm feed selects between the
|
||||
|
||||
@@ -394,12 +394,12 @@ Query every exact registry coordinate for HTTP 200 and resolve the Go module/tag
|
||||
|
||||
## Final Tasklist Exit Gate
|
||||
|
||||
- [ ] All 16 open P2 findings are Done with commit/test evidence.
|
||||
- [ ] WRK-34 is corrected to Done (`3679e59`).
|
||||
- [ ] Live watch-list refresh passes portable and windev tests.
|
||||
- [ ] WRK-32 public proto comment is regenerated through every client surface.
|
||||
- [ ] TST-35 nightly Windows gateway suite is proven on windev.
|
||||
- [ ] Full NonWindows gateway suite and all five client suites pass.
|
||||
- [ ] Worker.Tests and Windows gateway suite pass at the integrated release SHA.
|
||||
- [ ] 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] All 16 open P2 findings are Done with commit/test evidence.
|
||||
- [x] WRK-34 is corrected to Done (`3679e59`).
|
||||
- [x] Live watch-list refresh passes portable and windev tests.
|
||||
- [x] WRK-32 public proto comment is regenerated through every client surface.
|
||||
- [x] TST-35 nightly Windows gateway suite is proven on windev.
|
||||
- [x] Full NonWindows gateway suite and all five client suites pass.
|
||||
- [x] Worker.Tests and Windows gateway suite pass at the integrated release SHA.
|
||||
- [x] Five client releases plus Contracts are confirmed in registries; Go tag resolves.
|
||||
- [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>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
|
||||
/// 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.
|
||||
/// This is a documented behavioral bound, not wire schema — it is deliberately a C#
|
||||
/// constant and not a <c>.proto</c> field.
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class DashboardLdapLiveTests
|
||||
private const string SharedDirectoryPassword = "password";
|
||||
|
||||
/// <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
|
||||
/// list — nothing in the shared directory carries them.
|
||||
/// </summary>
|
||||
@@ -168,7 +168,7 @@ public sealed class DashboardLdapLiveTests
|
||||
}
|
||||
|
||||
/// <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>,
|
||||
/// 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.
|
||||
@@ -275,7 +275,7 @@ public sealed class DashboardLdapLiveTests
|
||||
};
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
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)
|
||||
// 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.
|
||||
// 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
|
||||
// degradation is visible in the test output even though it never fails
|
||||
// the test by itself.
|
||||
|
||||
+2
-2
@@ -22,8 +22,8 @@
|
||||
(IntegrationTests-028).
|
||||
-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.2.1" />
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.2.1" />
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.3.0" />
|
||||
<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.Binder" Version="10.0.7" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -19,7 +19,7 @@ public static class AlarmProviderReasons
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// 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
|
||||
/// renders it against the <c>ALARMMGR</c> mode as a coverage badge rather than a failover.
|
||||
/// </summary>
|
||||
@@ -29,7 +29,7 @@ public static class AlarmProviderReasons
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// (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.
|
||||
/// </summary>
|
||||
public const string SubtagDiscoveryFailed =
|
||||
@@ -37,7 +37,7 @@ public static class AlarmProviderReasons
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// rather than applied; a monitor restart re-resolves and re-sends.
|
||||
/// </summary>
|
||||
|
||||
@@ -5,8 +5,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||
/// <summary>
|
||||
/// 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"
|
||||
/// from one that means "discovery was configured and silently produced nothing"
|
||||
/// (GWC-37). <see cref="IAlarmWatchListResolver.ResolveAsync"/> deliberately never throws
|
||||
/// from one that means "discovery was configured and silently produced nothing".
|
||||
/// <see cref="IAlarmWatchListResolver.ResolveAsync"/> deliberately never throws
|
||||
/// 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.
|
||||
/// </summary>
|
||||
|
||||
@@ -39,7 +39,7 @@ public sealed class AlarmWatchListResolver : IAlarmWatchListResolver
|
||||
|
||||
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
|
||||
// deliberately swallowed below.
|
||||
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.
|
||||
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
|
||||
// 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,
|
||||
@@ -57,7 +57,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
private string _providerReason = string.Empty;
|
||||
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
|
||||
// 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
|
||||
@@ -258,7 +258,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
|
||||
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
|
||||
// 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
|
||||
@@ -351,7 +351,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
|
||||
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
|
||||
// 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.
|
||||
@@ -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
|
||||
// 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
|
||||
@@ -521,7 +521,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
&& 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
|
||||
// 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
|
||||
@@ -803,7 +803,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
|
||||
// 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
|
||||
// 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:
|
||||
@@ -857,7 +857,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
// Replaces the cache with the worker's authoritative snapshot, broadcasting
|
||||
// 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
|
||||
// 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
|
||||
@@ -867,7 +867,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
/// (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,
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
||||
/// 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
|
||||
/// 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.
|
||||
/// Blank is handled by the caller's required-field check and is not treated as non-rooted here.
|
||||
/// </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).",
|
||||
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.
|
||||
// Negatives express no intent.
|
||||
AddIfNegative(
|
||||
@@ -341,7 +341,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
}
|
||||
|
||||
// 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
|
||||
// upper-bound pattern.
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
@@ -671,7 +671,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
// Path rooting/validity rules are shared with GalaxyRepositoryOptionsValidator (both write a
|
||||
// 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
|
||||
// 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)
|
||||
=> 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
|
||||
/// <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
|
||||
/// 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
|
||||
/// (<c>GatewayHello.max_frame_bytes</c>). Default is the 16 MB public gRPC cap plus one reserve.
|
||||
/// </summary>
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// <summary>
|
||||
/// 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 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>
|
||||
public sealed class AuthenticatedDashboardRoleHandler
|
||||
: AuthorizationHandler<AuthenticatedDashboardRoleRequirement>
|
||||
|
||||
@@ -13,9 +13,9 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// into this one by accident.
|
||||
/// </summary>
|
||||
/// <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
|
||||
/// 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),
|
||||
/// 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
|
||||
|
||||
@@ -32,7 +32,7 @@ public sealed record DashboardAlarmProviderStatus(
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// badge names the standby rather than claiming the feed has failed over.
|
||||
/// </summary>
|
||||
@@ -77,7 +77,7 @@ public sealed record DashboardAlarmProviderStatus(
|
||||
&& status.Mode == AlarmProviderMode.Subtag
|
||||
&& 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
|
||||
// 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.
|
||||
|
||||
@@ -35,7 +35,7 @@ public static class DashboardAuthenticationDefaults
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="AuthenticatedDashboardRoleRequirement"/>, a requirement type
|
||||
/// <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";
|
||||
|
||||
/// <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
|
||||
/// <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
|
||||
/// <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
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// <param name="roleMapper">Maps LDAP groups to dashboard roles.</param>
|
||||
/// <param name="options">
|
||||
/// 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 name="logger">Logger for diagnostic, credential-free login outcomes.</param>
|
||||
public sealed class DashboardAuthenticator(
|
||||
@@ -142,7 +142,7 @@ public sealed class DashboardAuthenticator(
|
||||
claims.AddRange(groupNames.Select(group => new Claim(
|
||||
DashboardAuthenticationDefaults.LdapGroupClaimType,
|
||||
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.
|
||||
claims.AddRange(DashboardGroupTagMapping
|
||||
.MapGroupsToTags(groupNames, groupToTag)
|
||||
|
||||
@@ -107,7 +107,7 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
// 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
|
||||
// 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.
|
||||
endpoints.MapHub<DashboardSnapshotHub>("/hubs/snapshot");
|
||||
endpoints.MapHub<AlarmsHub>("/hubs/alarms");
|
||||
|
||||
@@ -177,7 +177,7 @@ public static class DashboardServiceCollectionExtensions
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
DashboardAuthenticationDefaults.HubAuthenticationScheme)
|
||||
.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
|
||||
// does not handle, so neither Authentication:Mode=Disabled nor anonymous localhost
|
||||
// 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>
|
||||
/// </list>
|
||||
/// <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
|
||||
/// through <c>MxGateway:Dashboard:GroupToTag</c> as it stands in the currently bound options —
|
||||
/// the same single <see cref="DashboardGroupTagMapping.MapGroupsToTags"/> call
|
||||
@@ -120,7 +120,7 @@ public sealed class DashboardSessionAcl(
|
||||
|
||||
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
|
||||
// case-insensitive comparison in the same direction the claim scan below does.
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
|
||||
@@ -11,9 +11,9 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// 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.HubClientsPolicy"/> the other hubs use, so the
|
||||
/// 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
|
||||
/// those audiences is subject to this dashboard-display flag.
|
||||
/// <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
|
||||
/// <see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/>, so only an authenticated caller
|
||||
/// 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>)
|
||||
/// 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
|
||||
/// per-session decision is <see cref="IDashboardSessionAcl"/>'s
|
||||
/// (SEC-25 / TST-15). The admin bypass is evaluated first, so an
|
||||
/// per-session decision is <see cref="IDashboardSessionAcl"/>'s.
|
||||
/// The admin bypass is evaluated first, so an
|
||||
/// Administrator joins any session id they name; every check below it
|
||||
/// applies to non-Admin callers only. For those: a Viewer sees a 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
|
||||
return base.OnDisconnectedAsync(exception);
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>IDashboardSessionEventSubscriber.Subscribe</c> used by the
|
||||
/// session-details page.
|
||||
|
||||
@@ -154,8 +154,8 @@ public static class GatewayApplication
|
||||
// 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
|
||||
// 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
|
||||
// (GWC-38). Under Mode=Disabled identity-absent is expected and stays unscoped.
|
||||
// invariant when the identity is absent, instead of resolving to the full hierarchy.
|
||||
// Under Mode=Disabled identity-absent is expected and stays unscoped.
|
||||
builder.Services.AddSingleton<ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider,
|
||||
Security.Authorization.GatewayBrowseScopeProvider>();
|
||||
|
||||
@@ -164,7 +164,7 @@ public static class GatewayApplication
|
||||
// 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
|
||||
// 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"]))
|
||||
{
|
||||
builder.Configuration["MxGateway:Galaxy:SnapshotCachePath"] = Path.Combine(
|
||||
@@ -175,7 +175,7 @@ public static class GatewayApplication
|
||||
|
||||
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<
|
||||
Microsoft.Extensions.Options.IValidateOptions<ZB.MOM.WW.GalaxyRepository.GalaxyRepositoryOptions>,
|
||||
Configuration.GalaxyRepositoryOptionsValidator>();
|
||||
@@ -224,7 +224,7 @@ public static class GatewayApplication
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// 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
|
||||
|
||||
@@ -116,7 +116,7 @@ public sealed class EventStreamService(
|
||||
// its `yield return` is what feeds the gRPC writer.
|
||||
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
|
||||
// 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
|
||||
@@ -197,7 +197,7 @@ public sealed class EventStreamService(
|
||||
}
|
||||
|
||||
// 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.
|
||||
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
|
||||
// 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;
|
||||
GatewaySession session = await sessionManager
|
||||
.OpenSessionAsync(
|
||||
@@ -130,7 +130,7 @@ public sealed class MxAccessGatewayService(
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// commandToInvoke — the caller still reads it below via TrackCommandReply.
|
||||
WorkerCommand workerCommand = mapper.MapCommand(commandToInvoke);
|
||||
@@ -167,7 +167,7 @@ public sealed class MxAccessGatewayService(
|
||||
.WithCancellation(context.CancellationToken)
|
||||
.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-
|
||||
// volume gateway path. Stopwatch.GetTimestamp/GetElapsedTime measure the exact
|
||||
// same wall-clock span the former Stopwatch.StartNew()/.Elapsed did.
|
||||
@@ -185,15 +185,6 @@ public sealed class MxAccessGatewayService(
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Surfaces the public AcknowledgeAlarm RPC. Acknowledgement is
|
||||
/// session-less: the gateway routes it through the always-on
|
||||
/// <see cref="IGatewayAlarmService"/> monitor session. An
|
||||
/// <c>alarm_full_reference</c> that parses as a canonical GUID forwards
|
||||
/// to <c>AcknowledgeAlarmCommand</c>; a <c>Provider!Group.Tag</c>
|
||||
/// reference forwards to <c>AcknowledgeAlarmByNameCommand</c>; anything
|
||||
/// else returns an <c>InvalidRequest</c> diagnostic in the reply.
|
||||
/// </remarks>
|
||||
public override async Task<AcknowledgeAlarmReply> AcknowledgeAlarm(
|
||||
AcknowledgeAlarmRequest request,
|
||||
ServerCallContext context)
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class MxAccessGrpcMapper
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <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
|
||||
// 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
|
||||
@@ -93,10 +93,10 @@ public sealed class MxAccessGrpcMapper
|
||||
{
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
private readonly ConcurrentDictionary<string, long> _eventsBySession = new(StringComparer.Ordinal);
|
||||
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
|
||||
// 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
|
||||
@@ -43,7 +43,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
private readonly ConcurrentDictionary<long, Func<int>> _eventStreamBacklogSources = new();
|
||||
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
|
||||
// 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
|
||||
@@ -210,7 +210,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
/// <param name="method">Name of the command method.</param>
|
||||
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
|
||||
// consistent multi-field view; GetSnapshot reads each with Interlocked.Read.
|
||||
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
|
||||
/// 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
|
||||
/// the last value any one session happened to push (PERF-02).
|
||||
/// the last value any one session happened to push.
|
||||
/// </summary>
|
||||
/// <param name="depth">
|
||||
/// 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
|
||||
/// 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
|
||||
/// per-event gauge bookkeeping (GWC-15).
|
||||
/// per-event gauge bookkeeping.
|
||||
/// </summary>
|
||||
/// <param name="backlog">
|
||||
/// 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
|
||||
// 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));
|
||||
}
|
||||
|
||||
@@ -460,7 +460,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
{
|
||||
// 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
|
||||
// run under this lock. GWC-15, PERF-02.
|
||||
// run under this lock.
|
||||
int workerEventQueueDepth = GetWorkerEventQueueDepth();
|
||||
int grpcEventStreamQueueDepth = GetGrpcEventStreamQueueDepth();
|
||||
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);
|
||||
|
||||
// Sums the live backlog across every registered event-stream subscriber.
|
||||
@@ -571,6 +571,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
{
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>Removes the source from its gauge; subsequent calls are no-ops.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
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>
|
||||
private static readonly TimeSpan ShutdownDrainCap = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// <inheritdoc />
|
||||
public override async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -64,12 +59,7 @@ public sealed class AuditDrainService(
|
||||
await base.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// <inheritdoc />
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
writer.DetachDrain();
|
||||
|
||||
@@ -87,11 +87,9 @@ public sealed class SqliteCanonicalAuditStore(
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// One connection, one transaction and one prepared command for the whole batch: the drain
|
||||
/// pays a single commit for up to <see cref="AuditDrainService.MaxBatchSize"/> events rather
|
||||
/// than one round-trip per event.
|
||||
/// </remarks>
|
||||
// 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
|
||||
// than one round-trip per event.
|
||||
public async Task InsertBatchAsync(IReadOnlyList<AuditEvent> auditEvents, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(auditEvents);
|
||||
@@ -148,17 +146,15 @@ public sealed class SqliteCanonicalAuditStore(
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// 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 — 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
|
||||
/// older schema, a future producer). On a mixed-format column a text comparison silently
|
||||
/// deletes live audit: <c>2026-05-17T09:00:00-05:00</c> is two hours AFTER a
|
||||
/// <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 <c>datetime()</c> cannot parse yields
|
||||
/// NULL and is therefore never deleted — audit that cannot be dated is kept, not swept.
|
||||
/// </remarks>
|
||||
// The comparison goes through SQLite's datetime() rather than comparing the stored
|
||||
// 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
|
||||
// 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
|
||||
// deletes live audit: 2026-05-17T09:00:00-05:00 is two hours AFTER a
|
||||
// 2026-05-17T12:00:00+00:00 cutoff yet sorts before it. Comparing instants is correct
|
||||
// regardless of how the text got there, and anything datetime() cannot parse yields
|
||||
// NULL and is therefore never deleted — audit that cannot be dated is kept, not swept.
|
||||
public async Task<int> DeleteOlderThanAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteConnection connection =
|
||||
|
||||
@@ -13,7 +13,7 @@ public sealed record ApiKeyConstraints(
|
||||
private readonly IReadOnlyList<string> _dashboardTags = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dashboard event-visibility tags granted to this key (SEC-25).
|
||||
/// Gets the dashboard event-visibility tags granted to this key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
|
||||
@@ -44,7 +44,7 @@ public interface IApiKeyCacheInvalidator
|
||||
/// 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
|
||||
/// 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>
|
||||
/// 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
|
||||
// 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
|
||||
// ExpiresUtc — that window remains TTL-bounded and documented in the class remarks.
|
||||
private void TryCacheSuccess(
|
||||
@@ -202,11 +202,14 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
|
||||
? generation
|
||||
: 0;
|
||||
|
||||
// Parses the key id out of a "Bearer mxgw_<keyId>_<secret>" header without any store access —
|
||||
// the same split the authorization interceptor does. Returns null for a header this cache cannot
|
||||
// attribute to a key id (in which case the generation race-guard is simply not applied).
|
||||
//
|
||||
// Correctness of the SEC-34 generation guard rests on parts[1] being the FULL key id: the '_'
|
||||
/// <summary>
|
||||
/// Parses the key id out of a <c>Bearer mxgw_<keyId>_<secret></c> header without any store access,
|
||||
/// using the same split the authorization interceptor does.
|
||||
/// </summary>
|
||||
/// <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
|
||||
// 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
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
/// source-rotating sprayer that never trips any single partition.
|
||||
/// </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
|
||||
/// 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
|
||||
@@ -29,8 +29,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
/// 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
|
||||
/// 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
|
||||
/// (SEC-32). A validly shaped presented key id still feeds its cross-peer aggregate when its peer
|
||||
/// address's fallback partition), the map bounds memory <em>and</em> resists a junk-token flush.
|
||||
/// 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
|
||||
/// 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
|
||||
|
||||
@@ -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>
|
||||
/// <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
|
||||
/// 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.
|
||||
|
||||
+7
-2
@@ -140,12 +140,17 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
/// <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_<keyId>_<secret></c> shape; otherwise <see langword="null"/>.</returns>
|
||||
internal static string? TryResolveKeyId(string? authorizationHeader)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authorizationHeader))
|
||||
|
||||
@@ -39,7 +39,7 @@ public sealed class GatewaySession
|
||||
private bool _everHadEventSubscriber;
|
||||
private SessionEventDistributor? _eventDistributor;
|
||||
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
|
||||
// EnsureDistributorCreated reads the null as "not created yet" and builds a REPLACEMENT
|
||||
// distributor after teardown — one whose pump can never start (_eventDistributorStarted is a
|
||||
@@ -163,7 +163,7 @@ public sealed class GatewaySession
|
||||
/// is unit-testable.
|
||||
/// </param>
|
||||
/// <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 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.
|
||||
@@ -265,8 +265,8 @@ public sealed class GatewaySession
|
||||
public string? OwnerKeyId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dashboard event-visibility tags this session inherited from its owning API key
|
||||
/// (SEC-25). An empty set means untagged.
|
||||
/// Gets the dashboard event-visibility tags this session inherited from its owning API key.
|
||||
/// An empty set means untagged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
// 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
|
||||
// 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.
|
||||
@@ -610,7 +610,7 @@ public sealed class GatewaySession
|
||||
/// </exception>
|
||||
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
|
||||
// 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
|
||||
@@ -694,7 +694,7 @@ public sealed class GatewaySession
|
||||
// These three calls are OUTSIDE _syncRoot to avoid holding it across
|
||||
// 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
|
||||
// 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
|
||||
@@ -1918,7 +1918,7 @@ public sealed class GatewaySession
|
||||
{
|
||||
distributor = _eventDistributor;
|
||||
_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.
|
||||
_eventDistributorDisposed = true;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public interface ISessionManager
|
||||
|
||||
/// <summary>
|
||||
/// Opens a new gateway session, stamping the owning API key's dashboard event-visibility
|
||||
/// tags onto it (SEC-25).
|
||||
/// tags onto it.
|
||||
/// </summary>
|
||||
/// <param name="request">Request payload.</param>
|
||||
/// <param name="clientIdentity">Client identity string.</param>
|
||||
|
||||
@@ -520,7 +520,7 @@ public sealed class SessionEventDistributor : IAsyncDisposable
|
||||
{
|
||||
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
|
||||
// after_worker_sequence = oldest_available_sequence - 1, so reporting 0 here made
|
||||
// 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
|
||||
// (TMPDIR + "CoreFxPipe_" + name) inside the 104-byte sun_path limit on
|
||||
// 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 nonce = CreateNonce();
|
||||
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
|
||||
// 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
|
||||
// (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
|
||||
// write into _events still completed inside EventChannelFullModeTimeout. A rejected TryWrite here
|
||||
// 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 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
|
||||
// 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.
|
||||
@@ -233,13 +233,13 @@ public sealed class WorkerClient : IWorkerClient
|
||||
// size the caller controls; checking here keeps a MessageTooLarge in the write loop a
|
||||
// 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
|
||||
// (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
|
||||
// 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
|
||||
// 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
|
||||
@@ -257,7 +257,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
|
||||
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
|
||||
// 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
|
||||
@@ -354,7 +354,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
{
|
||||
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);
|
||||
yield return workerEvent;
|
||||
}
|
||||
@@ -420,7 +420,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
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
|
||||
// client disposed while still Ready keeps reporting IsTerminalState() == false, and a read
|
||||
// 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
|
||||
/// 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
|
||||
/// 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
|
||||
/// 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
|
||||
@@ -505,12 +505,12 @@ public sealed class WorkerClient : IWorkerClient
|
||||
{
|
||||
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
|
||||
// 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
|
||||
// (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);
|
||||
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:
|
||||
/// 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
|
||||
/// 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
|
||||
/// 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
|
||||
/// 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
|
||||
/// complementary full-stall case (a consumer that stops entirely) is caught earlier by the
|
||||
/// timed write in <see cref="EnqueueWorkerEventAsync"/>.
|
||||
@@ -753,7 +753,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
/// the actionable fix (attach <c>StreamEvents</c> or raise
|
||||
/// <c>MxGateway:Events:QueueCapacity</c>). This is the full-stall half of
|
||||
/// 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
|
||||
/// was staged, so moving it between the two channels changes nothing.
|
||||
/// </summary>
|
||||
@@ -851,7 +851,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// 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>
|
||||
/// 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
|
||||
@@ -982,7 +982,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
{
|
||||
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)
|
||||
// — 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,
|
||||
@@ -1303,7 +1303,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
string correlationId,
|
||||
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
|
||||
// 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
|
||||
@@ -1340,7 +1340,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
{
|
||||
// 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
|
||||
// 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()
|
||||
{
|
||||
ProtocolVersion = _connection.FrameOptions.ProtocolVersion,
|
||||
|
||||
@@ -19,7 +19,7 @@ public sealed class WorkerFrameReader
|
||||
private readonly WorkerFrameProtocolOptions _options;
|
||||
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.
|
||||
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
|
||||
// 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
|
||||
// frame; large event frames near the cap would otherwise allocate an LOH buffer each time.
|
||||
// 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
|
||||
// buffer may be larger than requested, so the read and parse are bounded to length.
|
||||
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
|
||||
// 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
|
||||
// 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.
|
||||
int frameLength = sizeof(uint) + payloadLength;
|
||||
byte[] frame = ArrayPool<byte>.Shared.Rent(frameLength);
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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.Ldap" Version="0.2.1" />
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.2.1" />
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.2.1" />
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.3.0" />
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.3.0" />
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.3.0" />
|
||||
<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.Theme" Version="0.4.1" />
|
||||
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
||||
|
||||
@@ -19,7 +19,7 @@ public sealed class AlarmWatchListResolverTests
|
||||
/// <summary>
|
||||
/// Resolves and projects to the target list. These cases assert composition rules, which
|
||||
/// 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>
|
||||
/// <param name="resolver">The resolver under test.</param>
|
||||
/// <param name="options">Alarm options to resolve against.</param>
|
||||
@@ -259,7 +259,7 @@ public sealed class AlarmWatchListResolverTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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
|
||||
@@ -284,7 +284,7 @@ public sealed class AlarmWatchListResolverTests
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// must not report as a degraded standby (GWC-37).
|
||||
/// must not report as a degraded standby.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
|
||||
@@ -13,7 +13,7 @@ using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Alarms;
|
||||
|
||||
/// <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
|
||||
/// <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
|
||||
@@ -156,7 +156,7 @@ public sealed class GatewayAlarmMonitorAttachOrderTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// reconcile repair must broadcast a Raise carrying the incoming instance identity.
|
||||
/// </summary>
|
||||
@@ -216,7 +216,7 @@ public sealed class GatewayAlarmMonitorAttachOrderTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// timestamp and resulting state the cache already holds is a duplicate and is suppressed.
|
||||
/// </summary>
|
||||
@@ -275,7 +275,7 @@ public sealed class GatewayAlarmMonitorAttachOrderTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// raise timestamp — and a genuinely new raise/clear cycle is never swallowed.
|
||||
/// </summary>
|
||||
@@ -490,7 +490,7 @@ public sealed class GatewayAlarmMonitorAttachOrderTests
|
||||
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(
|
||||
AlarmConditionState state,
|
||||
Timestamp lastTransition,
|
||||
@@ -502,7 +502,7 @@ public sealed class GatewayAlarmMonitorAttachOrderTests
|
||||
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(
|
||||
ulong sequence,
|
||||
AlarmTransitionKind kind,
|
||||
|
||||
@@ -624,7 +624,7 @@ public sealed class GatewayAlarmMonitorProviderModeTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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
|
||||
@@ -661,7 +661,7 @@ public sealed class GatewayAlarmMonitorProviderModeTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// <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
|
||||
@@ -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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WatchListRefresh_WorkerRejects_RetainsFingerprintAndRetriesSameTargets()
|
||||
{
|
||||
@@ -937,7 +939,7 @@ public sealed class GatewayAlarmMonitorProviderModeTests
|
||||
/// <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
|
||||
/// 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>
|
||||
/// <param name="script">Resolutions to return, in call order.</param>
|
||||
private sealed class ScriptedWatchListResolver(params AlarmWatchListResolution[] script) : IAlarmWatchListResolver
|
||||
@@ -997,6 +999,8 @@ public sealed class GatewayAlarmMonitorProviderModeTests
|
||||
_events.Writer.TryWrite(new WorkerEvent { Event = mxEvent });
|
||||
|
||||
/// <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
|
||||
{
|
||||
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>
|
||||
/// <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)
|
||||
{
|
||||
await WaitUntilAsync(() => UpdateCommands.Count >= count, timeout);
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
|
||||
|
||||
/// <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
|
||||
/// 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.
|
||||
@@ -109,7 +109,7 @@ public sealed class GalaxyRepositoryOptionsValidatorTests
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// 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>
|
||||
[Fact]
|
||||
public void Build_DefaultsBlankSnapshotCachePathToRootedDefault()
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// from; the redaction assertions pin the opposite invariant, that the two secret-bearing members
|
||||
/// are the only ones masked.
|
||||
|
||||
@@ -86,7 +86,7 @@ public sealed class GatewayOptionsTests
|
||||
["MxGateway:Events:QueueCapacity"] = "256",
|
||||
["MxGateway:Dashboard:Enabled"] = "false",
|
||||
["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
|
||||
// gRPC cap without moving this one no longer validates. Kept at one reserve above,
|
||||
// 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
|
||||
/// 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 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>
|
||||
[Fact]
|
||||
public void DashboardOptions_GroupToTag_BindsCaseInsensitively()
|
||||
|
||||
@@ -778,7 +778,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// relative file. On Windows the same literal is genuinely rooted and passes.
|
||||
/// </summary>
|
||||
@@ -805,7 +805,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -1078,7 +1078,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
result.Failures!,
|
||||
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
|
||||
// "fixed" by re-committing a value.
|
||||
Assert.Contains("dotnet user-secrets set", failure);
|
||||
@@ -1258,7 +1258,7 @@ public sealed class GatewayOptionsValidatorTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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
|
||||
|
||||
@@ -18,6 +18,10 @@ namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
|
||||
/// </summary>
|
||||
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]
|
||||
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
|
||||
/// the seeded plaintext through the same pre-host expander the host runs.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExpandConfiguration_LdapBindReference_ResolvesToSeededPassword()
|
||||
{
|
||||
@@ -52,6 +57,10 @@ public sealed class PreHostSecretExpansionTests
|
||||
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]
|
||||
public async Task ExpandConfiguration_MissingReference_ThrowsFailClosed()
|
||||
{
|
||||
@@ -81,8 +90,11 @@ public sealed class PreHostSecretExpansionTests
|
||||
_envVarName = envVarName;
|
||||
}
|
||||
|
||||
/// <summary>Gets the secret resolver from the fixture's DI graph.</summary>
|
||||
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()
|
||||
{
|
||||
string unique = Guid.NewGuid().ToString("N");
|
||||
@@ -111,8 +123,13 @@ public sealed class PreHostSecretExpansionTests
|
||||
return new SecretsFixture(provider, sqlitePath, envVarName);
|
||||
}
|
||||
|
||||
// Seals a plaintext value under a fresh DEK and upserts it — the same store/cipher pair the
|
||||
// CLI 'secret set' verb uses.
|
||||
/// <summary>
|
||||
/// 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)
|
||||
{
|
||||
var secretName = new SecretName(name);
|
||||
@@ -122,11 +139,15 @@ public sealed class PreHostSecretExpansionTests
|
||||
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) =>
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(entries.ToDictionary(e => e.Key, e => (string?)e.Value))
|
||||
.Build();
|
||||
|
||||
/// <summary>Disposes the service provider and clears the test environment variable.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_provider.Dispose();
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// 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"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -100,7 +100,7 @@ public sealed class SecretsNavRenderTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// negative above: this is the assertion that distinguishes a gate from an inert one.
|
||||
/// </summary>
|
||||
@@ -114,7 +114,7 @@ public sealed class SecretsNavRenderTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// real dashboard role because the central monitor carries no per-session provenance.
|
||||
/// </summary>
|
||||
@@ -135,7 +135,7 @@ public sealed class SecretsNavRenderTests
|
||||
{
|
||||
options.AddSecretsAuthorization();
|
||||
// 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(
|
||||
DashboardAuthenticationDefaults.AlarmsPolicy,
|
||||
policy => policy.AddRequirements(AuthenticatedDashboardRoleRequirement.AnyDashboardRole));
|
||||
@@ -163,6 +163,7 @@ public sealed class SecretsNavRenderTests
|
||||
// vacuously.
|
||||
private sealed class StubAuthenticationStateProvider(string[] roles) : AuthenticationStateProvider
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
{
|
||||
ClaimsIdentity identity = roles.Length == 0
|
||||
@@ -181,6 +182,7 @@ public sealed class SecretsNavRenderTests
|
||||
// under static rendering.
|
||||
private sealed class StubNavigationManager : NavigationManager
|
||||
{
|
||||
/// <summary>Initializes the stub with a fixed localhost base URI.</summary>
|
||||
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;
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
|
||||
@@ -16,7 +16,7 @@ using AuthenticationOptions = ZB.MOM.WW.MxGateway.Server.Configuration.Authentic
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
/// <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
|
||||
/// receive galaxy-wide alarm metadata.
|
||||
/// </summary>
|
||||
@@ -65,7 +65,7 @@ public sealed class AlarmFeedAuthorizationTests
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// per-session provenance to tag-scope by.
|
||||
/// </summary>
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// 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>
|
||||
@@ -286,22 +286,48 @@ public sealed class AlarmsHubPublisherTests
|
||||
/// <summary>Gets the capturing client proxy shared by this fake.</summary>
|
||||
public CapturingClientProxy GroupProxy { get; } = new();
|
||||
|
||||
/// <summary>Gets the shared capturing proxy for all clients.</summary>
|
||||
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;
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
@@ -342,9 +368,19 @@ public sealed class AlarmsHubPublisherTests
|
||||
|
||||
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)
|
||||
=> 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)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
|
||||
/// <summary>
|
||||
/// An explicitly-degraded status whose mode is still Alarmmgr (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
|
||||
/// reporting "Subtag monitoring (degraded)" would name the wrong live provider.
|
||||
/// </summary>
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// shared source event is never mutated. Also verifies the viewer gate — the
|
||||
/// 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>
|
||||
public CapturingClientProxy GroupProxy { get; } = new();
|
||||
|
||||
/// <summary>Gets the shared capturing proxy for all connections.</summary>
|
||||
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;
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
@@ -367,9 +393,19 @@ public sealed class DashboardEventBroadcasterTests
|
||||
|
||||
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)
|
||||
=> 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)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed class DashboardLiveDataServiceTests
|
||||
/// <summary>
|
||||
/// Verifies a tag already in the advise set is not subscribed again on a later read.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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
|
||||
/// least-recently-read tag on the worker and leaves the re-read tag advised.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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),
|
||||
/// and the next read that subscribes anything squeezes the overshoot back out.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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
|
||||
/// the cap, and the following read trims it back.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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
|
||||
/// without any unsubscribe command — there is no item handle to unadvise.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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
|
||||
/// evicted tag is dropped from tracking anyway.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WhenEvictionUnsubscribeFails_StillCompletesRead()
|
||||
{
|
||||
@@ -190,10 +196,11 @@ public sealed class DashboardLiveDataServiceTests
|
||||
|
||||
/// <summary>
|
||||
/// 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,
|
||||
/// never the formatted tag value.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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
|
||||
/// contradict the error the same row displays.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
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;
|
||||
|
||||
private BulkSubscribeReply Subscribe(IEnumerable<string> tagAddresses)
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="DashboardSessionAcl"/>, the single decision both dashboard subscribe seams
|
||||
/// consult (SEC-25 / TST-15).
|
||||
/// consult.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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>
|
||||
/// <param name="sessionId">A blank or whitespace-only session id.</param>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
@@ -146,8 +147,8 @@ public sealed class DashboardSessionAclTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A principal carrying no tag claims — the anonymous-localhost / empty-grant Viewer of
|
||||
/// SEC-02 — sees a tagged session never, and an untagged one only when the operator opted
|
||||
/// A principal carrying no tag claims — the anonymous-localhost / empty-grant Viewer —
|
||||
/// sees a tagged session never, and an untagged one only when the operator opted
|
||||
/// into <see cref="UntaggedSessionVisibility.AllViewers"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -179,7 +180,7 @@ public sealed class DashboardSessionAclTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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
|
||||
@@ -224,7 +225,7 @@ public sealed class DashboardSessionAclTests
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// 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
|
||||
/// opposite direction.
|
||||
/// </summary>
|
||||
@@ -294,7 +295,7 @@ public sealed class DashboardSessionAclTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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.
|
||||
|
||||
@@ -7,7 +7,7 @@ using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the ACL gate on <see cref="EventsHub.SubscribeSession"/> (SEC-25 / TST-15).
|
||||
/// Covers the ACL gate on <see cref="EventsHub.SubscribeSession"/> .
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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>
|
||||
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)
|
||||
{
|
||||
Added.Add((connectionId, groupName));
|
||||
@@ -139,7 +143,11 @@ public sealed class EventsHubTests
|
||||
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(
|
||||
string connectionId,
|
||||
string groupName,
|
||||
|
||||
@@ -129,6 +129,8 @@ public sealed class EventsHubViewerRegistryTests
|
||||
}
|
||||
|
||||
/// <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]
|
||||
[InlineData("", "session-1")]
|
||||
[InlineData(" ", "session-1")]
|
||||
|
||||
@@ -178,7 +178,7 @@ public sealed class HubTokenServiceTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// <see cref="HubTokenService.Issue(ClaimsPrincipal)"/> and rehydrated as
|
||||
/// <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;
|
||||
|
||||
/// <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>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -214,7 +214,7 @@ public sealed class SessionDetailsPageEventAclTests
|
||||
/// <inheritdoc />
|
||||
public ChannelReader<MxEvent> Reader => _channel.Reader;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Marks the subscription as released and completes its channel.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
IsDisposed = true;
|
||||
|
||||
@@ -81,7 +81,7 @@ public sealed class GatewayApplicationTests
|
||||
{
|
||||
// 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
|
||||
// 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 app.StartAsync();
|
||||
try
|
||||
@@ -266,7 +266,7 @@ public sealed class GatewayApplicationTests
|
||||
{
|
||||
// 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
|
||||
// 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.
|
||||
await using WebApplication app = GatewayApplication.Build(
|
||||
[$"--{key}={value}", "--urls=http://127.0.0.1:0"]);
|
||||
|
||||
@@ -2,6 +2,7 @@ using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Xunit.Abstractions;
|
||||
using ZB.MOM.WW.MxGateway.Contracts;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
@@ -21,7 +22,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway;
|
||||
/// the fake worker harness. Covers fan-out to two concurrent subscribers, independent
|
||||
/// cancellation isolation, and the per-session subscriber cap.
|
||||
/// </summary>
|
||||
public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
public sealed class GatewayEndToEndMultiSubscriberTests(ITestOutputHelper output)
|
||||
{
|
||||
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10);
|
||||
private const int ServerHandle = 3001;
|
||||
@@ -37,7 +38,7 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
public async Task StreamEvents_TwoSubscribers_BothReceiveAllEvents()
|
||||
{
|
||||
MultiEventFakeWorkerProcessLauncher launcher = new(EventCount);
|
||||
await using MultiSubscriberGatewayServiceFixture fixture = new(launcher);
|
||||
await using MultiSubscriberGatewayServiceFixture fixture = new(launcher, diagnosticOutput: output.WriteLine);
|
||||
|
||||
OpenSessionReply openReply = await fixture.Service.OpenSession(
|
||||
new OpenSessionRequest
|
||||
@@ -136,7 +137,7 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
public async Task StreamEvents_OneSubscriberCancels_OtherContinuesReceivingEvents()
|
||||
{
|
||||
GatedEventFakeWorkerProcessLauncher launcher = new();
|
||||
await using MultiSubscriberGatewayServiceFixture fixture = new(launcher);
|
||||
await using MultiSubscriberGatewayServiceFixture fixture = new(launcher, diagnosticOutput: output.WriteLine);
|
||||
|
||||
OpenSessionReply openReply = await fixture.Service.OpenSession(
|
||||
new OpenSessionRequest
|
||||
@@ -254,7 +255,7 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
// (EventSubscriberLimitReached) is only reachable when multi-subscriber mode is on.
|
||||
// Without it the service hits EventSubscriberAlreadyActive instead — also
|
||||
// ResourceExhausted, but for the wrong reason — masking a misconfiguration.
|
||||
await using MultiSubscriberGatewayServiceFixture fixture = new(launcher, maxEventSubscribersPerSession: cap);
|
||||
await using MultiSubscriberGatewayServiceFixture fixture = new(launcher, maxEventSubscribersPerSession: cap, diagnosticOutput: output.WriteLine);
|
||||
|
||||
OpenSessionReply openReply = await fixture.Service.OpenSession(
|
||||
new OpenSessionRequest
|
||||
@@ -413,20 +414,25 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
{
|
||||
private readonly GatewayMetrics _metrics = new();
|
||||
private readonly SessionRegistry _registry = new();
|
||||
private readonly FakeWorkerDiagnostics _diagnostics;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="MultiSubscriberGatewayServiceFixture"/> class.</summary>
|
||||
/// <param name="launcher">Fake worker process launcher backing the session manager.</param>
|
||||
/// <param name="diagnosticOutput">Safe metadata output sink.</param>
|
||||
/// <param name="maxEventSubscribersPerSession">Maximum concurrent event subscribers allowed per session.</param>
|
||||
public MultiSubscriberGatewayServiceFixture(
|
||||
IWorkerProcessLauncher launcher,
|
||||
int maxEventSubscribersPerSession = 8)
|
||||
int maxEventSubscribersPerSession = 8,
|
||||
Action<string>? diagnosticOutput = null)
|
||||
{
|
||||
_diagnostics = ((IDiagnosticFakeWorker)launcher).Diagnostics;
|
||||
_diagnostics.SetOutput(diagnosticOutput);
|
||||
IOptions<GatewayOptions> options = Options.Create(CreateOptions(maxEventSubscribersPerSession));
|
||||
SessionWorkerClientFactory workerClientFactory = new(
|
||||
launcher,
|
||||
options,
|
||||
_metrics,
|
||||
NullLoggerFactory.Instance);
|
||||
_diagnostics);
|
||||
SessionManager sessionManager = new(
|
||||
_registry,
|
||||
workerClientFactory,
|
||||
@@ -500,12 +506,19 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (GatewaySession session in _registry.Snapshot())
|
||||
{
|
||||
await session.DisposeAsync();
|
||||
// Session disposal and worker observation share the existing test budget.
|
||||
// The observer consumes a late cleanup fault without replacing the primary failure.
|
||||
_diagnostics.Track(DisposeSessionsAsync(), "teardown");
|
||||
await _diagnostics.DrainAsync(TestTimeout).ConfigureAwait(false);
|
||||
_metrics.Dispose();
|
||||
}
|
||||
|
||||
_metrics.Dispose();
|
||||
private async Task DisposeSessionsAsync()
|
||||
{
|
||||
foreach (GatewaySession session in _registry.Snapshot())
|
||||
{
|
||||
await session.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static GatewayOptions CreateOptions(int maxEventSubscribersPerSession) =>
|
||||
@@ -540,7 +553,7 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
/// command, then waits for shutdown. Events carry an indexed string value so the test can
|
||||
/// verify fan-out order across two subscribers.
|
||||
/// </summary>
|
||||
private sealed class MultiEventFakeWorkerProcessLauncher(int eventCount) : IWorkerProcessLauncher
|
||||
private sealed class MultiEventFakeWorkerProcessLauncher(int eventCount) : IWorkerProcessLauncher, IDiagnosticFakeWorker
|
||||
{
|
||||
public const int ProcessId = 7710;
|
||||
|
||||
@@ -549,12 +562,16 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
/// <summary>Gets the task representing the fake worker's running background loop.</summary>
|
||||
public Task WorkerTask { get; private set; } = Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public FakeWorkerDiagnostics Diagnostics { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<WorkerProcessHandle> LaunchAsync(
|
||||
WorkerProcessLaunchRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
WorkerTask = RunWorkerAsync(request, cancellationToken);
|
||||
Diagnostics.Track(WorkerTask);
|
||||
|
||||
return Task.FromResult(new WorkerProcessHandle(
|
||||
_process,
|
||||
@@ -578,6 +595,7 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
{
|
||||
WorkerEnvelope envelope = await harness.ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Diagnostics.Received(envelope);
|
||||
if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerShutdown)
|
||||
{
|
||||
await harness.SendShutdownAckAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
@@ -630,7 +648,7 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
/// deterministic. Call <see cref="StopEmitting"/> before closing the session so the
|
||||
/// worker loop exits cleanly and can process the shutdown envelope.
|
||||
/// </summary>
|
||||
private sealed class GatedEventFakeWorkerProcessLauncher : IWorkerProcessLauncher
|
||||
private sealed class GatedEventFakeWorkerProcessLauncher : IWorkerProcessLauncher, IDiagnosticFakeWorker
|
||||
{
|
||||
public const int ProcessId = 7720;
|
||||
|
||||
@@ -643,6 +661,9 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
/// <summary>Gets the task representing the fake worker's running background loop.</summary>
|
||||
public Task WorkerTask { get; private set; } = Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public FakeWorkerDiagnostics Diagnostics { get; } = new();
|
||||
|
||||
/// <summary>Releases the gate so the worker emits one event.</summary>
|
||||
public void AllowNextEvent() => _emitGate.Release();
|
||||
|
||||
@@ -662,6 +683,7 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
WorkerTask = RunWorkerAsync(request, cancellationToken);
|
||||
Diagnostics.Track(WorkerTask);
|
||||
|
||||
return Task.FromResult(new WorkerProcessHandle(
|
||||
_process,
|
||||
@@ -685,19 +707,19 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
int advisedItemHandle = 0;
|
||||
int emittedCount = 0;
|
||||
|
||||
// Read envelopes one at a time. Between each envelope, if we have a
|
||||
// subscription and the gate is ready, emit an event before reading the
|
||||
// next envelope. When _stopEmitting is set, drain the gate and read
|
||||
// remaining envelopes (including shutdown) without emitting.
|
||||
await using GatedWorkerEnvelopeReader incoming = new(
|
||||
harness.ReadGatewayEnvelopeAsync, _emitGate, cancellationToken);
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// While subscribed and not stopped, try to emit gated events using
|
||||
// a short-timeout peek at the gate — yield to incoming envelopes to
|
||||
// avoid starving shutdown processing.
|
||||
while (advisedServerHandle != 0
|
||||
&& !_stopEmitting
|
||||
&& await _emitGate.WaitAsync(millisecondsTimeout: 0).ConfigureAwait(false))
|
||||
WorkerEnvelope? envelope = await incoming.ReadOrGateAsync(
|
||||
advisedServerHandle != 0 && !_stopEmitting).ConfigureAwait(false);
|
||||
if (envelope is null)
|
||||
{
|
||||
if (_stopEmitting)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int index = ++emittedCount;
|
||||
await harness.EmitEventAsync(
|
||||
MxEventFamily.OnDataChange,
|
||||
@@ -714,23 +736,10 @@ public sealed class GatewayEndToEndMultiSubscriberTests
|
||||
};
|
||||
mxEvent.OnDataChange = new OnDataChangeEvent();
|
||||
}).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;
|
||||
}
|
||||
|
||||
Diagnostics.Received(envelope);
|
||||
if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerShutdown)
|
||||
{
|
||||
await harness.SendShutdownAckAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Xunit.Abstractions;
|
||||
using ZB.MOM.WW.MxGateway.Contracts;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
@@ -19,7 +21,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// while the distributor pump keeps appending worker events to the replay ring, and a second
|
||||
/// stream reconnects with <see cref="StreamEventsRequest.AfterWorkerSequence"/>. Covers both
|
||||
@@ -36,7 +38,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway;
|
||||
/// per session and survives detach, so events emitted while no subscriber is attached are
|
||||
/// retained for the reconnecting stream.
|
||||
/// </remarks>
|
||||
public sealed class GatewayEndToEndReconnectReplayTests
|
||||
public sealed class GatewayEndToEndReconnectReplayTests(ITestOutputHelper output)
|
||||
{
|
||||
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10);
|
||||
private const int ServerHandle = 3001;
|
||||
@@ -58,7 +60,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
|
||||
GatedEventFakeWorkerProcessLauncher launcher = new();
|
||||
// Capacity 16 retains every event emitted here (7 total), so nothing is evicted and a
|
||||
// resume from a middle cursor never produces a gap.
|
||||
await using ReconnectReplayGatewayServiceFixture fixture = new(launcher, replayBufferCapacity: 16);
|
||||
await using ReconnectReplayGatewayServiceFixture fixture = new(launcher, replayBufferCapacity: 16, diagnosticOutput: output.WriteLine);
|
||||
|
||||
string sessionId = await OpenSessionAsync(fixture, "reconnect-no-gap");
|
||||
|
||||
@@ -157,7 +159,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
|
||||
GatedEventFakeWorkerProcessLauncher launcher = new();
|
||||
// Small capacity forces eviction: with 6 events emitted and a 3-slot ring, the first 3
|
||||
// are evicted and only the newest 3 remain replayable.
|
||||
await using ReconnectReplayGatewayServiceFixture fixture = new(launcher, replayBufferCapacity: capacity);
|
||||
await using ReconnectReplayGatewayServiceFixture fixture = new(launcher, replayBufferCapacity: capacity, diagnosticOutput: output.WriteLine);
|
||||
|
||||
string sessionId = await OpenSessionAsync(fixture, "reconnect-gap");
|
||||
|
||||
@@ -233,7 +235,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
|
||||
}
|
||||
|
||||
/// <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),
|
||||
/// so a client applying the documented
|
||||
/// <c>after_worker_sequence = oldest_available_sequence - 1</c> formula on its follow-up
|
||||
@@ -256,7 +258,8 @@ public sealed class GatewayEndToEndReconnectReplayTests
|
||||
launcher,
|
||||
replayBufferCapacity: 16,
|
||||
replayRetentionSeconds: retentionSeconds,
|
||||
timeProvider: clock);
|
||||
timeProvider: clock,
|
||||
diagnosticOutput: output.WriteLine);
|
||||
|
||||
string sessionId = await OpenSessionAsync(fixture, "reconnect-aged-out");
|
||||
|
||||
@@ -305,7 +308,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
|
||||
Assert.NotNull(sentinel.ReplayGap);
|
||||
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.
|
||||
Assert.Equal(highestSeen + 1, sentinel.ReplayGap.OldestAvailableSequence);
|
||||
|
||||
@@ -340,6 +343,34 @@ public sealed class GatewayEndToEndReconnectReplayTests
|
||||
$"Live event {delivered.WorkerSequence} must be newer than the highest previously seen {highestSeen}.");
|
||||
}
|
||||
|
||||
/// <summary>Setup failures preserve gateway EOF and report the original fake worker fault safely.</summary>
|
||||
/// <param name="faultCommand">The setup command where the fake worker fails.</param>
|
||||
/// <returns>The asynchronous test operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(MxCommandKind.Register)]
|
||||
[InlineData(MxCommandKind.AddItem)]
|
||||
public async Task Setup_WhenFakeWorkerFaults_ReportsFaultWithoutMaskingRpcFailure(MxCommandKind faultCommand)
|
||||
{
|
||||
ConcurrentQueue<string> output = new();
|
||||
GatedEventFakeWorkerProcessLauncher launcher = new(faultCommand);
|
||||
Exception? failure = await Record.ExceptionAsync(async () =>
|
||||
{
|
||||
await using ReconnectReplayGatewayServiceFixture fixture = new(
|
||||
launcher, replayBufferCapacity: 16, diagnosticOutput: output.Enqueue);
|
||||
string sessionId = await OpenSessionAsync(fixture, "injected-fault");
|
||||
await WireUpAdviseAsync(fixture, sessionId);
|
||||
});
|
||||
|
||||
RpcException rpc = Assert.IsType<RpcException>(failure);
|
||||
Assert.Equal(StatusCode.Unavailable, rpc.StatusCode);
|
||||
Assert.Contains("Worker pipe disconnected", rpc.Status.Detail);
|
||||
Assert.Contains(output, line => line.Contains("source=fake-worker")
|
||||
&& line.Contains("type=WorkerFrameProtocolException") && line.Contains("code=InvalidEnvelope")
|
||||
&& line.Contains($"command={faultCommand}") && line.Contains("phase=command"));
|
||||
Assert.Contains(output, line => line.Contains("source=gateway") && line.Contains("code=EndOfStream"));
|
||||
Assert.DoesNotContain(output, line => line.Contains("secret-item") || line.Contains("Galaxy.Tag.Value"));
|
||||
}
|
||||
|
||||
// ---- shared flow helpers ----
|
||||
|
||||
private static async Task<string> OpenSessionAsync(
|
||||
@@ -490,6 +521,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
|
||||
{
|
||||
private readonly GatewayMetrics _metrics = new();
|
||||
private readonly SessionRegistry _registry = new();
|
||||
private readonly FakeWorkerDiagnostics _diagnostics;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ReconnectReplayGatewayServiceFixture"/> class.</summary>
|
||||
/// <param name="launcher">Fake worker process launcher backing the session manager.</param>
|
||||
@@ -499,6 +531,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
|
||||
/// duration of a fast test; the age-eviction test shortens it and drives
|
||||
/// <paramref name="timeProvider"/>.
|
||||
/// </param>
|
||||
/// <param name="diagnosticOutput">Safe metadata output sink.</param>
|
||||
/// <param name="timeProvider">
|
||||
/// Clock handed to the session manager, which flows through to the session's event
|
||||
/// distributor. Pass a fake to make age-eviction deterministic.
|
||||
@@ -507,15 +540,18 @@ public sealed class GatewayEndToEndReconnectReplayTests
|
||||
IWorkerProcessLauncher launcher,
|
||||
int replayBufferCapacity,
|
||||
double replayRetentionSeconds = 300,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
Action<string>? diagnosticOutput = null)
|
||||
{
|
||||
_diagnostics = ((IDiagnosticFakeWorker)launcher).Diagnostics;
|
||||
_diagnostics.SetOutput(diagnosticOutput);
|
||||
IOptions<GatewayOptions> options = Options.Create(
|
||||
CreateOptions(replayBufferCapacity, replayRetentionSeconds));
|
||||
SessionWorkerClientFactory workerClientFactory = new(
|
||||
launcher,
|
||||
options,
|
||||
_metrics,
|
||||
NullLoggerFactory.Instance);
|
||||
_diagnostics);
|
||||
SessionManager sessionManager = new(
|
||||
_registry,
|
||||
workerClientFactory,
|
||||
@@ -585,13 +621,20 @@ public sealed class GatewayEndToEndReconnectReplayTests
|
||||
/// <summary>Disposes every session in the registry and releases the fixture's metrics.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// Session disposal and worker observation share the existing test budget.
|
||||
// The observer consumes a late cleanup fault without replacing the primary failure.
|
||||
_diagnostics.Track(DisposeSessionsAsync(), "teardown");
|
||||
await _diagnostics.DrainAsync(TestTimeout).ConfigureAwait(false);
|
||||
_metrics.Dispose();
|
||||
}
|
||||
|
||||
private async Task DisposeSessionsAsync()
|
||||
{
|
||||
foreach (GatewaySession session in _registry.Snapshot())
|
||||
{
|
||||
await session.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_metrics.Dispose();
|
||||
}
|
||||
|
||||
private static GatewayOptions CreateOptions(int replayBufferCapacity, double replayRetentionSeconds) =>
|
||||
@@ -642,7 +685,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
|
||||
/// <see cref="StopEmitting"/> before closing the session so the loop exits cleanly and can
|
||||
/// process the shutdown envelope.
|
||||
/// </summary>
|
||||
private sealed class GatedEventFakeWorkerProcessLauncher : IWorkerProcessLauncher
|
||||
private sealed class GatedEventFakeWorkerProcessLauncher(MxCommandKind? faultCommand = null) : IWorkerProcessLauncher, IDiagnosticFakeWorker
|
||||
{
|
||||
public const int ProcessId = 7730;
|
||||
|
||||
@@ -655,6 +698,9 @@ public sealed class GatewayEndToEndReconnectReplayTests
|
||||
/// <summary>Gets the task representing the fake worker's running background loop.</summary>
|
||||
public Task WorkerTask { get; private set; } = Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public FakeWorkerDiagnostics Diagnostics { get; } = new();
|
||||
|
||||
/// <summary>Releases the gate so the worker emits one event.</summary>
|
||||
public void AllowNextEvent() => _emitGate.Release();
|
||||
|
||||
@@ -674,6 +720,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
WorkerTask = RunWorkerAsync(request, cancellationToken);
|
||||
Diagnostics.Track(WorkerTask);
|
||||
|
||||
return Task.FromResult(new WorkerProcessHandle(
|
||||
_process,
|
||||
@@ -697,14 +744,19 @@ public sealed class GatewayEndToEndReconnectReplayTests
|
||||
int advisedItemHandle = 0;
|
||||
int emittedCount = 0;
|
||||
|
||||
await using GatedWorkerEnvelopeReader incoming = new(
|
||||
harness.ReadGatewayEnvelopeAsync, _emitGate, cancellationToken);
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// While subscribed and not stopped, emit gated events using a non-blocking peek
|
||||
// at the gate so incoming envelopes (including shutdown) are never starved.
|
||||
while (advisedServerHandle != 0
|
||||
&& !_stopEmitting
|
||||
&& await _emitGate.WaitAsync(millisecondsTimeout: 0).ConfigureAwait(false))
|
||||
WorkerEnvelope? envelope = await incoming.ReadOrGateAsync(
|
||||
advisedServerHandle != 0 && !_stopEmitting).ConfigureAwait(false);
|
||||
if (envelope is null)
|
||||
{
|
||||
if (_stopEmitting)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int index = ++emittedCount;
|
||||
await harness.EmitEventAsync(
|
||||
MxEventFamily.OnDataChange,
|
||||
@@ -721,21 +773,10 @@ public sealed class GatewayEndToEndReconnectReplayTests
|
||||
};
|
||||
mxEvent.OnDataChange = new OnDataChangeEvent();
|
||||
}).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;
|
||||
}
|
||||
|
||||
Diagnostics.Received(envelope);
|
||||
if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerShutdown)
|
||||
{
|
||||
await harness.SendShutdownAckAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
@@ -749,6 +790,11 @@ public sealed class GatewayEndToEndReconnectReplayTests
|
||||
}
|
||||
|
||||
MxCommand command = envelope.WorkerCommand.Command;
|
||||
if (command.Kind == faultCommand)
|
||||
{
|
||||
throw new WorkerFrameProtocolException(WorkerFrameProtocolErrorCode.InvalidEnvelope, "secret-item");
|
||||
}
|
||||
|
||||
await harness.ReplyToCommandAsync(
|
||||
envelope,
|
||||
configureReply: reply => ConfigureCommandReply(reply, command.Kind),
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class GalaxyRepositoryHostWiringTests
|
||||
/// Verifies the full production authz-filtering chain end-to-end.
|
||||
/// 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
|
||||
/// 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.
|
||||
/// Phase 2: identity with <c>BrowseSubtrees = ["NonExistent"]</c>
|
||||
/// pushed via <see cref="GatewayRequestIdentityAccessor"/> → the real
|
||||
|
||||
@@ -52,7 +52,7 @@ public sealed class MxAccessGatewayServiceTests
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
|
||||
@@ -41,7 +41,7 @@ public sealed class MxAccessGrpcMapperTests
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="WorkerCommand"/> as the request overload.
|
||||
/// </summary>
|
||||
@@ -145,7 +145,7 @@ public sealed class MxAccessGrpcMapperTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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.
|
||||
|
||||
@@ -669,7 +669,7 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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
|
||||
@@ -720,7 +720,7 @@ public sealed class GatewaySessionTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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)
|
||||
|
||||
@@ -843,7 +843,7 @@ public sealed class SessionEventDistributorTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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
|
||||
@@ -889,7 +889,7 @@ public sealed class SessionEventDistributorTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// get a usable resume anchor rather than <c>0</c>.
|
||||
/// </summary>
|
||||
@@ -924,7 +924,7 @@ public sealed class SessionEventDistributorTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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
|
||||
|
||||
@@ -41,7 +41,7 @@ public sealed class SessionManagerTests
|
||||
/// 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
|
||||
/// default per-user TMPDIR (~49 chars), and keeps the pid + session-guid uniqueness
|
||||
/// contract (NEXT-01).
|
||||
/// contract.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
@@ -110,7 +110,7 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
@@ -478,24 +478,42 @@ public sealed class SessionManagerTests
|
||||
[Fact]
|
||||
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 };
|
||||
SessionManager manager = CreateManager(
|
||||
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);
|
||||
Assert.Equal(SessionState.Ready, session.State);
|
||||
|
||||
// Flip the worker to Ready shortly after the invoke starts waiting.
|
||||
_ = Task.Run(async () =>
|
||||
TaskCompletionSource handshakingObserved = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
TaskCompletionSource allowReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
workerClient.OnStateRead = state =>
|
||||
{
|
||||
await Task.Delay(50, CancellationToken.None);
|
||||
workerClient.State = WorkerClientState.Ready;
|
||||
});
|
||||
|
||||
WorkerCommandReply reply = await manager.InvokeAsync(
|
||||
if (state == WorkerClientState.Handshaking)
|
||||
{
|
||||
handshakingObserved.TrySetResult();
|
||||
}
|
||||
};
|
||||
Task transition = TransitionAsync();
|
||||
Task<WorkerCommandReply> invoke = manager.InvokeAsync(
|
||||
session.SessionId,
|
||||
CreateCommand(MxCommandKind.Ping),
|
||||
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.Equal(1, workerClient.InvokeCount);
|
||||
@@ -1406,14 +1424,28 @@ public sealed class SessionManagerTests
|
||||
|
||||
private sealed class FakeWorkerClient : IWorkerClient
|
||||
{
|
||||
private WorkerClientState _state = WorkerClientState.Ready;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string SessionId { get; init; } = "session-1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public int? ProcessId { get; init; } = 1234;
|
||||
|
||||
/// <summary>Gets or sets the optional state-observation gate.</summary>
|
||||
public Action<WorkerClientState>? OnStateRead { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public WorkerClientState State { get; set; } = WorkerClientState.Ready;
|
||||
public WorkerClientState State
|
||||
{
|
||||
get
|
||||
{
|
||||
OnStateRead?.Invoke(_state);
|
||||
return _state;
|
||||
}
|
||||
|
||||
set => _state = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LastHeartbeatAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
+4
-1
@@ -24,7 +24,10 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncLifetime
|
||||
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.MxGateway.Server.Workers;
|
||||
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Workers;
|
||||
|
||||
/// <summary>Protects bounded teardown, fault observation, and safe diagnostic output.</summary>
|
||||
public sealed class FakeWorkerDiagnosticsTests
|
||||
{
|
||||
/// <summary>Repeated gateway logs remain bounded and never format sensitive state or messages.</summary>
|
||||
[Fact]
|
||||
public void Log_WhenFaultsRepeat_ReportsOnlyBoundedMetadata()
|
||||
{
|
||||
using FakeWorkerDiagnostics diagnostics = new();
|
||||
List<string> output = [];
|
||||
diagnostics.SetOutput(output.Add);
|
||||
for (int i = 0; i < 32; i++)
|
||||
{
|
||||
diagnostics.Log(LogLevel.Warning, new EventId(1), "secret-command",
|
||||
new WorkerFrameProtocolException(WorkerFrameProtocolErrorCode.InvalidEnvelope, "secret-item"),
|
||||
(state, exception) => $"{state} {exception}");
|
||||
}
|
||||
|
||||
Assert.NotEmpty(output);
|
||||
Assert.InRange(output.Count, 1, 16);
|
||||
Assert.All(output, line =>
|
||||
{
|
||||
Assert.Contains("type=WorkerFrameProtocolException", line);
|
||||
Assert.Contains("code=InvalidEnvelope", line);
|
||||
Assert.DoesNotContain("secret", line);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>A late fault is observed after timeout without calling the completed output sink.</summary>
|
||||
/// <returns>The asynchronous test operation.</returns>
|
||||
[Fact]
|
||||
public async Task DrainAsync_WhenWorkerOutlivesBudget_ObservesLateFaultWithoutLateOutput()
|
||||
{
|
||||
FakeWorkerDiagnostics diagnostics = new();
|
||||
List<string> output = [];
|
||||
diagnostics.SetOutput(output.Add);
|
||||
TaskCompletionSource worker = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
diagnostics.Track(worker.Task);
|
||||
await diagnostics.DrainAsync(TimeSpan.Zero);
|
||||
Assert.Contains(output, line => line.Contains("source=teardown") && line.Contains("type=TimeoutException"));
|
||||
int beforeFault = output.Count;
|
||||
worker.SetException(new WorkerFrameProtocolException(WorkerFrameProtocolErrorCode.InvalidEnvelope, "secret-item"));
|
||||
await diagnostics.DrainAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(beforeFault, output.Count);
|
||||
}
|
||||
|
||||
/// <summary>Expected cancellation does not add fault diagnostics.</summary>
|
||||
/// <returns>The asynchronous test operation.</returns>
|
||||
[Fact]
|
||||
public async Task DrainAsync_WhenWorkerIsCancelled_DoesNotReportFault()
|
||||
{
|
||||
FakeWorkerDiagnostics diagnostics = new();
|
||||
List<string> output = [];
|
||||
diagnostics.SetOutput(output.Add);
|
||||
diagnostics.Track(Task.FromCanceled(new CancellationToken(true)));
|
||||
await diagnostics.DrainAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Empty(output);
|
||||
}
|
||||
|
||||
/// <summary>An unavailable output helper cannot fault the observer or replace the primary failure.</summary>
|
||||
/// <returns>The asynchronous test operation.</returns>
|
||||
[Fact]
|
||||
public async Task DrainAsync_WhenOutputThrows_PreservesPrimaryFailure()
|
||||
{
|
||||
InvalidOperationException primary = new("primary");
|
||||
FakeWorkerDiagnostics diagnostics = new();
|
||||
diagnostics.SetOutput(_ => throw new InvalidOperationException("completed output"));
|
||||
Exception? failure = await Record.ExceptionAsync(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
throw primary;
|
||||
}
|
||||
finally
|
||||
{
|
||||
diagnostics.Track(Task.FromException(new WorkerFrameProtocolException(
|
||||
WorkerFrameProtocolErrorCode.InvalidEnvelope, "secret-item")));
|
||||
await diagnostics.DrainAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
});
|
||||
Assert.Same(primary, failure);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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>
|
||||
/// <param name="watchGate">Whether the emission gate is watched (false before Advise).</param>
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task ReadOrGateAsync_WhenGateOpensDuringSegmentedFrame_PreservesBothFrames(bool watchGate)
|
||||
{
|
||||
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(watchGate);
|
||||
Assert.False(pending.IsCompleted);
|
||||
gate.Release();
|
||||
if (watchGate)
|
||||
{
|
||||
Assert.Null(await pending.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Explicit deadline probe: pre-Advise framing must remain pending beyond the old
|
||||
// 50ms cancellation budget, even with the emission gate already open.
|
||||
Task oldReadBudget = Task.Delay(TimeSpan.FromMilliseconds(100), lifetime.Token);
|
||||
Assert.Same(oldReadBudget, await Task.WhenAny(pending, oldReadBudget));
|
||||
await oldReadBudget;
|
||||
}
|
||||
|
||||
await pipe.Writer.WriteAsync(bytes.AsMemory(5));
|
||||
await pipe.Writer.CompleteAsync();
|
||||
|
||||
Assert.Equal(first, watchGate ? await selector.ReadOrGateAsync() : await pending.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
Assert.Equal(second, await selector.ReadOrGateAsync(watchGate));
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="maxMessageBytes">Configured worker-frame maximum to negotiate.</param>
|
||||
@@ -164,7 +164,7 @@ public sealed class WorkerClientTests
|
||||
TestTimeout,
|
||||
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
|
||||
// command this assertion is about.
|
||||
WorkerEnvelope secondCommand = await ReadNextCommandAsync(pipePair, timedOutCommand.CorrelationId);
|
||||
@@ -180,7 +180,7 @@ public sealed class WorkerClientTests
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// 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
|
||||
/// cancel is protocol behavior the worker depends on, not an internal detail.
|
||||
/// </summary>
|
||||
@@ -223,7 +223,7 @@ public sealed class WorkerClientTests
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// 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
|
||||
/// commands nobody is waiting for while live callers queue behind them. Twin of
|
||||
/// <see cref="InvokeAsync_WhenCommandTimesOut_SendsWorkerCancelForThatCorrelation"/>, driven
|
||||
@@ -271,7 +271,7 @@ public sealed class WorkerClientTests
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// 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
|
||||
@@ -330,8 +330,7 @@ public sealed class WorkerClientTests
|
||||
/// 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
|
||||
/// 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
|
||||
/// (IPC-35). This is the window where the information matters most: first session open on a
|
||||
/// 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
|
||||
/// freshly deployed host, where the message reaches the operator log and the client's
|
||||
/// <c>OpenFailed</c> detail.
|
||||
/// </summary>
|
||||
@@ -368,8 +367,7 @@ public sealed class WorkerClientTests
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// the envelope is constructed lets two concurrent invokes stamp 1 and 2 but enqueue 2 then 1
|
||||
/// (GWC-28); stamping on the single-consumer write loop makes wire order and sequence order the
|
||||
/// 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
|
||||
/// same thing by construction.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
@@ -710,12 +708,12 @@ public sealed class WorkerClientTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// <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
|
||||
/// 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
|
||||
/// 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
|
||||
@@ -726,7 +724,7 @@ public sealed class WorkerClientTests
|
||||
public async Task DisposeAsync_WhileWorkerIsEmittingEvents_ClosesWithoutFaultingOrOverflowing()
|
||||
{
|
||||
// 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.
|
||||
const int capacity = 2048;
|
||||
using GatewayMetrics metrics = new();
|
||||
@@ -1007,13 +1005,13 @@ public sealed class WorkerClientTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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
|
||||
/// 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
|
||||
/// 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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
@@ -1094,7 +1092,7 @@ public sealed class WorkerClientTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// incremented at staging and decremented when the consumer reads, so the single counter
|
||||
/// reports total undelivered events and returns to zero once drained.
|
||||
@@ -1148,7 +1146,7 @@ public sealed class WorkerClientTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// client enters a terminal state rather than waiting for disposal.
|
||||
/// </summary>
|
||||
|
||||
@@ -57,7 +57,7 @@ public sealed class WorkerFrameProtocolTests
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <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.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.
|
||||
using IDisposable workerDepthSourceA = metrics.RegisterWorkerEventQueueDepthSource(static () => 3);
|
||||
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.
|
||||
using IDisposable backlogSource = metrics.RegisterEventStreamBacklogSource(static () => 3);
|
||||
metrics.QueueOverflow("session-events");
|
||||
@@ -58,7 +58,7 @@ public sealed class GatewayMetricsTests
|
||||
}
|
||||
|
||||
/// <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,
|
||||
/// 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.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.ProjectStructure;
|
||||
|
||||
/// <summary>Pins the structure of the windev worker CI script.</summary>
|
||||
public sealed class WindevCiContractTests
|
||||
{
|
||||
/// <summary>Verifies live mode runs the Windows test tiers in the documented order.</summary>
|
||||
[Fact]
|
||||
public void LiveMode_RunsWindowsTiersInDocumentedOrder()
|
||||
{
|
||||
|
||||
+1
@@ -238,6 +238,7 @@ public sealed class ApiKeyAdminCommandLineParserTests
|
||||
/// Verifies an empty tag segment is rejected rather than dropped: a stray comma must not
|
||||
/// silently persist a grant the operator did not write.
|
||||
/// </summary>
|
||||
/// <param name="tags">The raw dashboard tag list containing an empty segment.</param>
|
||||
[Theory]
|
||||
[InlineData("team-a,,team-b")]
|
||||
[InlineData("team-a, ")]
|
||||
|
||||
@@ -100,7 +100,7 @@ public sealed class CachingApiKeyVerifierTests
|
||||
}
|
||||
|
||||
/// <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,
|
||||
/// so the very next request re-verifies (revoke takes effect immediately, not after the TTL).
|
||||
/// </summary>
|
||||
|
||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization;
|
||||
|
||||
/// <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
|
||||
/// admission, the per-peer key-id partition cap, and the eviction preference order.
|
||||
/// </summary>
|
||||
@@ -58,7 +58,7 @@ public sealed class ApiKeyFailureLimiterTests
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
[Fact]
|
||||
public void CompositePartition_ThrottleDoesNotFollowKeyIdToAnotherPeer()
|
||||
@@ -102,7 +102,7 @@ public sealed class ApiKeyFailureLimiterTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// the peer layer through the fallback bucket.
|
||||
/// </summary>
|
||||
@@ -372,7 +372,7 @@ public sealed class ApiKeyFailureLimiterTests
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -395,7 +395,7 @@ public sealed class ApiKeyFailureLimiterTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// LRU cap bounds memory, it must not be a reset button for the block.
|
||||
/// </summary>
|
||||
@@ -420,7 +420,7 @@ public sealed class ApiKeyFailureLimiterTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// throttles the address wholesale instead of letting the spray mint unbounded state.
|
||||
/// </summary>
|
||||
@@ -445,7 +445,7 @@ public sealed class ApiKeyFailureLimiterTests
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
|
||||
+3
-3
@@ -32,8 +32,8 @@ public sealed class GatewayBrowseScopeProviderTests
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// to stay distinguishable from an absent identity, so it is asserted alongside its
|
||||
/// empty list is what the library reads as "no scoping". This is the state that must
|
||||
/// stay distinguishable from an absent identity, so it is asserted alongside its
|
||||
/// denying twin below rather than left implicit.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -49,7 +49,7 @@ public sealed class GatewayBrowseScopeProviderTests
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user