From 615b487a77afead7e98460566c1f8891ad0c9793 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 27 May 2026 14:20:10 -0400 Subject: [PATCH] docs+ui: backfill XML doc comments and finish dashboard layout pass Adds missing / XML docs across 99 server, worker, and test files so CommentChecker reports zero issues (TreatWarningsAsErrors needs the analyzer clean). Bundles in WIP dashboard work: NavSection extraction, MainLayout/site.css/js styling alignment, and DashboardOptions/Auth tweaks. --- docs/GatewayConfiguration.md | 2 + .../DashboardLdapLiveTests.cs | 5 + .../LiveLdapFactAttribute.cs | 2 + .../WorkerLiveMxAccessSmokeTests.cs | 12 + .../Alarms/GatewayAlarmMonitor.cs | 3 + .../Configuration/DashboardOptions.cs | 13 + .../Configuration/GatewayOptions.cs | 1 + .../Configuration/LdapOptions.cs | 11 + .../Configuration/ProtocolOptions.cs | 1 + .../Configuration/SessionOptions.cs | 2 + .../Dashboard/Components/App.razor | 1 + .../Components/Layout/MainLayout.razor | 263 +++++++++++++----- .../Components/Layout/NavSection.razor | 35 +++ .../Dashboard/DashboardApiKeyAuthorization.cs | 2 + .../DashboardApiKeyManagementResult.cs | 7 + .../DashboardApiKeyManagementService.cs | 18 ++ .../DashboardAuthenticationDefaults.cs | 2 +- .../Dashboard/DashboardAuthenticator.cs | 6 + .../DashboardAuthorizationRequirement.cs | 3 + .../DashboardConnectionStringDisplay.cs | 2 + ...DashboardEndpointRouteBuilderExtensions.cs | 17 +- .../Dashboard/DashboardGalaxyProjector.cs | 2 + .../DashboardServiceCollectionExtensions.cs | 14 +- .../Dashboard/DashboardSessionAdminResult.cs | 4 + .../Dashboard/DashboardSnapshotService.cs | 2 + .../HubTokenAuthenticationHandler.cs | 6 + .../Dashboard/HubTokenService.cs | 8 +- .../Dashboard/Hubs/AlarmsHub.cs | 1 + .../Dashboard/Hubs/AlarmsHubPublisher.cs | 1 + .../Hubs/DashboardEventBroadcaster.cs | 3 + .../Hubs/DashboardHubConnectionFactory.cs | 3 + .../Dashboard/Hubs/DashboardSnapshotHub.cs | 1 + .../Hubs/DashboardSnapshotPublisher.cs | 14 +- .../Dashboard/Hubs/EventsHub.cs | 6 + .../Hubs/IDashboardEventBroadcaster.cs | 3 + .../IDashboardApiKeyManagementService.cs | 23 ++ .../Dashboard/IDashboardAuthenticator.cs | 5 +- .../Galaxy/GalaxyGlobMatcher.cs | 3 + .../Galaxy/GalaxyHierarchyIndex.cs | 7 + .../Galaxy/GalaxyHierarchyProjector.cs | 22 ++ .../Grpc/MxAccessGatewayService.cs | 10 + .../ApiKeyConstraintSerializer.cs | 4 + .../Authentication/ApiKeyConstraints.cs | 4 + .../Security/Authentication/ApiKeyIdentity.cs | 1 + .../AuthSqliteConnectionFactory.cs | 2 + .../Authorization/ConstraintEnforcer.cs | 22 ++ .../Authorization/IConstraintEnforcer.cs | 22 ++ .../Sessions/GatewaySession.cs | 43 ++- .../SessionLeaseMonitorHostedService.cs | 1 + .../wwwroot/css/site.css | 160 +++++------ .../wwwroot/js/nav-state.js | 19 ++ .../ProtobufContractRoundTripTests.cs | 3 + .../Galaxy/GalaxyFilterInputSafetyTests.cs | 10 + .../Galaxy/GalaxyHierarchyCacheTests.cs | 20 ++ .../Galaxy/GalaxyHierarchyProjectorTests.cs | 4 + .../GalaxyHierarchyRefreshServiceTests.cs | 9 +- .../GalaxyHierarchySnapshotStoreTests.cs | 7 + .../DashboardApiKeyAuthorizationTests.cs | 3 + .../DashboardApiKeyManagementServiceTests.cs | 28 ++ .../Dashboard/DashboardAuthenticatorTests.cs | 7 + .../DashboardBrowseAndAlarmModelTests.cs | 11 + .../DashboardConnectionStringDisplayTests.cs | 1 + .../Dashboard/DashboardCookieOptionsTests.cs | 19 ++ .../DashboardHubsRegistrationTests.cs | 2 + .../DashboardSessionAdminServiceTests.cs | 23 ++ .../DashboardSnapshotPublisherTests.cs | 53 ++++ .../DashboardSnapshotServiceTests.cs | 12 + .../Gateway/Dashboard/HubTokenServiceTests.cs | 3 + .../Gateway/GatewayApplicationTests.cs | 2 + .../Gateway/Grpc/EventStreamServiceTests.cs | 4 + .../Grpc/GalaxyRepositoryGrpcServiceTests.cs | 13 + .../MxAccessGatewayServiceConstraintTests.cs | 54 ++++ .../Gateway/Sessions/GatewaySessionTests.cs | 25 ++ .../Sessions/SessionManagerBulkTests.cs | 22 ++ .../Gateway/Sessions/SessionManagerTests.cs | 6 + .../Workers/OrphanWorkerTerminatorTests.cs | 11 + .../Gateway/Workers/WorkerClientTests.cs | 11 + .../Workers/WorkerExecutableValidatorTests.cs | 8 + .../ApiKeyAdminCliRunnerTests.cs | 1 + .../ApiKeyAdminCommandLineParserTests.cs | 1 + .../Authorization/ConstraintEnforcerTests.cs | 11 + .../AlarmClientDiscoveryTests.cs | 3 + .../Ipc/WorkerPipeSessionTests.cs | 4 + .../MxAccess/AlarmCommandExecutorTests.cs | 55 ++++ .../MxAccess/AlarmCommandHandlerTests.cs | 40 +++ .../MxAccess/AlarmDispatcherTests.cs | 47 ++++ .../AlarmRecordTransitionMapperTests.cs | 13 + .../MxAccess/MxAccessComServerTests.cs | 52 ++++ .../MxAccess/MxAccessCommandExecutorTests.cs | 1 + .../MxAccess/MxAccessEventMapperTests.cs | 1 + .../MxAccess/MxAccessStaSessionTests.cs | 29 ++ .../MxAccess/MxAccessValueCacheTests.cs | 23 +- .../MxAccess/WnWrapAlarmConsumerXmlTests.cs | 11 + .../Probes/AlarmClientWmProbeTests.cs | 4 + .../Probes/AlarmsLiveSmokeTests.cs | 3 + .../Probes/WnWrapConsumerProbeTests.cs | 3 + .../MxAccess/AlarmCommandHandler.cs | 8 + .../MxAccess/AlarmDispatcher.cs | 23 ++ .../MxAccess/AlarmRecordTransitionMapper.cs | 6 + .../MxAccess/IAlarmCommandHandler.cs | 17 ++ .../MxAccess/IMxAccessAlarmConsumer.cs | 17 ++ .../MxAccess/MxAccessAlarmEventSink.cs | 15 + .../MxAccess/MxAccessBaseEventSink.cs | 19 ++ .../MxAccess/MxAccessCommandExecutor.cs | 7 + .../MxAccess/MxAccessSession.cs | 16 ++ .../MxAccess/MxAccessStaSession.cs | 6 + .../MxAccess/MxAccessValueCache.cs | 13 + .../MxAccess/MxAlarmSnapshot.cs | 14 + .../MxAccess/MxAlarmTransitionEvent.cs | 1 + .../MxAccess/WnWrapAlarmConsumer.cs | 6 + 110 files changed, 1473 insertions(+), 192 deletions(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/NavSection.razor create mode 100644 src/ZB.MOM.WW.MxGateway.Server/wwwroot/js/nav-state.js diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index e5c7799..d6d000c 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -46,6 +46,7 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid. "Dashboard": { "Enabled": true, "AllowAnonymousLocalhost": true, + "RequireHttpsCookie": true, "SnapshotIntervalMilliseconds": 1000, "RecentFaultLimit": 100, "RecentSessionLimit": 200, @@ -146,6 +147,7 @@ the affected stream while the MXAccess session remains active. |--------|---------|-------------| | `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. | | `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. | +| `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. | | `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. | | `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. | | `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. | diff --git a/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs index 166390e..f802660 100644 --- a/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs @@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.IntegrationTests; [Trait("Category", "LiveLdap")] public sealed class DashboardLdapLiveTests { + /// Verifies that an admin user in the GwAdmin group authenticates successfully. [LiveLdapFact] public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds() { @@ -38,6 +39,7 @@ public sealed class DashboardLdapLiveTests && claim.Value == DashboardRoles.Admin); } + /// Verifies that a readonly user without GwAdmin group fails to authenticate. [LiveLdapFact] public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails() { @@ -53,6 +55,7 @@ public sealed class DashboardLdapLiveTests Assert.DoesNotContain("readonly123", result.FailureMessage, StringComparison.Ordinal); } + /// Verifies that authentication with wrong password fails without leaking the password. [LiveLdapFact] public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword() { @@ -71,6 +74,7 @@ public sealed class DashboardLdapLiveTests Assert.DoesNotContain(wrongPassword, result.FailureMessage, StringComparison.Ordinal); } + /// Verifies that authentication with unknown username fails. [LiveLdapFact] public async Task AuthenticateAsync_UnknownUsername_Fails() { @@ -87,6 +91,7 @@ public sealed class DashboardLdapLiveTests Assert.Null(result.Principal); } + /// Verifies that authentication fails gracefully when the server is unreachable. [LiveLdapFact] public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing() { diff --git a/src/ZB.MOM.WW.MxGateway.IntegrationTests/LiveLdapFactAttribute.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/LiveLdapFactAttribute.cs index f5ec851..e97afa5 100644 --- a/src/ZB.MOM.WW.MxGateway.IntegrationTests/LiveLdapFactAttribute.cs +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/LiveLdapFactAttribute.cs @@ -4,6 +4,7 @@ public sealed class LiveLdapFactAttribute : FactAttribute { public const string EnableVariableName = "MXGATEWAY_RUN_LIVE_LDAP_TESTS"; + /// Initializes a live LDAP test fact that skips if LDAP tests are not enabled. public LiveLdapFactAttribute() { if (!Enabled) @@ -12,5 +13,6 @@ public sealed class LiveLdapFactAttribute : FactAttribute } } + /// Gets a value indicating whether live LDAP tests are enabled. public static bool Enabled => IntegrationTestEnvironment.IsEnabled(EnableVariableName); } diff --git a/src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs index 01cbc82..4464c52 100644 --- a/src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs @@ -1112,6 +1112,8 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) /// transitions it to Faulted, which the public gRPC API only exposes indirectly via /// CloseSession's reply (and not before a graceful close completes). /// + /// The session identifier. + /// The session if found; otherwise null. public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session) { return _registry.TryGet(sessionId, out session); @@ -1534,6 +1536,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) private readonly StringBuilder buffer = new(); private readonly object syncRoot = new(); + /// Gets the accumulated output buffer contents. public string Captured { get @@ -1545,6 +1548,8 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) } } + /// Writes a line of text to the buffer and inner helper. + /// The message to write. public void WriteLine(string message) { lock (syncRoot) @@ -1555,6 +1560,9 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) inner.WriteLine(message); } + /// Writes a formatted line of text to the buffer and inner helper. + /// The message format string. + /// The format arguments. public void WriteLine(string format, params object[] args) { string formatted = string.Format(System.Globalization.CultureInfo.InvariantCulture, format, args); @@ -1569,11 +1577,13 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer { + /// public Task CheckReadTagAsync( ApiKeyIdentity? identity, string tagAddress, CancellationToken cancellationToken) => Task.FromResult(null); + /// public Task CheckReadHandleAsync( ApiKeyIdentity? identity, GatewaySession session, @@ -1581,6 +1591,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) int itemHandle, CancellationToken cancellationToken) => Task.FromResult(null); + /// public Task CheckWriteHandleAsync( ApiKeyIdentity? identity, GatewaySession session, @@ -1588,6 +1599,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) int itemHandle, CancellationToken cancellationToken) => Task.FromResult(null); + /// public Task RecordDenialAsync( ApiKeyIdentity? identity, string commandKind, diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs index ada7dfd..30461c9 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs @@ -683,8 +683,11 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic private sealed class Subscriber(Channel channel, string prefix) { + /// Gets the channel for publishing alarm messages to this subscriber. public Channel Channel { get; } = channel; + /// Determines whether the alarm reference matches this subscriber's filter. + /// The alarm reference to match. public bool Matches(string reference) { return prefix.Length == 0 || reference.StartsWith(prefix, StringComparison.Ordinal); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs index 7d8e915..7a56ef6 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs @@ -8,6 +8,19 @@ public sealed class DashboardOptions /// Gets whether anonymous localhost access to dashboard is allowed. public bool AllowAnonymousLocalhost { get; init; } = true; + /// + /// When true (default), the dashboard auth cookie is restricted to HTTPS + /// requests via . + /// Set to false for plain-HTTP dev deployments — the cookie then uses + /// , + /// which still marks it Secure on any HTTPS request but allows it to + /// round-trip over HTTP. Browsers silently drop Secure cookies set over + /// plain HTTP from non-localhost hosts, so leaving this true breaks + /// dashboard login from a remote browser unless the dashboard is served + /// over HTTPS. + /// + public bool RequireHttpsCookie { get; init; } = true; + /// Gets the dashboard snapshot update interval in milliseconds. public int SnapshotIntervalMilliseconds { get; init; } = 1_000; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs index 0ac52fd..f5caf1f 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs @@ -9,6 +9,7 @@ public sealed class GatewayOptions /// public AuthenticationOptions Authentication { get; init; } = new(); + /// Gets LDAP configuration options. public LdapOptions Ldap { get; init; } = new(); /// diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs index d580569..d63ee4b 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs @@ -2,25 +2,36 @@ namespace ZB.MOM.WW.MxGateway.Server.Configuration; public sealed class LdapOptions { + /// Gets a value indicating whether LDAP authentication is enabled. public bool Enabled { get; init; } = true; + /// Gets the LDAP server address. public string Server { get; init; } = "localhost"; + /// Gets the LDAP server port. public int Port { get; init; } = 3893; + /// Gets a value indicating whether TLS is required for the connection. public bool UseTls { get; init; } + /// Gets a value indicating whether insecure LDAP connections are allowed. public bool AllowInsecureLdap { get; init; } = true; + /// Gets the LDAP search base distinguished name. public string SearchBase { get; init; } = "dc=lmxopcua,dc=local"; + /// Gets the service account distinguished name. public string ServiceAccountDn { get; init; } = "cn=serviceaccount,dc=lmxopcua,dc=local"; + /// Gets the service account password. public string ServiceAccountPassword { get; init; } = "serviceaccount123"; + /// Gets the LDAP attribute name for user names. public string UserNameAttribute { get; init; } = "cn"; + /// Gets the LDAP attribute name for display names. public string DisplayNameAttribute { get; init; } = "cn"; + /// Gets the LDAP attribute name for group membership. public string GroupAttribute { get; init; } = "memberOf"; } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/ProtocolOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/ProtocolOptions.cs index d526dce..5a983f5 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/ProtocolOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/ProtocolOptions.cs @@ -12,5 +12,6 @@ public sealed class ProtocolOptions /// public uint WorkerProtocolVersion { get; init; } = GatewayContractInfo.WorkerProtocolVersion; + /// Gets or sets the maximum gRPC message size in bytes. public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024; } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/SessionOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/SessionOptions.cs index 02136b1..9381775 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/SessionOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/SessionOptions.cs @@ -17,8 +17,10 @@ public sealed class SessionOptions /// public int MaxPendingCommandsPerSession { get; init; } = 128; + /// Gets the default session lease duration in seconds. public int DefaultLeaseSeconds { get; init; } = 1800; + /// Gets the interval for sweeping expired session leases in seconds. public int LeaseSweepIntervalSeconds { get; init; } = 30; /// diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/App.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/App.razor index a01fb1b..673e20a 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/App.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/App.razor @@ -12,6 +12,7 @@ + diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/MainLayout.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/MainLayout.razor index 81532ee..8b093e0 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/MainLayout.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/MainLayout.razor @@ -1,83 +1,210 @@ +@using System.Linq +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.JSInterop +@implements IDisposable @inherits LayoutComponentBase +@inject NavigationManager Navigation +@inject IJSRuntime JS -
- MXAccess Gateway - - gateway dashboard - - - - @authState.User.Identity?.Name - - signed in - - - - - signed out - - - -
+
+ @* Hamburger toggle: visible only on viewports
+ /// The collection of LDAP groups the user belongs to. + /// The mapping from group names to dashboard role names. internal static IReadOnlyList MapGroupsToRoles( IEnumerable groups, IReadOnlyDictionary groupToRole) @@ -171,6 +175,8 @@ public sealed class DashboardAuthenticator( return [.. roles]; } + /// Extracts the first RDN value from a distinguished name. + /// The LDAP distinguished name. internal static string ExtractFirstRdnValue(string distinguishedName) { int equalsIndex = distinguishedName.IndexOf('='); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs index 3dd55a0..c496c48 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs @@ -8,11 +8,14 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard; /// public sealed class DashboardAuthorizationRequirement(IReadOnlyList requiredRoles) : IAuthorizationRequirement { + /// Gets the list of roles required to satisfy this requirement. public IReadOnlyList RequiredRoles { get; } = requiredRoles; + /// Gets a requirement satisfied by any dashboard role (admin or viewer). public static DashboardAuthorizationRequirement AnyDashboardRole { get; } = new([DashboardRoles.Admin, DashboardRoles.Viewer]); + /// Gets a requirement satisfied only by the admin role. public static DashboardAuthorizationRequirement AdminOnly { get; } = new([DashboardRoles.Admin]); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardConnectionStringDisplay.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardConnectionStringDisplay.cs index 93b884a..b0f1f2d 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardConnectionStringDisplay.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardConnectionStringDisplay.cs @@ -4,6 +4,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard; public static class DashboardConnectionStringDisplay { + /// Returns a sanitized Galaxy Repository connection string for display. + /// The connection string to sanitize. public static string GalaxyRepositoryConnectionString(string connectionString) { try diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs index 9776351..96bedd6 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs @@ -191,11 +191,22 @@ public static class DashboardEndpointRouteBuilderExtensions """; - return RenderPage("Dashboard Sign In", body); + return RenderPage("Dashboard Sign In", heading: null, body); } private static string RenderPage(string title, string body) + => RenderPage(title, heading: title, body); + + private static string RenderPage(string title, string? heading, string body) { + string headingHtml = string.IsNullOrEmpty(heading) + ? string.Empty + : $""" +
+

{HtmlEncoder.Default.Encode(heading)}

+
+ """; + return $""" @@ -212,9 +223,7 @@ public static class DashboardEndpointRouteBuilderExtensions MXAccess Gateway
-
-

{HtmlEncoder.Default.Encode(title)}

-
+ {headingHtml} {body}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs index f09aaac..a87e0c4 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs @@ -5,6 +5,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard; /// Projects the precomputed Galaxy cache dashboard summary. internal static class DashboardGalaxyProjector { + /// Projects the cache entry to a dashboard Galaxy summary. + /// The Galaxy hierarchy cache entry. public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry) { return entry.DashboardSummary; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs index 7380b97..b8459f5 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server.Configuration; namespace ZB.MOM.WW.MxGateway.Server.Dashboard; @@ -39,8 +41,10 @@ public static class DashboardServiceCollectionExtensions { cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName; cookieOptions.Cookie.HttpOnly = true; - cookieOptions.Cookie.SecurePolicy = CookieSecurePolicy.Always; cookieOptions.Cookie.SameSite = SameSiteMode.Strict; + // SecurePolicy is bound via PostConfigure below so it can honour + // DashboardOptions.RequireHttpsCookie (default Always; dev HTTP + // deployments set RequireHttpsCookie=false to use SameAsRequest). cookieOptions.Cookie.Path = "/"; cookieOptions.LoginPath = "/login"; cookieOptions.LogoutPath = "/logout"; @@ -52,6 +56,14 @@ public static class DashboardServiceCollectionExtensions DashboardAuthenticationDefaults.HubAuthenticationScheme, _ => { }); + services.AddOptions(DashboardAuthenticationDefaults.AuthenticationScheme) + .Configure>((cookieOptions, gatewayOptions) => + { + cookieOptions.Cookie.SecurePolicy = gatewayOptions.Value.Dashboard.RequireHttpsCookie + ? CookieSecurePolicy.Always + : CookieSecurePolicy.SameAsRequest; + }); + services.AddAuthorization(authorization => { authorization.AddPolicy( diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminResult.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminResult.cs index ed7f05a..136e92a 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminResult.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminResult.cs @@ -4,11 +4,15 @@ public sealed record DashboardSessionAdminResult( bool Succeeded, string Message) { + /// Creates a successful result with the given message. + /// The result message. public static DashboardSessionAdminResult Success(string message) { return new DashboardSessionAdminResult(true, message); } + /// Creates a failed result with the given message. + /// The result message. public static DashboardSessionAdminResult Fail(string message) { return new DashboardSessionAdminResult(false, message); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs index 783c343..35b10bc 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs @@ -35,8 +35,10 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService /// Gateway metrics collector. /// Gateway configuration provider. /// Galaxy hierarchy cache. + /// API key administration store. /// Gateway configuration options. /// Provider for current time; defaults to system time. + /// Optional logger for the dashboard snapshot service. public DashboardSnapshotService( ISessionRegistry sessionRegistry, GatewayMetrics metrics, diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenAuthenticationHandler.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenAuthenticationHandler.cs index e12ddf0..af9d9ca 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenAuthenticationHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenAuthenticationHandler.cs @@ -14,6 +14,11 @@ public sealed class HubTokenAuthenticationHandler : AuthenticationHandlerInitializes a new hub token authentication handler. + /// The options monitor for the authentication scheme. + /// The logger factory for logging authentication events. + /// The URL encoder for encoding authentication values. + /// The hub token service for validating tokens. public HubTokenAuthenticationHandler( IOptionsMonitor options, ILoggerFactory logger, @@ -24,6 +29,7 @@ public sealed class HubTokenAuthenticationHandler : AuthenticationHandler protected override Task HandleAuthenticateAsync() { string? token = ExtractToken(); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs index d5c1254..52bd9af 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs @@ -30,13 +30,16 @@ public sealed class HubTokenService private readonly ITimeLimitedDataProtector _protector; + /// Initializes a new instance of the HubTokenService with a data protection provider. + /// The data protection provider for token encryption. public HubTokenService(IDataProtectionProvider dataProtection) { ArgumentNullException.ThrowIfNull(dataProtection); _protector = dataProtection.CreateProtector(ProtectorPurpose).ToTimeLimitedDataProtector(); } - /// Issue a bearer token carrying the user's identity and roles. + /// Issues a bearer token carrying the user's identity and roles. + /// The claims principal representing the user. public string Issue(ClaimsPrincipal user) { ArgumentNullException.ThrowIfNull(user); @@ -47,7 +50,8 @@ public sealed class HubTokenService return _protector.Protect(JsonSerializer.Serialize(payload), TokenLifetime); } - /// Validate a token and return the equivalent ; null when invalid or expired. + /// Validates a token and returns the equivalent claims principal; null when invalid or expired. + /// The token string to validate. public ClaimsPrincipal? Validate(string? token) { if (string.IsNullOrEmpty(token)) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHub.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHub.cs index 10edab1..dd22bb0 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHub.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHub.cs @@ -17,6 +17,7 @@ public sealed class AlarmsHub : Hub /// Method name used to push AlarmFeedMessage values to clients. public const string AlarmMessage = "AlarmFeed"; + /// public override async Task OnConnectedAsync() { await Groups.AddToGroupAsync(Context.ConnectionId, AllAlarmsGroup).ConfigureAwait(false); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs index 9989bb8..312d934 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs @@ -16,6 +16,7 @@ public sealed class AlarmsHubPublisher( IHubContext hubContext, ILogger logger) : BackgroundService { + /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // Loop forever — when StreamAsync completes (monitor restart, etc.) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardEventBroadcaster.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardEventBroadcaster.cs index 3ea0045..2e63b08 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardEventBroadcaster.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardEventBroadcaster.cs @@ -14,6 +14,9 @@ public sealed class DashboardEventBroadcaster( IHubContext hubContext, ILogger logger) : IDashboardEventBroadcaster { + /// Publishes an MX event to connected dashboard clients. + /// The session identifier. + /// The MX event to publish. public void Publish(string sessionId, MxEvent mxEvent) { if (string.IsNullOrEmpty(sessionId) || mxEvent is null) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardHubConnectionFactory.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardHubConnectionFactory.cs index fa7bc8d..ae78309 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardHubConnectionFactory.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardHubConnectionFactory.cs @@ -16,6 +16,9 @@ public sealed class DashboardHubConnectionFactory( HubTokenService tokens, AuthenticationStateProvider authState) { + /// Creates a new hub connection to the specified hub path. + /// The relative hub path (e.g., "/hubs/snapshot"). + /// A configured hub connection with automatic reconnection and token authentication. public HubConnection Create(string hubPath) { ArgumentException.ThrowIfNullOrWhiteSpace(hubPath); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotHub.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotHub.cs index a902e25..c93d01a 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotHub.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotHub.cs @@ -15,6 +15,7 @@ public sealed class DashboardSnapshotHub(IDashboardSnapshotService snapshotServi /// Method name used to push snapshot updates to clients. public const string SnapshotMessage = "SnapshotUpdated"; + /// public override async Task OnConnectedAsync() { await Clients.Caller.SendAsync(SnapshotMessage, snapshotService.GetSnapshot()).ConfigureAwait(false); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotPublisher.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotPublisher.cs index d485b4f..5d0a569 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotPublisher.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotPublisher.cs @@ -26,6 +26,10 @@ public sealed class DashboardSnapshotPublisher : BackgroundService private readonly ILogger _logger; private readonly TimeSpan _reconnectDelay; + /// Initializes a new instance of the DashboardSnapshotPublisher class. + /// The snapshot service to subscribe to. + /// The SignalR hub context for broadcasting. + /// The logger instance. public DashboardSnapshotPublisher( IDashboardSnapshotService snapshotService, IHubContext hubContext, @@ -34,9 +38,12 @@ public sealed class DashboardSnapshotPublisher : BackgroundService { } - // Internal hook for the Server-042 regression test: tests inject a - // very short reconnect delay so the assertion doesn't wait the full - // 5s. Production wiring always uses the 5s default via the public ctor. + /// Initializes a new instance of the DashboardSnapshotPublisher class with custom reconnect delay. + /// Internal hook for testing: tests inject a very short reconnect delay so assertions don't wait full 5s. + /// The snapshot service to subscribe to. + /// The SignalR hub context for broadcasting. + /// The logger instance. + /// The delay before reconnecting after a subscription failure. internal DashboardSnapshotPublisher( IDashboardSnapshotService snapshotService, IHubContext hubContext, @@ -49,6 +56,7 @@ public sealed class DashboardSnapshotPublisher : BackgroundService _reconnectDelay = reconnectDelay; } + /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // Loop forever — when WatchSnapshotsAsync completes or throws, reopen diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs index 9b327c8..382dcad 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs @@ -21,6 +21,9 @@ public sealed class EventsHub : Hub /// Method name used to push individual MxEvent values to clients. public const string EventMessage = "MxEvent"; + /// Computes the SignalR group name for a given session id. + /// The session id. + /// The group name for the session. public static string GroupName(string sessionId) => $"session:{sessionId}"; /// @@ -56,6 +59,9 @@ public sealed class EventsHub : Hub return Groups.AddToGroupAsync(Context.ConnectionId, GroupName(sessionId)); } + /// Unsubscribes the calling SignalR connection from the per-session events group. + /// Session id to unsubscribe the caller from. + /// A task representing the unsubscription operation. public Task UnsubscribeSession(string sessionId) { if (string.IsNullOrWhiteSpace(sessionId)) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/IDashboardEventBroadcaster.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/IDashboardEventBroadcaster.cs index 06f732e..3c69299 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/IDashboardEventBroadcaster.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/IDashboardEventBroadcaster.cs @@ -10,5 +10,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; /// public interface IDashboardEventBroadcaster { + /// Publishes an MxEvent to all dashboard clients subscribed to the session. + /// The session identifier. + /// The MxEvent to publish. void Publish(string sessionId, MxEvent mxEvent); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardApiKeyManagementService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardApiKeyManagementService.cs index 4164976..17a3a39 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardApiKeyManagementService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardApiKeyManagementService.cs @@ -4,23 +4,46 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard; public interface IDashboardApiKeyManagementService { + /// Determines whether the user can manage API keys. + /// The user principal. + /// True if the user can manage keys; otherwise false. bool CanManage(ClaimsPrincipal user); + /// Creates a new API key. + /// The user principal. + /// The key creation request details. + /// Cancellation token. + /// The creation result. Task CreateAsync( ClaimsPrincipal user, DashboardApiKeyManagementRequest request, CancellationToken cancellationToken); + /// Revokes an existing API key. + /// The user principal. + /// The key identifier to revoke. + /// Cancellation token. + /// The revocation result. Task RevokeAsync( ClaimsPrincipal user, string keyId, CancellationToken cancellationToken); + /// Rotates an existing API key. + /// The user principal. + /// The key identifier to rotate. + /// Cancellation token. + /// The rotation result. Task RotateAsync( ClaimsPrincipal user, string keyId, CancellationToken cancellationToken); + /// Deletes an API key. + /// The user principal. + /// The key identifier to delete. + /// Cancellation token. + /// The deletion result. Task DeleteAsync( ClaimsPrincipal user, string keyId, diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardAuthenticator.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardAuthenticator.cs index 41ff2b2..5496f4d 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardAuthenticator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardAuthenticator.cs @@ -6,9 +6,10 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard; public interface IDashboardAuthenticator { /// - /// Authenticates the dashboard session with an API key. + /// Authenticates the dashboard session with credentials. /// - /// The API key to authenticate. + /// Username to authenticate. + /// Password to authenticate. /// Token to cancel the asynchronous operation. Task AuthenticateAsync( string? username, diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyGlobMatcher.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyGlobMatcher.cs index 86e5f45..2ec139e 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyGlobMatcher.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyGlobMatcher.cs @@ -43,6 +43,9 @@ public static class GalaxyGlobMatcher /// internal static int CurrentCacheSize => RegexCache.Count; + /// Determines whether a value matches a glob pattern (with * and ? wildcards). + /// The value to test against the glob pattern. + /// The glob pattern with * and ? wildcards. public static bool IsMatch(string value, string glob) { if (string.IsNullOrWhiteSpace(glob)) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs index b210a6a..091cffb 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs @@ -14,17 +14,24 @@ public sealed class GalaxyHierarchyIndex TagsByAddress = tagsByAddress; } + /// Gets an empty Galaxy hierarchy index. public static GalaxyHierarchyIndex Empty { get; } = new( Array.Empty(), new Dictionary(), new Dictionary(StringComparer.OrdinalIgnoreCase)); + /// Gets the object views. public IReadOnlyList ObjectViews { get; } + /// Gets the object views indexed by GUID. public IReadOnlyDictionary ObjectViewsById { get; } + /// Gets tags indexed by address. public IReadOnlyDictionary TagsByAddress { get; } + /// Builds a Galaxy hierarchy index from the given objects. + /// The Galaxy objects to index. + /// A new Galaxy hierarchy index. public static GalaxyHierarchyIndex Build(IReadOnlyList objects) { if (objects.Count == 0) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs index 3aff398..e4449ac 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs @@ -21,6 +21,10 @@ public static class GalaxyHierarchyProjector /// private static readonly ConditionalWeakTable>> FilteredViewCache = new(); + /// Projects a discovery request against a cache entry and returns all matching objects. + /// The Galaxy hierarchy cache entry. + /// The discovery hierarchy request. + /// Optional glob patterns to filter browse subtrees. public static GalaxyHierarchyQueryResult Project( GalaxyHierarchyCacheEntry entry, DiscoverHierarchyRequest request, @@ -34,6 +38,12 @@ public static class GalaxyHierarchyProjector pageSize: int.MaxValue); } + /// Projects a discovery request with paging against a cache entry and returns a page of matching objects. + /// The Galaxy hierarchy cache entry. + /// The discovery hierarchy request. + /// Optional glob patterns to filter browse subtrees. + /// The zero-based offset into the result set. + /// The maximum number of results to return. public static GalaxyHierarchyQueryResult Project( GalaxyHierarchyCacheEntry entry, DiscoverHierarchyRequest request, @@ -118,6 +128,9 @@ public static class GalaxyHierarchyProjector (Views: views, Root: root, MaxDepth: maxDepth, BrowseSubtreeGlobs: browseSubtreeGlobs, Request: request)); } + /// Finds an object in the hierarchy by its tag address. + /// The Galaxy hierarchy cache entry. + /// The tag address to search for. public static GalaxyObject? FindObjectForTag( GalaxyHierarchyCacheEntry entry, string tagAddress) @@ -132,6 +145,9 @@ public static class GalaxyHierarchyProjector : null; } + /// Finds an attribute in the hierarchy by its tag address. + /// The Galaxy hierarchy cache entry. + /// The tag address to search for. public static GalaxyAttribute? FindAttributeForTag( GalaxyHierarchyCacheEntry entry, string tagAddress) @@ -146,6 +162,9 @@ public static class GalaxyHierarchyProjector : null; } + /// Gets the contained path for an object by its gobject ID. + /// The Galaxy hierarchy cache entry. + /// The Galaxy object ID. public static string GetContainedPath( GalaxyHierarchyCacheEntry entry, int gobjectId) @@ -260,6 +279,9 @@ public static class GalaxyHierarchyProjector return clone; } + /// Computes a stable filter signature for memoization purposes. + /// The discovery hierarchy request. + /// Optional glob patterns to filter browse subtrees. public static string ComputeFilterSignature( DiscoverHierarchyRequest request, IReadOnlyList? browseSubtreeGlobs) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs b/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs index 0ffcba0..362ad46 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs @@ -690,9 +690,13 @@ public sealed class MxAccessGatewayService( bool HasAllowedItems) { /// Builds a reply containing only the denied entries (used when no items survived filtering). + /// The original command request. + /// A reply with denied results only. public abstract MxCommandReply CreateDeniedReply(MxCommandRequest request); /// Splices denied entries back into the worker's allowed-only reply in original-index order. + /// The worker's reply containing only allowed results. + /// A merged reply in the original order. public abstract MxCommandReply MergeDeniedInto(MxCommandReply reply); } @@ -703,6 +707,7 @@ public sealed class MxAccessGatewayService( bool HasAllowedItems) : BulkConstraintPlan(Command, OriginalCount, HasAllowedItems) { + /// public override MxCommandReply CreateDeniedReply(MxCommandRequest request) { MxCommandReply reply = new() @@ -716,6 +721,7 @@ public sealed class MxAccessGatewayService( return reply; } + /// public override MxCommandReply MergeDeniedInto(MxCommandReply reply) { BulkSubscribeReply allowed = GetPayload(reply) ?? new BulkSubscribeReply(); @@ -774,6 +780,7 @@ public sealed class MxAccessGatewayService( bool HasAllowedItems) : BulkConstraintPlan(Command, OriginalCount, HasAllowedItems) { + /// public override MxCommandReply CreateDeniedReply(MxCommandRequest request) { MxCommandReply reply = new() @@ -787,6 +794,7 @@ public sealed class MxAccessGatewayService( return reply; } + /// public override MxCommandReply MergeDeniedInto(MxCommandReply reply) { BulkWriteReply allowed = GetPayload(reply) ?? new BulkWriteReply(); @@ -849,6 +857,7 @@ public sealed class MxAccessGatewayService( bool HasAllowedItems) : BulkConstraintPlan(Command, OriginalCount, HasAllowedItems) { + /// public override MxCommandReply CreateDeniedReply(MxCommandRequest request) { MxCommandReply reply = new() @@ -862,6 +871,7 @@ public sealed class MxAccessGatewayService( return reply; } + /// public override MxCommandReply MergeDeniedInto(MxCommandReply reply) { BulkReadReply allowed = reply.ReadBulk ?? new BulkReadReply(); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs index 47ff568..d2125ba 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs @@ -10,12 +10,16 @@ public static class ApiKeyConstraintSerializer WriteIndented = false, }; + /// Serializes API key constraints to JSON, or returns null if the constraints are empty. + /// The constraints to serialize. public static string? Serialize(ApiKeyConstraints constraints) { ArgumentNullException.ThrowIfNull(constraints); return constraints.IsEmpty ? null : JsonSerializer.Serialize(constraints, JsonOptions); } + /// Deserializes API key constraints from JSON, or returns empty constraints if JSON is null or whitespace. + /// The JSON string to deserialize. public static ApiKeyConstraints Deserialize(string? json) { if (string.IsNullOrWhiteSpace(json)) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs index b044d2d..6f0f1ee 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs @@ -10,6 +10,7 @@ public sealed record ApiKeyConstraints( bool ReadAlarmOnly, bool ReadHistorizedOnly) { + /// Gets an empty constraints instance with no restrictions. public static ApiKeyConstraints Empty { get; } = new( ReadSubtrees: Array.Empty(), WriteSubtrees: Array.Empty(), @@ -20,6 +21,7 @@ public sealed record ApiKeyConstraints( ReadAlarmOnly: false, ReadHistorizedOnly: false); + /// Gets a value indicating whether the constraints are empty (no restrictions). public bool IsEmpty => ReadSubtrees.Count == 0 && WriteSubtrees.Count == 0 @@ -30,12 +32,14 @@ public sealed record ApiKeyConstraints( && !ReadAlarmOnly && !ReadHistorizedOnly; + /// Gets a value indicating whether any read constraints are defined. public bool HasReadConstraints => ReadSubtrees.Count > 0 || ReadTagGlobs.Count > 0 || ReadAlarmOnly || ReadHistorizedOnly; + /// Gets a value indicating whether any write constraints are defined. public bool HasWriteConstraints => WriteSubtrees.Count > 0 || WriteTagGlobs.Count > 0 diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyIdentity.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyIdentity.cs index 9c47278..8ca5e75 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyIdentity.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyIdentity.cs @@ -7,5 +7,6 @@ public sealed record ApiKeyIdentity( IReadOnlySet Scopes, ApiKeyConstraints? Constraints = null) { + /// Gets the effective API key constraints (defaults to Empty if null). public ApiKeyConstraints EffectiveConstraints => Constraints ?? ApiKeyConstraints.Empty; } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs index 3a875a3..1ba5867 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs @@ -48,6 +48,8 @@ public sealed class AuthSqliteConnectionFactory(IOptions options /// non-zero busy timeout so concurrent readers and writers degrade gracefully /// rather than surfacing SQLITE_BUSY as a hard failure. /// + /// Cancellation token for the operation. + /// An opened and configured SQLite connection. public async Task OpenConnectionAsync(CancellationToken cancellationToken) { SqliteConnection connection = CreateConnection(); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs index bce0752..17c9e10 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs @@ -9,6 +9,10 @@ public sealed class ConstraintEnforcer( IGalaxyHierarchyCache cache, IApiKeyAuditStore auditStore) : IConstraintEnforcer { + /// Checks read constraints on a tag address. + /// The API key identity to check constraints for. + /// Tag address to validate. + /// Token to observe for cancellation. public Task CheckReadTagAsync( ApiKeyIdentity? identity, string tagAddress, @@ -23,6 +27,12 @@ public sealed class ConstraintEnforcer( return Task.FromResult(CheckReadTarget(constraints, tagAddress)); } + /// Checks read constraints on a server and item handle. + /// The API key identity to check constraints for. + /// The gateway session containing handle registrations. + /// The MXAccess server handle. + /// The MXAccess item handle. + /// Token to observe for cancellation. public Task CheckReadHandleAsync( ApiKeyIdentity? identity, GatewaySession session, @@ -44,6 +54,12 @@ public sealed class ConstraintEnforcer( return Task.FromResult(CheckReadTarget(constraints, registration.TagAddress)); } + /// Checks write constraints on a server and item handle. + /// The API key identity to check constraints for. + /// The gateway session containing handle registrations. + /// The MXAccess server handle. + /// The MXAccess item handle. + /// Token to observe for cancellation. public Task CheckWriteHandleAsync( ApiKeyIdentity? identity, GatewaySession session, @@ -92,6 +108,12 @@ public sealed class ConstraintEnforcer( return Task.FromResult(null); } + /// Records a constraint denial audit entry. + /// The API key identity that was denied. + /// The command type (e.g., read, write). + /// The target being accessed (tag address or handle). + /// The constraint failure details. + /// Token to observe for cancellation. public async Task RecordDenialAsync( ApiKeyIdentity? identity, string commandKind, diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/IConstraintEnforcer.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/IConstraintEnforcer.cs index 221b31a..91d6891 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/IConstraintEnforcer.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/IConstraintEnforcer.cs @@ -5,11 +5,21 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization; public interface IConstraintEnforcer { + /// Checks whether a read constraint is satisfied for a tag address. + /// The API key identity. + /// Tag address to check. + /// Token to observe for cancellation. Task CheckReadTagAsync( ApiKeyIdentity? identity, string tagAddress, CancellationToken cancellationToken); + /// Checks whether a read constraint is satisfied for an item handle. + /// The API key identity. + /// The gateway session. + /// The MXAccess server handle. + /// The MXAccess item handle. + /// Token to observe for cancellation. Task CheckReadHandleAsync( ApiKeyIdentity? identity, GatewaySession session, @@ -17,6 +27,12 @@ public interface IConstraintEnforcer int itemHandle, CancellationToken cancellationToken); + /// Checks whether a write constraint is satisfied for an item handle. + /// The API key identity. + /// The gateway session. + /// The MXAccess server handle. + /// The MXAccess item handle. + /// Token to observe for cancellation. Task CheckWriteHandleAsync( ApiKeyIdentity? identity, GatewaySession session, @@ -24,6 +40,12 @@ public interface IConstraintEnforcer int itemHandle, CancellationToken cancellationToken); + /// Records a constraint denial for audit and metrics. + /// The API key identity. + /// The kind of command denied. + /// The target of the denied command. + /// The constraint failure details. + /// Token to observe for cancellation. Task RecordDenialAsync( ApiKeyIdentity? identity, string commandKind, diff --git a/src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs index 29a47bd..eebfb4e 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs @@ -58,6 +58,21 @@ public sealed class GatewaySession { } + /// + /// Initializes a gateway session with session metadata, timeout configuration, and custom lease duration. + /// + /// Identifier of the session. + /// Name of the backend MXAccess proxy server. + /// Name of the named pipe for gateway-worker IPC. + /// Security nonce for worker validation. + /// Client identity from the authentication context. + /// Client-supplied session name. + /// Client-supplied correlation identifier. + /// Timeout for command invocation. + /// Timeout for worker process startup. + /// Timeout for worker process shutdown. + /// Duration of the session lease. + /// Timestamp when the session opened. public GatewaySession( string sessionId, string backendName, @@ -158,6 +173,7 @@ public sealed class GatewaySession /// public TimeSpan ShutdownTimeout { get; } + /// Gets the lease duration for the session. public TimeSpan LeaseDuration { get; } /// @@ -406,6 +422,10 @@ public sealed class GatewaySession return await workerClient.InvokeAsync(command, CommandTimeout, cancellationToken).ConfigureAwait(false); } + /// Gets the item registration for a server and item handle pair. + /// The MXAccess server handle. + /// The MXAccess item handle. + /// The item registration if found. public bool TryGetItemRegistration( int serverHandle, int itemHandle, @@ -417,6 +437,9 @@ public sealed class GatewaySession } } + /// Tracks item registrations from a command reply. + /// The executed command. + /// The command reply. public void TrackCommandReply( MxCommand command, MxCommandReply reply) @@ -608,9 +631,10 @@ public sealed class GatewaySession cancellationToken); } - /// - /// Executes a bulk Write command for the specified server and per-item entries. - /// + /// Executes a bulk Write command for the specified server and per-item entries. + /// Server handle returned by the worker. + /// Write entries to execute. + /// Token to cancel the asynchronous operation. public Task> WriteBulkAsync( int serverHandle, IReadOnlyList entries, @@ -631,6 +655,9 @@ public sealed class GatewaySession } /// Executes a bulk Write2 (timestamped) command. + /// Server handle returned by the worker. + /// Write entries to execute. + /// Token to cancel the asynchronous operation. public Task> Write2BulkAsync( int serverHandle, IReadOnlyList entries, @@ -651,6 +678,9 @@ public sealed class GatewaySession } /// Executes a bulk WriteSecured command. + /// Server handle returned by the worker. + /// Write entries to execute. + /// Token to cancel the asynchronous operation. public Task> WriteSecuredBulkAsync( int serverHandle, IReadOnlyList entries, @@ -671,6 +701,9 @@ public sealed class GatewaySession } /// Executes a bulk WriteSecured2 command. + /// Server handle returned by the worker. + /// Write entries to execute. + /// Token to cancel the asynchronous operation. public Task> WriteSecured2BulkAsync( int serverHandle, IReadOnlyList entries, @@ -694,6 +727,10 @@ public sealed class GatewaySession /// Executes a bulk Read command — see ReadBulkCommand's doc /// comment in the .proto for the cached-vs-snapshot semantics. /// + /// Server handle returned by the worker. + /// Tag addresses to read. + /// Timeout for the read operation. + /// Token to cancel the asynchronous operation. public Task> ReadBulkAsync( int serverHandle, IReadOnlyList tagAddresses, diff --git a/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionLeaseMonitorHostedService.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionLeaseMonitorHostedService.cs index 12cf6aa..be70d75 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionLeaseMonitorHostedService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionLeaseMonitorHostedService.cs @@ -13,6 +13,7 @@ public sealed class SessionLeaseMonitorHostedService( { private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; + /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { TimeSpan interval = TimeSpan.FromSeconds(Math.Max(1, options.Value.Sessions.LeaseSweepIntervalSeconds)); diff --git a/src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/site.css b/src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/site.css index 1c5d799..100071c 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/site.css +++ b/src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/site.css @@ -15,34 +15,23 @@ body.dashboard-body { min-height: 100vh; } .app-bar .brand { color: var(--ink); } .app-bar .brand:hover { text-decoration: none; } -/* ── App shell ─────────────────────────────────────────────────────────────── - Two-column layout: fixed-width side rail (220px) + flexible main column. - Becomes single-column under .page { flex: 1; min-width: 0; } - -/* ── Side rail ─────────────────────────────────────────────────────────────── - Left-rail navigation with eyebrow section headings, a footer session - block, and active-route accent. */ -.side-rail { - width: 220px; - flex: 0 0 220px; - display: flex; - flex-direction: column; - gap: 0.15rem; - padding: 1rem 0.7rem; +/* ── Sidebar ───────────────────────────────────────────────────────────────── + Left-rail navigation. Pattern lifted from ScadaLink CentralUI: a fixed-width + sidebar that hosts the brand at the top, a scrollable nav region with + collapsible NavSections in the middle, and a sign-in/out footer at the + bottom. The sidebar is wrapped in a Bootstrap .collapse so a hamburger + button can show/hide it on root oneof arm and its proto wrapper-typed max_depth field. /// + /// The oneof arm selector (0=RootGobjectId, 1=RootTagName, 2=RootContainedPath). [Theory] [InlineData(0)] [InlineData(1)] @@ -1165,6 +1166,8 @@ public sealed class ProtobufContractRoundTripTests /// expected value. Pins every new oneof case added by the bulk /// write/read extension. /// + /// The command kind to test. + /// The expected payload oneof case. [Theory] [InlineData(MxCommandKind.WriteBulk, MxCommandReply.PayloadOneofCase.WriteBulk)] [InlineData(MxCommandKind.Write2Bulk, MxCommandReply.PayloadOneofCase.Write2Bulk)] diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyFilterInputSafetyTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyFilterInputSafetyTests.cs index 6ee8cca..ab18465 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyFilterInputSafetyTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyFilterInputSafetyTests.cs @@ -44,6 +44,7 @@ public sealed class GalaxyFilterInputSafetyTests "Pump'001", ]; + /// Returns adversarial input cases for theory tests. public static TheoryData AdversarialInputCases() { TheoryData data = []; @@ -60,6 +61,7 @@ public sealed class GalaxyFilterInputSafetyTests /// LIKE-wildcards as literals — a glob equal to the literal value matches, /// and the same glob does not spuriously match an unrelated value. /// + /// An adversarial input containing SQL metacharacters or LIKE wildcards. [Theory] [MemberData(nameof(AdversarialInputCases))] public void GlobMatcher_TreatsSqlMetacharactersAsLiterals(string input) @@ -159,6 +161,7 @@ public sealed class GalaxyFilterInputSafetyTests /// treats an adversarial glob as a literal: it never wildcard-matches the whole /// hierarchy and never throws. /// + /// An adversarial glob containing SQL metacharacters or LIKE wildcards. [Theory] [MemberData(nameof(AdversarialInputCases))] public void Projector_TagNameGlob_WithAdversarialInput_DoesNotMatchEverything(string glob) @@ -180,6 +183,7 @@ public sealed class GalaxyFilterInputSafetyTests /// literal — an exact-match lookup that finds nothing and surfaces NotFound, /// never matching unrelated objects or throwing an unexpected exception. /// + /// An adversarial tag name containing SQL metacharacters or LIKE wildcards. [Theory] [MemberData(nameof(AdversarialInputCases))] public void Projector_RootTagName_WithAdversarialInput_ThrowsNotFound(string rootTagName) @@ -198,6 +202,7 @@ public sealed class GalaxyFilterInputSafetyTests /// Verifies an adversarial TemplateChainContains filter is a literal /// substring test — it never matches unrelated template chains and never throws. /// + /// An adversarial filter containing SQL metacharacters or LIKE wildcards. [Theory] [MemberData(nameof(AdversarialInputCases))] public void Projector_TemplateChainContains_WithAdversarialInput_MatchesNothing(string filter) @@ -216,6 +221,7 @@ public sealed class GalaxyFilterInputSafetyTests /// handles an adversarial TagNameGlob end-to-end: the request succeeds with /// zero matches rather than returning the whole hierarchy or faulting. /// + /// An adversarial glob containing SQL metacharacters or LIKE wildcards. [Theory] [MemberData(nameof(AdversarialInputCases))] public async Task DiscoverHierarchy_WithAdversarialTagNameGlob_ReturnsZeroMatches(string glob) @@ -235,6 +241,7 @@ public sealed class GalaxyFilterInputSafetyTests /// maps an adversarial RootTagName to NotFound rather than executing it as /// a query fragment or matching unrelated objects. /// + /// An adversarial tag name containing SQL metacharacters or LIKE wildcards. [Theory] [MemberData(nameof(AdversarialInputCases))] public async Task DiscoverHierarchy_WithAdversarialRootTagName_ReturnsNotFound(string rootTagName) @@ -319,10 +326,13 @@ public sealed class GalaxyFilterInputSafetyTests private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache { + /// public GalaxyHierarchyCacheEntry Current { get; } = current; + /// public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; + /// public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs index e4f54c2..96dcb2a 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs @@ -78,6 +78,7 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData); } + /// Verifies that the hierarchy index builds paths and lookups without throwing on bad metadata. [Fact] public void GalaxyHierarchyIndex_BuildsPathsAndTagLookupsWithoutThrowingOnBadMetadata() { @@ -357,19 +358,24 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable { private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); + /// Releases the blocking task. public void Release() => _release.TrySetResult(); + /// public Task TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(false); + /// public async Task GetLastDeployTimeAsync(CancellationToken ct = default) { await _release.Task.WaitAsync(ct).ConfigureAwait(false); throw new InvalidOperationException("Galaxy repository unreachable"); } + /// public Task> GetHierarchyAsync(CancellationToken ct = default) => throw new InvalidOperationException("GetHierarchyAsync should not be reached"); + /// public Task> GetAttributesAsync(CancellationToken ct = default) => throw new InvalidOperationException("GetAttributesAsync should not be reached"); } @@ -377,9 +383,11 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable /// Snapshot store whose cancels the token mid-save. private sealed class CancellingSaveStore(CancellationTokenSource cts) : IGalaxyHierarchySnapshotStore { + /// public Task TryLoadAsync(CancellationToken cancellationToken) => Task.FromResult(null); + /// public Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken) { cts.Cancel(); @@ -391,13 +399,17 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable /// Minimal that records every emitted log entry. private sealed class RecordingLogger : ILogger { + /// Gets the list of recorded log entries. public List<(LogLevel Level, string Message)> Entries { get; } = []; + /// public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + /// public bool IsEnabled(LogLevel logLevel) => true; + /// public void Log( LogLevel logLevel, EventId eventId, @@ -412,6 +424,7 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable { public static readonly NullScope Instance = new(); + /// public void Dispose() { } @@ -427,20 +440,26 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable private readonly List _hierarchy = hierarchy ?? []; private readonly List _attributes = attributes ?? []; + /// Gets the count of calls to . public int GetHierarchyCount { get; private set; } + /// Gets the count of calls to . public int GetAttributesCount { get; private set; } + /// public Task TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true); + /// public Task GetLastDeployTimeAsync(CancellationToken ct = default) => Task.FromResult(deployTime); + /// public Task> GetHierarchyAsync(CancellationToken ct = default) { GetHierarchyCount++; return Task.FromResult(_hierarchy); } + /// public Task> GetAttributesAsync(CancellationToken ct = default) { GetAttributesCount++; @@ -448,6 +467,7 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable } } + /// public void Dispose() { foreach (string path in _tempPaths) diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyProjectorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyProjectorTests.cs index f596c7d..907ac3e 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyProjectorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyProjectorTests.cs @@ -16,6 +16,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; /// public sealed class GalaxyHierarchyProjectorTests { + /// Verifies that paging across a hierarchy returns every object exactly once. [Fact] public void Project_PagedAcrossEntireHierarchy_ReturnsEveryObjectExactlyOnce() { @@ -43,6 +44,7 @@ public sealed class GalaxyHierarchyProjectorTests Assert.Equal("Object_025", collected[^1]); } + /// Verifies that distinct filters on the same entry do not share memoized view list. [Fact] public void Project_DistinctFiltersOnSameEntry_DoNotShareMemoizedViewList() { @@ -60,6 +62,7 @@ public sealed class GalaxyHierarchyProjectorTests Assert.Equal(10, unfiltered.TotalObjectCount); } + /// Verifies that the same filter repeated returns identical totals. [Fact] public void Project_SameFilterRepeated_ReturnsIdenticalTotals() { @@ -85,6 +88,7 @@ public sealed class GalaxyHierarchyProjectorTests Assert.NotEqual(first.Objects[0].TagName, second.Objects[0].TagName); } + /// Verifies that distinct cache entries project against their own data. [Fact] public void Project_DistinctCacheEntries_ProjectAgainstTheirOwnData() { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyRefreshServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyRefreshServiceTests.cs index 27261d6..7cf5045 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyRefreshServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyRefreshServiceTests.cs @@ -13,6 +13,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; /// public sealed class GalaxyHierarchyRefreshServiceTests { + /// Verifies that the background service does not fault when the first refresh throws a non-cancellation exception. [Fact] public async Task ExecuteAsync_WhenFirstRefreshThrowsNonCancellationException_DoesNotFaultBackgroundService() { @@ -62,13 +63,17 @@ public sealed class GalaxyHierarchyRefreshServiceTests private readonly TaskCompletionSource firstRefreshAttempted = new(TaskCreationOptions.RunContinuationsAsynchronously); + /// Gets the number of refresh calls. public int RefreshCallCount { get; private set; } - /// Completes once has been invoked at least once. + /// Gets a task that completes once refresh has been invoked at least once. public Task FirstRefreshAttempted => firstRefreshAttempted.Task; + /// Gets the current cache entry. public GalaxyHierarchyCacheEntry Current => GalaxyHierarchyCacheEntry.Empty; + /// Refreshes the cache asynchronously and throws the configured exception. + /// Token to observe for cancellation. public Task RefreshAsync(CancellationToken cancellationToken) { RefreshCallCount++; @@ -76,6 +81,8 @@ public sealed class GalaxyHierarchyRefreshServiceTests throw toThrow; } + /// Waits for the first load and completes immediately. + /// Token to observe for cancellation. public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchySnapshotStoreTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchySnapshotStoreTests.cs index 911ba28..6da3d34 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchySnapshotStoreTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchySnapshotStoreTests.cs @@ -12,6 +12,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable { private readonly List _tempPaths = []; + /// Verifies that snapshots are correctly saved to and loaded from disk. [Fact] public async Task SaveAsync_ThenTryLoadAsync_RoundTripsRows() { @@ -39,6 +40,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable Assert.Null(loaded.Attributes[1].ArrayDimension); } + /// Verifies that loading returns null when no snapshot file exists. [Fact] public async Task TryLoadAsync_WhenNoFileExists_ReturnsNull() { @@ -47,6 +49,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable Assert.Null(await store.TryLoadAsync(CancellationToken.None)); } + /// Verifies that save writes nothing when persistence is disabled. [Fact] public async Task SaveAsync_WhenPersistenceDisabled_WritesNothing() { @@ -59,6 +62,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable Assert.Null(await store.TryLoadAsync(CancellationToken.None)); } + /// Verifies that loading returns null when the file contains invalid JSON. [Fact] public async Task TryLoadAsync_WhenFileIsCorruptJson_ReturnsNull() { @@ -69,6 +73,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable Assert.Null(await store.TryLoadAsync(CancellationToken.None)); } + /// Verifies that loading returns null when the schema version is unrecognized. [Fact] public async Task TryLoadAsync_WhenSchemaVersionUnrecognized_ReturnsNull() { @@ -79,6 +84,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable Assert.Null(await store.TryLoadAsync(CancellationToken.None)); } + /// Verifies that saving overwrites an earlier snapshot. [Fact] public async Task SaveAsync_OverwritesAnEarlierSnapshot() { @@ -159,6 +165,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable return path; } + /// public void Dispose() { foreach (string path in _tempPaths) diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyAuthorizationTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyAuthorizationTests.cs index 3ca2910..bb5dddd 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyAuthorizationTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyAuthorizationTests.cs @@ -5,6 +5,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; public sealed class DashboardApiKeyAuthorizationTests { + /// Verifies that CanManage returns true for authenticated admin user. [Fact] public void CanManage_AuthenticatedAdmin_ReturnsTrue() { @@ -14,6 +15,7 @@ public sealed class DashboardApiKeyAuthorizationTests Assert.True(authorization.CanManage(user)); } + /// Verifies that CanManage returns false for anonymous user. [Fact] public void CanManage_AnonymousUser_ReturnsFalse() { @@ -23,6 +25,7 @@ public sealed class DashboardApiKeyAuthorizationTests Assert.False(authorization.CanManage(user)); } + /// Verifies that CanManage returns false for authenticated viewer user. [Fact] public void CanManage_AuthenticatedViewer_ReturnsFalse() { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs index 47f470d..917d877 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs @@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; public sealed class DashboardApiKeyManagementServiceTests { + /// Verifies that unauthorized users cannot create API keys. [Fact] public async Task CreateAsync_UnauthorizedUser_DoesNotCallStore() { @@ -25,6 +26,7 @@ public sealed class DashboardApiKeyManagementServiceTests Assert.Equal(0, adminStore.CreateCount); } + /// Verifies that authorized users can create keys with secret hashing and audit trail. [Fact] public async Task CreateAsync_AuthorizedUser_StoresHashOfSecretAndAudits() { @@ -54,6 +56,7 @@ public sealed class DashboardApiKeyManagementServiceTests && entry.KeyId == "operator01"); } + /// Verifies that unauthorized users cannot revoke API keys. [Fact] public async Task RevokeAsync_UnauthorizedUser_DoesNotCallStore() { @@ -69,6 +72,7 @@ public sealed class DashboardApiKeyManagementServiceTests Assert.Equal(0, adminStore.RevokeCount); } + /// Verifies that authorized users can revoke keys with audit trail. [Fact] public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits() { @@ -89,6 +93,7 @@ public sealed class DashboardApiKeyManagementServiceTests && entry.Details == "revoked"); } + /// Verifies that authorized users can rotate secret hashes with audit trail. [Fact] public async Task RotateAsync_AuthorizedUser_RotatesHashAndAudits() { @@ -112,6 +117,7 @@ public sealed class DashboardApiKeyManagementServiceTests && entry.Details == "rotated"); } + /// Verifies that unauthorized users cannot delete API keys. [Fact] public async Task DeleteAsync_UnauthorizedUser_DoesNotCallStore() { @@ -127,6 +133,7 @@ public sealed class DashboardApiKeyManagementServiceTests Assert.Equal(0, adminStore.DeleteCount); } + /// Verifies that authorized users can delete revoked keys with audit trail. [Fact] public async Task DeleteAsync_AuthorizedUser_DeletesRevokedKeyAndAudits() { @@ -181,6 +188,7 @@ public sealed class DashboardApiKeyManagementServiceTests /// ValidateKeyId after the authorisation check. A blank key id must fail with the /// shared "API key id is required." message before any store or audit call runs. /// + /// A blank or whitespace key identifier. [Theory] [InlineData("")] [InlineData(" ")] @@ -269,26 +277,37 @@ public sealed class DashboardApiKeyManagementServiceTests private sealed class FakeApiKeyAdminStore : IApiKeyAdminStore { + /// Gets the count of create operations performed. public int CreateCount { get; private set; } + /// Gets the count of revoke operations performed. public int RevokeCount { get; private set; } + /// Gets the count of delete operations performed. public int DeleteCount { get; private set; } + /// Gets or sets the result value returned by revoke operations. public bool RevokeResult { get; init; } + /// Gets or sets the result value returned by rotate operations. public bool RotateResult { get; init; } + /// Gets or sets the result value returned by delete operations. public bool DeleteResult { get; init; } + /// Gets the last key ID revoked. public string? LastRevokedKeyId { get; private set; } + /// Gets the last key ID deleted. public string? LastDeletedKeyId { get; private set; } + /// Gets the last secret hash rotated. public byte[]? LastRotatedSecretHash { get; private set; } + /// Gets the list of create requests received. public List CreatedRequests { get; } = []; + /// public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken) { CreateCount++; @@ -296,11 +315,13 @@ public sealed class DashboardApiKeyManagementServiceTests return Task.CompletedTask; } + /// public Task> ListAsync(CancellationToken cancellationToken) { return Task.FromResult>([]); } + /// public Task RevokeAsync( string keyId, DateTimeOffset revokedUtc, @@ -311,6 +332,7 @@ public sealed class DashboardApiKeyManagementServiceTests return Task.FromResult(RevokeResult); } + /// public Task RotateAsync( string keyId, byte[] secretHash, @@ -321,6 +343,7 @@ public sealed class DashboardApiKeyManagementServiceTests return Task.FromResult(RotateResult); } + /// public Task DeleteAsync(string keyId, CancellationToken cancellationToken) { DeleteCount++; @@ -331,14 +354,17 @@ public sealed class DashboardApiKeyManagementServiceTests private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore { + /// Gets the list of audit entries appended. public List Entries { get; } = []; + /// public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken) { Entries.Add(entry); return Task.CompletedTask; } + /// public Task> ListRecentAsync( int count, CancellationToken cancellationToken) @@ -349,8 +375,10 @@ public sealed class DashboardApiKeyManagementServiceTests private sealed class FakeApiKeySecretHasher : IApiKeySecretHasher { + /// Gets the last secret hashed. public string? LastSecret { get; private set; } + /// public byte[] HashSecret(string secret) { LastSecret = secret; diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs index 7522d2f..7cb977d 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs @@ -7,6 +7,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; public sealed class DashboardAuthenticatorTests { + /// Verifies that LDAP filter special characters are escaped correctly. [Fact] public void EscapeLdapFilter_EscapesSpecialCharacters() { @@ -15,6 +16,9 @@ public sealed class DashboardAuthenticatorTests Assert.Equal("a\\5cb\\2ac\\28d\\29e\\00f", escaped); } + /// Verifies that group-to-role mapping resolves by short name and distinguished name. + /// The LDAP group name or distinguished name. + /// The expected role or null if no match. [Theory] [InlineData("GwAdmin", DashboardRoles.Admin)] [InlineData("gwadmin", DashboardRoles.Admin)] @@ -42,6 +46,7 @@ public sealed class DashboardAuthenticatorTests } } + /// Verifies that admin and viewer roles are both emitted when groups are present. [Fact] public void MapGroupsToRoles_AdminPlusViewer_BothRolesEmitted() { @@ -59,6 +64,7 @@ public sealed class DashboardAuthenticatorTests Assert.Contains(DashboardRoles.Viewer, roles); } + /// Verifies that extraction returns the leading RDN value from a distinguished name. [Fact] public void ExtractFirstRdnValue_ReturnsLeadingRdnValue() { @@ -68,6 +74,7 @@ public sealed class DashboardAuthenticatorTests Assert.Equal("Gateway Admins", result); } + /// Verifies that authentication fails when LDAP is disabled without exposing raw credentials. [Fact] public async Task AuthenticateAsync_LdapDisabled_ReturnsFailureWithoutRawCredentials() { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardBrowseAndAlarmModelTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardBrowseAndAlarmModelTests.cs index 8786f1f..ee00eee 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardBrowseAndAlarmModelTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardBrowseAndAlarmModelTests.cs @@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; /// public sealed class DashboardBrowseAndAlarmModelTests { + /// Verifies that the tree builder links children to parents and promotes orphans to roots. [Fact] public void BuildTree_LinksChildrenToParents_AndPromotesOrphansToRoots() { @@ -27,6 +28,7 @@ public sealed class DashboardBrowseAndAlarmModelTests Assert.Contains(roots, node => node.Object.GobjectId == 3); } + /// Verifies that the tree builder sorts areas before non-area objects. [Fact] public void BuildTree_SortsAreasBeforeObjects() { @@ -41,6 +43,9 @@ public sealed class DashboardBrowseAndAlarmModelTests Assert.False(roots[1].IsArea); } + /// Verifies that the formatter renders boolean values correctly. + /// The boolean input value. + /// The expected formatted output. [Theory] [InlineData(true, "true")] [InlineData(false, "false")] @@ -50,6 +55,7 @@ public sealed class DashboardBrowseAndAlarmModelTests Assert.Equal(expected, DashboardMxValueFormatter.FormatValue(value)); } + /// Verifies that the formatter renders numbers and strings correctly. [Fact] public void FormatValue_FormatsNumbersAndStrings() { @@ -57,6 +63,7 @@ public sealed class DashboardBrowseAndAlarmModelTests Assert.Equal("hello", DashboardMxValueFormatter.FormatValue(new MxValue { StringValue = "hello" })); } + /// Verifies that the formatter handles null payloads and null references. [Fact] public void FormatValue_HandlesNullPayloadAndNullReference() { @@ -64,6 +71,7 @@ public sealed class DashboardBrowseAndAlarmModelTests Assert.Equal("(null)", DashboardMxValueFormatter.FormatValue(new MxValue { IsNull = true })); } + /// Verifies that tag values from successful reads mark good quality. [Fact] public void TagValue_FromSuccessfulReadResult_MarksGoodQuality() { @@ -83,6 +91,7 @@ public sealed class DashboardBrowseAndAlarmModelTests Assert.Null(value.Error); } + /// Verifies that tag values from failed reads carry the error message. [Fact] public void TagValue_FromFailedReadResult_CarriesError() { @@ -101,6 +110,7 @@ public sealed class DashboardBrowseAndAlarmModelTests Assert.Equal("invalid handle", value.Error); } + /// Verifies that active alarms parse provider and acknowledgement state from snapshots. [Fact] public void ActiveAlarm_FromSnapshot_ParsesProviderAndAcknowledgementState() { @@ -127,6 +137,7 @@ public sealed class DashboardBrowseAndAlarmModelTests Assert.False(ackedRow.IsUnacknowledged); } + /// Verifies that the formatter renders array elements and element type correctly. [Fact] public void FormatValue_AndDataType_RenderArrayElementsAndElementType() { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardConnectionStringDisplayTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardConnectionStringDisplayTests.cs index bc29ca9..c1ec485 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardConnectionStringDisplayTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardConnectionStringDisplayTests.cs @@ -4,6 +4,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; public sealed class DashboardConnectionStringDisplayTests { + /// Verifies that Galaxy connection strings strip SQL credentials and keep only non-secret fields. [Fact] public void GalaxyRepositoryConnectionString_WithSqlCredentials_OnlyKeepsNonSecretFields() { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs index 4a68110..95ed26c 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs @@ -30,4 +30,23 @@ public sealed class DashboardCookieOptionsTests Assert.Equal("/logout", options.LogoutPath); Assert.Equal("/denied", options.AccessDeniedPath); } + + /// + /// Verifies that setting MxGateway:Dashboard:RequireHttpsCookie=false + /// relaxes the cookie to so + /// the dashboard can be reached over plain HTTP in dev. + /// + [Fact] + public async Task Build_WithRequireHttpsCookieFalse_UsesSameAsRequest() + { + await using WebApplication app = GatewayApplication.Build( + ["--MxGateway:Dashboard:RequireHttpsCookie=false"]); + IOptionsMonitor optionsMonitor = app.Services + .GetRequiredService>(); + + CookieAuthenticationOptions options = optionsMonitor.Get( + DashboardAuthenticationDefaults.AuthenticationScheme); + + Assert.Equal(CookieSecurePolicy.SameAsRequest, options.Cookie.SecurePolicy); + } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardHubsRegistrationTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardHubsRegistrationTests.cs index 0b8f45b..3348029 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardHubsRegistrationTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardHubsRegistrationTests.cs @@ -9,6 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; public sealed class DashboardHubsRegistrationTests { + /// Verifies that dashboard build maps all three hubs and token endpoint. [Fact] public async Task Build_WhenDashboardEnabled_MapsAllThreeHubsAndTokenEndpoint() { @@ -25,6 +26,7 @@ public sealed class DashboardHubsRegistrationTests endpoint.Metadata.GetMetadata()?.EndpointName == "DashboardHubToken"); } + /// Verifies that dashboard build registers hub token service and connection factory. [Fact] public async Task Build_WhenDashboardEnabled_RegistersHubTokenServiceAndConnectionFactory() { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs index 4b08618..5ffa1d0 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs @@ -9,6 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; public sealed class DashboardSessionAdminServiceTests { + /// Verifies that a viewer cannot close a session. [Fact] public async Task CloseSessionAsync_ViewerCannotManage() { @@ -24,6 +25,7 @@ public sealed class DashboardSessionAdminServiceTests Assert.Equal(0, sessionManager.CloseCount); } + /// Verifies that an admin can close a session. [Fact] public async Task CloseSessionAsync_AdminClosesSession() { @@ -40,6 +42,7 @@ public sealed class DashboardSessionAdminServiceTests Assert.Equal("session-1", sessionManager.LastClosedSessionId); } + /// Verifies that closing a missing session returns a friendly error message. [Fact] public async Task CloseSessionAsync_WhenSessionMissing_ReportsFriendlyError() { @@ -58,6 +61,7 @@ public sealed class DashboardSessionAdminServiceTests Assert.Contains("not found", result.Message, StringComparison.OrdinalIgnoreCase); } + /// Verifies that a viewer cannot kill a worker. [Fact] public async Task KillWorkerAsync_ViewerCannotManage() { @@ -73,6 +77,7 @@ public sealed class DashboardSessionAdminServiceTests Assert.Equal(0, sessionManager.KillCount); } + /// Verifies that an admin can kill a worker. [Fact] public async Task KillWorkerAsync_AdminKillsWorker() { @@ -95,6 +100,7 @@ public sealed class DashboardSessionAdminServiceTests Assert.Equal("dashboard-admin-kill", sessionManager.LastKillReason); } + /// Verifies that killing a worker with a blank session ID returns failure. [Fact] public async Task KillWorkerAsync_BlankSessionId_ReturnsFailure() { @@ -130,6 +136,7 @@ public sealed class DashboardSessionAdminServiceTests Assert.Equal(0, sessionManager.CloseCount); } + /// Verifies that CanManage rejects unauthenticated users and viewers. [Fact] public void CanManage_RejectsUnauthenticatedAndViewer() { @@ -209,22 +216,31 @@ public sealed class DashboardSessionAdminServiceTests private sealed class FakeSessionManager : ISessionManager { + /// Gets the number of times CloseSessionAsync was invoked. public int CloseCount { get; private set; } + /// Gets the number of times KillWorkerAsync was invoked. public int KillCount { get; private set; } + /// Gets the last session ID passed to CloseSessionAsync. public string? LastClosedSessionId { get; private set; } + /// Gets the last session ID passed to KillWorkerAsync. public string? LastKilledSessionId { get; private set; } + /// Gets the last reason string passed to KillWorkerAsync. public string? LastKillReason { get; private set; } + /// Gets a value indicating whether CloseSessionAsync should throw SessionNotFound. public bool CloseThrowsNotFound { get; init; } + /// Gets the exception CloseSessionAsync should throw unexpectedly. public Exception? CloseThrowsUnexpected { get; init; } + /// Gets the exception KillWorkerAsync should throw unexpectedly. public Exception? KillThrowsUnexpected { get; init; } + /// public Task OpenSessionAsync( SessionOpenRequest request, string? clientIdentity, @@ -233,6 +249,7 @@ public sealed class DashboardSessionAdminServiceTests throw new NotSupportedException(); } + /// public bool TryGetSession( string sessionId, [MaybeNullWhen(false)] out GatewaySession session) @@ -241,6 +258,7 @@ public sealed class DashboardSessionAdminServiceTests return false; } + /// public Task InvokeAsync( string sessionId, WorkerCommand command, @@ -249,6 +267,7 @@ public sealed class DashboardSessionAdminServiceTests throw new NotSupportedException(); } + /// public IAsyncEnumerable ReadEventsAsync( string sessionId, CancellationToken cancellationToken) @@ -256,6 +275,7 @@ public sealed class DashboardSessionAdminServiceTests throw new NotSupportedException(); } + /// public Task CloseSessionAsync( string sessionId, CancellationToken cancellationToken) @@ -277,6 +297,7 @@ public sealed class DashboardSessionAdminServiceTests return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); } + /// public Task KillWorkerAsync( string sessionId, string reason, @@ -293,6 +314,7 @@ public sealed class DashboardSessionAdminServiceTests return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); } + /// public Task CloseExpiredLeasesAsync( DateTimeOffset now, CancellationToken cancellationToken) @@ -300,6 +322,7 @@ public sealed class DashboardSessionAdminServiceTests return Task.FromResult(0); } + /// public Task ShutdownAsync(CancellationToken cancellationToken) { return Task.CompletedTask; diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotPublisherTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotPublisherTests.cs index 46bdd60..6533a84 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotPublisherTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotPublisherTests.cs @@ -110,6 +110,7 @@ public sealed class DashboardSnapshotPublisherTests private sealed class ThrowOnceThenYieldSnapshotService : IDashboardSnapshotService { + /// Gets the number of subscription attempts. public int SubscribeCount { get; private set; } /// @@ -120,13 +121,17 @@ public sealed class DashboardSnapshotPublisherTests /// public DateTimeOffset? FirstThrowAt { get; private set; } + /// Gets the wall-clock instant of the second subscription attempt. public DateTimeOffset? SecondSubscribeAt { get; private set; } + /// Gets the current snapshot. public DashboardSnapshot GetSnapshot() { return null!; } + /// Watches for snapshot changes and yields them asynchronously. + /// Token to observe for cancellation. public async IAsyncEnumerable WatchSnapshotsAsync( [EnumeratorCancellation] CancellationToken cancellationToken) { @@ -159,13 +164,17 @@ public sealed class DashboardSnapshotPublisherTests private sealed class CompleteImmediatelySnapshotService : IDashboardSnapshotService { + /// Gets the number of subscription attempts. public int SubscribeCount { get; private set; } + /// Gets the current snapshot. public DashboardSnapshot GetSnapshot() { return null!; } + /// Watches for snapshot changes and completes immediately. + /// Token to observe for cancellation. #pragma warning disable CS1998 // async without await — IAsyncEnumerable contract requires async signature public async IAsyncEnumerable WatchSnapshotsAsync( [EnumeratorCancellation] CancellationToken cancellationToken) @@ -182,24 +191,55 @@ public sealed class DashboardSnapshotPublisherTests { private readonly RecordingHubClients _clients = new(); + /// Gets the hub clients. public IHubClients Clients => _clients; + + /// Gets the group manager. public IGroupManager Groups { get; } = new NoopGroupManager(); + /// Gets the number of send calls recorded. public int SendCount => _clients.AllProxy.SendCount; } private sealed class RecordingHubClients : IHubClients { + /// Gets the recording client proxy for all clients. public RecordingClientProxy AllProxy { get; } = new(); + /// Gets a client proxy targeting all clients. public IClientProxy All => AllProxy; + + /// Gets a client proxy excluding specified connections. + /// Connection identifiers to exclude. public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => AllProxy; + + /// Gets a client proxy for a specific connection. + /// The connection identifier. public IClientProxy Client(string connectionId) => AllProxy; + + /// Gets a client proxy for specified connections. + /// The connection identifiers. public IClientProxy Clients(IReadOnlyList connectionIds) => AllProxy; + + /// Gets a client proxy for a group. + /// The group name. public IClientProxy Group(string groupName) => AllProxy; + + /// Gets a client proxy for a group excluding specified connections. + /// The group name. + /// Connection identifiers to exclude. public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => AllProxy; + + /// Gets a client proxy for specified groups. + /// The group names. public IClientProxy Groups(IReadOnlyList groupNames) => AllProxy; + + /// Gets a client proxy for a specific user. + /// The user identifier. public IClientProxy User(string userId) => AllProxy; + + /// Gets a client proxy for specified users. + /// The user identifiers. public IClientProxy Users(IReadOnlyList userIds) => AllProxy; } @@ -207,8 +247,13 @@ public sealed class DashboardSnapshotPublisherTests { private int _sendCount; + /// Gets the number of send calls recorded. public int SendCount => Volatile.Read(ref _sendCount); + /// Records a send call and completes asynchronously. + /// The SignalR method name. + /// The method arguments. + /// Token to observe for cancellation. public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) { Interlocked.Increment(ref _sendCount); @@ -218,9 +263,17 @@ public sealed class DashboardSnapshotPublisherTests private sealed class NoopGroupManager : IGroupManager { + /// Completes immediately without performing group addition. + /// The connection identifier. + /// The group name. + /// Token to observe for cancellation. public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask; + /// Completes immediately without performing group removal. + /// The connection identifier. + /// The group name. + /// Token to observe for cancellation. public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask; } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs index c0794c9..c189d99 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs @@ -267,6 +267,7 @@ public sealed class DashboardSnapshotServiceTests Assert.Equal(0, apiKeyAdminStore.ListCount); } + /// Verifies that snapshot service refreshes API key summaries before each snapshot. [Fact] public async Task WatchSnapshotsAsync_RefreshesApiKeySummariesBeforeSnapshot() { @@ -303,6 +304,7 @@ public sealed class DashboardSnapshotServiceTests Assert.Equal(1, apiKeyAdminStore.ListCount); } + /// Verifies that snapshot service reuses previous summaries when API key refresh fails. [Fact] public async Task WatchSnapshotsAsync_WhenApiKeyRefreshFails_ReusesPreviousSummaries() { @@ -346,6 +348,7 @@ public sealed class DashboardSnapshotServiceTests Assert.Equal(2, apiKeyAdminStore.ListCount); } + /// Verifies that snapshot service disposes cleanly when subscriber cancels. [Fact] public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly() { @@ -421,16 +424,19 @@ public sealed class DashboardSnapshotServiceTests private class FakeApiKeyAdminStore : IApiKeyAdminStore { + /// public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken) { return Task.CompletedTask; } + /// public virtual Task> ListAsync(CancellationToken cancellationToken) { return Task.FromResult>([]); } + /// public Task RevokeAsync( string keyId, DateTimeOffset revokedUtc, @@ -439,6 +445,7 @@ public sealed class DashboardSnapshotServiceTests return Task.FromResult(false); } + /// public Task RotateAsync( string keyId, byte[] secretHash, @@ -448,6 +455,7 @@ public sealed class DashboardSnapshotServiceTests return Task.FromResult(false); } + /// public Task DeleteAsync(string keyId, CancellationToken cancellationToken) { return Task.FromResult(false); @@ -456,8 +464,10 @@ public sealed class DashboardSnapshotServiceTests private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore { + /// Gets the count of list operations performed. public int ListCount { get; protected set; } + /// public override Task> ListAsync(CancellationToken cancellationToken) { ListCount++; @@ -467,8 +477,10 @@ public sealed class DashboardSnapshotServiceTests private sealed class SequencedApiKeyAdminStore(ApiKeyRecord record) : CountingApiKeyAdminStore(record) { + /// Gets or sets a value indicating whether the next list operation should fail. public bool FailNext { get; set; } + /// public override Task> ListAsync(CancellationToken cancellationToken) { if (FailNext) diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs index a630ecc..943aaad 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs @@ -89,6 +89,7 @@ public sealed class HubTokenServiceTests Assert.True(result.IsInRole(DashboardRoles.Viewer)); } + /// Verifies that a null token returns null. [Fact] public void Validate_NullToken_ReturnsNull() { @@ -97,6 +98,7 @@ public sealed class HubTokenServiceTests Assert.Null(service.Validate(null)); } + /// Verifies that an empty token returns null. [Fact] public void Validate_EmptyToken_ReturnsNull() { @@ -105,6 +107,7 @@ public sealed class HubTokenServiceTests Assert.Null(service.Validate(string.Empty)); } + /// Verifies that an invalid token returns null. [Fact] public void Validate_GarbageToken_ReturnsNull() { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs index c590409..5f2c746 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs @@ -102,6 +102,7 @@ public sealed class GatewayApplicationTests } } + /// Verifies that dashboard routes are registered at root when enabled. [Fact] public async Task Build_WhenDashboardEnabled_RegistersDashboardRoutesAtRoot() { @@ -126,6 +127,7 @@ public sealed class GatewayApplicationTests } } + /// Verifies that dashboard routes are not mapped when disabled. [Fact] public async Task Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes() { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs index 990195a..6f45a15 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs @@ -355,8 +355,12 @@ public sealed class EventStreamServiceTests private sealed class ThrowingDashboardEventBroadcaster : ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs.IDashboardEventBroadcaster { + /// Gets the count of publish attempts. public int PublishAttempts { get; private set; } + /// Increments the attempt count and throws a simulated failure. + /// The session identifier. + /// The event to publish. public void Publish(string sessionId, MxEvent mxEvent) { PublishAttempts++; diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs index 7acb6d2..0557f86 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs @@ -11,6 +11,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc; public sealed class GalaxyRepositoryGrpcServiceTests { + /// Verifies that DiscoverHierarchy returns the requested page and totals. [Fact] public async Task DiscoverHierarchy_ReturnsRequestedPageAndTotals() { @@ -31,6 +32,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests Assert.Equal(3, reply.TotalObjectCount); } + /// Verifies that DiscoverHierarchy with a page token returns remaining objects. [Fact] public async Task DiscoverHierarchy_WithNextPageToken_ReturnsRemainingObjects() { @@ -56,6 +58,9 @@ public sealed class GalaxyRepositoryGrpcServiceTests Assert.Equal(3, reply.TotalObjectCount); } + /// Verifies that DiscoverHierarchy with invalid paging arguments returns InvalidArgument. + /// The page token to test. + /// The page size to test. [Theory] [InlineData("-1", 1)] [InlineData("not-an-offset", 1)] @@ -80,6 +85,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); } + /// Verifies that DiscoverHierarchy with subtree root and depth filters descendants. [Fact] public async Task DiscoverHierarchy_WithSubtreeRootAndDepth_FiltersDescendants() { @@ -98,6 +104,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests Assert.Equal(3, reply.TotalObjectCount); } + /// Verifies that DiscoverHierarchy applies server-side filters and omits attributes. [Fact] public async Task DiscoverHierarchy_WithServerSideFilters_AppliesAllFiltersAndOmitsAttributes() { @@ -123,6 +130,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests Assert.Equal(1, reply.TotalObjectCount); } + /// Verifies that DiscoverHierarchy with filtered paging returns post-filter total. [Fact] public async Task DiscoverHierarchy_WithFilteredPaging_ReturnsPostFilterTotal() { @@ -154,6 +162,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests Assert.NotEqual(firstObject.TagName, secondObject.TagName); } + /// Verifies that DiscoverHierarchy with mismatched filter token returns InvalidArgument. [Fact] public async Task DiscoverHierarchy_WithMismatchedFilterToken_ReturnsInvalidArgument() { @@ -180,6 +189,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests Assert.Contains("filters", exception.Status.Detail, StringComparison.OrdinalIgnoreCase); } + /// Verifies that DiscoverHierarchy with missing root returns NotFound. [Fact] public async Task DiscoverHierarchy_WithMissingRoot_ReturnsNotFound() { @@ -315,10 +325,13 @@ public sealed class GalaxyRepositoryGrpcServiceTests private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache { + /// public GalaxyHierarchyCacheEntry Current { get; } = current; + /// public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; + /// public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs index 9e99dd3..a01cfb8 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs @@ -827,12 +827,16 @@ public sealed class MxAccessGatewayServiceConstraintTests { private readonly Dictionary seededSessions = new(StringComparer.Ordinal); + /// Gets a value indicating whether only seeded sessions should be resolved. public bool ResolveOnlySeededSessions { get; init; } + /// Gets the last worker command that was invoked. public WorkerCommand? LastWorkerCommand { get; private set; } + /// Gets the count of invoke calls made. public int InvokeCount { get; private set; } + /// Gets or sets the default invoke reply to return. public WorkerCommandReply InvokeReply { get; set; } = new() { Reply = new MxCommandReply @@ -843,16 +847,26 @@ public sealed class MxAccessGatewayServiceConstraintTests }, }; + /// Gets the collection of events to stream. public List Events { get; } = []; + /// Seeds a test session into the fake manager. + /// The session to seed. public void SeedSession(GatewaySession session) => seededSessions[session.SessionId] = session; + /// Opens a test session asynchronously. + /// The session open request. + /// The client identity, if any. + /// Token to observe for cancellation. public Task OpenSessionAsync( SessionOpenRequest request, string? clientIdentity, CancellationToken cancellationToken) => Task.FromResult(seededSessions.Values.First()); + /// Tries to get a test session by identifier. + /// The session identifier. + /// The session, if found. public bool TryGetSession(string sessionId, out GatewaySession session) { if (seededSessions.TryGetValue(sessionId, out GatewaySession? seeded)) @@ -871,6 +885,10 @@ public sealed class MxAccessGatewayServiceConstraintTests return true; } + /// Invokes a worker command and returns the reply asynchronously. + /// The session identifier. + /// The worker command. + /// Token to observe for cancellation. public Task InvokeAsync( string sessionId, WorkerCommand command, @@ -881,6 +899,9 @@ public sealed class MxAccessGatewayServiceConstraintTests return Task.FromResult(InvokeReply); } + /// Reads events from the session asynchronously. + /// The session identifier. + /// Token to observe for cancellation. public async IAsyncEnumerable ReadEventsAsync( string sessionId, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) @@ -893,21 +914,33 @@ public sealed class MxAccessGatewayServiceConstraintTests } } + /// Closes a test session asynchronously. + /// The session identifier. + /// Token to observe for cancellation. public Task CloseSessionAsync( string sessionId, CancellationToken cancellationToken) => Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); + /// Kills a worker process asynchronously. + /// The session identifier. + /// The reason for killing the worker. + /// Token to observe for cancellation. public Task KillWorkerAsync( string sessionId, string reason, CancellationToken cancellationToken) => Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); + /// Closes expired session leases asynchronously. + /// The current time to check against. + /// Token to observe for cancellation. public Task CloseExpiredLeasesAsync( DateTimeOffset now, CancellationToken cancellationToken) => Task.FromResult(0); + /// Shuts down the test session manager asynchronously. + /// Token to observe for cancellation. public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; private static GatewaySession CreateFallbackSession(string sessionId) @@ -932,6 +965,9 @@ public sealed class MxAccessGatewayServiceConstraintTests private sealed class FakeEventStreamService(FakeSessionManager sessionManager) : IEventStreamService { + /// Streams events for the test session asynchronously. + /// The stream events request. + /// Token to observe for cancellation. public async IAsyncEnumerable StreamEventsAsync( StreamEventsRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) @@ -947,21 +983,33 @@ public sealed class MxAccessGatewayServiceConstraintTests private sealed class FakeWorkerClient : IWorkerClient { + /// Gets the test session identifier. public string SessionId { get; } = MxAccessGatewayServiceConstraintTests.SessionId; + /// Gets the test worker process identifier. public int? ProcessId { get; } = 1234; + /// Gets the test worker client state. public WorkerClientState State { get; } = WorkerClientState.Ready; + /// Gets the last recorded heartbeat time. public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow; + /// Starts the test worker client asynchronously. + /// Token to observe for cancellation. public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + /// Invokes a command on the test worker asynchronously. + /// The worker command. + /// Maximum time to wait for completion. + /// Token to observe for cancellation. public Task InvokeAsync( WorkerCommand command, TimeSpan timeout, CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply()); + /// Reads events from the test worker asynchronously. + /// Token to observe for cancellation. public async IAsyncEnumerable ReadEventsAsync( [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { @@ -969,12 +1017,18 @@ public sealed class MxAccessGatewayServiceConstraintTests yield break; } + /// Shuts down the test worker client asynchronously. + /// Maximum time to wait for completion. + /// Token to observe for cancellation. public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask; + /// Kills the test worker process. + /// The reason for killing the worker. public void Kill(string reason) { } + /// public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewaySessionTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewaySessionTests.cs index 9471bec..403e532 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewaySessionTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewaySessionTests.cs @@ -187,35 +187,46 @@ public sealed class GatewaySessionTests private readonly TaskCompletionSource _shutdownStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _shutdownReleased = new(TaskCreationOptions.RunContinuationsAsynchronously); + /// Gets the session identifier. public string SessionId { get; } = "session-test"; + /// Gets the worker process identifier. public int? ProcessId { get; } = 1234; + /// Gets or sets the worker client state. public WorkerClientState State { get; private set; } = WorkerClientState.Ready; + /// Gets the last recorded heartbeat timestamp. public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow; + /// Gets the count of shutdown invocations. public int ShutdownCount { get; private set; } + /// Gets the count of dispose invocations. public int DisposeCount { get; private set; } + /// Waits for shutdown to start. public Task WaitForShutdownStartAsync() { return _shutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); } + /// Releases the shutdown block. public void ReleaseShutdown() { _shutdownReleased.TrySetResult(); } + /// public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + /// public Task InvokeAsync( WorkerCommand command, TimeSpan timeout, CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply()); + /// public async IAsyncEnumerable ReadEventsAsync( [EnumeratorCancellation] CancellationToken cancellationToken) { @@ -223,6 +234,7 @@ public sealed class GatewaySessionTests yield break; } + /// public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) { ShutdownCount++; @@ -231,11 +243,13 @@ public sealed class GatewaySessionTests State = WorkerClientState.Closed; } + /// public void Kill(string reason) { State = WorkerClientState.Faulted; } + /// public ValueTask DisposeAsync() { DisposeCount++; @@ -245,23 +259,31 @@ public sealed class GatewaySessionTests private sealed class FakeWorkerClient : IWorkerClient { + /// Gets the session identifier. public string SessionId { get; } = "session-test"; + /// Gets the worker process identifier. public int? ProcessId { get; } = 1234; + /// Gets the worker client state. public WorkerClientState State { get; } = WorkerClientState.Ready; + /// Gets the last recorded heartbeat timestamp. public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow; + /// Gets the count of dispose invocations. public int DisposeCount { get; private set; } + /// public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + /// public Task InvokeAsync( WorkerCommand command, TimeSpan timeout, CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply()); + /// public async IAsyncEnumerable ReadEventsAsync( [EnumeratorCancellation] CancellationToken cancellationToken) { @@ -269,12 +291,15 @@ public sealed class GatewaySessionTests yield break; } + /// public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask; + /// public void Kill(string reason) { } + /// public ValueTask DisposeAsync() { DisposeCount++; diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerBulkTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerBulkTests.cs index e91d2ac..d5fd7d4 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerBulkTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerBulkTests.cs @@ -20,6 +20,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions; /// public sealed class SessionManagerBulkTests { + /// Verifies that AddItemBulkAsync forwards the command and returns results. [Fact] public async Task AddItemBulkAsync_ForwardsOneAddItemBulkCommandAndReturnsResults() { @@ -48,6 +49,7 @@ public sealed class SessionManagerBulkTests Assert.Equal("invalid tag", results[1].ErrorMessage); } + /// Verifies that AddItemBulkAsync propagates cancellation. [Fact] public async Task AddItemBulkAsync_PropagatesCancellation() { @@ -60,6 +62,7 @@ public sealed class SessionManagerBulkTests async () => await session.AddItemBulkAsync(12, ["Tag.A"], cts.Token)); } + /// Verifies that AdviseItemBulkAsync forwards the command and returns results. [Fact] public async Task AdviseItemBulkAsync_ForwardsOneAdviseItemBulkCommandAndReturnsResults() { @@ -86,6 +89,7 @@ public sealed class SessionManagerBulkTests Assert.False(results[1].WasSuccessful); } + /// Verifies that AdviseItemBulkAsync propagates cancellation. [Fact] public async Task AdviseItemBulkAsync_PropagatesCancellation() { @@ -98,6 +102,7 @@ public sealed class SessionManagerBulkTests async () => await session.AdviseItemBulkAsync(12, [101], cts.Token)); } + /// Verifies that RemoveItemBulkAsync forwards the command and returns results. [Fact] public async Task RemoveItemBulkAsync_ForwardsOneRemoveItemBulkCommandAndReturnsResults() { @@ -122,6 +127,7 @@ public sealed class SessionManagerBulkTests Assert.False(results[1].WasSuccessful); } + /// Verifies that RemoveItemBulkAsync propagates cancellation. [Fact] public async Task RemoveItemBulkAsync_PropagatesCancellation() { @@ -134,6 +140,7 @@ public sealed class SessionManagerBulkTests async () => await session.RemoveItemBulkAsync(12, [11], cts.Token)); } + /// Verifies that UnAdviseItemBulkAsync forwards the command and returns results. [Fact] public async Task UnAdviseItemBulkAsync_ForwardsOneUnAdviseItemBulkCommandAndReturnsResults() { @@ -159,6 +166,7 @@ public sealed class SessionManagerBulkTests Assert.Equal("not advised", results[1].ErrorMessage); } + /// Verifies that UnAdviseItemBulkAsync propagates cancellation. [Fact] public async Task UnAdviseItemBulkAsync_PropagatesCancellation() { @@ -171,6 +179,7 @@ public sealed class SessionManagerBulkTests async () => await session.UnAdviseItemBulkAsync(12, [21], cts.Token)); } + /// Verifies that SubscribeBulkAsync surfaces per-entry failures. [Fact] public async Task SubscribeBulkAsync_SurfacesPerEntryFailures() { @@ -198,6 +207,7 @@ public sealed class SessionManagerBulkTests Assert.Equal("MXAccess subscribe failed", results[1].ErrorMessage); } + /// Verifies that SubscribeBulkAsync propagates cancellation. [Fact] public async Task SubscribeBulkAsync_PropagatesCancellation() { @@ -210,6 +220,7 @@ public sealed class SessionManagerBulkTests async () => await session.SubscribeBulkAsync(12, ["Tag"], cts.Token)); } + /// Verifies that UnsubscribeBulkAsync forwards the command and returns results. [Fact] public async Task UnsubscribeBulkAsync_ForwardsOneUnsubscribeBulkCommandAndReturnsResults() { @@ -234,6 +245,7 @@ public sealed class SessionManagerBulkTests Assert.False(results[1].WasSuccessful); } + /// Verifies that UnsubscribeBulkAsync propagates cancellation. [Fact] public async Task UnsubscribeBulkAsync_PropagatesCancellation() { @@ -246,6 +258,7 @@ public sealed class SessionManagerBulkTests async () => await session.UnsubscribeBulkAsync(12, [31], cts.Token)); } + /// Verifies that WriteBulkAsync surfaces per-entry failures. [Fact] public async Task WriteBulkAsync_SurfacesPerEntryFailures() { @@ -278,6 +291,7 @@ public sealed class SessionManagerBulkTests Assert.Equal("MXAccess invalid handle", results[1].ErrorMessage); } + /// Verifies that WriteBulkAsync propagates cancellation. [Fact] public async Task WriteBulkAsync_PropagatesCancellation() { @@ -293,6 +307,7 @@ public sealed class SessionManagerBulkTests cts.Token)); } + /// Verifies that Write2BulkAsync forwards the command and preserves timestamp payload. [Fact] public async Task Write2BulkAsync_ForwardsOneWrite2BulkCommandAndPreservesTimestampPayload() { @@ -335,6 +350,7 @@ public sealed class SessionManagerBulkTests Assert.False(results[1].WasSuccessful); } + /// Verifies that Write2BulkAsync propagates cancellation. [Fact] public async Task Write2BulkAsync_PropagatesCancellation() { @@ -359,6 +375,7 @@ public sealed class SessionManagerBulkTests cts.Token)); } + /// Verifies that WriteSecuredBulkAsync forwards the command and preserves credential payload. [Fact] public async Task WriteSecuredBulkAsync_ForwardsOneWriteSecuredBulkCommandAndPreservesCredentialPayload() { @@ -409,6 +426,7 @@ public sealed class SessionManagerBulkTests Assert.Equal("MXAccess secured-write rejected", results[1].ErrorMessage); } + /// Verifies that WriteSecuredBulkAsync propagates cancellation. [Fact] public async Task WriteSecuredBulkAsync_PropagatesCancellation() { @@ -480,6 +498,7 @@ public sealed class SessionManagerBulkTests Assert.Equal(1, workerClient.InvokeCount); } + /// Verifies that WriteSecured2BulkAsync forwards the command and preserves credential and timestamp payload. [Fact] public async Task WriteSecured2BulkAsync_ForwardsOneWriteSecured2BulkCommandAndPreservesCredentialAndTimestampPayload() { @@ -527,6 +546,7 @@ public sealed class SessionManagerBulkTests Assert.False(results[1].WasSuccessful); } + /// Verifies that WriteSecured2BulkAsync propagates cancellation. [Fact] public async Task WriteSecured2BulkAsync_PropagatesCancellation() { @@ -552,6 +572,7 @@ public sealed class SessionManagerBulkTests cts.Token)); } + /// Verifies that ReadBulkAsync surfaces per-entry failures. [Fact] public async Task ReadBulkAsync_SurfacesPerEntryFailures() { @@ -597,6 +618,7 @@ public sealed class SessionManagerBulkTests Assert.Equal("MXAccess read timed out", results[1].ErrorMessage); } + /// Verifies that ReadBulkAsync propagates cancellation. [Fact] public async Task ReadBulkAsync_PropagatesCancellation() { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs index f379ab8..45134bf 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs @@ -50,6 +50,7 @@ public sealed class SessionManagerTests Assert.Equal(clock.GetUtcNow() + TimeSpan.FromMinutes(30), session.LeaseExpiresAt); } + /// Verifies that session generation creates client correlation ID from client name and session ID. [Fact] public async Task OpenSessionAsync_GeneratesClientCorrelationIdFromClientNameAndSessionId() { @@ -124,6 +125,7 @@ public sealed class SessionManagerTests Assert.True(session.LeaseExpiresAt > DateTimeOffset.UtcNow); } + /// Verifies that gateway session subscribe bulk forwards one bulk command and returns results. [Fact] public async Task GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommandAndReturnsResults() { @@ -168,6 +170,7 @@ public sealed class SessionManagerTests Assert.Equal(["Galaxy.Tag.Value"], workerClient.LastCommand?.Command.SubscribeBulk.TagAddresses); } + /// Verifies that gateway session write bulk forwards one bulk command and returns results. [Fact] public async Task GatewaySessionWriteBulkAsync_ForwardsOneBulkCommandAndReturnsResults() { @@ -232,6 +235,7 @@ public sealed class SessionManagerTests Assert.Equal(2, workerClient.LastCommand?.Command.WriteBulk.Entries.Count); } + /// Verifies that gateway session read bulk forwards one bulk command and returns results. [Fact] public async Task GatewaySessionReadBulkAsync_ForwardsOneBulkCommandAndReturnsResults() { @@ -497,6 +501,7 @@ public sealed class SessionManagerTests /// . A blank or whitespace reason must throw /// before any session lookup or worker call runs. /// + /// A blank or whitespace reason string. [Theory] [InlineData("")] [InlineData(" ")] @@ -710,6 +715,7 @@ public sealed class SessionManagerTests Assert.Equal(0, workerClient.ShutdownCount); } + /// Verifies that shutdown closes all registered sessions. [Fact] public async Task ShutdownAsync_ClosesAllRegisteredSessions() { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/OrphanWorkerTerminatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/OrphanWorkerTerminatorTests.cs index 3507700..3f407a7 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/OrphanWorkerTerminatorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/OrphanWorkerTerminatorTests.cs @@ -15,6 +15,7 @@ public sealed class OrphanWorkerTerminatorTests { private const string WorkerExecutablePath = @"C:\app\src\ZB.MOM.WW.MxGateway.Worker\bin\x86\Release\ZB.MOM.WW.MxGateway.Worker.exe"; + /// Verifies that orphan worker processes matching the configured executable path are killed. [Fact] public void TerminateOrphans_KillsWorkerProcessesMatchingConfiguredExecutablePath() { @@ -31,6 +32,7 @@ public sealed class OrphanWorkerTerminatorTests Assert.Equal([101, 102], inspector.KilledProcessIds.Order()); } + /// Verifies that orphan workers are killed when executable path is unreadable but image name matches. [Fact] public void TerminateOrphans_KillsImageNameMatchWhenExecutablePathUnreadable() { @@ -49,6 +51,7 @@ public sealed class OrphanWorkerTerminatorTests Assert.Equal([201], inspector.KilledProcessIds); } + /// Verifies that unrelated processes with the same image name are not killed. [Fact] public void TerminateOrphans_DoesNotKillUnrelatedProcessSharingImageName() { @@ -66,6 +69,7 @@ public sealed class OrphanWorkerTerminatorTests Assert.Empty(inspector.KilledProcessIds); } + /// Verifies that the current process is not killed even if path matches. [Fact] public void TerminateOrphans_DoesNotKillCurrentProcess() { @@ -81,6 +85,7 @@ public sealed class OrphanWorkerTerminatorTests Assert.Empty(inspector.KilledProcessIds); } + /// Verifies that termination continues when one process kill fails. [Fact] public void TerminateOrphans_ContinuesWhenOneKillThrows() { @@ -118,12 +123,18 @@ public sealed class OrphanWorkerTerminatorTests private sealed class FakeProcessInspector(IReadOnlyList processes) : IRunningProcessInspector { + /// Gets the list of killed process IDs. public List KilledProcessIds { get; } = []; + /// Gets or sets the process ID that should throw when killed. public int? ThrowOnKillProcessId { get; init; } + /// Gets the list of running processes by name. + /// The process name to search for. public IReadOnlyList GetProcessesByName(string processName) => processes; + /// Kills the specified process or records the kill attempt. + /// The process identifier to kill. public void Kill(int processId) { if (ThrowOnKillProcessId == processId) diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs index ef06a0a..7d46680 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs @@ -247,6 +247,7 @@ public sealed class WorkerClientTests Assert.Equal(WorkerClientState.Faulted, client.State); } + /// Verifies that pipe disconnect faults the client. [Fact] public async Task ReadLoop_WhenPipeDisconnects_FaultsClient() { @@ -767,23 +768,32 @@ public sealed class WorkerClientTests { private readonly TaskCompletionSource _exited = new(TaskCreationOptions.RunContinuationsAsynchronously); + /// Gets the process ID. public int Id { get; } = WorkerProcessId; + /// Gets a value indicating whether the process has exited. public bool HasExited { get; private set; } + /// Gets the process exit code. public int? ExitCode { get; private set; } + /// Gets the number of times kill was called. public int KillCount { get; private set; } + /// Gets the last kill request's entire process tree flag. public bool KillEntireProcessTree { get; private set; } + /// Gets a value indicating whether dispose was called. public bool Disposed { get; private set; } + /// public ValueTask WaitForExitAsync(CancellationToken cancellationToken) { return new ValueTask(_exited.Task.WaitAsync(cancellationToken)); } + /// Records a kill request. + /// Whether to kill the entire process tree. public void Kill(bool entireProcessTree) { KillCount++; @@ -793,6 +803,7 @@ public sealed class WorkerClientTests _exited.TrySetResult(); } + /// public void Dispose() { Disposed = true; diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerExecutableValidatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerExecutableValidatorTests.cs index ace4475..6f94e73 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerExecutableValidatorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerExecutableValidatorTests.cs @@ -17,6 +17,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable private readonly List _tempFiles = []; + /// Verifies that x86 executable matching required architecture does not throw. [Fact] public void Validate_X86ExecutableMatchingRequiredArchitecture_DoesNotThrow() { @@ -25,6 +26,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable WorkerExecutableValidator.Validate(path, WorkerArchitecture.X86); } + /// Verifies that x64 executable matching required architecture does not throw. [Fact] public void Validate_X64ExecutableMatchingRequiredArchitecture_DoesNotThrow() { @@ -33,6 +35,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable WorkerExecutableValidator.Validate(path, WorkerArchitecture.X64); } + /// Verifies that x64 executable when x86 required throws invalid executable. [Fact] public void Validate_X64ExecutableWhenX86Required_ThrowsInvalidExecutable() { @@ -45,6 +48,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable Assert.Contains("architecture", exception.Message, StringComparison.OrdinalIgnoreCase); } + /// Verifies that x86 executable when x64 required throws invalid executable. [Fact] public void Validate_X86ExecutableWhenX64Required_ThrowsInvalidExecutable() { @@ -56,6 +60,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable Assert.Equal(WorkerProcessLaunchErrorCode.InvalidExecutable, exception.ErrorCode); } + /// Verifies that file without MZ header throws invalid executable. [Fact] public void Validate_FileWithoutMzHeader_ThrowsInvalidExecutable() { @@ -70,6 +75,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable Assert.Contains("MZ", exception.Message, StringComparison.Ordinal); } + /// Verifies that file too small for PE header throws invalid executable. [Fact] public void Validate_FileTooSmallForPeHeader_ThrowsInvalidExecutable() { @@ -81,6 +87,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable Assert.Equal(WorkerProcessLaunchErrorCode.InvalidExecutable, exception.ErrorCode); } + /// Verifies that file without PE signature throws invalid executable. [Fact] public void Validate_FileWithoutPeSignature_ThrowsInvalidExecutable() { @@ -122,6 +129,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable return path; } + /// public void Dispose() { foreach (string path in _tempFiles) diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs index 65a1fe9..4cecdc0 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs @@ -177,6 +177,7 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable Assert.Equal(1, CountOccurrences(json, ApiKeySecret(apiKey))); } + /// Verifies that API key constraints are persisted correctly. [Fact] public async Task CreateKeyAsync_WithConstraints_PersistsConstraints() { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs index 08f5674..a7b5c27 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs @@ -141,6 +141,7 @@ public sealed class ApiKeyAdminCommandLineParserTests Assert.True(constraints.ReadHistorizedOnly); } + /// Verifies that create-key command without display name returns error. [Fact] public void Parse_CreateKeyWithoutDisplayName_ReturnsError() { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs index 0560b23..3b776b7 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs @@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization; public sealed class ConstraintEnforcerTests { + /// Verifies that read outside allowed subtree returns failure. [Fact] public async Task CheckReadTagAsync_WhenOutsideReadSubtree_ReturnsFailure() { @@ -28,6 +29,7 @@ public sealed class ConstraintEnforcerTests Assert.Equal("read_scope", failure.ConstraintName); } + /// Verifies that write with high classification returns failure and audits. [Fact] public async Task CheckWriteHandleAsync_WhenClassificationTooHigh_ReturnsFailureAndAudits() { @@ -70,6 +72,7 @@ public sealed class ConstraintEnforcerTests Assert.Contains("max_write_classification", entry.Details, StringComparison.Ordinal); } + /// Verifies that historized-only constraint requires historized attribute. [Fact] public async Task CheckReadTagAsync_WithHistorizedOnly_RequiresRequestedAttributeToBeHistorized() { @@ -88,6 +91,7 @@ public sealed class ConstraintEnforcerTests Assert.Equal("read_historized_only", failure.ConstraintName); } + /// Verifies that alarm-only constraint requires alarm attribute. [Fact] public async Task CheckReadTagAsync_WithAlarmOnly_RequiresRequestedAttributeToBeAlarm() { @@ -106,6 +110,7 @@ public sealed class ConstraintEnforcerTests Assert.Equal("read_alarm_only", failure.ConstraintName); } + /// Verifies that attribute-only constraint fails closed for object tag. [Fact] public async Task CheckReadTagAsync_WithAttributeOnlyConstraint_FailsClosedForObjectTag() { @@ -222,23 +227,29 @@ public sealed class ConstraintEnforcerTests private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache { + /// Gets the current cache entry. public GalaxyHierarchyCacheEntry Current { get; } = current; + /// public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; + /// public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; } private sealed class FakeAuditStore : IApiKeyAuditStore { + /// Gets the recorded audit entries. public List Entries { get; } = []; + /// public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken) { Entries.Add(entry); return Task.CompletedTask; } + /// public Task> ListRecentAsync(int count, CancellationToken cancellationToken) { return Task.FromResult>([]); diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/AlarmClientDiscoveryTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/AlarmClientDiscoveryTests.cs index c81239a..3d8b3d6 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/AlarmClientDiscoveryTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/AlarmClientDiscoveryTests.cs @@ -16,11 +16,14 @@ public sealed class AlarmClientDiscoveryTests { private readonly ITestOutputHelper output; + /// Initializes a new instance of the AlarmClientDiscoveryTests class. + /// The xUnit test output helper. public AlarmClientDiscoveryTests(ITestOutputHelper output) { this.output = output; } + /// Dumps the public surface of the aaAlarmManagedClient assembly. [Fact(Skip = "Discovery probe — flip Skip=null to dump aaAlarmManagedClient surface")] public void DumpAlarmClientPublicSurface() { diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs index 9078270..1300af4 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs @@ -1094,12 +1094,16 @@ public sealed class WorkerPipeSessionTests } /// Records an informational log event. + /// The event name. + /// The event fields. public void Information(string eventName, IReadOnlyDictionary fields) { Record(eventName, fields); } /// Records an error log event. + /// The event name. + /// The event fields. public void Error(string eventName, IReadOnlyDictionary fields) { Record(eventName, fields); diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs index b20fd66..27b2f08 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs @@ -22,6 +22,7 @@ public sealed class AlarmCommandExecutorTests private const string SessionId = "S"; private const string CorrelationId = "C"; + /// Verifies that the handler routes alarm subscriptions and returns ok. [Fact] public void SubscribeAlarms_WithHandler_RoutesToHandlerAndReturnsOk() { @@ -46,6 +47,7 @@ public sealed class AlarmCommandExecutorTests Assert.Equal(SessionId, handler.LastSessionId); } + /// Verifies that subscription without handler returns invalid request. [Fact] public void SubscribeAlarms_WithoutHandler_ReturnsInvalidRequest() { @@ -67,6 +69,7 @@ public sealed class AlarmCommandExecutorTests Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); } + /// Verifies that empty subscription expression returns invalid request. [Fact] public void SubscribeAlarms_WithEmptyExpression_ReturnsInvalidRequest() { @@ -88,6 +91,7 @@ public sealed class AlarmCommandExecutorTests Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); } + /// Verifies that acknowledge routes native status into hresult and payload. [Fact] public void AcknowledgeAlarm_WithHandler_RoutesNativeStatusIntoHresultAndPayload() { @@ -121,6 +125,7 @@ public sealed class AlarmCommandExecutorTests Assert.Equal("alice", handler.LastAckOperatorName); } + /// Verifies that invalid alarm GUID returns invalid request. [Fact] public void AcknowledgeAlarm_WithInvalidGuid_ReturnsInvalidRequest() { @@ -142,6 +147,7 @@ public sealed class AlarmCommandExecutorTests Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); } + /// Verifies that nonzero native status carries a diagnostic message. [Fact] public void AcknowledgeAlarm_WithNonzeroNativeStatus_CarriesDiagnostic() { @@ -165,6 +171,7 @@ public sealed class AlarmCommandExecutorTests Assert.Contains("-123", reply.DiagnosticMessage); } + /// Verifies that acknowledge by name routes tuple to handler. [Fact] public void AcknowledgeAlarmByName_WithHandler_RoutesTupleToHandler() { @@ -198,6 +205,7 @@ public sealed class AlarmCommandExecutorTests Assert.Equal("alice", handler.LastAckOperatorName); } + /// Verifies that empty alarm name returns invalid request. [Fact] public void AcknowledgeAlarmByName_WithEmptyName_ReturnsInvalidRequest() { @@ -221,6 +229,7 @@ public sealed class AlarmCommandExecutorTests Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); } + /// Verifies that query active alarms returns payload with snapshots. [Fact] public void QueryActiveAlarms_WithHandler_ReturnsPayloadWithSnapshots() { @@ -253,6 +262,7 @@ public sealed class AlarmCommandExecutorTests Assert.Equal("Galaxy!A", handler.LastFilterPrefix); } + /// Verifies that unsubscribe routes to handler. [Fact] public void UnsubscribeAlarms_WithHandler_RoutesToHandler() { @@ -273,6 +283,7 @@ public sealed class AlarmCommandExecutorTests Assert.True(handler.UnsubscribeCalled); } + /// Verifies that unsubscribe without handler is an ok noop. [Fact] public void UnsubscribeAlarms_WithoutHandler_IsOkNoop() { @@ -291,6 +302,7 @@ public sealed class AlarmCommandExecutorTests Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); } + /// Verifies that handler exception returns MXAccess failure status. [Fact] public void AcknowledgeAlarm_WhenHandlerThrows_ReturnsMxaccessFailure() { @@ -331,28 +343,56 @@ public sealed class AlarmCommandExecutorTests private sealed class FakeAlarmHandler : IAlarmCommandHandler { + /// Gets the last subscription expression. public string? LastSubscription { get; private set; } + + /// Gets the last session ID. public string? LastSessionId { get; private set; } + + /// Gets a value indicating whether unsubscribe was called. public bool UnsubscribeCalled { get; private set; } + + /// Gets the last acknowledge alarm GUID. public Guid LastAckGuid { get; private set; } + + /// Gets the last acknowledge operator name. public string? LastAckOperatorName { get; private set; } + + /// Gets or sets the value returned by acknowledge. public int AcknowledgeReturn { get; set; } + + /// Gets or sets a value indicating whether acknowledge throws. public bool AcknowledgeThrow { get; set; } + + /// Gets or sets the query result snapshots. public IReadOnlyList QueryResult { get; set; } = Array.Empty(); + + /// Gets the last alarm filter prefix. public string? LastFilterPrefix { get; private set; } + /// Records a subscription. + /// The subscription expression. + /// The session identifier. public void Subscribe(string subscription, string sessionId) { LastSubscription = subscription; LastSessionId = sessionId; } + /// Records an unsubscribe request. public void Unsubscribe() { UnsubscribeCalled = true; } + /// Records an acknowledge request. + /// The alarm identifier. + /// The acknowledge comment. + /// The operator user name. + /// The operator node name. + /// The operator domain. + /// The operator full name. public int Acknowledge( Guid alarmGuid, string comment, string operatorUser, string operatorNode, string operatorDomain, string operatorFullName) @@ -366,6 +406,15 @@ public sealed class AlarmCommandExecutorTests return AcknowledgeReturn; } + /// Records an acknowledge by name request. + /// The alarm name. + /// The provider name. + /// The group name. + /// The acknowledge comment. + /// The operator user name. + /// The operator node name. + /// The operator domain. + /// The operator full name. public int AcknowledgeByName( string alarmName, string providerName, string groupName, string comment, string operatorUser, string operatorNode, @@ -376,21 +425,27 @@ public sealed class AlarmCommandExecutorTests return AcknowledgeReturn; } + /// Gets the last acknowledge by name tuple. public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; } + /// Queries the active alarms with the given filter prefix. + /// The alarm filter prefix for the query. public IReadOnlyList QueryActive(string? alarmFilterPrefix) { LastFilterPrefix = alarmFilterPrefix; return QueryResult; } + /// Gets the number of poll calls. public int PollCount { get; private set; } + /// Increments the poll count. public void PollOnce() { PollCount++; } + /// public void Dispose() { } } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs index 7baff26..fdd004a 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs @@ -12,6 +12,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; /// public sealed class AlarmCommandHandlerTests { + /// Verifies that subscribe creates a consumer and forwards the subscription when not yet subscribed. [Fact] public void Subscribe_WhenNotYetSubscribed_CreatesConsumerAndCallsSubscribe() { @@ -26,6 +27,7 @@ public sealed class AlarmCommandHandlerTests Assert.Equal(@"\\HOST\Galaxy!Area", consumer.LastSubscription); } + /// Verifies that subscribe throws when already subscribed. [Fact] public void Subscribe_WhenAlreadySubscribed_Throws() { @@ -67,6 +69,7 @@ public sealed class AlarmCommandHandlerTests Assert.True(consumer.Disposed); } + /// Verifies that unsubscribe disposes consumer and clears state when subscribed. [Fact] public void Unsubscribe_WhenSubscribed_DisposesConsumerAndClearsState() { @@ -82,6 +85,7 @@ public sealed class AlarmCommandHandlerTests Assert.True(consumer.Disposed); } + /// Verifies that unsubscribe is a no-op when not yet subscribed. [Fact] public void Unsubscribe_WithoutPriorSubscribe_IsNoop() { @@ -92,6 +96,7 @@ public sealed class AlarmCommandHandlerTests Assert.False(handler.IsSubscribed); } + /// Verifies that acknowledge forwards to consumer with full operator identity when subscribed. [Fact] public void Acknowledge_WhenSubscribed_ForwardsToConsumerWithFullOperatorIdentity() { @@ -109,6 +114,7 @@ public sealed class AlarmCommandHandlerTests Assert.Equal("u", consumer.LastAckOperatorName); } + /// Verifies that acknowledge throws invalid operation when called before subscribe. [Fact] public void Acknowledge_BeforeSubscribe_ThrowsInvalidOperation() { @@ -120,6 +126,7 @@ public sealed class AlarmCommandHandlerTests () => handler.Acknowledge(Guid.Empty, "", "", "", "", "")); } + /// Verifies that query active returns mapped proto snapshots when consumer has alarms. [Fact] public void QueryActive_WhenConsumerHasAlarms_ReturnsMappedProtoSnapshots() { @@ -151,6 +158,7 @@ public sealed class AlarmCommandHandlerTests Assert.Equal(AlarmConditionState.Active, snapshots[0].CurrentState); } + /// Verifies that query active filters by prefix when prefix is provided. [Fact] public void QueryActive_WithPrefix_FiltersByPrefix() { @@ -173,6 +181,7 @@ public sealed class AlarmCommandHandlerTests Assert.Equal("Galaxy!AreaA.Tag1", filtered[0].AlarmFullReference); } + /// Verifies that dispose unsubscribes and disposes consumer when subscribed. [Fact] public void Dispose_WhenSubscribed_UnsubscribesAndDisposesConsumer() { @@ -281,18 +290,28 @@ public sealed class AlarmCommandHandlerTests private sealed class FakeConsumer : IMxAccessAlarmConsumer { #pragma warning disable CS0067 // Event never invoked — fake; AlarmCommandHandler tests don't drive transitions. + /// Emitted when an alarm state transition occurs. public event EventHandler? AlarmTransitionEmitted; #pragma warning restore CS0067 + /// Gets the last subscription request. public string? LastSubscription { get; private set; } + /// Gets the last acknowledged alarm GUID. public Guid LastAckGuid { get; private set; } + /// Gets the last acknowledged operator name. public string? LastAckOperatorName { get; private set; } + /// Gets or sets the return value for acknowledge operations. public int AcknowledgeReturn { get; set; } + /// Gets or sets the snapshot result to return. public IReadOnlyList SnapshotResult { get; set; } = Array.Empty(); + /// Gets or sets a value indicating whether to throw on subscribe. public bool ThrowOnSubscribe { get; set; } + /// Gets a value indicating whether the consumer has been disposed. public bool Disposed { get; private set; } + /// Subscribes to alarms with the given subscription string. + /// The subscription reference. public void Subscribe(string subscription) { LastSubscription = subscription; @@ -302,6 +321,13 @@ public sealed class AlarmCommandHandlerTests } } + /// Acknowledges an alarm by GUID. + /// The alarm GUID. + /// The acknowledgment comment. + /// The operator name. + /// The operator node. + /// The operator domain. + /// The operator full name. public int AcknowledgeByGuid( Guid alarmGuid, string ackComment, string ackOperatorName, string ackOperatorNode, string ackOperatorDomain, string ackOperatorFullName) @@ -311,6 +337,15 @@ public sealed class AlarmCommandHandlerTests return AcknowledgeReturn; } + /// Acknowledges an alarm by name. + /// The alarm name. + /// The provider name. + /// The alarm group name. + /// The acknowledgment comment. + /// The operator name. + /// The operator node. + /// The operator domain. + /// The operator full name. public int AcknowledgeByName( string alarmName, string providerName, string groupName, string ackComment, string ackOperatorName, string ackOperatorNode, @@ -321,17 +356,22 @@ public sealed class AlarmCommandHandlerTests return AcknowledgeReturn; } + /// Gets the last acknowledge-by-name parameters. public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; } + /// Returns a snapshot of active alarms. public IReadOnlyList SnapshotActiveAlarms() => SnapshotResult; + /// Gets the number of times polled. public int PollCount { get; private set; } + /// Polls once for alarm updates. public void PollOnce() { PollCount++; } + /// public void Dispose() { Disposed = true; diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs index 55a0b65..18af656 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs @@ -17,6 +17,7 @@ public sealed class AlarmDispatcherTests { private const string SessionId = "session-001"; + /// Verifies that alarm transitions land in the queue with correctly mapped fields. [Fact] public void OnTransition_WhenAlarmTransitionRaised_LandsInQueueWithMappedFields() { @@ -63,6 +64,7 @@ public sealed class AlarmDispatcherTests Assert.Equal(ts, body.TransitionTimestamp.ToDateTime()); } + /// Verifies that unchanged alarm states do not emit transitions. [Fact] public void OnTransition_WithConsecutiveUnchangedState_DoesNotEmitTransition() { @@ -89,6 +91,10 @@ public sealed class AlarmDispatcherTests Assert.Equal(0, queue.Count); } + /// Verifies that state transitions are mapped according to the state table. + /// The previous alarm state. + /// The current alarm state. + /// The expected transition kind. [Theory] [InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)] [InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.AckAlm, AlarmTransitionKind.Acknowledge)] @@ -122,6 +128,7 @@ public sealed class AlarmDispatcherTests Assert.Equal(expected, evt!.Event.OnAlarmTransition.TransitionKind); } + /// Verifies that subscribe calls are forwarded to the consumer. [Fact] public void Subscribe_WhenInvoked_ForwardsToConsumer() { @@ -135,6 +142,7 @@ public sealed class AlarmDispatcherTests Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription); } + /// Verifies that acknowledge calls are forwarded to the consumer with operator identity. [Fact] public void Acknowledge_WhenInvoked_ForwardsToConsumerWithFullOperatorIdentity() { @@ -158,6 +166,7 @@ public sealed class AlarmDispatcherTests Assert.Equal("Alice Smith", consumer.LastAckOperatorFullName); } + /// Verifies that acknowledge-by-name calls are forwarded to the consumer. [Fact] public void AcknowledgeByName_WhenInvoked_ForwardsToConsumerWithFullTuple() { @@ -184,6 +193,7 @@ public sealed class AlarmDispatcherTests Assert.Equal("TestArea", consumer.LastAckByNameTuple!.Value.Group); } + /// Verifies that consumer alarm records are mapped to proto snapshots. [Fact] public void SnapshotActiveAlarms_WhenConsumerHasRecords_MapsRecordsToProtos() { @@ -232,6 +242,7 @@ public sealed class AlarmDispatcherTests Assert.Equal(AlarmConditionState.ActiveAcked, snapshots[1].CurrentState); } + /// Verifies that dispose unsubscribes the handler and disposes the consumer. [Fact] public void Dispose_WhenSubscribed_UnsubscribesHandlerAndDisposesConsumer() { @@ -260,30 +271,52 @@ public sealed class AlarmDispatcherTests private sealed class FakeAlarmConsumer : IMxAccessAlarmConsumer { + /// Raised when an alarm transition occurs. public event EventHandler? AlarmTransitionEmitted; + /// Gets the last subscription reference. public string? LastSubscription { get; private set; } + /// Gets the GUID from the last acknowledge call. public Guid LastAckGuid { get; private set; } + /// Gets the comment from the last acknowledge call. public string? LastAckComment { get; private set; } + /// Gets the operator name from the last acknowledge call. public string? LastAckOperatorName { get; private set; } + /// Gets the operator node from the last acknowledge call. public string? LastAckOperatorNode { get; private set; } + /// Gets the operator domain from the last acknowledge call. public string? LastAckOperatorDomain { get; private set; } + /// Gets the operator full name from the last acknowledge call. public string? LastAckOperatorFullName { get; private set; } + /// Gets or sets the return code for acknowledge operations. public int AcknowledgeReturn { get; set; } + /// Gets or sets the result collection for snapshot operations. public IReadOnlyList SnapshotResult { get; set; } = Array.Empty(); + /// Gets a value indicating whether this instance has been disposed. public bool Disposed { get; private set; } + /// Raises an alarm transition event. + /// The alarm transition event. public void RaiseTransition(MxAlarmTransitionEvent transition) { AlarmTransitionEmitted?.Invoke(this, transition); } + /// Records the subscription reference. + /// The subscription reference. public void Subscribe(string subscription) { LastSubscription = subscription; } + /// Records an acknowledge-by-GUID call with operator identity. + /// The alarm GUID. + /// The acknowledgment comment. + /// The operator name. + /// The operator node. + /// The operator domain. + /// The operator full name. public int AcknowledgeByGuid( Guid alarmGuid, string ackComment, @@ -301,6 +334,15 @@ public sealed class AlarmDispatcherTests return AcknowledgeReturn; } + /// Records an acknowledge-by-name call with alarm name, provider, and group. + /// The alarm name. + /// The provider name. + /// The alarm group name. + /// The acknowledgment comment. + /// The operator name. + /// The operator node. + /// The operator domain. + /// The operator full name. public int AcknowledgeByName( string alarmName, string providerName, string groupName, string ackComment, string ackOperatorName, string ackOperatorNode, @@ -311,20 +353,25 @@ public sealed class AlarmDispatcherTests return AcknowledgeReturn; } + /// Gets the last acknowledge-by-name tuple (alarm name, provider, group). public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; } + /// Returns the current snapshot result collection. public IReadOnlyList SnapshotActiveAlarms() { return SnapshotResult; } + /// Gets the count of poll operations. public int PollCount { get; private set; } + /// Increments the poll count. public void PollOnce() { PollCount++; } + /// public void Dispose() { Disposed = true; diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs index 1f223c8..5009f6e 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs @@ -14,6 +14,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; /// public sealed class AlarmRecordTransitionMapperTests { + /// Verifies that full alarm reference uses provider!group.name format. [Fact] public void ComposeFullReference_WithProviderAndGroup_UsesProviderBangGroupDotNameFormat() { @@ -24,6 +25,7 @@ public sealed class AlarmRecordTransitionMapperTests Assert.Equal("GalaxyAlarmProvider!Tank01.Level.HiHi", reference); } + /// Verifies that reference omits provider when empty. [Fact] public void ComposeFullReference_WithEmptyProvider_DropsProvider() { @@ -32,6 +34,7 @@ public sealed class AlarmRecordTransitionMapperTests Assert.Equal("Tank01.Level.HiHi", reference); } + /// Verifies that reference omits group when empty. [Fact] public void ComposeFullReference_WithEmptyGroup_DropsGroup() { @@ -40,6 +43,7 @@ public sealed class AlarmRecordTransitionMapperTests Assert.Equal("GalaxyAlarmProvider!GlobalAlarm", reference); } + /// Verifies that reference returns alarm name when provider and group are empty. [Fact] public void ComposeFullReference_WithEmptyProviderAndGroup_ReturnsAlarmName() { @@ -48,6 +52,9 @@ public sealed class AlarmRecordTransitionMapperTests Assert.Equal("Bare", reference); } + /// Verifies that state string parsing decodes all valid state kinds. + /// The state string to parse. + /// The expected decoded state kind. [Theory] [InlineData("UNACK_ALM", MxAlarmStateKind.UnackAlm)] [InlineData("ACK_ALM", MxAlarmStateKind.AckAlm)] @@ -63,6 +70,10 @@ public sealed class AlarmRecordTransitionMapperTests Assert.Equal(expected, AlarmRecordTransitionMapper.ParseStateKind(input)); } + /// Verifies that state pair mapping decides the correct transition kind for all pairs. + /// The previous alarm state kind. + /// The current alarm state kind. + /// The expected transition kind. [Theory] // First sighting: new alarm in *_ALM → Raise. [InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)] @@ -91,6 +102,7 @@ public sealed class AlarmRecordTransitionMapperTests Assert.Equal(expected, AlarmRecordTransitionMapper.MapTransition(previous, current)); } + /// Verifies that valid XML fields assemble into correct UTC timestamp. [Fact] public void ParseTransitionTimestampUtc_WithValidXmlFields_AssemblesUtc() { @@ -109,6 +121,7 @@ public sealed class AlarmRecordTransitionMapperTests Assert.Equal(709, utc.Millisecond); } + /// Verifies that unparseable inputs return DateTime.MinValue. [Fact] public void ParseTransitionTimestampUtc_WithUnparseableInputs_ReturnsMinValue() { diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessComServerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessComServerTests.cs index ebc26c4..e821cac 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessComServerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessComServerTests.cs @@ -75,17 +75,24 @@ public sealed class MxAccessComServerTests private readonly int registerHandle; private readonly List calls = new(); + /// Initializes a new instance with the specified register handle. + /// The initial server handle value to return from Register. public RecordingMxAccessServer(int registerHandle) { this.registerHandle = registerHandle; } + /// Gets the client name passed to the most recent Register call. public string? RegisteredClientName { get; private set; } + /// Gets or sets an exception to throw from the Register method. public Exception? ThrowOnRegister { get; set; } + /// Gets the recorded method calls as strings. public IReadOnlyList Calls => calls.ToArray(); + /// Records a Register call and returns the configured handle. + /// The client name to record. public int Register(string clientName) { calls.Add($"Register:{clientName}"); @@ -98,58 +105,103 @@ public sealed class MxAccessComServerTests return registerHandle; } + /// Records an Unregister call. + /// The MXAccess server handle. public void Unregister(int serverHandle) { calls.Add($"Unregister:{serverHandle}"); } + /// Records an AddItem call and returns zero. + /// The MXAccess server handle. + /// The item definition string to record. public int AddItem(int serverHandle, string itemDefinition) { calls.Add($"AddItem:{serverHandle}:{itemDefinition}"); return 0; } + /// Records an AddItem2 call and returns zero. + /// The MXAccess server handle. + /// The item definition string to record. + /// The item context string to record. public int AddItem2(int serverHandle, string itemDefinition, string itemContext) { calls.Add($"AddItem2:{serverHandle}:{itemDefinition}:{itemContext}"); return 0; } + /// Records a RemoveItem call. + /// The MXAccess server handle. + /// The MXAccess item handle. public void RemoveItem(int serverHandle, int itemHandle) { calls.Add($"RemoveItem:{serverHandle}:{itemHandle}"); } + /// Records an Advise call. + /// The MXAccess server handle. + /// The MXAccess item handle. public void Advise(int serverHandle, int itemHandle) { calls.Add($"Advise:{serverHandle}:{itemHandle}"); } + /// Records an UnAdvise call. + /// The MXAccess server handle. + /// The MXAccess item handle. public void UnAdvise(int serverHandle, int itemHandle) { calls.Add($"UnAdvise:{serverHandle}:{itemHandle}"); } + /// Records an AdviseSupervisory call. + /// The MXAccess server handle. + /// The MXAccess item handle. public void AdviseSupervisory(int serverHandle, int itemHandle) { calls.Add($"AdviseSupervisory:{serverHandle}:{itemHandle}"); } + /// Records a Write call. + /// The MXAccess server handle. + /// The MXAccess item handle. + /// The value to write. + /// The user identifier. public void Write(int serverHandle, int itemHandle, object? value, int userId) { calls.Add($"Write:{serverHandle}:{itemHandle}:{value}:{userId}"); } + /// Records a Write2 call. + /// The MXAccess server handle. + /// The MXAccess item handle. + /// The value to write. + /// The timestamp value. + /// The user identifier. public void Write2(int serverHandle, int itemHandle, object? value, object? timestamp, int userId) { calls.Add($"Write2:{serverHandle}:{itemHandle}:{value}:{timestamp}:{userId}"); } + /// Records a WriteSecured call. + /// The MXAccess server handle. + /// The MXAccess item handle. + /// The current user identifier. + /// The verifier user identifier. + /// The value to write. public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value) { calls.Add($"WriteSecured:{serverHandle}:{itemHandle}:{currentUserId}:{verifierUserId}:{value}"); } + /// Records a WriteSecured2 call. + /// The MXAccess server handle. + /// The MXAccess item handle. + /// The current user identifier. + /// The verifier user identifier. + /// The value to write. + /// The timestamp value. public void WriteSecured2( int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestamp) { diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs index 1b39f88..f9d19fa 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs @@ -1441,6 +1441,7 @@ public sealed class MxAccessCommandExecutorTests /// Exception to throw from Advise, if any. /// Exception to throw from UnAdvise, if any. /// Exception to throw from AdviseSupervisory, if any. + /// Map of item handles to exceptions thrown on write. public FakeMxAccessComObject( int registerHandle, int addItemHandle = 0, diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs index 94b9cbc..9e1c09d 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs @@ -227,6 +227,7 @@ public sealed class MxAccessEventMapperTests } /// Verifies unparseable or empty timestamp input is rejected without throwing. + /// Unparseable or empty timestamp string. [Theory] [InlineData(null)] [InlineData("")] diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs index 88c6868..fcf563a 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs @@ -474,45 +474,73 @@ public sealed class MxAccessStaSessionTests private int pollCount; private int? lastPollThreadId; + /// Gets a value indicating whether the alarm client is currently subscribed. public bool IsSubscribed { get; private set; } + + /// Gets the last alarm subscription name. public string? LastSubscription { get; private set; } /// Exception thrown by PollOnce; null to succeed. public Exception? PollException { get; set; } + /// Gets the count of PollOnce calls. public int PollCount { get { lock (gate) return pollCount; } } + /// Gets the managed thread ID of the last PollOnce call. public int? LastPollThreadId { get { lock (gate) return lastPollThreadId; } } + /// Subscribes to alarm events. + /// The subscription descriptor. + /// The session identifier. public void Subscribe(string subscription, string sessionId) { IsSubscribed = true; LastSubscription = subscription; } + /// Unsubscribes from alarm events. public void Unsubscribe() { IsSubscribed = false; } + /// Acknowledges an alarm by guid. + /// The alarm GUID. + /// The acknowledgment comment. + /// The operator user name. + /// The operator node name. + /// The operator domain. + /// The operator full name. public int Acknowledge(Guid alarmGuid, string comment, string operatorUser, string operatorNode, string operatorDomain, string operatorFullName) => 0; + /// Acknowledges an alarm by name. + /// The alarm name. + /// The provider name. + /// The alarm group name. + /// The acknowledgment comment. + /// The operator user name. + /// The operator node name. + /// The operator domain. + /// The operator full name. public int AcknowledgeByName(string alarmName, string providerName, string groupName, string comment, string operatorUser, string operatorNode, string operatorDomain, string operatorFullName) => 0; + /// Queries active alarms. + /// Optional alarm name filter prefix. public IReadOnlyList QueryActive(string? alarmFilterPrefix) => Array.Empty(); + /// Polls for alarm events once. public void PollOnce() { lock (gate) @@ -527,6 +555,7 @@ public sealed class MxAccessStaSessionTests } } + /// public void Dispose() { } } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessValueCacheTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessValueCacheTests.cs index 4064883..14b2a8e 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessValueCacheTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessValueCacheTests.cs @@ -17,6 +17,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; /// public sealed class MxAccessValueCacheTests { + /// Verifies that cache returns the last value with incrementing versions. [Fact] public void Set_ThenTryGet_ReturnsLastValueWithIncrementingVersion() { @@ -45,6 +46,7 @@ public sealed class MxAccessValueCacheTests Assert.Equal(999, other.Value.Int32Value); } + /// Verifies that TryGet returns false for unknown handles. [Fact] public void TryGet_WithUnknownHandle_ReturnsFalse() { @@ -53,6 +55,7 @@ public sealed class MxAccessValueCacheTests Assert.False(cache.TryGet(serverHandle: 7, itemHandle: 21, out _)); } + /// Verifies that Remove drops entries and resets versions. [Fact] public void Remove_DropsEntryAndResetsVersion() { @@ -71,6 +74,7 @@ public sealed class MxAccessValueCacheTests Assert.Equal(1UL, reset.Version); } + /// Verifies that CurrentVersion returns zero for unknown handles and the latest for known ones. [Fact] public void CurrentVersion_ReturnsZeroForUnknown_AndLatestForKnown() { @@ -83,23 +87,7 @@ public sealed class MxAccessValueCacheTests Assert.Equal(2UL, cache.CurrentVersion(7, 21)); } - /// - /// Worker.Tests-020: pins the contract that TryWaitForUpdate - /// returns false when the deadline has elapsed with no - /// Set, yields a default CachedValue, and invokes - /// pumpStep at least once so MXAccess Windows messages can - /// be dispatched. Earlier revisions of this test asserted both an - /// elapsed-time floor (stopwatch.ElapsedMilliseconds >= 60) - /// and pumpCalls > 1 — the same wall-clock-floor race - /// pattern Worker.Tests-003/004/013 corrected. To eliminate the - /// timing dependency entirely (the equivalent of a manual time - /// source for a DateTime.UtcNow-based deadline), the test - /// now supplies a deadline already in the past: the loop pumps - /// once, observes the passed deadline, and returns false - /// deterministically without any Thread.Sleep. The - /// deadline-honouring contract is what this test exists to pin; - /// elapsed time and pump-iteration count are incidental. - /// + /// Verifies that TryWaitForUpdate returns false after the deadline expires. [Fact] public void TryWaitForUpdate_ReturnsFalseAfterDeadline_WhenNoSetOccurs() { @@ -126,6 +114,7 @@ public sealed class MxAccessValueCacheTests Assert.Equal(1, pumpCalls); } + /// Verifies that TryWaitForUpdate returns true when the cache is updated after the baseline. [Fact] public async Task TryWaitForUpdate_ReturnsTrue_WhenSetFiresAfterBaselineVersion() { diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs index 062fefc..32197b6 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs @@ -35,6 +35,7 @@ public sealed class WnWrapAlarmConsumerXmlTests private const string EmptyXml = ""; + /// Verifies that empty XML payload returns an empty dictionary. [Fact] public void ParseSnapshotXml_WithEmptyPayload_ReturnsEmptyDictionary() { @@ -42,6 +43,7 @@ public sealed class WnWrapAlarmConsumerXmlTests Assert.Empty(records); } + /// Verifies that null or whitespace payload returns an empty dictionary. [Fact] public void ParseSnapshotXml_WithNullOrWhitespace_ReturnsEmptyDictionary() { @@ -49,6 +51,7 @@ public sealed class WnWrapAlarmConsumerXmlTests Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(" ")); } + /// Verifies that single alarm XML payload decodes the record correctly. [Fact] public void ParseSnapshotXml_WithSingleActiveAlarm_DecodesRecord() { @@ -74,6 +77,7 @@ public sealed class WnWrapAlarmConsumerXmlTests Assert.Equal(26, record.TransitionTimestampUtc.Minute); } + /// Verifies that invalid GUIDs in XML payload are silently dropped. [Fact] public void ParseSnapshotXml_WithInvalidGuids_SilentlyDropsRecords() { @@ -83,6 +87,9 @@ public sealed class WnWrapAlarmConsumerXmlTests Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(xml)); } + /// Verifies that dashless 32-character hex GUIDs parse correctly. + /// The dashless hex string. + /// The expected canonical GUID form. [Theory] [InlineData("BCC4705395424D65BDAABCDEA6A32A73", "BCC47053-9542-4D65-BDAA-BCDEA6A32A73")] [InlineData("00000000000000000000000000000000", "00000000-0000-0000-0000-000000000000")] @@ -92,6 +99,8 @@ public sealed class WnWrapAlarmConsumerXmlTests Assert.Equal(new Guid(expected), guid); } + /// Verifies that canonical dashed GUID format is accepted. + /// The canonical GUID form. [Theory] [InlineData("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")] public void TryParseHexGuid_WithCanonicalDashedForm_Accepts(string canonical) @@ -100,6 +109,8 @@ public sealed class WnWrapAlarmConsumerXmlTests Assert.Equal(new Guid(canonical), guid); } + /// Verifies that invalid GUID inputs are rejected. + /// The invalid GUID hex string. [Theory] [InlineData(null)] [InlineData("")] diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmClientWmProbeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmClientWmProbeTests.cs index 8f7fbb6..6e74ba8 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmClientWmProbeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmClientWmProbeTests.cs @@ -135,11 +135,14 @@ public sealed class AlarmClientWmProbeTests : IDisposable private IntPtr probeWindow = IntPtr.Zero; private string? registeredClass; + /// Initializes a new instance of the test class. + /// The test output helper. public AlarmClientWmProbeTests(ITestOutputHelper output) { this.output = output; } + /// Probes the alarm client for window message behavior (requires AVEVA installed). [Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture alarm-path behavior")] public void ProbeAlarmClient_OnDevRig_LogsAlarmWindowMessages() { @@ -772,6 +775,7 @@ public sealed class AlarmClientWmProbeTests : IDisposable log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}"); } + /// public void Dispose() { if (wndProcHandle.IsAllocated) wndProcHandle.Free(); diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs index d2cf2ff..c92b794 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs @@ -36,11 +36,14 @@ public sealed class AlarmsLiveSmokeTests private readonly Stopwatch elapsed = Stopwatch.StartNew(); private readonly ConcurrentQueue log = new ConcurrentQueue(); + /// Initializes a new instance of the AlarmsLiveSmokeTests class. + /// Test output helper for logging. public AlarmsLiveSmokeTests(ITestOutputHelper output) { this.output = output; } + /// Verifies the alarm pipeline raises and acknowledges alarms correctly. [Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + the alarm flip script running. Verified working 2026-05-01.")] public void Alarms_FullPipelineRoundTrip_RaisesAndAcknowledges() { diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/WnWrapConsumerProbeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/WnWrapConsumerProbeTests.cs index ea585f4..ee99011 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/WnWrapConsumerProbeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/WnWrapConsumerProbeTests.cs @@ -46,11 +46,14 @@ public sealed class WnWrapConsumerProbeTests private readonly ConcurrentQueue log = new ConcurrentQueue(); private readonly Stopwatch elapsed = Stopwatch.StartNew(); + /// Initializes a new probe test with the given test output helper. + /// The xUnit test output helper. public WnWrapConsumerProbeTests(ITestOutputHelper output) { this.output = output; } + /// Probes wnwrap consumer on dev rig and logs XML alarm stream output. [Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture wnwrapConsumer XML alarm output. Verified working 2026-05-01.")] public void ProbeWnWrapConsumer_OnDevRig_LogsXmlAlarmStream() { diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs index a188aec..1afa909 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs @@ -41,12 +41,16 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler private AlarmDispatcher? dispatcher; private bool disposed; + /// Initializes a new alarm command handler with the given event queue. + /// The event queue. public AlarmCommandHandler(MxAccessEventQueue eventQueue) : this(eventQueue, () => new WnWrapAlarmConsumer(), threadAffinityCheck: null) { } /// Test seam — inject a custom consumer factory. + /// The event queue. + /// The alarm consumer factory. public AlarmCommandHandler( MxAccessEventQueue eventQueue, Func consumerFactory) @@ -68,6 +72,9 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler /// optional: tests that already drive the handler on a single /// thread can pass null. /// + /// The event queue. + /// The alarm consumer factory. + /// Optional thread affinity check action. public AlarmCommandHandler( MxAccessEventQueue eventQueue, Func consumerFactory, @@ -78,6 +85,7 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler this.threadAffinityCheck = threadAffinityCheck; } + /// Gets a value indicating whether the handler is subscribed. public bool IsSubscribed { get { lock (syncRoot) return dispatcher is not null; } diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs index ac46d66..7a9bce9 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs @@ -38,6 +38,10 @@ public sealed class AlarmDispatcher : IDisposable private readonly EventHandler handler; private bool disposed; + /// Initializes a new alarm dispatcher for the given consumer, sink, and session ID. + /// The alarm consumer. + /// The alarm event sink. + /// The session identifier. public AlarmDispatcher( IMxAccessAlarmConsumer consumer, MxAccessAlarmEventSink sink, @@ -61,6 +65,7 @@ public sealed class AlarmDispatcher : IDisposable /// transitions. The supplied subscription expression follows the /// canonical \\<machine>\Galaxy!<area> format. /// + /// The subscription expression (e.g., \\HOST\Galaxy!Area). public void Subscribe(string subscription) { if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); @@ -72,6 +77,13 @@ public sealed class AlarmDispatcher : IDisposable /// consumer's AlarmAckByGUID. Returns the AVEVA-native /// status code (0 = success). /// + /// The alarm GUID. + /// The acknowledgment comment. + /// The operator name. + /// The operator node. + /// The operator domain. + /// The operator full name. + /// The AVEVA-native status code. public int Acknowledge( Guid alarmGuid, string ackComment, @@ -95,6 +107,15 @@ public sealed class AlarmDispatcher : IDisposable /// Routes to the consumer's AcknowledgeByName path which /// maps to wwAlarmConsumerClass.AlarmAckByName. /// + /// The alarm name. + /// The provider name. + /// The group name. + /// The acknowledgment comment. + /// The operator name. + /// The operator node. + /// The operator domain. + /// The operator full name. + /// The AVEVA-native status code. public int AcknowledgeByName( string alarmName, string providerName, @@ -213,8 +234,10 @@ public sealed class AlarmDispatcher : IDisposable }; } + /// Gets the session ID. public string SessionId => sessionId; + /// public void Dispose() { if (disposed) return; diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmRecordTransitionMapper.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmRecordTransitionMapper.cs index b7003f3..fefa0c9 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmRecordTransitionMapper.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmRecordTransitionMapper.cs @@ -23,6 +23,7 @@ public static class AlarmRecordTransitionMapper /// . Unknown values map to /// . /// + /// The state XML string from AVEVA (e.g., UNACK_ALM, ACK_RTN). public static MxAlarmStateKind ParseStateKind(string? stateXml) { if (string.IsNullOrWhiteSpace(stateXml)) return MxAlarmStateKind.Unspecified; @@ -48,6 +49,8 @@ public static class AlarmRecordTransitionMapper /// Anything else → Unspecified (no proto kind to emit). /// /// + /// The previous alarm state kind. + /// The current alarm state kind. public static AlarmTransitionKind MapTransition( MxAlarmStateKind previous, MxAlarmStateKind current) @@ -81,6 +84,9 @@ public static class AlarmRecordTransitionMapper /// AcknowledgeAlarm RPC echoing a reference back as a GUID lookup) /// don't need translation. /// + /// The provider name, or null. + /// The group name, or null. + /// The alarm name, or null. public static string ComposeFullReference(string? providerName, string? groupName, string? alarmName) { string provider = providerName ?? string.Empty; diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs index 3d346ae..bad5680 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs @@ -14,12 +14,20 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; public interface IAlarmCommandHandler : IDisposable { /// Begin a subscription against the supplied AVEVA alarm-provider expression. + /// The AVEVA alarm-provider subscription expression. + /// The session identifier. void Subscribe(string subscription, string sessionId); /// Tear down the active subscription. No-op if not subscribed. void Unsubscribe(); /// Acknowledge a single alarm by GUID. Returns AVEVA's native status (0 = success). + /// The alarm GUID. + /// The acknowledgment comment. + /// The operator user name. + /// The operator node name. + /// The operator domain name. + /// The operator full name. int Acknowledge( Guid alarmGuid, string comment, @@ -32,6 +40,14 @@ public interface IAlarmCommandHandler : IDisposable /// Acknowledge a single alarm by (name, provider, group) — used when /// the caller has the human-readable reference but not the GUID. /// + /// The alarm name. + /// The provider name. + /// The group name. + /// The acknowledgment comment. + /// The operator user name. + /// The operator node name. + /// The operator domain name. + /// The operator full name. int AcknowledgeByName( string alarmName, string providerName, @@ -46,6 +62,7 @@ public interface IAlarmCommandHandler : IDisposable /// Snapshot the currently-active alarm set, optionally scoped to a /// prefix matched against AlarmFullReference. /// + /// Optional prefix to filter alarms by. IReadOnlyList QueryActive(string? alarmFilterPrefix); /// diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs index 484ef96..b3a18cf 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs @@ -45,6 +45,7 @@ public interface IMxAccessAlarmConsumer : IDisposable /// name). Subscribe does not start any polling of its own; the caller /// drives polls explicitly via . /// + /// The subscription expression (e.g., \\HOST\Galaxy!Area). void Subscribe(string subscription); /// @@ -53,6 +54,13 @@ public interface IMxAccessAlarmConsumer : IDisposable /// user / node / domain / full-name and the comment land atomically /// with the ack transition in the alarm-history log. /// + /// The alarm GUID. + /// The acknowledgment comment. + /// The operator name. + /// The operator node. + /// The operator domain. + /// The operator full name. + /// The AVEVA-native status code. int AcknowledgeByGuid( Guid alarmGuid, string ackComment, @@ -68,6 +76,15 @@ public interface IMxAccessAlarmConsumer : IDisposable /// , used when the caller has the /// human-readable reference but not the canonical GUID. /// + /// The alarm name. + /// The provider name. + /// The group name. + /// The acknowledgment comment. + /// The operator name. + /// The operator node. + /// The operator domain. + /// The operator full name. + /// The AVEVA-native status code. int AcknowledgeByName( string alarmName, string providerName, diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs index 33c485f..8948396 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs @@ -30,11 +30,15 @@ public sealed class MxAccessAlarmEventSink : IMxAccessEventSink private string sessionId = string.Empty; private bool attached; + /// Initializes a new instance of the MxAccessAlarmEventSink class with default dependencies. public MxAccessAlarmEventSink() : this(new MxAccessEventQueue(), new MxAccessEventMapper()) { } + /// Initializes a new instance of the MxAccessAlarmEventSink class. + /// Queue for buffering converted alarm events. + /// Converter for alarm transitions to protobuf format. public MxAccessAlarmEventSink( MxAccessEventQueue eventQueue, MxAccessEventMapper eventMapper) @@ -71,6 +75,17 @@ public sealed class MxAccessAlarmEventSink : IMxAccessEventSink /// tests can drive the proto build path without a real COM event /// source. /// + /// The fully qualified alarm object reference. + /// The object that sourced the alarm. + /// The alarm type name. + /// The kind of alarm transition. + /// The alarm severity level. + /// The original alarm raise timestamp in UTC, if available. + /// The timestamp of this transition in UTC. + /// The user who performed the operation, if applicable. + /// The operator's comment, if any. + /// The alarm category. + /// The alarm description. internal void EnqueueTransition( string alarmFullReference, string sourceObjectReference, diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs index 66c5053..33c0bfe 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs @@ -98,6 +98,12 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink /// Exposed internal so unit tests can drive the integrated /// sink → mapper → queue path without a live MXAccess COM event source. /// + /// The MXAccess server handle. + /// The MXAccess item handle. + /// The new item value. + /// The item quality. + /// The item timestamp. + /// Status array from MXAccess event. internal void OnDataChange( int hLMXServerHandle, int phItemHandle, @@ -128,6 +134,9 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink /// Handles the MXAccess OnWriteComplete COM event. Exposed /// internal as a unit-test seam; see . /// + /// The MXAccess server handle. + /// The MXAccess item handle. + /// Status array from MXAccess event. internal void OnWriteComplete( int hLMXServerHandle, int phItemHandle, @@ -145,6 +154,9 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink /// Handles the MXAccess OperationComplete COM event. Exposed /// internal as a unit-test seam; see . /// + /// The MXAccess server handle. + /// The MXAccess item handle. + /// Status array from MXAccess event. internal void OperationComplete( int hLMXServerHandle, int phItemHandle, @@ -162,6 +174,13 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink /// Handles the MXAccess OnBufferedDataChange COM event. Exposed /// internal as a unit-test seam; see . /// + /// The MXAccess server handle. + /// The MXAccess item handle. + /// The data type of the buffered value. + /// The new item value. + /// The item quality. + /// The item timestamp. + /// Status array from MXAccess event. internal void OnBufferedDataChange( int hLMXServerHandle, int phItemHandle, diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs index 058f388..446be67 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs @@ -46,6 +46,9 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor /// optional — when null, alarm-side commands return an /// "alarm consumer not configured" diagnostic. /// + /// MXAccess session on the STA thread. + /// Converter for MXAccess variant values to MxValue protobuf messages. + /// Optional handler for alarm-side commands. public MxAccessCommandExecutor( MxAccessSession session, VariantConverter variantConverter, @@ -63,6 +66,10 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor /// ReadBulk is exercised against a fake worker that pre-populates the /// value cache — the executor falls back to a no-op pump step. /// + /// MXAccess session on the STA thread. + /// Converter for MXAccess variant values to MxValue protobuf messages. + /// Optional handler for alarm-side commands. + /// Action to pump Windows messages, or null for tests. public MxAccessCommandExecutor( MxAccessSession session, VariantConverter variantConverter, diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessSession.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessSession.cs index 464f795..c89c667 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessSession.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessSession.cs @@ -593,6 +593,9 @@ public sealed class MxAccessSession : IDisposable /// captured as entries with /// was_successful = false; the loop never throws. /// + /// The MXAccess server handle. + /// The write entries to process. + /// Converts protobuf MxValue to COM-compatible variant. public IReadOnlyList WriteBulk( int serverHandle, IReadOnlyList entries, @@ -622,6 +625,9 @@ public sealed class MxAccessSession : IDisposable } /// Bulk Write2 — sequential MXAccess per entry. + /// The MXAccess server handle. + /// The write2 entries to process. + /// Converts protobuf MxValue to COM-compatible variant. public IReadOnlyList Write2Bulk( int serverHandle, IReadOnlyList entries, @@ -656,6 +662,9 @@ public sealed class MxAccessSession : IDisposable } /// Bulk WriteSecured — sequential MXAccess per entry. + /// The MXAccess server handle. + /// The WriteSecured entries to process. + /// Converts protobuf MxValue to COM-compatible variant. public IReadOnlyList WriteSecuredBulk( int serverHandle, IReadOnlyList entries, @@ -690,6 +699,9 @@ public sealed class MxAccessSession : IDisposable } /// Bulk WriteSecured2 — sequential MXAccess per entry. + /// The MXAccess server handle. + /// The WriteSecured2 entries to process. + /// Converts protobuf MxValue to COM-compatible variant. public IReadOnlyList WriteSecured2Bulk( int serverHandle, IReadOnlyList entries, @@ -734,6 +746,10 @@ public sealed class MxAccessSession : IDisposable /// iteration so the worker's STA can dispatch the incoming MXAccess /// message that carries the value. /// + /// The MXAccess server handle. + /// The tag addresses to read. + /// The timeout per tag. + /// Action invoked on each poll iteration. public IReadOnlyList ReadBulk( int serverHandle, IReadOnlyList tagAddresses, diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs index 22f4984..cd8448b 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs @@ -50,6 +50,7 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession /// ; pass null to opt out /// of alarm-side commands. /// + /// Factory that constructs the alarm-command handler. internal MxAccessStaSession(Func? alarmCommandHandlerFactory) : this( new StaRuntime(), @@ -130,6 +131,11 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession /// pass null to opt out of alarm-side commands (the worker rejects /// them with an "alarm consumer not configured" diagnostic). /// + /// STA thread runtime. + /// MXAccess COM object factory. + /// Event sink for MXAccess events. + /// Event queue for buffering MXAccess events. + /// Factory that constructs the alarm-command handler. public MxAccessStaSession( StaRuntime staRuntime, IMxAccessComObjectFactory factory, diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessValueCache.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessValueCache.cs index e763f9d..7bba593 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessValueCache.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessValueCache.cs @@ -57,6 +57,9 @@ public sealed class MxAccessValueCache } /// Tries to read the most recent cached value for the handle pair. + /// MXAccess server handle. + /// MXAccess item handle. + /// The cached value if found. public bool TryGet( int serverHandle, int itemHandle, @@ -74,6 +77,8 @@ public sealed class MxAccessValueCache /// when an item is unregistered so stale values are not served to a /// subsequent ReadBulk after a tag is removed and re-added. /// + /// MXAccess server handle. + /// MXAccess item handle. public void Remove( int serverHandle, int itemHandle) @@ -95,6 +100,7 @@ public sealed class MxAccessValueCache /// Version snapshot captured before the wait. /// Absolute UTC deadline. /// Action that pumps any pending Windows messages. + /// The cached value if the update was received before the deadline. /// How long to sleep between pump cycles. Default 5 ms. public bool TryWaitForUpdate( int serverHandle, @@ -129,6 +135,8 @@ public sealed class MxAccessValueCache } /// Returns the current version for a handle pair, or 0 if no entry exists. + /// MXAccess server handle. + /// MXAccess item handle. public ulong CurrentVersion( int serverHandle, int itemHandle) @@ -158,6 +166,11 @@ public sealed class MxAccessValueCache public readonly struct CachedValue { /// Initializes a new cached value snapshot. + /// Version counter incremented on each update. + /// The MXAccess value. + /// The MXAccess quality code. + /// The source timestamp of the value. + /// The MXAccess status codes. public CachedValue( ulong version, MxValue value, diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs index 401bdeb..529f089 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs @@ -9,18 +9,32 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// public sealed class MxAlarmSnapshotRecord { + /// Gets or sets the unique alarm identifier. public Guid AlarmGuid { get; set; } + /// Gets or sets the UTC timestamp of the transition. public DateTime TransitionTimestampUtc { get; set; } + /// Gets or sets the provider node name. public string ProviderNode { get; set; } = string.Empty; + /// Gets or sets the provider name. public string ProviderName { get; set; } = string.Empty; + /// Gets or sets the alarm group. public string Group { get; set; } = string.Empty; + /// Gets or sets the tag name. public string TagName { get; set; } = string.Empty; + /// Gets or sets the alarm type. public string Type { get; set; } = string.Empty; + /// Gets or sets the alarm value. public string Value { get; set; } = string.Empty; + /// Gets or sets the alarm limit. public string Limit { get; set; } = string.Empty; + /// Gets or sets the alarm priority. public int Priority { get; set; } + /// Gets or sets the alarm state. public MxAlarmStateKind State { get; set; } + /// Gets or sets the operator node name. public string OperatorNode { get; set; } = string.Empty; + /// Gets or sets the operator name. public string OperatorName { get; set; } = string.Empty; + /// Gets or sets the alarm comment. public string AlarmComment { get; set; } = string.Empty; } diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmTransitionEvent.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmTransitionEvent.cs index d903e99..4ee01ff 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmTransitionEvent.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmTransitionEvent.cs @@ -9,6 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// public sealed class MxAlarmTransitionEvent : EventArgs { + /// Gets or sets the latest alarm snapshot record. public MxAlarmSnapshotRecord Record { get; set; } = new MxAlarmSnapshotRecord(); /// diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs index bdcbf79..250cc97 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs @@ -74,6 +74,8 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer /// /// Test seam / explicit construction. /// + /// The COM alarm consumer instance. + /// Maximum alarms per fetch call. public WnWrapAlarmConsumer( wwAlarmConsumerClass client, int maxAlarmsPerFetch) @@ -393,6 +395,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer /// silently dropped (no fault is recorded — the next poll will /// resync). /// + /// The XML snapshot payload. public static Dictionary ParseSnapshotXml(string xml) { Dictionary records = @@ -455,6 +458,8 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer /// dashes (e.g. "BCC4705395424D65BDAABCDEA6A32A73"). Convert /// to 's canonical 8-4-4-4-12 layout. /// + /// The 32-character hex GUID string. + /// The parsed GUID, or Empty if parsing fails. public static bool TryParseHexGuid(string? hex, out Guid guid) { guid = Guid.Empty; @@ -481,6 +486,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer /// cleanly, fall back to a permissive ALL-priority/ALL-state form /// so the worker doesn't fail to start. /// + /// The subscription expression. internal static string ComposeXmlAlarmQuery(string subscription) { string node = Environment.MachineName;