Compare commits

..

6 Commits

Author SHA1 Message Date
Joseph Doherty 5df2ef0d1e chore(theme): bump ZB.MOM.WW.Theme 0.3.0 -> 0.3.1 (interactive-render nav fix) 2026-06-05 07:19:11 -04:00
Joseph Doherty e5785fd769 chore(theme): consume ZB.MOM.WW.Theme 0.3.0 (nav/login kit fixes) 2026-06-05 05:13:06 -04:00
Joseph Doherty 22370ca4da docs(glauth): repoint glauth.md at the shared GLAuth on 10.100.0.35
No more per-box C:\publish\glauth NSSM service — dev/test LDAP is the shared
zb-shared-glauth on 10.100.0.35:3893 (dc=zb,dc=local). Provisioning now via
scadaproj/infra/glauth/config.toml. Old localhost/NSSM procedures kept as
retired reference; test users multi-role/gw-viewer.
2026-06-04 16:38:24 -04:00
Joseph Doherty e0a3fbf35b fix(dashboard)!: move login POST to /auth/login to resolve AmbiguousMatchException
The themed Blazor <LoginCard> page (Components/Pages/Login.razor, @page "/login")
registers a Razor Components endpoint that matches ALL HTTP methods. The credential
form POSTed to /login, where MapPost("/login") also matched — so every POST /login
threw Microsoft.AspNetCore.Routing.Matching.AmbiguousMatchException (HTTP 500),
breaking dashboard login for every user. It was latent because the dashboard was only
ever reached via the AllowAnonymousLocalhost bypass on the host box.

Move the credential POST to a distinct /auth/login route (mirroring ScadaBridge, which
never collided because it posts to /auth/login). GET /login stays the Blazor page; the
cookie LoginPath stays /login. Adds a registration assertion pinning DashboardLoginPost
to /auth/login as the regression guard.

Files: Login.razor (LoginCard Action), DashboardEndpointRouteBuilderExtensions (MapPost
route), GatewayApplicationTests (route assertion).
2026-06-04 14:01:05 -04:00
Joseph Doherty 161ed6f80d chore(theme): bump ZB.MOM.WW.Theme 0.2.0 -> 0.2.1 (desktop app-shell render fix) 2026-06-04 10:23:44 -04:00
Joseph Doherty e57d864ab2 fix(dashboard): make dashboard auth cookie name configurable
The dashboard auth cookie name was hardcoded to the constant
DashboardAuthenticationDefaults.CookieName (MxGatewayDashboard). Browser
cookies are scoped by host+path but NOT by port, so two gateway instances
sharing a hostname would clobber each other's dashboard session under the
shared name.

