docs: strip task-tracking IDs from code comments and fill missing XML docs
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m16s
ci / java (push) Successful in 2m5s
ci / portable (push) Failing after 5m9s

Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
This commit is contained in:
Joseph Doherty
2026-10-07 15:34:32 -04:00
parent f2e0861709
commit f40409a688
115 changed files with 549 additions and 411 deletions
@@ -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);
@@ -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;
}
@@ -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"]);
@@ -19,7 +19,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
@@ -233,7 +233,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
@@ -305,7 +305,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);
@@ -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>
@@ -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>
@@ -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()
{
@@ -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]
@@ -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
@@ -365,7 +365,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
}
/// <summary>
/// SEC-31: once an attacking peer has exceeded the failure limit for a key id, the interceptor
/// Once an attacking peer has exceeded the failure limit for a key id, the interceptor
/// short-circuits with <see cref="StatusCode.ResourceExhausted"/> BEFORE calling the verifier, so
/// an online guessing loop stops spending a store read per attempt. The composite
/// <c>(peer, key id)</c> partition keeps that bound per attacking address.
@@ -407,7 +407,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
}
/// <summary>
/// SEC-31 (the lockout inversion): an attacker who floods failures for a victim's key id from its
/// Lockout inversion: an attacker who floods failures for a victim's key id from its
/// own address must not deny that key to the legitimate holder. The holder presents the correct
/// secret from a different transport peer and authenticates on the first attempt — the verifier
/// is reached and the RPC succeeds.
@@ -454,7 +454,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
}
/// <summary>
/// SEC-31 layer 2: failures for one key id sprayed across more distinct peers than
/// Layer 2: failures for one key id sprayed across more distinct peers than
/// <c>ApiKeyFailureAggregateLimit</c> put that key id into probe mode globally, so a
/// rotating-source attacker gets at most one verifier call per probe interval.
/// </summary>
@@ -515,7 +515,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
}
/// <summary>
/// SEC-31: the success-reset path stays reachable while throttled. A throttled partition admits
/// The success-reset path stays reachable while throttled. A throttled partition admits
/// one probe per interval; the correct secret rides that slot, authenticates, and fully clears
/// both limiter layers, so the next wrong attempt is <see cref="StatusCode.Unauthenticated"/>
/// rather than <see cref="StatusCode.ResourceExhausted"/>.
@@ -572,7 +572,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
}
/// <summary>
/// SEC-32: only a validly shaped <c>mxgw_&lt;keyId&gt;_&lt;secret&gt;</c> token mints a key-id
/// Only a validly shaped <c>mxgw_&lt;keyId&gt;_&lt;secret&gt;</c> token mints a key-id
/// partition. Junk tokens of varied shapes all collapse onto the sender's transport-peer fallback
/// partition, so a spray cannot mint one tracked entry per invented token.
/// </summary>
@@ -61,6 +61,7 @@ public sealed class ChannelWorkerClient(string sessionId, ChannelReader<WorkerEv
{
}
/// <inheritdoc />
/// <summary>Completes immediately because the fake holds no resources.</summary>
/// <returns>A completed value task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
@@ -85,7 +85,7 @@ internal static class TestHostEnvironmentInitializer
Environment.SetEnvironmentVariable("Secrets__SqlitePath", secretsPath);
}
// Starting the full host eagerly opens the auth SQLite store. Since SEC-33 the shipped
// Starting the full host eagerly opens the auth SQLite store. The shipped
// appsettings.json no longer carries an Authentication:SqlitePath, and the CommonApplicationData
// code default resolves under an unwritable /usr/share on macOS. Point every host-building test at
// a per-process temp store (same pattern as Secrets__SqlitePath above) so host-start tests are