docs+ui: backfill XML doc comments and finish dashboard layout pass

Adds missing <summary>/<param> XML docs across 99 server, worker, and test
files so CommentChecker reports zero issues (TreatWarningsAsErrors needs the
analyzer clean). Bundles in WIP dashboard work: NavSection extraction,
MainLayout/site.css/js styling alignment, and DashboardOptions/Auth tweaks.
This commit is contained in:
Joseph Doherty
2026-05-27 14:20:10 -04:00
parent 382861c602
commit 615b487a77
110 changed files with 1473 additions and 192 deletions
+2
View File
@@ -46,6 +46,7 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid.
"Dashboard": { "Dashboard": {
"Enabled": true, "Enabled": true,
"AllowAnonymousLocalhost": true, "AllowAnonymousLocalhost": true,
"RequireHttpsCookie": true,
"SnapshotIntervalMilliseconds": 1000, "SnapshotIntervalMilliseconds": 1000,
"RecentFaultLimit": 100, "RecentFaultLimit": 100,
"RecentSessionLimit": 200, "RecentSessionLimit": 200,
@@ -146,6 +147,7 @@ the affected stream while the MXAccess session remains active.
|--------|---------|-------------| |--------|---------|-------------|
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. | | `MxGateway:Dashboard: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: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. |
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.IntegrationTests;
[Trait("Category", "LiveLdap")] [Trait("Category", "LiveLdap")]
public sealed class DashboardLdapLiveTests public sealed class DashboardLdapLiveTests
{ {
/// <summary>Verifies that an admin user in the GwAdmin group authenticates successfully.</summary>
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds() public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds()
{ {
@@ -38,6 +39,7 @@ public sealed class DashboardLdapLiveTests
&& claim.Value == DashboardRoles.Admin); && claim.Value == DashboardRoles.Admin);
} }
/// <summary>Verifies that a readonly user without GwAdmin group fails to authenticate.</summary>
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails() public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails()
{ {
@@ -53,6 +55,7 @@ public sealed class DashboardLdapLiveTests
Assert.DoesNotContain("readonly123", result.FailureMessage, StringComparison.Ordinal); Assert.DoesNotContain("readonly123", result.FailureMessage, StringComparison.Ordinal);
} }
/// <summary>Verifies that authentication with wrong password fails without leaking the password.</summary>
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword() public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
{ {
@@ -71,6 +74,7 @@ public sealed class DashboardLdapLiveTests
Assert.DoesNotContain(wrongPassword, result.FailureMessage, StringComparison.Ordinal); Assert.DoesNotContain(wrongPassword, result.FailureMessage, StringComparison.Ordinal);
} }
/// <summary>Verifies that authentication with unknown username fails.</summary>
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_UnknownUsername_Fails() public async Task AuthenticateAsync_UnknownUsername_Fails()
{ {
@@ -87,6 +91,7 @@ public sealed class DashboardLdapLiveTests
Assert.Null(result.Principal); Assert.Null(result.Principal);
} }
/// <summary>Verifies that authentication fails gracefully when the server is unreachable.</summary>
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing() public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
{ {
@@ -4,6 +4,7 @@ public sealed class LiveLdapFactAttribute : FactAttribute
{ {
public const string EnableVariableName = "MXGATEWAY_RUN_LIVE_LDAP_TESTS"; public const string EnableVariableName = "MXGATEWAY_RUN_LIVE_LDAP_TESTS";
/// <summary>Initializes a live LDAP test fact that skips if LDAP tests are not enabled.</summary>
public LiveLdapFactAttribute() public LiveLdapFactAttribute()
{ {
if (!Enabled) if (!Enabled)
@@ -12,5 +13,6 @@ public sealed class LiveLdapFactAttribute : FactAttribute
} }
} }
/// <summary>Gets a value indicating whether live LDAP tests are enabled.</summary>
public static bool Enabled => IntegrationTestEnvironment.IsEnabled(EnableVariableName); public static bool Enabled => IntegrationTestEnvironment.IsEnabled(EnableVariableName);
} }
@@ -1112,6 +1112,8 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// transitions it to Faulted, which the public gRPC API only exposes indirectly via /// transitions it to Faulted, which the public gRPC API only exposes indirectly via
/// CloseSession's reply (and not before a graceful close completes). /// CloseSession's reply (and not before a graceful close completes).
/// </summary> /// </summary>
/// <param name="sessionId">The session identifier.</param>
/// <param name="session">The session if found; otherwise null.</param>
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);
@@ -1534,6 +1536,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
private readonly StringBuilder buffer = new(); private readonly StringBuilder buffer = new();
private readonly object syncRoot = new(); private readonly object syncRoot = new();
/// <summary>Gets the accumulated output buffer contents.</summary>
public string Captured public string Captured
{ {
get get
@@ -1545,6 +1548,8 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
} }
} }
/// <summary>Writes a line of text to the buffer and inner helper.</summary>
/// <param name="message">The message to write.</param>
public void WriteLine(string message) public void WriteLine(string message)
{ {
lock (syncRoot) lock (syncRoot)
@@ -1555,6 +1560,9 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
inner.WriteLine(message); inner.WriteLine(message);
} }
/// <summary>Writes a formatted line of text to the buffer and inner helper.</summary>
/// <param name="format">The message format string.</param>
/// <param name="args">The format arguments.</param>
public void WriteLine(string format, params object[] args) public void WriteLine(string format, params object[] args)
{ {
string formatted = string.Format(System.Globalization.CultureInfo.InvariantCulture, format, args); string formatted = string.Format(System.Globalization.CultureInfo.InvariantCulture, format, args);
@@ -1569,11 +1577,13 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer
{ {
/// <inheritdoc />
public Task<ConstraintFailure?> CheckReadTagAsync( public Task<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
string tagAddress, string tagAddress,
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null); CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
/// <inheritdoc />
public Task<ConstraintFailure?> CheckReadHandleAsync( public Task<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
GatewaySession session, GatewaySession session,
@@ -1581,6 +1591,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
int itemHandle, int itemHandle,
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null); CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
/// <inheritdoc />
public Task<ConstraintFailure?> CheckWriteHandleAsync( public Task<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
GatewaySession session, GatewaySession session,
@@ -1588,6 +1599,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
int itemHandle, int itemHandle,
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null); CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
/// <inheritdoc />
public Task RecordDenialAsync( public Task RecordDenialAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
string commandKind, string commandKind,
@@ -683,8 +683,11 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
private sealed class Subscriber(Channel<AlarmFeedMessage> channel, string prefix) private sealed class Subscriber(Channel<AlarmFeedMessage> channel, string prefix)
{ {
/// <summary>Gets the channel for publishing alarm messages to this subscriber.</summary>
public Channel<AlarmFeedMessage> Channel { get; } = channel; public Channel<AlarmFeedMessage> Channel { get; } = channel;
/// <summary>Determines whether the alarm reference matches this subscriber's filter.</summary>
/// <param name="reference">The alarm reference to match.</param>
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);
@@ -8,6 +8,19 @@ public sealed class DashboardOptions
/// <summary>Gets whether anonymous localhost access to dashboard is allowed.</summary> /// <summary>Gets whether anonymous localhost access to dashboard is allowed.</summary>
public bool AllowAnonymousLocalhost { get; init; } = true; public bool AllowAnonymousLocalhost { get; init; } = true;
/// <summary>
/// When true (default), the dashboard auth cookie is restricted to HTTPS
/// requests via <see cref="Microsoft.AspNetCore.Http.CookieSecurePolicy.Always"/>.
/// Set to false for plain-HTTP dev deployments — the cookie then uses
/// <see cref="Microsoft.AspNetCore.Http.CookieSecurePolicy.SameAsRequest"/>,
/// which still marks it Secure on any HTTPS request but allows it to
/// round-trip over HTTP. Browsers silently drop Secure cookies set over
/// plain HTTP from non-localhost hosts, so leaving this true breaks
/// dashboard login from a remote browser unless the dashboard is served
/// over HTTPS.
/// </summary>
public bool RequireHttpsCookie { get; init; } = true;
/// <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,6 +9,7 @@ public sealed class GatewayOptions
/// </summary> /// </summary>
public AuthenticationOptions Authentication { get; init; } = new(); public AuthenticationOptions Authentication { get; init; } = new();
/// <summary>Gets LDAP configuration options.</summary>
public LdapOptions Ldap { get; init; } = new(); public LdapOptions Ldap { get; init; } = new();
/// <summary> /// <summary>
@@ -2,25 +2,36 @@ namespace ZB.MOM.WW.MxGateway.Server.Configuration;
public sealed class LdapOptions public sealed class LdapOptions
{ {
/// <summary>Gets a value indicating whether LDAP authentication is enabled.</summary>
public bool Enabled { get; init; } = true; public bool Enabled { get; init; } = true;
/// <summary>Gets the LDAP server address.</summary>
public string Server { get; init; } = "localhost"; public string Server { get; init; } = "localhost";
/// <summary>Gets the LDAP server port.</summary>
public int Port { get; init; } = 3893; public int Port { get; init; } = 3893;
/// <summary>Gets a value indicating whether TLS is required for the connection.</summary>
public bool UseTls { get; init; } public bool UseTls { get; init; }
/// <summary>Gets a value indicating whether insecure LDAP connections are allowed.</summary>
public bool AllowInsecureLdap { get; init; } = true; public bool AllowInsecureLdap { get; init; } = true;
/// <summary>Gets the LDAP search base distinguished name.</summary>
public string SearchBase { get; init; } = "dc=lmxopcua,dc=local"; public string SearchBase { get; init; } = "dc=lmxopcua,dc=local";
/// <summary>Gets the service account distinguished name.</summary>
public string ServiceAccountDn { get; init; } = "cn=serviceaccount,dc=lmxopcua,dc=local"; public string ServiceAccountDn { get; init; } = "cn=serviceaccount,dc=lmxopcua,dc=local";
/// <summary>Gets the service account password.</summary>
public string ServiceAccountPassword { get; init; } = "serviceaccount123"; public string ServiceAccountPassword { get; init; } = "serviceaccount123";
/// <summary>Gets the LDAP attribute name for user names.</summary>
public string UserNameAttribute { get; init; } = "cn"; public string UserNameAttribute { get; init; } = "cn";
/// <summary>Gets the LDAP attribute name for display names.</summary>
public string DisplayNameAttribute { get; init; } = "cn"; public string DisplayNameAttribute { get; init; } = "cn";
/// <summary>Gets the LDAP attribute name for group membership.</summary>
public string GroupAttribute { get; init; } = "memberOf"; public string GroupAttribute { get; init; } = "memberOf";
} }
@@ -12,5 +12,6 @@ public sealed class ProtocolOptions
/// </summary> /// </summary>
public uint WorkerProtocolVersion { get; init; } = GatewayContractInfo.WorkerProtocolVersion; public uint WorkerProtocolVersion { get; init; } = GatewayContractInfo.WorkerProtocolVersion;
/// <summary>Gets or sets the maximum gRPC message size in bytes.</summary>
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024; public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
} }
@@ -17,8 +17,10 @@ public sealed class SessionOptions
/// </summary> /// </summary>
public int MaxPendingCommandsPerSession { get; init; } = 128; public int MaxPendingCommandsPerSession { get; init; } = 128;
/// <summary>Gets the default session lease duration in seconds.</summary>
public int DefaultLeaseSeconds { get; init; } = 1800; public int DefaultLeaseSeconds { get; init; } = 1800;
/// <summary>Gets the interval for sweeping expired session leases in seconds.</summary>
public int LeaseSweepIntervalSeconds { get; init; } = 30; public int LeaseSweepIntervalSeconds { get; init; } = 30;
/// <summary> /// <summary>
@@ -12,6 +12,7 @@
<body class="dashboard-body"> <body class="dashboard-body">
<Routes @rendermode="InteractiveServer" /> <Routes @rendermode="InteractiveServer" />
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script> <script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="/js/nav-state.js"></script>
<script src="/_framework/blazor.web.js"></script> <script src="/_framework/blazor.web.js"></script>
</body> </body>
</html> </html>
@@ -1,83 +1,210 @@
@using System.Linq
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.JSInterop
@implements IDisposable
@inherits LayoutComponentBase @inherits LayoutComponentBase
@inject NavigationManager Navigation
@inject IJSRuntime JS
<header class="app-bar"> <div class="d-flex flex-column flex-lg-row" style="min-height: 100vh;">
<a class="brand" href="/"><span class="mark">&#9646;</span> MXAccess Gateway</a> @* Hamburger toggle: visible only on viewports <lg. Bootstrap collapse JS
<span class="crumb">&rsaquo;</span> lives in bootstrap.bundle.min.js (loaded in App.razor). *@
<span class="crumb">gateway dashboard</span> <button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
<span class="spacer"></span> type="button"
<AuthorizeView> data-bs-toggle="collapse"
<Authorized Context="authState"> data-bs-target="#sidebar-collapse"
<span class="meta">@authState.User.Identity?.Name</span> aria-controls="sidebar-collapse"
<span class="conn-pill" data-state="connected"> aria-expanded="false"
<span class="dot"></span><span>signed in</span> aria-label="Toggle navigation">
</span> &#9776;
</Authorized> </button>
<NotAuthorized>
<span class="conn-pill" data-state="disconnected">
<span class="dot"></span><span>signed out</span>
</span>
</NotAuthorized>
</AuthorizeView>
</header>
@* Hamburger toggle: visible only on viewports <lg. Bootstrap collapse JS lives
in bootstrap.bundle.min.js (loaded in App.razor). The app-bar above stays
full-width; the hamburger only collapses the side rail. *@
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
type="button"
data-bs-toggle="collapse"
data-bs-target="#sidebar-collapse"
aria-controls="sidebar-collapse"
aria-expanded="false"
aria-label="Toggle navigation">
&#9776;
</button>
<div class="app-shell">
<div class="collapse d-lg-block" id="sidebar-collapse"> <div class="collapse d-lg-block" id="sidebar-collapse">
<nav class="side-rail"> <nav class="sidebar d-flex flex-column">
<div class="rail-eyebrow">Overview</div> <a class="brand" href="/"><span class="mark">&#9646;</span> MXAccess Gateway</a>
<NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
<div class="rail-eyebrow">Runtime</div> <div style="overflow-y:auto; flex:1 1 auto; min-height:0;">
<NavLink class="rail-link" href="/sessions" Match="NavLinkMatch.Prefix">Sessions</NavLink> <ul class="nav flex-column">
<NavLink class="rail-link" href="/workers" Match="NavLinkMatch.Prefix">Workers</NavLink> <li class="nav-item">
<NavLink class="rail-link" href="/events" Match="NavLinkMatch.Prefix">Events</NavLink> <NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
<NavLink class="rail-link" href="/alarms" Match="NavLinkMatch.Prefix">Alarms</NavLink> </li>
<div class="rail-eyebrow">Galaxy</div> <NavSection Title="Runtime"
<NavLink class="rail-link" href="/galaxy" Match="NavLinkMatch.Prefix">Repository</NavLink> Expanded="@_expanded.Contains("runtime")"
<NavLink class="rail-link" href="/browse" Match="NavLinkMatch.Prefix">Browse</NavLink> OnToggle="@(() => ToggleAsync("runtime"))">
<li class="nav-item">
<NavLink class="nav-link" href="/sessions" Match="NavLinkMatch.Prefix">Sessions</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/workers" Match="NavLinkMatch.Prefix">Workers</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/events" Match="NavLinkMatch.Prefix">Events</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/alarms" Match="NavLinkMatch.Prefix">Alarms</NavLink>
</li>
</NavSection>
<div class="rail-eyebrow">Admin</div> <NavSection Title="Galaxy"
<NavLink class="rail-link" href="/apikeys" Match="NavLinkMatch.Prefix">API keys</NavLink> Expanded="@_expanded.Contains("galaxy")"
<NavLink class="rail-link" href="/settings" Match="NavLinkMatch.Prefix">Settings</NavLink> OnToggle="@(() => ToggleAsync("galaxy"))">
<li class="nav-item">
<NavLink class="nav-link" href="/galaxy" Match="NavLinkMatch.Prefix">Repository</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/browse" Match="NavLinkMatch.Prefix">Browse</NavLink>
</li>
</NavSection>
<div class="rail-foot"> <NavSection Title="Admin"
<AuthorizeView> Expanded="@_expanded.Contains("admin")"
<Authorized Context="authState"> OnToggle="@(() => ToggleAsync("admin"))">
<div class="rail-eyebrow">Session</div> <li class="nav-item">
<div class="rail-user">@authState.User.Identity?.Name</div> <NavLink class="nav-link" href="/apikeys" Match="NavLinkMatch.Prefix">API Keys</NavLink>
<div class="rail-roles"> </li>
@string.Join(", ", authState.User.Claims <li class="nav-item">
.Where(c => c.Type == System.Security.Claims.ClaimTypes.Role) <NavLink class="nav-link" href="/settings" Match="NavLinkMatch.Prefix">Settings</NavLink>
.Select(c => c.Value)) </li>
</div> </NavSection>
<form method="post" action="/logout"> </ul>
<AntiforgeryToken />
<button class="rail-btn" type="submit">Sign out</button>
</form>
</Authorized>
<NotAuthorized>
<div class="rail-eyebrow">Session</div>
<a class="rail-btn" href="/login">Sign in</a>
</NotAuthorized>
</AuthorizeView>
</div> </div>
<AuthorizeView>
<Authorized Context="authState">
<div class="border-top px-3 py-2">
<div class="d-flex justify-content-between align-items-center">
<span class="text-body-secondary small">@authState.User.Identity?.Name</span>
<form method="post" action="/logout" data-enhance="false">
<AntiforgeryToken />
<button type="submit" class="btn btn-outline-secondary btn-sm py-0 px-2">Sign Out</button>
</form>
</div>
</div>
</Authorized>
<NotAuthorized>
<div class="border-top px-3 py-2">
<a href="/login" class="btn btn-outline-secondary btn-sm py-0 px-2 w-100">Sign In</a>
</div>
</NotAuthorized>
</AuthorizeView>
</nav> </nav>
</div> </div>
<main class="page"> <main class="page flex-grow-1">
@Body @Body
</main> </main>
</div> </div>
@code {
// Sections whose collapsed/expanded state we persist. Acts as the allow-list
// when parsing the cookie so stale or attacker-supplied ids are ignored.
private static readonly string[] SectionIds = { "runtime", "galaxy", "admin" };
// The currently-expanded sections. Populated from the cookie on first
// render; mutated by ToggleAsync and by navigating into a section.
private readonly HashSet<string> _expanded = new(StringComparer.Ordinal);
protected override void OnInitialized()
{
Navigation.LocationChanged += OnLocationChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
// Hydrate from the cookie. Until this completes the sidebar paints
// collapsed, matching the CentralUI behaviour.
string saved;
try
{
saved = await JS.InvokeAsync<string>("navState.get") ?? string.Empty;
}
catch (JSDisconnectedException)
{
return;
}
foreach (var id in saved.Split(
',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (Array.IndexOf(SectionIds, id) >= 0)
{
_expanded.Add(id);
}
}
// The section of the page we loaded on is always expanded.
if (EnsureCurrentSectionExpanded())
{
await PersistAsync();
}
StateHasChanged();
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
if (EnsureCurrentSectionExpanded())
{
_ = PersistAsync();
_ = InvokeAsync(StateHasChanged);
}
}
private async Task ToggleAsync(string id)
{
if (!_expanded.Remove(id))
{
_expanded.Add(id);
}
await PersistAsync();
}
// Adds the current page's section to _expanded; returns true if it changed.
private bool EnsureCurrentSectionExpanded()
{
var section = CurrentSection();
return section is not null && _expanded.Add(section);
}
// Maps the current URL's first path segment to a section id, or null for
// sectionless pages (Dashboard, Login).
private string? CurrentSection()
{
var relative = Navigation.ToBaseRelativePath(Navigation.Uri);
var firstSegment = relative.Split('?', '#')[0]
.Split('/', StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault();
return firstSegment switch
{
"sessions" or "workers" or "events" or "alarms" => "runtime",
"galaxy" or "browse" => "galaxy",
"apikeys" or "settings" => "admin",
_ => null,
};
}
private async Task PersistAsync()
{
try
{
await JS.InvokeVoidAsync("navState.set", string.Join(',', _expanded));
}
catch (JSDisconnectedException)
{
// The circuit is gone — nothing to persist to.
}
}
public void Dispose()
{
Navigation.LocationChanged -= OnLocationChanged;
}
}
@@ -0,0 +1,35 @@
@* A collapsible sidebar nav section. The header is a full-width button that
toggles ChildContent visibility. Pattern lifted from ScadaLink CentralUI
(Components/Layout/NavSection.razor) — see [[project-deployed-service]]. *@
<li class="nav-item">
<button type="button"
class="nav-section-toggle"
@onclick="OnToggle"
aria-expanded="@(Expanded ? "true" : "false")">
<span class="chevron" aria-hidden="true">@(Expanded ? "▾" : "▸")</span>
<span>@Title</span>
</button>
</li>
@if (Expanded)
{
@ChildContent
}
@code {
/// <summary>Section label shown in the header (e.g. "Runtime").</summary>
[Parameter, EditorRequired]
public string Title { get; set; } = string.Empty;
/// <summary>Whether the section is expanded — its items rendered.</summary>
[Parameter]
public bool Expanded { get; set; }
/// <summary>Raised when the header button is clicked.</summary>
[Parameter]
public EventCallback OnToggle { get; set; }
/// <summary>The section's nav items, rendered only while expanded.</summary>
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
@@ -4,6 +4,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class DashboardApiKeyAuthorization public sealed class DashboardApiKeyAuthorization
{ {
/// <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)
{ {
if (user.Identity?.IsAuthenticated != true) if (user.Identity?.IsAuthenticated != true)
@@ -5,11 +5,18 @@ public sealed record DashboardApiKeyManagementResult(
string Message, string Message,
string? ApiKey) string? ApiKey)
{ {
/// <summary>Creates a successful result with an optional API key.</summary>
/// <param name="message">The success message.</param>
/// <param name="apiKey">The API key if generated or modified.</param>
/// <returns>A successful result record.</returns>
public static DashboardApiKeyManagementResult Success(string message, string? apiKey = null) public static DashboardApiKeyManagementResult Success(string message, string? apiKey = null)
{ {
return new DashboardApiKeyManagementResult(true, message, apiKey); return new DashboardApiKeyManagementResult(true, message, apiKey);
} }
/// <summary>Creates a failed result with an error message.</summary>
/// <param name="message">The error message.</param>
/// <returns>A failed result record.</returns>
public static DashboardApiKeyManagementResult Fail(string message) public static DashboardApiKeyManagementResult Fail(string message)
{ {
return new DashboardApiKeyManagementResult(false, message, null); return new DashboardApiKeyManagementResult(false, message, null);
@@ -14,11 +14,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.";
/// <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);
} }
/// <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,
@@ -67,6 +73,10 @@ public sealed class DashboardApiKeyManagementService(
} }
} }
/// <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,
@@ -100,6 +110,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.");
} }
/// <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,
@@ -143,6 +157,10 @@ public sealed class DashboardApiKeyManagementService(
} }
} }
/// <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,
@@ -35,5 +35,5 @@ public static class DashboardAuthenticationDefaults
public const string LdapGroupClaimType = "mxgateway:ldap_group"; public const string LdapGroupClaimType = "mxgateway:ldap_group";
public const string KeyPrefixClaimType = "mxgateway:key_prefix"; public const string KeyPrefixClaimType = "mxgateway:key_prefix";
public const string CookieName = "__Host-MxGatewayDashboard"; public const string CookieName = "MxGatewayDashboard";
} }
@@ -117,6 +117,8 @@ public sealed class DashboardAuthenticator(
} }
} }
/// <summary>Escapes special characters in LDAP filter strings.</summary>
/// <param name="value">The string value to escape.</param>
internal static string EscapeLdapFilter(string value) internal static string EscapeLdapFilter(string value)
{ {
StringBuilder builder = new(value.Length); StringBuilder builder = new(value.Length);
@@ -141,6 +143,8 @@ public sealed class DashboardAuthenticator(
/// multiple roles; Admin and Viewer are the only legal values. Returns /// multiple roles; Admin and Viewer are the only legal values. Returns
/// an empty list when no group matches (caller rejects the login). /// an empty list when no group matches (caller rejects the login).
/// </summary> /// </summary>
/// <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>
internal static IReadOnlyList<string> MapGroupsToRoles( internal static IReadOnlyList<string> MapGroupsToRoles(
IEnumerable<string> groups, IEnumerable<string> groups,
IReadOnlyDictionary<string, string> groupToRole) IReadOnlyDictionary<string, string> groupToRole)
@@ -171,6 +175,8 @@ public sealed class DashboardAuthenticator(
return [.. roles]; return [.. roles];
} }
/// <summary>Extracts the first RDN value from a distinguished name.</summary>
/// <param name="distinguishedName">The LDAP distinguished name.</param>
internal static string ExtractFirstRdnValue(string distinguishedName) internal static string ExtractFirstRdnValue(string distinguishedName)
{ {
int equalsIndex = distinguishedName.IndexOf('='); int equalsIndex = distinguishedName.IndexOf('=');
@@ -8,11 +8,14 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// </summary> /// </summary>
public sealed class DashboardAuthorizationRequirement(IReadOnlyList<string> requiredRoles) : IAuthorizationRequirement public sealed class DashboardAuthorizationRequirement(IReadOnlyList<string> requiredRoles) : IAuthorizationRequirement
{ {
/// <summary>Gets the list of roles required to satisfy this requirement.</summary>
public IReadOnlyList<string> RequiredRoles { get; } = requiredRoles; public IReadOnlyList<string> RequiredRoles { get; } = requiredRoles;
/// <summary>Gets a requirement satisfied by any dashboard role (admin or viewer).</summary>
public static DashboardAuthorizationRequirement AnyDashboardRole { get; } = public static DashboardAuthorizationRequirement AnyDashboardRole { get; } =
new([DashboardRoles.Admin, DashboardRoles.Viewer]); new([DashboardRoles.Admin, DashboardRoles.Viewer]);
/// <summary>Gets a requirement satisfied only by the admin role.</summary>
public static DashboardAuthorizationRequirement AdminOnly { get; } = public static DashboardAuthorizationRequirement AdminOnly { get; } =
new([DashboardRoles.Admin]); new([DashboardRoles.Admin]);
} }
@@ -4,6 +4,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public static class DashboardConnectionStringDisplay public static class DashboardConnectionStringDisplay
{ {
/// <summary>Returns a sanitized Galaxy Repository connection string for display.</summary>
/// <param name="connectionString">The connection string to sanitize.</param>
public static string GalaxyRepositoryConnectionString(string connectionString) public static string GalaxyRepositoryConnectionString(string connectionString)
{ {
try try
@@ -191,11 +191,22 @@ public static class DashboardEndpointRouteBuilderExtensions
</section> </section>
"""; """;
return RenderPage("Dashboard Sign In", body); return RenderPage("Dashboard Sign In", heading: null, body);
} }
private static string RenderPage(string title, string body) private static string RenderPage(string title, string body)
=> RenderPage(title, heading: title, body);
private static string RenderPage(string title, string? heading, string body)
{ {
string headingHtml = string.IsNullOrEmpty(heading)
? string.Empty
: $"""
<div class="dashboard-page-header">
<h1>{HtmlEncoder.Default.Encode(heading)}</h1>
</div>
""";
return $""" return $"""
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
@@ -212,9 +223,7 @@ public static class DashboardEndpointRouteBuilderExtensions
<span class="brand"><span class="mark">&#9646;</span> MXAccess Gateway</span> <span class="brand"><span class="mark">&#9646;</span> MXAccess Gateway</span>
</header> </header>
<main class="page"> <main class="page">
<div class="dashboard-page-header"> {headingHtml}
<h1>{HtmlEncoder.Default.Encode(title)}</h1>
</div>
{body} {body}
</main> </main>
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script> <script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
@@ -5,6 +5,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>Projects the precomputed Galaxy cache dashboard summary.</summary> /// <summary>Projects the precomputed Galaxy cache dashboard summary.</summary>
internal static class DashboardGalaxyProjector internal static class DashboardGalaxyProjector
{ {
/// <summary>Projects the cache entry to a dashboard Galaxy summary.</summary>
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry) public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
{ {
return entry.DashboardSummary; return entry.DashboardSummary;
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard; namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -39,8 +41,10 @@ public static class DashboardServiceCollectionExtensions
{ {
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName; cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
cookieOptions.Cookie.HttpOnly = true; cookieOptions.Cookie.HttpOnly = true;
cookieOptions.Cookie.SecurePolicy = CookieSecurePolicy.Always;
cookieOptions.Cookie.SameSite = SameSiteMode.Strict; cookieOptions.Cookie.SameSite = SameSiteMode.Strict;
// SecurePolicy is bound via PostConfigure below so it can honour
// DashboardOptions.RequireHttpsCookie (default Always; dev HTTP
// deployments set RequireHttpsCookie=false to use SameAsRequest).
cookieOptions.Cookie.Path = "/"; cookieOptions.Cookie.Path = "/";
cookieOptions.LoginPath = "/login"; cookieOptions.LoginPath = "/login";
cookieOptions.LogoutPath = "/logout"; cookieOptions.LogoutPath = "/logout";
@@ -52,6 +56,14 @@ public static class DashboardServiceCollectionExtensions
DashboardAuthenticationDefaults.HubAuthenticationScheme, DashboardAuthenticationDefaults.HubAuthenticationScheme,
_ => { }); _ => { });
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
.Configure<IOptions<GatewayOptions>>((cookieOptions, gatewayOptions) =>
{
cookieOptions.Cookie.SecurePolicy = gatewayOptions.Value.Dashboard.RequireHttpsCookie
? CookieSecurePolicy.Always
: CookieSecurePolicy.SameAsRequest;
});
services.AddAuthorization(authorization => services.AddAuthorization(authorization =>
{ {
authorization.AddPolicy( authorization.AddPolicy(
@@ -4,11 +4,15 @@ public sealed record DashboardSessionAdminResult(
bool Succeeded, bool Succeeded,
string Message) string Message)
{ {
/// <summary>Creates a successful result with the given message.</summary>
/// <param name="message">The result message.</param>
public static DashboardSessionAdminResult Success(string message) public static DashboardSessionAdminResult Success(string message)
{ {
return new DashboardSessionAdminResult(true, message); return new DashboardSessionAdminResult(true, message);
} }
/// <summary>Creates a failed result with the given message.</summary>
/// <param name="message">The result message.</param>
public static DashboardSessionAdminResult Fail(string message) public static DashboardSessionAdminResult Fail(string message)
{ {
return new DashboardSessionAdminResult(false, message); return new DashboardSessionAdminResult(false, message);
@@ -35,8 +35,10 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
/// <param name="metrics">Gateway metrics collector.</param> /// <param name="metrics">Gateway metrics collector.</param>
/// <param name="configurationProvider">Gateway configuration provider.</param> /// <param name="configurationProvider">Gateway configuration provider.</param>
/// <param name="galaxyHierarchyCache">Galaxy hierarchy cache.</param> /// <param name="galaxyHierarchyCache">Galaxy hierarchy cache.</param>
/// <param name="apiKeyAdminStore">API key administration store.</param>
/// <param name="options">Gateway configuration options.</param> /// <param name="options">Gateway configuration options.</param>
/// <param name="timeProvider">Provider for current time; defaults to system time.</param> /// <param name="timeProvider">Provider for current time; defaults to system time.</param>
/// <param name="logger">Optional logger for the dashboard snapshot service.</param>
public DashboardSnapshotService( public DashboardSnapshotService(
ISessionRegistry sessionRegistry, ISessionRegistry sessionRegistry,
GatewayMetrics metrics, GatewayMetrics metrics,
@@ -14,6 +14,11 @@ public sealed class HubTokenAuthenticationHandler : AuthenticationHandler<Authen
{ {
private readonly HubTokenService _tokens; private readonly HubTokenService _tokens;
/// <summary>Initializes a new hub token authentication handler.</summary>
/// <param name="options">The options monitor for the authentication scheme.</param>
/// <param name="logger">The logger factory for logging authentication events.</param>
/// <param name="encoder">The URL encoder for encoding authentication values.</param>
/// <param name="tokens">The hub token service for validating tokens.</param>
public HubTokenAuthenticationHandler( public HubTokenAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options, IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, ILoggerFactory logger,
@@ -24,6 +29,7 @@ public sealed class HubTokenAuthenticationHandler : AuthenticationHandler<Authen
_tokens = tokens; _tokens = tokens;
} }
/// <inheritdoc />
protected override Task<AuthenticateResult> HandleAuthenticateAsync() protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{ {
string? token = ExtractToken(); string? token = ExtractToken();
@@ -30,13 +30,16 @@ public sealed class HubTokenService
private readonly ITimeLimitedDataProtector _protector; private readonly ITimeLimitedDataProtector _protector;
/// <summary>Initializes a new instance of the HubTokenService with a data protection provider.</summary>
/// <param name="dataProtection">The data protection provider for token encryption.</param>
public HubTokenService(IDataProtectionProvider dataProtection) public HubTokenService(IDataProtectionProvider dataProtection)
{ {
ArgumentNullException.ThrowIfNull(dataProtection); ArgumentNullException.ThrowIfNull(dataProtection);
_protector = dataProtection.CreateProtector(ProtectorPurpose).ToTimeLimitedDataProtector(); _protector = dataProtection.CreateProtector(ProtectorPurpose).ToTimeLimitedDataProtector();
} }
/// <summary>Issue 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>
public string Issue(ClaimsPrincipal user) public string Issue(ClaimsPrincipal user)
{ {
ArgumentNullException.ThrowIfNull(user); ArgumentNullException.ThrowIfNull(user);
@@ -47,7 +50,8 @@ public sealed class HubTokenService
return _protector.Protect(JsonSerializer.Serialize(payload), TokenLifetime); return _protector.Protect(JsonSerializer.Serialize(payload), TokenLifetime);
} }
/// <summary>Validate a token and return the equivalent <see cref="ClaimsPrincipal"/>; 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>
public ClaimsPrincipal? Validate(string? token) public ClaimsPrincipal? Validate(string? token)
{ {
if (string.IsNullOrEmpty(token)) if (string.IsNullOrEmpty(token))
@@ -17,6 +17,7 @@ public sealed class AlarmsHub : Hub
/// <summary>Method name used to push <c>AlarmFeedMessage</c> values to clients.</summary> /// <summary>Method name used to push <c>AlarmFeedMessage</c> values to clients.</summary>
public const string AlarmMessage = "AlarmFeed"; public const string AlarmMessage = "AlarmFeed";
/// <inheritdoc />
public override async Task OnConnectedAsync() public override async Task OnConnectedAsync()
{ {
await Groups.AddToGroupAsync(Context.ConnectionId, AllAlarmsGroup).ConfigureAwait(false); await Groups.AddToGroupAsync(Context.ConnectionId, AllAlarmsGroup).ConfigureAwait(false);
@@ -16,6 +16,7 @@ public sealed class AlarmsHubPublisher(
IHubContext<AlarmsHub> hubContext, IHubContext<AlarmsHub> hubContext,
ILogger<AlarmsHubPublisher> logger) : BackgroundService ILogger<AlarmsHubPublisher> logger) : BackgroundService
{ {
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
// Loop forever — when StreamAsync completes (monitor restart, etc.) // Loop forever — when StreamAsync completes (monitor restart, etc.)
@@ -14,6 +14,9 @@ public sealed class DashboardEventBroadcaster(
IHubContext<EventsHub> hubContext, IHubContext<EventsHub> hubContext,
ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster
{ {
/// <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)
@@ -16,6 +16,9 @@ public sealed class DashboardHubConnectionFactory(
HubTokenService tokens, HubTokenService tokens,
AuthenticationStateProvider authState) AuthenticationStateProvider authState)
{ {
/// <summary>Creates a new hub connection to the specified hub path.</summary>
/// <param name="hubPath">The relative hub path (e.g., "/hubs/snapshot").</param>
/// <returns>A configured hub connection with automatic reconnection and token authentication.</returns>
public HubConnection Create(string hubPath) public HubConnection Create(string hubPath)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(hubPath); ArgumentException.ThrowIfNullOrWhiteSpace(hubPath);
@@ -15,6 +15,7 @@ public sealed class DashboardSnapshotHub(IDashboardSnapshotService snapshotServi
/// <summary>Method name used to push snapshot updates to clients.</summary> /// <summary>Method name used to push snapshot updates to clients.</summary>
public const string SnapshotMessage = "SnapshotUpdated"; public const string SnapshotMessage = "SnapshotUpdated";
/// <inheritdoc />
public override async Task OnConnectedAsync() public override async Task OnConnectedAsync()
{ {
await Clients.Caller.SendAsync(SnapshotMessage, snapshotService.GetSnapshot()).ConfigureAwait(false); await Clients.Caller.SendAsync(SnapshotMessage, snapshotService.GetSnapshot()).ConfigureAwait(false);
@@ -26,6 +26,10 @@ public sealed class DashboardSnapshotPublisher : BackgroundService
private readonly ILogger<DashboardSnapshotPublisher> _logger; private readonly ILogger<DashboardSnapshotPublisher> _logger;
private readonly TimeSpan _reconnectDelay; private readonly TimeSpan _reconnectDelay;
/// <summary>Initializes a new instance of the DashboardSnapshotPublisher class.</summary>
/// <param name="snapshotService">The snapshot service to subscribe to.</param>
/// <param name="hubContext">The SignalR hub context for broadcasting.</param>
/// <param name="logger">The logger instance.</param>
public DashboardSnapshotPublisher( public DashboardSnapshotPublisher(
IDashboardSnapshotService snapshotService, IDashboardSnapshotService snapshotService,
IHubContext<DashboardSnapshotHub> hubContext, IHubContext<DashboardSnapshotHub> hubContext,
@@ -34,9 +38,12 @@ public sealed class DashboardSnapshotPublisher : BackgroundService
{ {
} }
// Internal hook for the Server-042 regression test: tests inject a /// <summary>Initializes a new instance of the DashboardSnapshotPublisher class with custom reconnect delay.</summary>
// very short reconnect delay so the assertion doesn't wait the full /// <remarks>Internal hook for testing: tests inject a very short reconnect delay so assertions don't wait full 5s.</remarks>
// 5s. Production wiring always uses the 5s default via the public ctor. /// <param name="snapshotService">The snapshot service to subscribe to.</param>
/// <param name="hubContext">The SignalR hub context for broadcasting.</param>
/// <param name="logger">The logger instance.</param>
/// <param name="reconnectDelay">The delay before reconnecting after a subscription failure.</param>
internal DashboardSnapshotPublisher( internal DashboardSnapshotPublisher(
IDashboardSnapshotService snapshotService, IDashboardSnapshotService snapshotService,
IHubContext<DashboardSnapshotHub> hubContext, IHubContext<DashboardSnapshotHub> hubContext,
@@ -49,6 +56,7 @@ public sealed class DashboardSnapshotPublisher : BackgroundService
_reconnectDelay = reconnectDelay; _reconnectDelay = reconnectDelay;
} }
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
// Loop forever — when WatchSnapshotsAsync completes or throws, reopen // Loop forever — when WatchSnapshotsAsync completes or throws, reopen
@@ -21,6 +21,9 @@ public sealed class EventsHub : Hub
/// <summary>Method name used to push individual <c>MxEvent</c> values to clients.</summary> /// <summary>Method name used to push individual <c>MxEvent</c> values to clients.</summary>
public const string EventMessage = "MxEvent"; public const string EventMessage = "MxEvent";
/// <summary>Computes the SignalR group name for a given session id.</summary>
/// <param name="sessionId">The session id.</param>
/// <returns>The group name for the session.</returns>
public static string GroupName(string sessionId) => $"session:{sessionId}"; public static string GroupName(string sessionId) => $"session:{sessionId}";
/// <summary> /// <summary>
@@ -56,6 +59,9 @@ public sealed class EventsHub : Hub
return Groups.AddToGroupAsync(Context.ConnectionId, GroupName(sessionId)); return Groups.AddToGroupAsync(Context.ConnectionId, GroupName(sessionId));
} }
/// <summary>Unsubscribes the calling SignalR connection from the per-session events group.</summary>
/// <param name="sessionId">Session id to unsubscribe the caller from.</param>
/// <returns>A task representing the unsubscription operation.</returns>
public Task UnsubscribeSession(string sessionId) public Task UnsubscribeSession(string sessionId)
{ {
if (string.IsNullOrWhiteSpace(sessionId)) if (string.IsNullOrWhiteSpace(sessionId))
@@ -10,5 +10,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// </summary> /// </summary>
public interface IDashboardEventBroadcaster public interface IDashboardEventBroadcaster
{ {
/// <summary>Publishes an MxEvent to all dashboard clients subscribed to the session.</summary>
/// <param name="sessionId">The session identifier.</param>
/// <param name="mxEvent">The MxEvent to publish.</param>
void Publish(string sessionId, MxEvent mxEvent); void Publish(string sessionId, MxEvent mxEvent);
} }
@@ -4,23 +4,46 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public interface IDashboardApiKeyManagementService public interface IDashboardApiKeyManagementService
{ {
/// <summary>Determines whether the user can manage API keys.</summary>
/// <param name="user">The user principal.</param>
/// <returns>True if the user can manage keys; otherwise false.</returns>
bool CanManage(ClaimsPrincipal user); bool CanManage(ClaimsPrincipal user);
/// <summary>Creates a new API key.</summary>
/// <param name="user">The user principal.</param>
/// <param name="request">The key creation request details.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The creation result.</returns>
Task<DashboardApiKeyManagementResult> CreateAsync( Task<DashboardApiKeyManagementResult> CreateAsync(
ClaimsPrincipal user, ClaimsPrincipal user,
DashboardApiKeyManagementRequest request, DashboardApiKeyManagementRequest request,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary>Revokes an existing API key.</summary>
/// <param name="user">The user principal.</param>
/// <param name="keyId">The key identifier to revoke.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The revocation result.</returns>
Task<DashboardApiKeyManagementResult> RevokeAsync( Task<DashboardApiKeyManagementResult> RevokeAsync(
ClaimsPrincipal user, ClaimsPrincipal user,
string keyId, string keyId,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary>Rotates an existing API key.</summary>
/// <param name="user">The user principal.</param>
/// <param name="keyId">The key identifier to rotate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The rotation result.</returns>
Task<DashboardApiKeyManagementResult> RotateAsync( Task<DashboardApiKeyManagementResult> RotateAsync(
ClaimsPrincipal user, ClaimsPrincipal user,
string keyId, string keyId,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary>Deletes an API key.</summary>
/// <param name="user">The user principal.</param>
/// <param name="keyId">The key identifier to delete.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The deletion result.</returns>
Task<DashboardApiKeyManagementResult> DeleteAsync( Task<DashboardApiKeyManagementResult> DeleteAsync(
ClaimsPrincipal user, ClaimsPrincipal user,
string keyId, string keyId,
@@ -6,9 +6,10 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public interface IDashboardAuthenticator public interface IDashboardAuthenticator
{ {
/// <summary> /// <summary>
/// Authenticates the dashboard session with an API key. /// Authenticates the dashboard session with credentials.
/// </summary> /// </summary>
/// <param name="apiKey">The API key to authenticate.</param> /// <param name="username">Username 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>
Task<DashboardAuthenticationResult> AuthenticateAsync( Task<DashboardAuthenticationResult> AuthenticateAsync(
string? username, string? username,
@@ -43,6 +43,9 @@ public static class GalaxyGlobMatcher
/// </summary> /// </summary>
internal static int CurrentCacheSize => RegexCache.Count; internal static int CurrentCacheSize => RegexCache.Count;
/// <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="glob">The glob pattern with * and ? wildcards.</param>
public static bool IsMatch(string value, string glob) public static bool IsMatch(string value, string glob)
{ {
if (string.IsNullOrWhiteSpace(glob)) if (string.IsNullOrWhiteSpace(glob))
@@ -14,17 +14,24 @@ public sealed class GalaxyHierarchyIndex
TagsByAddress = tagsByAddress; TagsByAddress = tagsByAddress;
} }
/// <summary>Gets an empty Galaxy hierarchy index.</summary>
public static GalaxyHierarchyIndex Empty { get; } = new( public static GalaxyHierarchyIndex Empty { get; } = new(
Array.Empty<GalaxyObjectView>(), Array.Empty<GalaxyObjectView>(),
new Dictionary<int, GalaxyObjectView>(), new Dictionary<int, GalaxyObjectView>(),
new Dictionary<string, GalaxyTagLookup>(StringComparer.OrdinalIgnoreCase)); new Dictionary<string, GalaxyTagLookup>(StringComparer.OrdinalIgnoreCase));
/// <summary>Gets the object views.</summary>
public IReadOnlyList<GalaxyObjectView> ObjectViews { get; } public IReadOnlyList<GalaxyObjectView> ObjectViews { get; }
/// <summary>Gets the object views indexed by GUID.</summary>
public IReadOnlyDictionary<int, GalaxyObjectView> ObjectViewsById { get; } public IReadOnlyDictionary<int, GalaxyObjectView> ObjectViewsById { get; }
/// <summary>Gets tags indexed by address.</summary>
public IReadOnlyDictionary<string, GalaxyTagLookup> TagsByAddress { get; } public IReadOnlyDictionary<string, GalaxyTagLookup> TagsByAddress { get; }
/// <summary>Builds a Galaxy hierarchy index from the given objects.</summary>
/// <param name="objects">The Galaxy objects to index.</param>
/// <returns>A new Galaxy hierarchy index.</returns>
public static GalaxyHierarchyIndex Build(IReadOnlyList<GalaxyObject> objects) public static GalaxyHierarchyIndex Build(IReadOnlyList<GalaxyObject> objects)
{ {
if (objects.Count == 0) if (objects.Count == 0)
@@ -21,6 +21,10 @@ public static class GalaxyHierarchyProjector
/// </summary> /// </summary>
private static readonly ConditionalWeakTable<GalaxyHierarchyCacheEntry, ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>>> FilteredViewCache = new(); private static readonly ConditionalWeakTable<GalaxyHierarchyCacheEntry, ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>>> FilteredViewCache = new();
/// <summary>Projects a discovery request against a cache entry and returns all matching objects.</summary>
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
/// <param name="request">The discovery hierarchy request.</param>
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
public static GalaxyHierarchyQueryResult Project( public static GalaxyHierarchyQueryResult Project(
GalaxyHierarchyCacheEntry entry, GalaxyHierarchyCacheEntry entry,
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
@@ -34,6 +38,12 @@ public static class GalaxyHierarchyProjector
pageSize: int.MaxValue); pageSize: int.MaxValue);
} }
/// <summary>Projects a discovery request with paging against a cache entry and returns a page of matching objects.</summary>
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
/// <param name="request">The discovery hierarchy request.</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="pageSize">The maximum number of results to return.</param>
public static GalaxyHierarchyQueryResult Project( public static GalaxyHierarchyQueryResult Project(
GalaxyHierarchyCacheEntry entry, GalaxyHierarchyCacheEntry entry,
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
@@ -118,6 +128,9 @@ public static class GalaxyHierarchyProjector
(Views: views, Root: root, MaxDepth: maxDepth, BrowseSubtreeGlobs: browseSubtreeGlobs, Request: request)); (Views: views, Root: root, MaxDepth: maxDepth, BrowseSubtreeGlobs: browseSubtreeGlobs, Request: request));
} }
/// <summary>Finds an object in the hierarchy by its tag address.</summary>
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
/// <param name="tagAddress">The tag address to search for.</param>
public static GalaxyObject? FindObjectForTag( public static GalaxyObject? FindObjectForTag(
GalaxyHierarchyCacheEntry entry, GalaxyHierarchyCacheEntry entry,
string tagAddress) string tagAddress)
@@ -132,6 +145,9 @@ public static class GalaxyHierarchyProjector
: null; : null;
} }
/// <summary>Finds an attribute in the hierarchy by its tag address.</summary>
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
/// <param name="tagAddress">The tag address to search for.</param>
public static GalaxyAttribute? FindAttributeForTag( public static GalaxyAttribute? FindAttributeForTag(
GalaxyHierarchyCacheEntry entry, GalaxyHierarchyCacheEntry entry,
string tagAddress) string tagAddress)
@@ -146,6 +162,9 @@ public static class GalaxyHierarchyProjector
: null; : null;
} }
/// <summary>Gets the contained path for an object by its gobject ID.</summary>
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
/// <param name="gobjectId">The Galaxy object ID.</param>
public static string GetContainedPath( public static string GetContainedPath(
GalaxyHierarchyCacheEntry entry, GalaxyHierarchyCacheEntry entry,
int gobjectId) int gobjectId)
@@ -260,6 +279,9 @@ public static class GalaxyHierarchyProjector
return clone; return clone;
} }
/// <summary>Computes a stable filter signature for memoization purposes.</summary>
/// <param name="request">The discovery hierarchy request.</param>
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
public static string ComputeFilterSignature( public static string ComputeFilterSignature(
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
IReadOnlyList<string>? browseSubtreeGlobs) IReadOnlyList<string>? browseSubtreeGlobs)
@@ -690,9 +690,13 @@ public sealed class MxAccessGatewayService(
bool HasAllowedItems) bool HasAllowedItems)
{ {
/// <summary>Builds a reply containing only the denied entries (used when no items survived filtering).</summary> /// <summary>Builds a reply containing only the denied entries (used when no items survived filtering).</summary>
/// <param name="request">The original command request.</param>
/// <returns>A reply with denied results only.</returns>
public abstract MxCommandReply CreateDeniedReply(MxCommandRequest request); public abstract MxCommandReply CreateDeniedReply(MxCommandRequest request);
/// <summary>Splices denied entries back into the worker's allowed-only reply in original-index order.</summary> /// <summary>Splices denied entries back into the worker's allowed-only reply in original-index order.</summary>
/// <param name="reply">The worker's reply containing only allowed results.</param>
/// <returns>A merged reply in the original order.</returns>
public abstract MxCommandReply MergeDeniedInto(MxCommandReply reply); public abstract MxCommandReply MergeDeniedInto(MxCommandReply reply);
} }
@@ -703,6 +707,7 @@ public sealed class MxAccessGatewayService(
bool HasAllowedItems) bool HasAllowedItems)
: BulkConstraintPlan(Command, OriginalCount, HasAllowedItems) : BulkConstraintPlan(Command, OriginalCount, HasAllowedItems)
{ {
/// <inheritdoc />
public override MxCommandReply CreateDeniedReply(MxCommandRequest request) public override MxCommandReply CreateDeniedReply(MxCommandRequest request)
{ {
MxCommandReply reply = new() MxCommandReply reply = new()
@@ -716,6 +721,7 @@ public sealed class MxAccessGatewayService(
return reply; return reply;
} }
/// <inheritdoc />
public override MxCommandReply MergeDeniedInto(MxCommandReply reply) public override MxCommandReply MergeDeniedInto(MxCommandReply reply)
{ {
BulkSubscribeReply allowed = GetPayload(reply) ?? new BulkSubscribeReply(); BulkSubscribeReply allowed = GetPayload(reply) ?? new BulkSubscribeReply();
@@ -774,6 +780,7 @@ public sealed class MxAccessGatewayService(
bool HasAllowedItems) bool HasAllowedItems)
: BulkConstraintPlan(Command, OriginalCount, HasAllowedItems) : BulkConstraintPlan(Command, OriginalCount, HasAllowedItems)
{ {
/// <inheritdoc />
public override MxCommandReply CreateDeniedReply(MxCommandRequest request) public override MxCommandReply CreateDeniedReply(MxCommandRequest request)
{ {
MxCommandReply reply = new() MxCommandReply reply = new()
@@ -787,6 +794,7 @@ public sealed class MxAccessGatewayService(
return reply; return reply;
} }
/// <inheritdoc />
public override MxCommandReply MergeDeniedInto(MxCommandReply reply) public override MxCommandReply MergeDeniedInto(MxCommandReply reply)
{ {
BulkWriteReply allowed = GetPayload(reply) ?? new BulkWriteReply(); BulkWriteReply allowed = GetPayload(reply) ?? new BulkWriteReply();
@@ -849,6 +857,7 @@ public sealed class MxAccessGatewayService(
bool HasAllowedItems) bool HasAllowedItems)
: BulkConstraintPlan(Command, OriginalCount, HasAllowedItems) : BulkConstraintPlan(Command, OriginalCount, HasAllowedItems)
{ {
/// <inheritdoc />
public override MxCommandReply CreateDeniedReply(MxCommandRequest request) public override MxCommandReply CreateDeniedReply(MxCommandRequest request)
{ {
MxCommandReply reply = new() MxCommandReply reply = new()
@@ -862,6 +871,7 @@ public sealed class MxAccessGatewayService(
return reply; return reply;
} }
/// <inheritdoc />
public override MxCommandReply MergeDeniedInto(MxCommandReply reply) public override MxCommandReply MergeDeniedInto(MxCommandReply reply)
{ {
BulkReadReply allowed = reply.ReadBulk ?? new BulkReadReply(); BulkReadReply allowed = reply.ReadBulk ?? new BulkReadReply();
@@ -10,12 +10,16 @@ public static class ApiKeyConstraintSerializer
WriteIndented = false, WriteIndented = false,
}; };
/// <summary>Serializes API key constraints to JSON, or returns null if the constraints are empty.</summary>
/// <param name="constraints">The constraints to serialize.</param>
public static string? Serialize(ApiKeyConstraints constraints) public static string? Serialize(ApiKeyConstraints constraints)
{ {
ArgumentNullException.ThrowIfNull(constraints); ArgumentNullException.ThrowIfNull(constraints);
return constraints.IsEmpty ? null : JsonSerializer.Serialize(constraints, JsonOptions); return constraints.IsEmpty ? null : JsonSerializer.Serialize(constraints, JsonOptions);
} }
/// <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>
public static ApiKeyConstraints Deserialize(string? json) public static ApiKeyConstraints Deserialize(string? json)
{ {
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
@@ -10,6 +10,7 @@ public sealed record ApiKeyConstraints(
bool ReadAlarmOnly, bool ReadAlarmOnly,
bool ReadHistorizedOnly) bool ReadHistorizedOnly)
{ {
/// <summary>Gets an empty constraints instance with no restrictions.</summary>
public static ApiKeyConstraints Empty { get; } = new( public static ApiKeyConstraints Empty { get; } = new(
ReadSubtrees: Array.Empty<string>(), ReadSubtrees: Array.Empty<string>(),
WriteSubtrees: Array.Empty<string>(), WriteSubtrees: Array.Empty<string>(),
@@ -20,6 +21,7 @@ public sealed record ApiKeyConstraints(
ReadAlarmOnly: false, ReadAlarmOnly: false,
ReadHistorizedOnly: false); ReadHistorizedOnly: false);
/// <summary>Gets a value indicating whether the constraints are empty (no restrictions).</summary>
public bool IsEmpty => public bool IsEmpty =>
ReadSubtrees.Count == 0 ReadSubtrees.Count == 0
&& WriteSubtrees.Count == 0 && WriteSubtrees.Count == 0
@@ -30,12 +32,14 @@ public sealed record ApiKeyConstraints(
&& !ReadAlarmOnly && !ReadAlarmOnly
&& !ReadHistorizedOnly; && !ReadHistorizedOnly;
/// <summary>Gets a value indicating whether any read constraints are defined.</summary>
public bool HasReadConstraints => public bool HasReadConstraints =>
ReadSubtrees.Count > 0 ReadSubtrees.Count > 0
|| ReadTagGlobs.Count > 0 || ReadTagGlobs.Count > 0
|| ReadAlarmOnly || ReadAlarmOnly
|| ReadHistorizedOnly; || ReadHistorizedOnly;
/// <summary>Gets a value indicating whether any write constraints are defined.</summary>
public bool HasWriteConstraints => public bool HasWriteConstraints =>
WriteSubtrees.Count > 0 WriteSubtrees.Count > 0
|| WriteTagGlobs.Count > 0 || WriteTagGlobs.Count > 0
@@ -7,5 +7,6 @@ public sealed record ApiKeyIdentity(
IReadOnlySet<string> Scopes, IReadOnlySet<string> Scopes,
ApiKeyConstraints? Constraints = null) ApiKeyConstraints? Constraints = null)
{ {
/// <summary>Gets the effective API key constraints (defaults to Empty if null).</summary>
public ApiKeyConstraints EffectiveConstraints => Constraints ?? ApiKeyConstraints.Empty; public ApiKeyConstraints EffectiveConstraints => Constraints ?? ApiKeyConstraints.Empty;
} }
@@ -48,6 +48,8 @@ public sealed class AuthSqliteConnectionFactory(IOptions<GatewayOptions> options
/// non-zero busy timeout so concurrent readers and writers degrade gracefully /// non-zero busy timeout so concurrent readers and writers degrade gracefully
/// rather than surfacing <c>SQLITE_BUSY</c> as a hard failure. /// rather than surfacing <c>SQLITE_BUSY</c> as a hard failure.
/// </summary> /// </summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>An opened and configured SQLite connection.</returns>
public async Task<SqliteConnection> OpenConnectionAsync(CancellationToken cancellationToken) public async Task<SqliteConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{ {
SqliteConnection connection = CreateConnection(); SqliteConnection connection = CreateConnection();
@@ -9,6 +9,10 @@ public sealed class ConstraintEnforcer(
IGalaxyHierarchyCache cache, IGalaxyHierarchyCache cache,
IApiKeyAuditStore auditStore) : IConstraintEnforcer IApiKeyAuditStore auditStore) : IConstraintEnforcer
{ {
/// <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,
@@ -23,6 +27,12 @@ public sealed class ConstraintEnforcer(
return Task.FromResult(CheckReadTarget(constraints, tagAddress)); return Task.FromResult(CheckReadTarget(constraints, tagAddress));
} }
/// <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,
@@ -44,6 +54,12 @@ public sealed class ConstraintEnforcer(
return Task.FromResult(CheckReadTarget(constraints, registration.TagAddress)); return Task.FromResult(CheckReadTarget(constraints, registration.TagAddress));
} }
/// <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,
@@ -92,6 +108,12 @@ public sealed class ConstraintEnforcer(
return Task.FromResult<ConstraintFailure?>(null); return Task.FromResult<ConstraintFailure?>(null);
} }
/// <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,
@@ -5,11 +5,21 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
public interface IConstraintEnforcer public interface IConstraintEnforcer
{ {
/// <summary>Checks whether a read constraint is satisfied for a tag address.</summary>
/// <param name="identity">The API key identity.</param>
/// <param name="tagAddress">Tag address to check.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
Task<ConstraintFailure?> CheckReadTagAsync( Task<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
string tagAddress, string tagAddress,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary>Checks whether a read constraint is satisfied for an item handle.</summary>
/// <param name="identity">The API key identity.</param>
/// <param name="session">The gateway session.</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>
Task<ConstraintFailure?> CheckReadHandleAsync( Task<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
GatewaySession session, GatewaySession session,
@@ -17,6 +27,12 @@ public interface IConstraintEnforcer
int itemHandle, int itemHandle,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary>Checks whether a write constraint is satisfied for an item handle.</summary>
/// <param name="identity">The API key identity.</param>
/// <param name="session">The gateway session.</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>
Task<ConstraintFailure?> CheckWriteHandleAsync( Task<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
GatewaySession session, GatewaySession session,
@@ -24,6 +40,12 @@ public interface IConstraintEnforcer
int itemHandle, int itemHandle,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary>Records a constraint denial for audit and metrics.</summary>
/// <param name="identity">The API key identity.</param>
/// <param name="commandKind">The kind of command denied.</param>
/// <param name="target">The target of the denied command.</param>
/// <param name="failure">The constraint failure details.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
Task RecordDenialAsync( Task RecordDenialAsync(
ApiKeyIdentity? identity, ApiKeyIdentity? identity,
string commandKind, string commandKind,
@@ -58,6 +58,21 @@ public sealed class GatewaySession
{ {
} }
/// <summary>
/// Initializes a gateway session with session metadata, timeout configuration, and custom lease duration.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="backendName">Name of the backend MXAccess proxy server.</param>
/// <param name="pipeName">Name of the named pipe for gateway-worker IPC.</param>
/// <param name="nonce">Security nonce for worker validation.</param>
/// <param name="clientIdentity">Client identity from the authentication context.</param>
/// <param name="clientSessionName">Client-supplied session name.</param>
/// <param name="clientCorrelationId">Client-supplied correlation identifier.</param>
/// <param name="commandTimeout">Timeout for command invocation.</param>
/// <param name="startupTimeout">Timeout for worker process startup.</param>
/// <param name="shutdownTimeout">Timeout for worker process shutdown.</param>
/// <param name="leaseDuration">Duration of the session lease.</param>
/// <param name="openedAt">Timestamp when the session opened.</param>
public GatewaySession( public GatewaySession(
string sessionId, string sessionId,
string backendName, string backendName,
@@ -158,6 +173,7 @@ public sealed class GatewaySession
/// </summary> /// </summary>
public TimeSpan ShutdownTimeout { get; } public TimeSpan ShutdownTimeout { get; }
/// <summary>Gets the lease duration for the session.</summary>
public TimeSpan LeaseDuration { get; } public TimeSpan LeaseDuration { get; }
/// <summary> /// <summary>
@@ -406,6 +422,10 @@ public sealed class GatewaySession
return await workerClient.InvokeAsync(command, CommandTimeout, cancellationToken).ConfigureAwait(false); return await workerClient.InvokeAsync(command, CommandTimeout, cancellationToken).ConfigureAwait(false);
} }
/// <summary>Gets the item registration for a server and item handle pair.</summary>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="registration">The item registration if found.</param>
public bool TryGetItemRegistration( public bool TryGetItemRegistration(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -417,6 +437,9 @@ public sealed class GatewaySession
} }
} }
/// <summary>Tracks item registrations from a command reply.</summary>
/// <param name="command">The executed command.</param>
/// <param name="reply">The command reply.</param>
public void TrackCommandReply( public void TrackCommandReply(
MxCommand command, MxCommand command,
MxCommandReply reply) MxCommandReply reply)
@@ -608,9 +631,10 @@ public sealed class GatewaySession
cancellationToken); cancellationToken);
} }
/// <summary> /// <summary>Executes a bulk Write command for the specified server and per-item entries.</summary>
/// Executes a bulk Write command for the specified server and per-item entries. /// <param name="serverHandle">Server handle returned by the worker.</param>
/// </summary> /// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync( public Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<WriteBulkEntry> entries, IReadOnlyList<WriteBulkEntry> entries,
@@ -631,6 +655,9 @@ public sealed class GatewaySession
} }
/// <summary>Executes a bulk Write2 (timestamped) command.</summary> /// <summary>Executes a bulk Write2 (timestamped) command.</summary>
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync( public Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<Write2BulkEntry> entries, IReadOnlyList<Write2BulkEntry> entries,
@@ -651,6 +678,9 @@ public sealed class GatewaySession
} }
/// <summary>Executes a bulk WriteSecured command.</summary> /// <summary>Executes a bulk WriteSecured command.</summary>
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync( public Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<WriteSecuredBulkEntry> entries, IReadOnlyList<WriteSecuredBulkEntry> entries,
@@ -671,6 +701,9 @@ public sealed class GatewaySession
} }
/// <summary>Executes a bulk WriteSecured2 command.</summary> /// <summary>Executes a bulk WriteSecured2 command.</summary>
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync( public Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<WriteSecured2BulkEntry> entries, IReadOnlyList<WriteSecured2BulkEntry> entries,
@@ -694,6 +727,10 @@ public sealed class GatewaySession
/// Executes a bulk Read command — see <c>ReadBulkCommand</c>'s doc /// Executes a bulk Read command — see <c>ReadBulkCommand</c>'s doc
/// comment in the .proto for the cached-vs-snapshot semantics. /// comment in the .proto for the cached-vs-snapshot semantics.
/// </summary> /// </summary>
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="tagAddresses">Tag addresses to read.</param>
/// <param name="timeout">Timeout for the read operation.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync( public Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<string> tagAddresses, IReadOnlyList<string> tagAddresses,
@@ -13,6 +13,7 @@ public sealed class SessionLeaseMonitorHostedService(
{ {
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
TimeSpan interval = TimeSpan.FromSeconds(Math.Max(1, options.Value.Sessions.LeaseSweepIntervalSeconds)); TimeSpan interval = TimeSpan.FromSeconds(Math.Max(1, options.Value.Sessions.LeaseSweepIntervalSeconds));
@@ -15,34 +15,23 @@ body.dashboard-body { min-height: 100vh; }
.app-bar .brand { color: var(--ink); } .app-bar .brand { color: var(--ink); }
.app-bar .brand:hover { text-decoration: none; } .app-bar .brand:hover { text-decoration: none; }
/* ── App shell ─────────────────────────────────────────────────────────────── /* ── Sidebar ─────────────────────────────────────────────────────────────────
Two-column layout: fixed-width side rail (220px) + flexible main column. Left-rail navigation. Pattern lifted from ScadaLink CentralUI: a fixed-width
Becomes single-column under <lg viewports — see media queries below. */ sidebar that hosts the brand at the top, a scrollable nav region with
.app-shell { collapsible NavSections in the middle, and a sign-in/out footer at the
display: flex; bottom. The sidebar is wrapped in a Bootstrap .collapse so a hamburger
align-items: stretch; button can show/hide it on <lg viewports. */
min-height: calc(100vh - 3.3rem); .sidebar {
} min-width: 220px;
.app-shell > .page { flex: 1; min-width: 0; } max-width: 220px;
height: 100vh;
/* ── Side rail ───────────────────────────────────────────────────────────────
Left-rail navigation with eyebrow section headings, a footer session
block, and active-route accent. */
.side-rail {
width: 220px;
flex: 0 0 220px;
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 1rem 0.7rem;
background: var(--card); background: var(--card);
border-right: 1px solid var(--rule-strong); border-right: 1px solid var(--rule-strong);
} }
/* Sidebar collapse wrapper: on lg+ it stays sticky to the viewport so the rail /* Pin the sidebar to the viewport on lg+ so it stays visible when the main
remains visible when main content scrolls past 100vh (matches master). On content scrolls past 100vh. The wrapper is the flex child of MainLayout;
<lg viewports the Bootstrap .collapse class drives show/hide and the rail align-self prevents the flex row from stretching it. */
spans full width — see the max-width media query below. */
@media (min-width: 992px) { @media (min-width: 992px) {
#sidebar-collapse { #sidebar-collapse {
position: sticky; position: sticky;
@@ -53,82 +42,73 @@ body.dashboard-body { min-height: 100vh; }
} }
} }
/* When collapsed under <lg viewports the Bootstrap collapse container removes
the fixed width; restore full width on mobile. */
@media (max-width: 991.98px) { @media (max-width: 991.98px) {
.app-shell { flex-direction: column; } .sidebar {
#sidebar-collapse { width: 100%; } min-width: 100%;
.side-rail { max-width: 100%;
width: 100%;
flex: 0 0 auto;
height: auto; height: auto;
border-right: none;
border-bottom: 1px solid var(--rule-strong);
} }
} }
.rail-eyebrow { .sidebar .brand {
font-size: 0.68rem; display: block;
color: var(--ink);
font-size: 1.1rem;
font-weight: 600;
letter-spacing: 0.02em;
padding: 1rem;
border-bottom: 1px solid var(--rule);
text-decoration: none;
}
.sidebar .brand:hover { text-decoration: none; }
.sidebar .brand .mark { color: var(--accent); }
.sidebar .nav-link {
color: var(--ink-soft);
padding: 0.4rem 1rem;
font-size: 0.9rem;
}
.sidebar .nav-link:hover {
color: var(--ink);
background-color: var(--paper);
text-decoration: none;
}
.sidebar .nav-link.active {
color: var(--accent-deep);
background-color: var(--paper);
font-weight: 600;
/* Left accent so active state isn't carried by colour alone. */
border-left: 3px solid var(--accent);
padding-left: calc(1rem - 3px);
}
/* Collapsible section header — a full-width button styled as an uppercase
eyebrow with a leading expand/collapse chevron. */
.sidebar .nav-section-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
background: none;
border: 0;
cursor: pointer;
text-align: left;
color: var(--ink-faint);
font-size: 0.7rem;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.07em; letter-spacing: 0.07em;
color: var(--ink-faint); padding: 0.75rem 1rem 0.25rem;
padding: 0.4rem 0.6rem 0.2rem; margin-top: 0.5rem;
} }
.sidebar .nav-section-toggle:hover { color: var(--ink); }
.rail-link { .sidebar .nav-section-toggle .chevron {
display: block;
padding: 0.4rem 0.6rem;
border-radius: 4px;
border-left: 2px solid transparent;
font-size: 0.86rem;
color: var(--ink-soft);
text-decoration: none;
}
.rail-link:hover {
background: #f3f6fd;
color: var(--ink);
text-decoration: none;
}
.rail-link.active {
background: #eef2fc;
border-left-color: var(--accent);
color: var(--accent-deep);
font-weight: 600;
}
.rail-foot {
margin-top: auto;
padding-top: 0.6rem;
border-top: 1px solid var(--rule);
}
.rail-user {
padding: 0 0.6rem;
font-weight: 600;
font-size: 0.88rem;
color: var(--ink);
}
.rail-roles {
padding: 0.1rem 0.6rem 0.5rem;
font-family: var(--mono);
font-size: 0.72rem;
color: var(--ink-faint);
}
.rail-btn {
display: inline-block; display: inline-block;
margin: 0 0.6rem; width: 0.8rem;
padding: 0.3rem 0.7rem; font-size: 0.8rem;
font-size: 0.78rem; line-height: 1;
font-weight: 600;
color: var(--ink-soft);
background: var(--card);
border: 1px solid var(--rule-strong);
border-radius: 4px;
cursor: pointer;
text-decoration: none;
}
.rail-btn:hover {
border-color: var(--accent);
color: var(--accent);
text-decoration: none;
} }
/* ── Page header ───────────────────────────────────────────────────────────── /* ── Page header ─────────────────────────────────────────────────────────────
@@ -0,0 +1,19 @@
// Sidebar nav collapse state — persisted in the `mxgateway_nav` cookie so it
// survives full page reloads and reconnects. Invoked from MainLayout.razor via
// JS interop (window.navState.get / .set). Pattern lifted from ScadaLink
// CentralUI's wwwroot/js/nav-state.js.
window.navState = {
// Returns the raw cookie value (comma-separated expanded section ids), or
// an empty string when the cookie is absent.
get: function () {
const match = document.cookie.match(/(?:^|;\s*)mxgateway_nav=([^;]*)/);
return match ? decodeURIComponent(match[1]) : "";
},
// Writes the cookie with a one-year lifetime. SameSite=Lax; not HttpOnly
// (JS must write it) and not sensitive.
set: function (value) {
const oneYearSeconds = 60 * 60 * 24 * 365;
document.cookie = "mxgateway_nav=" + encodeURIComponent(value) +
";path=/;max-age=" + oneYearSeconds + ";samesite=lax";
}
};
@@ -667,6 +667,7 @@ public sealed class ProtobufContractRoundTripTests
/// Verifies that a DiscoverHierarchyRequest round-trips through every /// Verifies that a DiscoverHierarchyRequest round-trips through every
/// <c>root</c> oneof arm and its proto wrapper-typed <c>max_depth</c> field. /// <c>root</c> oneof arm and its proto wrapper-typed <c>max_depth</c> field.
/// </summary> /// </summary>
/// <param name="rootArm">The oneof arm selector (0=RootGobjectId, 1=RootTagName, 2=RootContainedPath).</param>
[Theory] [Theory]
[InlineData(0)] [InlineData(0)]
[InlineData(1)] [InlineData(1)]
@@ -1165,6 +1166,8 @@ public sealed class ProtobufContractRoundTripTests
/// expected value. Pins every new oneof case added by the bulk /// expected value. Pins every new oneof case added by the bulk
/// write/read extension. /// write/read extension.
/// </summary> /// </summary>
/// <param name="kind">The command kind to test.</param>
/// <param name="expectedPayloadCase">The expected payload oneof case.</param>
[Theory] [Theory]
[InlineData(MxCommandKind.WriteBulk, MxCommandReply.PayloadOneofCase.WriteBulk)] [InlineData(MxCommandKind.WriteBulk, MxCommandReply.PayloadOneofCase.WriteBulk)]
[InlineData(MxCommandKind.Write2Bulk, MxCommandReply.PayloadOneofCase.Write2Bulk)] [InlineData(MxCommandKind.Write2Bulk, MxCommandReply.PayloadOneofCase.Write2Bulk)]
@@ -44,6 +44,7 @@ public sealed class GalaxyFilterInputSafetyTests
"Pump'001", "Pump'001",
]; ];
/// <summary>Returns adversarial input cases for theory tests.</summary>
public static TheoryData<string> AdversarialInputCases() public static TheoryData<string> AdversarialInputCases()
{ {
TheoryData<string> data = []; TheoryData<string> data = [];
@@ -60,6 +61,7 @@ public sealed class GalaxyFilterInputSafetyTests
/// <c>LIKE</c>-wildcards as literals — a glob equal to the literal value matches, /// <c>LIKE</c>-wildcards as literals — a glob equal to the literal value matches,
/// and the same glob does not spuriously match an unrelated value. /// and the same glob does not spuriously match an unrelated value.
/// </summary> /// </summary>
/// <param name="input">An adversarial input containing SQL metacharacters or LIKE wildcards.</param>
[Theory] [Theory]
[MemberData(nameof(AdversarialInputCases))] [MemberData(nameof(AdversarialInputCases))]
public void GlobMatcher_TreatsSqlMetacharactersAsLiterals(string input) public void GlobMatcher_TreatsSqlMetacharactersAsLiterals(string input)
@@ -159,6 +161,7 @@ public sealed class GalaxyFilterInputSafetyTests
/// treats an adversarial glob as a literal: it never wildcard-matches the whole /// treats an adversarial glob as a literal: it never wildcard-matches the whole
/// hierarchy and never throws. /// hierarchy and never throws.
/// </summary> /// </summary>
/// <param name="glob">An adversarial glob containing SQL metacharacters or LIKE wildcards.</param>
[Theory] [Theory]
[MemberData(nameof(AdversarialInputCases))] [MemberData(nameof(AdversarialInputCases))]
public void Projector_TagNameGlob_WithAdversarialInput_DoesNotMatchEverything(string glob) public void Projector_TagNameGlob_WithAdversarialInput_DoesNotMatchEverything(string glob)
@@ -180,6 +183,7 @@ public sealed class GalaxyFilterInputSafetyTests
/// literal — an exact-match lookup that finds nothing and surfaces NotFound, /// literal — an exact-match lookup that finds nothing and surfaces NotFound,
/// never matching unrelated objects or throwing an unexpected exception. /// never matching unrelated objects or throwing an unexpected exception.
/// </summary> /// </summary>
/// <param name="rootTagName">An adversarial tag name containing SQL metacharacters or LIKE wildcards.</param>
[Theory] [Theory]
[MemberData(nameof(AdversarialInputCases))] [MemberData(nameof(AdversarialInputCases))]
public void Projector_RootTagName_WithAdversarialInput_ThrowsNotFound(string rootTagName) public void Projector_RootTagName_WithAdversarialInput_ThrowsNotFound(string rootTagName)
@@ -198,6 +202,7 @@ public sealed class GalaxyFilterInputSafetyTests
/// Verifies an adversarial <c>TemplateChainContains</c> filter is a literal /// Verifies an adversarial <c>TemplateChainContains</c> filter is a literal
/// substring test — it never matches unrelated template chains and never throws. /// substring test — it never matches unrelated template chains and never throws.
/// </summary> /// </summary>
/// <param name="filter">An adversarial filter containing SQL metacharacters or LIKE wildcards.</param>
[Theory] [Theory]
[MemberData(nameof(AdversarialInputCases))] [MemberData(nameof(AdversarialInputCases))]
public void Projector_TemplateChainContains_WithAdversarialInput_MatchesNothing(string filter) public void Projector_TemplateChainContains_WithAdversarialInput_MatchesNothing(string filter)
@@ -216,6 +221,7 @@ public sealed class GalaxyFilterInputSafetyTests
/// handles an adversarial <c>TagNameGlob</c> end-to-end: the request succeeds with /// handles an adversarial <c>TagNameGlob</c> end-to-end: the request succeeds with
/// zero matches rather than returning the whole hierarchy or faulting. /// zero matches rather than returning the whole hierarchy or faulting.
/// </summary> /// </summary>
/// <param name="glob">An adversarial glob containing SQL metacharacters or LIKE wildcards.</param>
[Theory] [Theory]
[MemberData(nameof(AdversarialInputCases))] [MemberData(nameof(AdversarialInputCases))]
public async Task DiscoverHierarchy_WithAdversarialTagNameGlob_ReturnsZeroMatches(string glob) public async Task DiscoverHierarchy_WithAdversarialTagNameGlob_ReturnsZeroMatches(string glob)
@@ -235,6 +241,7 @@ public sealed class GalaxyFilterInputSafetyTests
/// maps an adversarial <c>RootTagName</c> to NotFound rather than executing it as /// maps an adversarial <c>RootTagName</c> to NotFound rather than executing it as
/// a query fragment or matching unrelated objects. /// a query fragment or matching unrelated objects.
/// </summary> /// </summary>
/// <param name="rootTagName">An adversarial tag name containing SQL metacharacters or LIKE wildcards.</param>
[Theory] [Theory]
[MemberData(nameof(AdversarialInputCases))] [MemberData(nameof(AdversarialInputCases))]
public async Task DiscoverHierarchy_WithAdversarialRootTagName_ReturnsNotFound(string rootTagName) public async Task DiscoverHierarchy_WithAdversarialRootTagName_ReturnsNotFound(string rootTagName)
@@ -319,10 +326,13 @@ public sealed class GalaxyFilterInputSafetyTests
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{ {
/// <inheritdoc />
public GalaxyHierarchyCacheEntry Current { get; } = current; public GalaxyHierarchyCacheEntry Current { get; } = current;
/// <inheritdoc />
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
} }
@@ -78,6 +78,7 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData); Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData);
} }
/// <summary>Verifies that the hierarchy index builds paths and lookups without throwing on bad metadata.</summary>
[Fact] [Fact]
public void GalaxyHierarchyIndex_BuildsPathsAndTagLookupsWithoutThrowingOnBadMetadata() public void GalaxyHierarchyIndex_BuildsPathsAndTagLookupsWithoutThrowingOnBadMetadata()
{ {
@@ -357,19 +358,24 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
{ {
private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Releases the blocking task.</summary>
public void Release() => _release.TrySetResult(); public void Release() => _release.TrySetResult();
/// <inheritdoc />
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(false); public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(false);
/// <inheritdoc />
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
{ {
await _release.Task.WaitAsync(ct).ConfigureAwait(false); await _release.Task.WaitAsync(ct).ConfigureAwait(false);
throw new InvalidOperationException("Galaxy repository unreachable"); throw new InvalidOperationException("Galaxy repository unreachable");
} }
/// <inheritdoc />
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
=> throw new InvalidOperationException("GetHierarchyAsync should not be reached"); => throw new InvalidOperationException("GetHierarchyAsync should not be reached");
/// <inheritdoc />
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
=> throw new InvalidOperationException("GetAttributesAsync should not be reached"); => throw new InvalidOperationException("GetAttributesAsync should not be reached");
} }
@@ -377,9 +383,11 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
/// <summary>Snapshot store whose <see cref="SaveAsync"/> cancels the token mid-save.</summary> /// <summary>Snapshot store whose <see cref="SaveAsync"/> cancels the token mid-save.</summary>
private sealed class CancellingSaveStore(CancellationTokenSource cts) : IGalaxyHierarchySnapshotStore private sealed class CancellingSaveStore(CancellationTokenSource cts) : IGalaxyHierarchySnapshotStore
{ {
/// <inheritdoc />
public Task<GalaxyHierarchySnapshot?> TryLoadAsync(CancellationToken cancellationToken) public Task<GalaxyHierarchySnapshot?> TryLoadAsync(CancellationToken cancellationToken)
=> Task.FromResult<GalaxyHierarchySnapshot?>(null); => Task.FromResult<GalaxyHierarchySnapshot?>(null);
/// <inheritdoc />
public Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken) public Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken)
{ {
cts.Cancel(); cts.Cancel();
@@ -391,13 +399,17 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
/// <summary>Minimal <see cref="ILogger{T}"/> that records every emitted log entry.</summary> /// <summary>Minimal <see cref="ILogger{T}"/> that records every emitted log entry.</summary>
private sealed class RecordingLogger<T> : ILogger<T> private sealed class RecordingLogger<T> : ILogger<T>
{ {
/// <summary>Gets the list of recorded log entries.</summary>
public List<(LogLevel Level, string Message)> Entries { get; } = []; public List<(LogLevel Level, string Message)> Entries { get; } = [];
/// <inheritdoc />
public IDisposable BeginScope<TState>(TState state) public IDisposable BeginScope<TState>(TState state)
where TState : notnull => NullScope.Instance; where TState : notnull => NullScope.Instance;
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true; public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc />
public void Log<TState>( public void Log<TState>(
LogLevel logLevel, LogLevel logLevel,
EventId eventId, EventId eventId,
@@ -412,6 +424,7 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
{ {
public static readonly NullScope Instance = new(); public static readonly NullScope Instance = new();
/// <inheritdoc />
public void Dispose() public void Dispose()
{ {
} }
@@ -427,20 +440,26 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
private readonly List<GalaxyHierarchyRow> _hierarchy = hierarchy ?? []; private readonly List<GalaxyHierarchyRow> _hierarchy = hierarchy ?? [];
private readonly List<GalaxyAttributeRow> _attributes = attributes ?? []; private readonly List<GalaxyAttributeRow> _attributes = attributes ?? [];
/// <summary>Gets the count of calls to <see cref="GetHierarchyAsync"/>.</summary>
public int GetHierarchyCount { get; private set; } public int GetHierarchyCount { get; private set; }
/// <summary>Gets the count of calls to <see cref="GetAttributesAsync"/>.</summary>
public int GetAttributesCount { get; private set; } public int GetAttributesCount { get; private set; }
/// <inheritdoc />
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true); public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
/// <inheritdoc />
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) => Task.FromResult(deployTime); public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) => Task.FromResult(deployTime);
/// <inheritdoc />
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
{ {
GetHierarchyCount++; GetHierarchyCount++;
return Task.FromResult(_hierarchy); return Task.FromResult(_hierarchy);
} }
/// <inheritdoc />
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
{ {
GetAttributesCount++; GetAttributesCount++;
@@ -448,6 +467,7 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
} }
} }
/// <inheritdoc />
public void Dispose() public void Dispose()
{ {
foreach (string path in _tempPaths) foreach (string path in _tempPaths)
@@ -16,6 +16,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Galaxy;
/// </summary> /// </summary>
public sealed class GalaxyHierarchyProjectorTests public sealed class GalaxyHierarchyProjectorTests
{ {
/// <summary>Verifies that paging across a hierarchy returns every object exactly once.</summary>
[Fact] [Fact]
public void Project_PagedAcrossEntireHierarchy_ReturnsEveryObjectExactlyOnce() public void Project_PagedAcrossEntireHierarchy_ReturnsEveryObjectExactlyOnce()
{ {
@@ -43,6 +44,7 @@ public sealed class GalaxyHierarchyProjectorTests
Assert.Equal("Object_025", collected[^1]); Assert.Equal("Object_025", collected[^1]);
} }
/// <summary>Verifies that distinct filters on the same entry do not share memoized view list.</summary>
[Fact] [Fact]
public void Project_DistinctFiltersOnSameEntry_DoNotShareMemoizedViewList() public void Project_DistinctFiltersOnSameEntry_DoNotShareMemoizedViewList()
{ {
@@ -60,6 +62,7 @@ public sealed class GalaxyHierarchyProjectorTests
Assert.Equal(10, unfiltered.TotalObjectCount); Assert.Equal(10, unfiltered.TotalObjectCount);
} }
/// <summary>Verifies that the same filter repeated returns identical totals.</summary>
[Fact] [Fact]
public void Project_SameFilterRepeated_ReturnsIdenticalTotals() public void Project_SameFilterRepeated_ReturnsIdenticalTotals()
{ {
@@ -85,6 +88,7 @@ public sealed class GalaxyHierarchyProjectorTests
Assert.NotEqual(first.Objects[0].TagName, second.Objects[0].TagName); Assert.NotEqual(first.Objects[0].TagName, second.Objects[0].TagName);
} }
/// <summary>Verifies that distinct cache entries project against their own data.</summary>
[Fact] [Fact]
public void Project_DistinctCacheEntries_ProjectAgainstTheirOwnData() public void Project_DistinctCacheEntries_ProjectAgainstTheirOwnData()
{ {
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Galaxy;
/// </summary> /// </summary>
public sealed class GalaxyHierarchyRefreshServiceTests public sealed class GalaxyHierarchyRefreshServiceTests
{ {
/// <summary>Verifies that the background service does not fault when the first refresh throws a non-cancellation exception.</summary>
[Fact] [Fact]
public async Task ExecuteAsync_WhenFirstRefreshThrowsNonCancellationException_DoesNotFaultBackgroundService() public async Task ExecuteAsync_WhenFirstRefreshThrowsNonCancellationException_DoesNotFaultBackgroundService()
{ {
@@ -62,13 +63,17 @@ public sealed class GalaxyHierarchyRefreshServiceTests
private readonly TaskCompletionSource firstRefreshAttempted = private readonly TaskCompletionSource firstRefreshAttempted =
new(TaskCreationOptions.RunContinuationsAsynchronously); new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Gets the number of refresh calls.</summary>
public int RefreshCallCount { get; private set; } public int RefreshCallCount { get; private set; }
/// <summary>Completes once <see cref="RefreshAsync"/> has been invoked at least once.</summary> /// <summary>Gets a task that completes once refresh has been invoked at least once.</summary>
public Task FirstRefreshAttempted => firstRefreshAttempted.Task; public Task FirstRefreshAttempted => firstRefreshAttempted.Task;
/// <summary>Gets the current cache entry.</summary>
public GalaxyHierarchyCacheEntry Current => GalaxyHierarchyCacheEntry.Empty; public GalaxyHierarchyCacheEntry Current => GalaxyHierarchyCacheEntry.Empty;
/// <summary>Refreshes the cache asynchronously and throws the configured exception.</summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task RefreshAsync(CancellationToken cancellationToken) public Task RefreshAsync(CancellationToken cancellationToken)
{ {
RefreshCallCount++; RefreshCallCount++;
@@ -76,6 +81,8 @@ public sealed class GalaxyHierarchyRefreshServiceTests
throw toThrow; throw toThrow;
} }
/// <summary>Waits for the first load and completes immediately.</summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
} }
} }
@@ -12,6 +12,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
{ {
private readonly List<string> _tempPaths = []; private readonly List<string> _tempPaths = [];
/// <summary>Verifies that snapshots are correctly saved to and loaded from disk.</summary>
[Fact] [Fact]
public async Task SaveAsync_ThenTryLoadAsync_RoundTripsRows() public async Task SaveAsync_ThenTryLoadAsync_RoundTripsRows()
{ {
@@ -39,6 +40,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
Assert.Null(loaded.Attributes[1].ArrayDimension); Assert.Null(loaded.Attributes[1].ArrayDimension);
} }
/// <summary>Verifies that loading returns null when no snapshot file exists.</summary>
[Fact] [Fact]
public async Task TryLoadAsync_WhenNoFileExists_ReturnsNull() public async Task TryLoadAsync_WhenNoFileExists_ReturnsNull()
{ {
@@ -47,6 +49,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
Assert.Null(await store.TryLoadAsync(CancellationToken.None)); Assert.Null(await store.TryLoadAsync(CancellationToken.None));
} }
/// <summary>Verifies that save writes nothing when persistence is disabled.</summary>
[Fact] [Fact]
public async Task SaveAsync_WhenPersistenceDisabled_WritesNothing() public async Task SaveAsync_WhenPersistenceDisabled_WritesNothing()
{ {
@@ -59,6 +62,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
Assert.Null(await store.TryLoadAsync(CancellationToken.None)); Assert.Null(await store.TryLoadAsync(CancellationToken.None));
} }
/// <summary>Verifies that loading returns null when the file contains invalid JSON.</summary>
[Fact] [Fact]
public async Task TryLoadAsync_WhenFileIsCorruptJson_ReturnsNull() public async Task TryLoadAsync_WhenFileIsCorruptJson_ReturnsNull()
{ {
@@ -69,6 +73,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
Assert.Null(await store.TryLoadAsync(CancellationToken.None)); Assert.Null(await store.TryLoadAsync(CancellationToken.None));
} }
/// <summary>Verifies that loading returns null when the schema version is unrecognized.</summary>
[Fact] [Fact]
public async Task TryLoadAsync_WhenSchemaVersionUnrecognized_ReturnsNull() public async Task TryLoadAsync_WhenSchemaVersionUnrecognized_ReturnsNull()
{ {
@@ -79,6 +84,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
Assert.Null(await store.TryLoadAsync(CancellationToken.None)); Assert.Null(await store.TryLoadAsync(CancellationToken.None));
} }
/// <summary>Verifies that saving overwrites an earlier snapshot.</summary>
[Fact] [Fact]
public async Task SaveAsync_OverwritesAnEarlierSnapshot() public async Task SaveAsync_OverwritesAnEarlierSnapshot()
{ {
@@ -159,6 +165,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
return path; return path;
} }
/// <inheritdoc />
public void Dispose() public void Dispose()
{ {
foreach (string path in _tempPaths) foreach (string path in _tempPaths)
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardApiKeyAuthorizationTests public sealed class DashboardApiKeyAuthorizationTests
{ {
/// <summary>Verifies that CanManage returns true for authenticated admin user.</summary>
[Fact] [Fact]
public void CanManage_AuthenticatedAdmin_ReturnsTrue() public void CanManage_AuthenticatedAdmin_ReturnsTrue()
{ {
@@ -14,6 +15,7 @@ public sealed class DashboardApiKeyAuthorizationTests
Assert.True(authorization.CanManage(user)); Assert.True(authorization.CanManage(user));
} }
/// <summary>Verifies that CanManage returns false for anonymous user.</summary>
[Fact] [Fact]
public void CanManage_AnonymousUser_ReturnsFalse() public void CanManage_AnonymousUser_ReturnsFalse()
{ {
@@ -23,6 +25,7 @@ public sealed class DashboardApiKeyAuthorizationTests
Assert.False(authorization.CanManage(user)); Assert.False(authorization.CanManage(user));
} }
/// <summary>Verifies that CanManage returns false for authenticated viewer user.</summary>
[Fact] [Fact]
public void CanManage_AuthenticatedViewer_ReturnsFalse() public void CanManage_AuthenticatedViewer_ReturnsFalse()
{ {
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardApiKeyManagementServiceTests public sealed class DashboardApiKeyManagementServiceTests
{ {
/// <summary>Verifies that unauthorized users cannot create API keys.</summary>
[Fact] [Fact]
public async Task CreateAsync_UnauthorizedUser_DoesNotCallStore() public async Task CreateAsync_UnauthorizedUser_DoesNotCallStore()
{ {
@@ -25,6 +26,7 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.Equal(0, adminStore.CreateCount); Assert.Equal(0, adminStore.CreateCount);
} }
/// <summary>Verifies that authorized users can create keys with secret hashing and audit trail.</summary>
[Fact] [Fact]
public async Task CreateAsync_AuthorizedUser_StoresHashOfSecretAndAudits() public async Task CreateAsync_AuthorizedUser_StoresHashOfSecretAndAudits()
{ {
@@ -54,6 +56,7 @@ public sealed class DashboardApiKeyManagementServiceTests
&& entry.KeyId == "operator01"); && entry.KeyId == "operator01");
} }
/// <summary>Verifies that unauthorized users cannot revoke API keys.</summary>
[Fact] [Fact]
public async Task RevokeAsync_UnauthorizedUser_DoesNotCallStore() public async Task RevokeAsync_UnauthorizedUser_DoesNotCallStore()
{ {
@@ -69,6 +72,7 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.Equal(0, adminStore.RevokeCount); Assert.Equal(0, adminStore.RevokeCount);
} }
/// <summary>Verifies that authorized users can revoke keys with audit trail.</summary>
[Fact] [Fact]
public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits() public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits()
{ {
@@ -89,6 +93,7 @@ public sealed class DashboardApiKeyManagementServiceTests
&& entry.Details == "revoked"); && entry.Details == "revoked");
} }
/// <summary>Verifies that authorized users can rotate secret hashes with audit trail.</summary>
[Fact] [Fact]
public async Task RotateAsync_AuthorizedUser_RotatesHashAndAudits() public async Task RotateAsync_AuthorizedUser_RotatesHashAndAudits()
{ {
@@ -112,6 +117,7 @@ public sealed class DashboardApiKeyManagementServiceTests
&& entry.Details == "rotated"); && entry.Details == "rotated");
} }
/// <summary>Verifies that unauthorized users cannot delete API keys.</summary>
[Fact] [Fact]
public async Task DeleteAsync_UnauthorizedUser_DoesNotCallStore() public async Task DeleteAsync_UnauthorizedUser_DoesNotCallStore()
{ {
@@ -127,6 +133,7 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.Equal(0, adminStore.DeleteCount); Assert.Equal(0, adminStore.DeleteCount);
} }
/// <summary>Verifies that authorized users can delete revoked keys with audit trail.</summary>
[Fact] [Fact]
public async Task DeleteAsync_AuthorizedUser_DeletesRevokedKeyAndAudits() public async Task DeleteAsync_AuthorizedUser_DeletesRevokedKeyAndAudits()
{ {
@@ -181,6 +188,7 @@ public sealed class DashboardApiKeyManagementServiceTests
/// <c>ValidateKeyId</c> after the authorisation check. A blank key id must fail with the /// <c>ValidateKeyId</c> after the authorisation check. A blank key id must fail with the
/// shared "API key id is required." message before any store or audit call runs. /// shared "API key id is required." message before any store or audit call runs.
/// </summary> /// </summary>
/// <param name="blankKeyId">A blank or whitespace key identifier.</param>
[Theory] [Theory]
[InlineData("")] [InlineData("")]
[InlineData(" ")] [InlineData(" ")]
@@ -269,26 +277,37 @@ public sealed class DashboardApiKeyManagementServiceTests
private sealed class FakeApiKeyAdminStore : IApiKeyAdminStore private sealed class FakeApiKeyAdminStore : IApiKeyAdminStore
{ {
/// <summary>Gets the count of create operations performed.</summary>
public int CreateCount { get; private set; } public int CreateCount { get; private set; }
/// <summary>Gets the count of revoke operations performed.</summary>
public int RevokeCount { get; private set; } public int RevokeCount { get; private set; }
/// <summary>Gets the count of delete operations performed.</summary>
public int DeleteCount { get; private set; } public int DeleteCount { get; private set; }
/// <summary>Gets or sets the result value returned by revoke operations.</summary>
public bool RevokeResult { get; init; } public bool RevokeResult { get; init; }
/// <summary>Gets or sets the result value returned by rotate operations.</summary>
public bool RotateResult { get; init; } public bool RotateResult { get; init; }
/// <summary>Gets or sets the result value returned by delete operations.</summary>
public bool DeleteResult { get; init; } public bool DeleteResult { get; init; }
/// <summary>Gets the last key ID revoked.</summary>
public string? LastRevokedKeyId { get; private set; } public string? LastRevokedKeyId { get; private set; }
/// <summary>Gets the last key ID deleted.</summary>
public string? LastDeletedKeyId { get; private set; } public string? LastDeletedKeyId { get; private set; }
/// <summary>Gets the last secret hash rotated.</summary>
public byte[]? LastRotatedSecretHash { get; private set; } public byte[]? LastRotatedSecretHash { get; private set; }
/// <summary>Gets the list of create requests received.</summary>
public List<ApiKeyCreateRequest> CreatedRequests { get; } = []; public List<ApiKeyCreateRequest> CreatedRequests { get; } = [];
/// <inheritdoc />
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken) public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
{ {
CreateCount++; CreateCount++;
@@ -296,11 +315,13 @@ public sealed class DashboardApiKeyManagementServiceTests
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <inheritdoc />
public Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken) public Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{ {
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]); return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
} }
/// <inheritdoc />
public Task<bool> RevokeAsync( public Task<bool> RevokeAsync(
string keyId, string keyId,
DateTimeOffset revokedUtc, DateTimeOffset revokedUtc,
@@ -311,6 +332,7 @@ public sealed class DashboardApiKeyManagementServiceTests
return Task.FromResult(RevokeResult); return Task.FromResult(RevokeResult);
} }
/// <inheritdoc />
public Task<bool> RotateAsync( public Task<bool> RotateAsync(
string keyId, string keyId,
byte[] secretHash, byte[] secretHash,
@@ -321,6 +343,7 @@ public sealed class DashboardApiKeyManagementServiceTests
return Task.FromResult(RotateResult); return Task.FromResult(RotateResult);
} }
/// <inheritdoc />
public Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken) public Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken)
{ {
DeleteCount++; DeleteCount++;
@@ -331,14 +354,17 @@ public sealed class DashboardApiKeyManagementServiceTests
private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore
{ {
/// <summary>Gets the list of audit entries appended.</summary>
public List<ApiKeyAuditEntry> Entries { get; } = []; public List<ApiKeyAuditEntry> Entries { get; } = [];
/// <inheritdoc />
public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken) public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
{ {
Entries.Add(entry); Entries.Add(entry);
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <inheritdoc />
public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync( public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(
int count, int count,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -349,8 +375,10 @@ public sealed class DashboardApiKeyManagementServiceTests
private sealed class FakeApiKeySecretHasher : IApiKeySecretHasher private sealed class FakeApiKeySecretHasher : IApiKeySecretHasher
{ {
/// <summary>Gets the last secret hashed.</summary>
public string? LastSecret { get; private set; } public string? LastSecret { get; private set; }
/// <inheritdoc />
public byte[] HashSecret(string secret) public byte[] HashSecret(string secret)
{ {
LastSecret = secret; LastSecret = secret;
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardAuthenticatorTests public sealed class DashboardAuthenticatorTests
{ {
/// <summary>Verifies that LDAP filter special characters are escaped correctly.</summary>
[Fact] [Fact]
public void EscapeLdapFilter_EscapesSpecialCharacters() public void EscapeLdapFilter_EscapesSpecialCharacters()
{ {
@@ -15,6 +16,9 @@ public sealed class DashboardAuthenticatorTests
Assert.Equal("a\\5cb\\2ac\\28d\\29e\\00f", escaped); Assert.Equal("a\\5cb\\2ac\\28d\\29e\\00f", escaped);
} }
/// <summary>Verifies that group-to-role mapping resolves by short name and distinguished name.</summary>
/// <param name="ldapGroup">The LDAP group name or distinguished name.</param>
/// <param name="expectedRole">The expected role or null if no match.</param>
[Theory] [Theory]
[InlineData("GwAdmin", DashboardRoles.Admin)] [InlineData("GwAdmin", DashboardRoles.Admin)]
[InlineData("gwadmin", DashboardRoles.Admin)] [InlineData("gwadmin", DashboardRoles.Admin)]
@@ -42,6 +46,7 @@ public sealed class DashboardAuthenticatorTests
} }
} }
/// <summary>Verifies that admin and viewer roles are both emitted when groups are present.</summary>
[Fact] [Fact]
public void MapGroupsToRoles_AdminPlusViewer_BothRolesEmitted() public void MapGroupsToRoles_AdminPlusViewer_BothRolesEmitted()
{ {
@@ -59,6 +64,7 @@ public sealed class DashboardAuthenticatorTests
Assert.Contains(DashboardRoles.Viewer, roles); Assert.Contains(DashboardRoles.Viewer, roles);
} }
/// <summary>Verifies that extraction returns the leading RDN value from a distinguished name.</summary>
[Fact] [Fact]
public void ExtractFirstRdnValue_ReturnsLeadingRdnValue() public void ExtractFirstRdnValue_ReturnsLeadingRdnValue()
{ {
@@ -68,6 +74,7 @@ public sealed class DashboardAuthenticatorTests
Assert.Equal("Gateway Admins", result); Assert.Equal("Gateway Admins", result);
} }
/// <summary>Verifies that authentication fails when LDAP is disabled without exposing raw credentials.</summary>
[Fact] [Fact]
public async Task AuthenticateAsync_LdapDisabled_ReturnsFailureWithoutRawCredentials() public async Task AuthenticateAsync_LdapDisabled_ReturnsFailureWithoutRawCredentials()
{ {
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// </summary> /// </summary>
public sealed class DashboardBrowseAndAlarmModelTests public sealed class DashboardBrowseAndAlarmModelTests
{ {
/// <summary>Verifies that the tree builder links children to parents and promotes orphans to roots.</summary>
[Fact] [Fact]
public void BuildTree_LinksChildrenToParents_AndPromotesOrphansToRoots() public void BuildTree_LinksChildrenToParents_AndPromotesOrphansToRoots()
{ {
@@ -27,6 +28,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Contains(roots, node => node.Object.GobjectId == 3); Assert.Contains(roots, node => node.Object.GobjectId == 3);
} }
/// <summary>Verifies that the tree builder sorts areas before non-area objects.</summary>
[Fact] [Fact]
public void BuildTree_SortsAreasBeforeObjects() public void BuildTree_SortsAreasBeforeObjects()
{ {
@@ -41,6 +43,9 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.False(roots[1].IsArea); Assert.False(roots[1].IsArea);
} }
/// <summary>Verifies that the formatter renders boolean values correctly.</summary>
/// <param name="input">The boolean input value.</param>
/// <param name="expected">The expected formatted output.</param>
[Theory] [Theory]
[InlineData(true, "true")] [InlineData(true, "true")]
[InlineData(false, "false")] [InlineData(false, "false")]
@@ -50,6 +55,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Equal(expected, DashboardMxValueFormatter.FormatValue(value)); Assert.Equal(expected, DashboardMxValueFormatter.FormatValue(value));
} }
/// <summary>Verifies that the formatter renders numbers and strings correctly.</summary>
[Fact] [Fact]
public void FormatValue_FormatsNumbersAndStrings() public void FormatValue_FormatsNumbersAndStrings()
{ {
@@ -57,6 +63,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Equal("hello", DashboardMxValueFormatter.FormatValue(new MxValue { StringValue = "hello" })); Assert.Equal("hello", DashboardMxValueFormatter.FormatValue(new MxValue { StringValue = "hello" }));
} }
/// <summary>Verifies that the formatter handles null payloads and null references.</summary>
[Fact] [Fact]
public void FormatValue_HandlesNullPayloadAndNullReference() public void FormatValue_HandlesNullPayloadAndNullReference()
{ {
@@ -64,6 +71,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Equal("(null)", DashboardMxValueFormatter.FormatValue(new MxValue { IsNull = true })); Assert.Equal("(null)", DashboardMxValueFormatter.FormatValue(new MxValue { IsNull = true }));
} }
/// <summary>Verifies that tag values from successful reads mark good quality.</summary>
[Fact] [Fact]
public void TagValue_FromSuccessfulReadResult_MarksGoodQuality() public void TagValue_FromSuccessfulReadResult_MarksGoodQuality()
{ {
@@ -83,6 +91,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Null(value.Error); Assert.Null(value.Error);
} }
/// <summary>Verifies that tag values from failed reads carry the error message.</summary>
[Fact] [Fact]
public void TagValue_FromFailedReadResult_CarriesError() public void TagValue_FromFailedReadResult_CarriesError()
{ {
@@ -101,6 +110,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Equal("invalid handle", value.Error); Assert.Equal("invalid handle", value.Error);
} }
/// <summary>Verifies that active alarms parse provider and acknowledgement state from snapshots.</summary>
[Fact] [Fact]
public void ActiveAlarm_FromSnapshot_ParsesProviderAndAcknowledgementState() public void ActiveAlarm_FromSnapshot_ParsesProviderAndAcknowledgementState()
{ {
@@ -127,6 +137,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.False(ackedRow.IsUnacknowledged); Assert.False(ackedRow.IsUnacknowledged);
} }
/// <summary>Verifies that the formatter renders array elements and element type correctly.</summary>
[Fact] [Fact]
public void FormatValue_AndDataType_RenderArrayElementsAndElementType() public void FormatValue_AndDataType_RenderArrayElementsAndElementType()
{ {
@@ -4,6 +4,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardConnectionStringDisplayTests public sealed class DashboardConnectionStringDisplayTests
{ {
/// <summary>Verifies that Galaxy connection strings strip SQL credentials and keep only non-secret fields.</summary>
[Fact] [Fact]
public void GalaxyRepositoryConnectionString_WithSqlCredentials_OnlyKeepsNonSecretFields() public void GalaxyRepositoryConnectionString_WithSqlCredentials_OnlyKeepsNonSecretFields()
{ {
@@ -30,4 +30,23 @@ public sealed class DashboardCookieOptionsTests
Assert.Equal("/logout", options.LogoutPath); Assert.Equal("/logout", options.LogoutPath);
Assert.Equal("/denied", options.AccessDeniedPath); Assert.Equal("/denied", options.AccessDeniedPath);
} }
/// <summary>
/// Verifies that setting <c>MxGateway:Dashboard:RequireHttpsCookie=false</c>
/// relaxes the cookie to <see cref="CookieSecurePolicy.SameAsRequest"/> so
/// the dashboard can be reached over plain HTTP in dev.
/// </summary>
[Fact]
public async Task Build_WithRequireHttpsCookieFalse_UsesSameAsRequest()
{
await using WebApplication app = GatewayApplication.Build(
["--MxGateway:Dashboard:RequireHttpsCookie=false"]);
IOptionsMonitor<CookieAuthenticationOptions> optionsMonitor = app.Services
.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>();
CookieAuthenticationOptions options = optionsMonitor.Get(
DashboardAuthenticationDefaults.AuthenticationScheme);
Assert.Equal(CookieSecurePolicy.SameAsRequest, options.Cookie.SecurePolicy);
}
} }
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardHubsRegistrationTests public sealed class DashboardHubsRegistrationTests
{ {
/// <summary>Verifies that dashboard build maps all three hubs and token endpoint.</summary>
[Fact] [Fact]
public async Task Build_WhenDashboardEnabled_MapsAllThreeHubsAndTokenEndpoint() public async Task Build_WhenDashboardEnabled_MapsAllThreeHubsAndTokenEndpoint()
{ {
@@ -25,6 +26,7 @@ public sealed class DashboardHubsRegistrationTests
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardHubToken"); endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardHubToken");
} }
/// <summary>Verifies that dashboard build registers hub token service and connection factory.</summary>
[Fact] [Fact]
public async Task Build_WhenDashboardEnabled_RegistersHubTokenServiceAndConnectionFactory() public async Task Build_WhenDashboardEnabled_RegistersHubTokenServiceAndConnectionFactory()
{ {
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardSessionAdminServiceTests public sealed class DashboardSessionAdminServiceTests
{ {
/// <summary>Verifies that a viewer cannot close a session.</summary>
[Fact] [Fact]
public async Task CloseSessionAsync_ViewerCannotManage() public async Task CloseSessionAsync_ViewerCannotManage()
{ {
@@ -24,6 +25,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Equal(0, sessionManager.CloseCount); Assert.Equal(0, sessionManager.CloseCount);
} }
/// <summary>Verifies that an admin can close a session.</summary>
[Fact] [Fact]
public async Task CloseSessionAsync_AdminClosesSession() public async Task CloseSessionAsync_AdminClosesSession()
{ {
@@ -40,6 +42,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Equal("session-1", sessionManager.LastClosedSessionId); Assert.Equal("session-1", sessionManager.LastClosedSessionId);
} }
/// <summary>Verifies that closing a missing session returns a friendly error message.</summary>
[Fact] [Fact]
public async Task CloseSessionAsync_WhenSessionMissing_ReportsFriendlyError() public async Task CloseSessionAsync_WhenSessionMissing_ReportsFriendlyError()
{ {
@@ -58,6 +61,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Contains("not found", result.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("not found", result.Message, StringComparison.OrdinalIgnoreCase);
} }
/// <summary>Verifies that a viewer cannot kill a worker.</summary>
[Fact] [Fact]
public async Task KillWorkerAsync_ViewerCannotManage() public async Task KillWorkerAsync_ViewerCannotManage()
{ {
@@ -73,6 +77,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Equal(0, sessionManager.KillCount); Assert.Equal(0, sessionManager.KillCount);
} }
/// <summary>Verifies that an admin can kill a worker.</summary>
[Fact] [Fact]
public async Task KillWorkerAsync_AdminKillsWorker() public async Task KillWorkerAsync_AdminKillsWorker()
{ {
@@ -95,6 +100,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Equal("dashboard-admin-kill", sessionManager.LastKillReason); Assert.Equal("dashboard-admin-kill", sessionManager.LastKillReason);
} }
/// <summary>Verifies that killing a worker with a blank session ID returns failure.</summary>
[Fact] [Fact]
public async Task KillWorkerAsync_BlankSessionId_ReturnsFailure() public async Task KillWorkerAsync_BlankSessionId_ReturnsFailure()
{ {
@@ -130,6 +136,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Equal(0, sessionManager.CloseCount); Assert.Equal(0, sessionManager.CloseCount);
} }
/// <summary>Verifies that CanManage rejects unauthenticated users and viewers.</summary>
[Fact] [Fact]
public void CanManage_RejectsUnauthenticatedAndViewer() public void CanManage_RejectsUnauthenticatedAndViewer()
{ {
@@ -209,22 +216,31 @@ public sealed class DashboardSessionAdminServiceTests
private sealed class FakeSessionManager : ISessionManager private sealed class FakeSessionManager : ISessionManager
{ {
/// <summary>Gets the number of times CloseSessionAsync was invoked.</summary>
public int CloseCount { get; private set; } public int CloseCount { get; private set; }
/// <summary>Gets the number of times KillWorkerAsync was invoked.</summary>
public int KillCount { get; private set; } public int KillCount { get; private set; }
/// <summary>Gets the last session ID passed to CloseSessionAsync.</summary>
public string? LastClosedSessionId { get; private set; } public string? LastClosedSessionId { get; private set; }
/// <summary>Gets the last session ID passed to KillWorkerAsync.</summary>
public string? LastKilledSessionId { get; private set; } public string? LastKilledSessionId { get; private set; }
/// <summary>Gets the last reason string passed to KillWorkerAsync.</summary>
public string? LastKillReason { get; private set; } public string? LastKillReason { get; private set; }
/// <summary>Gets a value indicating whether CloseSessionAsync should throw SessionNotFound.</summary>
public bool CloseThrowsNotFound { get; init; } public bool CloseThrowsNotFound { get; init; }
/// <summary>Gets the exception CloseSessionAsync should throw unexpectedly.</summary>
public Exception? CloseThrowsUnexpected { get; init; } public Exception? CloseThrowsUnexpected { get; init; }
/// <summary>Gets the exception KillWorkerAsync should throw unexpectedly.</summary>
public Exception? KillThrowsUnexpected { get; init; } public Exception? KillThrowsUnexpected { get; init; }
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync( public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request, SessionOpenRequest request,
string? clientIdentity, string? clientIdentity,
@@ -233,6 +249,7 @@ public sealed class DashboardSessionAdminServiceTests
throw new NotSupportedException(); throw new NotSupportedException();
} }
/// <inheritdoc />
public bool TryGetSession( public bool TryGetSession(
string sessionId, string sessionId,
[MaybeNullWhen(false)] out GatewaySession session) [MaybeNullWhen(false)] out GatewaySession session)
@@ -241,6 +258,7 @@ public sealed class DashboardSessionAdminServiceTests
return false; return false;
} }
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync( public Task<WorkerCommandReply> InvokeAsync(
string sessionId, string sessionId,
WorkerCommand command, WorkerCommand command,
@@ -249,6 +267,7 @@ public sealed class DashboardSessionAdminServiceTests
throw new NotSupportedException(); throw new NotSupportedException();
} }
/// <inheritdoc />
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync( public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId, string sessionId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -256,6 +275,7 @@ public sealed class DashboardSessionAdminServiceTests
throw new NotSupportedException(); throw new NotSupportedException();
} }
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync( public Task<SessionCloseResult> CloseSessionAsync(
string sessionId, string sessionId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -277,6 +297,7 @@ public sealed class DashboardSessionAdminServiceTests
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
} }
/// <inheritdoc />
public Task<SessionCloseResult> KillWorkerAsync( public Task<SessionCloseResult> KillWorkerAsync(
string sessionId, string sessionId,
string reason, string reason,
@@ -293,6 +314,7 @@ public sealed class DashboardSessionAdminServiceTests
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
} }
/// <inheritdoc />
public Task<int> CloseExpiredLeasesAsync( public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now, DateTimeOffset now,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -300,6 +322,7 @@ public sealed class DashboardSessionAdminServiceTests
return Task.FromResult(0); return Task.FromResult(0);
} }
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken) public Task ShutdownAsync(CancellationToken cancellationToken)
{ {
return Task.CompletedTask; return Task.CompletedTask;
@@ -110,6 +110,7 @@ public sealed class DashboardSnapshotPublisherTests
private sealed class ThrowOnceThenYieldSnapshotService : IDashboardSnapshotService private sealed class ThrowOnceThenYieldSnapshotService : IDashboardSnapshotService
{ {
/// <summary>Gets the number of subscription attempts.</summary>
public int SubscribeCount { get; private set; } public int SubscribeCount { get; private set; }
/// <summary> /// <summary>
@@ -120,13 +121,17 @@ public sealed class DashboardSnapshotPublisherTests
/// </summary> /// </summary>
public DateTimeOffset? FirstThrowAt { get; private set; } public DateTimeOffset? FirstThrowAt { get; private set; }
/// <summary>Gets the wall-clock instant of the second subscription attempt.</summary>
public DateTimeOffset? SecondSubscribeAt { get; private set; } public DateTimeOffset? SecondSubscribeAt { get; private set; }
/// <summary>Gets the current snapshot.</summary>
public DashboardSnapshot GetSnapshot() public DashboardSnapshot GetSnapshot()
{ {
return null!; return null!;
} }
/// <summary>Watches for snapshot changes and yields them asynchronously.</summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync( public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken) [EnumeratorCancellation] CancellationToken cancellationToken)
{ {
@@ -159,13 +164,17 @@ public sealed class DashboardSnapshotPublisherTests
private sealed class CompleteImmediatelySnapshotService : IDashboardSnapshotService private sealed class CompleteImmediatelySnapshotService : IDashboardSnapshotService
{ {
/// <summary>Gets the number of subscription attempts.</summary>
public int SubscribeCount { get; private set; } public int SubscribeCount { get; private set; }
/// <summary>Gets the current snapshot.</summary>
public DashboardSnapshot GetSnapshot() public DashboardSnapshot GetSnapshot()
{ {
return null!; return null!;
} }
/// <summary>Watches for snapshot changes and completes immediately.</summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
#pragma warning disable CS1998 // async without await — IAsyncEnumerable contract requires async signature #pragma warning disable CS1998 // async without await — IAsyncEnumerable contract requires async signature
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync( public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken) [EnumeratorCancellation] CancellationToken cancellationToken)
@@ -182,24 +191,55 @@ public sealed class DashboardSnapshotPublisherTests
{ {
private readonly RecordingHubClients _clients = new(); private readonly RecordingHubClients _clients = new();
/// <summary>Gets the hub clients.</summary>
public IHubClients Clients => _clients; public IHubClients Clients => _clients;
/// <summary>Gets the group manager.</summary>
public IGroupManager Groups { get; } = new NoopGroupManager(); public IGroupManager Groups { get; } = new NoopGroupManager();
/// <summary>Gets the number of send calls recorded.</summary>
public int SendCount => _clients.AllProxy.SendCount; public int SendCount => _clients.AllProxy.SendCount;
} }
private sealed class RecordingHubClients : IHubClients private sealed class RecordingHubClients : IHubClients
{ {
/// <summary>Gets the recording client proxy for all clients.</summary>
public RecordingClientProxy AllProxy { get; } = new(); public RecordingClientProxy AllProxy { get; } = new();
/// <summary>Gets a client proxy targeting all clients.</summary>
public IClientProxy All => AllProxy; public IClientProxy All => AllProxy;
/// <summary>Gets a client proxy excluding specified connections.</summary>
/// <param name="excludedConnectionIds">Connection identifiers to exclude.</param>
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => AllProxy; public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => AllProxy;
/// <summary>Gets a client proxy for a specific connection.</summary>
/// <param name="connectionId">The connection identifier.</param>
public IClientProxy Client(string connectionId) => AllProxy; public IClientProxy Client(string connectionId) => AllProxy;
/// <summary>Gets a client proxy for specified connections.</summary>
/// <param name="connectionIds">The connection identifiers.</param>
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => AllProxy; public IClientProxy Clients(IReadOnlyList<string> connectionIds) => AllProxy;
/// <summary>Gets a client proxy for a group.</summary>
/// <param name="groupName">The group name.</param>
public IClientProxy Group(string groupName) => AllProxy; public IClientProxy Group(string groupName) => AllProxy;
/// <summary>Gets a client proxy for a group excluding specified connections.</summary>
/// <param name="groupName">The group name.</param>
/// <param name="excludedConnectionIds">Connection identifiers to exclude.</param>
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => AllProxy; public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => AllProxy;
/// <summary>Gets a client proxy for specified groups.</summary>
/// <param name="groupNames">The group names.</param>
public IClientProxy Groups(IReadOnlyList<string> groupNames) => AllProxy; public IClientProxy Groups(IReadOnlyList<string> groupNames) => AllProxy;
/// <summary>Gets a client proxy for a specific user.</summary>
/// <param name="userId">The user identifier.</param>
public IClientProxy User(string userId) => AllProxy; public IClientProxy User(string userId) => AllProxy;
/// <summary>Gets a client proxy for specified users.</summary>
/// <param name="userIds">The user identifiers.</param>
public IClientProxy Users(IReadOnlyList<string> userIds) => AllProxy; public IClientProxy Users(IReadOnlyList<string> userIds) => AllProxy;
} }
@@ -207,8 +247,13 @@ public sealed class DashboardSnapshotPublisherTests
{ {
private int _sendCount; private int _sendCount;
/// <summary>Gets the number of send calls recorded.</summary>
public int SendCount => Volatile.Read(ref _sendCount); public int SendCount => Volatile.Read(ref _sendCount);
/// <summary>Records a send call and completes asynchronously.</summary>
/// <param name="method">The SignalR method name.</param>
/// <param name="args">The method arguments.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{ {
Interlocked.Increment(ref _sendCount); Interlocked.Increment(ref _sendCount);
@@ -218,9 +263,17 @@ public sealed class DashboardSnapshotPublisherTests
private sealed class NoopGroupManager : IGroupManager private sealed class NoopGroupManager : IGroupManager
{ {
/// <summary>Completes immediately without performing group addition.</summary>
/// <param name="connectionId">The connection identifier.</param>
/// <param name="groupName">The group name.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
=> Task.CompletedTask; => Task.CompletedTask;
/// <summary>Completes immediately without performing group removal.</summary>
/// <param name="connectionId">The connection identifier.</param>
/// <param name="groupName">The group name.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
=> Task.CompletedTask; => Task.CompletedTask;
} }
@@ -267,6 +267,7 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal(0, apiKeyAdminStore.ListCount); Assert.Equal(0, apiKeyAdminStore.ListCount);
} }
/// <summary>Verifies that snapshot service refreshes API key summaries before each snapshot.</summary>
[Fact] [Fact]
public async Task WatchSnapshotsAsync_RefreshesApiKeySummariesBeforeSnapshot() public async Task WatchSnapshotsAsync_RefreshesApiKeySummariesBeforeSnapshot()
{ {
@@ -303,6 +304,7 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal(1, apiKeyAdminStore.ListCount); Assert.Equal(1, apiKeyAdminStore.ListCount);
} }
/// <summary>Verifies that snapshot service reuses previous summaries when API key refresh fails.</summary>
[Fact] [Fact]
public async Task WatchSnapshotsAsync_WhenApiKeyRefreshFails_ReusesPreviousSummaries() public async Task WatchSnapshotsAsync_WhenApiKeyRefreshFails_ReusesPreviousSummaries()
{ {
@@ -346,6 +348,7 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal(2, apiKeyAdminStore.ListCount); Assert.Equal(2, apiKeyAdminStore.ListCount);
} }
/// <summary>Verifies that snapshot service disposes cleanly when subscriber cancels.</summary>
[Fact] [Fact]
public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly() public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly()
{ {
@@ -421,16 +424,19 @@ public sealed class DashboardSnapshotServiceTests
private class FakeApiKeyAdminStore : IApiKeyAdminStore private class FakeApiKeyAdminStore : IApiKeyAdminStore
{ {
/// <inheritdoc />
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken) public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <inheritdoc />
public virtual Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken) public virtual Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{ {
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]); return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
} }
/// <inheritdoc />
public Task<bool> RevokeAsync( public Task<bool> RevokeAsync(
string keyId, string keyId,
DateTimeOffset revokedUtc, DateTimeOffset revokedUtc,
@@ -439,6 +445,7 @@ public sealed class DashboardSnapshotServiceTests
return Task.FromResult(false); return Task.FromResult(false);
} }
/// <inheritdoc />
public Task<bool> RotateAsync( public Task<bool> RotateAsync(
string keyId, string keyId,
byte[] secretHash, byte[] secretHash,
@@ -448,6 +455,7 @@ public sealed class DashboardSnapshotServiceTests
return Task.FromResult(false); return Task.FromResult(false);
} }
/// <inheritdoc />
public Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken) public Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken)
{ {
return Task.FromResult(false); return Task.FromResult(false);
@@ -456,8 +464,10 @@ public sealed class DashboardSnapshotServiceTests
private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore
{ {
/// <summary>Gets the count of list operations performed.</summary>
public int ListCount { get; protected set; } public int ListCount { get; protected set; }
/// <inheritdoc />
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken) public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{ {
ListCount++; ListCount++;
@@ -467,8 +477,10 @@ public sealed class DashboardSnapshotServiceTests
private sealed class SequencedApiKeyAdminStore(ApiKeyRecord record) : CountingApiKeyAdminStore(record) private sealed class SequencedApiKeyAdminStore(ApiKeyRecord record) : CountingApiKeyAdminStore(record)
{ {
/// <summary>Gets or sets a value indicating whether the next list operation should fail.</summary>
public bool FailNext { get; set; } public bool FailNext { get; set; }
/// <inheritdoc />
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken) public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{ {
if (FailNext) if (FailNext)
@@ -89,6 +89,7 @@ public sealed class HubTokenServiceTests
Assert.True(result.IsInRole(DashboardRoles.Viewer)); Assert.True(result.IsInRole(DashboardRoles.Viewer));
} }
/// <summary>Verifies that a null token returns null.</summary>
[Fact] [Fact]
public void Validate_NullToken_ReturnsNull() public void Validate_NullToken_ReturnsNull()
{ {
@@ -97,6 +98,7 @@ public sealed class HubTokenServiceTests
Assert.Null(service.Validate(null)); Assert.Null(service.Validate(null));
} }
/// <summary>Verifies that an empty token returns null.</summary>
[Fact] [Fact]
public void Validate_EmptyToken_ReturnsNull() public void Validate_EmptyToken_ReturnsNull()
{ {
@@ -105,6 +107,7 @@ public sealed class HubTokenServiceTests
Assert.Null(service.Validate(string.Empty)); Assert.Null(service.Validate(string.Empty));
} }
/// <summary>Verifies that an invalid token returns null.</summary>
[Fact] [Fact]
public void Validate_GarbageToken_ReturnsNull() public void Validate_GarbageToken_ReturnsNull()
{ {
@@ -102,6 +102,7 @@ public sealed class GatewayApplicationTests
} }
} }
/// <summary>Verifies that dashboard routes are registered at root when enabled.</summary>
[Fact] [Fact]
public async Task Build_WhenDashboardEnabled_RegistersDashboardRoutesAtRoot() public async Task Build_WhenDashboardEnabled_RegistersDashboardRoutesAtRoot()
{ {
@@ -126,6 +127,7 @@ public sealed class GatewayApplicationTests
} }
} }
/// <summary>Verifies that dashboard routes are not mapped when disabled.</summary>
[Fact] [Fact]
public async Task Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes() public async Task Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes()
{ {
@@ -355,8 +355,12 @@ public sealed class EventStreamServiceTests
private sealed class ThrowingDashboardEventBroadcaster : ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs.IDashboardEventBroadcaster private sealed class ThrowingDashboardEventBroadcaster : ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs.IDashboardEventBroadcaster
{ {
/// <summary>Gets the count of publish attempts.</summary>
public int PublishAttempts { get; private set; } public int PublishAttempts { get; private set; }
/// <summary>Increments the attempt count and throws a simulated failure.</summary>
/// <param name="sessionId">The session identifier.</param>
/// <param name="mxEvent">The event to publish.</param>
public void Publish(string sessionId, MxEvent mxEvent) public void Publish(string sessionId, MxEvent mxEvent)
{ {
PublishAttempts++; PublishAttempts++;
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc;
public sealed class GalaxyRepositoryGrpcServiceTests public sealed class GalaxyRepositoryGrpcServiceTests
{ {
/// <summary>Verifies that DiscoverHierarchy returns the requested page and totals.</summary>
[Fact] [Fact]
public async Task DiscoverHierarchy_ReturnsRequestedPageAndTotals() public async Task DiscoverHierarchy_ReturnsRequestedPageAndTotals()
{ {
@@ -31,6 +32,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Assert.Equal(3, reply.TotalObjectCount); Assert.Equal(3, reply.TotalObjectCount);
} }
/// <summary>Verifies that DiscoverHierarchy with a page token returns remaining objects.</summary>
[Fact] [Fact]
public async Task DiscoverHierarchy_WithNextPageToken_ReturnsRemainingObjects() public async Task DiscoverHierarchy_WithNextPageToken_ReturnsRemainingObjects()
{ {
@@ -56,6 +58,9 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Assert.Equal(3, reply.TotalObjectCount); Assert.Equal(3, reply.TotalObjectCount);
} }
/// <summary>Verifies that DiscoverHierarchy with invalid paging arguments returns InvalidArgument.</summary>
/// <param name="pageToken">The page token to test.</param>
/// <param name="pageSize">The page size to test.</param>
[Theory] [Theory]
[InlineData("-1", 1)] [InlineData("-1", 1)]
[InlineData("not-an-offset", 1)] [InlineData("not-an-offset", 1)]
@@ -80,6 +85,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
} }
/// <summary>Verifies that DiscoverHierarchy with subtree root and depth filters descendants.</summary>
[Fact] [Fact]
public async Task DiscoverHierarchy_WithSubtreeRootAndDepth_FiltersDescendants() public async Task DiscoverHierarchy_WithSubtreeRootAndDepth_FiltersDescendants()
{ {
@@ -98,6 +104,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Assert.Equal(3, reply.TotalObjectCount); Assert.Equal(3, reply.TotalObjectCount);
} }
/// <summary>Verifies that DiscoverHierarchy applies server-side filters and omits attributes.</summary>
[Fact] [Fact]
public async Task DiscoverHierarchy_WithServerSideFilters_AppliesAllFiltersAndOmitsAttributes() public async Task DiscoverHierarchy_WithServerSideFilters_AppliesAllFiltersAndOmitsAttributes()
{ {
@@ -123,6 +130,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Assert.Equal(1, reply.TotalObjectCount); Assert.Equal(1, reply.TotalObjectCount);
} }
/// <summary>Verifies that DiscoverHierarchy with filtered paging returns post-filter total.</summary>
[Fact] [Fact]
public async Task DiscoverHierarchy_WithFilteredPaging_ReturnsPostFilterTotal() public async Task DiscoverHierarchy_WithFilteredPaging_ReturnsPostFilterTotal()
{ {
@@ -154,6 +162,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Assert.NotEqual(firstObject.TagName, secondObject.TagName); Assert.NotEqual(firstObject.TagName, secondObject.TagName);
} }
/// <summary>Verifies that DiscoverHierarchy with mismatched filter token returns InvalidArgument.</summary>
[Fact] [Fact]
public async Task DiscoverHierarchy_WithMismatchedFilterToken_ReturnsInvalidArgument() public async Task DiscoverHierarchy_WithMismatchedFilterToken_ReturnsInvalidArgument()
{ {
@@ -180,6 +189,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Assert.Contains("filters", exception.Status.Detail, StringComparison.OrdinalIgnoreCase); Assert.Contains("filters", exception.Status.Detail, StringComparison.OrdinalIgnoreCase);
} }
/// <summary>Verifies that DiscoverHierarchy with missing root returns NotFound.</summary>
[Fact] [Fact]
public async Task DiscoverHierarchy_WithMissingRoot_ReturnsNotFound() public async Task DiscoverHierarchy_WithMissingRoot_ReturnsNotFound()
{ {
@@ -315,10 +325,13 @@ public sealed class GalaxyRepositoryGrpcServiceTests
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{ {
/// <inheritdoc />
public GalaxyHierarchyCacheEntry Current { get; } = current; public GalaxyHierarchyCacheEntry Current { get; } = current;
/// <inheritdoc />
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
} }
@@ -827,12 +827,16 @@ public sealed class MxAccessGatewayServiceConstraintTests
{ {
private readonly Dictionary<string, GatewaySession> seededSessions = new(StringComparer.Ordinal); private readonly Dictionary<string, GatewaySession> seededSessions = new(StringComparer.Ordinal);
/// <summary>Gets a value indicating whether only seeded sessions should be resolved.</summary>
public bool ResolveOnlySeededSessions { get; init; } public bool ResolveOnlySeededSessions { get; init; }
/// <summary>Gets the last worker command that was invoked.</summary>
public WorkerCommand? LastWorkerCommand { get; private set; } public WorkerCommand? LastWorkerCommand { get; private set; }
/// <summary>Gets the count of invoke calls made.</summary>
public int InvokeCount { get; private set; } public int InvokeCount { get; private set; }
/// <summary>Gets or sets the default invoke reply to return.</summary>
public WorkerCommandReply InvokeReply { get; set; } = new() public WorkerCommandReply InvokeReply { get; set; } = new()
{ {
Reply = new MxCommandReply Reply = new MxCommandReply
@@ -843,16 +847,26 @@ public sealed class MxAccessGatewayServiceConstraintTests
}, },
}; };
/// <summary>Gets the collection of events to stream.</summary>
public List<WorkerEvent> Events { get; } = []; public List<WorkerEvent> Events { get; } = [];
/// <summary>Seeds a test session into the fake manager.</summary>
/// <param name="session">The session to seed.</param>
public void SeedSession(GatewaySession session) => seededSessions[session.SessionId] = session; public void SeedSession(GatewaySession session) => seededSessions[session.SessionId] = session;
/// <summary>Opens a test session asynchronously.</summary>
/// <param name="request">The session open request.</param>
/// <param name="clientIdentity">The client identity, if any.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task<GatewaySession> OpenSessionAsync( public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request, SessionOpenRequest request,
string? clientIdentity, string? clientIdentity,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
Task.FromResult(seededSessions.Values.First()); Task.FromResult(seededSessions.Values.First());
/// <summary>Tries to get a test session by identifier.</summary>
/// <param name="sessionId">The session identifier.</param>
/// <param name="session">The session, if found.</param>
public bool TryGetSession(string sessionId, out GatewaySession session) public bool TryGetSession(string sessionId, out GatewaySession session)
{ {
if (seededSessions.TryGetValue(sessionId, out GatewaySession? seeded)) if (seededSessions.TryGetValue(sessionId, out GatewaySession? seeded))
@@ -871,6 +885,10 @@ public sealed class MxAccessGatewayServiceConstraintTests
return true; return true;
} }
/// <summary>Invokes a worker command and returns the reply asynchronously.</summary>
/// <param name="sessionId">The session identifier.</param>
/// <param name="command">The worker command.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task<WorkerCommandReply> InvokeAsync( public Task<WorkerCommandReply> InvokeAsync(
string sessionId, string sessionId,
WorkerCommand command, WorkerCommand command,
@@ -881,6 +899,9 @@ public sealed class MxAccessGatewayServiceConstraintTests
return Task.FromResult(InvokeReply); return Task.FromResult(InvokeReply);
} }
/// <summary>Reads events from the session asynchronously.</summary>
/// <param name="sessionId">The session identifier.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync( public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId, string sessionId,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
@@ -893,21 +914,33 @@ public sealed class MxAccessGatewayServiceConstraintTests
} }
} }
/// <summary>Closes a test session asynchronously.</summary>
/// <param name="sessionId">The session identifier.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task<SessionCloseResult> CloseSessionAsync( public Task<SessionCloseResult> CloseSessionAsync(
string sessionId, string sessionId,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
/// <summary>Kills a worker process asynchronously.</summary>
/// <param name="sessionId">The session identifier.</param>
/// <param name="reason">The reason for killing the worker.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task<SessionCloseResult> KillWorkerAsync( public Task<SessionCloseResult> KillWorkerAsync(
string sessionId, string sessionId,
string reason, string reason,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
/// <summary>Closes expired session leases asynchronously.</summary>
/// <param name="now">The current time to check against.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task<int> CloseExpiredLeasesAsync( public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now, DateTimeOffset now,
CancellationToken cancellationToken) => Task.FromResult(0); CancellationToken cancellationToken) => Task.FromResult(0);
/// <summary>Shuts down the test session manager asynchronously.</summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private static GatewaySession CreateFallbackSession(string sessionId) private static GatewaySession CreateFallbackSession(string sessionId)
@@ -932,6 +965,9 @@ public sealed class MxAccessGatewayServiceConstraintTests
private sealed class FakeEventStreamService(FakeSessionManager sessionManager) : IEventStreamService private sealed class FakeEventStreamService(FakeSessionManager sessionManager) : IEventStreamService
{ {
/// <summary>Streams events for the test session asynchronously.</summary>
/// <param name="request">The stream events request.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public async IAsyncEnumerable<MxEvent> StreamEventsAsync( public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request, StreamEventsRequest request,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
@@ -947,21 +983,33 @@ public sealed class MxAccessGatewayServiceConstraintTests
private sealed class FakeWorkerClient : IWorkerClient private sealed class FakeWorkerClient : IWorkerClient
{ {
/// <summary>Gets the test session identifier.</summary>
public string SessionId { get; } = MxAccessGatewayServiceConstraintTests.SessionId; public string SessionId { get; } = MxAccessGatewayServiceConstraintTests.SessionId;
/// <summary>Gets the test worker process identifier.</summary>
public int? ProcessId { get; } = 1234; public int? ProcessId { get; } = 1234;
/// <summary>Gets the test worker client state.</summary>
public WorkerClientState State { get; } = WorkerClientState.Ready; public WorkerClientState State { get; } = WorkerClientState.Ready;
/// <summary>Gets the last recorded heartbeat time.</summary>
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow; public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// <summary>Starts the test worker client asynchronously.</summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>Invokes a command on the test worker asynchronously.</summary>
/// <param name="command">The worker command.</param>
/// <param name="timeout">Maximum time to wait for completion.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task<WorkerCommandReply> InvokeAsync( public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command, WorkerCommand command,
TimeSpan timeout, TimeSpan timeout,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply()); CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
/// <summary>Reads events from the test worker asynchronously.</summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync( public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{ {
@@ -969,12 +1017,18 @@ public sealed class MxAccessGatewayServiceConstraintTests
yield break; yield break;
} }
/// <summary>Shuts down the test worker client asynchronously.</summary>
/// <param name="timeout">Maximum time to wait for completion.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask; public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>Kills the test worker process.</summary>
/// <param name="reason">The reason for killing the worker.</param>
public void Kill(string reason) public void Kill(string reason)
{ {
} }
/// <inheritdoc />
public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;
} }
} }
@@ -187,35 +187,46 @@ public sealed class GatewaySessionTests
private readonly TaskCompletionSource _shutdownStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _shutdownStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _shutdownReleased = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _shutdownReleased = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Gets the session identifier.</summary>
public string SessionId { get; } = "session-test"; public string SessionId { get; } = "session-test";
/// <summary>Gets the worker process identifier.</summary>
public int? ProcessId { get; } = 1234; public int? ProcessId { get; } = 1234;
/// <summary>Gets or sets the worker client state.</summary>
public WorkerClientState State { get; private set; } = WorkerClientState.Ready; public WorkerClientState State { get; private set; } = WorkerClientState.Ready;
/// <summary>Gets the last recorded heartbeat timestamp.</summary>
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow; public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// <summary>Gets the count of shutdown invocations.</summary>
public int ShutdownCount { get; private set; } public int ShutdownCount { get; private set; }
/// <summary>Gets the count of dispose invocations.</summary>
public int DisposeCount { get; private set; } public int DisposeCount { get; private set; }
/// <summary>Waits for shutdown to start.</summary>
public Task WaitForShutdownStartAsync() public Task WaitForShutdownStartAsync()
{ {
return _shutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); return _shutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
} }
/// <summary>Releases the shutdown block.</summary>
public void ReleaseShutdown() public void ReleaseShutdown()
{ {
_shutdownReleased.TrySetResult(); _shutdownReleased.TrySetResult();
} }
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync( public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command, WorkerCommand command,
TimeSpan timeout, TimeSpan timeout,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply()); CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync( public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken) [EnumeratorCancellation] CancellationToken cancellationToken)
{ {
@@ -223,6 +234,7 @@ public sealed class GatewaySessionTests
yield break; yield break;
} }
/// <inheritdoc />
public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
{ {
ShutdownCount++; ShutdownCount++;
@@ -231,11 +243,13 @@ public sealed class GatewaySessionTests
State = WorkerClientState.Closed; State = WorkerClientState.Closed;
} }
/// <inheritdoc />
public void Kill(string reason) public void Kill(string reason)
{ {
State = WorkerClientState.Faulted; State = WorkerClientState.Faulted;
} }
/// <inheritdoc />
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
DisposeCount++; DisposeCount++;
@@ -245,23 +259,31 @@ public sealed class GatewaySessionTests
private sealed class FakeWorkerClient : IWorkerClient private sealed class FakeWorkerClient : IWorkerClient
{ {
/// <summary>Gets the session identifier.</summary>
public string SessionId { get; } = "session-test"; public string SessionId { get; } = "session-test";
/// <summary>Gets the worker process identifier.</summary>
public int? ProcessId { get; } = 1234; public int? ProcessId { get; } = 1234;
/// <summary>Gets the worker client state.</summary>
public WorkerClientState State { get; } = WorkerClientState.Ready; public WorkerClientState State { get; } = WorkerClientState.Ready;
/// <summary>Gets the last recorded heartbeat timestamp.</summary>
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow; public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// <summary>Gets the count of dispose invocations.</summary>
public int DisposeCount { get; private set; } public int DisposeCount { get; private set; }
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync( public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command, WorkerCommand command,
TimeSpan timeout, TimeSpan timeout,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply()); CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync( public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken) [EnumeratorCancellation] CancellationToken cancellationToken)
{ {
@@ -269,12 +291,15 @@ public sealed class GatewaySessionTests
yield break; yield break;
} }
/// <inheritdoc />
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask; public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public void Kill(string reason) public void Kill(string reason)
{ {
} }
/// <inheritdoc />
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
DisposeCount++; DisposeCount++;
@@ -20,6 +20,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
/// </summary> /// </summary>
public sealed class SessionManagerBulkTests public sealed class SessionManagerBulkTests
{ {
/// <summary>Verifies that AddItemBulkAsync forwards the command and returns results.</summary>
[Fact] [Fact]
public async Task AddItemBulkAsync_ForwardsOneAddItemBulkCommandAndReturnsResults() public async Task AddItemBulkAsync_ForwardsOneAddItemBulkCommandAndReturnsResults()
{ {
@@ -48,6 +49,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal("invalid tag", results[1].ErrorMessage); Assert.Equal("invalid tag", results[1].ErrorMessage);
} }
/// <summary>Verifies that AddItemBulkAsync propagates cancellation.</summary>
[Fact] [Fact]
public async Task AddItemBulkAsync_PropagatesCancellation() public async Task AddItemBulkAsync_PropagatesCancellation()
{ {
@@ -60,6 +62,7 @@ public sealed class SessionManagerBulkTests
async () => await session.AddItemBulkAsync(12, ["Tag.A"], cts.Token)); async () => await session.AddItemBulkAsync(12, ["Tag.A"], cts.Token));
} }
/// <summary>Verifies that AdviseItemBulkAsync forwards the command and returns results.</summary>
[Fact] [Fact]
public async Task AdviseItemBulkAsync_ForwardsOneAdviseItemBulkCommandAndReturnsResults() public async Task AdviseItemBulkAsync_ForwardsOneAdviseItemBulkCommandAndReturnsResults()
{ {
@@ -86,6 +89,7 @@ public sealed class SessionManagerBulkTests
Assert.False(results[1].WasSuccessful); Assert.False(results[1].WasSuccessful);
} }
/// <summary>Verifies that AdviseItemBulkAsync propagates cancellation.</summary>
[Fact] [Fact]
public async Task AdviseItemBulkAsync_PropagatesCancellation() public async Task AdviseItemBulkAsync_PropagatesCancellation()
{ {
@@ -98,6 +102,7 @@ public sealed class SessionManagerBulkTests
async () => await session.AdviseItemBulkAsync(12, [101], cts.Token)); async () => await session.AdviseItemBulkAsync(12, [101], cts.Token));
} }
/// <summary>Verifies that RemoveItemBulkAsync forwards the command and returns results.</summary>
[Fact] [Fact]
public async Task RemoveItemBulkAsync_ForwardsOneRemoveItemBulkCommandAndReturnsResults() public async Task RemoveItemBulkAsync_ForwardsOneRemoveItemBulkCommandAndReturnsResults()
{ {
@@ -122,6 +127,7 @@ public sealed class SessionManagerBulkTests
Assert.False(results[1].WasSuccessful); Assert.False(results[1].WasSuccessful);
} }
/// <summary>Verifies that RemoveItemBulkAsync propagates cancellation.</summary>
[Fact] [Fact]
public async Task RemoveItemBulkAsync_PropagatesCancellation() public async Task RemoveItemBulkAsync_PropagatesCancellation()
{ {
@@ -134,6 +140,7 @@ public sealed class SessionManagerBulkTests
async () => await session.RemoveItemBulkAsync(12, [11], cts.Token)); async () => await session.RemoveItemBulkAsync(12, [11], cts.Token));
} }
/// <summary>Verifies that UnAdviseItemBulkAsync forwards the command and returns results.</summary>
[Fact] [Fact]
public async Task UnAdviseItemBulkAsync_ForwardsOneUnAdviseItemBulkCommandAndReturnsResults() public async Task UnAdviseItemBulkAsync_ForwardsOneUnAdviseItemBulkCommandAndReturnsResults()
{ {
@@ -159,6 +166,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal("not advised", results[1].ErrorMessage); Assert.Equal("not advised", results[1].ErrorMessage);
} }
/// <summary>Verifies that UnAdviseItemBulkAsync propagates cancellation.</summary>
[Fact] [Fact]
public async Task UnAdviseItemBulkAsync_PropagatesCancellation() public async Task UnAdviseItemBulkAsync_PropagatesCancellation()
{ {
@@ -171,6 +179,7 @@ public sealed class SessionManagerBulkTests
async () => await session.UnAdviseItemBulkAsync(12, [21], cts.Token)); async () => await session.UnAdviseItemBulkAsync(12, [21], cts.Token));
} }
/// <summary>Verifies that SubscribeBulkAsync surfaces per-entry failures.</summary>
[Fact] [Fact]
public async Task SubscribeBulkAsync_SurfacesPerEntryFailures() public async Task SubscribeBulkAsync_SurfacesPerEntryFailures()
{ {
@@ -198,6 +207,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal("MXAccess subscribe failed", results[1].ErrorMessage); Assert.Equal("MXAccess subscribe failed", results[1].ErrorMessage);
} }
/// <summary>Verifies that SubscribeBulkAsync propagates cancellation.</summary>
[Fact] [Fact]
public async Task SubscribeBulkAsync_PropagatesCancellation() public async Task SubscribeBulkAsync_PropagatesCancellation()
{ {
@@ -210,6 +220,7 @@ public sealed class SessionManagerBulkTests
async () => await session.SubscribeBulkAsync(12, ["Tag"], cts.Token)); async () => await session.SubscribeBulkAsync(12, ["Tag"], cts.Token));
} }
/// <summary>Verifies that UnsubscribeBulkAsync forwards the command and returns results.</summary>
[Fact] [Fact]
public async Task UnsubscribeBulkAsync_ForwardsOneUnsubscribeBulkCommandAndReturnsResults() public async Task UnsubscribeBulkAsync_ForwardsOneUnsubscribeBulkCommandAndReturnsResults()
{ {
@@ -234,6 +245,7 @@ public sealed class SessionManagerBulkTests
Assert.False(results[1].WasSuccessful); Assert.False(results[1].WasSuccessful);
} }
/// <summary>Verifies that UnsubscribeBulkAsync propagates cancellation.</summary>
[Fact] [Fact]
public async Task UnsubscribeBulkAsync_PropagatesCancellation() public async Task UnsubscribeBulkAsync_PropagatesCancellation()
{ {
@@ -246,6 +258,7 @@ public sealed class SessionManagerBulkTests
async () => await session.UnsubscribeBulkAsync(12, [31], cts.Token)); async () => await session.UnsubscribeBulkAsync(12, [31], cts.Token));
} }
/// <summary>Verifies that WriteBulkAsync surfaces per-entry failures.</summary>
[Fact] [Fact]
public async Task WriteBulkAsync_SurfacesPerEntryFailures() public async Task WriteBulkAsync_SurfacesPerEntryFailures()
{ {
@@ -278,6 +291,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal("MXAccess invalid handle", results[1].ErrorMessage); Assert.Equal("MXAccess invalid handle", results[1].ErrorMessage);
} }
/// <summary>Verifies that WriteBulkAsync propagates cancellation.</summary>
[Fact] [Fact]
public async Task WriteBulkAsync_PropagatesCancellation() public async Task WriteBulkAsync_PropagatesCancellation()
{ {
@@ -293,6 +307,7 @@ public sealed class SessionManagerBulkTests
cts.Token)); cts.Token));
} }
/// <summary>Verifies that Write2BulkAsync forwards the command and preserves timestamp payload.</summary>
[Fact] [Fact]
public async Task Write2BulkAsync_ForwardsOneWrite2BulkCommandAndPreservesTimestampPayload() public async Task Write2BulkAsync_ForwardsOneWrite2BulkCommandAndPreservesTimestampPayload()
{ {
@@ -335,6 +350,7 @@ public sealed class SessionManagerBulkTests
Assert.False(results[1].WasSuccessful); Assert.False(results[1].WasSuccessful);
} }
/// <summary>Verifies that Write2BulkAsync propagates cancellation.</summary>
[Fact] [Fact]
public async Task Write2BulkAsync_PropagatesCancellation() public async Task Write2BulkAsync_PropagatesCancellation()
{ {
@@ -359,6 +375,7 @@ public sealed class SessionManagerBulkTests
cts.Token)); cts.Token));
} }
/// <summary>Verifies that WriteSecuredBulkAsync forwards the command and preserves credential payload.</summary>
[Fact] [Fact]
public async Task WriteSecuredBulkAsync_ForwardsOneWriteSecuredBulkCommandAndPreservesCredentialPayload() public async Task WriteSecuredBulkAsync_ForwardsOneWriteSecuredBulkCommandAndPreservesCredentialPayload()
{ {
@@ -409,6 +426,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal("MXAccess secured-write rejected", results[1].ErrorMessage); Assert.Equal("MXAccess secured-write rejected", results[1].ErrorMessage);
} }
/// <summary>Verifies that WriteSecuredBulkAsync propagates cancellation.</summary>
[Fact] [Fact]
public async Task WriteSecuredBulkAsync_PropagatesCancellation() public async Task WriteSecuredBulkAsync_PropagatesCancellation()
{ {
@@ -480,6 +498,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal(1, workerClient.InvokeCount); Assert.Equal(1, workerClient.InvokeCount);
} }
/// <summary>Verifies that WriteSecured2BulkAsync forwards the command and preserves credential and timestamp payload.</summary>
[Fact] [Fact]
public async Task WriteSecured2BulkAsync_ForwardsOneWriteSecured2BulkCommandAndPreservesCredentialAndTimestampPayload() public async Task WriteSecured2BulkAsync_ForwardsOneWriteSecured2BulkCommandAndPreservesCredentialAndTimestampPayload()
{ {
@@ -527,6 +546,7 @@ public sealed class SessionManagerBulkTests
Assert.False(results[1].WasSuccessful); Assert.False(results[1].WasSuccessful);
} }
/// <summary>Verifies that WriteSecured2BulkAsync propagates cancellation.</summary>
[Fact] [Fact]
public async Task WriteSecured2BulkAsync_PropagatesCancellation() public async Task WriteSecured2BulkAsync_PropagatesCancellation()
{ {
@@ -552,6 +572,7 @@ public sealed class SessionManagerBulkTests
cts.Token)); cts.Token));
} }
/// <summary>Verifies that ReadBulkAsync surfaces per-entry failures.</summary>
[Fact] [Fact]
public async Task ReadBulkAsync_SurfacesPerEntryFailures() public async Task ReadBulkAsync_SurfacesPerEntryFailures()
{ {
@@ -597,6 +618,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal("MXAccess read timed out", results[1].ErrorMessage); Assert.Equal("MXAccess read timed out", results[1].ErrorMessage);
} }
/// <summary>Verifies that ReadBulkAsync propagates cancellation.</summary>
[Fact] [Fact]
public async Task ReadBulkAsync_PropagatesCancellation() public async Task ReadBulkAsync_PropagatesCancellation()
{ {
@@ -50,6 +50,7 @@ public sealed class SessionManagerTests
Assert.Equal(clock.GetUtcNow() + TimeSpan.FromMinutes(30), session.LeaseExpiresAt); Assert.Equal(clock.GetUtcNow() + TimeSpan.FromMinutes(30), session.LeaseExpiresAt);
} }
/// <summary>Verifies that session generation creates client correlation ID from client name and session ID.</summary>
[Fact] [Fact]
public async Task OpenSessionAsync_GeneratesClientCorrelationIdFromClientNameAndSessionId() public async Task OpenSessionAsync_GeneratesClientCorrelationIdFromClientNameAndSessionId()
{ {
@@ -124,6 +125,7 @@ public sealed class SessionManagerTests
Assert.True(session.LeaseExpiresAt > DateTimeOffset.UtcNow); Assert.True(session.LeaseExpiresAt > DateTimeOffset.UtcNow);
} }
/// <summary>Verifies that gateway session subscribe bulk forwards one bulk command and returns results.</summary>
[Fact] [Fact]
public async Task GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommandAndReturnsResults() public async Task GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
{ {
@@ -168,6 +170,7 @@ public sealed class SessionManagerTests
Assert.Equal(["Galaxy.Tag.Value"], workerClient.LastCommand?.Command.SubscribeBulk.TagAddresses); Assert.Equal(["Galaxy.Tag.Value"], workerClient.LastCommand?.Command.SubscribeBulk.TagAddresses);
} }
/// <summary>Verifies that gateway session write bulk forwards one bulk command and returns results.</summary>
[Fact] [Fact]
public async Task GatewaySessionWriteBulkAsync_ForwardsOneBulkCommandAndReturnsResults() public async Task GatewaySessionWriteBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
{ {
@@ -232,6 +235,7 @@ public sealed class SessionManagerTests
Assert.Equal(2, workerClient.LastCommand?.Command.WriteBulk.Entries.Count); Assert.Equal(2, workerClient.LastCommand?.Command.WriteBulk.Entries.Count);
} }
/// <summary>Verifies that gateway session read bulk forwards one bulk command and returns results.</summary>
[Fact] [Fact]
public async Task GatewaySessionReadBulkAsync_ForwardsOneBulkCommandAndReturnsResults() public async Task GatewaySessionReadBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
{ {
@@ -497,6 +501,7 @@ public sealed class SessionManagerTests
/// <see cref="ArgumentException.ThrowIfNullOrWhiteSpace"/>. A blank or whitespace reason must throw /// <see cref="ArgumentException.ThrowIfNullOrWhiteSpace"/>. A blank or whitespace reason must throw
/// <see cref="ArgumentException"/> before any session lookup or worker call runs. /// <see cref="ArgumentException"/> before any session lookup or worker call runs.
/// </summary> /// </summary>
/// <param name="blankReason">A blank or whitespace reason string.</param>
[Theory] [Theory]
[InlineData("")] [InlineData("")]
[InlineData(" ")] [InlineData(" ")]
@@ -710,6 +715,7 @@ public sealed class SessionManagerTests
Assert.Equal(0, workerClient.ShutdownCount); Assert.Equal(0, workerClient.ShutdownCount);
} }
/// <summary>Verifies that shutdown closes all registered sessions.</summary>
[Fact] [Fact]
public async Task ShutdownAsync_ClosesAllRegisteredSessions() public async Task ShutdownAsync_ClosesAllRegisteredSessions()
{ {
@@ -15,6 +15,7 @@ public sealed class OrphanWorkerTerminatorTests
{ {
private const string WorkerExecutablePath = @"C:\app\src\ZB.MOM.WW.MxGateway.Worker\bin\x86\Release\ZB.MOM.WW.MxGateway.Worker.exe"; private const string WorkerExecutablePath = @"C:\app\src\ZB.MOM.WW.MxGateway.Worker\bin\x86\Release\ZB.MOM.WW.MxGateway.Worker.exe";
/// <summary>Verifies that orphan worker processes matching the configured executable path are killed.</summary>
[Fact] [Fact]
public void TerminateOrphans_KillsWorkerProcessesMatchingConfiguredExecutablePath() public void TerminateOrphans_KillsWorkerProcessesMatchingConfiguredExecutablePath()
{ {
@@ -31,6 +32,7 @@ public sealed class OrphanWorkerTerminatorTests
Assert.Equal([101, 102], inspector.KilledProcessIds.Order()); Assert.Equal([101, 102], inspector.KilledProcessIds.Order());
} }
/// <summary>Verifies that orphan workers are killed when executable path is unreadable but image name matches.</summary>
[Fact] [Fact]
public void TerminateOrphans_KillsImageNameMatchWhenExecutablePathUnreadable() public void TerminateOrphans_KillsImageNameMatchWhenExecutablePathUnreadable()
{ {
@@ -49,6 +51,7 @@ public sealed class OrphanWorkerTerminatorTests
Assert.Equal([201], inspector.KilledProcessIds); Assert.Equal([201], inspector.KilledProcessIds);
} }
/// <summary>Verifies that unrelated processes with the same image name are not killed.</summary>
[Fact] [Fact]
public void TerminateOrphans_DoesNotKillUnrelatedProcessSharingImageName() public void TerminateOrphans_DoesNotKillUnrelatedProcessSharingImageName()
{ {
@@ -66,6 +69,7 @@ public sealed class OrphanWorkerTerminatorTests
Assert.Empty(inspector.KilledProcessIds); Assert.Empty(inspector.KilledProcessIds);
} }
/// <summary>Verifies that the current process is not killed even if path matches.</summary>
[Fact] [Fact]
public void TerminateOrphans_DoesNotKillCurrentProcess() public void TerminateOrphans_DoesNotKillCurrentProcess()
{ {
@@ -81,6 +85,7 @@ public sealed class OrphanWorkerTerminatorTests
Assert.Empty(inspector.KilledProcessIds); Assert.Empty(inspector.KilledProcessIds);
} }
/// <summary>Verifies that termination continues when one process kill fails.</summary>
[Fact] [Fact]
public void TerminateOrphans_ContinuesWhenOneKillThrows() public void TerminateOrphans_ContinuesWhenOneKillThrows()
{ {
@@ -118,12 +123,18 @@ public sealed class OrphanWorkerTerminatorTests
private sealed class FakeProcessInspector(IReadOnlyList<RunningProcessInfo> processes) private sealed class FakeProcessInspector(IReadOnlyList<RunningProcessInfo> processes)
: IRunningProcessInspector : IRunningProcessInspector
{ {
/// <summary>Gets the list of killed process IDs.</summary>
public List<int> KilledProcessIds { get; } = []; public List<int> KilledProcessIds { get; } = [];
/// <summary>Gets or sets the process ID that should throw when killed.</summary>
public int? ThrowOnKillProcessId { get; init; } public int? ThrowOnKillProcessId { get; init; }
/// <summary>Gets the list of running processes by name.</summary>
/// <param name="processName">The process name to search for.</param>
public IReadOnlyList<RunningProcessInfo> GetProcessesByName(string processName) => processes; public IReadOnlyList<RunningProcessInfo> GetProcessesByName(string processName) => processes;
/// <summary>Kills the specified process or records the kill attempt.</summary>
/// <param name="processId">The process identifier to kill.</param>
public void Kill(int processId) public void Kill(int processId)
{ {
if (ThrowOnKillProcessId == processId) if (ThrowOnKillProcessId == processId)
@@ -247,6 +247,7 @@ public sealed class WorkerClientTests
Assert.Equal(WorkerClientState.Faulted, client.State); Assert.Equal(WorkerClientState.Faulted, client.State);
} }
/// <summary>Verifies that pipe disconnect faults the client.</summary>
[Fact] [Fact]
public async Task ReadLoop_WhenPipeDisconnects_FaultsClient() public async Task ReadLoop_WhenPipeDisconnects_FaultsClient()
{ {
@@ -767,23 +768,32 @@ public sealed class WorkerClientTests
{ {
private readonly TaskCompletionSource _exited = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _exited = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Gets the process ID.</summary>
public int Id { get; } = WorkerProcessId; public int Id { get; } = WorkerProcessId;
/// <summary>Gets a value indicating whether the process has exited.</summary>
public bool HasExited { get; private set; } public bool HasExited { get; private set; }
/// <summary>Gets the process exit code.</summary>
public int? ExitCode { get; private set; } public int? ExitCode { get; private set; }
/// <summary>Gets the number of times kill was called.</summary>
public int KillCount { get; private set; } public int KillCount { get; private set; }
/// <summary>Gets the last kill request's entire process tree flag.</summary>
public bool KillEntireProcessTree { get; private set; } public bool KillEntireProcessTree { get; private set; }
/// <summary>Gets a value indicating whether dispose was called.</summary>
public bool Disposed { get; private set; } public bool Disposed { get; private set; }
/// <inheritdoc />
public ValueTask WaitForExitAsync(CancellationToken cancellationToken) public ValueTask WaitForExitAsync(CancellationToken cancellationToken)
{ {
return new ValueTask(_exited.Task.WaitAsync(cancellationToken)); return new ValueTask(_exited.Task.WaitAsync(cancellationToken));
} }
/// <summary>Records a kill request.</summary>
/// <param name="entireProcessTree">Whether to kill the entire process tree.</param>
public void Kill(bool entireProcessTree) public void Kill(bool entireProcessTree)
{ {
KillCount++; KillCount++;
@@ -793,6 +803,7 @@ public sealed class WorkerClientTests
_exited.TrySetResult(); _exited.TrySetResult();
} }
/// <inheritdoc />
public void Dispose() public void Dispose()
{ {
Disposed = true; Disposed = true;
@@ -17,6 +17,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
private readonly List<string> _tempFiles = []; private readonly List<string> _tempFiles = [];
/// <summary>Verifies that x86 executable matching required architecture does not throw.</summary>
[Fact] [Fact]
public void Validate_X86ExecutableMatchingRequiredArchitecture_DoesNotThrow() public void Validate_X86ExecutableMatchingRequiredArchitecture_DoesNotThrow()
{ {
@@ -25,6 +26,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
WorkerExecutableValidator.Validate(path, WorkerArchitecture.X86); WorkerExecutableValidator.Validate(path, WorkerArchitecture.X86);
} }
/// <summary>Verifies that x64 executable matching required architecture does not throw.</summary>
[Fact] [Fact]
public void Validate_X64ExecutableMatchingRequiredArchitecture_DoesNotThrow() public void Validate_X64ExecutableMatchingRequiredArchitecture_DoesNotThrow()
{ {
@@ -33,6 +35,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
WorkerExecutableValidator.Validate(path, WorkerArchitecture.X64); WorkerExecutableValidator.Validate(path, WorkerArchitecture.X64);
} }
/// <summary>Verifies that x64 executable when x86 required throws invalid executable.</summary>
[Fact] [Fact]
public void Validate_X64ExecutableWhenX86Required_ThrowsInvalidExecutable() public void Validate_X64ExecutableWhenX86Required_ThrowsInvalidExecutable()
{ {
@@ -45,6 +48,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
Assert.Contains("architecture", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("architecture", exception.Message, StringComparison.OrdinalIgnoreCase);
} }
/// <summary>Verifies that x86 executable when x64 required throws invalid executable.</summary>
[Fact] [Fact]
public void Validate_X86ExecutableWhenX64Required_ThrowsInvalidExecutable() public void Validate_X86ExecutableWhenX64Required_ThrowsInvalidExecutable()
{ {
@@ -56,6 +60,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
Assert.Equal(WorkerProcessLaunchErrorCode.InvalidExecutable, exception.ErrorCode); Assert.Equal(WorkerProcessLaunchErrorCode.InvalidExecutable, exception.ErrorCode);
} }
/// <summary>Verifies that file without MZ header throws invalid executable.</summary>
[Fact] [Fact]
public void Validate_FileWithoutMzHeader_ThrowsInvalidExecutable() public void Validate_FileWithoutMzHeader_ThrowsInvalidExecutable()
{ {
@@ -70,6 +75,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
Assert.Contains("MZ", exception.Message, StringComparison.Ordinal); Assert.Contains("MZ", exception.Message, StringComparison.Ordinal);
} }
/// <summary>Verifies that file too small for PE header throws invalid executable.</summary>
[Fact] [Fact]
public void Validate_FileTooSmallForPeHeader_ThrowsInvalidExecutable() public void Validate_FileTooSmallForPeHeader_ThrowsInvalidExecutable()
{ {
@@ -81,6 +87,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
Assert.Equal(WorkerProcessLaunchErrorCode.InvalidExecutable, exception.ErrorCode); Assert.Equal(WorkerProcessLaunchErrorCode.InvalidExecutable, exception.ErrorCode);
} }
/// <summary>Verifies that file without PE signature throws invalid executable.</summary>
[Fact] [Fact]
public void Validate_FileWithoutPeSignature_ThrowsInvalidExecutable() public void Validate_FileWithoutPeSignature_ThrowsInvalidExecutable()
{ {
@@ -122,6 +129,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
return path; return path;
} }
/// <inheritdoc />
public void Dispose() public void Dispose()
{ {
foreach (string path in _tempFiles) foreach (string path in _tempFiles)
@@ -177,6 +177,7 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
Assert.Equal(1, CountOccurrences(json, ApiKeySecret(apiKey))); Assert.Equal(1, CountOccurrences(json, ApiKeySecret(apiKey)));
} }
/// <summary>Verifies that API key constraints are persisted correctly.</summary>
[Fact] [Fact]
public async Task CreateKeyAsync_WithConstraints_PersistsConstraints() public async Task CreateKeyAsync_WithConstraints_PersistsConstraints()
{ {
@@ -141,6 +141,7 @@ public sealed class ApiKeyAdminCommandLineParserTests
Assert.True(constraints.ReadHistorizedOnly); Assert.True(constraints.ReadHistorizedOnly);
} }
/// <summary>Verifies that create-key command without display name returns error.</summary>
[Fact] [Fact]
public void Parse_CreateKeyWithoutDisplayName_ReturnsError() public void Parse_CreateKeyWithoutDisplayName_ReturnsError()
{ {
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization;
public sealed class ConstraintEnforcerTests public sealed class ConstraintEnforcerTests
{ {
/// <summary>Verifies that read outside allowed subtree returns failure.</summary>
[Fact] [Fact]
public async Task CheckReadTagAsync_WhenOutsideReadSubtree_ReturnsFailure() public async Task CheckReadTagAsync_WhenOutsideReadSubtree_ReturnsFailure()
{ {
@@ -28,6 +29,7 @@ public sealed class ConstraintEnforcerTests
Assert.Equal("read_scope", failure.ConstraintName); Assert.Equal("read_scope", failure.ConstraintName);
} }
/// <summary>Verifies that write with high classification returns failure and audits.</summary>
[Fact] [Fact]
public async Task CheckWriteHandleAsync_WhenClassificationTooHigh_ReturnsFailureAndAudits() public async Task CheckWriteHandleAsync_WhenClassificationTooHigh_ReturnsFailureAndAudits()
{ {
@@ -70,6 +72,7 @@ public sealed class ConstraintEnforcerTests
Assert.Contains("max_write_classification", entry.Details, StringComparison.Ordinal); Assert.Contains("max_write_classification", entry.Details, StringComparison.Ordinal);
} }
/// <summary>Verifies that historized-only constraint requires historized attribute.</summary>
[Fact] [Fact]
public async Task CheckReadTagAsync_WithHistorizedOnly_RequiresRequestedAttributeToBeHistorized() public async Task CheckReadTagAsync_WithHistorizedOnly_RequiresRequestedAttributeToBeHistorized()
{ {
@@ -88,6 +91,7 @@ public sealed class ConstraintEnforcerTests
Assert.Equal("read_historized_only", failure.ConstraintName); Assert.Equal("read_historized_only", failure.ConstraintName);
} }
/// <summary>Verifies that alarm-only constraint requires alarm attribute.</summary>
[Fact] [Fact]
public async Task CheckReadTagAsync_WithAlarmOnly_RequiresRequestedAttributeToBeAlarm() public async Task CheckReadTagAsync_WithAlarmOnly_RequiresRequestedAttributeToBeAlarm()
{ {
@@ -106,6 +110,7 @@ public sealed class ConstraintEnforcerTests
Assert.Equal("read_alarm_only", failure.ConstraintName); Assert.Equal("read_alarm_only", failure.ConstraintName);
} }
/// <summary>Verifies that attribute-only constraint fails closed for object tag.</summary>
[Fact] [Fact]
public async Task CheckReadTagAsync_WithAttributeOnlyConstraint_FailsClosedForObjectTag() public async Task CheckReadTagAsync_WithAttributeOnlyConstraint_FailsClosedForObjectTag()
{ {
@@ -222,23 +227,29 @@ public sealed class ConstraintEnforcerTests
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{ {
/// <summary>Gets the current cache entry.</summary>
public GalaxyHierarchyCacheEntry Current { get; } = current; public GalaxyHierarchyCacheEntry Current { get; } = current;
/// <inheritdoc />
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
} }
private sealed class FakeAuditStore : IApiKeyAuditStore private sealed class FakeAuditStore : IApiKeyAuditStore
{ {
/// <summary>Gets the recorded audit entries.</summary>
public List<ApiKeyAuditEntry> Entries { get; } = []; public List<ApiKeyAuditEntry> Entries { get; } = [];
/// <inheritdoc />
public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken) public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
{ {
Entries.Add(entry); Entries.Add(entry);
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <inheritdoc />
public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(int count, CancellationToken cancellationToken) public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(int count, CancellationToken cancellationToken)
{ {
return Task.FromResult<IReadOnlyList<ApiKeyAuditRecord>>([]); return Task.FromResult<IReadOnlyList<ApiKeyAuditRecord>>([]);
@@ -16,11 +16,14 @@ public sealed class AlarmClientDiscoveryTests
{ {
private readonly ITestOutputHelper output; private readonly ITestOutputHelper output;
/// <summary>Initializes a new instance of the AlarmClientDiscoveryTests class.</summary>
/// <param name="output">The xUnit test output helper.</param>
public AlarmClientDiscoveryTests(ITestOutputHelper output) public AlarmClientDiscoveryTests(ITestOutputHelper output)
{ {
this.output = output; this.output = output;
} }
/// <summary>Dumps the public surface of the aaAlarmManagedClient assembly.</summary>
[Fact(Skip = "Discovery probe — flip Skip=null to dump aaAlarmManagedClient surface")] [Fact(Skip = "Discovery probe — flip Skip=null to dump aaAlarmManagedClient surface")]
public void DumpAlarmClientPublicSurface() public void DumpAlarmClientPublicSurface()
{ {
@@ -1094,12 +1094,16 @@ public sealed class WorkerPipeSessionTests
} }
/// <summary>Records an informational log event.</summary> /// <summary>Records an informational log event.</summary>
/// <param name="eventName">The event name.</param>
/// <param name="fields">The event fields.</param>
public void Information(string eventName, IReadOnlyDictionary<string, object?> fields) public void Information(string eventName, IReadOnlyDictionary<string, object?> fields)
{ {
Record(eventName, fields); Record(eventName, fields);
} }
/// <summary>Records an error log event.</summary> /// <summary>Records an error log event.</summary>
/// <param name="eventName">The event name.</param>
/// <param name="fields">The event fields.</param>
public void Error(string eventName, IReadOnlyDictionary<string, object?> fields) public void Error(string eventName, IReadOnlyDictionary<string, object?> fields)
{ {
Record(eventName, fields); Record(eventName, fields);
@@ -22,6 +22,7 @@ public sealed class AlarmCommandExecutorTests
private const string SessionId = "S"; private const string SessionId = "S";
private const string CorrelationId = "C"; private const string CorrelationId = "C";
/// <summary>Verifies that the handler routes alarm subscriptions and returns ok.</summary>
[Fact] [Fact]
public void SubscribeAlarms_WithHandler_RoutesToHandlerAndReturnsOk() public void SubscribeAlarms_WithHandler_RoutesToHandlerAndReturnsOk()
{ {
@@ -46,6 +47,7 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal(SessionId, handler.LastSessionId); Assert.Equal(SessionId, handler.LastSessionId);
} }
/// <summary>Verifies that subscription without handler returns invalid request.</summary>
[Fact] [Fact]
public void SubscribeAlarms_WithoutHandler_ReturnsInvalidRequest() public void SubscribeAlarms_WithoutHandler_ReturnsInvalidRequest()
{ {
@@ -67,6 +69,7 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
} }
/// <summary>Verifies that empty subscription expression returns invalid request.</summary>
[Fact] [Fact]
public void SubscribeAlarms_WithEmptyExpression_ReturnsInvalidRequest() public void SubscribeAlarms_WithEmptyExpression_ReturnsInvalidRequest()
{ {
@@ -88,6 +91,7 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
} }
/// <summary>Verifies that acknowledge routes native status into hresult and payload.</summary>
[Fact] [Fact]
public void AcknowledgeAlarm_WithHandler_RoutesNativeStatusIntoHresultAndPayload() public void AcknowledgeAlarm_WithHandler_RoutesNativeStatusIntoHresultAndPayload()
{ {
@@ -121,6 +125,7 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal("alice", handler.LastAckOperatorName); Assert.Equal("alice", handler.LastAckOperatorName);
} }
/// <summary>Verifies that invalid alarm GUID returns invalid request.</summary>
[Fact] [Fact]
public void AcknowledgeAlarm_WithInvalidGuid_ReturnsInvalidRequest() public void AcknowledgeAlarm_WithInvalidGuid_ReturnsInvalidRequest()
{ {
@@ -142,6 +147,7 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
} }
/// <summary>Verifies that nonzero native status carries a diagnostic message.</summary>
[Fact] [Fact]
public void AcknowledgeAlarm_WithNonzeroNativeStatus_CarriesDiagnostic() public void AcknowledgeAlarm_WithNonzeroNativeStatus_CarriesDiagnostic()
{ {
@@ -165,6 +171,7 @@ public sealed class AlarmCommandExecutorTests
Assert.Contains("-123", reply.DiagnosticMessage); Assert.Contains("-123", reply.DiagnosticMessage);
} }
/// <summary>Verifies that acknowledge by name routes tuple to handler.</summary>
[Fact] [Fact]
public void AcknowledgeAlarmByName_WithHandler_RoutesTupleToHandler() public void AcknowledgeAlarmByName_WithHandler_RoutesTupleToHandler()
{ {
@@ -198,6 +205,7 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal("alice", handler.LastAckOperatorName); Assert.Equal("alice", handler.LastAckOperatorName);
} }
/// <summary>Verifies that empty alarm name returns invalid request.</summary>
[Fact] [Fact]
public void AcknowledgeAlarmByName_WithEmptyName_ReturnsInvalidRequest() public void AcknowledgeAlarmByName_WithEmptyName_ReturnsInvalidRequest()
{ {
@@ -221,6 +229,7 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
} }
/// <summary>Verifies that query active alarms returns payload with snapshots.</summary>
[Fact] [Fact]
public void QueryActiveAlarms_WithHandler_ReturnsPayloadWithSnapshots() public void QueryActiveAlarms_WithHandler_ReturnsPayloadWithSnapshots()
{ {
@@ -253,6 +262,7 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal("Galaxy!A", handler.LastFilterPrefix); Assert.Equal("Galaxy!A", handler.LastFilterPrefix);
} }
/// <summary>Verifies that unsubscribe routes to handler.</summary>
[Fact] [Fact]
public void UnsubscribeAlarms_WithHandler_RoutesToHandler() public void UnsubscribeAlarms_WithHandler_RoutesToHandler()
{ {
@@ -273,6 +283,7 @@ public sealed class AlarmCommandExecutorTests
Assert.True(handler.UnsubscribeCalled); Assert.True(handler.UnsubscribeCalled);
} }
/// <summary>Verifies that unsubscribe without handler is an ok noop.</summary>
[Fact] [Fact]
public void UnsubscribeAlarms_WithoutHandler_IsOkNoop() public void UnsubscribeAlarms_WithoutHandler_IsOkNoop()
{ {
@@ -291,6 +302,7 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
} }
/// <summary>Verifies that handler exception returns MXAccess failure status.</summary>
[Fact] [Fact]
public void AcknowledgeAlarm_WhenHandlerThrows_ReturnsMxaccessFailure() public void AcknowledgeAlarm_WhenHandlerThrows_ReturnsMxaccessFailure()
{ {
@@ -331,28 +343,56 @@ public sealed class AlarmCommandExecutorTests
private sealed class FakeAlarmHandler : IAlarmCommandHandler private sealed class FakeAlarmHandler : IAlarmCommandHandler
{ {
/// <summary>Gets the last subscription expression.</summary>
public string? LastSubscription { get; private set; } public string? LastSubscription { get; private set; }
/// <summary>Gets the last session ID.</summary>
public string? LastSessionId { get; private set; } public string? LastSessionId { get; private set; }
/// <summary>Gets a value indicating whether unsubscribe was called.</summary>
public bool UnsubscribeCalled { get; private set; } public bool UnsubscribeCalled { get; private set; }
/// <summary>Gets the last acknowledge alarm GUID.</summary>
public Guid LastAckGuid { get; private set; } public Guid LastAckGuid { get; private set; }
/// <summary>Gets the last acknowledge operator name.</summary>
public string? LastAckOperatorName { get; private set; } public string? LastAckOperatorName { get; private set; }
/// <summary>Gets or sets the value returned by acknowledge.</summary>
public int AcknowledgeReturn { get; set; } public int AcknowledgeReturn { get; set; }
/// <summary>Gets or sets a value indicating whether acknowledge throws.</summary>
public bool AcknowledgeThrow { get; set; } public bool AcknowledgeThrow { get; set; }
/// <summary>Gets or sets the query result snapshots.</summary>
public IReadOnlyList<ActiveAlarmSnapshot> QueryResult { get; set; } = public IReadOnlyList<ActiveAlarmSnapshot> QueryResult { get; set; } =
Array.Empty<ActiveAlarmSnapshot>(); Array.Empty<ActiveAlarmSnapshot>();
/// <summary>Gets the last alarm filter prefix.</summary>
public string? LastFilterPrefix { get; private set; } public string? LastFilterPrefix { get; private set; }
/// <summary>Records a subscription.</summary>
/// <param name="subscription">The subscription expression.</param>
/// <param name="sessionId">The session identifier.</param>
public void Subscribe(string subscription, string sessionId) public void Subscribe(string subscription, string sessionId)
{ {
LastSubscription = subscription; LastSubscription = subscription;
LastSessionId = sessionId; LastSessionId = sessionId;
} }
/// <summary>Records an unsubscribe request.</summary>
public void Unsubscribe() public void Unsubscribe()
{ {
UnsubscribeCalled = true; UnsubscribeCalled = true;
} }
/// <summary>Records an acknowledge request.</summary>
/// <param name="alarmGuid">The alarm identifier.</param>
/// <param name="comment">The acknowledge comment.</param>
/// <param name="operatorUser">The operator user name.</param>
/// <param name="operatorNode">The operator node name.</param>
/// <param name="operatorDomain">The operator domain.</param>
/// <param name="operatorFullName">The operator full name.</param>
public int Acknowledge( public int Acknowledge(
Guid alarmGuid, string comment, string operatorUser, Guid alarmGuid, string comment, string operatorUser,
string operatorNode, string operatorDomain, string operatorFullName) string operatorNode, string operatorDomain, string operatorFullName)
@@ -366,6 +406,15 @@ public sealed class AlarmCommandExecutorTests
return AcknowledgeReturn; return AcknowledgeReturn;
} }
/// <summary>Records an acknowledge by name request.</summary>
/// <param name="alarmName">The alarm name.</param>
/// <param name="providerName">The provider name.</param>
/// <param name="groupName">The group name.</param>
/// <param name="comment">The acknowledge comment.</param>
/// <param name="operatorUser">The operator user name.</param>
/// <param name="operatorNode">The operator node name.</param>
/// <param name="operatorDomain">The operator domain.</param>
/// <param name="operatorFullName">The operator full name.</param>
public int AcknowledgeByName( public int AcknowledgeByName(
string alarmName, string providerName, string groupName, string alarmName, string providerName, string groupName,
string comment, string operatorUser, string operatorNode, string comment, string operatorUser, string operatorNode,
@@ -376,21 +425,27 @@ public sealed class AlarmCommandExecutorTests
return AcknowledgeReturn; return AcknowledgeReturn;
} }
/// <summary>Gets the last acknowledge by name tuple.</summary>
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; } public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
/// <summary>Queries the active alarms with the given filter prefix.</summary>
/// <param name="alarmFilterPrefix">The alarm filter prefix for the query.</param>
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix) public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix)
{ {
LastFilterPrefix = alarmFilterPrefix; LastFilterPrefix = alarmFilterPrefix;
return QueryResult; return QueryResult;
} }
/// <summary>Gets the number of poll calls.</summary>
public int PollCount { get; private set; } public int PollCount { get; private set; }
/// <summary>Increments the poll count.</summary>
public void PollOnce() public void PollOnce()
{ {
PollCount++; PollCount++;
} }
/// <inheritdoc />
public void Dispose() { } public void Dispose() { }
} }
} }
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// </summary> /// </summary>
public sealed class AlarmCommandHandlerTests public sealed class AlarmCommandHandlerTests
{ {
/// <summary>Verifies that subscribe creates a consumer and forwards the subscription when not yet subscribed.</summary>
[Fact] [Fact]
public void Subscribe_WhenNotYetSubscribed_CreatesConsumerAndCallsSubscribe() public void Subscribe_WhenNotYetSubscribed_CreatesConsumerAndCallsSubscribe()
{ {
@@ -26,6 +27,7 @@ public sealed class AlarmCommandHandlerTests
Assert.Equal(@"\\HOST\Galaxy!Area", consumer.LastSubscription); Assert.Equal(@"\\HOST\Galaxy!Area", consumer.LastSubscription);
} }
/// <summary>Verifies that subscribe throws when already subscribed.</summary>
[Fact] [Fact]
public void Subscribe_WhenAlreadySubscribed_Throws() public void Subscribe_WhenAlreadySubscribed_Throws()
{ {
@@ -67,6 +69,7 @@ public sealed class AlarmCommandHandlerTests
Assert.True(consumer.Disposed); Assert.True(consumer.Disposed);
} }
/// <summary>Verifies that unsubscribe disposes consumer and clears state when subscribed.</summary>
[Fact] [Fact]
public void Unsubscribe_WhenSubscribed_DisposesConsumerAndClearsState() public void Unsubscribe_WhenSubscribed_DisposesConsumerAndClearsState()
{ {
@@ -82,6 +85,7 @@ public sealed class AlarmCommandHandlerTests
Assert.True(consumer.Disposed); Assert.True(consumer.Disposed);
} }
/// <summary>Verifies that unsubscribe is a no-op when not yet subscribed.</summary>
[Fact] [Fact]
public void Unsubscribe_WithoutPriorSubscribe_IsNoop() public void Unsubscribe_WithoutPriorSubscribe_IsNoop()
{ {
@@ -92,6 +96,7 @@ public sealed class AlarmCommandHandlerTests
Assert.False(handler.IsSubscribed); Assert.False(handler.IsSubscribed);
} }
/// <summary>Verifies that acknowledge forwards to consumer with full operator identity when subscribed.</summary>
[Fact] [Fact]
public void Acknowledge_WhenSubscribed_ForwardsToConsumerWithFullOperatorIdentity() public void Acknowledge_WhenSubscribed_ForwardsToConsumerWithFullOperatorIdentity()
{ {
@@ -109,6 +114,7 @@ public sealed class AlarmCommandHandlerTests
Assert.Equal("u", consumer.LastAckOperatorName); Assert.Equal("u", consumer.LastAckOperatorName);
} }
/// <summary>Verifies that acknowledge throws invalid operation when called before subscribe.</summary>
[Fact] [Fact]
public void Acknowledge_BeforeSubscribe_ThrowsInvalidOperation() public void Acknowledge_BeforeSubscribe_ThrowsInvalidOperation()
{ {
@@ -120,6 +126,7 @@ public sealed class AlarmCommandHandlerTests
() => handler.Acknowledge(Guid.Empty, "", "", "", "", "")); () => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
} }
/// <summary>Verifies that query active returns mapped proto snapshots when consumer has alarms.</summary>
[Fact] [Fact]
public void QueryActive_WhenConsumerHasAlarms_ReturnsMappedProtoSnapshots() public void QueryActive_WhenConsumerHasAlarms_ReturnsMappedProtoSnapshots()
{ {
@@ -151,6 +158,7 @@ public sealed class AlarmCommandHandlerTests
Assert.Equal(AlarmConditionState.Active, snapshots[0].CurrentState); Assert.Equal(AlarmConditionState.Active, snapshots[0].CurrentState);
} }
/// <summary>Verifies that query active filters by prefix when prefix is provided.</summary>
[Fact] [Fact]
public void QueryActive_WithPrefix_FiltersByPrefix() public void QueryActive_WithPrefix_FiltersByPrefix()
{ {
@@ -173,6 +181,7 @@ public sealed class AlarmCommandHandlerTests
Assert.Equal("Galaxy!AreaA.Tag1", filtered[0].AlarmFullReference); Assert.Equal("Galaxy!AreaA.Tag1", filtered[0].AlarmFullReference);
} }
/// <summary>Verifies that dispose unsubscribes and disposes consumer when subscribed.</summary>
[Fact] [Fact]
public void Dispose_WhenSubscribed_UnsubscribesAndDisposesConsumer() public void Dispose_WhenSubscribed_UnsubscribesAndDisposesConsumer()
{ {
@@ -281,18 +290,28 @@ public sealed class AlarmCommandHandlerTests
private sealed class FakeConsumer : IMxAccessAlarmConsumer private sealed class FakeConsumer : IMxAccessAlarmConsumer
{ {
#pragma warning disable CS0067 // Event never invoked — fake; AlarmCommandHandler tests don't drive transitions. #pragma warning disable CS0067 // Event never invoked — fake; AlarmCommandHandler tests don't drive transitions.
/// <summary>Emitted when an alarm state transition occurs.</summary>
public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted; public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
#pragma warning restore CS0067 #pragma warning restore CS0067
/// <summary>Gets the last subscription request.</summary>
public string? LastSubscription { get; private set; } public string? LastSubscription { get; private set; }
/// <summary>Gets the last acknowledged alarm GUID.</summary>
public Guid LastAckGuid { get; private set; } public Guid LastAckGuid { get; private set; }
/// <summary>Gets the last acknowledged operator name.</summary>
public string? LastAckOperatorName { get; private set; } public string? LastAckOperatorName { get; private set; }
/// <summary>Gets or sets the return value for acknowledge operations.</summary>
public int AcknowledgeReturn { get; set; } public int AcknowledgeReturn { get; set; }
/// <summary>Gets or sets the snapshot result to return.</summary>
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } = public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
Array.Empty<MxAlarmSnapshotRecord>(); Array.Empty<MxAlarmSnapshotRecord>();
/// <summary>Gets or sets a value indicating whether to throw on subscribe.</summary>
public bool ThrowOnSubscribe { get; set; } public bool ThrowOnSubscribe { get; set; }
/// <summary>Gets a value indicating whether the consumer has been disposed.</summary>
public bool Disposed { get; private set; } public bool Disposed { get; private set; }
/// <summary>Subscribes to alarms with the given subscription string.</summary>
/// <param name="subscription">The subscription reference.</param>
public void Subscribe(string subscription) public void Subscribe(string subscription)
{ {
LastSubscription = subscription; LastSubscription = subscription;
@@ -302,6 +321,13 @@ public sealed class AlarmCommandHandlerTests
} }
} }
/// <summary>Acknowledges an alarm by GUID.</summary>
/// <param name="alarmGuid">The alarm GUID.</param>
/// <param name="ackComment">The acknowledgment comment.</param>
/// <param name="ackOperatorName">The operator name.</param>
/// <param name="ackOperatorNode">The operator node.</param>
/// <param name="ackOperatorDomain">The operator domain.</param>
/// <param name="ackOperatorFullName">The operator full name.</param>
public int AcknowledgeByGuid( public int AcknowledgeByGuid(
Guid alarmGuid, string ackComment, string ackOperatorName, Guid alarmGuid, string ackComment, string ackOperatorName,
string ackOperatorNode, string ackOperatorDomain, string ackOperatorFullName) string ackOperatorNode, string ackOperatorDomain, string ackOperatorFullName)
@@ -311,6 +337,15 @@ public sealed class AlarmCommandHandlerTests
return AcknowledgeReturn; return AcknowledgeReturn;
} }
/// <summary>Acknowledges an alarm by name.</summary>
/// <param name="alarmName">The alarm name.</param>
/// <param name="providerName">The provider name.</param>
/// <param name="groupName">The alarm group name.</param>
/// <param name="ackComment">The acknowledgment comment.</param>
/// <param name="ackOperatorName">The operator name.</param>
/// <param name="ackOperatorNode">The operator node.</param>
/// <param name="ackOperatorDomain">The operator domain.</param>
/// <param name="ackOperatorFullName">The operator full name.</param>
public int AcknowledgeByName( public int AcknowledgeByName(
string alarmName, string providerName, string groupName, string alarmName, string providerName, string groupName,
string ackComment, string ackOperatorName, string ackOperatorNode, string ackComment, string ackOperatorName, string ackOperatorNode,
@@ -321,17 +356,22 @@ public sealed class AlarmCommandHandlerTests
return AcknowledgeReturn; return AcknowledgeReturn;
} }
/// <summary>Gets the last acknowledge-by-name parameters.</summary>
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; } public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
/// <summary>Returns a snapshot of active alarms.</summary>
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms() => SnapshotResult; public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms() => SnapshotResult;
/// <summary>Gets the number of times polled.</summary>
public int PollCount { get; private set; } public int PollCount { get; private set; }
/// <summary>Polls once for alarm updates.</summary>
public void PollOnce() public void PollOnce()
{ {
PollCount++; PollCount++;
} }
/// <inheritdoc />
public void Dispose() public void Dispose()
{ {
Disposed = true; Disposed = true;
@@ -17,6 +17,7 @@ public sealed class AlarmDispatcherTests
{ {
private const string SessionId = "session-001"; private const string SessionId = "session-001";
/// <summary>Verifies that alarm transitions land in the queue with correctly mapped fields.</summary>
[Fact] [Fact]
public void OnTransition_WhenAlarmTransitionRaised_LandsInQueueWithMappedFields() public void OnTransition_WhenAlarmTransitionRaised_LandsInQueueWithMappedFields()
{ {
@@ -63,6 +64,7 @@ public sealed class AlarmDispatcherTests
Assert.Equal(ts, body.TransitionTimestamp.ToDateTime()); Assert.Equal(ts, body.TransitionTimestamp.ToDateTime());
} }
/// <summary>Verifies that unchanged alarm states do not emit transitions.</summary>
[Fact] [Fact]
public void OnTransition_WithConsecutiveUnchangedState_DoesNotEmitTransition() public void OnTransition_WithConsecutiveUnchangedState_DoesNotEmitTransition()
{ {
@@ -89,6 +91,10 @@ public sealed class AlarmDispatcherTests
Assert.Equal(0, queue.Count); Assert.Equal(0, queue.Count);
} }
/// <summary>Verifies that state transitions are mapped according to the state table.</summary>
/// <param name="previous">The previous alarm state.</param>
/// <param name="current">The current alarm state.</param>
/// <param name="expected">The expected transition kind.</param>
[Theory] [Theory]
[InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)] [InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)]
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.AckAlm, AlarmTransitionKind.Acknowledge)] [InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.AckAlm, AlarmTransitionKind.Acknowledge)]
@@ -122,6 +128,7 @@ public sealed class AlarmDispatcherTests
Assert.Equal(expected, evt!.Event.OnAlarmTransition.TransitionKind); Assert.Equal(expected, evt!.Event.OnAlarmTransition.TransitionKind);
} }
/// <summary>Verifies that subscribe calls are forwarded to the consumer.</summary>
[Fact] [Fact]
public void Subscribe_WhenInvoked_ForwardsToConsumer() public void Subscribe_WhenInvoked_ForwardsToConsumer()
{ {
@@ -135,6 +142,7 @@ public sealed class AlarmDispatcherTests
Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription); Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription);
} }
/// <summary>Verifies that acknowledge calls are forwarded to the consumer with operator identity.</summary>
[Fact] [Fact]
public void Acknowledge_WhenInvoked_ForwardsToConsumerWithFullOperatorIdentity() public void Acknowledge_WhenInvoked_ForwardsToConsumerWithFullOperatorIdentity()
{ {
@@ -158,6 +166,7 @@ public sealed class AlarmDispatcherTests
Assert.Equal("Alice Smith", consumer.LastAckOperatorFullName); Assert.Equal("Alice Smith", consumer.LastAckOperatorFullName);
} }
/// <summary>Verifies that acknowledge-by-name calls are forwarded to the consumer.</summary>
[Fact] [Fact]
public void AcknowledgeByName_WhenInvoked_ForwardsToConsumerWithFullTuple() public void AcknowledgeByName_WhenInvoked_ForwardsToConsumerWithFullTuple()
{ {
@@ -184,6 +193,7 @@ public sealed class AlarmDispatcherTests
Assert.Equal("TestArea", consumer.LastAckByNameTuple!.Value.Group); Assert.Equal("TestArea", consumer.LastAckByNameTuple!.Value.Group);
} }
/// <summary>Verifies that consumer alarm records are mapped to proto snapshots.</summary>
[Fact] [Fact]
public void SnapshotActiveAlarms_WhenConsumerHasRecords_MapsRecordsToProtos() public void SnapshotActiveAlarms_WhenConsumerHasRecords_MapsRecordsToProtos()
{ {
@@ -232,6 +242,7 @@ public sealed class AlarmDispatcherTests
Assert.Equal(AlarmConditionState.ActiveAcked, snapshots[1].CurrentState); Assert.Equal(AlarmConditionState.ActiveAcked, snapshots[1].CurrentState);
} }
/// <summary>Verifies that dispose unsubscribes the handler and disposes the consumer.</summary>
[Fact] [Fact]
public void Dispose_WhenSubscribed_UnsubscribesHandlerAndDisposesConsumer() public void Dispose_WhenSubscribed_UnsubscribesHandlerAndDisposesConsumer()
{ {
@@ -260,30 +271,52 @@ public sealed class AlarmDispatcherTests
private sealed class FakeAlarmConsumer : IMxAccessAlarmConsumer private sealed class FakeAlarmConsumer : IMxAccessAlarmConsumer
{ {
/// <summary>Raised when an alarm transition occurs.</summary>
public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted; public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
/// <summary>Gets the last subscription reference.</summary>
public string? LastSubscription { get; private set; } public string? LastSubscription { get; private set; }
/// <summary>Gets the GUID from the last acknowledge call.</summary>
public Guid LastAckGuid { get; private set; } public Guid LastAckGuid { get; private set; }
/// <summary>Gets the comment from the last acknowledge call.</summary>
public string? LastAckComment { get; private set; } public string? LastAckComment { get; private set; }
/// <summary>Gets the operator name from the last acknowledge call.</summary>
public string? LastAckOperatorName { get; private set; } public string? LastAckOperatorName { get; private set; }
/// <summary>Gets the operator node from the last acknowledge call.</summary>
public string? LastAckOperatorNode { get; private set; } public string? LastAckOperatorNode { get; private set; }
/// <summary>Gets the operator domain from the last acknowledge call.</summary>
public string? LastAckOperatorDomain { get; private set; } public string? LastAckOperatorDomain { get; private set; }
/// <summary>Gets the operator full name from the last acknowledge call.</summary>
public string? LastAckOperatorFullName { get; private set; } public string? LastAckOperatorFullName { get; private set; }
/// <summary>Gets or sets the return code for acknowledge operations.</summary>
public int AcknowledgeReturn { get; set; } public int AcknowledgeReturn { get; set; }
/// <summary>Gets or sets the result collection for snapshot operations.</summary>
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } = public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
Array.Empty<MxAlarmSnapshotRecord>(); Array.Empty<MxAlarmSnapshotRecord>();
/// <summary>Gets a value indicating whether this instance has been disposed.</summary>
public bool Disposed { get; private set; } public bool Disposed { get; private set; }
/// <summary>Raises an alarm transition event.</summary>
/// <param name="transition">The alarm transition event.</param>
public void RaiseTransition(MxAlarmTransitionEvent transition) public void RaiseTransition(MxAlarmTransitionEvent transition)
{ {
AlarmTransitionEmitted?.Invoke(this, transition); AlarmTransitionEmitted?.Invoke(this, transition);
} }
/// <summary>Records the subscription reference.</summary>
/// <param name="subscription">The subscription reference.</param>
public void Subscribe(string subscription) public void Subscribe(string subscription)
{ {
LastSubscription = subscription; LastSubscription = subscription;
} }
/// <summary>Records an acknowledge-by-GUID call with operator identity.</summary>
/// <param name="alarmGuid">The alarm GUID.</param>
/// <param name="ackComment">The acknowledgment comment.</param>
/// <param name="ackOperatorName">The operator name.</param>
/// <param name="ackOperatorNode">The operator node.</param>
/// <param name="ackOperatorDomain">The operator domain.</param>
/// <param name="ackOperatorFullName">The operator full name.</param>
public int AcknowledgeByGuid( public int AcknowledgeByGuid(
Guid alarmGuid, Guid alarmGuid,
string ackComment, string ackComment,
@@ -301,6 +334,15 @@ public sealed class AlarmDispatcherTests
return AcknowledgeReturn; return AcknowledgeReturn;
} }
/// <summary>Records an acknowledge-by-name call with alarm name, provider, and group.</summary>
/// <param name="alarmName">The alarm name.</param>
/// <param name="providerName">The provider name.</param>
/// <param name="groupName">The alarm group name.</param>
/// <param name="ackComment">The acknowledgment comment.</param>
/// <param name="ackOperatorName">The operator name.</param>
/// <param name="ackOperatorNode">The operator node.</param>
/// <param name="ackOperatorDomain">The operator domain.</param>
/// <param name="ackOperatorFullName">The operator full name.</param>
public int AcknowledgeByName( public int AcknowledgeByName(
string alarmName, string providerName, string groupName, string alarmName, string providerName, string groupName,
string ackComment, string ackOperatorName, string ackOperatorNode, string ackComment, string ackOperatorName, string ackOperatorNode,
@@ -311,20 +353,25 @@ public sealed class AlarmDispatcherTests
return AcknowledgeReturn; return AcknowledgeReturn;
} }
/// <summary>Gets the last acknowledge-by-name tuple (alarm name, provider, group).</summary>
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; } public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
/// <summary>Returns the current snapshot result collection.</summary>
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms() public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms()
{ {
return SnapshotResult; return SnapshotResult;
} }
/// <summary>Gets the count of poll operations.</summary>
public int PollCount { get; private set; } public int PollCount { get; private set; }
/// <summary>Increments the poll count.</summary>
public void PollOnce() public void PollOnce()
{ {
PollCount++; PollCount++;
} }
/// <inheritdoc />
public void Dispose() public void Dispose()
{ {
Disposed = true; Disposed = true;
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// </summary> /// </summary>
public sealed class AlarmRecordTransitionMapperTests public sealed class AlarmRecordTransitionMapperTests
{ {
/// <summary>Verifies that full alarm reference uses provider!group.name format.</summary>
[Fact] [Fact]
public void ComposeFullReference_WithProviderAndGroup_UsesProviderBangGroupDotNameFormat() public void ComposeFullReference_WithProviderAndGroup_UsesProviderBangGroupDotNameFormat()
{ {
@@ -24,6 +25,7 @@ public sealed class AlarmRecordTransitionMapperTests
Assert.Equal("GalaxyAlarmProvider!Tank01.Level.HiHi", reference); Assert.Equal("GalaxyAlarmProvider!Tank01.Level.HiHi", reference);
} }
/// <summary>Verifies that reference omits provider when empty.</summary>
[Fact] [Fact]
public void ComposeFullReference_WithEmptyProvider_DropsProvider() public void ComposeFullReference_WithEmptyProvider_DropsProvider()
{ {
@@ -32,6 +34,7 @@ public sealed class AlarmRecordTransitionMapperTests
Assert.Equal("Tank01.Level.HiHi", reference); Assert.Equal("Tank01.Level.HiHi", reference);
} }
/// <summary>Verifies that reference omits group when empty.</summary>
[Fact] [Fact]
public void ComposeFullReference_WithEmptyGroup_DropsGroup() public void ComposeFullReference_WithEmptyGroup_DropsGroup()
{ {
@@ -40,6 +43,7 @@ public sealed class AlarmRecordTransitionMapperTests
Assert.Equal("GalaxyAlarmProvider!GlobalAlarm", reference); Assert.Equal("GalaxyAlarmProvider!GlobalAlarm", reference);
} }
/// <summary>Verifies that reference returns alarm name when provider and group are empty.</summary>
[Fact] [Fact]
public void ComposeFullReference_WithEmptyProviderAndGroup_ReturnsAlarmName() public void ComposeFullReference_WithEmptyProviderAndGroup_ReturnsAlarmName()
{ {
@@ -48,6 +52,9 @@ public sealed class AlarmRecordTransitionMapperTests
Assert.Equal("Bare", reference); Assert.Equal("Bare", reference);
} }
/// <summary>Verifies that state string parsing decodes all valid state kinds.</summary>
/// <param name="input">The state string to parse.</param>
/// <param name="expected">The expected decoded state kind.</param>
[Theory] [Theory]
[InlineData("UNACK_ALM", MxAlarmStateKind.UnackAlm)] [InlineData("UNACK_ALM", MxAlarmStateKind.UnackAlm)]
[InlineData("ACK_ALM", MxAlarmStateKind.AckAlm)] [InlineData("ACK_ALM", MxAlarmStateKind.AckAlm)]
@@ -63,6 +70,10 @@ public sealed class AlarmRecordTransitionMapperTests
Assert.Equal(expected, AlarmRecordTransitionMapper.ParseStateKind(input)); Assert.Equal(expected, AlarmRecordTransitionMapper.ParseStateKind(input));
} }
/// <summary>Verifies that state pair mapping decides the correct transition kind for all pairs.</summary>
/// <param name="previous">The previous alarm state kind.</param>
/// <param name="current">The current alarm state kind.</param>
/// <param name="expected">The expected transition kind.</param>
[Theory] [Theory]
// First sighting: new alarm in *_ALM → Raise. // First sighting: new alarm in *_ALM → Raise.
[InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)] [InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)]
@@ -91,6 +102,7 @@ public sealed class AlarmRecordTransitionMapperTests
Assert.Equal(expected, AlarmRecordTransitionMapper.MapTransition(previous, current)); Assert.Equal(expected, AlarmRecordTransitionMapper.MapTransition(previous, current));
} }
/// <summary>Verifies that valid XML fields assemble into correct UTC timestamp.</summary>
[Fact] [Fact]
public void ParseTransitionTimestampUtc_WithValidXmlFields_AssemblesUtc() public void ParseTransitionTimestampUtc_WithValidXmlFields_AssemblesUtc()
{ {
@@ -109,6 +121,7 @@ public sealed class AlarmRecordTransitionMapperTests
Assert.Equal(709, utc.Millisecond); Assert.Equal(709, utc.Millisecond);
} }
/// <summary>Verifies that unparseable inputs return DateTime.MinValue.</summary>
[Fact] [Fact]
public void ParseTransitionTimestampUtc_WithUnparseableInputs_ReturnsMinValue() public void ParseTransitionTimestampUtc_WithUnparseableInputs_ReturnsMinValue()
{ {
@@ -75,17 +75,24 @@ public sealed class MxAccessComServerTests
private readonly int registerHandle; private readonly int registerHandle;
private readonly List<string> calls = new(); private readonly List<string> calls = new();
/// <summary>Initializes a new instance with the specified register handle.</summary>
/// <param name="registerHandle">The initial server handle value to return from Register.</param>
public RecordingMxAccessServer(int registerHandle) public RecordingMxAccessServer(int registerHandle)
{ {
this.registerHandle = registerHandle; this.registerHandle = registerHandle;
} }
/// <summary>Gets the client name passed to the most recent Register call.</summary>
public string? RegisteredClientName { get; private set; } public string? RegisteredClientName { get; private set; }
/// <summary>Gets or sets an exception to throw from the Register method.</summary>
public Exception? ThrowOnRegister { get; set; } public Exception? ThrowOnRegister { get; set; }
/// <summary>Gets the recorded method calls as strings.</summary>
public IReadOnlyList<string> Calls => calls.ToArray(); public IReadOnlyList<string> Calls => calls.ToArray();
/// <summary>Records a Register call and returns the configured handle.</summary>
/// <param name="clientName">The client name to record.</param>
public int Register(string clientName) public int Register(string clientName)
{ {
calls.Add($"Register:{clientName}"); calls.Add($"Register:{clientName}");
@@ -98,58 +105,103 @@ public sealed class MxAccessComServerTests
return registerHandle; return registerHandle;
} }
/// <summary>Records an Unregister call.</summary>
/// <param name="serverHandle">The MXAccess server handle.</param>
public void Unregister(int serverHandle) public void Unregister(int serverHandle)
{ {
calls.Add($"Unregister:{serverHandle}"); calls.Add($"Unregister:{serverHandle}");
} }
/// <summary>Records an AddItem call and returns zero.</summary>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemDefinition">The item definition string to record.</param>
public int AddItem(int serverHandle, string itemDefinition) public int AddItem(int serverHandle, string itemDefinition)
{ {
calls.Add($"AddItem:{serverHandle}:{itemDefinition}"); calls.Add($"AddItem:{serverHandle}:{itemDefinition}");
return 0; return 0;
} }
/// <summary>Records an AddItem2 call and returns zero.</summary>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemDefinition">The item definition string to record.</param>
/// <param name="itemContext">The item context string to record.</param>
public int AddItem2(int serverHandle, string itemDefinition, string itemContext) public int AddItem2(int serverHandle, string itemDefinition, string itemContext)
{ {
calls.Add($"AddItem2:{serverHandle}:{itemDefinition}:{itemContext}"); calls.Add($"AddItem2:{serverHandle}:{itemDefinition}:{itemContext}");
return 0; return 0;
} }
/// <summary>Records a RemoveItem call.</summary>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
public void RemoveItem(int serverHandle, int itemHandle) public void RemoveItem(int serverHandle, int itemHandle)
{ {
calls.Add($"RemoveItem:{serverHandle}:{itemHandle}"); calls.Add($"RemoveItem:{serverHandle}:{itemHandle}");
} }
/// <summary>Records an Advise call.</summary>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
public void Advise(int serverHandle, int itemHandle) public void Advise(int serverHandle, int itemHandle)
{ {
calls.Add($"Advise:{serverHandle}:{itemHandle}"); calls.Add($"Advise:{serverHandle}:{itemHandle}");
} }
/// <summary>Records an UnAdvise call.</summary>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
public void UnAdvise(int serverHandle, int itemHandle) public void UnAdvise(int serverHandle, int itemHandle)
{ {
calls.Add($"UnAdvise:{serverHandle}:{itemHandle}"); calls.Add($"UnAdvise:{serverHandle}:{itemHandle}");
} }
/// <summary>Records an AdviseSupervisory call.</summary>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
public void AdviseSupervisory(int serverHandle, int itemHandle) public void AdviseSupervisory(int serverHandle, int itemHandle)
{ {
calls.Add($"AdviseSupervisory:{serverHandle}:{itemHandle}"); calls.Add($"AdviseSupervisory:{serverHandle}:{itemHandle}");
} }
/// <summary>Records a Write call.</summary>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="value">The value to write.</param>
/// <param name="userId">The user identifier.</param>
public void Write(int serverHandle, int itemHandle, object? value, int userId) public void Write(int serverHandle, int itemHandle, object? value, int userId)
{ {
calls.Add($"Write:{serverHandle}:{itemHandle}:{value}:{userId}"); calls.Add($"Write:{serverHandle}:{itemHandle}:{value}:{userId}");
} }
/// <summary>Records a Write2 call.</summary>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="value">The value to write.</param>
/// <param name="timestamp">The timestamp value.</param>
/// <param name="userId">The user identifier.</param>
public void Write2(int serverHandle, int itemHandle, object? value, object? timestamp, int userId) public void Write2(int serverHandle, int itemHandle, object? value, object? timestamp, int userId)
{ {
calls.Add($"Write2:{serverHandle}:{itemHandle}:{value}:{timestamp}:{userId}"); calls.Add($"Write2:{serverHandle}:{itemHandle}:{value}:{timestamp}:{userId}");
} }
/// <summary>Records a WriteSecured call.</summary>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="currentUserId">The current user identifier.</param>
/// <param name="verifierUserId">The verifier user identifier.</param>
/// <param name="value">The value to write.</param>
public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value) public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value)
{ {
calls.Add($"WriteSecured:{serverHandle}:{itemHandle}:{currentUserId}:{verifierUserId}:{value}"); calls.Add($"WriteSecured:{serverHandle}:{itemHandle}:{currentUserId}:{verifierUserId}:{value}");
} }
/// <summary>Records a WriteSecured2 call.</summary>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="currentUserId">The current user identifier.</param>
/// <param name="verifierUserId">The verifier user identifier.</param>
/// <param name="value">The value to write.</param>
/// <param name="timestamp">The timestamp value.</param>
public void WriteSecured2( public void WriteSecured2(
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestamp) int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestamp)
{ {
@@ -1441,6 +1441,7 @@ public sealed class MxAccessCommandExecutorTests
/// <param name="adviseException">Exception to throw from Advise, if any.</param> /// <param name="adviseException">Exception to throw from Advise, if any.</param>
/// <param name="unAdviseException">Exception to throw from UnAdvise, if any.</param> /// <param name="unAdviseException">Exception to throw from UnAdvise, if any.</param>
/// <param name="adviseSupervisoryException">Exception to throw from AdviseSupervisory, if any.</param> /// <param name="adviseSupervisoryException">Exception to throw from AdviseSupervisory, if any.</param>
/// <param name="writeExceptionByItemHandle">Map of item handles to exceptions thrown on write.</param>
public FakeMxAccessComObject( public FakeMxAccessComObject(
int registerHandle, int registerHandle,
int addItemHandle = 0, int addItemHandle = 0,
@@ -227,6 +227,7 @@ public sealed class MxAccessEventMapperTests
} }
/// <summary>Verifies unparseable or empty timestamp input is rejected without throwing.</summary> /// <summary>Verifies unparseable or empty timestamp input is rejected without throwing.</summary>
/// <param name="text">Unparseable or empty timestamp string.</param>
[Theory] [Theory]
[InlineData(null)] [InlineData(null)]
[InlineData("")] [InlineData("")]
@@ -474,45 +474,73 @@ public sealed class MxAccessStaSessionTests
private int pollCount; private int pollCount;
private int? lastPollThreadId; private int? lastPollThreadId;
/// <summary>Gets a value indicating whether the alarm client is currently subscribed.</summary>
public bool IsSubscribed { get; private set; } public bool IsSubscribed { get; private set; }
/// <summary>Gets the last alarm subscription name.</summary>
public string? LastSubscription { get; private set; } public string? LastSubscription { get; private set; }
/// <summary>Exception thrown by PollOnce; null to succeed.</summary> /// <summary>Exception thrown by PollOnce; null to succeed.</summary>
public Exception? PollException { get; set; } public Exception? PollException { get; set; }
/// <summary>Gets the count of PollOnce calls.</summary>
public int PollCount public int PollCount
{ {
get { lock (gate) return pollCount; } get { lock (gate) return pollCount; }
} }
/// <summary>Gets the managed thread ID of the last PollOnce call.</summary>
public int? LastPollThreadId public int? LastPollThreadId
{ {
get { lock (gate) return lastPollThreadId; } get { lock (gate) return lastPollThreadId; }
} }
/// <summary>Subscribes to alarm events.</summary>
/// <param name="subscription">The subscription descriptor.</param>
/// <param name="sessionId">The session identifier.</param>
public void Subscribe(string subscription, string sessionId) public void Subscribe(string subscription, string sessionId)
{ {
IsSubscribed = true; IsSubscribed = true;
LastSubscription = subscription; LastSubscription = subscription;
} }
/// <summary>Unsubscribes from alarm events.</summary>
public void Unsubscribe() public void Unsubscribe()
{ {
IsSubscribed = false; IsSubscribed = false;
} }
/// <summary>Acknowledges an alarm by guid.</summary>
/// <param name="alarmGuid">The alarm GUID.</param>
/// <param name="comment">The acknowledgment comment.</param>
/// <param name="operatorUser">The operator user name.</param>
/// <param name="operatorNode">The operator node name.</param>
/// <param name="operatorDomain">The operator domain.</param>
/// <param name="operatorFullName">The operator full name.</param>
public int Acknowledge(Guid alarmGuid, string comment, string operatorUser, public int Acknowledge(Guid alarmGuid, string comment, string operatorUser,
string operatorNode, string operatorDomain, string operatorFullName) string operatorNode, string operatorDomain, string operatorFullName)
=> 0; => 0;
/// <summary>Acknowledges an alarm by name.</summary>
/// <param name="alarmName">The alarm name.</param>
/// <param name="providerName">The provider name.</param>
/// <param name="groupName">The alarm group name.</param>
/// <param name="comment">The acknowledgment comment.</param>
/// <param name="operatorUser">The operator user name.</param>
/// <param name="operatorNode">The operator node name.</param>
/// <param name="operatorDomain">The operator domain.</param>
/// <param name="operatorFullName">The operator full name.</param>
public int AcknowledgeByName(string alarmName, string providerName, string groupName, public int AcknowledgeByName(string alarmName, string providerName, string groupName,
string comment, string operatorUser, string operatorNode, string comment, string operatorUser, string operatorNode,
string operatorDomain, string operatorFullName) string operatorDomain, string operatorFullName)
=> 0; => 0;
/// <summary>Queries active alarms.</summary>
/// <param name="alarmFilterPrefix">Optional alarm name filter prefix.</param>
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix) public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix)
=> Array.Empty<ActiveAlarmSnapshot>(); => Array.Empty<ActiveAlarmSnapshot>();
/// <summary>Polls for alarm events once.</summary>
public void PollOnce() public void PollOnce()
{ {
lock (gate) lock (gate)
@@ -527,6 +555,7 @@ public sealed class MxAccessStaSessionTests
} }
} }
/// <inheritdoc />
public void Dispose() { } public void Dispose() { }
} }
} }
@@ -17,6 +17,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// </summary> /// </summary>
public sealed class MxAccessValueCacheTests public sealed class MxAccessValueCacheTests
{ {
/// <summary>Verifies that cache returns the last value with incrementing versions.</summary>
[Fact] [Fact]
public void Set_ThenTryGet_ReturnsLastValueWithIncrementingVersion() public void Set_ThenTryGet_ReturnsLastValueWithIncrementingVersion()
{ {
@@ -45,6 +46,7 @@ public sealed class MxAccessValueCacheTests
Assert.Equal(999, other.Value.Int32Value); Assert.Equal(999, other.Value.Int32Value);
} }
/// <summary>Verifies that TryGet returns false for unknown handles.</summary>
[Fact] [Fact]
public void TryGet_WithUnknownHandle_ReturnsFalse() public void TryGet_WithUnknownHandle_ReturnsFalse()
{ {
@@ -53,6 +55,7 @@ public sealed class MxAccessValueCacheTests
Assert.False(cache.TryGet(serverHandle: 7, itemHandle: 21, out _)); Assert.False(cache.TryGet(serverHandle: 7, itemHandle: 21, out _));
} }
/// <summary>Verifies that Remove drops entries and resets versions.</summary>
[Fact] [Fact]
public void Remove_DropsEntryAndResetsVersion() public void Remove_DropsEntryAndResetsVersion()
{ {
@@ -71,6 +74,7 @@ public sealed class MxAccessValueCacheTests
Assert.Equal(1UL, reset.Version); Assert.Equal(1UL, reset.Version);
} }
/// <summary>Verifies that CurrentVersion returns zero for unknown handles and the latest for known ones.</summary>
[Fact] [Fact]
public void CurrentVersion_ReturnsZeroForUnknown_AndLatestForKnown() public void CurrentVersion_ReturnsZeroForUnknown_AndLatestForKnown()
{ {
@@ -83,23 +87,7 @@ public sealed class MxAccessValueCacheTests
Assert.Equal(2UL, cache.CurrentVersion(7, 21)); Assert.Equal(2UL, cache.CurrentVersion(7, 21));
} }
/// <summary> /// <summary>Verifies that TryWaitForUpdate returns false after the deadline expires.</summary>
/// Worker.Tests-020: pins the contract that <c>TryWaitForUpdate</c>
/// returns <c>false</c> when the deadline has elapsed with no
/// <c>Set</c>, yields a default <c>CachedValue</c>, and invokes
/// <c>pumpStep</c> at least once so MXAccess Windows messages can
/// be dispatched. Earlier revisions of this test asserted both an
/// elapsed-time floor (<c>stopwatch.ElapsedMilliseconds &gt;= 60</c>)
/// and <c>pumpCalls &gt; 1</c> — the same wall-clock-floor race
/// pattern Worker.Tests-003/004/013 corrected. To eliminate the
/// timing dependency entirely (the equivalent of a manual time
/// source for a <c>DateTime.UtcNow</c>-based deadline), the test
/// now supplies a deadline already in the past: the loop pumps
/// once, observes the passed deadline, and returns false
/// deterministically without any <c>Thread.Sleep</c>. The
/// deadline-honouring contract is what this test exists to pin;
/// elapsed time and pump-iteration count are incidental.
/// </summary>
[Fact] [Fact]
public void TryWaitForUpdate_ReturnsFalseAfterDeadline_WhenNoSetOccurs() public void TryWaitForUpdate_ReturnsFalseAfterDeadline_WhenNoSetOccurs()
{ {
@@ -126,6 +114,7 @@ public sealed class MxAccessValueCacheTests
Assert.Equal(1, pumpCalls); Assert.Equal(1, pumpCalls);
} }
/// <summary>Verifies that TryWaitForUpdate returns true when the cache is updated after the baseline.</summary>
[Fact] [Fact]
public async Task TryWaitForUpdate_ReturnsTrue_WhenSetFiresAfterBaselineVersion() public async Task TryWaitForUpdate_ReturnsTrue_WhenSetFiresAfterBaselineVersion()
{ {
@@ -35,6 +35,7 @@ public sealed class WnWrapAlarmConsumerXmlTests
private const string EmptyXml = private const string EmptyXml =
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"0\"></ALARM_RECORDS>"; "<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"0\"></ALARM_RECORDS>";
/// <summary>Verifies that empty XML payload returns an empty dictionary.</summary>
[Fact] [Fact]
public void ParseSnapshotXml_WithEmptyPayload_ReturnsEmptyDictionary() public void ParseSnapshotXml_WithEmptyPayload_ReturnsEmptyDictionary()
{ {
@@ -42,6 +43,7 @@ public sealed class WnWrapAlarmConsumerXmlTests
Assert.Empty(records); Assert.Empty(records);
} }
/// <summary>Verifies that null or whitespace payload returns an empty dictionary.</summary>
[Fact] [Fact]
public void ParseSnapshotXml_WithNullOrWhitespace_ReturnsEmptyDictionary() public void ParseSnapshotXml_WithNullOrWhitespace_ReturnsEmptyDictionary()
{ {
@@ -49,6 +51,7 @@ public sealed class WnWrapAlarmConsumerXmlTests
Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(" ")); Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(" "));
} }
/// <summary>Verifies that single alarm XML payload decodes the record correctly.</summary>
[Fact] [Fact]
public void ParseSnapshotXml_WithSingleActiveAlarm_DecodesRecord() public void ParseSnapshotXml_WithSingleActiveAlarm_DecodesRecord()
{ {
@@ -74,6 +77,7 @@ public sealed class WnWrapAlarmConsumerXmlTests
Assert.Equal(26, record.TransitionTimestampUtc.Minute); Assert.Equal(26, record.TransitionTimestampUtc.Minute);
} }
/// <summary>Verifies that invalid GUIDs in XML payload are silently dropped.</summary>
[Fact] [Fact]
public void ParseSnapshotXml_WithInvalidGuids_SilentlyDropsRecords() public void ParseSnapshotXml_WithInvalidGuids_SilentlyDropsRecords()
{ {
@@ -83,6 +87,9 @@ public sealed class WnWrapAlarmConsumerXmlTests
Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(xml)); Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(xml));
} }
/// <summary>Verifies that dashless 32-character hex GUIDs parse correctly.</summary>
/// <param name="hex">The dashless hex string.</param>
/// <param name="expected">The expected canonical GUID form.</param>
[Theory] [Theory]
[InlineData("BCC4705395424D65BDAABCDEA6A32A73", "BCC47053-9542-4D65-BDAA-BCDEA6A32A73")] [InlineData("BCC4705395424D65BDAABCDEA6A32A73", "BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]
[InlineData("00000000000000000000000000000000", "00000000-0000-0000-0000-000000000000")] [InlineData("00000000000000000000000000000000", "00000000-0000-0000-0000-000000000000")]
@@ -92,6 +99,8 @@ public sealed class WnWrapAlarmConsumerXmlTests
Assert.Equal(new Guid(expected), guid); Assert.Equal(new Guid(expected), guid);
} }
/// <summary>Verifies that canonical dashed GUID format is accepted.</summary>
/// <param name="canonical">The canonical GUID form.</param>
[Theory] [Theory]
[InlineData("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")] [InlineData("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]
public void TryParseHexGuid_WithCanonicalDashedForm_Accepts(string canonical) public void TryParseHexGuid_WithCanonicalDashedForm_Accepts(string canonical)
@@ -100,6 +109,8 @@ public sealed class WnWrapAlarmConsumerXmlTests
Assert.Equal(new Guid(canonical), guid); Assert.Equal(new Guid(canonical), guid);
} }
/// <summary>Verifies that invalid GUID inputs are rejected.</summary>
/// <param name="hex">The invalid GUID hex string.</param>
[Theory] [Theory]
[InlineData(null)] [InlineData(null)]
[InlineData("")] [InlineData("")]
@@ -135,11 +135,14 @@ public sealed class AlarmClientWmProbeTests : IDisposable
private IntPtr probeWindow = IntPtr.Zero; private IntPtr probeWindow = IntPtr.Zero;
private string? registeredClass; private string? registeredClass;
/// <summary>Initializes a new instance of the test class.</summary>
/// <param name="output">The test output helper.</param>
public AlarmClientWmProbeTests(ITestOutputHelper output) public AlarmClientWmProbeTests(ITestOutputHelper output)
{ {
this.output = output; this.output = output;
} }
/// <summary>Probes the alarm client for window message behavior (requires AVEVA installed).</summary>
[Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture alarm-path behavior")] [Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture alarm-path behavior")]
public void ProbeAlarmClient_OnDevRig_LogsAlarmWindowMessages() public void ProbeAlarmClient_OnDevRig_LogsAlarmWindowMessages()
{ {
@@ -772,6 +775,7 @@ public sealed class AlarmClientWmProbeTests : IDisposable
log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}"); log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
} }
/// <inheritdoc />
public void Dispose() public void Dispose()
{ {
if (wndProcHandle.IsAllocated) wndProcHandle.Free(); if (wndProcHandle.IsAllocated) wndProcHandle.Free();
@@ -36,11 +36,14 @@ public sealed class AlarmsLiveSmokeTests
private readonly Stopwatch elapsed = Stopwatch.StartNew(); private readonly Stopwatch elapsed = Stopwatch.StartNew();
private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>(); private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>();
/// <summary>Initializes a new instance of the AlarmsLiveSmokeTests class.</summary>
/// <param name="output">Test output helper for logging.</param>
public AlarmsLiveSmokeTests(ITestOutputHelper output) public AlarmsLiveSmokeTests(ITestOutputHelper output)
{ {
this.output = output; this.output = output;
} }
/// <summary>Verifies the alarm pipeline raises and acknowledges alarms correctly.</summary>
[Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + the alarm flip script running. Verified working 2026-05-01.")] [Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + the alarm flip script running. Verified working 2026-05-01.")]
public void Alarms_FullPipelineRoundTrip_RaisesAndAcknowledges() public void Alarms_FullPipelineRoundTrip_RaisesAndAcknowledges()
{ {
@@ -46,11 +46,14 @@ public sealed class WnWrapConsumerProbeTests
private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>(); private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>();
private readonly Stopwatch elapsed = Stopwatch.StartNew(); private readonly Stopwatch elapsed = Stopwatch.StartNew();
/// <summary>Initializes a new probe test with the given test output helper.</summary>
/// <param name="output">The xUnit test output helper.</param>
public WnWrapConsumerProbeTests(ITestOutputHelper output) public WnWrapConsumerProbeTests(ITestOutputHelper output)
{ {
this.output = output; this.output = output;
} }
/// <summary>Probes wnwrap consumer on dev rig and logs XML alarm stream output.</summary>
[Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture wnwrapConsumer XML alarm output. Verified working 2026-05-01.")] [Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture wnwrapConsumer XML alarm output. Verified working 2026-05-01.")]
public void ProbeWnWrapConsumer_OnDevRig_LogsXmlAlarmStream() public void ProbeWnWrapConsumer_OnDevRig_LogsXmlAlarmStream()
{ {
@@ -41,12 +41,16 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
private AlarmDispatcher? dispatcher; private AlarmDispatcher? dispatcher;
private bool disposed; private bool disposed;
/// <summary>Initializes a new alarm command handler with the given event queue.</summary>
/// <param name="eventQueue">The event queue.</param>
public AlarmCommandHandler(MxAccessEventQueue eventQueue) public AlarmCommandHandler(MxAccessEventQueue eventQueue)
: this(eventQueue, () => new WnWrapAlarmConsumer(), threadAffinityCheck: null) : this(eventQueue, () => new WnWrapAlarmConsumer(), threadAffinityCheck: null)
{ {
} }
/// <summary>Test seam — inject a custom consumer factory.</summary> /// <summary>Test seam — inject a custom consumer factory.</summary>
/// <param name="eventQueue">The event queue.</param>
/// <param name="consumerFactory">The alarm consumer factory.</param>
public AlarmCommandHandler( public AlarmCommandHandler(
MxAccessEventQueue eventQueue, MxAccessEventQueue eventQueue,
Func<IMxAccessAlarmConsumer> consumerFactory) Func<IMxAccessAlarmConsumer> consumerFactory)
@@ -68,6 +72,9 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
/// optional: tests that already drive the handler on a single /// optional: tests that already drive the handler on a single
/// thread can pass <c>null</c>. /// thread can pass <c>null</c>.
/// </summary> /// </summary>
/// <param name="eventQueue">The event queue.</param>
/// <param name="consumerFactory">The alarm consumer factory.</param>
/// <param name="threadAffinityCheck">Optional thread affinity check action.</param>
public AlarmCommandHandler( public AlarmCommandHandler(
MxAccessEventQueue eventQueue, MxAccessEventQueue eventQueue,
Func<IMxAccessAlarmConsumer> consumerFactory, Func<IMxAccessAlarmConsumer> consumerFactory,
@@ -78,6 +85,7 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
this.threadAffinityCheck = threadAffinityCheck; this.threadAffinityCheck = threadAffinityCheck;
} }
/// <summary>Gets a value indicating whether the handler is subscribed.</summary>
public bool IsSubscribed public bool IsSubscribed
{ {
get { lock (syncRoot) return dispatcher is not null; } get { lock (syncRoot) return dispatcher is not null; }
@@ -38,6 +38,10 @@ public sealed class AlarmDispatcher : IDisposable
private readonly EventHandler<MxAlarmTransitionEvent> handler; private readonly EventHandler<MxAlarmTransitionEvent> handler;
private bool disposed; private bool disposed;
/// <summary>Initializes a new alarm dispatcher for the given consumer, sink, and session ID.</summary>
/// <param name="consumer">The alarm consumer.</param>
/// <param name="sink">The alarm event sink.</param>
/// <param name="sessionId">The session identifier.</param>
public AlarmDispatcher( public AlarmDispatcher(
IMxAccessAlarmConsumer consumer, IMxAccessAlarmConsumer consumer,
MxAccessAlarmEventSink sink, MxAccessAlarmEventSink sink,
@@ -61,6 +65,7 @@ public sealed class AlarmDispatcher : IDisposable
/// transitions. The supplied subscription expression follows the /// transitions. The supplied subscription expression follows the
/// canonical <c>\\&lt;machine&gt;\Galaxy!&lt;area&gt;</c> format. /// canonical <c>\\&lt;machine&gt;\Galaxy!&lt;area&gt;</c> format.
/// </summary> /// </summary>
/// <param name="subscription">The subscription expression (e.g., <c>\\HOST\Galaxy!Area</c>).</param>
public void Subscribe(string subscription) public void Subscribe(string subscription)
{ {
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
@@ -72,6 +77,13 @@ public sealed class AlarmDispatcher : IDisposable
/// consumer's <c>AlarmAckByGUID</c>. Returns the AVEVA-native /// consumer's <c>AlarmAckByGUID</c>. Returns the AVEVA-native
/// status code (0 = success). /// status code (0 = success).
/// </summary> /// </summary>
/// <param name="alarmGuid">The alarm GUID.</param>
/// <param name="ackComment">The acknowledgment comment.</param>
/// <param name="ackOperatorName">The operator name.</param>
/// <param name="ackOperatorNode">The operator node.</param>
/// <param name="ackOperatorDomain">The operator domain.</param>
/// <param name="ackOperatorFullName">The operator full name.</param>
/// <returns>The AVEVA-native status code.</returns>
public int Acknowledge( public int Acknowledge(
Guid alarmGuid, Guid alarmGuid,
string ackComment, string ackComment,
@@ -95,6 +107,15 @@ public sealed class AlarmDispatcher : IDisposable
/// Routes to the consumer's <c>AcknowledgeByName</c> path which /// Routes to the consumer's <c>AcknowledgeByName</c> path which
/// maps to <c>wwAlarmConsumerClass.AlarmAckByName</c>. /// maps to <c>wwAlarmConsumerClass.AlarmAckByName</c>.
/// </summary> /// </summary>
/// <param name="alarmName">The alarm name.</param>
/// <param name="providerName">The provider name.</param>
/// <param name="groupName">The group name.</param>
/// <param name="ackComment">The acknowledgment comment.</param>
/// <param name="ackOperatorName">The operator name.</param>
/// <param name="ackOperatorNode">The operator node.</param>
/// <param name="ackOperatorDomain">The operator domain.</param>
/// <param name="ackOperatorFullName">The operator full name.</param>
/// <returns>The AVEVA-native status code.</returns>
public int AcknowledgeByName( public int AcknowledgeByName(
string alarmName, string alarmName,
string providerName, string providerName,
@@ -213,8 +234,10 @@ public sealed class AlarmDispatcher : IDisposable
}; };
} }
/// <summary>Gets the session ID.</summary>
public string SessionId => sessionId; public string SessionId => sessionId;
/// <inheritdoc />
public void Dispose() public void Dispose()
{ {
if (disposed) return; if (disposed) return;
@@ -23,6 +23,7 @@ public static class AlarmRecordTransitionMapper
/// <see cref="MxAlarmStateKind"/>. Unknown values map to /// <see cref="MxAlarmStateKind"/>. Unknown values map to
/// <see cref="MxAlarmStateKind.Unspecified"/>. /// <see cref="MxAlarmStateKind.Unspecified"/>.
/// </summary> /// </summary>
/// <param name="stateXml">The state XML string from AVEVA (e.g., UNACK_ALM, ACK_RTN).</param>
public static MxAlarmStateKind ParseStateKind(string? stateXml) public static MxAlarmStateKind ParseStateKind(string? stateXml)
{ {
if (string.IsNullOrWhiteSpace(stateXml)) return MxAlarmStateKind.Unspecified; if (string.IsNullOrWhiteSpace(stateXml)) return MxAlarmStateKind.Unspecified;
@@ -48,6 +49,8 @@ public static class AlarmRecordTransitionMapper
/// <item><description>Anything else → Unspecified (no proto kind to emit).</description></item> /// <item><description>Anything else → Unspecified (no proto kind to emit).</description></item>
/// </list> /// </list>
/// </summary> /// </summary>
/// <param name="previous">The previous alarm state kind.</param>
/// <param name="current">The current alarm state kind.</param>
public static AlarmTransitionKind MapTransition( public static AlarmTransitionKind MapTransition(
MxAlarmStateKind previous, MxAlarmStateKind previous,
MxAlarmStateKind current) MxAlarmStateKind current)
@@ -81,6 +84,9 @@ public static class AlarmRecordTransitionMapper
/// AcknowledgeAlarm RPC echoing a reference back as a GUID lookup) /// AcknowledgeAlarm RPC echoing a reference back as a GUID lookup)
/// don't need translation. /// don't need translation.
/// </summary> /// </summary>
/// <param name="providerName">The provider name, or null.</param>
/// <param name="groupName">The group name, or null.</param>
/// <param name="alarmName">The alarm name, or null.</param>
public static string ComposeFullReference(string? providerName, string? groupName, string? alarmName) public static string ComposeFullReference(string? providerName, string? groupName, string? alarmName)
{ {
string provider = providerName ?? string.Empty; string provider = providerName ?? string.Empty;
@@ -14,12 +14,20 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
public interface IAlarmCommandHandler : IDisposable public interface IAlarmCommandHandler : IDisposable
{ {
/// <summary>Begin a subscription against the supplied AVEVA alarm-provider expression.</summary> /// <summary>Begin a subscription against the supplied AVEVA alarm-provider expression.</summary>
/// <param name="subscription">The AVEVA alarm-provider subscription expression.</param>
/// <param name="sessionId">The session identifier.</param>
void Subscribe(string subscription, string sessionId); void Subscribe(string subscription, string sessionId);
/// <summary>Tear down the active subscription. No-op if not subscribed.</summary> /// <summary>Tear down the active subscription. No-op if not subscribed.</summary>
void Unsubscribe(); void Unsubscribe();
/// <summary>Acknowledge a single alarm by GUID. Returns AVEVA's native status (0 = success).</summary> /// <summary>Acknowledge a single alarm by GUID. Returns AVEVA's native status (0 = success).</summary>
/// <param name="alarmGuid">The alarm GUID.</param>
/// <param name="comment">The acknowledgment comment.</param>
/// <param name="operatorUser">The operator user name.</param>
/// <param name="operatorNode">The operator node name.</param>
/// <param name="operatorDomain">The operator domain name.</param>
/// <param name="operatorFullName">The operator full name.</param>
int Acknowledge( int Acknowledge(
Guid alarmGuid, Guid alarmGuid,
string comment, string comment,
@@ -32,6 +40,14 @@ public interface IAlarmCommandHandler : IDisposable
/// Acknowledge a single alarm by (name, provider, group) — used when /// Acknowledge a single alarm by (name, provider, group) — used when
/// the caller has the human-readable reference but not the GUID. /// the caller has the human-readable reference but not the GUID.
/// </summary> /// </summary>
/// <param name="alarmName">The alarm name.</param>
/// <param name="providerName">The provider name.</param>
/// <param name="groupName">The group name.</param>
/// <param name="comment">The acknowledgment comment.</param>
/// <param name="operatorUser">The operator user name.</param>
/// <param name="operatorNode">The operator node name.</param>
/// <param name="operatorDomain">The operator domain name.</param>
/// <param name="operatorFullName">The operator full name.</param>
int AcknowledgeByName( int AcknowledgeByName(
string alarmName, string alarmName,
string providerName, string providerName,
@@ -46,6 +62,7 @@ public interface IAlarmCommandHandler : IDisposable
/// Snapshot the currently-active alarm set, optionally scoped to a /// Snapshot the currently-active alarm set, optionally scoped to a
/// prefix matched against <c>AlarmFullReference</c>. /// prefix matched against <c>AlarmFullReference</c>.
/// </summary> /// </summary>
/// <param name="alarmFilterPrefix">Optional prefix to filter alarms by.</param>
IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix); IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix);
/// <summary> /// <summary>

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