Add DashboardOptions.CookieName (MxGateway:Dashboard:CookieName); null/blank
keeps the canonical default. Applied in the existing dashboard cookie
PostConfigure (runs after the inline AddCookie default, so it wins). Behaviour
is unchanged when unset. Adds a Tests case for the override.
2026-06-03 13:11:29 -04:00
196 changed files with 991 additions and 1241 deletions
+1 -1
View File
@@ -100,7 +100,7 @@ When source code changes, build and test the affected component before reporting
## Design Sources To Consult Before Non-Trivial Changes ## Design Sources To Consult Before Non-Trivial Changes
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling. - `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
- `glauth.md`local LDAP server (GLAuth on `localhost:3893`, base DN `dc=zb,dc=local`) used for dev authn. Pre-provisioned users (`admin/admin123`, `readonly/readonly123`, etc.) and the role→capability mapping live there. - `glauth.md`shared GLAuth LDAP server (`10.100.0.35:3893`, base DN `dc=zb,dc=local`, source of truth `scadaproj/infra/glauth/`) used for dev authn. Dashboard test users (`multi-role`/`password` = Administrator, `gw-viewer`/`password` = Viewer) and the role→capability mapping live there.
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.). - `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs. - `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`. - `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
@@ -44,7 +44,6 @@ internal sealed class CliArguments
/// <summary>Returns whether the named flag was present in the arguments.</summary> /// <summary>Returns whether the named flag was present in the arguments.</summary>
/// <param name="name">The flag name (without '--' prefix).</param> /// <param name="name">The flag name (without '--' prefix).</param>
/// <returns>True if the flag was present; otherwise false.</returns>
public bool HasFlag(string name) public bool HasFlag(string name)
{ {
return _flags.Contains(name); return _flags.Contains(name);
@@ -52,7 +51,6 @@ internal sealed class CliArguments
/// <summary>Returns the value for a named argument, or <c>null</c> if absent.</summary> /// <summary>Returns the value for a named argument, or <c>null</c> if absent.</summary>
/// <param name="name">The argument name (without '--' prefix).</param> /// <param name="name">The argument name (without '--' prefix).</param>
/// <returns>The argument value, or null if the argument was not provided.</returns>
public string? GetOptional(string name) public string? GetOptional(string name)
{ {
return _values.TryGetValue(name, out string? value) return _values.TryGetValue(name, out string? value)
@@ -62,7 +60,6 @@ internal sealed class CliArguments
/// <summary>Returns the value for a required named argument, or throws if absent.</summary> /// <summary>Returns the value for a required named argument, or throws if absent.</summary>
/// <param name="name">The argument name (without '--' prefix).</param> /// <param name="name">The argument name (without '--' prefix).</param>
/// <returns>The argument value.</returns>
public string GetRequired(string name) public string GetRequired(string name)
{ {
string? value = GetOptional(name); string? value = GetOptional(name);
@@ -77,7 +74,6 @@ internal sealed class CliArguments
/// <summary>Parses and returns an int32 argument, or the default value if absent.</summary> /// <summary>Parses and returns an int32 argument, or the default value if absent.</summary>
/// <param name="name">The argument name (without '--' prefix).</param> /// <param name="name">The argument name (without '--' prefix).</param>
/// <param name="defaultValue">The default value if the argument is absent; if <c>null</c>, the argument is required.</param> /// <param name="defaultValue">The default value if the argument is absent; if <c>null</c>, the argument is required.</param>
/// <returns>The parsed int32 value, or the default if absent.</returns>
public int GetInt32(string name, int? defaultValue = null) public int GetInt32(string name, int? defaultValue = null)
{ {
string? value = GetOptional(name); string? value = GetOptional(name);
@@ -97,7 +93,6 @@ internal sealed class CliArguments
/// <summary>Parses and returns a uint32 argument, or the default value if absent.</summary> /// <summary>Parses and returns a uint32 argument, or the default value if absent.</summary>
/// <param name="name">The argument name (without '--' prefix).</param> /// <param name="name">The argument name (without '--' prefix).</param>
/// <param name="defaultValue">The default value if the argument is absent.</param> /// <param name="defaultValue">The default value if the argument is absent.</param>
/// <returns>The parsed uint32 value, or the default if absent.</returns>
public uint GetUInt32(string name, uint defaultValue) public uint GetUInt32(string name, uint defaultValue)
{ {
string? value = GetOptional(name); string? value = GetOptional(name);
@@ -109,7 +104,6 @@ internal sealed class CliArguments
/// <summary>Parses and returns a uint64 argument, or the default value if absent.</summary> /// <summary>Parses and returns a uint64 argument, or the default value if absent.</summary>
/// <param name="name">The argument name (without '--' prefix).</param> /// <param name="name">The argument name (without '--' prefix).</param>
/// <param name="defaultValue">The default value if the argument is absent.</param> /// <param name="defaultValue">The default value if the argument is absent.</param>
/// <returns>The parsed uint64 value, or the default if absent.</returns>
public ulong GetUInt64(string name, ulong defaultValue) public ulong GetUInt64(string name, ulong defaultValue)
{ {
string? value = GetOptional(name); string? value = GetOptional(name);
@@ -121,7 +115,6 @@ internal sealed class CliArguments
/// <summary>Parses and returns a TimeSpan argument, or the default value if absent. Supports "ms", "s", and standard TimeSpan format.</summary> /// <summary>Parses and returns a TimeSpan argument, or the default value if absent. Supports "ms", "s", and standard TimeSpan format.</summary>
/// <param name="name">The argument name (without '--' prefix).</param> /// <param name="name">The argument name (without '--' prefix).</param>
/// <param name="defaultValue">The default value if the argument is absent.</param> /// <param name="defaultValue">The default value if the argument is absent.</param>
/// <returns>The parsed TimeSpan value, or the default if absent.</returns>
public TimeSpan GetDuration(string name, TimeSpan defaultValue) public TimeSpan GetDuration(string name, TimeSpan defaultValue)
{ {
string? value = GetOptional(name); string? value = GetOptional(name);
@@ -100,8 +100,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
return _galaxyClient.Value.WatchDeployEventsRawAsync(request, cancellationToken); return _galaxyClient.Value.WatchDeployEventsRawAsync(request, cancellationToken);
} }
/// <summary>Disposes the galaxy client (if created) and the underlying gateway client.</summary> /// <inheritdoc />
/// <returns>A value task that completes when both clients are disposed.</returns>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (_galaxyClient.IsValueCreated) if (_galaxyClient.IsValueCreated)
@@ -6,7 +6,6 @@ internal static class MxGatewayCliSecretRedactor
/// <summary>Replaces occurrences of the API key in the value with a redacted placeholder.</summary> /// <summary>Replaces occurrences of the API key in the value with a redacted placeholder.</summary>
/// <param name="value">The message text to redact.</param> /// <param name="value">The message text to redact.</param>
/// <param name="apiKey">The API key to remove; no redaction if null or empty.</param> /// <param name="apiKey">The API key to remove; no redaction if null or empty.</param>
/// <returns>The message text with any API key occurrence replaced by <c>[redacted]</c>.</returns>
public static string Redact(string value, string? apiKey) public static string Redact(string value, string? apiKey)
{ {
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey)) if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey))
@@ -22,7 +22,6 @@ public static class MxGatewayClientCli
/// <param name="args">Command-line arguments (command name followed by options).</param> /// <param name="args">Command-line arguments (command name followed by options).</param>
/// <param name="standardOutput">TextWriter for command output.</param> /// <param name="standardOutput">TextWriter for command output.</param>
/// <param name="standardError">TextWriter for error messages.</param> /// <param name="standardError">TextWriter for error messages.</param>
/// <returns>The process exit code (0 for success, 1 for error).</returns>
public static int Run( public static int Run(
string[] args, string[] args,
TextWriter standardOutput, TextWriter standardOutput,
@@ -39,7 +38,6 @@ public static class MxGatewayClientCli
/// <param name="standardError">TextWriter for error messages.</param> /// <param name="standardError">TextWriter for error messages.</param>
/// <param name="clientFactory">Optional factory to create the gateway client; defaults to MxGatewayClient.Create.</param> /// <param name="clientFactory">Optional factory to create the gateway client; defaults to MxGatewayClient.Create.</param>
/// <param name="standardInput">Optional TextReader for batch-mode stdin; defaults to <see cref="Console.In"/>.</param> /// <param name="standardInput">Optional TextReader for batch-mode stdin; defaults to <see cref="Console.In"/>.</param>
/// <returns>A task that resolves to the process exit code (0 for success, 1 for error).</returns>
public static Task<int> RunAsync( public static Task<int> RunAsync(
string[] args, string[] args,
TextWriter standardOutput, TextWriter standardOutput,
@@ -14,7 +14,6 @@ public sealed class BrowseChildrenSmokeTests
/// Verifies that BrowseChildren returns a non-zero cache sequence and /// Verifies that BrowseChildren returns a non-zero cache sequence and
/// a consistent children/child-has-children count from a live gateway. /// a consistent children/child-has-children count from a live gateway.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact(Skip = "Set MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to enable.")] [Fact(Skip = "Set MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to enable.")]
public async Task BrowseChildren_LiveGateway_ReturnsRootsWithCacheSequence() public async Task BrowseChildren_LiveGateway_ReturnsRootsWithCacheSequence()
{ {
@@ -8,10 +8,14 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// </summary> /// </summary>
internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions options) : IGalaxyRepositoryClientTransport internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions options) : IGalaxyRepositoryClientTransport
{ {
/// <inheritdoc /> /// <summary>
/// Gets the gateway client options.
/// </summary>
public MxGatewayClientOptions Options { get; } = options; public MxGatewayClientOptions Options { get; } = options;
/// <inheritdoc /> /// <summary>
/// Gets the raw gRPC client; always null for the fake.
/// </summary>
public GalaxyRepository.GalaxyRepositoryClient? RawClient => null; public GalaxyRepository.GalaxyRepositoryClient? RawClient => null;
/// <summary> /// <summary>
@@ -62,7 +66,11 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
/// </summary> /// </summary>
public Queue<Exception> DiscoverHierarchyExceptions { get; } = new(); public Queue<Exception> DiscoverHierarchyExceptions { get; } = new();
/// <inheritdoc /> /// <summary>
/// Records the request and either throws a queued exception or returns the configured reply.
/// </summary>
/// <param name="request">The TestConnectionRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<TestConnectionReply> TestConnectionAsync( public Task<TestConnectionReply> TestConnectionAsync(
TestConnectionRequest request, TestConnectionRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -76,7 +84,11 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
return Task.FromResult(TestConnectionReply); return Task.FromResult(TestConnectionReply);
} }
/// <inheritdoc /> /// <summary>
/// Records the request and either throws a queued exception or returns the configured reply.
/// </summary>
/// <param name="request">The GetLastDeployTimeRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<GetLastDeployTimeReply> GetLastDeployTimeAsync( public Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
GetLastDeployTimeRequest request, GetLastDeployTimeRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -90,7 +102,11 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
return Task.FromResult(GetLastDeployTimeReply); return Task.FromResult(GetLastDeployTimeReply);
} }
/// <inheritdoc /> /// <summary>
/// Records the request and either throws a queued exception or returns the configured reply.
/// </summary>
/// <param name="request">The DiscoverHierarchyRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<DiscoverHierarchyReply> DiscoverHierarchyAsync( public Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -119,7 +135,11 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
/// <summary>Queue of exceptions to throw from BrowseChildren; dequeued in FIFO order.</summary> /// <summary>Queue of exceptions to throw from BrowseChildren; dequeued in FIFO order.</summary>
public Queue<Exception> BrowseChildrenExceptions { get; } = new(); public Queue<Exception> BrowseChildrenExceptions { get; } = new();
/// <inheritdoc /> /// <summary>
/// Records the request and either throws a queued exception or returns the configured reply.
/// </summary>
/// <param name="request">The BrowseChildrenRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<BrowseChildrenReply> BrowseChildrenAsync( public Task<BrowseChildrenReply> BrowseChildrenAsync(
BrowseChildrenRequest request, BrowseChildrenRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -157,7 +177,11 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
/// </summary> /// </summary>
public Func<CancellationToken, Task>? WatchDeployEventsBeforeYield { get; set; } public Func<CancellationToken, Task>? WatchDeployEventsBeforeYield { get; set; }
/// <inheritdoc /> /// <summary>
/// Records the request and streams events, checking for queued exceptions and calling WatchDeployEventsBeforeYield before each event.
/// </summary>
/// <param name="request">The WatchDeployEventsRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync( public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request, WatchDeployEventsRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -11,10 +11,14 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
private readonly Queue<MxCommandReply> _invokeReplies = new(); private readonly Queue<MxCommandReply> _invokeReplies = new();
private readonly List<MxEvent> _events = []; private readonly List<MxEvent> _events = [];
/// <inheritdoc /> /// <summary>
/// Gets the gateway client options.
/// </summary>
public MxGatewayClientOptions Options { get; } = options; public MxGatewayClientOptions Options { get; } = options;
/// <inheritdoc /> /// <summary>
/// Gets null, since this is a test fake without a real gRPC client.
/// </summary>
public MxAccessGateway.MxAccessGatewayClient? RawClient => null; public MxAccessGateway.MxAccessGatewayClient? RawClient => null;
/// <summary> /// <summary>
@@ -98,7 +102,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
/// </summary> /// </summary>
public Queue<Exception> InvokeExceptions { get; } = new(); public Queue<Exception> InvokeExceptions { get; } = new();
/// <inheritdoc /> /// <summary>
/// Verifies that the OpenSessionAsync call is recorded and returns the configured reply.
/// </summary>
/// <param name="request">The OpenSessionRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<OpenSessionReply> OpenSessionAsync( public Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request, OpenSessionRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -112,7 +120,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
return Task.FromResult(OpenSessionReply); return Task.FromResult(OpenSessionReply);
} }
/// <inheritdoc /> /// <summary>
/// Verifies that the CloseSessionAsync call is recorded and returns the configured reply.
/// </summary>
/// <param name="request">The CloseSessionRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<CloseSessionReply> CloseSessionAsync( public Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request, CloseSessionRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -126,7 +138,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
return Task.FromResult(CloseSessionReply); return Task.FromResult(CloseSessionReply);
} }
/// <inheritdoc /> /// <summary>
/// Verifies that the InvokeAsync call is recorded and returns the next enqueued reply.
/// </summary>
/// <param name="request">The MxCommandRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<MxCommandReply> InvokeAsync( public Task<MxCommandReply> InvokeAsync(
MxCommandRequest request, MxCommandRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -140,7 +156,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
return Task.FromResult(_invokeReplies.Dequeue()); return Task.FromResult(_invokeReplies.Dequeue());
} }
/// <inheritdoc /> /// <summary>
/// Verifies that the StreamEventsAsync call is recorded and yields all enqueued events.
/// </summary>
/// <param name="request">The StreamEventsRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public async IAsyncEnumerable<MxEvent> StreamEventsAsync( public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request, StreamEventsRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -173,7 +193,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
_events.Add(gatewayEvent); _events.Add(gatewayEvent);
} }
/// <inheritdoc /> /// <summary>
/// Records the acknowledge call and returns the next enqueued reply (or default).
/// </summary>
/// <param name="request">The acknowledge alarm request.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync( public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
AcknowledgeAlarmRequest request, AcknowledgeAlarmRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -194,7 +218,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
}); });
} }
/// <inheritdoc /> /// <summary>
/// Records the query call and yields each enqueued snapshot.
/// </summary>
/// <param name="request">The query active alarms request.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync( public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
QueryActiveAlarmsRequest request, QueryActiveAlarmsRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -223,7 +251,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
_activeAlarmSnapshots.Add(snapshot); _activeAlarmSnapshots.Add(snapshot);
} }
/// <inheritdoc /> /// <summary>
/// Records the stream-alarms call and yields each enqueued feed message.
/// </summary>
/// <param name="request">The stream alarms request.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync( public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request, StreamAlarmsRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -9,7 +9,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that TestConnectionAsync attaches the API key in request metadata and returns the Ok flag. /// Verifies that TestConnectionAsync attaches the API key in request metadata and returns the Ok flag.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task TestConnectionAsync_AttachesApiKeyMetadataAndReturnsOkFlag() public async Task TestConnectionAsync_AttachesApiKeyMetadataAndReturnsOkFlag()
{ {
@@ -28,7 +27,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that TestConnectionAsync returns false when the server reports NotOk. /// Verifies that TestConnectionAsync returns false when the server reports NotOk.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task TestConnectionAsync_ReturnsFalseWhenServerReportsNotOk() public async Task TestConnectionAsync_ReturnsFalseWhenServerReportsNotOk()
{ {
@@ -44,7 +42,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that GetLastDeployTimeAsync returns null when the server reports not present. /// Verifies that GetLastDeployTimeAsync returns null when the server reports not present.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task GetLastDeployTimeAsync_ReturnsNullWhenNotPresent() public async Task GetLastDeployTimeAsync_ReturnsNullWhenNotPresent()
{ {
@@ -61,7 +58,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that GetLastDeployTimeAsync returns the timestamp when the server reports it present. /// Verifies that GetLastDeployTimeAsync returns the timestamp when the server reports it present.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task GetLastDeployTimeAsync_ReturnsTimestampWhenPresent() public async Task GetLastDeployTimeAsync_ReturnsTimestampWhenPresent()
{ {
@@ -83,7 +79,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that DiscoverHierarchyAsync returns the objects from the server reply. /// Verifies that DiscoverHierarchyAsync returns the objects from the server reply.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply() public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply()
{ {
@@ -146,7 +141,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that DiscoverHierarchyAsync propagates cancellation tokens to the transport. /// Verifies that DiscoverHierarchyAsync propagates cancellation tokens to the transport.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task DiscoverHierarchyAsync_PropagatesCancellationToTransport() public async Task DiscoverHierarchyAsync_PropagatesCancellationToTransport()
{ {
@@ -167,7 +161,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that TestConnectionAsync retries on transient gRPC failures. /// Verifies that TestConnectionAsync retries on transient gRPC failures.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task DiscoverHierarchyAsync_WithRepeatedPageToken_ThrowsProtocolError() public async Task DiscoverHierarchyAsync_WithRepeatedPageToken_ThrowsProtocolError()
{ {
@@ -191,7 +184,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that DiscoverHierarchyAsync maps typed filter options correctly to the request. /// Verifies that DiscoverHierarchyAsync maps typed filter options correctly to the request.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters() public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters()
{ {
@@ -226,7 +218,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that TestConnectionAsync retries on transient gRPC failures. /// Verifies that TestConnectionAsync retries on transient gRPC failures.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure() public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
{ {
@@ -244,7 +235,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that DiscoverHierarchyAsync retries on transient gRPC failures. /// Verifies that DiscoverHierarchyAsync retries on transient gRPC failures.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task DiscoverHierarchyAsync_RetriesOnTransientGrpcFailure() public async Task DiscoverHierarchyAsync_RetriesOnTransientGrpcFailure()
{ {
@@ -261,7 +251,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that WatchDeployEventsAsync delivers the bootstrap event. /// Verifies that WatchDeployEventsAsync delivers the bootstrap event.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WatchDeployEventsAsync_DeliversBootstrapEvent() public async Task WatchDeployEventsAsync_DeliversBootstrapEvent()
{ {
@@ -298,7 +287,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that WatchDeployEventsAsync delivers multiple events in order. /// Verifies that WatchDeployEventsAsync delivers multiple events in order.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WatchDeployEventsAsync_DeliversMultipleEventsInOrder() public async Task WatchDeployEventsAsync_DeliversMultipleEventsInOrder()
{ {
@@ -337,7 +325,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that WatchDeployEventsAsync stops iteration cleanly when cancelled. /// Verifies that WatchDeployEventsAsync stops iteration cleanly when cancelled.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WatchDeployEventsAsync_CancellationStopsIterationCleanly() public async Task WatchDeployEventsAsync_CancellationStopsIterationCleanly()
{ {
@@ -382,7 +369,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that WatchDeployEventsAsync throws ObjectDisposedException after the client is disposed. /// Verifies that WatchDeployEventsAsync throws ObjectDisposedException after the client is disposed.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WatchDeployEventsAsync_ThrowsAfterDisposal() public async Task WatchDeployEventsAsync_ThrowsAfterDisposal()
{ {
@@ -398,7 +384,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that TestConnectionAsync throws ObjectDisposedException after the client is disposed. /// Verifies that TestConnectionAsync throws ObjectDisposedException after the client is disposed.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task TestConnectionAsync_ThrowsAfterDisposal() public async Task TestConnectionAsync_ThrowsAfterDisposal()
{ {
@@ -12,7 +12,6 @@ public sealed class LazyBrowseNodeTests
/// Verifies that calling BrowseAsync with no parent returns the root nodes /// Verifies that calling BrowseAsync with no parent returns the root nodes
/// from the first BrowseChildren reply and surfaces the per-child has-children hint. /// from the first BrowseChildren reply and surfaces the per-child has-children hint.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Browse_NoParent_ReturnsRoots() public async Task Browse_NoParent_ReturnsRoots()
{ {
@@ -37,7 +36,6 @@ public sealed class LazyBrowseNodeTests
/// <summary> /// <summary>
/// Verifies that ExpandAsync populates Children and marks the node expanded after one RPC. /// Verifies that ExpandAsync populates Children and marks the node expanded after one RPC.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Expand_PopulatesChildrenAndMarksExpanded() public async Task Expand_PopulatesChildrenAndMarksExpanded()
{ {
@@ -64,7 +62,6 @@ public sealed class LazyBrowseNodeTests
/// <summary> /// <summary>
/// Verifies that a second ExpandAsync call is a no-op and issues no additional RPC. /// Verifies that a second ExpandAsync call is a no-op and issues no additional RPC.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Expand_CalledTwice_NoSecondRpc() public async Task Expand_CalledTwice_NoSecondRpc()
{ {
@@ -89,7 +86,6 @@ public sealed class LazyBrowseNodeTests
/// <summary> /// <summary>
/// Verifies that an RPC failure (NotFound) during expand is wrapped in MxGatewayException. /// Verifies that an RPC failure (NotFound) during expand is wrapped in MxGatewayException.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Expand_UnknownParent_ThrowsMxGatewayException() public async Task Expand_UnknownParent_ThrowsMxGatewayException()
{ {
@@ -117,7 +113,6 @@ public sealed class LazyBrowseNodeTests
/// <summary> /// <summary>
/// Verifies that ExpandAsync drains multi-page sibling replies and forwards the page token. /// Verifies that ExpandAsync drains multi-page sibling replies and forwards the page token.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Expand_MultiPageSiblings_GathersAllPages() public async Task Expand_MultiPageSiblings_GathersAllPages()
{ {
@@ -152,7 +147,6 @@ public sealed class LazyBrowseNodeTests
/// <summary> /// <summary>
/// Verifies that ten concurrent ExpandAsync calls issue exactly one RPC, not ten. /// Verifies that ten concurrent ExpandAsync calls issue exactly one RPC, not ten.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Expand_CalledConcurrently_OnlyFiresOneRpc() public async Task Expand_CalledConcurrently_OnlyFiresOneRpc()
{ {
@@ -184,7 +178,6 @@ public sealed class LazyBrowseNodeTests
/// <summary> /// <summary>
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request. /// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Browse_WithFilter_ForwardsToRequest() public async Task Browse_WithFilter_ForwardsToRequest()
{ {
@@ -12,7 +12,6 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxGatewayClientAlarmsTests public sealed class MxGatewayClientAlarmsTests
{ {
/// <summary>AcknowledgeAlarmAsync records request and returns reply.</summary> /// <summary>AcknowledgeAlarmAsync records request and returns reply.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply() public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply()
{ {
@@ -49,7 +48,6 @@ public sealed class MxGatewayClientAlarmsTests
} }
/// <summary>AcknowledgeAlarmAsync honors cancellation.</summary> /// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AcknowledgeAlarmAsync_HonorsCancellation() public async Task AcknowledgeAlarmAsync_HonorsCancellation()
{ {
@@ -74,7 +72,6 @@ public sealed class MxGatewayClientAlarmsTests
} }
/// <summary>AcknowledgeAlarmAsync maps unauthenticated RPC exception to typed exception.</summary> /// <summary>AcknowledgeAlarmAsync maps unauthenticated RPC exception to typed exception.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException() public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException()
{ {
@@ -100,7 +97,6 @@ public sealed class MxGatewayClientAlarmsTests
} }
/// <summary>QueryActiveAlarmsAsync streams enqueued snapshots.</summary> /// <summary>QueryActiveAlarmsAsync streams enqueued snapshots.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task QueryActiveAlarmsAsync_StreamsEnqueuedSnapshots() public async Task QueryActiveAlarmsAsync_StreamsEnqueuedSnapshots()
{ {
@@ -126,7 +122,6 @@ public sealed class MxGatewayClientAlarmsTests
} }
/// <summary>QueryActiveAlarmsAsync passes filter prefix.</summary> /// <summary>QueryActiveAlarmsAsync passes filter prefix.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task QueryActiveAlarmsAsync_PassesFilterPrefix() public async Task QueryActiveAlarmsAsync_PassesFilterPrefix()
{ {
@@ -147,7 +142,6 @@ public sealed class MxGatewayClientAlarmsTests
} }
/// <summary>QueryActiveAlarmsAsync honors cancellation during enumeration.</summary> /// <summary>QueryActiveAlarmsAsync honors cancellation during enumeration.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task QueryActiveAlarmsAsync_HonorsCancellationDuringEnumeration() public async Task QueryActiveAlarmsAsync_HonorsCancellationDuringEnumeration()
{ {
@@ -24,7 +24,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that the version command with --json flag prints JSON protocol versions.</summary> /// <summary>Verifies that the version command with --json flag prints JSON protocol versions.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions() public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions()
{ {
@@ -39,7 +38,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that the write command builds a write request and prints JSON reply.</summary> /// <summary>Verifies that the write command builds a write request and prints JSON reply.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply() public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply()
{ {
@@ -85,7 +83,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that error output redacts sensitive API key values.</summary> /// <summary>Verifies that error output redacts sensitive API key values.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_ErrorOutput_RedactsApiKey() public async Task RunAsync_ErrorOutput_RedactsApiKey()
{ {
@@ -110,7 +107,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary> /// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput() public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
{ {
@@ -153,7 +149,6 @@ public sealed class MxGatewayClientCliTests
/// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary> /// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_StreamAlarms_WithMaxEventsStopsAndDistinguishesPayloadCases() public async Task RunAsync_StreamAlarms_WithMaxEventsStopsAndDistinguishesPayloadCases()
{ {
@@ -193,7 +188,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary> /// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply() public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
{ {
@@ -236,7 +230,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that smoke command closes opened session when a command fails.</summary> /// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession() public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
{ {
@@ -268,7 +261,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that galaxy-test-connection command prints JSON reply.</summary> /// <summary>Verifies that galaxy-test-connection command prints JSON reply.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply() public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply()
{ {
@@ -299,7 +291,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that galaxy-discover command prints hierarchy summary.</summary> /// <summary>Verifies that galaxy-discover command prints hierarchy summary.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary() public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary()
{ {
@@ -370,7 +361,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that galaxy-watch command prints text output for deploy events.</summary> /// <summary>Verifies that galaxy-watch command prints text output for deploy events.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents() public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents()
{ {
@@ -425,7 +415,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that galaxy-watch with --json emits one JSON object per event.</summary> /// <summary>Verifies that galaxy-watch with --json emits one JSON object per event.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent() public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent()
{ {
@@ -461,7 +450,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that batch mode dispatches a single version command and emits the EOR sentinel.</summary> /// <summary>Verifies that batch mode dispatches a single version command and emits the EOR sentinel.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_Batch_DispatchesVersionAndWritesEndOfRecord() public async Task RunAsync_Batch_DispatchesVersionAndWritesEndOfRecord()
{ {
@@ -488,7 +476,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that batch mode routes per-command errors to stdout as JSON between EOR markers.</summary> /// <summary>Verifies that batch mode routes per-command errors to stdout as JSON between EOR markers.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_Batch_WritesErrorsToStdoutAsJson() public async Task RunAsync_Batch_WritesErrorsToStdoutAsJson()
{ {
@@ -533,7 +520,6 @@ public sealed class MxGatewayClientCliTests
/// against exit code 0. /// against exit code 0.
/// </summary> /// </summary>
/// <param name="command">The alarm subcommand to validate (e.g. "stream-alarms", "acknowledge-alarm").</param> /// <param name="command">The alarm subcommand to validate (e.g. "stream-alarms", "acknowledge-alarm").</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory] [Theory]
[InlineData("stream-alarms")] [InlineData("stream-alarms")]
[InlineData("acknowledge-alarm")] [InlineData("acknowledge-alarm")]
@@ -588,7 +574,6 @@ public sealed class MxGatewayClientCliTests
/// against a zero server handle. The fix must fail loudly with a /// against a zero server handle. The fix must fail loudly with a
/// descriptive <see cref="MxGatewayException"/>. /// descriptive <see cref="MxGatewayException"/>.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_BenchReadBulk_WhenRegisterReplyMissingTypedPayload_FailsLoudly() public async Task RunAsync_BenchReadBulk_WhenRegisterReplyMissingTypedPayload_FailsLoudly()
{ {
@@ -639,7 +624,6 @@ public sealed class MxGatewayClientCliTests
/// kept spinning until <c>--duration-seconds</c> elapsed. After the fix /// kept spinning until <c>--duration-seconds</c> elapsed. After the fix
/// the bench must exit promptly when the supplied token cancels. /// the bench must exit promptly when the supplied token cancels.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_BenchReadBulk_WhenSteadyStateLoopReceivesCancellation_ExitsPromptly() public async Task RunAsync_BenchReadBulk_WhenSteadyStateLoopReceivesCancellation_ExitsPromptly()
{ {
@@ -734,7 +718,6 @@ public sealed class MxGatewayClientCliTests
/// to ~49.7 days. The fix must reject negatives with a clear error. /// to ~49.7 days. The fix must reject negatives with a clear error.
/// </summary> /// </summary>
/// <param name="command">The bulk-read subcommand to validate (e.g. "read-bulk", "bench-read-bulk").</param> /// <param name="command">The bulk-read subcommand to validate (e.g. "read-bulk", "bench-read-bulk").</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory] [Theory]
[InlineData("read-bulk")] [InlineData("read-bulk")]
[InlineData("bench-read-bulk")] [InlineData("bench-read-bulk")]
@@ -897,8 +880,7 @@ public sealed class MxGatewayClientCliTests
/// <summary>Optional per-call handler that overrides queue-based behaviour.</summary> /// <summary>Optional per-call handler that overrides queue-based behaviour.</summary>
public Func<MxCommandRequest, CancellationToken, Task<MxCommandReply>>? InvokeHandler { get; init; } public Func<MxCommandRequest, CancellationToken, Task<MxCommandReply>>? InvokeHandler { get; init; }
/// <summary>Releases resources held by the fake CLI client.</summary> /// <inheritdoc />
/// <returns>A completed value task.</returns>
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
@@ -7,7 +7,6 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxGatewayClientSessionTests public sealed class MxGatewayClientSessionTests
{ {
/// <summary>Verifies that open session attaches API key metadata and cancellation token.</summary> /// <summary>Verifies that open session attaches API key metadata and cancellation token.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task OpenSessionRawAsync_AttachesApiKeyMetadataAndCancellation() public async Task OpenSessionRawAsync_AttachesApiKeyMetadataAndCancellation()
{ {
@@ -23,7 +22,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that open session returns a session with the raw open reply.</summary> /// <summary>Verifies that open session returns a session with the raw open reply.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task OpenSessionAsync_ReturnsSessionWithRawOpenReply() public async Task OpenSessionAsync_ReturnsSessionWithRawOpenReply()
{ {
@@ -39,7 +37,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that register builds a register command and returns server handle.</summary> /// <summary>Verifies that register builds a register command and returns server handle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RegisterAsync_BuildsRegisterCommandAndReturnsServerHandle() public async Task RegisterAsync_BuildsRegisterCommandAndReturnsServerHandle()
{ {
@@ -65,7 +62,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that add item 2 builds a command with the specified context.</summary> /// <summary>Verifies that add item 2 builds a command with the specified context.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AddItem2Async_BuildsAddItem2CommandWithContext() public async Task AddItem2Async_BuildsAddItem2CommandWithContext()
{ {
@@ -91,7 +87,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that write raw builds a write command with the raw value.</summary> /// <summary>Verifies that write raw builds a write command with the raw value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteRawAsync_BuildsWriteCommandWithRawValue() public async Task WriteRawAsync_BuildsWriteCommandWithRawValue()
{ {
@@ -123,7 +118,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that write 2 raw builds a write 2 command with value and timestamp.</summary> /// <summary>Verifies that write 2 raw builds a write 2 command with value and timestamp.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp() public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp()
{ {
@@ -152,7 +146,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that subscribe bulk builds one command and returns per-item results.</summary> /// <summary>Verifies that subscribe bulk builds one command and returns per-item results.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task SubscribeBulkAsync_BuildsOneBulkCommandAndReturnsPerItemResults() public async Task SubscribeBulkAsync_BuildsOneBulkCommandAndReturnsPerItemResults()
{ {
@@ -192,7 +185,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that stream events yields events in the order received from the gateway.</summary> /// <summary>Verifies that stream events yields events in the order received from the gateway.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task StreamEventsAsync_YieldsEventsInGatewayOrder() public async Task StreamEventsAsync_YieldsEventsInGatewayOrder()
{ {
@@ -224,7 +216,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that close is explicit and idempotent.</summary> /// <summary>Verifies that close is explicit and idempotent.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task CloseAsync_IsExplicitAndIdempotent() public async Task CloseAsync_IsExplicitAndIdempotent()
{ {
@@ -241,7 +232,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that invoke retries safe diagnostic commands on transient RPC failure.</summary> /// <summary>Verifies that invoke retries safe diagnostic commands on transient RPC failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure() public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure()
{ {
@@ -266,7 +256,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that open session does not retry on transient RPC failure.</summary> /// <summary>Verifies that open session does not retry on transient RPC failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure() public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure()
{ {
@@ -280,7 +269,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that invoke does not retry write commands on transient RPC failure.</summary> /// <summary>Verifies that invoke does not retry write commands on transient RPC failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task InvokeAsync_DoesNotRetryWriteCommand() public async Task InvokeAsync_DoesNotRetryWriteCommand()
{ {
@@ -296,7 +284,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that invoke helpers pass cancellation token to the transport.</summary> /// <summary>Verifies that invoke helpers pass cancellation token to the transport.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task InvokeHelpers_PassCancellationTokenToTransport() public async Task InvokeHelpers_PassCancellationTokenToTransport()
{ {
@@ -3,7 +3,6 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxGatewayGeneratedContractTests public sealed class MxGatewayGeneratedContractTests
{ {
/// <summary>Verifies that the generated gRPC client can be instantiated from the client factory.</summary> /// <summary>Verifies that the generated gRPC client can be instantiated from the client factory.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task GeneratedGrpcClient_CanBeConstructedFromClientFactory() public async Task GeneratedGrpcClient_CanBeConstructedFromClientFactory()
{ {
@@ -337,9 +337,6 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
cancellationToken); cancellationToken);
} }
/// <summary>Builds a <see cref="BrowseChildrenRequest"/> from the provided options.</summary>
/// <param name="options">Browse children options to convert.</param>
/// <returns>The constructed request message.</returns>
internal static BrowseChildrenRequest BuildBrowseChildrenRequest(BrowseChildrenOptions options) internal static BrowseChildrenRequest BuildBrowseChildrenRequest(BrowseChildrenOptions options)
{ {
ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(options);
@@ -427,7 +424,6 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
/// <summary> /// <summary>
/// Closes the gRPC channel and releases resources. /// Closes the gRPC channel and releases resources.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous dispose operation.</returns>
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
if (_disposed) if (_disposed)
@@ -497,9 +493,6 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) => private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
CreateHttpHandlerForTests(options); CreateHttpHandlerForTests(options);
/// <summary>Creates an <see cref="HttpMessageHandler"/> configured from the provided options for test use.</summary>
/// <param name="options">Client options used to configure TLS and timeouts.</param>
/// <returns>The configured HTTP message handler.</returns>
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options) internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
{ {
SocketsHttpHandler handler = new() SocketsHttpHandler handler = new()
@@ -10,7 +10,9 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
MxGatewayClientOptions options, MxGatewayClientOptions options,
GalaxyRepository.GalaxyRepositoryClient rawClient) : IGalaxyRepositoryClientTransport GalaxyRepository.GalaxyRepositoryClient rawClient) : IGalaxyRepositoryClientTransport
{ {
/// <inheritdoc /> /// <summary>
/// Gets the gateway client options.
/// </summary>
public MxGatewayClientOptions Options { get; } = options; public MxGatewayClientOptions Options { get; } = options;
/// <summary> /// <summary>
@@ -89,11 +91,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
} }
} }
/// <summary>Streams deploy events from the Galaxy Repository, using an explicit cancellation token that overrides the call options token when provided.</summary> /// <inheritdoc />
/// <param name="request">The watch deploy events request.</param>
/// <param name="callOptions">Call options for the underlying gRPC call.</param>
/// <param name="cancellationToken">Optional cancellation token; takes precedence over the token in <paramref name="callOptions"/> when cancellable.</param>
/// <returns>An async enumerable of deploy events.</returns>
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync( public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request, WatchDeployEventsRequest request,
CallOptions callOptions, CallOptions callOptions,
@@ -10,7 +10,9 @@ internal sealed class GrpcMxGatewayClientTransport(
MxGatewayClientOptions options, MxGatewayClientOptions options,
MxAccessGateway.MxAccessGatewayClient rawClient) : IMxGatewayClientTransport MxAccessGateway.MxAccessGatewayClient rawClient) : IMxGatewayClientTransport
{ {
/// <inheritdoc /> /// <summary>
/// Gets the gateway client options.
/// </summary>
public MxGatewayClientOptions Options { get; } = options; public MxGatewayClientOptions Options { get; } = options;
/// <summary> /// <summary>
@@ -72,11 +74,7 @@ internal sealed class GrpcMxGatewayClientTransport(
} }
} }
/// <summary>Streams MXAccess events from the gateway, forwarding an explicit cancellation token to the stream reader.</summary> /// <inheritdoc />
/// <param name="request">The stream events request.</param>
/// <param name="callOptions">gRPC call options.</param>
/// <param name="cancellationToken">Token to cancel the streaming enumeration.</param>
/// <returns>An async enumerable of MXAccess events.</returns>
public async IAsyncEnumerable<MxEvent> StreamEventsAsync( public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request, StreamEventsRequest request,
CallOptions callOptions, CallOptions callOptions,
@@ -135,11 +133,7 @@ internal sealed class GrpcMxGatewayClientTransport(
} }
} }
/// <summary>Queries active alarms from the gateway, forwarding an explicit cancellation token to the stream reader.</summary> /// <inheritdoc />
/// <param name="request">The query active alarms request.</param>
/// <param name="callOptions">gRPC call options.</param>
/// <param name="cancellationToken">Token to cancel the streaming enumeration.</param>
/// <returns>An async enumerable of active alarm snapshots.</returns>
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync( public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
QueryActiveAlarmsRequest request, QueryActiveAlarmsRequest request,
CallOptions callOptions, CallOptions callOptions,
@@ -181,11 +175,7 @@ internal sealed class GrpcMxGatewayClientTransport(
return QueryActiveAlarmsAsync(request, callOptions); return QueryActiveAlarmsAsync(request, callOptions);
} }
/// <summary>Streams alarm feed messages from the gateway, forwarding an explicit cancellation token to the stream reader.</summary> /// <inheritdoc />
/// <param name="request">The stream alarms request.</param>
/// <param name="callOptions">gRPC call options.</param>
/// <param name="cancellationToken">Token to cancel the streaming enumeration.</param>
/// <returns>An async enumerable of alarm feed messages.</returns>
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync( public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request, StreamAlarmsRequest request,
CallOptions callOptions, CallOptions callOptions,
@@ -15,7 +15,6 @@ internal interface IGalaxyRepositoryClientTransport
/// <summary>Tests the connection to the Galaxy Repository server.</summary> /// <summary>Tests the connection to the Galaxy Repository server.</summary>
/// <param name="request">The test connection request.</param> /// <param name="request">The test connection request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param> /// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
/// <returns>A task that resolves to the test connection reply.</returns>
Task<TestConnectionReply> TestConnectionAsync( Task<TestConnectionReply> TestConnectionAsync(
TestConnectionRequest request, TestConnectionRequest request,
CallOptions callOptions); CallOptions callOptions);
@@ -23,7 +22,6 @@ internal interface IGalaxyRepositoryClientTransport
/// <summary>Gets the last deploy time from the Galaxy Repository server.</summary> /// <summary>Gets the last deploy time from the Galaxy Repository server.</summary>
/// <param name="request">The get last deploy time request.</param> /// <param name="request">The get last deploy time request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param> /// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
/// <returns>A task that resolves to the last deploy time reply.</returns>
Task<GetLastDeployTimeReply> GetLastDeployTimeAsync( Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
GetLastDeployTimeRequest request, GetLastDeployTimeRequest request,
CallOptions callOptions); CallOptions callOptions);
@@ -31,7 +29,6 @@ internal interface IGalaxyRepositoryClientTransport
/// <summary>Discovers the object hierarchy in the Galaxy Repository.</summary> /// <summary>Discovers the object hierarchy in the Galaxy Repository.</summary>
/// <param name="request">The discover hierarchy request.</param> /// <param name="request">The discover hierarchy request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param> /// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
/// <returns>A task that resolves to the hierarchy discovery reply.</returns>
Task<DiscoverHierarchyReply> DiscoverHierarchyAsync( Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
CallOptions callOptions); CallOptions callOptions);
@@ -39,7 +36,6 @@ internal interface IGalaxyRepositoryClientTransport
/// <summary>Returns direct children of a parent in the Galaxy hierarchy.</summary> /// <summary>Returns direct children of a parent in the Galaxy hierarchy.</summary>
/// <param name="request">The browse children request.</param> /// <param name="request">The browse children request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param> /// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
/// <returns>A task that resolves to the browse children reply.</returns>
Task<BrowseChildrenReply> BrowseChildrenAsync( Task<BrowseChildrenReply> BrowseChildrenAsync(
BrowseChildrenRequest request, BrowseChildrenRequest request,
CallOptions callOptions); CallOptions callOptions);
@@ -47,7 +43,6 @@ internal interface IGalaxyRepositoryClientTransport
/// <summary>Watches for deployment events from the Galaxy Repository server.</summary> /// <summary>Watches for deployment events from the Galaxy Repository server.</summary>
/// <param name="request">The watch deploy events request.</param> /// <param name="request">The watch deploy events request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param> /// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
/// <returns>An async enumerable of deploy events.</returns>
IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync( IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request, WatchDeployEventsRequest request,
CallOptions callOptions); CallOptions callOptions);
@@ -16,11 +16,6 @@ public sealed class LazyBrowseNode
private readonly SemaphoreSlim _expandLock = new(1, 1); private readonly SemaphoreSlim _expandLock = new(1, 1);
private bool _isExpanded; private bool _isExpanded;
/// <summary>Initializes a new instance of <see cref="LazyBrowseNode"/>.</summary>
/// <param name="client">The repository client used to fetch children.</param>
/// <param name="object">The underlying Galaxy object for this node.</param>
/// <param name="hasChildrenHint">True when the server reports the node has at least one matching descendant.</param>
/// <param name="options">Options controlling child browse behavior.</param>
internal LazyBrowseNode( internal LazyBrowseNode(
GalaxyRepositoryClient client, GalaxyRepositoryClient client,
GalaxyObject @object, GalaxyObject @object,
@@ -54,7 +49,6 @@ public sealed class LazyBrowseNode
/// (after the first completes) return immediately. /// (after the first completes) return immediately.
/// </remarks> /// </remarks>
/// <param name="cancellationToken">Token to observe for cancellation.</param> /// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task ExpandAsync(CancellationToken cancellationToken = default) public async Task ExpandAsync(CancellationToken cancellationToken = default)
{ {
if (_isExpanded) if (_isExpanded)
@@ -7,7 +7,6 @@ public static class MxCommandReplyExtensions
{ {
/// <summary>Validates that the reply has a successful protocol status (Ok or MxAccessFailure), throwing a gateway exception if not.</summary> /// <summary>Validates that the reply has a successful protocol status (Ok or MxAccessFailure), throwing a gateway exception if not.</summary>
/// <param name="reply">The command reply to check.</param> /// <param name="reply">The command reply to check.</param>
/// <returns>The same <paramref name="reply"/> for fluent chaining when validation passes.</returns>
public static MxCommandReply EnsureProtocolSuccess(this MxCommandReply reply) public static MxCommandReply EnsureProtocolSuccess(this MxCommandReply reply)
{ {
ArgumentNullException.ThrowIfNull(reply); ArgumentNullException.ThrowIfNull(reply);
@@ -25,7 +24,6 @@ public static class MxCommandReplyExtensions
/// <summary>Validates that the reply indicates MXAccess success (no HResult or status failures), throwing MxAccessException if not.</summary> /// <summary>Validates that the reply indicates MXAccess success (no HResult or status failures), throwing MxAccessException if not.</summary>
/// <param name="reply">The command reply to check.</param> /// <param name="reply">The command reply to check.</param>
/// <returns>The same <paramref name="reply"/> for fluent chaining when validation passes.</returns>
public static MxCommandReply EnsureMxAccessSuccess(this MxCommandReply reply) public static MxCommandReply EnsureMxAccessSuccess(this MxCommandReply reply)
{ {
ArgumentNullException.ThrowIfNull(reply); ArgumentNullException.ThrowIfNull(reply);
@@ -249,7 +249,6 @@ public sealed class MxGatewayClient : IAsyncDisposable
/// <summary> /// <summary>
/// Disposes the client and releases all resources. /// Disposes the client and releases all resources.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous dispose operation.</returns>
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
if (_disposed) if (_disposed)
@@ -319,9 +318,6 @@ public sealed class MxGatewayClient : IAsyncDisposable
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) => private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
CreateHttpHandlerForTests(options); CreateHttpHandlerForTests(options);
/// <summary>Creates an <see cref="HttpMessageHandler"/> configured from the provided options for test use.</summary>
/// <param name="options">Client options used to configure TLS and timeouts.</param>
/// <returns>The configured HTTP message handler.</returns>
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options) internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
{ {
SocketsHttpHandler handler = new() SocketsHttpHandler handler = new()
@@ -12,7 +12,6 @@ internal static class MxGatewayClientRetryPolicy
/// <summary>Creates a Polly ResiliencePipeline that retries transient gRPC failures with exponential backoff.</summary> /// <summary>Creates a Polly ResiliencePipeline that retries transient gRPC failures with exponential backoff.</summary>
/// <param name="options">Retry configuration (max attempts, delay bounds, jitter).</param> /// <param name="options">Retry configuration (max attempts, delay bounds, jitter).</param>
/// <param name="logger">Optional logger for retry diagnostics.</param> /// <param name="logger">Optional logger for retry diagnostics.</param>
/// <returns>A configured <see cref="ResiliencePipeline"/> with exponential-backoff retry.</returns>
public static ResiliencePipeline Create( public static ResiliencePipeline Create(
MxGatewayClientRetryOptions options, MxGatewayClientRetryOptions options,
ILogger? logger) ILogger? logger)
@@ -43,7 +42,6 @@ internal static class MxGatewayClientRetryPolicy
/// <summary>Returns whether a command kind is eligible for automatic retry on transient failures.</summary> /// <summary>Returns whether a command kind is eligible for automatic retry on transient failures.</summary>
/// <param name="kind">The command kind to check.</param> /// <param name="kind">The command kind to check.</param>
/// <returns><see langword="true"/> if the command kind is safe to retry; otherwise <see langword="false"/>.</returns>
public static bool IsRetryableCommand(MxCommandKind kind) public static bool IsRetryableCommand(MxCommandKind kind)
{ {
return kind is MxCommandKind.Ping return kind is MxCommandKind.Ping
@@ -211,7 +211,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <param name="serverHandle">The ServerHandle from register.</param> /// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param> /// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task AdviseAsync( public async Task AdviseAsync(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -253,7 +252,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <param name="serverHandle">The ServerHandle from register.</param> /// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param> /// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task UnAdviseAsync( public async Task UnAdviseAsync(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -295,7 +293,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <param name="serverHandle">The ServerHandle from register.</param> /// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param> /// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task RemoveItemAsync( public async Task RemoveItemAsync(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -678,7 +675,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <param name="value">The value to write.</param> /// <param name="value">The value to write.</param>
/// <param name="userId">User ID context for the write.</param> /// <param name="userId">User ID context for the write.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task WriteAsync( public async Task WriteAsync(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -733,7 +729,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <param name="timestampValue">The timestamp to write with the value.</param> /// <param name="timestampValue">The timestamp to write with the value.</param>
/// <param name="userId">User ID context for the write.</param> /// <param name="userId">User ID context for the write.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task Write2Async( public async Task Write2Async(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -826,7 +821,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <summary> /// <summary>
/// Closes the session and releases resources. /// Closes the session and releases resources.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
await CloseAsync().ConfigureAwait(false); await CloseAsync().ConfigureAwait(false);
@@ -7,7 +7,6 @@ public static class MxStatusProxyExtensions
{ {
/// <summary>Returns whether the status indicates success (success flag set and category is Ok).</summary> /// <summary>Returns whether the status indicates success (success flag set and category is Ok).</summary>
/// <param name="status">The status to check.</param> /// <param name="status">The status to check.</param>
/// <returns><c>true</c> if the status is successful; <c>false</c> otherwise.</returns>
public static bool IsSuccess(this MxStatusProxy status) public static bool IsSuccess(this MxStatusProxy status)
{ {
ArgumentNullException.ThrowIfNull(status); ArgumentNullException.ThrowIfNull(status);
@@ -18,7 +17,6 @@ public static class MxStatusProxyExtensions
/// <summary>Returns a formatted summary of the status for diagnostic output.</summary> /// <summary>Returns a formatted summary of the status for diagnostic output.</summary>
/// <param name="status">The status to summarize.</param> /// <param name="status">The status to summarize.</param>
/// <returns>A human-readable string combining category, source, detail, and diagnostic text.</returns>
public static string ToDiagnosticSummary(this MxStatusProxy status) public static string ToDiagnosticSummary(this MxStatusProxy status)
{ {
ArgumentNullException.ThrowIfNull(status); ArgumentNullException.ThrowIfNull(status);
@@ -14,7 +14,6 @@ public static class MxValueExtensions
/// Converts a boolean value to an MxValue with MxDataType.Boolean. /// Converts a boolean value to an MxValue with MxDataType.Boolean.
/// </summary> /// </summary>
/// <param name="value">Scalar boolean value to wrap.</param> /// <param name="value">Scalar boolean value to wrap.</param>
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Boolean</c>.</returns>
public static MxValue ToMxValue(this bool value) public static MxValue ToMxValue(this bool value)
{ {
return new MxValue return new MxValue
@@ -29,7 +28,6 @@ public static class MxValueExtensions
/// Converts a 32-bit integer value to an MxValue with MxDataType.Integer. /// Converts a 32-bit integer value to an MxValue with MxDataType.Integer.
/// </summary> /// </summary>
/// <param name="value">32-bit integer value to wrap.</param> /// <param name="value">32-bit integer value to wrap.</param>
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Integer</c>.</returns>
public static MxValue ToMxValue(this int value) public static MxValue ToMxValue(this int value)
{ {
return new MxValue return new MxValue
@@ -44,7 +42,6 @@ public static class MxValueExtensions
/// Converts a 64-bit integer value to an MxValue with MxDataType.Integer. /// Converts a 64-bit integer value to an MxValue with MxDataType.Integer.
/// </summary> /// </summary>
/// <param name="value">64-bit integer value to wrap.</param> /// <param name="value">64-bit integer value to wrap.</param>
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Integer</c>.</returns>
public static MxValue ToMxValue(this long value) public static MxValue ToMxValue(this long value)
{ {
return new MxValue return new MxValue
@@ -59,7 +56,6 @@ public static class MxValueExtensions
/// Converts a single-precision floating-point value to an MxValue with MxDataType.Float. /// Converts a single-precision floating-point value to an MxValue with MxDataType.Float.
/// </summary> /// </summary>
/// <param name="value">Single-precision floating-point value to wrap.</param> /// <param name="value">Single-precision floating-point value to wrap.</param>
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Float</c>.</returns>
public static MxValue ToMxValue(this float value) public static MxValue ToMxValue(this float value)
{ {
return new MxValue return new MxValue
@@ -74,7 +70,6 @@ public static class MxValueExtensions
/// Converts a double-precision floating-point value to an MxValue with MxDataType.Double. /// Converts a double-precision floating-point value to an MxValue with MxDataType.Double.
/// </summary> /// </summary>
/// <param name="value">Double-precision floating-point value to wrap.</param> /// <param name="value">Double-precision floating-point value to wrap.</param>
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Double</c>.</returns>
public static MxValue ToMxValue(this double value) public static MxValue ToMxValue(this double value)
{ {
return new MxValue return new MxValue
@@ -89,7 +84,6 @@ public static class MxValueExtensions
/// Converts a string value to an MxValue with MxDataType.String. /// Converts a string value to an MxValue with MxDataType.String.
/// </summary> /// </summary>
/// <param name="value">String value to wrap.</param> /// <param name="value">String value to wrap.</param>
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.String</c>.</returns>
public static MxValue ToMxValue(this string value) public static MxValue ToMxValue(this string value)
{ {
ArgumentNullException.ThrowIfNull(value); ArgumentNullException.ThrowIfNull(value);
@@ -106,7 +100,6 @@ public static class MxValueExtensions
/// Converts a DateTimeOffset value to an MxValue with MxDataType.Time. /// Converts a DateTimeOffset value to an MxValue with MxDataType.Time.
/// </summary> /// </summary>
/// <param name="value">DateTimeOffset value to wrap.</param> /// <param name="value">DateTimeOffset value to wrap.</param>
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Time</c>.</returns>
public static MxValue ToMxValue(this DateTimeOffset value) public static MxValue ToMxValue(this DateTimeOffset value)
{ {
return new MxValue return new MxValue
@@ -121,7 +114,6 @@ public static class MxValueExtensions
/// Converts a DateTime value to an MxValue with MxDataType.Time. /// Converts a DateTime value to an MxValue with MxDataType.Time.
/// </summary> /// </summary>
/// <param name="value">DateTime value to wrap.</param> /// <param name="value">DateTime value to wrap.</param>
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Time</c>.</returns>
public static MxValue ToMxValue(this DateTime value) public static MxValue ToMxValue(this DateTime value)
{ {
return new DateTimeOffset( return new DateTimeOffset(
@@ -135,7 +127,6 @@ public static class MxValueExtensions
/// Converts a boolean array to an MxValue with MxDataType.Boolean. /// Converts a boolean array to an MxValue with MxDataType.Boolean.
/// </summary> /// </summary>
/// <param name="values">Array of boolean values to wrap.</param> /// <param name="values">Array of boolean values to wrap.</param>
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Boolean</c> and an array payload.</returns>
public static MxValue ToMxValue(this IReadOnlyList<bool> values) public static MxValue ToMxValue(this IReadOnlyList<bool> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -154,7 +145,6 @@ public static class MxValueExtensions
/// Converts a 32-bit integer array to an MxValue with MxDataType.Integer. /// Converts a 32-bit integer array to an MxValue with MxDataType.Integer.
/// </summary> /// </summary>
/// <param name="values">Array of 32-bit integer values to wrap.</param> /// <param name="values">Array of 32-bit integer values to wrap.</param>
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Integer</c> and an array payload.</returns>
public static MxValue ToMxValue(this IReadOnlyList<int> values) public static MxValue ToMxValue(this IReadOnlyList<int> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -173,7 +163,6 @@ public static class MxValueExtensions
/// Converts a 64-bit integer array to an MxValue with MxDataType.Integer. /// Converts a 64-bit integer array to an MxValue with MxDataType.Integer.
/// </summary> /// </summary>
/// <param name="values">Array of 64-bit integer values to wrap.</param> /// <param name="values">Array of 64-bit integer values to wrap.</param>
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Integer</c> and an array payload.</returns>
public static MxValue ToMxValue(this IReadOnlyList<long> values) public static MxValue ToMxValue(this IReadOnlyList<long> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -192,7 +181,6 @@ public static class MxValueExtensions
/// Converts a single-precision floating-point array to an MxValue with MxDataType.Float. /// Converts a single-precision floating-point array to an MxValue with MxDataType.Float.
/// </summary> /// </summary>
/// <param name="values">Array of single-precision floating-point values to wrap.</param> /// <param name="values">Array of single-precision floating-point values to wrap.</param>
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Float</c> and an array payload.</returns>
public static MxValue ToMxValue(this IReadOnlyList<float> values) public static MxValue ToMxValue(this IReadOnlyList<float> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -211,7 +199,6 @@ public static class MxValueExtensions
/// Converts a double-precision floating-point array to an MxValue with MxDataType.Double. /// Converts a double-precision floating-point array to an MxValue with MxDataType.Double.
/// </summary> /// </summary>
/// <param name="values">Array of double-precision floating-point values to wrap.</param> /// <param name="values">Array of double-precision floating-point values to wrap.</param>
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Double</c> and an array payload.</returns>
public static MxValue ToMxValue(this IReadOnlyList<double> values) public static MxValue ToMxValue(this IReadOnlyList<double> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -230,7 +217,6 @@ public static class MxValueExtensions
/// Converts a string array to an MxValue with MxDataType.String. /// Converts a string array to an MxValue with MxDataType.String.
/// </summary> /// </summary>
/// <param name="values">Array of string values to wrap.</param> /// <param name="values">Array of string values to wrap.</param>
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.String</c> and an array payload.</returns>
public static MxValue ToMxValue(this IReadOnlyList<string> values) public static MxValue ToMxValue(this IReadOnlyList<string> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -249,7 +235,6 @@ public static class MxValueExtensions
/// Converts a DateTimeOffset array to an MxValue with MxDataType.Time. /// Converts a DateTimeOffset array to an MxValue with MxDataType.Time.
/// </summary> /// </summary>
/// <param name="values">Array of DateTimeOffset values to wrap.</param> /// <param name="values">Array of DateTimeOffset values to wrap.</param>
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Time</c> and an array payload.</returns>
public static MxValue ToMxValue(this IReadOnlyList<DateTimeOffset> values) public static MxValue ToMxValue(this IReadOnlyList<DateTimeOffset> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -268,7 +253,6 @@ public static class MxValueExtensions
/// Gets the projection kind (field name) of the given MxValue's current oneof value. /// Gets the projection kind (field name) of the given MxValue's current oneof value.
/// </summary> /// </summary>
/// <param name="value">The MxValue whose oneof projection kind is returned.</param> /// <param name="value">The MxValue whose oneof projection kind is returned.</param>
/// <returns>The JSON field name of the active oneof case, or <c>"nullValue"</c>/<c>"unspecified"</c> for null/unset values.</returns>
public static string GetProjectionKind(this MxValue value) public static string GetProjectionKind(this MxValue value)
{ {
ArgumentNullException.ThrowIfNull(value); ArgumentNullException.ThrowIfNull(value);
@@ -292,7 +276,6 @@ public static class MxValueExtensions
/// Converts an MxValue to a CLR object; returns the boxed value or null for null MxValues. /// Converts an MxValue to a CLR object; returns the boxed value or null for null MxValues.
/// </summary> /// </summary>
/// <param name="value">The MxValue to convert.</param> /// <param name="value">The MxValue to convert.</param>
/// <returns>The boxed CLR value, or null if the MxValue represents a null.</returns>
public static object? ToClrValue(this MxValue value) public static object? ToClrValue(this MxValue value)
{ {
ArgumentNullException.ThrowIfNull(value); ArgumentNullException.ThrowIfNull(value);
@@ -316,7 +299,6 @@ public static class MxValueExtensions
/// Converts an MxArray to a CLR array; returns null if the array does not have a known element type. /// Converts an MxArray to a CLR array; returns null if the array does not have a known element type.
/// </summary> /// </summary>
/// <param name="array">The MxArray to convert.</param> /// <param name="array">The MxArray to convert.</param>
/// <returns>A CLR array of the appropriate element type, or null for unknown element types.</returns>
public static object? ToClrArrayValue(this MxArray array) public static object? ToClrArrayValue(this MxArray array)
{ {
ArgumentNullException.ThrowIfNull(array); ArgumentNullException.ThrowIfNull(array);
@@ -346,7 +328,6 @@ public static class MxValueExtensions
/// <param name="variantType">Variant type string (e.g., "VT_BSTR").</param> /// <param name="variantType">Variant type string (e.g., "VT_BSTR").</param>
/// <param name="rawDiagnostic">Diagnostic string describing the raw value.</param> /// <param name="rawDiagnostic">Diagnostic string describing the raw value.</param>
/// <param name="rawDataType">Optional MXAccess data type override.</param> /// <param name="rawDataType">Optional MXAccess data type override.</param>
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Unknown</c> and the raw byte payload.</returns>
public static MxValue ToRawMxValue( public static MxValue ToRawMxValue(
byte[] value, byte[] value,
string variantType, string variantType,
+1
View File
@@ -148,6 +148,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: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: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: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:CookieName` | `MxGatewayDashboard` | Dashboard auth cookie name. Leave unset (null/blank) to use the default. Override it to give a distinct name to a gateway that shares a hostname with another gateway instance: browser cookies are scoped by host+path but **not** by port, so two instances on the same host would otherwise clobber each other's dashboard session under a shared cookie name. Changing it signs out existing dashboard sessions on next deploy. |
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. | | `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: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. | | `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
+81 -40
View File
@@ -1,27 +1,36 @@
# GLAuth — LDAP authn reference for mxaccessgw # GLAuth — LDAP authn reference for mxaccessgw
GLAuth is a lightweight LDAP server installed on this dev box at > **UPDATED 2026-06-04 — mxaccessgw no longer uses a per-box GLAuth at `C:\publish\glauth`.
`C:\publish\glauth\` and run as a Windows service via NSSM. It already > Dev/test LDAP is now the SHARED GLAuth on `10.100.0.35:3893` (`dc=zb,dc=local`);
backs the LmxOpcUa OPC UA server's UserName-token authn and the LmxOpcUa > the single source of truth is `scadaproj/infra/glauth/` (`config.toml` + `README`).
Admin UI's cookie login; this doc captures everything mxaccessgw needs > The localhost/NSSM/`glauth.cfg` procedures below are RETIRED, kept for reference/rollback.**
to consume the same directory so a single set of dev credentials covers
both stacks.
The authoritative copy of LmxOpcUa's reference lives at GLAuth is a lightweight LDAP server. It already backs all three sister apps (MxAccessGateway,
`C:\publish\glauth\auth.md`. This doc is a redistilled view tailored to OtOpcUa, ScadaBridge) through a **shared container** (`zb-shared-glauth`) running on the Linux
mxaccessgw — what users + groups are already provisioned, how to bind docker host at **`10.100.0.35:3893`**. This doc captures everything mxaccessgw needs to consume
against them, and what's needed to add a gw-specific role. that directory so a single set of dev credentials covers all stacks.
~~GLAuth is installed on this dev box at `C:\publish\glauth\` and run as a Windows service via
NSSM.~~ *(RETIRED — the per-box Windows service has been stopped and set to Manual startup;
kept only as a rollback option. Do not edit or restart it for new work.)*
The single source of truth for the shared GLAuth is
**`~/Desktop/scadaproj/infra/glauth/config.toml`** (deploy/verify runbook:
`scadaproj/infra/glauth/README.md`). This doc is a redistilled view tailored to mxaccessgw —
what users + groups are provisioned, how to bind against them, and what's needed to add a
gw-specific role.
## Connection details ## Connection details
| Setting | Value | | Setting | Value |
|---|---| |---|---|
| Protocol | LDAP (unencrypted) | | Protocol | LDAP (unencrypted) |
| Host | `localhost` | | Host | **`10.100.0.35`** (shared docker host — ~~`localhost`~~ retired) |
| Port | `3893` | | Port | `3893` |
| LDAPS | disabled in dev (set `[ldaps]` block to enable) | | LDAPS | disabled in dev (`Transport=None`, `AllowInsecure=true`) |
| Base DN | `dc=zb,dc=local` | | Base DN | `dc=zb,dc=local` |
| Bind DN format | `cn={username},dc=zb,dc=local` | | Bind DN format | `cn={username},dc=zb,dc=local` |
| Service account DN | `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123` |
| Group OU | `ou=<groupname>,ou=groups,dc=zb,dc=local` | | Group OU | `ou=<groupname>,ou=groups,dc=zb,dc=local` |
| Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) | | Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) |
@@ -59,13 +68,13 @@ For mxaccessgw dev, `admin` covers every gw-side capability test;
`readonly` is the right "negative" case for proving Browse-OK / `readonly` is the right "negative" case for proving Browse-OK /
Write-denied. Write-denied.
The gateway dashboard adds one role beyond this LmxOpcUa taxonomy: The gateway dashboard uses two gateway-specific groups beyond the LmxOpcUa taxonomy:
`GwAdmin`. `LdapOptions.RequiredGroup` defaults to `GwAdmin`, so the `GwAdmin` (gid 5610 → role `Administrator`) and `GwReader` (gid 5611 → role `Viewer`).
dashboard login and `DashboardLdapLiveTests` require `admin` to be a These are already provisioned in the shared `scadaproj/infra/glauth/config.toml`.
member of a `GwAdmin` group. `GwAdmin` is **not** in the baseline The dashboard test users are **`multi-role`/`password`** (Administrator) and
GLAuth config — it must be provisioned before dashboard authn or the **`gw-viewer`/`password`** (Viewer). `LdapOptions.RequiredGroup` defaults to `GwAdmin`.
LDAP live tests work. See [Provisioning the GwAdmin See [Provisioning the GwAdmin group](#provisioning-the-gwadmin-group) below for the
group](#provisioning-the-gwadmin-group) below. (now-retired) per-box procedure and for the shared-config equivalent.
> **Dashboard role value (Task 1.7):** the LDAP `GwAdmin` group now maps to > **Dashboard role value (Task 1.7):** the LDAP `GwAdmin` group now maps to
> the canonical dashboard role **`Administrator`** (was `Admin`); `GwReader` > the canonical dashboard role **`Administrator`** (was `Admin`); `GwReader`
@@ -118,7 +127,7 @@ record:
```yaml ```yaml
ldap: ldap:
enabled: true enabled: true
server: localhost server: 10.100.0.35 # shared GLAuth on docker host (was localhost)
port: 3893 port: 3893
useTls: false useTls: false
allowInsecureLdap: true # dev only allowInsecureLdap: true # dev only
@@ -143,13 +152,29 @@ look that up in `groupToRole`.
## Provisioning the GwAdmin group ## Provisioning the GwAdmin group
> **UPDATED 2026-06-04 — RETIRED per-box procedure.** `GwAdmin` (gid 5610) and `GwReader`
> (gid 5611) are already present in the shared GLAuth. To add or modify users/groups,
> edit **`~/Desktop/scadaproj/infra/glauth/config.toml`** on host `10.100.0.35` and run:
>
> ```bash
> cd ~/Desktop/scadaproj/infra/glauth
> docker compose up -d --force-recreate
> ```
>
> The per-box `C:\publish\glauth\glauth.cfg` + NSSM procedure below is kept for
> rollback reference only — do not use it for new provisioning.
`GwAdmin` is the gateway-specific dashboard-admin role. It is the `GwAdmin` is the gateway-specific dashboard-admin role. It is the
default `LdapOptions.RequiredGroup`, so the dashboard cookie login and default `LdapOptions.RequiredGroup`, so the dashboard cookie login and
`DashboardLdapLiveTests` (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) reject `DashboardLdapLiveTests` (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) reject
`admin` until a `GwAdmin` group exists and `admin` is a member. logins unless the user is a member of `GwAdmin`.
GLAuth's baseline config ships only the five LmxOpcUa role groups, so The `GwAdmin` (gid 5610) and `GwReader` (gid 5611) groups already exist in the shared
`GwAdmin` must be added to GLAuth rather than run from a separate LDAP config at `scadaproj/infra/glauth/config.toml`. Dashboard test users are
server: `multi-role`/`password` (Administrator) and `gw-viewer`/`password` (Viewer).
---
**RETIRED — per-box provisioning (reference/rollback only):**
1. Edit `C:\publish\glauth\glauth.cfg` 1. Edit `C:\publish\glauth\glauth.cfg`
2. Append the group: 2. Append the group:
@@ -199,15 +224,16 @@ echo -n "yourpassword" | openssl dgst -sha256
## Quick verification ## Quick verification
From mxaccessgw's dev box, prove the directory is reachable: From mxaccessgw's dev box, prove the shared directory is reachable:
```powershell ```powershell
# Plain bind via PowerShell + System.DirectoryServices.Protocols # Plain bind via PowerShell + System.DirectoryServices.Protocols
$ldap = New-Object System.DirectoryServices.Protocols.LdapConnection("localhost:3893") # (shared GLAuth on 10.100.0.35 — was localhost, now the docker host)
$ldap = New-Object System.DirectoryServices.Protocols.LdapConnection("10.100.0.35:3893")
$ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic $ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
$ldap.SessionOptions.ProtocolVersion = 3 $ldap.SessionOptions.ProtocolVersion = 3
$ldap.SessionOptions.SecureSocketLayer = $false $ldap.SessionOptions.SecureSocketLayer = $false
$cred = New-Object System.Net.NetworkCredential("cn=admin,dc=zb,dc=local","admin123") $cred = New-Object System.Net.NetworkCredential("cn=multi-role,dc=zb,dc=local","password")
$ldap.Bind($cred) $ldap.Bind($cred)
"Bind OK" "Bind OK"
``` ```
@@ -215,17 +241,32 @@ $ldap.Bind($cred)
Or via `ldapsearch` if you have OpenLDAP CLI tools: Or via `ldapsearch` if you have OpenLDAP CLI tools:
```bash ```bash
ldapsearch -x -H ldap://localhost:3893 \ ldapsearch -x -H ldap://10.100.0.35:3893 \
-D "cn=admin,dc=zb,dc=local" -w admin123 \ -D "cn=serviceaccount,dc=zb,dc=local" -w serviceaccount123 \
-b "dc=zb,dc=local" "(uid=admin)" -b "dc=zb,dc=local" "(uid=multi-role)"
``` ```
The response should list `admin`'s entry with `memberOf` populated for The response should list `multi-role`'s entry with `memberOf` including
all five role groups — plus `GwAdmin` once the gateway-specific group `ou=GwAdmin,ou=groups,dc=zb,dc=local`.
is provisioned.
## Service management ## Service management
> **RETIRED — per-box NSSM service (reference/rollback only).** The shared GLAuth is
> managed via `docker compose` on `10.100.0.35` (`scadaproj/infra/glauth/`). The
> Windows NSSM `GLAuth` service on the dev box has been stopped and set to
> `StartupType=Manual`; only restart it if you need to roll back to a local directory.
>
> **Active (shared) management:**
> ```bash
> ssh 10.100.0.35
> cd ~/Desktop/scadaproj/infra/glauth
> docker compose ps # check container status
> docker compose up -d --force-recreate # apply config.toml changes
> docker compose logs -f # tail logs
> ```
**RETIRED — per-box NSSM commands (rollback reference):**
```powershell ```powershell
# Status / start / stop / restart # Status / start / stop / restart
nssm status GLAuth nssm status GLAuth
@@ -259,7 +300,7 @@ applies to mxaccessgw verbatim. Keys that change:
| Field | GLAuth dev value | AD production value | | Field | GLAuth dev value | AD production value |
|---|---|---| |---|---|---|
| `Server` | `localhost` | a domain controller FQDN, or the domain itself | | `Server` | `10.100.0.35` (shared docker host) | a domain controller FQDN, or the domain itself |
| `Port` | `3893` | `636` (LDAPS) — AD increasingly rejects plain bind under LDAP-signing enforcement | | `Port` | `3893` | `636` (LDAPS) — AD increasingly rejects plain bind under LDAP-signing enforcement |
| `UseTls` | `false` | `true` | | `UseTls` | `false` | `true` |
| `AllowInsecureLdap` | `true` | `false` | | `AllowInsecureLdap` | `true` | `false` |
@@ -275,12 +316,12 @@ add a `tokenGroups` query as an enhancement.
## Security notes for production ## Security notes for production
- **Plaintext passwords in `glauth.cfg` are dev-only.** The config is - **Plaintext passwords in `config.toml` are dev-only.** The shared config is in
unencrypted on disk; anyone with read access to `C:\publish\glauth\` `scadaproj/infra/glauth/config.toml` (unencrypted); restrict filesystem access on
can SHA256-rainbow-table the entries. Treat the dev creds as `10.100.0.35` accordingly. Treat the dev creds as throwaway. Production LDAP is Active
throwaway. Production LDAP is Active Directory. Directory. *(The retired per-box `C:\publish\glauth\glauth.cfg` has the same caveat.)*
- The 3-fail / 10-minute lockout is per source IP, not per user — a - The 3-fail / 10-minute lockout is per source IP, not per user — a
shared NAT can lock out a whole office. Tunable in `[behaviors]`. shared NAT can lock out a whole office. Tunable in `[behaviors]`.
- LDAPS isn't enabled in dev; binding sends passwords cleartext on the - LDAPS isn't enabled in dev; binding sends passwords cleartext on the
wire. Fine for `localhost`, never expose port 3893 off-box without wire. The shared GLAuth listens only on the LAN (`10.100.0.35`); never
enabling TLS first. expose port 3893 externally without enabling TLS first.
@@ -14,7 +14,6 @@ namespace ZB.MOM.WW.MxGateway.IntegrationTests;
public sealed class DashboardLdapLiveTests public sealed class DashboardLdapLiveTests
{ {
/// <summary>Verifies that an admin user in the GwAdmin group authenticates successfully.</summary> /// <summary>Verifies that an admin user in the GwAdmin group authenticates successfully.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds() public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds()
{ {
@@ -43,7 +42,6 @@ public sealed class DashboardLdapLiveTests
} }
/// <summary>Verifies that a readonly user without GwAdmin group fails to authenticate.</summary> /// <summary>Verifies that a readonly user without GwAdmin group fails to authenticate.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails() public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails()
{ {
@@ -60,7 +58,6 @@ public sealed class DashboardLdapLiveTests
} }
/// <summary>Verifies that authentication with wrong password fails without leaking the password.</summary> /// <summary>Verifies that authentication with wrong password fails without leaking the password.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword() public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
{ {
@@ -80,7 +77,6 @@ public sealed class DashboardLdapLiveTests
} }
/// <summary>Verifies that authentication with unknown username fails.</summary> /// <summary>Verifies that authentication with unknown username fails.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_UnknownUsername_Fails() public async Task AuthenticateAsync_UnknownUsername_Fails()
{ {
@@ -98,7 +94,6 @@ public sealed class DashboardLdapLiveTests
} }
/// <summary>Verifies that authentication fails gracefully when the server is unreachable.</summary> /// <summary>Verifies that authentication fails gracefully when the server is unreachable.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing() public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
{ {
@@ -7,7 +7,6 @@ namespace ZB.MOM.WW.MxGateway.IntegrationTests.Galaxy;
public sealed class GalaxyRepositoryLiveTests public sealed class GalaxyRepositoryLiveTests
{ {
/// <summary>Verifies that the Galaxy Repository can establish a live connection to the ZB database.</summary> /// <summary>Verifies that the Galaxy Repository can establish a live connection to the ZB database.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveGalaxyRepositoryFact] [LiveGalaxyRepositoryFact]
public async Task TestConnection_AgainstZb_Succeeds() public async Task TestConnection_AgainstZb_Succeeds()
{ {
@@ -19,7 +18,6 @@ public sealed class GalaxyRepositoryLiveTests
} }
/// <summary>Verifies that the last deploy time can be retrieved from the ZB database.</summary> /// <summary>Verifies that the last deploy time can be retrieved from the ZB database.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveGalaxyRepositoryFact] [LiveGalaxyRepositoryFact]
public async Task GetLastDeployTime_AgainstZb_ReturnsTimestamp() public async Task GetLastDeployTime_AgainstZb_ReturnsTimestamp()
{ {
@@ -31,7 +29,6 @@ public sealed class GalaxyRepositoryLiveTests
} }
/// <summary>Verifies that the hierarchy can be retrieved from the ZB database.</summary> /// <summary>Verifies that the hierarchy can be retrieved from the ZB database.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveGalaxyRepositoryFact] [LiveGalaxyRepositoryFact]
public async Task GetHierarchy_AgainstZb_ReturnsObjects() public async Task GetHierarchy_AgainstZb_ReturnsObjects()
{ {
@@ -49,7 +46,6 @@ public sealed class GalaxyRepositoryLiveTests
} }
/// <summary>Verifies that object attributes can be retrieved from the ZB database.</summary> /// <summary>Verifies that object attributes can be retrieved from the ZB database.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveGalaxyRepositoryFact] [LiveGalaxyRepositoryFact]
public async Task GetAttributes_AgainstZb_ReturnsAtLeastOneAttribute() public async Task GetAttributes_AgainstZb_ReturnsAtLeastOneAttribute()
{ {
@@ -30,7 +30,6 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// <summary> /// <summary>
/// Verifies that a gateway session can register, add item, advise, and stream events from live MXAccess. /// Verifies that a gateway session can register, add item, advise, and stream events from live MXAccess.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact] [LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_RegistersAdvisesStreamsDataAndCloses() public async Task GatewaySession_WithLiveWorker_RegistersAdvisesStreamsDataAndCloses()
{ {
@@ -120,7 +119,6 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// and that the worker emits a matching <see cref="MxEventFamily.OnWriteComplete"/> event /// and that the worker emits a matching <see cref="MxEventFamily.OnWriteComplete"/> event
/// — the proof of round-trip the cross-language client e2e runner relies on. /// — the proof of round-trip the cross-language client e2e runner relies on.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact] [LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_WritesValueToAdvisedItem() public async Task GatewaySession_WithLiveWorker_WritesValueToAdvisedItem()
{ {
@@ -237,7 +235,6 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// Verifies that an AddItem against an invalid server handle surfaces the MXAccess failure /// Verifies that an AddItem against an invalid server handle surfaces the MXAccess failure
/// without faulting the gateway transport, exercising the invalid-handle parity path. /// without faulting the gateway transport, exercising the invalid-handle parity path.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact] [LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_InvalidHandleCommand_SurfacesFailureWithoutTransportFault() public async Task GatewaySession_WithLiveWorker_InvalidHandleCommand_SurfacesFailureWithoutTransportFault()
{ {
@@ -296,7 +293,6 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// OnDataChange events for the un-advised item. Exercises the lifecycle-ordering /// OnDataChange events for the un-advised item. Exercises the lifecycle-ordering
/// parity CLAUDE.md singles out as a "do not synthesize" rule. /// parity CLAUDE.md singles out as a "do not synthesize" rule.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact] [LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_UnadviseRemoveItemUnregister_TeardownOrderingParity() public async Task GatewaySession_WithLiveWorker_UnadviseRemoveItemUnregister_TeardownOrderingParity()
{ {
@@ -441,7 +437,6 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// parity surface the gateway must not "fix" — the test asserts the reply kind and /// parity surface the gateway must not "fix" — the test asserts the reply kind and
/// protocol status, not a fabricated outcome. /// protocol status, not a fabricated outcome.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact] [LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_WriteSecured_AuthenticatedRoundTripParity() public async Task GatewaySession_WithLiveWorker_WriteSecured_AuthenticatedRoundTripParity()
{ {
@@ -573,7 +568,6 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// must observe the abnormal exit, transition the session, and surface a non-empty /// must observe the abnormal exit, transition the session, and surface a non-empty
/// fault description rather than hanging or crashing. /// fault description rather than hanging or crashing.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact] [LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_AbnormalWorkerExit_MarksSessionFaulted() public async Task GatewaySession_WithLiveWorker_AbnormalWorkerExit_MarksSessionFaulted()
{ {
@@ -1120,7 +1114,6 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// </summary> /// </summary>
/// <param name="sessionId">The session identifier.</param> /// <param name="sessionId">The session identifier.</param>
/// <param name="session">The session if found; otherwise null.</param> /// <param name="session">The session if found; otherwise null.</param>
/// <returns>True if the session was found; otherwise false.</returns>
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session) public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
{ {
return _registry.TryGet(sessionId, out session); return _registry.TryGet(sessionId, out session);
@@ -1129,7 +1122,6 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// <summary> /// <summary>
/// Disposes the fixture resources and closes all sessions. /// Disposes the fixture resources and closes all sessions.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous dispose operation.</returns>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
foreach (GatewaySession session in _registry.Snapshot()) foreach (GatewaySession session in _registry.Snapshot())
@@ -1200,7 +1192,6 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// Records the message and signals any pending waiter. /// Records the message and signals any pending waiter.
/// </summary> /// </summary>
/// <param name="message">The message to write.</param> /// <param name="message">The message to write.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task WriteAsync(T message) public Task WriteAsync(T message)
{ {
lock (syncRoot) lock (syncRoot)
@@ -1383,9 +1374,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
return workerProcess; return workerProcess;
} }
/// <summary>Waits for all recorded worker processes to exit within the specified timeout.</summary> /// <inheritdoc />
/// <param name="timeout">Maximum time to wait for each process to exit.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task WaitForProcessesAsync(TimeSpan timeout) public async Task WaitForProcessesAsync(TimeSpan timeout)
{ {
foreach (TestWorkerProcess process in processes) foreach (TestWorkerProcess process in processes)
@@ -1465,7 +1454,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
process.Kill(entireProcessTree); process.Kill(entireProcessTree);
} }
/// <summary>Releases the wrapped process resources.</summary> /// <inheritdoc />
public void Dispose() public void Dispose()
{ {
process.Dispose(); process.Dispose();
@@ -1477,15 +1466,13 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// </summary> /// </summary>
private sealed class TestOutputLoggerProvider(ITestOutputHelper output) : ILoggerProvider private sealed class TestOutputLoggerProvider(ITestOutputHelper output) : ILoggerProvider
{ {
/// <summary>Creates a logger that writes to the test output helper for the given category.</summary> /// <inheritdoc />
/// <param name="categoryName">The logger category name.</param>
/// <returns>A logger that forwards to the test output helper.</returns>
public ILogger CreateLogger(string categoryName) public ILogger CreateLogger(string categoryName)
{ {
return new TestOutputLogger(output, categoryName); return new TestOutputLogger(output, categoryName);
} }
/// <summary>Releases resources held by the provider (no-op for this test double).</summary> /// <inheritdoc />
public void Dispose() public void Dispose()
{ {
} }
@@ -1498,31 +1485,20 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
ITestOutputHelper output, ITestOutputHelper output,
string categoryName) : ILogger string categoryName) : ILogger
{ {
/// <summary>Begins a log scope; returns null as this test logger does not support scopes.</summary> /// <inheritdoc />
/// <param name="state">The state object for the scope.</param>
/// <typeparam name="TState">The type of the state object.</typeparam>
/// <returns>Always null.</returns>
public IDisposable? BeginScope<TState>(TState state) public IDisposable? BeginScope<TState>(TState state)
where TState : notnull where TState : notnull
{ {
return null; return null;
} }
/// <summary>Returns true for log levels at or above <see cref="LogLevel.Information"/>.</summary> /// <inheritdoc />
/// <param name="logLevel">The log level to check.</param>
/// <returns>True if the log level is enabled.</returns>
public bool IsEnabled(LogLevel logLevel) public bool IsEnabled(LogLevel logLevel)
{ {
return logLevel >= LogLevel.Information; return logLevel >= LogLevel.Information;
} }
/// <summary>Writes a log entry to the test output helper.</summary> /// <inheritdoc />
/// <param name="logLevel">The log level.</param>
/// <param name="eventId">The event identifier.</param>
/// <param name="state">The state object to log.</param>
/// <param name="exception">Optional exception associated with the log entry.</param>
/// <param name="formatter">Function to format the state and exception into a string.</param>
/// <typeparam name="TState">The type of the state object.</typeparam>
public void Log<TState>( public void Log<TState>(
LogLevel logLevel, LogLevel logLevel,
EventId eventId, EventId eventId,
@@ -688,7 +688,6 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
/// <summary>Determines whether the alarm reference matches this subscriber's filter.</summary> /// <summary>Determines whether the alarm reference matches this subscriber's filter.</summary>
/// <param name="reference">The alarm reference to match.</param> /// <param name="reference">The alarm reference to match.</param>
/// <returns>True if the reference starts with this subscriber's prefix or no prefix is set.</returns>
public bool Matches(string reference) public bool Matches(string reference)
{ {
return prefix.Length == 0 || reference.StartsWith(prefix, StringComparison.Ordinal); return prefix.Length == 0 || reference.StartsWith(prefix, StringComparison.Ordinal);
@@ -46,7 +46,6 @@ public interface IGatewayAlarmService
/// </summary> /// </summary>
/// <param name="alarmFilterPrefix">Optional alarm-reference prefix scoping the feed.</param> /// <param name="alarmFilterPrefix">Optional alarm-reference prefix scoping the feed.</param>
/// <param name="cancellationToken">Token that ends the subscription.</param> /// <param name="cancellationToken">Token that ends the subscription.</param>
/// <returns>An async enumerable of alarm feed messages.</returns>
IAsyncEnumerable<AlarmFeedMessage> StreamAsync( IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
string? alarmFilterPrefix, string? alarmFilterPrefix,
CancellationToken cancellationToken); CancellationToken cancellationToken);
@@ -58,7 +57,6 @@ public interface IGatewayAlarmService
/// </summary> /// </summary>
/// <param name="request">The acknowledge request.</param> /// <param name="request">The acknowledge request.</param>
/// <param name="cancellationToken">Token to cancel the call.</param> /// <param name="cancellationToken">Token to cancel the call.</param>
/// <returns>A task that resolves to the acknowledge reply.</returns>
Task<AcknowledgeAlarmReply> AcknowledgeAsync( Task<AcknowledgeAlarmReply> AcknowledgeAsync(
AcknowledgeAlarmRequest request, AcknowledgeAlarmRequest request,
CancellationToken cancellationToken); CancellationToken cancellationToken);
@@ -21,6 +21,17 @@ public sealed class DashboardOptions
/// </summary> /// </summary>
public bool RequireHttpsCookie { get; init; } = true; public bool RequireHttpsCookie { get; init; } = true;
/// <summary>
/// Dashboard auth cookie name. When null/blank (the default) the canonical
/// <see cref="ZB.MOM.WW.MxGateway.Server.Dashboard.DashboardAuthenticationDefaults.CookieName"/>
/// is used. Override it (<c>MxGateway:Dashboard:CookieName</c>) to give a distinct name to a
/// gateway that shares a hostname with another gateway instance — browser cookies are scoped
/// by host+path but NOT by port, so two instances on the same host would otherwise clobber
/// each other's dashboard session under a shared cookie name. Changing this signs out
/// existing dashboard sessions on next deploy.
/// </summary>
public string? CookieName { get; init; }
/// <summary>Gets the dashboard snapshot update interval in milliseconds.</summary> /// <summary>Gets the dashboard snapshot update interval in milliseconds.</summary>
public int SnapshotIntervalMilliseconds { get; init; } = 1_000; public int SnapshotIntervalMilliseconds { get; init; } = 1_000;
@@ -9,7 +9,11 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
private const int MinimumMaxMessageBytes = 1024; private const int MinimumMaxMessageBytes = 1024;
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024; private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
/// <inheritdoc /> /// <summary>
/// Validates gateway configuration options.
/// </summary>
/// <param name="builder">The accumulator to record failures on.</param>
/// <param name="options">Gateway options to validate.</param>
protected override void Validate(ValidationBuilder builder, GatewayOptions options) protected override void Validate(ValidationBuilder builder, GatewayOptions options)
{ {
ValidateAuthentication(options.Authentication, builder); ValidateAuthentication(options.Authentication, builder);
@@ -8,6 +8,5 @@ public interface IGatewayConfigurationProvider
/// <summary> /// <summary>
/// Returns the validated and effective gateway configuration. /// Returns the validated and effective gateway configuration.
/// </summary> /// </summary>
/// <returns>The <see cref="EffectiveGatewayConfiguration"/> with validated defaults applied.</returns>
EffectiveGatewayConfiguration GetEffectiveConfiguration(); EffectiveGatewayConfiguration GetEffectiveConfiguration();
} }
@@ -38,8 +38,7 @@ public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
await ConnectHubAsync().ConfigureAwait(false); await ConnectHubAsync().ConfigureAwait(false);
} }
/// <summary>Disposes the SignalR hub connection and suppresses finalization.</summary> /// <inheritdoc />
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (_hub is not null) if (_hub is not null)
@@ -6,13 +6,19 @@
cookie scheme's LoginPath="/login" redirect lands here for unauthenticated users. cookie scheme's LoginPath="/login" redirect lands here for unauthenticated users.
The card is the shared kit's <LoginCard>: it renders a NATIVE static The card is the shared kit's <LoginCard>: it renders a NATIVE static
<form method="post" action="/login"> (username/password + hidden returnUrl). A native <form method="post" action="/auth/login"> (username/password + hidden returnUrl). A native
form submit is not a Blazor event, so it reaches the minimal-API POST /login endpoint form submit is not a Blazor event, so it reaches the minimal-API POST /auth/login endpoint
regardless of this app's InteractiveServer render mode. <AntiforgeryToken/> supplies the regardless of this app's InteractiveServer render mode. <AntiforgeryToken/> supplies the
token that PostLoginAsync's antiforgery.ValidateRequestAsync checks. *@ token that PostLoginAsync's antiforgery.ValidateRequestAsync checks.
NOTE: the POST target is /auth/login, NOT /login. This @page lives at "/login" and the
Razor Components endpoint matches ALL methods, so a POST to /login collided with the
minimal-API MapPost("/login") and threw AmbiguousMatchException (HTTP 500). Posting to a
distinct /auth/login path (mirroring ScadaBridge) keeps the GET page and POST handler from
sharing a route. *@
@attribute [AllowAnonymous] @attribute [AllowAnonymous]
<LoginCard Product="MXAccess Gateway" Action="/login" ReturnUrl="@ReturnUrl" Error="@Error"> <LoginCard Product="MXAccess Gateway" Action="/auth/login" ReturnUrl="@ReturnUrl" Error="@Error">
<AntiforgeryToken /> <AntiforgeryToken />
</LoginCard> </LoginCard>
@@ -6,7 +6,6 @@ public sealed class DashboardApiKeyAuthorization
{ {
/// <summary>Determines whether the user can manage API keys.</summary> /// <summary>Determines whether the user can manage API keys.</summary>
/// <param name="user">The authenticated user principal.</param> /// <param name="user">The authenticated user principal.</param>
/// <returns>True if the user is an authenticated admin; otherwise false.</returns>
public bool CanManage(ClaimsPrincipal user) public bool CanManage(ClaimsPrincipal user)
{ {
if (user.Identity?.IsAuthenticated != true) if (user.Identity?.IsAuthenticated != true)
@@ -20,13 +20,17 @@ public sealed class DashboardApiKeyManagementService(
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys."; private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
private const string PepperUnavailableMarker = "pepper unavailable"; private const string PepperUnavailableMarker = "pepper unavailable";
/// <inheritdoc /> /// <summary>Determines whether the user can manage API keys.</summary>
/// <param name="user">The authenticated user principal.</param>
public bool CanManage(ClaimsPrincipal user) public bool CanManage(ClaimsPrincipal user)
{ {
return authorization.CanManage(user); return authorization.CanManage(user);
} }
/// <inheritdoc /> /// <summary>Creates an API key asynchronously.</summary>
/// <param name="user">The authenticated user principal.</param>
/// <param name="request">The request payload.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public async Task<DashboardApiKeyManagementResult> CreateAsync( public async Task<DashboardApiKeyManagementResult> CreateAsync(
ClaimsPrincipal user, ClaimsPrincipal user,
DashboardApiKeyManagementRequest request, DashboardApiKeyManagementRequest request,
@@ -78,7 +82,10 @@ public sealed class DashboardApiKeyManagementService(
} }
} }
/// <inheritdoc /> /// <summary>Revokes an API key asynchronously.</summary>
/// <param name="user">The authenticated user principal.</param>
/// <param name="keyId">The API key identifier.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public async Task<DashboardApiKeyManagementResult> RevokeAsync( public async Task<DashboardApiKeyManagementResult> RevokeAsync(
ClaimsPrincipal user, ClaimsPrincipal user,
string keyId, string keyId,
@@ -113,7 +120,10 @@ public sealed class DashboardApiKeyManagementService(
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked."); : DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
} }
/// <inheritdoc /> /// <summary>Rotates an API key secret asynchronously.</summary>
/// <param name="user">The authenticated user principal.</param>
/// <param name="keyId">The API key identifier.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public async Task<DashboardApiKeyManagementResult> RotateAsync( public async Task<DashboardApiKeyManagementResult> RotateAsync(
ClaimsPrincipal user, ClaimsPrincipal user,
string keyId, string keyId,
@@ -160,7 +170,10 @@ public sealed class DashboardApiKeyManagementService(
} }
} }
/// <inheritdoc /> /// <summary>Deletes a revoked API key asynchronously.</summary>
/// <param name="user">The authenticated user principal.</param>
/// <param name="keyId">The API key identifier.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public async Task<DashboardApiKeyManagementResult> DeleteAsync( public async Task<DashboardApiKeyManagementResult> DeleteAsync(
ClaimsPrincipal user, ClaimsPrincipal user,
string keyId, string keyId,
@@ -23,7 +23,6 @@ public sealed record DashboardAuthenticationResult(
/// Creates a successful authentication result. /// Creates a successful authentication result.
/// </summary> /// </summary>
/// <param name="principal">Authenticated principal.</param> /// <param name="principal">Authenticated principal.</param>
/// <returns>A successful <see cref="DashboardAuthenticationResult"/> wrapping the principal.</returns>
public static DashboardAuthenticationResult Success(ClaimsPrincipal principal) public static DashboardAuthenticationResult Success(ClaimsPrincipal principal)
{ {
return new DashboardAuthenticationResult(true, principal, null); return new DashboardAuthenticationResult(true, principal, null);
@@ -33,7 +32,6 @@ public sealed record DashboardAuthenticationResult(
/// Creates a failed authentication result. /// Creates a failed authentication result.
/// </summary> /// </summary>
/// <param name="failureMessage">Diagnostic message describing the failure.</param> /// <param name="failureMessage">Diagnostic message describing the failure.</param>
/// <returns>A failed <see cref="DashboardAuthenticationResult"/> with the given message.</returns>
public static DashboardAuthenticationResult Fail(string failureMessage) public static DashboardAuthenticationResult Fail(string failureMessage)
{ {
return new DashboardAuthenticationResult(false, null, failureMessage); return new DashboardAuthenticationResult(false, null, failureMessage);
@@ -6,7 +6,6 @@ public static class DashboardConnectionStringDisplay
{ {
/// <summary>Returns a sanitized Galaxy Repository connection string for display.</summary> /// <summary>Returns a sanitized Galaxy Repository connection string for display.</summary>
/// <param name="connectionString">The connection string to sanitize.</param> /// <param name="connectionString">The connection string to sanitize.</param>
/// <returns>A sanitized connection string with credentials removed, or <c>"[invalid connection string]"</c> if parsing fails.</returns>
public static string GalaxyRepositoryConnectionString(string connectionString) public static string GalaxyRepositoryConnectionString(string connectionString)
{ {
try try
@@ -29,8 +29,14 @@ public static class DashboardEndpointRouteBuilderExtensions
// kit's <LoginCard>. Its [AllowAnonymous] attribute overrides the // kit's <LoginCard>. Its [AllowAnonymous] attribute overrides the
// RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies, // RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies,
// so the cookie scheme's LoginPath="/login" redirect resolves for anonymous users. // so the cookie scheme's LoginPath="/login" redirect resolves for anonymous users.
//
// The credential POST is mapped to /auth/login, NOT /login. The @page "/login"
// Razor Components endpoint matches ALL HTTP methods, so a MapPost("/login") shared
// the "/login" route with it and every POST threw AmbiguousMatchException (HTTP 500).
// A distinct /auth/login path (as ScadaBridge does) keeps the GET page and the POST
// handler on separate routes. The <LoginCard Action="/auth/login"> form posts here.
endpoints.MapPost( endpoints.MapPost(
"/login", "/auth/login",
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) => (HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
PostLoginAsync(httpContext, antiforgery, authenticator)) PostLoginAsync(httpContext, antiforgery, authenticator))
.AllowAnonymous() .AllowAnonymous()
@@ -7,7 +7,6 @@ internal static class DashboardGalaxyProjector
{ {
/// <summary>Projects the cache entry to a dashboard Galaxy summary.</summary> /// <summary>Projects the cache entry to a dashboard Galaxy summary.</summary>
/// <param name="entry">The Galaxy hierarchy cache entry.</param> /// <param name="entry">The Galaxy hierarchy cache entry.</param>
/// <returns>The precomputed <see cref="DashboardGalaxySummary"/> from the cache entry.</returns>
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry) public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
{ {
return entry.DashboardSummary; return entry.DashboardSummary;
@@ -17,10 +17,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class DashboardGroupRoleMapper(IOptions<GatewayOptions> options) public sealed class DashboardGroupRoleMapper(IOptions<GatewayOptions> options)
: IGroupRoleMapper<string> : IGroupRoleMapper<string>
{ {
/// <summary>Maps LDAP group memberships to dashboard roles using the configured group-to-role rules.</summary> /// <inheritdoc />
/// <param name="groups">The list of LDAP group names or distinguished names for the authenticated user.</param>
/// <param name="ct">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the role mapping for the supplied groups.</returns>
public Task<GroupRoleMapping<string>> MapAsync( public Task<GroupRoleMapping<string>> MapAsync(
IReadOnlyList<string> groups, IReadOnlyList<string> groups,
CancellationToken ct) CancellationToken ct)
@@ -16,7 +16,6 @@ internal static class DashboardGroupRoleMapping
/// </summary> /// </summary>
/// <param name="groups">The collection of LDAP groups the user belongs to.</param> /// <param name="groups">The collection of LDAP groups the user belongs to.</param>
/// <param name="groupToRole">The mapping from group names to dashboard role names.</param> /// <param name="groupToRole">The mapping from group names to dashboard role names.</param>
/// <returns>The distinct set of dashboard roles matched from the user's groups.</returns>
internal static IReadOnlyList<string> MapGroupsToRoles( internal static IReadOnlyList<string> MapGroupsToRoles(
IEnumerable<string> groups, IEnumerable<string> groups,
IReadOnlyDictionary<string, string> groupToRole) IReadOnlyDictionary<string, string> groupToRole)
@@ -62,7 +61,6 @@ internal static class DashboardGroupRoleMapping
/// <summary>Extracts the first RDN value from a distinguished name.</summary> /// <summary>Extracts the first RDN value from a distinguished name.</summary>
/// <param name="distinguishedName">The LDAP distinguished name.</param> /// <param name="distinguishedName">The LDAP distinguished name.</param>
/// <returns>The value portion of the first RDN component, or the full string if no <c>=</c> is found.</returns>
internal static string ExtractFirstRdnValue(string distinguishedName) internal static string ExtractFirstRdnValue(string distinguishedName)
{ {
int equalsIndex = distinguishedName.IndexOf('='); int equalsIndex = distinguishedName.IndexOf('=');
@@ -192,8 +192,7 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
} }
} }
/// <summary>Releases resources and closes the associated gateway session.</summary> /// <inheritdoc />
/// <returns>A task that represents the asynchronous dispose operation.</returns>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (_disposed) if (_disposed)
@@ -23,7 +23,6 @@ public static class DashboardServiceCollectionExtensions
/// Application configuration, used to bind the shared LDAP provider's options /// Application configuration, used to bind the shared LDAP provider's options
/// from the <c>MxGateway:Ldap</c> section. /// from the <c>MxGateway:Ldap</c> section.
/// </param> /// </param>
/// <returns>The <paramref name="services"/> collection for chaining.</returns>
public static IServiceCollection AddGatewayDashboard( public static IServiceCollection AddGatewayDashboard(
this IServiceCollection services, this IServiceCollection services,
IConfiguration configuration) IConfiguration configuration)
@@ -67,6 +66,8 @@ public static class DashboardServiceCollectionExtensions
ZbCookieDefaults.Apply(cookieOptions, requireHttps: true, idleTimeout: TimeSpan.FromHours(8)); ZbCookieDefaults.Apply(cookieOptions, requireHttps: true, idleTimeout: TimeSpan.FromHours(8));
// Cookie name, path, and redirect paths are MxGateway-specific — set after Apply // Cookie name, path, and redirect paths are MxGateway-specific — set after Apply
// so they are never overwritten by the shared helper (Apply intentionally skips name). // so they are never overwritten by the shared helper (Apply intentionally skips name).
// This is the canonical default; it is overridden per-environment from
// DashboardOptions.CookieName by the PostConfigure below.
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName; cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
cookieOptions.Cookie.Path = "/"; cookieOptions.Cookie.Path = "/";
cookieOptions.LoginPath = "/login"; cookieOptions.LoginPath = "/login";
@@ -78,13 +79,22 @@ public static class DashboardServiceCollectionExtensions
_ => { }); _ => { });
// Honour DashboardOptions.RequireHttpsCookie (default true / Always; set false for dev // Honour DashboardOptions.RequireHttpsCookie (default true / Always; set false for dev
// HTTP deployments → SameAsRequest). This overrides the Apply default above. // HTTP deployments → SameAsRequest) and the optional per-environment cookie-name
// override. Both run after the inline AddCookie config above, so they win.
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme) services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
.Configure<IOptions<GatewayOptions>>((cookieOptions, gatewayOptions) => .Configure<IOptions<GatewayOptions>>((cookieOptions, gatewayOptions) =>
{ {
cookieOptions.Cookie.SecurePolicy = gatewayOptions.Value.Dashboard.RequireHttpsCookie cookieOptions.Cookie.SecurePolicy = gatewayOptions.Value.Dashboard.RequireHttpsCookie
? CookieSecurePolicy.Always ? CookieSecurePolicy.Always
: CookieSecurePolicy.SameAsRequest; : CookieSecurePolicy.SameAsRequest;
// Config-driven cookie name (MxGateway:Dashboard:CookieName). Null/blank keeps
// the canonical default set above, so a misconfiguration cannot unname the cookie.
var cookieName = gatewayOptions.Value.Dashboard.CookieName;
if (!string.IsNullOrWhiteSpace(cookieName))
{
cookieOptions.Cookie.Name = cookieName;
}
}); });
services.AddAuthorization(authorization => services.AddAuthorization(authorization =>
@@ -6,7 +6,6 @@ public sealed record DashboardSessionAdminResult(
{ {
/// <summary>Creates a successful result with the given message.</summary> /// <summary>Creates a successful result with the given message.</summary>
/// <param name="message">The result message.</param> /// <param name="message">The result message.</param>
/// <returns>A <see cref="DashboardSessionAdminResult"/> with <c>Succeeded</c> set to <c>true</c>.</returns>
public static DashboardSessionAdminResult Success(string message) public static DashboardSessionAdminResult Success(string message)
{ {
return new DashboardSessionAdminResult(true, message); return new DashboardSessionAdminResult(true, message);
@@ -14,7 +13,6 @@ public sealed record DashboardSessionAdminResult(
/// <summary>Creates a failed result with the given message.</summary> /// <summary>Creates a failed result with the given message.</summary>
/// <param name="message">The result message.</param> /// <param name="message">The result message.</param>
/// <returns>A <see cref="DashboardSessionAdminResult"/> with <c>Succeeded</c> set to <c>false</c>.</returns>
public static DashboardSessionAdminResult Fail(string message) public static DashboardSessionAdminResult Fail(string message)
{ {
return new DashboardSessionAdminResult(false, message); return new DashboardSessionAdminResult(false, message);
@@ -65,7 +65,10 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
_logger = logger ?? NullLogger<DashboardSnapshotService>.Instance; _logger = logger ?? NullLogger<DashboardSnapshotService>.Instance;
} }
/// <inheritdoc /> /// <summary>
/// Gets a current dashboard snapshot of gateway state.
/// </summary>
/// <returns>Dashboard snapshot.</returns>
public DashboardSnapshot GetSnapshot() public DashboardSnapshot GetSnapshot()
{ {
DateTimeOffset generatedAt = _timeProvider.GetUtcNow(); DateTimeOffset generatedAt = _timeProvider.GetUtcNow();
@@ -97,7 +100,11 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current)); Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current));
} }
/// <inheritdoc /> /// <summary>
/// Watches dashboard snapshots at regular intervals asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of dashboard snapshots.</returns>
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync( public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken) [EnumeratorCancellation] CancellationToken cancellationToken)
{ {
@@ -40,7 +40,6 @@ public sealed class HubTokenService
/// <summary>Issues a bearer token carrying the user's identity and roles.</summary> /// <summary>Issues a bearer token carrying the user's identity and roles.</summary>
/// <param name="user">The claims principal representing the user.</param> /// <param name="user">The claims principal representing the user.</param>
/// <returns>The time-limited bearer token string.</returns>
public string Issue(ClaimsPrincipal user) public string Issue(ClaimsPrincipal user)
{ {
ArgumentNullException.ThrowIfNull(user); ArgumentNullException.ThrowIfNull(user);
@@ -53,7 +52,6 @@ public sealed class HubTokenService
/// <summary>Validates a token and returns the equivalent claims principal; null when invalid or expired.</summary> /// <summary>Validates a token and returns the equivalent claims principal; null when invalid or expired.</summary>
/// <param name="token">The token string to validate.</param> /// <param name="token">The token string to validate.</param>
/// <returns>The claims principal if the token is valid, or null if invalid or expired.</returns>
public ClaimsPrincipal? Validate(string? token) public ClaimsPrincipal? Validate(string? token)
{ {
if (string.IsNullOrEmpty(token)) if (string.IsNullOrEmpty(token))
@@ -14,7 +14,9 @@ public sealed class DashboardEventBroadcaster(
IHubContext<EventsHub> hubContext, IHubContext<EventsHub> hubContext,
ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster
{ {
/// <inheritdoc /> /// <summary>Publishes an MX event to connected dashboard clients.</summary>
/// <param name="sessionId">The session identifier.</param>
/// <param name="mxEvent">The MX event to publish.</param>
public void Publish(string sessionId, MxEvent mxEvent) public void Publish(string sessionId, MxEvent mxEvent)
{ {
if (string.IsNullOrEmpty(sessionId) || mxEvent is null) if (string.IsNullOrEmpty(sessionId) || mxEvent is null)
@@ -49,7 +49,6 @@ public sealed class EventsHub : Hub
/// dedicated authorization policy applied to the hub method itself. /// dedicated authorization policy applied to the hub method itself.
/// </remarks> /// </remarks>
/// <param name="sessionId">Session id to subscribe the caller to.</param> /// <param name="sessionId">Session id to subscribe the caller to.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task SubscribeSession(string sessionId) public Task SubscribeSession(string sessionId)
{ {
if (string.IsNullOrWhiteSpace(sessionId)) if (string.IsNullOrWhiteSpace(sessionId))
@@ -11,7 +11,6 @@ public interface IDashboardAuthenticator
/// <param name="username">Username to authenticate.</param> /// <param name="username">Username to authenticate.</param>
/// <param name="password">Password to authenticate.</param> /// <param name="password">Password to authenticate.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the authentication result.</returns>
Task<DashboardAuthenticationResult> AuthenticateAsync( Task<DashboardAuthenticationResult> AuthenticateAsync(
string? username, string? username,
string? password, string? password,
@@ -12,13 +12,11 @@ public interface IDashboardBrowseService
{ {
/// <summary>Returns root browse nodes (objects with no parent).</summary> /// <summary>Returns root browse nodes (objects with no parent).</summary>
/// <param name="filter">Filter arguments forwarded to the projector.</param> /// <param name="filter">Filter arguments forwarded to the projector.</param>
/// <returns>The root-level browse result.</returns>
BrowseLevelResult GetRoots(BrowseFilterArgs filter); BrowseLevelResult GetRoots(BrowseFilterArgs filter);
/// <summary>Returns the direct children of the given parent gobject id.</summary> /// <summary>Returns the direct children of the given parent gobject id.</summary>
/// <param name="parentGobjectId">The Galaxy gobject id of the parent to expand.</param> /// <param name="parentGobjectId">The Galaxy gobject id of the parent to expand.</param>
/// <param name="filter">Filter arguments forwarded to the projector.</param> /// <param name="filter">Filter arguments forwarded to the projector.</param>
/// <returns>The children browse result for the specified parent.</returns>
BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter); BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter);
/// <summary>Current Galaxy cache sequence. Bumps after each successful refresh.</summary> /// <summary>Current Galaxy cache sequence. Bumps after each successful refresh.</summary>
@@ -8,13 +8,11 @@ public interface IDashboardSnapshotService
/// <summary> /// <summary>
/// Gets the current dashboard snapshot. /// Gets the current dashboard snapshot.
/// </summary> /// </summary>
/// <returns>The most recent <see cref="DashboardSnapshot"/>.</returns>
DashboardSnapshot GetSnapshot(); DashboardSnapshot GetSnapshot();
/// <summary> /// <summary>
/// Watches for changes to the dashboard state as an async enumerable. /// Watches for changes to the dashboard state as an async enumerable.
/// </summary> /// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>An async sequence of <see cref="DashboardSnapshot"/> values as state changes.</returns>
IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken); IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken);
} }
@@ -12,15 +12,9 @@ public sealed class AuthStoreHealthCheck : IHealthCheck
{ {
private readonly AuthSqliteConnectionFactory _connectionFactory; private readonly AuthSqliteConnectionFactory _connectionFactory;
/// <summary>Initializes a new instance of <see cref="AuthStoreHealthCheck"/> with the given connection factory.</summary>
/// <param name="connectionFactory">Factory for opening SQLite connections to the auth store.</param>
public AuthStoreHealthCheck(AuthSqliteConnectionFactory connectionFactory) => public AuthStoreHealthCheck(AuthSqliteConnectionFactory connectionFactory) =>
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
/// <summary>Runs a lightweight connectivity probe against the SQLite authentication store.</summary>
/// <param name="context">Health check context supplied by the framework.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the health check result.</returns>
public async Task<HealthCheckResult> CheckHealthAsync( public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, HealthCheckContext context,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -19,7 +19,6 @@ public static class GatewayLogRedactor
/// Determines whether a command method bears credentials. /// Determines whether a command method bears credentials.
/// </summary> /// </summary>
/// <param name="commandMethod">The command method name to check.</param> /// <param name="commandMethod">The command method name to check.</param>
/// <returns><c>true</c> if the method carries credentials; otherwise <c>false</c>.</returns>
public static bool IsCredentialBearingCommand(string? commandMethod) public static bool IsCredentialBearingCommand(string? commandMethod)
{ {
return commandMethod is not null return commandMethod is not null
@@ -30,7 +29,6 @@ public static class GatewayLogRedactor
/// Redacts the API key secret portion of a Bearer authorization header. /// Redacts the API key secret portion of a Bearer authorization header.
/// </summary> /// </summary>
/// <param name="authorizationHeader">The authorization header value to redact.</param> /// <param name="authorizationHeader">The authorization header value to redact.</param>
/// <returns>The header with the secret portion replaced by <see cref="RedactedValue"/>, or the original if no key is detected.</returns>
public static string? RedactApiKey(string? authorizationHeader) public static string? RedactApiKey(string? authorizationHeader)
{ {
if (string.IsNullOrWhiteSpace(authorizationHeader)) if (string.IsNullOrWhiteSpace(authorizationHeader))
@@ -64,7 +62,6 @@ public static class GatewayLogRedactor
/// Redacts the client identity if it contains an API key. /// Redacts the client identity if it contains an API key.
/// </summary> /// </summary>
/// <param name="clientIdentity">The client identity string to redact.</param> /// <param name="clientIdentity">The client identity string to redact.</param>
/// <returns>The redacted identity string, or the original if no key pattern is found.</returns>
public static string? RedactClientIdentity(string? clientIdentity) public static string? RedactClientIdentity(string? clientIdentity)
{ {
if (string.IsNullOrWhiteSpace(clientIdentity)) if (string.IsNullOrWhiteSpace(clientIdentity))
@@ -83,7 +80,6 @@ public static class GatewayLogRedactor
/// <param name="commandMethod">The command method name to check for credentials.</param> /// <param name="commandMethod">The command method name to check for credentials.</param>
/// <param name="value">The command value to redact.</param> /// <param name="value">The command value to redact.</param>
/// <param name="valueLoggingEnabled">Whether value logging is enabled.</param> /// <param name="valueLoggingEnabled">Whether value logging is enabled.</param>
/// <returns>The original value when logging is enabled and the command is not credential-bearing; otherwise <see cref="RedactedValue"/>.</returns>
public static object? RedactCommandValue( public static object? RedactCommandValue(
string? commandMethod, string? commandMethod,
object? value, object? value,
@@ -8,7 +8,6 @@ public sealed record GatewayLogScope(
string? ClientIdentity = null) string? ClientIdentity = null)
{ {
/// <summary>Converts the log scope to a dictionary with redacted sensitive fields.</summary> /// <summary>Converts the log scope to a dictionary with redacted sensitive fields.</summary>
/// <returns>A dictionary of non-null scope properties with sensitive fields redacted.</returns>
public IReadOnlyDictionary<string, object?> ToDictionary() public IReadOnlyDictionary<string, object?> ToDictionary()
{ {
Dictionary<string, object?> values = []; Dictionary<string, object?> values = [];
@@ -19,7 +19,6 @@ public static class GatewayRequestLoggingMiddlewareExtensions
/// <summary>Adds gateway request logging scope middleware that reads correlation headers and redacts sensitive data.</summary> /// <summary>Adds gateway request logging scope middleware that reads correlation headers and redacts sensitive data.</summary>
/// <param name="app">Application builder.</param> /// <param name="app">Application builder.</param>
/// <returns>The <paramref name="app"/> instance for chaining.</returns>
public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app) public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app)
{ {
ArgumentNullException.ThrowIfNull(app); ArgumentNullException.ThrowIfNull(app);
@@ -27,7 +27,6 @@ public static class GalaxyBrowseProjector
/// <param name="browseSubtreeGlobs">Optional API-key browse-subtree constraints.</param> /// <param name="browseSubtreeGlobs">Optional API-key browse-subtree constraints.</param>
/// <param name="offset">Zero-based offset into the filtered child list.</param> /// <param name="offset">Zero-based offset into the filtered child list.</param>
/// <param name="pageSize">Maximum number of children to return.</param> /// <param name="pageSize">Maximum number of children to return.</param>
/// <returns>A page of children with total count and filter signature.</returns>
public static GalaxyBrowseChildrenResult ProjectChildren( public static GalaxyBrowseChildrenResult ProjectChildren(
GalaxyHierarchyCacheEntry entry, GalaxyHierarchyCacheEntry entry,
BrowseChildrenRequest request, BrowseChildrenRequest request,
@@ -72,7 +71,6 @@ public static class GalaxyBrowseProjector
/// </summary> /// </summary>
/// <param name="entry">The Galaxy hierarchy cache entry to query.</param> /// <param name="entry">The Galaxy hierarchy cache entry to query.</param>
/// <param name="request">The browse-children request.</param> /// <param name="request">The browse-children request.</param>
/// <returns>The resolved parent gobject id, or 0 for roots.</returns>
public static int ResolveParentId(GalaxyHierarchyCacheEntry entry, BrowseChildrenRequest request) public static int ResolveParentId(GalaxyHierarchyCacheEntry entry, BrowseChildrenRequest request)
{ {
switch (request.ParentCase) switch (request.ParentCase)
@@ -259,7 +257,6 @@ public static class GalaxyBrowseProjector
/// <param name="request">The browse-children request.</param> /// <param name="request">The browse-children request.</param>
/// <param name="browseSubtreeGlobs">Optional API-key browse-subtree constraints.</param> /// <param name="browseSubtreeGlobs">Optional API-key browse-subtree constraints.</param>
/// <param name="parentId">Resolved parent gobject id (0 for roots).</param> /// <param name="parentId">Resolved parent gobject id (0 for roots).</param>
/// <returns>A hex-encoded SHA-256 prefix that uniquely identifies the filter combination.</returns>
public static string ComputeFilterSignature( public static string ComputeFilterSignature(
BrowseChildrenRequest request, BrowseChildrenRequest request,
IReadOnlyList<string>? browseSubtreeGlobs, IReadOnlyList<string>? browseSubtreeGlobs,
@@ -20,7 +20,9 @@ public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier
private readonly ConcurrentDictionary<Guid, Channel<GalaxyDeployEventInfo>> _subscribers = new(); private readonly ConcurrentDictionary<Guid, Channel<GalaxyDeployEventInfo>> _subscribers = new();
private GalaxyDeployEventInfo? _latest; private GalaxyDeployEventInfo? _latest;
/// <inheritdoc /> /// <summary>
/// The most recent deploy event, or null if none has been published.
/// </summary>
public GalaxyDeployEventInfo? Latest => Volatile.Read(ref _latest); public GalaxyDeployEventInfo? Latest => Volatile.Read(ref _latest);
/// <inheritdoc /> /// <inheritdoc />
@@ -46,7 +46,6 @@ public static class GalaxyGlobMatcher
/// <summary>Determines whether a value matches a glob pattern (with * and ? wildcards).</summary> /// <summary>Determines whether a value matches a glob pattern (with * and ? wildcards).</summary>
/// <param name="value">The value to test against the glob pattern.</param> /// <param name="value">The value to test against the glob pattern.</param>
/// <param name="glob">The glob pattern with * and ? wildcards.</param> /// <param name="glob">The glob pattern with * and ? wildcards.</param>
/// <returns><see langword="true"/> if the value matches the glob pattern; otherwise <see langword="false"/>.</returns>
public static bool IsMatch(string value, string glob) public static bool IsMatch(string value, string glob)
{ {
if (string.IsNullOrWhiteSpace(glob)) if (string.IsNullOrWhiteSpace(glob))
@@ -54,7 +54,7 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
_snapshotStore = snapshotStore; _snapshotStore = snapshotStore;
} }
/// <inheritdoc /> /// <summary>Gets the current Galaxy hierarchy cache entry with projected status.</summary>
public GalaxyHierarchyCacheEntry Current public GalaxyHierarchyCacheEntry Current
{ {
get get
@@ -74,7 +74,9 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
} }
} }
/// <inheritdoc /> /// <summary>Refreshes the Galaxy hierarchy cache if the deploy time has advanced.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Asynchronous task representing the refresh operation.</returns>
public async Task RefreshAsync(CancellationToken cancellationToken) public async Task RefreshAsync(CancellationToken cancellationToken)
{ {
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false); await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
@@ -88,7 +90,9 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
} }
} }
/// <inheritdoc /> /// <summary>Waits for the Galaxy hierarchy cache to complete its first load.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Asynchronous task representing the wait operation.</returns>
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) public Task WaitForFirstLoadAsync(CancellationToken cancellationToken)
{ {
return _firstLoad.Task.WaitAsync(cancellationToken); return _firstLoad.Task.WaitAsync(cancellationToken);
@@ -25,7 +25,6 @@ public static class GalaxyHierarchyProjector
/// <param name="entry">The Galaxy hierarchy cache entry.</param> /// <param name="entry">The Galaxy hierarchy cache entry.</param>
/// <param name="request">The discovery hierarchy request.</param> /// <param name="request">The discovery hierarchy request.</param>
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param> /// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
/// <returns>The query result containing matching objects.</returns>
public static GalaxyHierarchyQueryResult Project( public static GalaxyHierarchyQueryResult Project(
GalaxyHierarchyCacheEntry entry, GalaxyHierarchyCacheEntry entry,
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
@@ -45,7 +44,6 @@ public static class GalaxyHierarchyProjector
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param> /// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
/// <param name="offset">The zero-based offset into the result set.</param> /// <param name="offset">The zero-based offset into the result set.</param>
/// <param name="pageSize">The maximum number of results to return.</param> /// <param name="pageSize">The maximum number of results to return.</param>
/// <returns>The query result containing the requested page of matching objects.</returns>
public static GalaxyHierarchyQueryResult Project( public static GalaxyHierarchyQueryResult Project(
GalaxyHierarchyCacheEntry entry, GalaxyHierarchyCacheEntry entry,
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
@@ -133,7 +131,6 @@ public static class GalaxyHierarchyProjector
/// <summary>Finds an object in the hierarchy by its tag address.</summary> /// <summary>Finds an object in the hierarchy by its tag address.</summary>
/// <param name="entry">The Galaxy hierarchy cache entry.</param> /// <param name="entry">The Galaxy hierarchy cache entry.</param>
/// <param name="tagAddress">The tag address to search for.</param> /// <param name="tagAddress">The tag address to search for.</param>
/// <returns>The matching Galaxy object, or <c>null</c> if not found.</returns>
public static GalaxyObject? FindObjectForTag( public static GalaxyObject? FindObjectForTag(
GalaxyHierarchyCacheEntry entry, GalaxyHierarchyCacheEntry entry,
string tagAddress) string tagAddress)
@@ -151,7 +148,6 @@ public static class GalaxyHierarchyProjector
/// <summary>Finds an attribute in the hierarchy by its tag address.</summary> /// <summary>Finds an attribute in the hierarchy by its tag address.</summary>
/// <param name="entry">The Galaxy hierarchy cache entry.</param> /// <param name="entry">The Galaxy hierarchy cache entry.</param>
/// <param name="tagAddress">The tag address to search for.</param> /// <param name="tagAddress">The tag address to search for.</param>
/// <returns>The matching Galaxy attribute, or <c>null</c> if not found.</returns>
public static GalaxyAttribute? FindAttributeForTag( public static GalaxyAttribute? FindAttributeForTag(
GalaxyHierarchyCacheEntry entry, GalaxyHierarchyCacheEntry entry,
string tagAddress) string tagAddress)
@@ -169,7 +165,6 @@ public static class GalaxyHierarchyProjector
/// <summary>Gets the contained path for an object by its gobject ID.</summary> /// <summary>Gets the contained path for an object by its gobject ID.</summary>
/// <param name="entry">The Galaxy hierarchy cache entry.</param> /// <param name="entry">The Galaxy hierarchy cache entry.</param>
/// <param name="gobjectId">The Galaxy object ID.</param> /// <param name="gobjectId">The Galaxy object ID.</param>
/// <returns>The contained path string, or an empty string if the object is not found.</returns>
public static string GetContainedPath( public static string GetContainedPath(
GalaxyHierarchyCacheEntry entry, GalaxyHierarchyCacheEntry entry,
int gobjectId) int gobjectId)
@@ -287,7 +282,6 @@ public static class GalaxyHierarchyProjector
/// <summary>Computes a stable filter signature for memoization purposes.</summary> /// <summary>Computes a stable filter signature for memoization purposes.</summary>
/// <param name="request">The discovery hierarchy request.</param> /// <param name="request">The discovery hierarchy request.</param>
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param> /// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
/// <returns>A string key that uniquely identifies the combination of filter parameters.</returns>
public static string ComputeFilterSignature( public static string ComputeFilterSignature(
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
IReadOnlyList<string>? browseSubtreeGlobs) IReadOnlyList<string>? browseSubtreeGlobs)
@@ -15,7 +15,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
/// </summary> /// </summary>
public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyRepository public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyRepository
{ {
/// <inheritdoc /> /// <summary>Tests the connection to the Galaxy Repository database.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param>
public async Task<bool> TestConnectionAsync(CancellationToken ct = default) public async Task<bool> TestConnectionAsync(CancellationToken ct = default)
{ {
try try
@@ -30,7 +31,8 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyR
catch (InvalidOperationException) { return false; } catch (InvalidOperationException) { return false; }
} }
/// <inheritdoc /> /// <summary>Retrieves the last deployment time from the Galaxy Repository.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param>
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
{ {
using SqlConnection conn = new(options.ConnectionString); using SqlConnection conn = new(options.ConnectionString);
@@ -41,7 +43,8 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyR
return result is DateTime dt ? dt : null; return result is DateTime dt ? dt : null;
} }
/// <inheritdoc /> /// <summary>Retrieves the complete hierarchy of Galaxy objects from the repository.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param>
public async Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) public async Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
{ {
List<GalaxyHierarchyRow> rows = new(); List<GalaxyHierarchyRow> rows = new();
@@ -78,7 +81,8 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyR
return rows; return rows;
} }
/// <inheritdoc /> /// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param>
public async Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) public async Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
{ {
List<GalaxyAttributeRow> rows = new(); List<GalaxyAttributeRow> rows = new();
@@ -13,7 +13,6 @@ public interface IGalaxyHierarchyCache
/// refresh. /// refresh.
/// </summary> /// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task RefreshAsync(CancellationToken cancellationToken); Task RefreshAsync(CancellationToken cancellationToken);
/// <summary> /// <summary>
@@ -22,6 +21,5 @@ public interface IGalaxyHierarchyCache
/// very first request after gateway start. /// very first request after gateway start.
/// </summary> /// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task WaitForFirstLoadAsync(CancellationToken cancellationToken); Task WaitForFirstLoadAsync(CancellationToken cancellationToken);
} }
@@ -13,7 +13,6 @@ public interface IGalaxyHierarchySnapshotStore
/// </summary> /// </summary>
/// <param name="snapshot">The browse dataset to persist.</param> /// <param name="snapshot">The browse dataset to persist.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous save operation.</returns>
Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken); Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken);
/// <summary> /// <summary>
@@ -14,21 +14,17 @@ public interface IGalaxyRepository
{ {
/// <summary>Tests the connection to the Galaxy Repository database.</summary> /// <summary>Tests the connection to the Galaxy Repository database.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param> /// <param name="ct">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to <see langword="true"/> if the connection succeeds; otherwise <see langword="false"/>.</returns>
Task<bool> TestConnectionAsync(CancellationToken ct = default); Task<bool> TestConnectionAsync(CancellationToken ct = default);
/// <summary>Retrieves the last deployment time from the Galaxy Repository.</summary> /// <summary>Retrieves the last deployment time from the Galaxy Repository.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param> /// <param name="ct">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the last deploy time, or <see langword="null"/> if not available.</returns>
Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default); Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default);
/// <summary>Retrieves the complete hierarchy of Galaxy objects from the repository.</summary> /// <summary>Retrieves the complete hierarchy of Galaxy objects from the repository.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param> /// <param name="ct">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the list of hierarchy rows.</returns>
Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default); Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default);
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary> /// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param> /// <param name="ct">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the list of attribute rows.</returns>
Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default); Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default);
} }
@@ -18,7 +18,12 @@ public sealed class EventStreamService(
IDashboardEventBroadcaster dashboardEventBroadcaster, IDashboardEventBroadcaster dashboardEventBroadcaster,
ILogger<EventStreamService> logger) : IEventStreamService ILogger<EventStreamService> logger) : IEventStreamService
{ {
/// <inheritdoc /> /// <summary>
/// Streams events from a session to the client asynchronously.
/// </summary>
/// <param name="request">Stream events request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of MX events.</returns>
public async IAsyncEnumerable<MxEvent> StreamEventsAsync( public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request, StreamEventsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken) [EnumeratorCancellation] CancellationToken cancellationToken)
@@ -13,7 +13,6 @@ public static class GalaxyProtoMapper
/// <summary>Maps Galaxy hierarchy and attribute rows to Galaxy object protos.</summary> /// <summary>Maps Galaxy hierarchy and attribute rows to Galaxy object protos.</summary>
/// <param name="hierarchy">Hierarchy rows from Galaxy Repository.</param> /// <param name="hierarchy">Hierarchy rows from Galaxy Repository.</param>
/// <param name="attributes">Attribute rows from Galaxy Repository.</param> /// <param name="attributes">Attribute rows from Galaxy Repository.</param>
/// <returns>An enumerable of mapped Galaxy object protos.</returns>
public static IEnumerable<GalaxyObject> MapHierarchy( public static IEnumerable<GalaxyObject> MapHierarchy(
IReadOnlyList<GalaxyHierarchyRow> hierarchy, IReadOnlyList<GalaxyHierarchyRow> hierarchy,
IReadOnlyList<GalaxyAttributeRow> attributes) IReadOnlyList<GalaxyAttributeRow> attributes)
@@ -31,7 +30,6 @@ public static class GalaxyProtoMapper
/// <summary>Maps a Galaxy hierarchy row to a Galaxy object proto.</summary> /// <summary>Maps a Galaxy hierarchy row to a Galaxy object proto.</summary>
/// <param name="row">Hierarchy row from Galaxy Repository.</param> /// <param name="row">Hierarchy row from Galaxy Repository.</param>
/// <param name="attributesByGobjectId">Attributes indexed by gobject ID.</param> /// <param name="attributesByGobjectId">Attributes indexed by gobject ID.</param>
/// <returns>The mapped Galaxy object proto.</returns>
public static GalaxyObject MapObject( public static GalaxyObject MapObject(
GalaxyHierarchyRow row, GalaxyHierarchyRow row,
IReadOnlyDictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId) IReadOnlyDictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId)
@@ -62,7 +60,6 @@ public static class GalaxyProtoMapper
/// <summary>Maps a Galaxy attribute row to a Galaxy attribute proto.</summary> /// <summary>Maps a Galaxy attribute row to a Galaxy attribute proto.</summary>
/// <param name="row">Attribute row from Galaxy Repository.</param> /// <param name="row">Attribute row from Galaxy Repository.</param>
/// <returns>The mapped Galaxy attribute proto.</returns>
public static GalaxyAttribute MapAttribute(GalaxyAttributeRow row) => new() public static GalaxyAttribute MapAttribute(GalaxyAttributeRow row) => new()
{ {
AttributeName = row.AttributeName, AttributeName = row.AttributeName,
@@ -12,7 +12,6 @@ public interface IEventStreamService
/// </summary> /// </summary>
/// <param name="request">Request payload.</param> /// <param name="request">Request payload.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>An async enumerable of MXAccess events.</returns>
IAsyncEnumerable<MxEvent> StreamEventsAsync( IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request, StreamEventsRequest request,
CancellationToken cancellationToken); CancellationToken cancellationToken);
@@ -162,6 +162,15 @@ public sealed class MxAccessGatewayService(
} }
/// <inheritdoc /> /// <inheritdoc />
/// <remarks>
/// Surfaces the public AcknowledgeAlarm RPC. Acknowledgement is
/// session-less: the gateway routes it through the always-on
/// <see cref="IGatewayAlarmService"/> monitor session. An
/// <c>alarm_full_reference</c> that parses as a canonical GUID forwards
/// to <c>AcknowledgeAlarmCommand</c>; a <c>Provider!Group.Tag</c>
/// reference forwards to <c>AcknowledgeAlarmByNameCommand</c>; anything
/// else returns an <c>InvalidRequest</c> diagnostic in the reply.
/// </remarks>
public override async Task<AcknowledgeAlarmReply> AcknowledgeAlarm( public override async Task<AcknowledgeAlarmReply> AcknowledgeAlarm(
AcknowledgeAlarmRequest request, AcknowledgeAlarmRequest request,
ServerCallContext context) ServerCallContext context)
@@ -184,6 +193,14 @@ public sealed class MxAccessGatewayService(
} }
/// <inheritdoc /> /// <inheritdoc />
/// <remarks>
/// Surfaces the public StreamAlarms RPC — the session-less central
/// alarm feed. The stream opens with one <c>active_alarm</c> per
/// currently-active alarm, then a single <c>snapshot_complete</c>, then
/// a <c>transition</c> for every subsequent change. Served by the
/// gateway's always-on <see cref="IGatewayAlarmService"/> monitor; any
/// number of clients fan out from the single monitor.
/// </remarks>
public override async Task StreamAlarms( public override async Task StreamAlarms(
StreamAlarmsRequest request, StreamAlarmsRequest request,
IServerStreamWriter<AlarmFeedMessage> responseStream, IServerStreamWriter<AlarmFeedMessage> responseStream,
@@ -207,6 +224,12 @@ public sealed class MxAccessGatewayService(
} }
/// <inheritdoc /> /// <inheritdoc />
/// <remarks>
/// Snapshot of the active-alarm cache maintained by the gateway's
/// always-on alarm monitor. Streams one <see cref="ActiveAlarmSnapshot"/>
/// per currently-active alarm and completes — no transitions are
/// emitted. Use <c>StreamAlarms</c> for a live transition feed.
/// </remarks>
public override async Task QueryActiveAlarms( public override async Task QueryActiveAlarms(
QueryActiveAlarmsRequest request, QueryActiveAlarmsRequest request,
IServerStreamWriter<ActiveAlarmSnapshot> responseStream, IServerStreamWriter<ActiveAlarmSnapshot> responseStream,
@@ -23,7 +23,6 @@ public sealed class MxAccessGrpcMapper
/// Maps a gRPC MX command request to a worker command. /// Maps a gRPC MX command request to a worker command.
/// </summary> /// </summary>
/// <param name="request">Request payload.</param> /// <param name="request">Request payload.</param>
/// <returns>The mapped worker command.</returns>
public WorkerCommand MapCommand(MxCommandRequest request) public WorkerCommand MapCommand(MxCommandRequest request)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
@@ -40,7 +39,6 @@ public sealed class MxAccessGrpcMapper
/// Maps a worker command reply to a gRPC MX command reply. /// Maps a worker command reply to a gRPC MX command reply.
/// </summary> /// </summary>
/// <param name="reply">Worker command reply.</param> /// <param name="reply">Worker command reply.</param>
/// <returns>The mapped gRPC command reply.</returns>
public MxCommandReply MapCommandReply(WorkerCommandReply reply) public MxCommandReply MapCommandReply(WorkerCommandReply reply)
{ {
ArgumentNullException.ThrowIfNull(reply); ArgumentNullException.ThrowIfNull(reply);
@@ -60,7 +58,6 @@ public sealed class MxAccessGrpcMapper
/// Maps a worker event to a gRPC MX event. /// Maps a worker event to a gRPC MX event.
/// </summary> /// </summary>
/// <param name="workerEvent">Worker event to map.</param> /// <param name="workerEvent">Worker event to map.</param>
/// <returns>The mapped gRPC MX event.</returns>
public MxEvent MapEvent(WorkerEvent workerEvent) public MxEvent MapEvent(WorkerEvent workerEvent)
{ {
ArgumentNullException.ThrowIfNull(workerEvent); ArgumentNullException.ThrowIfNull(workerEvent);
@@ -76,7 +73,6 @@ public sealed class MxAccessGrpcMapper
/// Creates an OK protocol status. /// Creates an OK protocol status.
/// </summary> /// </summary>
/// <param name="message">Status message.</param> /// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.Ok"/>.</returns>
public static ProtocolStatus Ok(string message = "OK") public static ProtocolStatus Ok(string message = "OK")
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -90,7 +86,6 @@ public sealed class MxAccessGrpcMapper
/// Creates an InvalidRequest protocol status. /// Creates an InvalidRequest protocol status.
/// </summary> /// </summary>
/// <param name="message">Status message.</param> /// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.InvalidRequest"/>.</returns>
public static ProtocolStatus InvalidRequest(string message) public static ProtocolStatus InvalidRequest(string message)
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -104,7 +99,6 @@ public sealed class MxAccessGrpcMapper
/// Creates a SessionNotFound protocol status. /// Creates a SessionNotFound protocol status.
/// </summary> /// </summary>
/// <param name="message">Status message.</param> /// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.SessionNotFound"/>.</returns>
public static ProtocolStatus SessionNotFound(string message) public static ProtocolStatus SessionNotFound(string message)
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -118,7 +112,6 @@ public sealed class MxAccessGrpcMapper
/// Creates a SessionNotReady protocol status. /// Creates a SessionNotReady protocol status.
/// </summary> /// </summary>
/// <param name="message">Status message.</param> /// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.SessionNotReady"/>.</returns>
public static ProtocolStatus SessionNotReady(string message) public static ProtocolStatus SessionNotReady(string message)
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -132,7 +125,6 @@ public sealed class MxAccessGrpcMapper
/// Creates a WorkerUnavailable protocol status. /// Creates a WorkerUnavailable protocol status.
/// </summary> /// </summary>
/// <param name="message">Status message.</param> /// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.WorkerUnavailable"/>.</returns>
public static ProtocolStatus WorkerUnavailable(string message) public static ProtocolStatus WorkerUnavailable(string message)
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -146,7 +138,6 @@ public sealed class MxAccessGrpcMapper
/// Creates a Timeout protocol status. /// Creates a Timeout protocol status.
/// </summary> /// </summary>
/// <param name="message">Status message.</param> /// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.Timeout"/>.</returns>
public static ProtocolStatus Timeout(string message) public static ProtocolStatus Timeout(string message)
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -160,7 +151,6 @@ public sealed class MxAccessGrpcMapper
/// Creates a Canceled protocol status. /// Creates a Canceled protocol status.
/// </summary> /// </summary>
/// <param name="message">Status message.</param> /// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.Canceled"/>.</returns>
public static ProtocolStatus Canceled(string message) public static ProtocolStatus Canceled(string message)
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -174,7 +164,6 @@ public sealed class MxAccessGrpcMapper
/// Creates a ProtocolViolation protocol status. /// Creates a ProtocolViolation protocol status.
/// </summary> /// </summary>
/// <param name="message">Status message.</param> /// <param name="message">Status message.</param>
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.ProtocolViolation"/>.</returns>
public static ProtocolStatus ProtocolViolation(string message) public static ProtocolStatus ProtocolViolation(string message)
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -380,7 +380,6 @@ public sealed class GatewayMetrics : IDisposable
/// <summary> /// <summary>
/// Returns a snapshot of all current metric values. /// Returns a snapshot of all current metric values.
/// </summary> /// </summary>
/// <returns>A consistent snapshot of the current metric counters and gauges.</returns>
public GatewayMetricsSnapshot GetSnapshot() public GatewayMetricsSnapshot GetSnapshot()
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -19,10 +19,7 @@ public sealed class CanonicalAuditWriter(
SqliteCanonicalAuditStore store, SqliteCanonicalAuditStore store,
ILogger<CanonicalAuditWriter> logger) : IAuditWriter ILogger<CanonicalAuditWriter> logger) : IAuditWriter
{ {
/// <summary>Persists the audit event to the canonical store; swallows and logs any write failure rather than propagating it.</summary> /// <inheritdoc />
/// <param name="auditEvent">The audit event to persist.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default) public async Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
{ {
ArgumentNullException.ThrowIfNull(auditEvent); ArgumentNullException.ThrowIfNull(auditEvent);
@@ -43,10 +43,7 @@ public sealed class CanonicalForwardingApiKeyAuditStore(
/// <summary>The library's keyless schema-init event type.</summary> /// <summary>The library's keyless schema-init event type.</summary>
private const string InitDbEventType = "init-db"; private const string InitDbEventType = "init-db";
/// <summary>Converts the library audit entry to a canonical <see cref="AuditEvent"/> and forwards it through the gateway's audit writer.</summary> /// <inheritdoc />
/// <param name="entry">The API key audit entry to append.</param>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct) public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct)
{ {
ArgumentNullException.ThrowIfNull(entry); ArgumentNullException.ThrowIfNull(entry);
@@ -74,10 +71,7 @@ public sealed class CanonicalForwardingApiKeyAuditStore(
await auditWriter.WriteAsync(auditEvent, ct).ConfigureAwait(false); await auditWriter.WriteAsync(auditEvent, ct).ConfigureAwait(false);
} }
/// <summary>Reads recent audit events from the canonical store and maps them back to library-compatible <see cref="ApiKeyAuditEntry"/> records.</summary> /// <inheritdoc />
/// <param name="limit">Maximum number of entries to return.</param>
/// <param name="ct">Token to observe for cancellation.</param>
/// <returns>A task that resolves to the most recent audit entries, up to <paramref name="limit"/> items.</returns>
public async Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct) public async Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
{ {
IReadOnlyList<AuditEvent> events = await store.ListRecentAsync(limit, ct).ConfigureAwait(false); IReadOnlyList<AuditEvent> events = await store.ListRecentAsync(limit, ct).ConfigureAwait(false);
@@ -43,7 +43,6 @@ public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connec
/// <summary>Inserts a canonical audit event into the <c>audit_event</c> table.</summary> /// <summary>Inserts a canonical audit event into the <c>audit_event</c> table.</summary>
/// <param name="auditEvent">The canonical event to persist.</param> /// <param name="auditEvent">The canonical event to persist.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param> /// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken) public async Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken)
{ {
ArgumentNullException.ThrowIfNull(auditEvent); ArgumentNullException.ThrowIfNull(auditEvent);
@@ -80,7 +79,6 @@ public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connec
/// <summary>Returns the most recent canonical audit events, newest first.</summary> /// <summary>Returns the most recent canonical audit events, newest first.</summary>
/// <param name="limit">Maximum number of events to return.</param> /// <param name="limit">Maximum number of events to return.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param> /// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that resolves to the most recent audit events, up to <paramref name="limit"/>.</returns>
public async Task<IReadOnlyList<AuditEvent>> ListRecentAsync(int limit, CancellationToken cancellationToken) public async Task<IReadOnlyList<AuditEvent>> ListRecentAsync(int limit, CancellationToken cancellationToken)
{ {
if (limit <= 0) if (limit <= 0)
@@ -26,7 +26,6 @@ public sealed class ApiKeyAdminCliRunner(ApiKeyAdminCommands commands)
/// <param name="command">API key administration command to execute.</param> /// <param name="command">API key administration command to execute.</param>
/// <param name="output">Text writer for command output.</param> /// <param name="output">Text writer for command output.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the exit code (always 0 on success).</returns>
public async Task<int> RunAsync( public async Task<int> RunAsync(
ApiKeyAdminCommand command, ApiKeyAdminCommand command,
TextWriter output, TextWriter output,
@@ -6,7 +6,6 @@ public sealed record ApiKeyAdminParseResult(
string? Error) string? Error)
{ {
/// <summary>Returns a result indicating the input was not an API key command.</summary> /// <summary>Returns a result indicating the input was not an API key command.</summary>
/// <returns>A parse result with <see cref="IsApiKeyCommand"/> set to false.</returns>
public static ApiKeyAdminParseResult NotApiKeyCommand() public static ApiKeyAdminParseResult NotApiKeyCommand()
{ {
return new ApiKeyAdminParseResult(false, null, null); return new ApiKeyAdminParseResult(false, null, null);
@@ -14,7 +13,6 @@ public sealed record ApiKeyAdminParseResult(
/// <summary>Returns a successful parse result with the parsed API key command.</summary> /// <summary>Returns a successful parse result with the parsed API key command.</summary>
/// <param name="command">Parsed API key administration command.</param> /// <param name="command">Parsed API key administration command.</param>
/// <returns>A parse result with <see cref="IsApiKeyCommand"/> set to true and the command populated.</returns>
public static ApiKeyAdminParseResult Success(ApiKeyAdminCommand command) public static ApiKeyAdminParseResult Success(ApiKeyAdminCommand command)
{ {
return new ApiKeyAdminParseResult(true, command, null); return new ApiKeyAdminParseResult(true, command, null);
@@ -22,7 +20,6 @@ public sealed record ApiKeyAdminParseResult(
/// <summary>Returns a parse result with the specified error message.</summary> /// <summary>Returns a parse result with the specified error message.</summary>
/// <param name="error">Error message describing the parse failure.</param> /// <param name="error">Error message describing the parse failure.</param>
/// <returns>A parse result with <see cref="IsApiKeyCommand"/> set to true and the error message populated.</returns>
public static ApiKeyAdminParseResult Fail(string error) public static ApiKeyAdminParseResult Fail(string error)
{ {
return new ApiKeyAdminParseResult(true, null, error); return new ApiKeyAdminParseResult(true, null, error);
@@ -12,7 +12,6 @@ public static class ApiKeyConstraintSerializer
/// <summary>Serializes API key constraints to JSON, or returns null if the constraints are empty.</summary> /// <summary>Serializes API key constraints to JSON, or returns null if the constraints are empty.</summary>
/// <param name="constraints">The constraints to serialize.</param> /// <param name="constraints">The constraints to serialize.</param>
/// <returns>A JSON string representing the constraints, or <see langword="null"/> if empty.</returns>
public static string? Serialize(ApiKeyConstraints constraints) public static string? Serialize(ApiKeyConstraints constraints)
{ {
ArgumentNullException.ThrowIfNull(constraints); ArgumentNullException.ThrowIfNull(constraints);
@@ -21,7 +20,6 @@ public static class ApiKeyConstraintSerializer
/// <summary>Deserializes API key constraints from JSON, or returns empty constraints if JSON is null or whitespace.</summary> /// <summary>Deserializes API key constraints from JSON, or returns empty constraints if JSON is null or whitespace.</summary>
/// <param name="json">The JSON string to deserialize.</param> /// <param name="json">The JSON string to deserialize.</param>
/// <returns>The deserialized <see cref="ApiKeyConstraints"/>, or <see cref="ApiKeyConstraints.Empty"/> when <paramref name="json"/> is null or whitespace.</returns>
public static ApiKeyConstraints Deserialize(string? json) public static ApiKeyConstraints Deserialize(string? json)
{ {
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
@@ -16,7 +16,10 @@ public sealed class ConstraintEnforcer(
IGalaxyHierarchyCache cache, IGalaxyHierarchyCache cache,
IAuditWriter auditWriter) : IConstraintEnforcer IAuditWriter auditWriter) : IConstraintEnforcer
{ {
/// <inheritdoc /> /// <summary>Checks read constraints on a tag address.</summary>
/// <param name="identity">The API key identity to check constraints for.</param>
/// <param name="tagAddress">Tag address to validate.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task<ConstraintFailure?> CheckReadTagAsync( public Task<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
string tagAddress, string tagAddress,
@@ -31,7 +34,12 @@ public sealed class ConstraintEnforcer(
return Task.FromResult(CheckReadTarget(constraints, tagAddress)); return Task.FromResult(CheckReadTarget(constraints, tagAddress));
} }
/// <inheritdoc /> /// <summary>Checks read constraints on a server and item handle.</summary>
/// <param name="identity">The API key identity to check constraints for.</param>
/// <param name="session">The gateway session containing handle registrations.</param>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task<ConstraintFailure?> CheckReadHandleAsync( public Task<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
GatewaySession session, GatewaySession session,
@@ -53,7 +61,12 @@ public sealed class ConstraintEnforcer(
return Task.FromResult(CheckReadTarget(constraints, registration.TagAddress)); return Task.FromResult(CheckReadTarget(constraints, registration.TagAddress));
} }
/// <inheritdoc /> /// <summary>Checks write constraints on a server and item handle.</summary>
/// <param name="identity">The API key identity to check constraints for.</param>
/// <param name="session">The gateway session containing handle registrations.</param>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task<ConstraintFailure?> CheckWriteHandleAsync( public Task<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
GatewaySession session, GatewaySession session,
@@ -102,7 +115,12 @@ public sealed class ConstraintEnforcer(
return Task.FromResult<ConstraintFailure?>(null); return Task.FromResult<ConstraintFailure?>(null);
} }
/// <inheritdoc /> /// <summary>Records a constraint denial audit entry.</summary>
/// <param name="identity">The API key identity that was denied.</param>
/// <param name="commandKind">The command type (e.g., read, write).</param>
/// <param name="target">The target being accessed (tag address or handle).</param>
/// <param name="failure">The constraint failure details.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public async Task RecordDenialAsync( public async Task RecordDenialAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
string commandKind, string commandKind,
@@ -6,10 +6,12 @@ public sealed class GatewayRequestIdentityAccessor : IGatewayRequestIdentityAcce
{ {
private readonly AsyncLocal<ApiKeyIdentity?> currentIdentity = new(); private readonly AsyncLocal<ApiKeyIdentity?> currentIdentity = new();
/// <inheritdoc /> /// <summary>Gets the current request identity.</summary>
public ApiKeyIdentity? Current => currentIdentity.Value; public ApiKeyIdentity? Current => currentIdentity.Value;
/// <inheritdoc /> /// <summary>Sets the current identity and returns a scope that restores the previous identity.</summary>
/// <param name="identity">The identity to push.</param>
/// <returns>Disposable scope.</returns>
public IDisposable Push(ApiKeyIdentity identity) public IDisposable Push(ApiKeyIdentity identity)
{ {
ArgumentNullException.ThrowIfNull(identity); ArgumentNullException.ThrowIfNull(identity);
@@ -13,7 +13,6 @@ public static class GrpcAuthorizationServiceCollectionExtensions
/// Registers gRPC authorization middleware and scope resolver. /// Registers gRPC authorization middleware and scope resolver.
/// </summary> /// </summary>
/// <param name="services">Service collection to register dependencies into.</param> /// <param name="services">Service collection to register dependencies into.</param>
/// <returns>The same <paramref name="services"/> for fluent chaining.</returns>
public static IServiceCollection AddGatewayGrpcAuthorization(this IServiceCollection services) public static IServiceCollection AddGatewayGrpcAuthorization(this IServiceCollection services)
{ {
services.AddSingleton<GatewayGrpcScopeResolver>(); services.AddSingleton<GatewayGrpcScopeResolver>();
@@ -9,7 +9,6 @@ public interface IConstraintEnforcer
/// <param name="identity">The API key identity.</param> /// <param name="identity">The API key identity.</param>
/// <param name="tagAddress">Tag address to check.</param> /// <param name="tagAddress">Tag address to check.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param> /// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that resolves to the constraint failure, or null if the check passes.</returns>
Task<ConstraintFailure?> CheckReadTagAsync( Task<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
string tagAddress, string tagAddress,
@@ -21,7 +20,6 @@ public interface IConstraintEnforcer
/// <param name="serverHandle">The MXAccess server handle.</param> /// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param> /// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param> /// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that resolves to the constraint failure, or null if the check passes.</returns>
Task<ConstraintFailure?> CheckReadHandleAsync( Task<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
GatewaySession session, GatewaySession session,
@@ -35,7 +33,6 @@ public interface IConstraintEnforcer
/// <param name="serverHandle">The MXAccess server handle.</param> /// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param> /// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param> /// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that resolves to the constraint failure, or null if the check passes.</returns>
Task<ConstraintFailure?> CheckWriteHandleAsync( Task<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
GatewaySession session, GatewaySession session,
@@ -49,7 +46,6 @@ public interface IConstraintEnforcer
/// <param name="target">The target of the denied command.</param> /// <param name="target">The target of the denied command.</param>
/// <param name="failure">The constraint failure details.</param> /// <param name="failure">The constraint failure details.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param> /// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task RecordDenialAsync( Task RecordDenialAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
string commandKind, string commandKind,
@@ -10,6 +10,5 @@ public interface IGatewayRequestIdentityAccessor
/// <summary>Temporarily pushes an identity onto the scope stack, returning a handle to restore the previous state.</summary> /// <summary>Temporarily pushes an identity onto the scope stack, returning a handle to restore the previous state.</summary>
/// <param name="identity">API key identity to push.</param> /// <param name="identity">API key identity to push.</param>
/// <returns>A disposable scope that restores the previous identity when disposed.</returns>
IDisposable Push(ApiKeyIdentity identity); IDisposable Push(ApiKeyIdentity identity);
} }
@@ -16,8 +16,6 @@ public static class KestrelTlsInspector
/// <c>Certificate:Thumbprint</c>), meaning the gateway must supply a /// <c>Certificate:Thumbprint</c>), meaning the gateway must supply a
/// generated fallback certificate. /// generated fallback certificate.
/// </summary> /// </summary>
/// <param name="configuration">Application configuration containing the Kestrel endpoint settings.</param>
/// <returns><see langword="true"/> if a generated certificate is required; otherwise <see langword="false"/>.</returns>
public static bool RequiresGeneratedCertificate(IConfiguration configuration) public static bool RequiresGeneratedCertificate(IConfiguration configuration)
{ {
// A Kestrel default certificate applies to every endpoint that lacks its own. // A Kestrel default certificate applies to every endpoint that lacks its own.
@@ -20,10 +20,6 @@ public sealed class SelfSignedCertificateProvider
private readonly ILogger<SelfSignedCertificateProvider> _logger; private readonly ILogger<SelfSignedCertificateProvider> _logger;
private readonly TimeProvider _timeProvider; private readonly TimeProvider _timeProvider;
/// <summary>Initializes a new instance of the <see cref="SelfSignedCertificateProvider"/> class.</summary>
/// <param name="options">TLS configuration options controlling certificate subject, validity, and storage.</param>
/// <param name="logger">Logger for certificate lifecycle events.</param>
/// <param name="timeProvider">Time provider used to compute certificate validity windows.</param>
public SelfSignedCertificateProvider( public SelfSignedCertificateProvider(
TlsOptions options, TlsOptions options,
ILogger<SelfSignedCertificateProvider> logger, ILogger<SelfSignedCertificateProvider> logger,
@@ -35,7 +31,6 @@ public sealed class SelfSignedCertificateProvider
} }
/// <summary>Creates a fresh in-memory ECDSA P-256 self-signed certificate.</summary> /// <summary>Creates a fresh in-memory ECDSA P-256 self-signed certificate.</summary>
/// <returns>A new self-signed <see cref="X509Certificate2"/> with the configured SANs and validity period.</returns>
public X509Certificate2 GenerateCertificate() public X509Certificate2 GenerateCertificate()
{ {
using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256); using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
@@ -94,7 +89,6 @@ public sealed class SelfSignedCertificateProvider
/// <summary>Loads the persisted certificate, regenerating when missing, /// <summary>Loads the persisted certificate, regenerating when missing,
/// expired (and allowed), or unreadable.</summary> /// expired (and allowed), or unreadable.</summary>
/// <returns>The loaded or newly generated <see cref="X509Certificate2"/>.</returns>
public X509Certificate2 LoadOrCreate() public X509Certificate2 LoadOrCreate()
{ {
string path = _options.SelfSignedCertPath; string path = _options.SelfSignedCertPath;
@@ -370,7 +370,6 @@ public sealed class GatewaySession
/// Determines whether the session lease has expired. /// Determines whether the session lease has expired.
/// </summary> /// </summary>
/// <param name="now">Current timestamp for comparison.</param> /// <param name="now">Current timestamp for comparison.</param>
/// <returns><c>true</c> if the lease has expired; otherwise <c>false</c>.</returns>
public bool IsLeaseExpired(DateTimeOffset now) public bool IsLeaseExpired(DateTimeOffset now)
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -385,7 +384,6 @@ public sealed class GatewaySession
/// Attaches an event subscriber and returns a disposable lease. /// Attaches an event subscriber and returns a disposable lease.
/// </summary> /// </summary>
/// <param name="allowMultipleSubscribers">If true, allows multiple concurrent event subscribers.</param> /// <param name="allowMultipleSubscribers">If true, allows multiple concurrent event subscribers.</param>
/// <returns>A disposable that releases the subscriber slot when disposed.</returns>
public IDisposable AttachEventSubscriber(bool allowMultipleSubscribers) public IDisposable AttachEventSubscriber(bool allowMultipleSubscribers)
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -414,7 +412,6 @@ public sealed class GatewaySession
/// </summary> /// </summary>
/// <param name="command">Worker command to invoke.</param> /// <param name="command">Worker command to invoke.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the worker command reply.</returns>
public async Task<WorkerCommandReply> InvokeAsync( public async Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command, WorkerCommand command,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -429,7 +426,6 @@ public sealed class GatewaySession
/// <param name="serverHandle">The MXAccess server handle.</param> /// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param> /// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="registration">The item registration if found.</param> /// <param name="registration">The item registration if found.</param>
/// <returns><c>true</c> if the item registration was found; otherwise <c>false</c>.</returns>
public bool TryGetItemRegistration( public bool TryGetItemRegistration(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -491,7 +487,6 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param> /// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="tagAddresses">Tag addresses to add.</param> /// <param name="tagAddresses">Tag addresses to add.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the per-item subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> AddItemBulkAsync( public Task<IReadOnlyList<SubscribeResult>> AddItemBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<string> tagAddresses, IReadOnlyList<string> tagAddresses,
@@ -517,7 +512,6 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param> /// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="itemHandles">Item handles to advise.</param> /// <param name="itemHandles">Item handles to advise.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the per-item subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> AdviseItemBulkAsync( public Task<IReadOnlyList<SubscribeResult>> AdviseItemBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<int> itemHandles, IReadOnlyList<int> itemHandles,
@@ -543,7 +537,6 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param> /// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="itemHandles">Item handles to remove.</param> /// <param name="itemHandles">Item handles to remove.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the per-item subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> RemoveItemBulkAsync( public Task<IReadOnlyList<SubscribeResult>> RemoveItemBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<int> itemHandles, IReadOnlyList<int> itemHandles,
@@ -569,7 +562,6 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param> /// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="itemHandles">Item handles to un-advise.</param> /// <param name="itemHandles">Item handles to un-advise.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the per-item subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> UnAdviseItemBulkAsync( public Task<IReadOnlyList<SubscribeResult>> UnAdviseItemBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<int> itemHandles, IReadOnlyList<int> itemHandles,
@@ -595,7 +587,6 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param> /// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="tagAddresses">Tag addresses to subscribe to.</param> /// <param name="tagAddresses">Tag addresses to subscribe to.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the per-item subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync( public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<string> tagAddresses, IReadOnlyList<string> tagAddresses,
@@ -621,7 +612,6 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param> /// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="itemHandles">Item handles to unsubscribe from.</param> /// <param name="itemHandles">Item handles to unsubscribe from.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the per-item subscribe results.</returns>
public Task<IReadOnlyList<SubscribeResult>> UnsubscribeBulkAsync( public Task<IReadOnlyList<SubscribeResult>> UnsubscribeBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<int> itemHandles, IReadOnlyList<int> itemHandles,
@@ -645,7 +635,6 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param> /// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param> /// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the per-item write results.</returns>
public Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync( public Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<WriteBulkEntry> entries, IReadOnlyList<WriteBulkEntry> entries,
@@ -669,7 +658,6 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param> /// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param> /// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the per-item write results.</returns>
public Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync( public Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<Write2BulkEntry> entries, IReadOnlyList<Write2BulkEntry> entries,
@@ -693,7 +681,6 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param> /// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param> /// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the per-item write results.</returns>
public Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync( public Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<WriteSecuredBulkEntry> entries, IReadOnlyList<WriteSecuredBulkEntry> entries,
@@ -717,7 +704,6 @@ public sealed class GatewaySession
/// <param name="serverHandle">Server handle returned by the worker.</param> /// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param> /// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the per-item write results.</returns>
public Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync( public Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<WriteSecured2BulkEntry> entries, IReadOnlyList<WriteSecured2BulkEntry> entries,
@@ -745,7 +731,6 @@ public sealed class GatewaySession
/// <param name="tagAddresses">Tag addresses to read.</param> /// <param name="tagAddresses">Tag addresses to read.</param>
/// <param name="timeout">Timeout for the read operation.</param> /// <param name="timeout">Timeout for the read operation.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the per-item read results.</returns>
public Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync( public Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<string> tagAddresses, IReadOnlyList<string> tagAddresses,
@@ -774,7 +759,6 @@ public sealed class GatewaySession
/// Reads events from the worker as an asynchronous enumerable stream. /// Reads events from the worker as an asynchronous enumerable stream.
/// </summary> /// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>An async enumerable of worker events.</returns>
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(CancellationToken cancellationToken) public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(CancellationToken cancellationToken)
{ {
IWorkerClient workerClient = GetReadyWorkerClient(); IWorkerClient workerClient = GetReadyWorkerClient();
@@ -788,7 +772,6 @@ public sealed class GatewaySession
/// </summary> /// </summary>
/// <param name="reason">Reason for closing the session.</param> /// <param name="reason">Reason for closing the session.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the session close result.</returns>
/// <remarks> /// <remarks>
/// Concurrent close attempts are serialized by <c>_closeLock</c> so only one close /// Concurrent close attempts are serialized by <c>_closeLock</c> so only one close
/// runs at a time, but every read/write of <c>_state</c> still passes through /// runs at a time, but every read/write of <c>_state</c> still passes through
@@ -936,7 +919,6 @@ public sealed class GatewaySession
/// <summary> /// <summary>
/// Disposes the session and frees associated resources. /// Disposes the session and frees associated resources.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <remarks> /// <remarks>
/// Acquires <c>_closeLock</c> once before disposing so an in-flight /// Acquires <c>_closeLock</c> once before disposing so an in-flight
/// <see cref="CloseAsync"/> finishes before the semaphore is released and /// <see cref="CloseAsync"/> finishes before the semaphore is released and
@@ -72,6 +72,5 @@ public interface ISessionManager
/// <summary>Shuts down all sessions and the session manager.</summary> /// <summary>Shuts down all sessions and the session manager.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task ShutdownAsync(CancellationToken cancellationToken); Task ShutdownAsync(CancellationToken cancellationToken);
} }
@@ -53,7 +53,13 @@ public sealed class SessionManager : ISessionManager
_sessionSlots = new SemaphoreSlim(_options.Sessions.MaxSessions, _options.Sessions.MaxSessions); _sessionSlots = new SemaphoreSlim(_options.Sessions.MaxSessions, _options.Sessions.MaxSessions);
} }
/// <inheritdoc /> /// <summary>
/// Opens a new gateway session and connects to the worker.
/// </summary>
/// <param name="request">Session open request.</param>
/// <param name="clientIdentity">Client authentication identity.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Opened gateway session.</returns>
public async Task<GatewaySession> OpenSessionAsync( public async Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request, SessionOpenRequest request,
string? clientIdentity, string? clientIdentity,
@@ -117,7 +123,12 @@ public sealed class SessionManager : ISessionManager
} }
} }
/// <inheritdoc /> /// <summary>
/// Attempts to retrieve a session by ID.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="session">The session if found.</param>
/// <returns>True if session found; otherwise false.</returns>
public bool TryGetSession( public bool TryGetSession(
string sessionId, string sessionId,
[MaybeNullWhen(false)] out GatewaySession session) [MaybeNullWhen(false)] out GatewaySession session)
@@ -125,7 +136,13 @@ public sealed class SessionManager : ISessionManager
return _registry.TryGet(sessionId, out session); return _registry.TryGet(sessionId, out session);
} }
/// <inheritdoc /> /// <summary>
/// Invokes a worker command on a session asynchronously.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="command">Worker command.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Command reply.</returns>
public async Task<WorkerCommandReply> InvokeAsync( public async Task<WorkerCommandReply> InvokeAsync(
string sessionId, string sessionId,
WorkerCommand command, WorkerCommand command,
@@ -152,7 +169,12 @@ public sealed class SessionManager : ISessionManager
} }
} }
/// <inheritdoc /> /// <summary>
/// Reads events from a session's event stream asynchronously.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of worker events.</returns>
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync( public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId, string sessionId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -162,7 +184,12 @@ public sealed class SessionManager : ISessionManager
return session.ReadEventsAsync(cancellationToken); return session.ReadEventsAsync(cancellationToken);
} }
/// <inheritdoc /> /// <summary>
/// Closes a gateway session asynchronously.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Session close result.</returns>
public async Task<SessionCloseResult> CloseSessionAsync( public async Task<SessionCloseResult> CloseSessionAsync(
string sessionId, string sessionId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -176,7 +203,16 @@ public sealed class SessionManager : ISessionManager
return result; return result;
} }
/// <inheritdoc /> /// <summary>
/// Forcefully terminates a session's worker without attempting graceful shutdown.
/// Mirrors the registry/metrics cleanup that <see cref="CloseSessionCoreAsync"/>
/// performs after a successful close, but skips the <c>WorkerClient.ShutdownAsync</c>
/// step that <see cref="GatewaySession.CloseAsync"/> would otherwise attempt.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="reason">Reason recorded for the kill.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Session close result.</returns>
public async Task<SessionCloseResult> KillWorkerAsync( public async Task<SessionCloseResult> KillWorkerAsync(
string sessionId, string sessionId,
string reason, string reason,
@@ -227,7 +263,12 @@ public sealed class SessionManager : ISessionManager
return new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: wasClosed); return new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: wasClosed);
} }
/// <inheritdoc /> /// <summary>
/// Closes all sessions with expired leases asynchronously.
/// </summary>
/// <param name="now">Current time for lease expiration check.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Count of sessions closed.</returns>
public async Task<int> CloseExpiredLeasesAsync( public async Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now, DateTimeOffset now,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -247,7 +288,11 @@ public sealed class SessionManager : ISessionManager
return closedCount; return closedCount;
} }
/// <inheritdoc /> /// <summary>
/// Shuts down all active sessions gracefully asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
public async Task ShutdownAsync(CancellationToken cancellationToken) public async Task ShutdownAsync(CancellationToken cancellationToken)
{ {
foreach (GatewaySession session in _registry.Snapshot()) foreach (GatewaySession session in _registry.Snapshot())
@@ -11,7 +11,6 @@ public sealed record SessionOpenRequest(
{ {
/// <summary>Creates a SessionOpenRequest from a gRPC OpenSessionRequest contract.</summary> /// <summary>Creates a SessionOpenRequest from a gRPC OpenSessionRequest contract.</summary>
/// <param name="request">Request payload.</param> /// <param name="request">Request payload.</param>
/// <returns>A new <see cref="SessionOpenRequest"/> populated from the contract fields.</returns>
public static SessionOpenRequest FromContract(OpenSessionRequest request) public static SessionOpenRequest FromContract(OpenSessionRequest request)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
@@ -11,13 +11,20 @@ public sealed class SessionRegistry : ISessionRegistry
{ {
private readonly ConcurrentDictionary<string, GatewaySession> _sessions = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, GatewaySession> _sessions = new(StringComparer.Ordinal);
/// <inheritdoc /> /// <summary>
/// Gets the total count of sessions in the registry.
/// </summary>
public int Count => _sessions.Count; public int Count => _sessions.Count;
/// <inheritdoc /> /// <summary>
/// Gets the count of non-closed sessions.
/// </summary>
public int ActiveCount => _sessions.Values.Count(session => session.State is not SessionState.Closed); public int ActiveCount => _sessions.Values.Count(session => session.State is not SessionState.Closed);
/// <inheritdoc /> /// <summary>
/// Adds a session to the registry.
/// </summary>
/// <param name="session">Gateway session to add.</param>
public bool TryAdd(GatewaySession session) public bool TryAdd(GatewaySession session)
{ {
ArgumentNullException.ThrowIfNull(session); ArgumentNullException.ThrowIfNull(session);
@@ -25,7 +32,11 @@ public sealed class SessionRegistry : ISessionRegistry
return _sessions.TryAdd(session.SessionId, session); return _sessions.TryAdd(session.SessionId, session);
} }
/// <inheritdoc /> /// <summary>
/// Retrieves a session by identifier.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="session">The retrieved session if found.</param>
public bool TryGet( public bool TryGet(
string sessionId, string sessionId,
[MaybeNullWhen(false)] out GatewaySession session) [MaybeNullWhen(false)] out GatewaySession session)
@@ -33,7 +44,11 @@ public sealed class SessionRegistry : ISessionRegistry
return _sessions.TryGetValue(sessionId, out session); return _sessions.TryGetValue(sessionId, out session);
} }
/// <inheritdoc /> /// <summary>
/// Removes a session from the registry by identifier.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="session">The removed session if found.</param>
public bool TryRemove( public bool TryRemove(
string sessionId, string sessionId,
[MaybeNullWhen(false)] out GatewaySession session) [MaybeNullWhen(false)] out GatewaySession session)
@@ -41,7 +56,9 @@ public sealed class SessionRegistry : ISessionRegistry
return _sessions.TryRemove(sessionId, out session); return _sessions.TryRemove(sessionId, out session);
} }
/// <inheritdoc /> /// <summary>
/// Returns a snapshot of all sessions in the registry.
/// </summary>
public IReadOnlyCollection<GatewaySession> Snapshot() public IReadOnlyCollection<GatewaySession> Snapshot()
{ {
return _sessions.Values.ToArray(); return _sessions.Values.ToArray();
@@ -8,17 +8,13 @@ public sealed class SessionShutdownHostedService(
ISessionManager sessionManager, ISessionManager sessionManager,
ILogger<SessionShutdownHostedService> logger) : IHostedService ILogger<SessionShutdownHostedService> logger) : IHostedService
{ {
/// <summary>No-op start handler; this service only acts on shutdown.</summary> /// <inheritdoc />
/// <param name="cancellationToken">Cancellation token (unused).</param>
/// <returns>A completed task.</returns>
public Task StartAsync(CancellationToken cancellationToken) public Task StartAsync(CancellationToken cancellationToken)
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <summary>Shuts down all active gateway sessions gracefully.</summary> /// <inheritdoc />
/// <param name="cancellationToken">Token signaled when the host shutdown deadline is reached.</param>
/// <returns>A task that represents the asynchronous shutdown operation.</returns>
public async Task StopAsync(CancellationToken cancellationToken) public async Task StopAsync(CancellationToken cancellationToken)
{ {
try try
@@ -39,7 +39,10 @@ public sealed class SessionWorkerClientFactory : ISessionWorkerClientFactory
_options = options.Value; _options = options.Value;
} }
/// <inheritdoc /> /// <summary>Creates a worker client and launches the worker process.</summary>
/// <param name="session">The gateway session.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created worker client.</returns>
public async Task<IWorkerClient> CreateAsync( public async Task<IWorkerClient> CreateAsync(
GatewaySession session, GatewaySession session,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -19,14 +19,12 @@ public interface IWorkerClient : IAsyncDisposable
/// <summary>Initiates the handshake and enters ready state.</summary> /// <summary>Initiates the handshake and enters ready state.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task StartAsync(CancellationToken cancellationToken); Task StartAsync(CancellationToken cancellationToken);
/// <summary>Sends a command to the worker and waits for a reply.</summary> /// <summary>Sends a command to the worker and waits for a reply.</summary>
/// <param name="command">Worker command to invoke.</param> /// <param name="command">Worker command to invoke.</param>
/// <param name="timeout">Timeout for waiting for the reply.</param> /// <param name="timeout">Timeout for waiting for the reply.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that resolves to the worker command reply.</returns>
Task<WorkerCommandReply> InvokeAsync( Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command, WorkerCommand command,
TimeSpan timeout, TimeSpan timeout,
@@ -34,13 +32,11 @@ public interface IWorkerClient : IAsyncDisposable
/// <summary>Reads events from the worker as they arrive.</summary> /// <summary>Reads events from the worker as they arrive.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>An async sequence of worker events.</returns>
IAsyncEnumerable<WorkerEvent> ReadEventsAsync(CancellationToken cancellationToken); IAsyncEnumerable<WorkerEvent> ReadEventsAsync(CancellationToken cancellationToken);
/// <summary>Gracefully shuts down the worker by closing the connection.</summary> /// <summary>Gracefully shuts down the worker by closing the connection.</summary>
/// <param name="timeout">Timeout for shutdown.</param> /// <param name="timeout">Timeout for shutdown.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken); Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken);
/// <summary>Terminates the worker process immediately with a diagnostic reason.</summary> /// <summary>Terminates the worker process immediately with a diagnostic reason.</summary>
@@ -24,7 +24,6 @@ public interface IWorkerProcess : IDisposable
/// Waits for the process to exit with the specified cancellation token. /// Waits for the process to exit with the specified cancellation token.
/// </summary> /// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A value task that completes when the process exits.</returns>
ValueTask WaitForExitAsync(CancellationToken cancellationToken); ValueTask WaitForExitAsync(CancellationToken cancellationToken);
/// <summary> /// <summary>
@@ -8,9 +8,7 @@ public sealed class OrphanWorkerCleanupHostedService(
OrphanWorkerTerminator terminator, OrphanWorkerTerminator terminator,
ILogger<OrphanWorkerCleanupHostedService> logger) : IHostedService ILogger<OrphanWorkerCleanupHostedService> logger) : IHostedService
{ {
/// <summary>Terminates any orphaned MXAccess worker processes found at startup.</summary> /// <inheritdoc />
/// <param name="cancellationToken">Token to observe for cancellation (unused; cleanup is best-effort).</param>
/// <returns>A completed task.</returns>
public Task StartAsync(CancellationToken cancellationToken) public Task StartAsync(CancellationToken cancellationToken)
{ {
try try
@@ -27,8 +25,6 @@ public sealed class OrphanWorkerCleanupHostedService(
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <summary>Performs no action on stop; all cleanup runs at startup.</summary> /// <inheritdoc />
/// <param name="cancellationToken">Token to observe for cancellation (unused).</param>
/// <returns>A completed task.</returns>
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
} }
@@ -28,7 +28,7 @@ internal sealed class SystemWorkerProcess(Process process) : IWorkerProcess
process.Kill(entireProcessTree); process.Kill(entireProcessTree);
} }
/// <summary>Releases the underlying process resources.</summary> /// <inheritdoc />
public void Dispose() public void Dispose()
{ {
process.Dispose(); process.Dispose();
@@ -78,10 +78,10 @@ public sealed class WorkerClient : IWorkerClient
_lastHeartbeatAt = _timeProvider.GetUtcNow(); _lastHeartbeatAt = _timeProvider.GetUtcNow();
} }
/// <inheritdoc /> /// <summary>Gets the worker's session ID.</summary>
public string SessionId => _connection.SessionId; public string SessionId => _connection.SessionId;
/// <inheritdoc /> /// <summary>Gets the worker process ID.</summary>
public int? ProcessId public int? ProcessId
{ {
get get
@@ -93,7 +93,7 @@ public sealed class WorkerClient : IWorkerClient
} }
} }
/// <inheritdoc /> /// <summary>Gets the current client state.</summary>
public WorkerClientState State public WorkerClientState State
{ {
get get
@@ -105,7 +105,7 @@ public sealed class WorkerClient : IWorkerClient
} }
} }
/// <inheritdoc /> /// <summary>Gets the timestamp of the last received heartbeat.</summary>
public DateTimeOffset LastHeartbeatAt public DateTimeOffset LastHeartbeatAt
{ {
get get
@@ -117,7 +117,8 @@ public sealed class WorkerClient : IWorkerClient
} }
} }
/// <inheritdoc /> /// <summary>Starts the worker client and completes the handshake.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task StartAsync(CancellationToken cancellationToken) public async Task StartAsync(CancellationToken cancellationToken)
{ {
ThrowIfDisposed(); ThrowIfDisposed();
@@ -140,7 +141,11 @@ public sealed class WorkerClient : IWorkerClient
_heartbeatLoopTask = Task.Run(HeartbeatLoopAsync); _heartbeatLoopTask = Task.Run(HeartbeatLoopAsync);
} }
/// <inheritdoc /> /// <summary>Invokes a command on the worker and waits for reply.</summary>
/// <param name="command">The command to invoke.</param>
/// <param name="timeout">Command timeout.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The command reply.</returns>
public async Task<WorkerCommandReply> InvokeAsync( public async Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command, WorkerCommand command,
TimeSpan timeout, TimeSpan timeout,
@@ -223,7 +228,9 @@ public sealed class WorkerClient : IWorkerClient
} }
} }
/// <inheritdoc /> /// <summary>Reads events from the worker as an async stream.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of worker events.</returns>
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync( public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken) [EnumeratorCancellation] CancellationToken cancellationToken)
{ {
@@ -235,7 +242,9 @@ public sealed class WorkerClient : IWorkerClient
} }
} }
/// <inheritdoc /> /// <summary>Shuts down the worker gracefully.</summary>
/// <param name="timeout">Shutdown timeout.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
{ {
ThrowIfDisposed(); ThrowIfDisposed();
@@ -280,7 +289,8 @@ public sealed class WorkerClient : IWorkerClient
} }
} }
/// <inheritdoc /> /// <summary>Terminates the worker process immediately.</summary>
/// <param name="reason">Reason for termination.</param>
public void Kill(string reason) public void Kill(string reason)
{ {
ThrowIfDisposed(); ThrowIfDisposed();
@@ -292,7 +302,6 @@ public sealed class WorkerClient : IWorkerClient
} }
/// <summary>Disposes the worker client and releases resources.</summary> /// <summary>Disposes the worker client and releases resources.</summary>
/// <returns>A task that represents the asynchronous dispose operation.</returns>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (_disposed) if (_disposed)
@@ -30,7 +30,6 @@ public sealed class WorkerFrameWriter
/// </summary> /// </summary>
/// <param name="envelope">Worker envelope message to write.</param> /// <param name="envelope">Worker envelope message to write.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param> /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A value task that represents the asynchronous operation.</returns>
public async ValueTask WriteAsync( public async ValueTask WriteAsync(
WorkerEnvelope envelope, WorkerEnvelope envelope,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)

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