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": {
"Enabled": true,
"AllowAnonymousLocalhost": true,
"RequireHttpsCookie": true,
"SnapshotIntervalMilliseconds": 1000,
"RecentFaultLimit": 100,
"RecentSessionLimit": 200,
@@ -146,6 +147,7 @@ the affected stream while the MXAccess session remains active.
|--------|---------|-------------|
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. |
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. |
| `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. |
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.IntegrationTests;
[Trait("Category", "LiveLdap")]
public sealed class DashboardLdapLiveTests
{
/// <summary>Verifies that an admin user in the GwAdmin group authenticates successfully.</summary>
[LiveLdapFact]
public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds()
{
@@ -38,6 +39,7 @@ public sealed class DashboardLdapLiveTests
&& claim.Value == DashboardRoles.Admin);
}
/// <summary>Verifies that a readonly user without GwAdmin group fails to authenticate.</summary>
[LiveLdapFact]
public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails()
{
@@ -53,6 +55,7 @@ public sealed class DashboardLdapLiveTests
Assert.DoesNotContain("readonly123", result.FailureMessage, StringComparison.Ordinal);
}
/// <summary>Verifies that authentication with wrong password fails without leaking the password.</summary>
[LiveLdapFact]
public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
{
@@ -71,6 +74,7 @@ public sealed class DashboardLdapLiveTests
Assert.DoesNotContain(wrongPassword, result.FailureMessage, StringComparison.Ordinal);
}
/// <summary>Verifies that authentication with unknown username fails.</summary>
[LiveLdapFact]
public async Task AuthenticateAsync_UnknownUsername_Fails()
{
@@ -87,6 +91,7 @@ public sealed class DashboardLdapLiveTests
Assert.Null(result.Principal);
}
/// <summary>Verifies that authentication fails gracefully when the server is unreachable.</summary>
[LiveLdapFact]
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
{
@@ -4,6 +4,7 @@ public sealed class LiveLdapFactAttribute : FactAttribute
{
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()
{
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);
}
@@ -1112,6 +1112,8 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// transitions it to Faulted, which the public gRPC API only exposes indirectly via
/// CloseSession's reply (and not before a graceful close completes).
/// </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)
{
return _registry.TryGet(sessionId, out session);
@@ -1534,6 +1536,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
private readonly StringBuilder buffer = new();
private readonly object syncRoot = new();
/// <summary>Gets the accumulated output buffer contents.</summary>
public string Captured
{
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)
{
lock (syncRoot)
@@ -1555,6 +1560,9 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
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)
{
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
{
/// <inheritdoc />
public Task<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity,
string tagAddress,
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
/// <inheritdoc />
public Task<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
@@ -1581,6 +1591,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
int itemHandle,
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
/// <inheritdoc />
public Task<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
@@ -1588,6 +1599,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
int itemHandle,
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
/// <inheritdoc />
public Task RecordDenialAsync(
ApiKeyIdentity? identity,
string commandKind,
@@ -683,8 +683,11 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
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;
/// <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)
{
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>
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>
public int SnapshotIntervalMilliseconds { get; init; } = 1_000;
@@ -9,6 +9,7 @@ public sealed class GatewayOptions
/// </summary>
public AuthenticationOptions Authentication { get; init; } = new();
/// <summary>Gets LDAP configuration options.</summary>
public LdapOptions Ldap { get; init; } = new();
/// <summary>
@@ -2,25 +2,36 @@ namespace ZB.MOM.WW.MxGateway.Server.Configuration;
public sealed class LdapOptions
{
/// <summary>Gets a value indicating whether LDAP authentication is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>Gets the LDAP server address.</summary>
public string Server { get; init; } = "localhost";
/// <summary>Gets the LDAP server port.</summary>
public int Port { get; init; } = 3893;
/// <summary>Gets a value indicating whether TLS is required for the connection.</summary>
public bool UseTls { get; init; }
/// <summary>Gets a value indicating whether insecure LDAP connections are allowed.</summary>
public bool AllowInsecureLdap { get; init; } = true;
/// <summary>Gets the LDAP search base distinguished name.</summary>
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";
/// <summary>Gets the service account password.</summary>
public string ServiceAccountPassword { get; init; } = "serviceaccount123";
/// <summary>Gets the LDAP attribute name for user names.</summary>
public string UserNameAttribute { get; init; } = "cn";
/// <summary>Gets the LDAP attribute name for display names.</summary>
public string DisplayNameAttribute { get; init; } = "cn";
/// <summary>Gets the LDAP attribute name for group membership.</summary>
public string GroupAttribute { get; init; } = "memberOf";
}
@@ -12,5 +12,6 @@ public sealed class ProtocolOptions
/// </summary>
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;
}
@@ -17,8 +17,10 @@ public sealed class SessionOptions
/// </summary>
public int MaxPendingCommandsPerSession { get; init; } = 128;
/// <summary>Gets the default session lease duration in seconds.</summary>
public int DefaultLeaseSeconds { get; init; } = 1800;
/// <summary>Gets the interval for sweeping expired session leases in seconds.</summary>
public int LeaseSweepIntervalSeconds { get; init; } = 30;
/// <summary>
@@ -12,6 +12,7 @@
<body class="dashboard-body">
<Routes @rendermode="InteractiveServer" />
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="/js/nav-state.js"></script>
<script src="/_framework/blazor.web.js"></script>
</body>
</html>
@@ -1,83 +1,210 @@
@using System.Linq
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.JSInterop
@implements IDisposable
@inherits LayoutComponentBase
@inject NavigationManager Navigation
@inject IJSRuntime JS
<header class="app-bar">
<a class="brand" href="/"><span class="mark">&#9646;</span> MXAccess Gateway</a>
<span class="crumb">&rsaquo;</span>
<span class="crumb">gateway dashboard</span>
<span class="spacer"></span>
<AuthorizeView>
<Authorized Context="authState">
<span class="meta">@authState.User.Identity?.Name</span>
<span class="conn-pill" data-state="connected">
<span class="dot"></span><span>signed in</span>
</span>
</Authorized>
<NotAuthorized>
<span class="conn-pill" data-state="disconnected">
<span class="dot"></span><span>signed out</span>
</span>
</NotAuthorized>
</AuthorizeView>
</header>
<div class="d-flex flex-column flex-lg-row" style="min-height: 100vh;">
@* Hamburger toggle: visible only on viewports <lg. Bootstrap collapse JS
lives in bootstrap.bundle.min.js (loaded in App.razor). *@
<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>
@* 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">
<nav class="side-rail">
<div class="rail-eyebrow">Overview</div>
<NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
<nav class="sidebar d-flex flex-column">
<a class="brand" href="/"><span class="mark">&#9646;</span> MXAccess Gateway</a>
<div class="rail-eyebrow">Runtime</div>
<NavLink class="rail-link" href="/sessions" Match="NavLinkMatch.Prefix">Sessions</NavLink>
<NavLink class="rail-link" href="/workers" Match="NavLinkMatch.Prefix">Workers</NavLink>
<NavLink class="rail-link" href="/events" Match="NavLinkMatch.Prefix">Events</NavLink>
<NavLink class="rail-link" href="/alarms" Match="NavLinkMatch.Prefix">Alarms</NavLink>
<div style="overflow-y:auto; flex:1 1 auto; min-height:0;">
<ul class="nav flex-column">
<li class="nav-item">
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
</li>
<div class="rail-eyebrow">Galaxy</div>
<NavLink class="rail-link" href="/galaxy" Match="NavLinkMatch.Prefix">Repository</NavLink>
<NavLink class="rail-link" href="/browse" Match="NavLinkMatch.Prefix">Browse</NavLink>
<NavSection Title="Runtime"
Expanded="@_expanded.Contains("runtime")"
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>
<NavLink class="rail-link" href="/apikeys" Match="NavLinkMatch.Prefix">API keys</NavLink>
<NavLink class="rail-link" href="/settings" Match="NavLinkMatch.Prefix">Settings</NavLink>
<NavSection Title="Galaxy"
Expanded="@_expanded.Contains("galaxy")"
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">
<AuthorizeView>
<Authorized Context="authState">
<div class="rail-eyebrow">Session</div>
<div class="rail-user">@authState.User.Identity?.Name</div>
<div class="rail-roles">
@string.Join(", ", authState.User.Claims
.Where(c => c.Type == System.Security.Claims.ClaimTypes.Role)
.Select(c => c.Value))
</div>
<form method="post" action="/logout">
<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>
<NavSection Title="Admin"
Expanded="@_expanded.Contains("admin")"
OnToggle="@(() => ToggleAsync("admin"))">
<li class="nav-item">
<NavLink class="nav-link" href="/apikeys" Match="NavLinkMatch.Prefix">API Keys</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/settings" Match="NavLinkMatch.Prefix">Settings</NavLink>
</li>
</NavSection>
</ul>
</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>
</div>
<main class="page">
<main class="page flex-grow-1">
@Body
</main>
</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
{
/// <summary>Determines whether the user can manage API keys.</summary>
/// <param name="user">The authenticated user principal.</param>
public bool CanManage(ClaimsPrincipal user)
{
if (user.Identity?.IsAuthenticated != true)
@@ -5,11 +5,18 @@ public sealed record DashboardApiKeyManagementResult(
string Message,
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)
{
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)
{
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.";
/// <summary>Determines whether the user can manage API keys.</summary>
/// <param name="user">The authenticated user principal.</param>
public bool CanManage(ClaimsPrincipal 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(
ClaimsPrincipal user,
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(
ClaimsPrincipal user,
string keyId,
@@ -100,6 +110,10 @@ public sealed class DashboardApiKeyManagementService(
: 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(
ClaimsPrincipal user,
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(
ClaimsPrincipal user,
string keyId,
@@ -35,5 +35,5 @@ public static class DashboardAuthenticationDefaults
public const string LdapGroupClaimType = "mxgateway:ldap_group";
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)
{
StringBuilder builder = new(value.Length);
@@ -141,6 +143,8 @@ public sealed class DashboardAuthenticator(
/// multiple roles; Admin and Viewer are the only legal values. Returns
/// an empty list when no group matches (caller rejects the login).
/// </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(
IEnumerable<string> groups,
IReadOnlyDictionary<string, string> groupToRole)
@@ -171,6 +175,8 @@ public sealed class DashboardAuthenticator(
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)
{
int equalsIndex = distinguishedName.IndexOf('=');
@@ -8,11 +8,14 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// </summary>
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;
/// <summary>Gets a requirement satisfied by any dashboard role (admin or viewer).</summary>
public static DashboardAuthorizationRequirement AnyDashboardRole { get; } =
new([DashboardRoles.Admin, DashboardRoles.Viewer]);
/// <summary>Gets a requirement satisfied only by the admin role.</summary>
public static DashboardAuthorizationRequirement AdminOnly { get; } =
new([DashboardRoles.Admin]);
}
@@ -4,6 +4,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
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)
{
try
@@ -191,11 +191,22 @@ public static class DashboardEndpointRouteBuilderExtensions
</section>
""";
return RenderPage("Dashboard Sign In", body);
return RenderPage("Dashboard Sign In", heading: null, body);
}
private static string RenderPage(string title, string body)
=> RenderPage(title, heading: title, body);
private static string RenderPage(string title, string? heading, string body)
{
string headingHtml = string.IsNullOrEmpty(heading)
? string.Empty
: $"""
<div class="dashboard-page-header">
<h1>{HtmlEncoder.Default.Encode(heading)}</h1>
</div>
""";
return $"""
<!doctype html>
<html lang="en">
@@ -212,9 +223,7 @@ public static class DashboardEndpointRouteBuilderExtensions
<span class="brand"><span class="mark">&#9646;</span> MXAccess Gateway</span>
</header>
<main class="page">
<div class="dashboard-page-header">
<h1>{HtmlEncoder.Default.Encode(title)}</h1>
</div>
{headingHtml}
{body}
</main>
<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>
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)
{
return entry.DashboardSummary;
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -39,8 +41,10 @@ public static class DashboardServiceCollectionExtensions
{
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
cookieOptions.Cookie.HttpOnly = true;
cookieOptions.Cookie.SecurePolicy = CookieSecurePolicy.Always;
cookieOptions.Cookie.SameSite = SameSiteMode.Strict;
// SecurePolicy is bound via PostConfigure below so it can honour
// DashboardOptions.RequireHttpsCookie (default Always; dev HTTP
// deployments set RequireHttpsCookie=false to use SameAsRequest).
cookieOptions.Cookie.Path = "/";
cookieOptions.LoginPath = "/login";
cookieOptions.LogoutPath = "/logout";
@@ -52,6 +56,14 @@ public static class DashboardServiceCollectionExtensions
DashboardAuthenticationDefaults.HubAuthenticationScheme,
_ => { });
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
.Configure<IOptions<GatewayOptions>>((cookieOptions, gatewayOptions) =>
{
cookieOptions.Cookie.SecurePolicy = gatewayOptions.Value.Dashboard.RequireHttpsCookie
? CookieSecurePolicy.Always
: CookieSecurePolicy.SameAsRequest;
});
services.AddAuthorization(authorization =>
{
authorization.AddPolicy(
@@ -4,11 +4,15 @@ public sealed record DashboardSessionAdminResult(
bool Succeeded,
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)
{
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)
{
return new DashboardSessionAdminResult(false, message);
@@ -35,8 +35,10 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
/// <param name="metrics">Gateway metrics collector.</param>
/// <param name="configurationProvider">Gateway configuration provider.</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="timeProvider">Provider for current time; defaults to system time.</param>
/// <param name="logger">Optional logger for the dashboard snapshot service.</param>
public DashboardSnapshotService(
ISessionRegistry sessionRegistry,
GatewayMetrics metrics,
@@ -14,6 +14,11 @@ public sealed class HubTokenAuthenticationHandler : AuthenticationHandler<Authen
{
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(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
@@ -24,6 +29,7 @@ public sealed class HubTokenAuthenticationHandler : AuthenticationHandler<Authen
_tokens = tokens;
}
/// <inheritdoc />
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
string? token = ExtractToken();
@@ -30,13 +30,16 @@ public sealed class HubTokenService
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)
{
ArgumentNullException.ThrowIfNull(dataProtection);
_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)
{
ArgumentNullException.ThrowIfNull(user);
@@ -47,7 +50,8 @@ public sealed class HubTokenService
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)
{
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>
public const string AlarmMessage = "AlarmFeed";
/// <inheritdoc />
public override async Task OnConnectedAsync()
{
await Groups.AddToGroupAsync(Context.ConnectionId, AllAlarmsGroup).ConfigureAwait(false);
@@ -16,6 +16,7 @@ public sealed class AlarmsHubPublisher(
IHubContext<AlarmsHub> hubContext,
ILogger<AlarmsHubPublisher> logger) : BackgroundService
{
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Loop forever — when StreamAsync completes (monitor restart, etc.)
@@ -14,6 +14,9 @@ public sealed class DashboardEventBroadcaster(
IHubContext<EventsHub> hubContext,
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)
{
if (string.IsNullOrEmpty(sessionId) || mxEvent is null)
@@ -16,6 +16,9 @@ public sealed class DashboardHubConnectionFactory(
HubTokenService tokens,
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)
{
ArgumentException.ThrowIfNullOrWhiteSpace(hubPath);
@@ -15,6 +15,7 @@ public sealed class DashboardSnapshotHub(IDashboardSnapshotService snapshotServi
/// <summary>Method name used to push snapshot updates to clients.</summary>
public const string SnapshotMessage = "SnapshotUpdated";
/// <inheritdoc />
public override async Task OnConnectedAsync()
{
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 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(
IDashboardSnapshotService snapshotService,
IHubContext<DashboardSnapshotHub> hubContext,
@@ -34,9 +38,12 @@ public sealed class DashboardSnapshotPublisher : BackgroundService
{
}
// Internal hook for the Server-042 regression test: tests inject a
// very short reconnect delay so the assertion doesn't wait the full
// 5s. Production wiring always uses the 5s default via the public ctor.
/// <summary>Initializes a new instance of the DashboardSnapshotPublisher class with custom reconnect delay.</summary>
/// <remarks>Internal hook for testing: tests inject a very short reconnect delay so assertions don't wait full 5s.</remarks>
/// <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(
IDashboardSnapshotService snapshotService,
IHubContext<DashboardSnapshotHub> hubContext,
@@ -49,6 +56,7 @@ public sealed class DashboardSnapshotPublisher : BackgroundService
_reconnectDelay = reconnectDelay;
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// 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>
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}";
/// <summary>
@@ -56,6 +59,9 @@ public sealed class EventsHub : Hub
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)
{
if (string.IsNullOrWhiteSpace(sessionId))
@@ -10,5 +10,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// </summary>
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);
}
@@ -4,23 +4,46 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
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);
/// <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(
ClaimsPrincipal user,
DashboardApiKeyManagementRequest request,
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(
ClaimsPrincipal user,
string keyId,
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(
ClaimsPrincipal user,
string keyId,
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(
ClaimsPrincipal user,
string keyId,
@@ -6,9 +6,10 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public interface IDashboardAuthenticator
{
/// <summary>
/// Authenticates the dashboard session with an API key.
/// Authenticates the dashboard session with credentials.
/// </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>
Task<DashboardAuthenticationResult> AuthenticateAsync(
string? username,
@@ -43,6 +43,9 @@ public static class GalaxyGlobMatcher
/// </summary>
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)
{
if (string.IsNullOrWhiteSpace(glob))
@@ -14,17 +14,24 @@ public sealed class GalaxyHierarchyIndex
TagsByAddress = tagsByAddress;
}
/// <summary>Gets an empty Galaxy hierarchy index.</summary>
public static GalaxyHierarchyIndex Empty { get; } = new(
Array.Empty<GalaxyObjectView>(),
new Dictionary<int, GalaxyObjectView>(),
new Dictionary<string, GalaxyTagLookup>(StringComparer.OrdinalIgnoreCase));
/// <summary>Gets the object views.</summary>
public IReadOnlyList<GalaxyObjectView> ObjectViews { get; }
/// <summary>Gets the object views indexed by GUID.</summary>
public IReadOnlyDictionary<int, GalaxyObjectView> ObjectViewsById { get; }
/// <summary>Gets tags indexed by address.</summary>
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)
{
if (objects.Count == 0)
@@ -21,6 +21,10 @@ public static class GalaxyHierarchyProjector
/// </summary>
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(
GalaxyHierarchyCacheEntry entry,
DiscoverHierarchyRequest request,
@@ -34,6 +38,12 @@ public static class GalaxyHierarchyProjector
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(
GalaxyHierarchyCacheEntry entry,
DiscoverHierarchyRequest request,
@@ -118,6 +128,9 @@ public static class GalaxyHierarchyProjector
(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(
GalaxyHierarchyCacheEntry entry,
string tagAddress)
@@ -132,6 +145,9 @@ public static class GalaxyHierarchyProjector
: 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(
GalaxyHierarchyCacheEntry entry,
string tagAddress)
@@ -146,6 +162,9 @@ public static class GalaxyHierarchyProjector
: 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(
GalaxyHierarchyCacheEntry entry,
int gobjectId)
@@ -260,6 +279,9 @@ public static class GalaxyHierarchyProjector
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(
DiscoverHierarchyRequest request,
IReadOnlyList<string>? browseSubtreeGlobs)
@@ -690,9 +690,13 @@ public sealed class MxAccessGatewayService(
bool HasAllowedItems)
{
/// <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);
/// <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);
}
@@ -703,6 +707,7 @@ public sealed class MxAccessGatewayService(
bool HasAllowedItems)
: BulkConstraintPlan(Command, OriginalCount, HasAllowedItems)
{
/// <inheritdoc />
public override MxCommandReply CreateDeniedReply(MxCommandRequest request)
{
MxCommandReply reply = new()
@@ -716,6 +721,7 @@ public sealed class MxAccessGatewayService(
return reply;
}
/// <inheritdoc />
public override MxCommandReply MergeDeniedInto(MxCommandReply reply)
{
BulkSubscribeReply allowed = GetPayload(reply) ?? new BulkSubscribeReply();
@@ -774,6 +780,7 @@ public sealed class MxAccessGatewayService(
bool HasAllowedItems)
: BulkConstraintPlan(Command, OriginalCount, HasAllowedItems)
{
/// <inheritdoc />
public override MxCommandReply CreateDeniedReply(MxCommandRequest request)
{
MxCommandReply reply = new()
@@ -787,6 +794,7 @@ public sealed class MxAccessGatewayService(
return reply;
}
/// <inheritdoc />
public override MxCommandReply MergeDeniedInto(MxCommandReply reply)
{
BulkWriteReply allowed = GetPayload(reply) ?? new BulkWriteReply();
@@ -849,6 +857,7 @@ public sealed class MxAccessGatewayService(
bool HasAllowedItems)
: BulkConstraintPlan(Command, OriginalCount, HasAllowedItems)
{
/// <inheritdoc />
public override MxCommandReply CreateDeniedReply(MxCommandRequest request)
{
MxCommandReply reply = new()
@@ -862,6 +871,7 @@ public sealed class MxAccessGatewayService(
return reply;
}
/// <inheritdoc />
public override MxCommandReply MergeDeniedInto(MxCommandReply reply)
{
BulkReadReply allowed = reply.ReadBulk ?? new BulkReadReply();
@@ -10,12 +10,16 @@ public static class ApiKeyConstraintSerializer
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)
{
ArgumentNullException.ThrowIfNull(constraints);
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)
{
if (string.IsNullOrWhiteSpace(json))
@@ -10,6 +10,7 @@ public sealed record ApiKeyConstraints(
bool ReadAlarmOnly,
bool ReadHistorizedOnly)
{
/// <summary>Gets an empty constraints instance with no restrictions.</summary>
public static ApiKeyConstraints Empty { get; } = new(
ReadSubtrees: Array.Empty<string>(),
WriteSubtrees: Array.Empty<string>(),
@@ -20,6 +21,7 @@ public sealed record ApiKeyConstraints(
ReadAlarmOnly: false,
ReadHistorizedOnly: false);
/// <summary>Gets a value indicating whether the constraints are empty (no restrictions).</summary>
public bool IsEmpty =>
ReadSubtrees.Count == 0
&& WriteSubtrees.Count == 0
@@ -30,12 +32,14 @@ public sealed record ApiKeyConstraints(
&& !ReadAlarmOnly
&& !ReadHistorizedOnly;
/// <summary>Gets a value indicating whether any read constraints are defined.</summary>
public bool HasReadConstraints =>
ReadSubtrees.Count > 0
|| ReadTagGlobs.Count > 0
|| ReadAlarmOnly
|| ReadHistorizedOnly;
/// <summary>Gets a value indicating whether any write constraints are defined.</summary>
public bool HasWriteConstraints =>
WriteSubtrees.Count > 0
|| WriteTagGlobs.Count > 0
@@ -7,5 +7,6 @@ public sealed record ApiKeyIdentity(
IReadOnlySet<string> Scopes,
ApiKeyConstraints? Constraints = null)
{
/// <summary>Gets the effective API key constraints (defaults to Empty if null).</summary>
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
/// rather than surfacing <c>SQLITE_BUSY</c> as a hard failure.
/// </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)
{
SqliteConnection connection = CreateConnection();
@@ -9,6 +9,10 @@ public sealed class ConstraintEnforcer(
IGalaxyHierarchyCache cache,
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(
ApiKeyIdentity? identity,
string tagAddress,
@@ -23,6 +27,12 @@ public sealed class ConstraintEnforcer(
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(
ApiKeyIdentity? identity,
GatewaySession session,
@@ -44,6 +54,12 @@ public sealed class ConstraintEnforcer(
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(
ApiKeyIdentity? identity,
GatewaySession session,
@@ -92,6 +108,12 @@ public sealed class ConstraintEnforcer(
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(
ApiKeyIdentity? identity,
string commandKind,
@@ -5,11 +5,21 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
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(
ApiKeyIdentity? identity,
string tagAddress,
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(
ApiKeyIdentity? identity,
GatewaySession session,
@@ -17,6 +27,12 @@ public interface IConstraintEnforcer
int itemHandle,
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(
ApiKeyIdentity? identity,
GatewaySession session,
@@ -24,6 +40,12 @@ public interface IConstraintEnforcer
int itemHandle,
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(
ApiKeyIdentity? identity,
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(
string sessionId,
string backendName,
@@ -158,6 +173,7 @@ public sealed class GatewaySession
/// </summary>
public TimeSpan ShutdownTimeout { get; }
/// <summary>Gets the lease duration for the session.</summary>
public TimeSpan LeaseDuration { get; }
/// <summary>
@@ -406,6 +422,10 @@ public sealed class GatewaySession
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(
int serverHandle,
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(
MxCommand command,
MxCommandReply reply)
@@ -608,9 +631,10 @@ public sealed class GatewaySession
cancellationToken);
}
/// <summary>
/// Executes a bulk Write command for the specified server and per-item entries.
/// </summary>
/// <summary>Executes a bulk Write command for the specified server and per-item entries.</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>> WriteBulkAsync(
int serverHandle,
IReadOnlyList<WriteBulkEntry> entries,
@@ -631,6 +655,9 @@ public sealed class GatewaySession
}
/// <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(
int serverHandle,
IReadOnlyList<Write2BulkEntry> entries,
@@ -651,6 +678,9 @@ public sealed class GatewaySession
}
/// <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(
int serverHandle,
IReadOnlyList<WriteSecuredBulkEntry> entries,
@@ -671,6 +701,9 @@ public sealed class GatewaySession
}
/// <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(
int serverHandle,
IReadOnlyList<WriteSecured2BulkEntry> entries,
@@ -694,6 +727,10 @@ public sealed class GatewaySession
/// Executes a bulk Read command — see <c>ReadBulkCommand</c>'s doc
/// comment in the .proto for the cached-vs-snapshot semantics.
/// </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(
int serverHandle,
IReadOnlyList<string> tagAddresses,
@@ -13,6 +13,7 @@ public sealed class SessionLeaseMonitorHostedService(
{
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
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:hover { text-decoration: none; }
/* ── App shell ───────────────────────────────────────────────────────────────
Two-column layout: fixed-width side rail (220px) + flexible main column.
Becomes single-column under <lg viewports — see media queries below. */
.app-shell {
display: flex;
align-items: stretch;
min-height: calc(100vh - 3.3rem);
}
.app-shell > .page { flex: 1; min-width: 0; }
/* ── Side rail ───────────────────────────────────────────────────────────────
Left-rail navigation with eyebrow section headings, a footer session
block, and active-route accent. */
.side-rail {
width: 220px;
flex: 0 0 220px;
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 1rem 0.7rem;
/* ── Sidebar ─────────────────────────────────────────────────────────────────
Left-rail navigation. Pattern lifted from ScadaLink CentralUI: a fixed-width
sidebar that hosts the brand at the top, a scrollable nav region with
collapsible NavSections in the middle, and a sign-in/out footer at the
bottom. The sidebar is wrapped in a Bootstrap .collapse so a hamburger
button can show/hide it on <lg viewports. */
.sidebar {
min-width: 220px;
max-width: 220px;
height: 100vh;
background: var(--card);
border-right: 1px solid var(--rule-strong);
}
/* Sidebar collapse wrapper: on lg+ it stays sticky to the viewport so the rail
remains visible when main content scrolls past 100vh (matches master). On
<lg viewports the Bootstrap .collapse class drives show/hide and the rail
spans full width — see the max-width media query below. */
/* Pin the sidebar to the viewport on lg+ so it stays visible when the main
content scrolls past 100vh. The wrapper is the flex child of MainLayout;
align-self prevents the flex row from stretching it. */
@media (min-width: 992px) {
#sidebar-collapse {
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) {
.app-shell { flex-direction: column; }
#sidebar-collapse { width: 100%; }
.side-rail {
width: 100%;
flex: 0 0 auto;
.sidebar {
min-width: 100%;
max-width: 100%;
height: auto;
border-right: none;
border-bottom: 1px solid var(--rule-strong);
}
}
.rail-eyebrow {
font-size: 0.68rem;
.sidebar .brand {
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;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--ink-faint);
padding: 0.4rem 0.6rem 0.2rem;
padding: 0.75rem 1rem 0.25rem;
margin-top: 0.5rem;
}
.rail-link {
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 {
.sidebar .nav-section-toggle:hover { color: var(--ink); }
.sidebar .nav-section-toggle .chevron {
display: inline-block;
margin: 0 0.6rem;
padding: 0.3rem 0.7rem;
font-size: 0.78rem;
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;
width: 0.8rem;
font-size: 0.8rem;
line-height: 1;
}
/* ── 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
/// <c>root</c> oneof arm and its proto wrapper-typed <c>max_depth</c> field.
/// </summary>
/// <param name="rootArm">The oneof arm selector (0=RootGobjectId, 1=RootTagName, 2=RootContainedPath).</param>
[Theory]
[InlineData(0)]
[InlineData(1)]
@@ -1165,6 +1166,8 @@ public sealed class ProtobufContractRoundTripTests
/// expected value. Pins every new oneof case added by the bulk
/// write/read extension.
/// </summary>
/// <param name="kind">The command kind to test.</param>
/// <param name="expectedPayloadCase">The expected payload oneof case.</param>
[Theory]
[InlineData(MxCommandKind.WriteBulk, MxCommandReply.PayloadOneofCase.WriteBulk)]
[InlineData(MxCommandKind.Write2Bulk, MxCommandReply.PayloadOneofCase.Write2Bulk)]
@@ -44,6 +44,7 @@ public sealed class GalaxyFilterInputSafetyTests
"Pump'001",
];
/// <summary>Returns adversarial input cases for theory tests.</summary>
public static TheoryData<string> AdversarialInputCases()
{
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,
/// and the same glob does not spuriously match an unrelated value.
/// </summary>
/// <param name="input">An adversarial input containing SQL metacharacters or LIKE wildcards.</param>
[Theory]
[MemberData(nameof(AdversarialInputCases))]
public void GlobMatcher_TreatsSqlMetacharactersAsLiterals(string input)
@@ -159,6 +161,7 @@ public sealed class GalaxyFilterInputSafetyTests
/// treats an adversarial glob as a literal: it never wildcard-matches the whole
/// hierarchy and never throws.
/// </summary>
/// <param name="glob">An adversarial glob containing SQL metacharacters or LIKE wildcards.</param>
[Theory]
[MemberData(nameof(AdversarialInputCases))]
public void Projector_TagNameGlob_WithAdversarialInput_DoesNotMatchEverything(string glob)
@@ -180,6 +183,7 @@ public sealed class GalaxyFilterInputSafetyTests
/// literal — an exact-match lookup that finds nothing and surfaces NotFound,
/// never matching unrelated objects or throwing an unexpected exception.
/// </summary>
/// <param name="rootTagName">An adversarial tag name containing SQL metacharacters or LIKE wildcards.</param>
[Theory]
[MemberData(nameof(AdversarialInputCases))]
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
/// substring test — it never matches unrelated template chains and never throws.
/// </summary>
/// <param name="filter">An adversarial filter containing SQL metacharacters or LIKE wildcards.</param>
[Theory]
[MemberData(nameof(AdversarialInputCases))]
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
/// zero matches rather than returning the whole hierarchy or faulting.
/// </summary>
/// <param name="glob">An adversarial glob containing SQL metacharacters or LIKE wildcards.</param>
[Theory]
[MemberData(nameof(AdversarialInputCases))]
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
/// a query fragment or matching unrelated objects.
/// </summary>
/// <param name="rootTagName">An adversarial tag name containing SQL metacharacters or LIKE wildcards.</param>
[Theory]
[MemberData(nameof(AdversarialInputCases))]
public async Task DiscoverHierarchy_WithAdversarialRootTagName_ReturnsNotFound(string rootTagName)
@@ -319,10 +326,13 @@ public sealed class GalaxyFilterInputSafetyTests
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{
/// <inheritdoc />
public GalaxyHierarchyCacheEntry Current { get; } = current;
/// <inheritdoc />
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -78,6 +78,7 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData);
}
/// <summary>Verifies that the hierarchy index builds paths and lookups without throwing on bad metadata.</summary>
[Fact]
public void GalaxyHierarchyIndex_BuildsPathsAndTagLookupsWithoutThrowingOnBadMetadata()
{
@@ -357,19 +358,24 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
{
private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Releases the blocking task.</summary>
public void Release() => _release.TrySetResult();
/// <inheritdoc />
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(false);
/// <inheritdoc />
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
{
await _release.Task.WaitAsync(ct).ConfigureAwait(false);
throw new InvalidOperationException("Galaxy repository unreachable");
}
/// <inheritdoc />
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
=> throw new InvalidOperationException("GetHierarchyAsync should not be reached");
/// <inheritdoc />
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
=> 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>
private sealed class CancellingSaveStore(CancellationTokenSource cts) : IGalaxyHierarchySnapshotStore
{
/// <inheritdoc />
public Task<GalaxyHierarchySnapshot?> TryLoadAsync(CancellationToken cancellationToken)
=> Task.FromResult<GalaxyHierarchySnapshot?>(null);
/// <inheritdoc />
public Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken)
{
cts.Cancel();
@@ -391,13 +399,17 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
/// <summary>Minimal <see cref="ILogger{T}"/> that records every emitted log entry.</summary>
private sealed class RecordingLogger<T> : ILogger<T>
{
/// <summary>Gets the list of recorded log entries.</summary>
public List<(LogLevel Level, string Message)> Entries { get; } = [];
/// <inheritdoc />
public IDisposable BeginScope<TState>(TState state)
where TState : notnull => NullScope.Instance;
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc />
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
@@ -412,6 +424,7 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
{
public static readonly NullScope Instance = new();
/// <inheritdoc />
public void Dispose()
{
}
@@ -427,20 +440,26 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
private readonly List<GalaxyHierarchyRow> _hierarchy = hierarchy ?? [];
private readonly List<GalaxyAttributeRow> _attributes = attributes ?? [];
/// <summary>Gets the count of calls to <see cref="GetHierarchyAsync"/>.</summary>
public int GetHierarchyCount { get; private set; }
/// <summary>Gets the count of calls to <see cref="GetAttributesAsync"/>.</summary>
public int GetAttributesCount { get; private set; }
/// <inheritdoc />
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
/// <inheritdoc />
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) => Task.FromResult(deployTime);
/// <inheritdoc />
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
{
GetHierarchyCount++;
return Task.FromResult(_hierarchy);
}
/// <inheritdoc />
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
{
GetAttributesCount++;
@@ -448,6 +467,7 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
}
}
/// <inheritdoc />
public void Dispose()
{
foreach (string path in _tempPaths)
@@ -16,6 +16,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Galaxy;
/// </summary>
public sealed class GalaxyHierarchyProjectorTests
{
/// <summary>Verifies that paging across a hierarchy returns every object exactly once.</summary>
[Fact]
public void Project_PagedAcrossEntireHierarchy_ReturnsEveryObjectExactlyOnce()
{
@@ -43,6 +44,7 @@ public sealed class GalaxyHierarchyProjectorTests
Assert.Equal("Object_025", collected[^1]);
}
/// <summary>Verifies that distinct filters on the same entry do not share memoized view list.</summary>
[Fact]
public void Project_DistinctFiltersOnSameEntry_DoNotShareMemoizedViewList()
{
@@ -60,6 +62,7 @@ public sealed class GalaxyHierarchyProjectorTests
Assert.Equal(10, unfiltered.TotalObjectCount);
}
/// <summary>Verifies that the same filter repeated returns identical totals.</summary>
[Fact]
public void Project_SameFilterRepeated_ReturnsIdenticalTotals()
{
@@ -85,6 +88,7 @@ public sealed class GalaxyHierarchyProjectorTests
Assert.NotEqual(first.Objects[0].TagName, second.Objects[0].TagName);
}
/// <summary>Verifies that distinct cache entries project against their own data.</summary>
[Fact]
public void Project_DistinctCacheEntries_ProjectAgainstTheirOwnData()
{
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Galaxy;
/// </summary>
public sealed class GalaxyHierarchyRefreshServiceTests
{
/// <summary>Verifies that the background service does not fault when the first refresh throws a non-cancellation exception.</summary>
[Fact]
public async Task ExecuteAsync_WhenFirstRefreshThrowsNonCancellationException_DoesNotFaultBackgroundService()
{
@@ -62,13 +63,17 @@ public sealed class GalaxyHierarchyRefreshServiceTests
private readonly TaskCompletionSource firstRefreshAttempted =
new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Gets the number of refresh calls.</summary>
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;
/// <summary>Gets the current cache entry.</summary>
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)
{
RefreshCallCount++;
@@ -76,6 +81,8 @@ public sealed class GalaxyHierarchyRefreshServiceTests
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;
}
}
@@ -12,6 +12,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
{
private readonly List<string> _tempPaths = [];
/// <summary>Verifies that snapshots are correctly saved to and loaded from disk.</summary>
[Fact]
public async Task SaveAsync_ThenTryLoadAsync_RoundTripsRows()
{
@@ -39,6 +40,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
Assert.Null(loaded.Attributes[1].ArrayDimension);
}
/// <summary>Verifies that loading returns null when no snapshot file exists.</summary>
[Fact]
public async Task TryLoadAsync_WhenNoFileExists_ReturnsNull()
{
@@ -47,6 +49,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
Assert.Null(await store.TryLoadAsync(CancellationToken.None));
}
/// <summary>Verifies that save writes nothing when persistence is disabled.</summary>
[Fact]
public async Task SaveAsync_WhenPersistenceDisabled_WritesNothing()
{
@@ -59,6 +62,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
Assert.Null(await store.TryLoadAsync(CancellationToken.None));
}
/// <summary>Verifies that loading returns null when the file contains invalid JSON.</summary>
[Fact]
public async Task TryLoadAsync_WhenFileIsCorruptJson_ReturnsNull()
{
@@ -69,6 +73,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
Assert.Null(await store.TryLoadAsync(CancellationToken.None));
}
/// <summary>Verifies that loading returns null when the schema version is unrecognized.</summary>
[Fact]
public async Task TryLoadAsync_WhenSchemaVersionUnrecognized_ReturnsNull()
{
@@ -79,6 +84,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
Assert.Null(await store.TryLoadAsync(CancellationToken.None));
}
/// <summary>Verifies that saving overwrites an earlier snapshot.</summary>
[Fact]
public async Task SaveAsync_OverwritesAnEarlierSnapshot()
{
@@ -159,6 +165,7 @@ public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
return path;
}
/// <inheritdoc />
public void Dispose()
{
foreach (string path in _tempPaths)
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardApiKeyAuthorizationTests
{
/// <summary>Verifies that CanManage returns true for authenticated admin user.</summary>
[Fact]
public void CanManage_AuthenticatedAdmin_ReturnsTrue()
{
@@ -14,6 +15,7 @@ public sealed class DashboardApiKeyAuthorizationTests
Assert.True(authorization.CanManage(user));
}
/// <summary>Verifies that CanManage returns false for anonymous user.</summary>
[Fact]
public void CanManage_AnonymousUser_ReturnsFalse()
{
@@ -23,6 +25,7 @@ public sealed class DashboardApiKeyAuthorizationTests
Assert.False(authorization.CanManage(user));
}
/// <summary>Verifies that CanManage returns false for authenticated viewer user.</summary>
[Fact]
public void CanManage_AuthenticatedViewer_ReturnsFalse()
{
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardApiKeyManagementServiceTests
{
/// <summary>Verifies that unauthorized users cannot create API keys.</summary>
[Fact]
public async Task CreateAsync_UnauthorizedUser_DoesNotCallStore()
{
@@ -25,6 +26,7 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.Equal(0, adminStore.CreateCount);
}
/// <summary>Verifies that authorized users can create keys with secret hashing and audit trail.</summary>
[Fact]
public async Task CreateAsync_AuthorizedUser_StoresHashOfSecretAndAudits()
{
@@ -54,6 +56,7 @@ public sealed class DashboardApiKeyManagementServiceTests
&& entry.KeyId == "operator01");
}
/// <summary>Verifies that unauthorized users cannot revoke API keys.</summary>
[Fact]
public async Task RevokeAsync_UnauthorizedUser_DoesNotCallStore()
{
@@ -69,6 +72,7 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.Equal(0, adminStore.RevokeCount);
}
/// <summary>Verifies that authorized users can revoke keys with audit trail.</summary>
[Fact]
public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits()
{
@@ -89,6 +93,7 @@ public sealed class DashboardApiKeyManagementServiceTests
&& entry.Details == "revoked");
}
/// <summary>Verifies that authorized users can rotate secret hashes with audit trail.</summary>
[Fact]
public async Task RotateAsync_AuthorizedUser_RotatesHashAndAudits()
{
@@ -112,6 +117,7 @@ public sealed class DashboardApiKeyManagementServiceTests
&& entry.Details == "rotated");
}
/// <summary>Verifies that unauthorized users cannot delete API keys.</summary>
[Fact]
public async Task DeleteAsync_UnauthorizedUser_DoesNotCallStore()
{
@@ -127,6 +133,7 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.Equal(0, adminStore.DeleteCount);
}
/// <summary>Verifies that authorized users can delete revoked keys with audit trail.</summary>
[Fact]
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
/// shared "API key id is required." message before any store or audit call runs.
/// </summary>
/// <param name="blankKeyId">A blank or whitespace key identifier.</param>
[Theory]
[InlineData("")]
[InlineData(" ")]
@@ -269,26 +277,37 @@ public sealed class DashboardApiKeyManagementServiceTests
private sealed class FakeApiKeyAdminStore : IApiKeyAdminStore
{
/// <summary>Gets the count of create operations performed.</summary>
public int CreateCount { get; private set; }
/// <summary>Gets the count of revoke operations performed.</summary>
public int RevokeCount { get; private set; }
/// <summary>Gets the count of delete operations performed.</summary>
public int DeleteCount { get; private set; }
/// <summary>Gets or sets the result value returned by revoke operations.</summary>
public bool RevokeResult { get; init; }
/// <summary>Gets or sets the result value returned by rotate operations.</summary>
public bool RotateResult { get; init; }
/// <summary>Gets or sets the result value returned by delete operations.</summary>
public bool DeleteResult { get; init; }
/// <summary>Gets the last key ID revoked.</summary>
public string? LastRevokedKeyId { get; private set; }
/// <summary>Gets the last key ID deleted.</summary>
public string? LastDeletedKeyId { get; private set; }
/// <summary>Gets the last secret hash rotated.</summary>
public byte[]? LastRotatedSecretHash { get; private set; }
/// <summary>Gets the list of create requests received.</summary>
public List<ApiKeyCreateRequest> CreatedRequests { get; } = [];
/// <inheritdoc />
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
{
CreateCount++;
@@ -296,11 +315,13 @@ public sealed class DashboardApiKeyManagementServiceTests
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
}
/// <inheritdoc />
public Task<bool> RevokeAsync(
string keyId,
DateTimeOffset revokedUtc,
@@ -311,6 +332,7 @@ public sealed class DashboardApiKeyManagementServiceTests
return Task.FromResult(RevokeResult);
}
/// <inheritdoc />
public Task<bool> RotateAsync(
string keyId,
byte[] secretHash,
@@ -321,6 +343,7 @@ public sealed class DashboardApiKeyManagementServiceTests
return Task.FromResult(RotateResult);
}
/// <inheritdoc />
public Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken)
{
DeleteCount++;
@@ -331,14 +354,17 @@ public sealed class DashboardApiKeyManagementServiceTests
private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore
{
/// <summary>Gets the list of audit entries appended.</summary>
public List<ApiKeyAuditEntry> Entries { get; } = [];
/// <inheritdoc />
public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
{
Entries.Add(entry);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(
int count,
CancellationToken cancellationToken)
@@ -349,8 +375,10 @@ public sealed class DashboardApiKeyManagementServiceTests
private sealed class FakeApiKeySecretHasher : IApiKeySecretHasher
{
/// <summary>Gets the last secret hashed.</summary>
public string? LastSecret { get; private set; }
/// <inheritdoc />
public byte[] HashSecret(string secret)
{
LastSecret = secret;
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardAuthenticatorTests
{
/// <summary>Verifies that LDAP filter special characters are escaped correctly.</summary>
[Fact]
public void EscapeLdapFilter_EscapesSpecialCharacters()
{
@@ -15,6 +16,9 @@ public sealed class DashboardAuthenticatorTests
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]
[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]
public void MapGroupsToRoles_AdminPlusViewer_BothRolesEmitted()
{
@@ -59,6 +64,7 @@ public sealed class DashboardAuthenticatorTests
Assert.Contains(DashboardRoles.Viewer, roles);
}
/// <summary>Verifies that extraction returns the leading RDN value from a distinguished name.</summary>
[Fact]
public void ExtractFirstRdnValue_ReturnsLeadingRdnValue()
{
@@ -68,6 +74,7 @@ public sealed class DashboardAuthenticatorTests
Assert.Equal("Gateway Admins", result);
}
/// <summary>Verifies that authentication fails when LDAP is disabled without exposing raw credentials.</summary>
[Fact]
public async Task AuthenticateAsync_LdapDisabled_ReturnsFailureWithoutRawCredentials()
{
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// </summary>
public sealed class DashboardBrowseAndAlarmModelTests
{
/// <summary>Verifies that the tree builder links children to parents and promotes orphans to roots.</summary>
[Fact]
public void BuildTree_LinksChildrenToParents_AndPromotesOrphansToRoots()
{
@@ -27,6 +28,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Contains(roots, node => node.Object.GobjectId == 3);
}
/// <summary>Verifies that the tree builder sorts areas before non-area objects.</summary>
[Fact]
public void BuildTree_SortsAreasBeforeObjects()
{
@@ -41,6 +43,9 @@ public sealed class DashboardBrowseAndAlarmModelTests
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]
[InlineData(true, "true")]
[InlineData(false, "false")]
@@ -50,6 +55,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Equal(expected, DashboardMxValueFormatter.FormatValue(value));
}
/// <summary>Verifies that the formatter renders numbers and strings correctly.</summary>
[Fact]
public void FormatValue_FormatsNumbersAndStrings()
{
@@ -57,6 +63,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Equal("hello", DashboardMxValueFormatter.FormatValue(new MxValue { StringValue = "hello" }));
}
/// <summary>Verifies that the formatter handles null payloads and null references.</summary>
[Fact]
public void FormatValue_HandlesNullPayloadAndNullReference()
{
@@ -64,6 +71,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Equal("(null)", DashboardMxValueFormatter.FormatValue(new MxValue { IsNull = true }));
}
/// <summary>Verifies that tag values from successful reads mark good quality.</summary>
[Fact]
public void TagValue_FromSuccessfulReadResult_MarksGoodQuality()
{
@@ -83,6 +91,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Null(value.Error);
}
/// <summary>Verifies that tag values from failed reads carry the error message.</summary>
[Fact]
public void TagValue_FromFailedReadResult_CarriesError()
{
@@ -101,6 +110,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.Equal("invalid handle", value.Error);
}
/// <summary>Verifies that active alarms parse provider and acknowledgement state from snapshots.</summary>
[Fact]
public void ActiveAlarm_FromSnapshot_ParsesProviderAndAcknowledgementState()
{
@@ -127,6 +137,7 @@ public sealed class DashboardBrowseAndAlarmModelTests
Assert.False(ackedRow.IsUnacknowledged);
}
/// <summary>Verifies that the formatter renders array elements and element type correctly.</summary>
[Fact]
public void FormatValue_AndDataType_RenderArrayElementsAndElementType()
{
@@ -4,6 +4,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardConnectionStringDisplayTests
{
/// <summary>Verifies that Galaxy connection strings strip SQL credentials and keep only non-secret fields.</summary>
[Fact]
public void GalaxyRepositoryConnectionString_WithSqlCredentials_OnlyKeepsNonSecretFields()
{
@@ -30,4 +30,23 @@ public sealed class DashboardCookieOptionsTests
Assert.Equal("/logout", options.LogoutPath);
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
{
/// <summary>Verifies that dashboard build maps all three hubs and token endpoint.</summary>
[Fact]
public async Task Build_WhenDashboardEnabled_MapsAllThreeHubsAndTokenEndpoint()
{
@@ -25,6 +26,7 @@ public sealed class DashboardHubsRegistrationTests
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardHubToken");
}
/// <summary>Verifies that dashboard build registers hub token service and connection factory.</summary>
[Fact]
public async Task Build_WhenDashboardEnabled_RegistersHubTokenServiceAndConnectionFactory()
{
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardSessionAdminServiceTests
{
/// <summary>Verifies that a viewer cannot close a session.</summary>
[Fact]
public async Task CloseSessionAsync_ViewerCannotManage()
{
@@ -24,6 +25,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Equal(0, sessionManager.CloseCount);
}
/// <summary>Verifies that an admin can close a session.</summary>
[Fact]
public async Task CloseSessionAsync_AdminClosesSession()
{
@@ -40,6 +42,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Equal("session-1", sessionManager.LastClosedSessionId);
}
/// <summary>Verifies that closing a missing session returns a friendly error message.</summary>
[Fact]
public async Task CloseSessionAsync_WhenSessionMissing_ReportsFriendlyError()
{
@@ -58,6 +61,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Contains("not found", result.Message, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that a viewer cannot kill a worker.</summary>
[Fact]
public async Task KillWorkerAsync_ViewerCannotManage()
{
@@ -73,6 +77,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Equal(0, sessionManager.KillCount);
}
/// <summary>Verifies that an admin can kill a worker.</summary>
[Fact]
public async Task KillWorkerAsync_AdminKillsWorker()
{
@@ -95,6 +100,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Equal("dashboard-admin-kill", sessionManager.LastKillReason);
}
/// <summary>Verifies that killing a worker with a blank session ID returns failure.</summary>
[Fact]
public async Task KillWorkerAsync_BlankSessionId_ReturnsFailure()
{
@@ -130,6 +136,7 @@ public sealed class DashboardSessionAdminServiceTests
Assert.Equal(0, sessionManager.CloseCount);
}
/// <summary>Verifies that CanManage rejects unauthenticated users and viewers.</summary>
[Fact]
public void CanManage_RejectsUnauthenticatedAndViewer()
{
@@ -209,22 +216,31 @@ public sealed class DashboardSessionAdminServiceTests
private sealed class FakeSessionManager : ISessionManager
{
/// <summary>Gets the number of times CloseSessionAsync was invoked.</summary>
public int CloseCount { get; private set; }
/// <summary>Gets the number of times KillWorkerAsync was invoked.</summary>
public int KillCount { get; private set; }
/// <summary>Gets the last session ID passed to CloseSessionAsync.</summary>
public string? LastClosedSessionId { get; private set; }
/// <summary>Gets the last session ID passed to KillWorkerAsync.</summary>
public string? LastKilledSessionId { get; private set; }
/// <summary>Gets the last reason string passed to KillWorkerAsync.</summary>
public string? LastKillReason { get; private set; }
/// <summary>Gets a value indicating whether CloseSessionAsync should throw SessionNotFound.</summary>
public bool CloseThrowsNotFound { get; init; }
/// <summary>Gets the exception CloseSessionAsync should throw unexpectedly.</summary>
public Exception? CloseThrowsUnexpected { get; init; }
/// <summary>Gets the exception KillWorkerAsync should throw unexpectedly.</summary>
public Exception? KillThrowsUnexpected { get; init; }
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
@@ -233,6 +249,7 @@ public sealed class DashboardSessionAdminServiceTests
throw new NotSupportedException();
}
/// <inheritdoc />
public bool TryGetSession(
string sessionId,
[MaybeNullWhen(false)] out GatewaySession session)
@@ -241,6 +258,7 @@ public sealed class DashboardSessionAdminServiceTests
return false;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
@@ -249,6 +267,7 @@ public sealed class DashboardSessionAdminServiceTests
throw new NotSupportedException();
}
/// <inheritdoc />
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken)
@@ -256,6 +275,7 @@ public sealed class DashboardSessionAdminServiceTests
throw new NotSupportedException();
}
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken)
@@ -277,6 +297,7 @@ public sealed class DashboardSessionAdminServiceTests
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
}
/// <inheritdoc />
public Task<SessionCloseResult> KillWorkerAsync(
string sessionId,
string reason,
@@ -293,6 +314,7 @@ public sealed class DashboardSessionAdminServiceTests
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
}
/// <inheritdoc />
public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken)
@@ -300,6 +322,7 @@ public sealed class DashboardSessionAdminServiceTests
return Task.FromResult(0);
}
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
@@ -110,6 +110,7 @@ public sealed class DashboardSnapshotPublisherTests
private sealed class ThrowOnceThenYieldSnapshotService : IDashboardSnapshotService
{
/// <summary>Gets the number of subscription attempts.</summary>
public int SubscribeCount { get; private set; }
/// <summary>
@@ -120,13 +121,17 @@ public sealed class DashboardSnapshotPublisherTests
/// </summary>
public DateTimeOffset? FirstThrowAt { get; private set; }
/// <summary>Gets the wall-clock instant of the second subscription attempt.</summary>
public DateTimeOffset? SecondSubscribeAt { get; private set; }
/// <summary>Gets the current snapshot.</summary>
public DashboardSnapshot GetSnapshot()
{
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(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -159,13 +164,17 @@ public sealed class DashboardSnapshotPublisherTests
private sealed class CompleteImmediatelySnapshotService : IDashboardSnapshotService
{
/// <summary>Gets the number of subscription attempts.</summary>
public int SubscribeCount { get; private set; }
/// <summary>Gets the current snapshot.</summary>
public DashboardSnapshot GetSnapshot()
{
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
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
@@ -182,24 +191,55 @@ public sealed class DashboardSnapshotPublisherTests
{
private readonly RecordingHubClients _clients = new();
/// <summary>Gets the hub clients.</summary>
public IHubClients Clients => _clients;
/// <summary>Gets the group manager.</summary>
public IGroupManager Groups { get; } = new NoopGroupManager();
/// <summary>Gets the number of send calls recorded.</summary>
public int SendCount => _clients.AllProxy.SendCount;
}
private sealed class RecordingHubClients : IHubClients
{
/// <summary>Gets the recording client proxy for all clients.</summary>
public RecordingClientProxy AllProxy { get; } = new();
/// <summary>Gets a client proxy targeting all clients.</summary>
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;
/// <summary>Gets a client proxy for a specific connection.</summary>
/// <param name="connectionId">The connection identifier.</param>
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;
/// <summary>Gets a client proxy for a group.</summary>
/// <param name="groupName">The group name.</param>
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;
/// <summary>Gets a client proxy for specified groups.</summary>
/// <param name="groupNames">The group names.</param>
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;
/// <summary>Gets a client proxy for specified users.</summary>
/// <param name="userIds">The user identifiers.</param>
public IClientProxy Users(IReadOnlyList<string> userIds) => AllProxy;
}
@@ -207,8 +247,13 @@ public sealed class DashboardSnapshotPublisherTests
{
private int _sendCount;
/// <summary>Gets the number of send calls recorded.</summary>
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)
{
Interlocked.Increment(ref _sendCount);
@@ -218,9 +263,17 @@ public sealed class DashboardSnapshotPublisherTests
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)
=> 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)
=> Task.CompletedTask;
}
@@ -267,6 +267,7 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal(0, apiKeyAdminStore.ListCount);
}
/// <summary>Verifies that snapshot service refreshes API key summaries before each snapshot.</summary>
[Fact]
public async Task WatchSnapshotsAsync_RefreshesApiKeySummariesBeforeSnapshot()
{
@@ -303,6 +304,7 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal(1, apiKeyAdminStore.ListCount);
}
/// <summary>Verifies that snapshot service reuses previous summaries when API key refresh fails.</summary>
[Fact]
public async Task WatchSnapshotsAsync_WhenApiKeyRefreshFails_ReusesPreviousSummaries()
{
@@ -346,6 +348,7 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal(2, apiKeyAdminStore.ListCount);
}
/// <summary>Verifies that snapshot service disposes cleanly when subscriber cancels.</summary>
[Fact]
public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly()
{
@@ -421,16 +424,19 @@ public sealed class DashboardSnapshotServiceTests
private class FakeApiKeyAdminStore : IApiKeyAdminStore
{
/// <inheritdoc />
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public virtual Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
}
/// <inheritdoc />
public Task<bool> RevokeAsync(
string keyId,
DateTimeOffset revokedUtc,
@@ -439,6 +445,7 @@ public sealed class DashboardSnapshotServiceTests
return Task.FromResult(false);
}
/// <inheritdoc />
public Task<bool> RotateAsync(
string keyId,
byte[] secretHash,
@@ -448,6 +455,7 @@ public sealed class DashboardSnapshotServiceTests
return Task.FromResult(false);
}
/// <inheritdoc />
public Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken)
{
return Task.FromResult(false);
@@ -456,8 +464,10 @@ public sealed class DashboardSnapshotServiceTests
private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore
{
/// <summary>Gets the count of list operations performed.</summary>
public int ListCount { get; protected set; }
/// <inheritdoc />
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
ListCount++;
@@ -467,8 +477,10 @@ public sealed class DashboardSnapshotServiceTests
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; }
/// <inheritdoc />
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
if (FailNext)
@@ -89,6 +89,7 @@ public sealed class HubTokenServiceTests
Assert.True(result.IsInRole(DashboardRoles.Viewer));
}
/// <summary>Verifies that a null token returns null.</summary>
[Fact]
public void Validate_NullToken_ReturnsNull()
{
@@ -97,6 +98,7 @@ public sealed class HubTokenServiceTests
Assert.Null(service.Validate(null));
}
/// <summary>Verifies that an empty token returns null.</summary>
[Fact]
public void Validate_EmptyToken_ReturnsNull()
{
@@ -105,6 +107,7 @@ public sealed class HubTokenServiceTests
Assert.Null(service.Validate(string.Empty));
}
/// <summary>Verifies that an invalid token returns null.</summary>
[Fact]
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]
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]
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
{
/// <summary>Gets the count of publish attempts.</summary>
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)
{
PublishAttempts++;
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc;
public sealed class GalaxyRepositoryGrpcServiceTests
{
/// <summary>Verifies that DiscoverHierarchy returns the requested page and totals.</summary>
[Fact]
public async Task DiscoverHierarchy_ReturnsRequestedPageAndTotals()
{
@@ -31,6 +32,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Assert.Equal(3, reply.TotalObjectCount);
}
/// <summary>Verifies that DiscoverHierarchy with a page token returns remaining objects.</summary>
[Fact]
public async Task DiscoverHierarchy_WithNextPageToken_ReturnsRemainingObjects()
{
@@ -56,6 +58,9 @@ public sealed class GalaxyRepositoryGrpcServiceTests
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]
[InlineData("-1", 1)]
[InlineData("not-an-offset", 1)]
@@ -80,6 +85,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
}
/// <summary>Verifies that DiscoverHierarchy with subtree root and depth filters descendants.</summary>
[Fact]
public async Task DiscoverHierarchy_WithSubtreeRootAndDepth_FiltersDescendants()
{
@@ -98,6 +104,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Assert.Equal(3, reply.TotalObjectCount);
}
/// <summary>Verifies that DiscoverHierarchy applies server-side filters and omits attributes.</summary>
[Fact]
public async Task DiscoverHierarchy_WithServerSideFilters_AppliesAllFiltersAndOmitsAttributes()
{
@@ -123,6 +130,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Assert.Equal(1, reply.TotalObjectCount);
}
/// <summary>Verifies that DiscoverHierarchy with filtered paging returns post-filter total.</summary>
[Fact]
public async Task DiscoverHierarchy_WithFilteredPaging_ReturnsPostFilterTotal()
{
@@ -154,6 +162,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Assert.NotEqual(firstObject.TagName, secondObject.TagName);
}
/// <summary>Verifies that DiscoverHierarchy with mismatched filter token returns InvalidArgument.</summary>
[Fact]
public async Task DiscoverHierarchy_WithMismatchedFilterToken_ReturnsInvalidArgument()
{
@@ -180,6 +189,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Assert.Contains("filters", exception.Status.Detail, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that DiscoverHierarchy with missing root returns NotFound.</summary>
[Fact]
public async Task DiscoverHierarchy_WithMissingRoot_ReturnsNotFound()
{
@@ -315,10 +325,13 @@ public sealed class GalaxyRepositoryGrpcServiceTests
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{
/// <inheritdoc />
public GalaxyHierarchyCacheEntry Current { get; } = current;
/// <inheritdoc />
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -827,12 +827,16 @@ public sealed class MxAccessGatewayServiceConstraintTests
{
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; }
/// <summary>Gets the last worker command that was invoked.</summary>
public WorkerCommand? LastWorkerCommand { get; private set; }
/// <summary>Gets the count of invoke calls made.</summary>
public int InvokeCount { get; private set; }
/// <summary>Gets or sets the default invoke reply to return.</summary>
public WorkerCommandReply InvokeReply { get; set; } = new()
{
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; } = [];
/// <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;
/// <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(
SessionOpenRequest request,
string? clientIdentity,
CancellationToken cancellationToken) =>
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)
{
if (seededSessions.TryGetValue(sessionId, out GatewaySession? seeded))
@@ -871,6 +885,10 @@ public sealed class MxAccessGatewayServiceConstraintTests
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(
string sessionId,
WorkerCommand command,
@@ -881,6 +899,9 @@ public sealed class MxAccessGatewayServiceConstraintTests
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(
string sessionId,
[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(
string sessionId,
CancellationToken cancellationToken) =>
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(
string sessionId,
string reason,
CancellationToken cancellationToken) =>
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(
DateTimeOffset now,
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;
private static GatewaySession CreateFallbackSession(string sessionId)
@@ -932,6 +965,9 @@ public sealed class MxAccessGatewayServiceConstraintTests
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(
StreamEventsRequest request,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
@@ -947,21 +983,33 @@ public sealed class MxAccessGatewayServiceConstraintTests
private sealed class FakeWorkerClient : IWorkerClient
{
/// <summary>Gets the test session identifier.</summary>
public string SessionId { get; } = MxAccessGatewayServiceConstraintTests.SessionId;
/// <summary>Gets the test worker process identifier.</summary>
public int? ProcessId { get; } = 1234;
/// <summary>Gets the test worker client state.</summary>
public WorkerClientState State { get; } = WorkerClientState.Ready;
/// <summary>Gets the last recorded heartbeat time.</summary>
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;
/// <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(
WorkerCommand command,
TimeSpan timeout,
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(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -969,12 +1017,18 @@ public sealed class MxAccessGatewayServiceConstraintTests
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;
/// <summary>Kills the test worker process.</summary>
/// <param name="reason">The reason for killing the worker.</param>
public void Kill(string reason)
{
}
/// <inheritdoc />
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -187,35 +187,46 @@ public sealed class GatewaySessionTests
private readonly TaskCompletionSource _shutdownStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _shutdownReleased = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Gets the session identifier.</summary>
public string SessionId { get; } = "session-test";
/// <summary>Gets the worker process identifier.</summary>
public int? ProcessId { get; } = 1234;
/// <summary>Gets or sets the worker client state.</summary>
public WorkerClientState State { get; private set; } = WorkerClientState.Ready;
/// <summary>Gets the last recorded heartbeat timestamp.</summary>
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// <summary>Gets the count of shutdown invocations.</summary>
public int ShutdownCount { get; private set; }
/// <summary>Gets the count of dispose invocations.</summary>
public int DisposeCount { get; private set; }
/// <summary>Waits for shutdown to start.</summary>
public Task WaitForShutdownStartAsync()
{
return _shutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
/// <summary>Releases the shutdown block.</summary>
public void ReleaseShutdown()
{
_shutdownReleased.TrySetResult();
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -223,6 +234,7 @@ public sealed class GatewaySessionTests
yield break;
}
/// <inheritdoc />
public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
ShutdownCount++;
@@ -231,11 +243,13 @@ public sealed class GatewaySessionTests
State = WorkerClientState.Closed;
}
/// <inheritdoc />
public void Kill(string reason)
{
State = WorkerClientState.Faulted;
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
DisposeCount++;
@@ -245,23 +259,31 @@ public sealed class GatewaySessionTests
private sealed class FakeWorkerClient : IWorkerClient
{
/// <summary>Gets the session identifier.</summary>
public string SessionId { get; } = "session-test";
/// <summary>Gets the worker process identifier.</summary>
public int? ProcessId { get; } = 1234;
/// <summary>Gets the worker client state.</summary>
public WorkerClientState State { get; } = WorkerClientState.Ready;
/// <summary>Gets the last recorded heartbeat timestamp.</summary>
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
/// <summary>Gets the count of dispose invocations.</summary>
public int DisposeCount { get; private set; }
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -269,12 +291,15 @@ public sealed class GatewaySessionTests
yield break;
}
/// <inheritdoc />
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public void Kill(string reason)
{
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
DisposeCount++;
@@ -20,6 +20,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
/// </summary>
public sealed class SessionManagerBulkTests
{
/// <summary>Verifies that AddItemBulkAsync forwards the command and returns results.</summary>
[Fact]
public async Task AddItemBulkAsync_ForwardsOneAddItemBulkCommandAndReturnsResults()
{
@@ -48,6 +49,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal("invalid tag", results[1].ErrorMessage);
}
/// <summary>Verifies that AddItemBulkAsync propagates cancellation.</summary>
[Fact]
public async Task AddItemBulkAsync_PropagatesCancellation()
{
@@ -60,6 +62,7 @@ public sealed class SessionManagerBulkTests
async () => await session.AddItemBulkAsync(12, ["Tag.A"], cts.Token));
}
/// <summary>Verifies that AdviseItemBulkAsync forwards the command and returns results.</summary>
[Fact]
public async Task AdviseItemBulkAsync_ForwardsOneAdviseItemBulkCommandAndReturnsResults()
{
@@ -86,6 +89,7 @@ public sealed class SessionManagerBulkTests
Assert.False(results[1].WasSuccessful);
}
/// <summary>Verifies that AdviseItemBulkAsync propagates cancellation.</summary>
[Fact]
public async Task AdviseItemBulkAsync_PropagatesCancellation()
{
@@ -98,6 +102,7 @@ public sealed class SessionManagerBulkTests
async () => await session.AdviseItemBulkAsync(12, [101], cts.Token));
}
/// <summary>Verifies that RemoveItemBulkAsync forwards the command and returns results.</summary>
[Fact]
public async Task RemoveItemBulkAsync_ForwardsOneRemoveItemBulkCommandAndReturnsResults()
{
@@ -122,6 +127,7 @@ public sealed class SessionManagerBulkTests
Assert.False(results[1].WasSuccessful);
}
/// <summary>Verifies that RemoveItemBulkAsync propagates cancellation.</summary>
[Fact]
public async Task RemoveItemBulkAsync_PropagatesCancellation()
{
@@ -134,6 +140,7 @@ public sealed class SessionManagerBulkTests
async () => await session.RemoveItemBulkAsync(12, [11], cts.Token));
}
/// <summary>Verifies that UnAdviseItemBulkAsync forwards the command and returns results.</summary>
[Fact]
public async Task UnAdviseItemBulkAsync_ForwardsOneUnAdviseItemBulkCommandAndReturnsResults()
{
@@ -159,6 +166,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal("not advised", results[1].ErrorMessage);
}
/// <summary>Verifies that UnAdviseItemBulkAsync propagates cancellation.</summary>
[Fact]
public async Task UnAdviseItemBulkAsync_PropagatesCancellation()
{
@@ -171,6 +179,7 @@ public sealed class SessionManagerBulkTests
async () => await session.UnAdviseItemBulkAsync(12, [21], cts.Token));
}
/// <summary>Verifies that SubscribeBulkAsync surfaces per-entry failures.</summary>
[Fact]
public async Task SubscribeBulkAsync_SurfacesPerEntryFailures()
{
@@ -198,6 +207,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal("MXAccess subscribe failed", results[1].ErrorMessage);
}
/// <summary>Verifies that SubscribeBulkAsync propagates cancellation.</summary>
[Fact]
public async Task SubscribeBulkAsync_PropagatesCancellation()
{
@@ -210,6 +220,7 @@ public sealed class SessionManagerBulkTests
async () => await session.SubscribeBulkAsync(12, ["Tag"], cts.Token));
}
/// <summary>Verifies that UnsubscribeBulkAsync forwards the command and returns results.</summary>
[Fact]
public async Task UnsubscribeBulkAsync_ForwardsOneUnsubscribeBulkCommandAndReturnsResults()
{
@@ -234,6 +245,7 @@ public sealed class SessionManagerBulkTests
Assert.False(results[1].WasSuccessful);
}
/// <summary>Verifies that UnsubscribeBulkAsync propagates cancellation.</summary>
[Fact]
public async Task UnsubscribeBulkAsync_PropagatesCancellation()
{
@@ -246,6 +258,7 @@ public sealed class SessionManagerBulkTests
async () => await session.UnsubscribeBulkAsync(12, [31], cts.Token));
}
/// <summary>Verifies that WriteBulkAsync surfaces per-entry failures.</summary>
[Fact]
public async Task WriteBulkAsync_SurfacesPerEntryFailures()
{
@@ -278,6 +291,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal("MXAccess invalid handle", results[1].ErrorMessage);
}
/// <summary>Verifies that WriteBulkAsync propagates cancellation.</summary>
[Fact]
public async Task WriteBulkAsync_PropagatesCancellation()
{
@@ -293,6 +307,7 @@ public sealed class SessionManagerBulkTests
cts.Token));
}
/// <summary>Verifies that Write2BulkAsync forwards the command and preserves timestamp payload.</summary>
[Fact]
public async Task Write2BulkAsync_ForwardsOneWrite2BulkCommandAndPreservesTimestampPayload()
{
@@ -335,6 +350,7 @@ public sealed class SessionManagerBulkTests
Assert.False(results[1].WasSuccessful);
}
/// <summary>Verifies that Write2BulkAsync propagates cancellation.</summary>
[Fact]
public async Task Write2BulkAsync_PropagatesCancellation()
{
@@ -359,6 +375,7 @@ public sealed class SessionManagerBulkTests
cts.Token));
}
/// <summary>Verifies that WriteSecuredBulkAsync forwards the command and preserves credential payload.</summary>
[Fact]
public async Task WriteSecuredBulkAsync_ForwardsOneWriteSecuredBulkCommandAndPreservesCredentialPayload()
{
@@ -409,6 +426,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal("MXAccess secured-write rejected", results[1].ErrorMessage);
}
/// <summary>Verifies that WriteSecuredBulkAsync propagates cancellation.</summary>
[Fact]
public async Task WriteSecuredBulkAsync_PropagatesCancellation()
{
@@ -480,6 +498,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal(1, workerClient.InvokeCount);
}
/// <summary>Verifies that WriteSecured2BulkAsync forwards the command and preserves credential and timestamp payload.</summary>
[Fact]
public async Task WriteSecured2BulkAsync_ForwardsOneWriteSecured2BulkCommandAndPreservesCredentialAndTimestampPayload()
{
@@ -527,6 +546,7 @@ public sealed class SessionManagerBulkTests
Assert.False(results[1].WasSuccessful);
}
/// <summary>Verifies that WriteSecured2BulkAsync propagates cancellation.</summary>
[Fact]
public async Task WriteSecured2BulkAsync_PropagatesCancellation()
{
@@ -552,6 +572,7 @@ public sealed class SessionManagerBulkTests
cts.Token));
}
/// <summary>Verifies that ReadBulkAsync surfaces per-entry failures.</summary>
[Fact]
public async Task ReadBulkAsync_SurfacesPerEntryFailures()
{
@@ -597,6 +618,7 @@ public sealed class SessionManagerBulkTests
Assert.Equal("MXAccess read timed out", results[1].ErrorMessage);
}
/// <summary>Verifies that ReadBulkAsync propagates cancellation.</summary>
[Fact]
public async Task ReadBulkAsync_PropagatesCancellation()
{
@@ -50,6 +50,7 @@ public sealed class SessionManagerTests
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]
public async Task OpenSessionAsync_GeneratesClientCorrelationIdFromClientNameAndSessionId()
{
@@ -124,6 +125,7 @@ public sealed class SessionManagerTests
Assert.True(session.LeaseExpiresAt > DateTimeOffset.UtcNow);
}
/// <summary>Verifies that gateway session subscribe bulk forwards one bulk command and returns results.</summary>
[Fact]
public async Task GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
{
@@ -168,6 +170,7 @@ public sealed class SessionManagerTests
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]
public async Task GatewaySessionWriteBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
{
@@ -232,6 +235,7 @@ public sealed class SessionManagerTests
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]
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"/> before any session lookup or worker call runs.
/// </summary>
/// <param name="blankReason">A blank or whitespace reason string.</param>
[Theory]
[InlineData("")]
[InlineData(" ")]
@@ -710,6 +715,7 @@ public sealed class SessionManagerTests
Assert.Equal(0, workerClient.ShutdownCount);
}
/// <summary>Verifies that shutdown closes all registered sessions.</summary>
[Fact]
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";
/// <summary>Verifies that orphan worker processes matching the configured executable path are killed.</summary>
[Fact]
public void TerminateOrphans_KillsWorkerProcessesMatchingConfiguredExecutablePath()
{
@@ -31,6 +32,7 @@ public sealed class OrphanWorkerTerminatorTests
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]
public void TerminateOrphans_KillsImageNameMatchWhenExecutablePathUnreadable()
{
@@ -49,6 +51,7 @@ public sealed class OrphanWorkerTerminatorTests
Assert.Equal([201], inspector.KilledProcessIds);
}
/// <summary>Verifies that unrelated processes with the same image name are not killed.</summary>
[Fact]
public void TerminateOrphans_DoesNotKillUnrelatedProcessSharingImageName()
{
@@ -66,6 +69,7 @@ public sealed class OrphanWorkerTerminatorTests
Assert.Empty(inspector.KilledProcessIds);
}
/// <summary>Verifies that the current process is not killed even if path matches.</summary>
[Fact]
public void TerminateOrphans_DoesNotKillCurrentProcess()
{
@@ -81,6 +85,7 @@ public sealed class OrphanWorkerTerminatorTests
Assert.Empty(inspector.KilledProcessIds);
}
/// <summary>Verifies that termination continues when one process kill fails.</summary>
[Fact]
public void TerminateOrphans_ContinuesWhenOneKillThrows()
{
@@ -118,12 +123,18 @@ public sealed class OrphanWorkerTerminatorTests
private sealed class FakeProcessInspector(IReadOnlyList<RunningProcessInfo> processes)
: IRunningProcessInspector
{
/// <summary>Gets the list of killed process IDs.</summary>
public List<int> KilledProcessIds { get; } = [];
/// <summary>Gets or sets the process ID that should throw when killed.</summary>
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;
/// <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)
{
if (ThrowOnKillProcessId == processId)
@@ -247,6 +247,7 @@ public sealed class WorkerClientTests
Assert.Equal(WorkerClientState.Faulted, client.State);
}
/// <summary>Verifies that pipe disconnect faults the client.</summary>
[Fact]
public async Task ReadLoop_WhenPipeDisconnects_FaultsClient()
{
@@ -767,23 +768,32 @@ public sealed class WorkerClientTests
{
private readonly TaskCompletionSource _exited = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Gets the process ID.</summary>
public int Id { get; } = WorkerProcessId;
/// <summary>Gets a value indicating whether the process has exited.</summary>
public bool HasExited { get; private set; }
/// <summary>Gets the process exit code.</summary>
public int? ExitCode { get; private set; }
/// <summary>Gets the number of times kill was called.</summary>
public int KillCount { get; private set; }
/// <summary>Gets the last kill request's entire process tree flag.</summary>
public bool KillEntireProcessTree { get; private set; }
/// <summary>Gets a value indicating whether dispose was called.</summary>
public bool Disposed { get; private set; }
/// <inheritdoc />
public ValueTask WaitForExitAsync(CancellationToken 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)
{
KillCount++;
@@ -793,6 +803,7 @@ public sealed class WorkerClientTests
_exited.TrySetResult();
}
/// <inheritdoc />
public void Dispose()
{
Disposed = true;
@@ -17,6 +17,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
private readonly List<string> _tempFiles = [];
/// <summary>Verifies that x86 executable matching required architecture does not throw.</summary>
[Fact]
public void Validate_X86ExecutableMatchingRequiredArchitecture_DoesNotThrow()
{
@@ -25,6 +26,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
WorkerExecutableValidator.Validate(path, WorkerArchitecture.X86);
}
/// <summary>Verifies that x64 executable matching required architecture does not throw.</summary>
[Fact]
public void Validate_X64ExecutableMatchingRequiredArchitecture_DoesNotThrow()
{
@@ -33,6 +35,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
WorkerExecutableValidator.Validate(path, WorkerArchitecture.X64);
}
/// <summary>Verifies that x64 executable when x86 required throws invalid executable.</summary>
[Fact]
public void Validate_X64ExecutableWhenX86Required_ThrowsInvalidExecutable()
{
@@ -45,6 +48,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
Assert.Contains("architecture", exception.Message, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that x86 executable when x64 required throws invalid executable.</summary>
[Fact]
public void Validate_X86ExecutableWhenX64Required_ThrowsInvalidExecutable()
{
@@ -56,6 +60,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
Assert.Equal(WorkerProcessLaunchErrorCode.InvalidExecutable, exception.ErrorCode);
}
/// <summary>Verifies that file without MZ header throws invalid executable.</summary>
[Fact]
public void Validate_FileWithoutMzHeader_ThrowsInvalidExecutable()
{
@@ -70,6 +75,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
Assert.Contains("MZ", exception.Message, StringComparison.Ordinal);
}
/// <summary>Verifies that file too small for PE header throws invalid executable.</summary>
[Fact]
public void Validate_FileTooSmallForPeHeader_ThrowsInvalidExecutable()
{
@@ -81,6 +87,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
Assert.Equal(WorkerProcessLaunchErrorCode.InvalidExecutable, exception.ErrorCode);
}
/// <summary>Verifies that file without PE signature throws invalid executable.</summary>
[Fact]
public void Validate_FileWithoutPeSignature_ThrowsInvalidExecutable()
{
@@ -122,6 +129,7 @@ public sealed class WorkerExecutableValidatorTests : IDisposable
return path;
}
/// <inheritdoc />
public void Dispose()
{
foreach (string path in _tempFiles)
@@ -177,6 +177,7 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
Assert.Equal(1, CountOccurrences(json, ApiKeySecret(apiKey)));
}
/// <summary>Verifies that API key constraints are persisted correctly.</summary>
[Fact]
public async Task CreateKeyAsync_WithConstraints_PersistsConstraints()
{
@@ -141,6 +141,7 @@ public sealed class ApiKeyAdminCommandLineParserTests
Assert.True(constraints.ReadHistorizedOnly);
}
/// <summary>Verifies that create-key command without display name returns error.</summary>
[Fact]
public void Parse_CreateKeyWithoutDisplayName_ReturnsError()
{
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization;
public sealed class ConstraintEnforcerTests
{
/// <summary>Verifies that read outside allowed subtree returns failure.</summary>
[Fact]
public async Task CheckReadTagAsync_WhenOutsideReadSubtree_ReturnsFailure()
{
@@ -28,6 +29,7 @@ public sealed class ConstraintEnforcerTests
Assert.Equal("read_scope", failure.ConstraintName);
}
/// <summary>Verifies that write with high classification returns failure and audits.</summary>
[Fact]
public async Task CheckWriteHandleAsync_WhenClassificationTooHigh_ReturnsFailureAndAudits()
{
@@ -70,6 +72,7 @@ public sealed class ConstraintEnforcerTests
Assert.Contains("max_write_classification", entry.Details, StringComparison.Ordinal);
}
/// <summary>Verifies that historized-only constraint requires historized attribute.</summary>
[Fact]
public async Task CheckReadTagAsync_WithHistorizedOnly_RequiresRequestedAttributeToBeHistorized()
{
@@ -88,6 +91,7 @@ public sealed class ConstraintEnforcerTests
Assert.Equal("read_historized_only", failure.ConstraintName);
}
/// <summary>Verifies that alarm-only constraint requires alarm attribute.</summary>
[Fact]
public async Task CheckReadTagAsync_WithAlarmOnly_RequiresRequestedAttributeToBeAlarm()
{
@@ -106,6 +110,7 @@ public sealed class ConstraintEnforcerTests
Assert.Equal("read_alarm_only", failure.ConstraintName);
}
/// <summary>Verifies that attribute-only constraint fails closed for object tag.</summary>
[Fact]
public async Task CheckReadTagAsync_WithAttributeOnlyConstraint_FailsClosedForObjectTag()
{
@@ -222,23 +227,29 @@ public sealed class ConstraintEnforcerTests
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{
/// <summary>Gets the current cache entry.</summary>
public GalaxyHierarchyCacheEntry Current { get; } = current;
/// <inheritdoc />
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
private sealed class FakeAuditStore : IApiKeyAuditStore
{
/// <summary>Gets the recorded audit entries.</summary>
public List<ApiKeyAuditEntry> Entries { get; } = [];
/// <inheritdoc />
public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
{
Entries.Add(entry);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(int count, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<ApiKeyAuditRecord>>([]);
@@ -16,11 +16,14 @@ public sealed class AlarmClientDiscoveryTests
{
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)
{
this.output = output;
}
/// <summary>Dumps the public surface of the aaAlarmManagedClient assembly.</summary>
[Fact(Skip = "Discovery probe — flip Skip=null to dump aaAlarmManagedClient surface")]
public void DumpAlarmClientPublicSurface()
{
@@ -1094,12 +1094,16 @@ public sealed class WorkerPipeSessionTests
}
/// <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)
{
Record(eventName, fields);
}
/// <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)
{
Record(eventName, fields);
@@ -22,6 +22,7 @@ public sealed class AlarmCommandExecutorTests
private const string SessionId = "S";
private const string CorrelationId = "C";
/// <summary>Verifies that the handler routes alarm subscriptions and returns ok.</summary>
[Fact]
public void SubscribeAlarms_WithHandler_RoutesToHandlerAndReturnsOk()
{
@@ -46,6 +47,7 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal(SessionId, handler.LastSessionId);
}
/// <summary>Verifies that subscription without handler returns invalid request.</summary>
[Fact]
public void SubscribeAlarms_WithoutHandler_ReturnsInvalidRequest()
{
@@ -67,6 +69,7 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
}
/// <summary>Verifies that empty subscription expression returns invalid request.</summary>
[Fact]
public void SubscribeAlarms_WithEmptyExpression_ReturnsInvalidRequest()
{
@@ -88,6 +91,7 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
}
/// <summary>Verifies that acknowledge routes native status into hresult and payload.</summary>
[Fact]
public void AcknowledgeAlarm_WithHandler_RoutesNativeStatusIntoHresultAndPayload()
{
@@ -121,6 +125,7 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal("alice", handler.LastAckOperatorName);
}
/// <summary>Verifies that invalid alarm GUID returns invalid request.</summary>
[Fact]
public void AcknowledgeAlarm_WithInvalidGuid_ReturnsInvalidRequest()
{
@@ -142,6 +147,7 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
}
/// <summary>Verifies that nonzero native status carries a diagnostic message.</summary>
[Fact]
public void AcknowledgeAlarm_WithNonzeroNativeStatus_CarriesDiagnostic()
{
@@ -165,6 +171,7 @@ public sealed class AlarmCommandExecutorTests
Assert.Contains("-123", reply.DiagnosticMessage);
}
/// <summary>Verifies that acknowledge by name routes tuple to handler.</summary>
[Fact]
public void AcknowledgeAlarmByName_WithHandler_RoutesTupleToHandler()
{
@@ -198,6 +205,7 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal("alice", handler.LastAckOperatorName);
}
/// <summary>Verifies that empty alarm name returns invalid request.</summary>
[Fact]
public void AcknowledgeAlarmByName_WithEmptyName_ReturnsInvalidRequest()
{
@@ -221,6 +229,7 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
}
/// <summary>Verifies that query active alarms returns payload with snapshots.</summary>
[Fact]
public void QueryActiveAlarms_WithHandler_ReturnsPayloadWithSnapshots()
{
@@ -253,6 +262,7 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal("Galaxy!A", handler.LastFilterPrefix);
}
/// <summary>Verifies that unsubscribe routes to handler.</summary>
[Fact]
public void UnsubscribeAlarms_WithHandler_RoutesToHandler()
{
@@ -273,6 +283,7 @@ public sealed class AlarmCommandExecutorTests
Assert.True(handler.UnsubscribeCalled);
}
/// <summary>Verifies that unsubscribe without handler is an ok noop.</summary>
[Fact]
public void UnsubscribeAlarms_WithoutHandler_IsOkNoop()
{
@@ -291,6 +302,7 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
}
/// <summary>Verifies that handler exception returns MXAccess failure status.</summary>
[Fact]
public void AcknowledgeAlarm_WhenHandlerThrows_ReturnsMxaccessFailure()
{
@@ -331,28 +343,56 @@ public sealed class AlarmCommandExecutorTests
private sealed class FakeAlarmHandler : IAlarmCommandHandler
{
/// <summary>Gets the last subscription expression.</summary>
public string? LastSubscription { get; private set; }
/// <summary>Gets the last session ID.</summary>
public string? LastSessionId { get; private set; }
/// <summary>Gets a value indicating whether unsubscribe was called.</summary>
public bool UnsubscribeCalled { get; private set; }
/// <summary>Gets the last acknowledge alarm GUID.</summary>
public Guid LastAckGuid { get; private set; }
/// <summary>Gets the last acknowledge operator name.</summary>
public string? LastAckOperatorName { get; private set; }
/// <summary>Gets or sets the value returned by acknowledge.</summary>
public int AcknowledgeReturn { get; set; }
/// <summary>Gets or sets a value indicating whether acknowledge throws.</summary>
public bool AcknowledgeThrow { get; set; }
/// <summary>Gets or sets the query result snapshots.</summary>
public IReadOnlyList<ActiveAlarmSnapshot> QueryResult { get; set; } =
Array.Empty<ActiveAlarmSnapshot>();
/// <summary>Gets the last alarm filter prefix.</summary>
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)
{
LastSubscription = subscription;
LastSessionId = sessionId;
}
/// <summary>Records an unsubscribe request.</summary>
public void Unsubscribe()
{
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(
Guid alarmGuid, string comment, string operatorUser,
string operatorNode, string operatorDomain, string operatorFullName)
@@ -366,6 +406,15 @@ public sealed class AlarmCommandExecutorTests
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(
string alarmName, string providerName, string groupName,
string comment, string operatorUser, string operatorNode,
@@ -376,21 +425,27 @@ public sealed class AlarmCommandExecutorTests
return AcknowledgeReturn;
}
/// <summary>Gets the last acknowledge by name tuple.</summary>
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)
{
LastFilterPrefix = alarmFilterPrefix;
return QueryResult;
}
/// <summary>Gets the number of poll calls.</summary>
public int PollCount { get; private set; }
/// <summary>Increments the poll count.</summary>
public void PollOnce()
{
PollCount++;
}
/// <inheritdoc />
public void Dispose() { }
}
}
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// </summary>
public sealed class AlarmCommandHandlerTests
{
/// <summary>Verifies that subscribe creates a consumer and forwards the subscription when not yet subscribed.</summary>
[Fact]
public void Subscribe_WhenNotYetSubscribed_CreatesConsumerAndCallsSubscribe()
{
@@ -26,6 +27,7 @@ public sealed class AlarmCommandHandlerTests
Assert.Equal(@"\\HOST\Galaxy!Area", consumer.LastSubscription);
}
/// <summary>Verifies that subscribe throws when already subscribed.</summary>
[Fact]
public void Subscribe_WhenAlreadySubscribed_Throws()
{
@@ -67,6 +69,7 @@ public sealed class AlarmCommandHandlerTests
Assert.True(consumer.Disposed);
}
/// <summary>Verifies that unsubscribe disposes consumer and clears state when subscribed.</summary>
[Fact]
public void Unsubscribe_WhenSubscribed_DisposesConsumerAndClearsState()
{
@@ -82,6 +85,7 @@ public sealed class AlarmCommandHandlerTests
Assert.True(consumer.Disposed);
}
/// <summary>Verifies that unsubscribe is a no-op when not yet subscribed.</summary>
[Fact]
public void Unsubscribe_WithoutPriorSubscribe_IsNoop()
{
@@ -92,6 +96,7 @@ public sealed class AlarmCommandHandlerTests
Assert.False(handler.IsSubscribed);
}
/// <summary>Verifies that acknowledge forwards to consumer with full operator identity when subscribed.</summary>
[Fact]
public void Acknowledge_WhenSubscribed_ForwardsToConsumerWithFullOperatorIdentity()
{
@@ -109,6 +114,7 @@ public sealed class AlarmCommandHandlerTests
Assert.Equal("u", consumer.LastAckOperatorName);
}
/// <summary>Verifies that acknowledge throws invalid operation when called before subscribe.</summary>
[Fact]
public void Acknowledge_BeforeSubscribe_ThrowsInvalidOperation()
{
@@ -120,6 +126,7 @@ public sealed class AlarmCommandHandlerTests
() => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
}
/// <summary>Verifies that query active returns mapped proto snapshots when consumer has alarms.</summary>
[Fact]
public void QueryActive_WhenConsumerHasAlarms_ReturnsMappedProtoSnapshots()
{
@@ -151,6 +158,7 @@ public sealed class AlarmCommandHandlerTests
Assert.Equal(AlarmConditionState.Active, snapshots[0].CurrentState);
}
/// <summary>Verifies that query active filters by prefix when prefix is provided.</summary>
[Fact]
public void QueryActive_WithPrefix_FiltersByPrefix()
{
@@ -173,6 +181,7 @@ public sealed class AlarmCommandHandlerTests
Assert.Equal("Galaxy!AreaA.Tag1", filtered[0].AlarmFullReference);
}
/// <summary>Verifies that dispose unsubscribes and disposes consumer when subscribed.</summary>
[Fact]
public void Dispose_WhenSubscribed_UnsubscribesAndDisposesConsumer()
{
@@ -281,18 +290,28 @@ public sealed class AlarmCommandHandlerTests
private sealed class FakeConsumer : IMxAccessAlarmConsumer
{
#pragma warning disable CS0067 // Event never invoked — fake; AlarmCommandHandler tests don't drive transitions.
/// <summary>Emitted when an alarm state transition occurs.</summary>
public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
#pragma warning restore CS0067
/// <summary>Gets the last subscription request.</summary>
public string? LastSubscription { get; private set; }
/// <summary>Gets the last acknowledged alarm GUID.</summary>
public Guid LastAckGuid { get; private set; }
/// <summary>Gets the last acknowledged operator name.</summary>
public string? LastAckOperatorName { get; private set; }
/// <summary>Gets or sets the return value for acknowledge operations.</summary>
public int AcknowledgeReturn { get; set; }
/// <summary>Gets or sets the snapshot result to return.</summary>
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
Array.Empty<MxAlarmSnapshotRecord>();
/// <summary>Gets or sets a value indicating whether to throw on subscribe.</summary>
public bool ThrowOnSubscribe { get; set; }
/// <summary>Gets a value indicating whether the consumer has been disposed.</summary>
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)
{
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(
Guid alarmGuid, string ackComment, string ackOperatorName,
string ackOperatorNode, string ackOperatorDomain, string ackOperatorFullName)
@@ -311,6 +337,15 @@ public sealed class AlarmCommandHandlerTests
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(
string alarmName, string providerName, string groupName,
string ackComment, string ackOperatorName, string ackOperatorNode,
@@ -321,17 +356,22 @@ public sealed class AlarmCommandHandlerTests
return AcknowledgeReturn;
}
/// <summary>Gets the last acknowledge-by-name parameters.</summary>
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
/// <summary>Returns a snapshot of active alarms.</summary>
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms() => SnapshotResult;
/// <summary>Gets the number of times polled.</summary>
public int PollCount { get; private set; }
/// <summary>Polls once for alarm updates.</summary>
public void PollOnce()
{
PollCount++;
}
/// <inheritdoc />
public void Dispose()
{
Disposed = true;
@@ -17,6 +17,7 @@ public sealed class AlarmDispatcherTests
{
private const string SessionId = "session-001";
/// <summary>Verifies that alarm transitions land in the queue with correctly mapped fields.</summary>
[Fact]
public void OnTransition_WhenAlarmTransitionRaised_LandsInQueueWithMappedFields()
{
@@ -63,6 +64,7 @@ public sealed class AlarmDispatcherTests
Assert.Equal(ts, body.TransitionTimestamp.ToDateTime());
}
/// <summary>Verifies that unchanged alarm states do not emit transitions.</summary>
[Fact]
public void OnTransition_WithConsecutiveUnchangedState_DoesNotEmitTransition()
{
@@ -89,6 +91,10 @@ public sealed class AlarmDispatcherTests
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]
[InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)]
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.AckAlm, AlarmTransitionKind.Acknowledge)]
@@ -122,6 +128,7 @@ public sealed class AlarmDispatcherTests
Assert.Equal(expected, evt!.Event.OnAlarmTransition.TransitionKind);
}
/// <summary>Verifies that subscribe calls are forwarded to the consumer.</summary>
[Fact]
public void Subscribe_WhenInvoked_ForwardsToConsumer()
{
@@ -135,6 +142,7 @@ public sealed class AlarmDispatcherTests
Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription);
}
/// <summary>Verifies that acknowledge calls are forwarded to the consumer with operator identity.</summary>
[Fact]
public void Acknowledge_WhenInvoked_ForwardsToConsumerWithFullOperatorIdentity()
{
@@ -158,6 +166,7 @@ public sealed class AlarmDispatcherTests
Assert.Equal("Alice Smith", consumer.LastAckOperatorFullName);
}
/// <summary>Verifies that acknowledge-by-name calls are forwarded to the consumer.</summary>
[Fact]
public void AcknowledgeByName_WhenInvoked_ForwardsToConsumerWithFullTuple()
{
@@ -184,6 +193,7 @@ public sealed class AlarmDispatcherTests
Assert.Equal("TestArea", consumer.LastAckByNameTuple!.Value.Group);
}
/// <summary>Verifies that consumer alarm records are mapped to proto snapshots.</summary>
[Fact]
public void SnapshotActiveAlarms_WhenConsumerHasRecords_MapsRecordsToProtos()
{
@@ -232,6 +242,7 @@ public sealed class AlarmDispatcherTests
Assert.Equal(AlarmConditionState.ActiveAcked, snapshots[1].CurrentState);
}
/// <summary>Verifies that dispose unsubscribes the handler and disposes the consumer.</summary>
[Fact]
public void Dispose_WhenSubscribed_UnsubscribesHandlerAndDisposesConsumer()
{
@@ -260,30 +271,52 @@ public sealed class AlarmDispatcherTests
private sealed class FakeAlarmConsumer : IMxAccessAlarmConsumer
{
/// <summary>Raised when an alarm transition occurs.</summary>
public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
/// <summary>Gets the last subscription reference.</summary>
public string? LastSubscription { get; private set; }
/// <summary>Gets the GUID from the last acknowledge call.</summary>
public Guid LastAckGuid { get; private set; }
/// <summary>Gets the comment from the last acknowledge call.</summary>
public string? LastAckComment { get; private set; }
/// <summary>Gets the operator name from the last acknowledge call.</summary>
public string? LastAckOperatorName { get; private set; }
/// <summary>Gets the operator node from the last acknowledge call.</summary>
public string? LastAckOperatorNode { get; private set; }
/// <summary>Gets the operator domain from the last acknowledge call.</summary>
public string? LastAckOperatorDomain { get; private set; }
/// <summary>Gets the operator full name from the last acknowledge call.</summary>
public string? LastAckOperatorFullName { get; private set; }
/// <summary>Gets or sets the return code for acknowledge operations.</summary>
public int AcknowledgeReturn { get; set; }
/// <summary>Gets or sets the result collection for snapshot operations.</summary>
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
Array.Empty<MxAlarmSnapshotRecord>();
/// <summary>Gets a value indicating whether this instance has been disposed.</summary>
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)
{
AlarmTransitionEmitted?.Invoke(this, transition);
}
/// <summary>Records the subscription reference.</summary>
/// <param name="subscription">The subscription reference.</param>
public void Subscribe(string 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(
Guid alarmGuid,
string ackComment,
@@ -301,6 +334,15 @@ public sealed class AlarmDispatcherTests
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(
string alarmName, string providerName, string groupName,
string ackComment, string ackOperatorName, string ackOperatorNode,
@@ -311,20 +353,25 @@ public sealed class AlarmDispatcherTests
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; }
/// <summary>Returns the current snapshot result collection.</summary>
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms()
{
return SnapshotResult;
}
/// <summary>Gets the count of poll operations.</summary>
public int PollCount { get; private set; }
/// <summary>Increments the poll count.</summary>
public void PollOnce()
{
PollCount++;
}
/// <inheritdoc />
public void Dispose()
{
Disposed = true;
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// </summary>
public sealed class AlarmRecordTransitionMapperTests
{
/// <summary>Verifies that full alarm reference uses provider!group.name format.</summary>
[Fact]
public void ComposeFullReference_WithProviderAndGroup_UsesProviderBangGroupDotNameFormat()
{
@@ -24,6 +25,7 @@ public sealed class AlarmRecordTransitionMapperTests
Assert.Equal("GalaxyAlarmProvider!Tank01.Level.HiHi", reference);
}
/// <summary>Verifies that reference omits provider when empty.</summary>
[Fact]
public void ComposeFullReference_WithEmptyProvider_DropsProvider()
{
@@ -32,6 +34,7 @@ public sealed class AlarmRecordTransitionMapperTests
Assert.Equal("Tank01.Level.HiHi", reference);
}
/// <summary>Verifies that reference omits group when empty.</summary>
[Fact]
public void ComposeFullReference_WithEmptyGroup_DropsGroup()
{
@@ -40,6 +43,7 @@ public sealed class AlarmRecordTransitionMapperTests
Assert.Equal("GalaxyAlarmProvider!GlobalAlarm", reference);
}
/// <summary>Verifies that reference returns alarm name when provider and group are empty.</summary>
[Fact]
public void ComposeFullReference_WithEmptyProviderAndGroup_ReturnsAlarmName()
{
@@ -48,6 +52,9 @@ public sealed class AlarmRecordTransitionMapperTests
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]
[InlineData("UNACK_ALM", MxAlarmStateKind.UnackAlm)]
[InlineData("ACK_ALM", MxAlarmStateKind.AckAlm)]
@@ -63,6 +70,10 @@ public sealed class AlarmRecordTransitionMapperTests
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]
// First sighting: new alarm in *_ALM → Raise.
[InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)]
@@ -91,6 +102,7 @@ public sealed class AlarmRecordTransitionMapperTests
Assert.Equal(expected, AlarmRecordTransitionMapper.MapTransition(previous, current));
}
/// <summary>Verifies that valid XML fields assemble into correct UTC timestamp.</summary>
[Fact]
public void ParseTransitionTimestampUtc_WithValidXmlFields_AssemblesUtc()
{
@@ -109,6 +121,7 @@ public sealed class AlarmRecordTransitionMapperTests
Assert.Equal(709, utc.Millisecond);
}
/// <summary>Verifies that unparseable inputs return DateTime.MinValue.</summary>
[Fact]
public void ParseTransitionTimestampUtc_WithUnparseableInputs_ReturnsMinValue()
{
@@ -75,17 +75,24 @@ public sealed class MxAccessComServerTests
private readonly int registerHandle;
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)
{
this.registerHandle = registerHandle;
}
/// <summary>Gets the client name passed to the most recent Register call.</summary>
public string? RegisteredClientName { get; private set; }
/// <summary>Gets or sets an exception to throw from the Register method.</summary>
public Exception? ThrowOnRegister { get; set; }
/// <summary>Gets the recorded method calls as strings.</summary>
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)
{
calls.Add($"Register:{clientName}");
@@ -98,58 +105,103 @@ public sealed class MxAccessComServerTests
return registerHandle;
}
/// <summary>Records an Unregister call.</summary>
/// <param name="serverHandle">The MXAccess server handle.</param>
public void Unregister(int 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)
{
calls.Add($"AddItem:{serverHandle}:{itemDefinition}");
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)
{
calls.Add($"AddItem2:{serverHandle}:{itemDefinition}:{itemContext}");
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)
{
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)
{
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)
{
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)
{
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)
{
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)
{
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)
{
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(
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="unAdviseException">Exception to throw from UnAdvise, 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(
int registerHandle,
int addItemHandle = 0,
@@ -227,6 +227,7 @@ public sealed class MxAccessEventMapperTests
}
/// <summary>Verifies unparseable or empty timestamp input is rejected without throwing.</summary>
/// <param name="text">Unparseable or empty timestamp string.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -474,45 +474,73 @@ public sealed class MxAccessStaSessionTests
private int pollCount;
private int? lastPollThreadId;
/// <summary>Gets a value indicating whether the alarm client is currently subscribed.</summary>
public bool IsSubscribed { get; private set; }
/// <summary>Gets the last alarm subscription name.</summary>
public string? LastSubscription { get; private set; }
/// <summary>Exception thrown by PollOnce; null to succeed.</summary>
public Exception? PollException { get; set; }
/// <summary>Gets the count of PollOnce calls.</summary>
public int PollCount
{
get { lock (gate) return pollCount; }
}
/// <summary>Gets the managed thread ID of the last PollOnce call.</summary>
public int? 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)
{
IsSubscribed = true;
LastSubscription = subscription;
}
/// <summary>Unsubscribes from alarm events.</summary>
public void Unsubscribe()
{
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,
string operatorNode, string operatorDomain, string operatorFullName)
=> 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,
string comment, string operatorUser, string operatorNode,
string operatorDomain, string operatorFullName)
=> 0;
/// <summary>Queries active alarms.</summary>
/// <param name="alarmFilterPrefix">Optional alarm name filter prefix.</param>
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix)
=> Array.Empty<ActiveAlarmSnapshot>();
/// <summary>Polls for alarm events once.</summary>
public void PollOnce()
{
lock (gate)
@@ -527,6 +555,7 @@ public sealed class MxAccessStaSessionTests
}
}
/// <inheritdoc />
public void Dispose() { }
}
}
@@ -17,6 +17,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// </summary>
public sealed class MxAccessValueCacheTests
{
/// <summary>Verifies that cache returns the last value with incrementing versions.</summary>
[Fact]
public void Set_ThenTryGet_ReturnsLastValueWithIncrementingVersion()
{
@@ -45,6 +46,7 @@ public sealed class MxAccessValueCacheTests
Assert.Equal(999, other.Value.Int32Value);
}
/// <summary>Verifies that TryGet returns false for unknown handles.</summary>
[Fact]
public void TryGet_WithUnknownHandle_ReturnsFalse()
{
@@ -53,6 +55,7 @@ public sealed class MxAccessValueCacheTests
Assert.False(cache.TryGet(serverHandle: 7, itemHandle: 21, out _));
}
/// <summary>Verifies that Remove drops entries and resets versions.</summary>
[Fact]
public void Remove_DropsEntryAndResetsVersion()
{
@@ -71,6 +74,7 @@ public sealed class MxAccessValueCacheTests
Assert.Equal(1UL, reset.Version);
}
/// <summary>Verifies that CurrentVersion returns zero for unknown handles and the latest for known ones.</summary>
[Fact]
public void CurrentVersion_ReturnsZeroForUnknown_AndLatestForKnown()
{
@@ -83,23 +87,7 @@ public sealed class MxAccessValueCacheTests
Assert.Equal(2UL, cache.CurrentVersion(7, 21));
}
/// <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>
/// <summary>Verifies that TryWaitForUpdate returns false after the deadline expires.</summary>
[Fact]
public void TryWaitForUpdate_ReturnsFalseAfterDeadline_WhenNoSetOccurs()
{
@@ -126,6 +114,7 @@ public sealed class MxAccessValueCacheTests
Assert.Equal(1, pumpCalls);
}
/// <summary>Verifies that TryWaitForUpdate returns true when the cache is updated after the baseline.</summary>
[Fact]
public async Task TryWaitForUpdate_ReturnsTrue_WhenSetFiresAfterBaselineVersion()
{
@@ -35,6 +35,7 @@ public sealed class WnWrapAlarmConsumerXmlTests
private const string EmptyXml =
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"0\"></ALARM_RECORDS>";
/// <summary>Verifies that empty XML payload returns an empty dictionary.</summary>
[Fact]
public void ParseSnapshotXml_WithEmptyPayload_ReturnsEmptyDictionary()
{
@@ -42,6 +43,7 @@ public sealed class WnWrapAlarmConsumerXmlTests
Assert.Empty(records);
}
/// <summary>Verifies that null or whitespace payload returns an empty dictionary.</summary>
[Fact]
public void ParseSnapshotXml_WithNullOrWhitespace_ReturnsEmptyDictionary()
{
@@ -49,6 +51,7 @@ public sealed class WnWrapAlarmConsumerXmlTests
Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(" "));
}
/// <summary>Verifies that single alarm XML payload decodes the record correctly.</summary>
[Fact]
public void ParseSnapshotXml_WithSingleActiveAlarm_DecodesRecord()
{
@@ -74,6 +77,7 @@ public sealed class WnWrapAlarmConsumerXmlTests
Assert.Equal(26, record.TransitionTimestampUtc.Minute);
}
/// <summary>Verifies that invalid GUIDs in XML payload are silently dropped.</summary>
[Fact]
public void ParseSnapshotXml_WithInvalidGuids_SilentlyDropsRecords()
{
@@ -83,6 +87,9 @@ public sealed class WnWrapAlarmConsumerXmlTests
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]
[InlineData("BCC4705395424D65BDAABCDEA6A32A73", "BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]
[InlineData("00000000000000000000000000000000", "00000000-0000-0000-0000-000000000000")]
@@ -92,6 +99,8 @@ public sealed class WnWrapAlarmConsumerXmlTests
Assert.Equal(new Guid(expected), guid);
}
/// <summary>Verifies that canonical dashed GUID format is accepted.</summary>
/// <param name="canonical">The canonical GUID form.</param>
[Theory]
[InlineData("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]
public void TryParseHexGuid_WithCanonicalDashedForm_Accepts(string canonical)
@@ -100,6 +109,8 @@ public sealed class WnWrapAlarmConsumerXmlTests
Assert.Equal(new Guid(canonical), guid);
}
/// <summary>Verifies that invalid GUID inputs are rejected.</summary>
/// <param name="hex">The invalid GUID hex string.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -135,11 +135,14 @@ public sealed class AlarmClientWmProbeTests : IDisposable
private IntPtr probeWindow = IntPtr.Zero;
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)
{
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")]
public void ProbeAlarmClient_OnDevRig_LogsAlarmWindowMessages()
{
@@ -772,6 +775,7 @@ public sealed class AlarmClientWmProbeTests : IDisposable
log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
}
/// <inheritdoc />
public void Dispose()
{
if (wndProcHandle.IsAllocated) wndProcHandle.Free();
@@ -36,11 +36,14 @@ public sealed class AlarmsLiveSmokeTests
private readonly Stopwatch elapsed = Stopwatch.StartNew();
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)
{
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.")]
public void Alarms_FullPipelineRoundTrip_RaisesAndAcknowledges()
{
@@ -46,11 +46,14 @@ public sealed class WnWrapConsumerProbeTests
private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>();
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)
{
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.")]
public void ProbeWnWrapConsumer_OnDevRig_LogsXmlAlarmStream()
{
@@ -41,12 +41,16 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
private AlarmDispatcher? dispatcher;
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)
: this(eventQueue, () => new WnWrapAlarmConsumer(), threadAffinityCheck: null)
{
}
/// <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(
MxAccessEventQueue eventQueue,
Func<IMxAccessAlarmConsumer> consumerFactory)
@@ -68,6 +72,9 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
/// optional: tests that already drive the handler on a single
/// thread can pass <c>null</c>.
/// </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(
MxAccessEventQueue eventQueue,
Func<IMxAccessAlarmConsumer> consumerFactory,
@@ -78,6 +85,7 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
this.threadAffinityCheck = threadAffinityCheck;
}
/// <summary>Gets a value indicating whether the handler is subscribed.</summary>
public bool IsSubscribed
{
get { lock (syncRoot) return dispatcher is not null; }
@@ -38,6 +38,10 @@ public sealed class AlarmDispatcher : IDisposable
private readonly EventHandler<MxAlarmTransitionEvent> handler;
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(
IMxAccessAlarmConsumer consumer,
MxAccessAlarmEventSink sink,
@@ -61,6 +65,7 @@ public sealed class AlarmDispatcher : IDisposable
/// transitions. The supplied subscription expression follows the
/// canonical <c>\\&lt;machine&gt;\Galaxy!&lt;area&gt;</c> format.
/// </summary>
/// <param name="subscription">The subscription expression (e.g., <c>\\HOST\Galaxy!Area</c>).</param>
public void Subscribe(string subscription)
{
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
/// status code (0 = success).
/// </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(
Guid alarmGuid,
string ackComment,
@@ -95,6 +107,15 @@ public sealed class AlarmDispatcher : IDisposable
/// Routes to the consumer's <c>AcknowledgeByName</c> path which
/// maps to <c>wwAlarmConsumerClass.AlarmAckByName</c>.
/// </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(
string alarmName,
string providerName,
@@ -213,8 +234,10 @@ public sealed class AlarmDispatcher : IDisposable
};
}
/// <summary>Gets the session ID.</summary>
public string SessionId => sessionId;
/// <inheritdoc />
public void Dispose()
{
if (disposed) return;
@@ -23,6 +23,7 @@ public static class AlarmRecordTransitionMapper
/// <see cref="MxAlarmStateKind"/>. Unknown values map to
/// <see cref="MxAlarmStateKind.Unspecified"/>.
/// </summary>
/// <param name="stateXml">The state XML string from AVEVA (e.g., UNACK_ALM, ACK_RTN).</param>
public static MxAlarmStateKind ParseStateKind(string? stateXml)
{
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>
/// </list>
/// </summary>
/// <param name="previous">The previous alarm state kind.</param>
/// <param name="current">The current alarm state kind.</param>
public static AlarmTransitionKind MapTransition(
MxAlarmStateKind previous,
MxAlarmStateKind current)
@@ -81,6 +84,9 @@ public static class AlarmRecordTransitionMapper
/// AcknowledgeAlarm RPC echoing a reference back as a GUID lookup)
/// don't need translation.
/// </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)
{
string provider = providerName ?? string.Empty;
@@ -14,12 +14,20 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
public interface IAlarmCommandHandler : IDisposable
{
/// <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);
/// <summary>Tear down the active subscription. No-op if not subscribed.</summary>
void Unsubscribe();
/// <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(
Guid alarmGuid,
string comment,
@@ -32,6 +40,14 @@ public interface IAlarmCommandHandler : IDisposable
/// Acknowledge a single alarm by (name, provider, group) — used when
/// the caller has the human-readable reference but not the GUID.
/// </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(
string alarmName,
string providerName,
@@ -46,6 +62,7 @@ public interface IAlarmCommandHandler : IDisposable
/// Snapshot the currently-active alarm set, optionally scoped to a
/// prefix matched against <c>AlarmFullReference</c>.
/// </summary>
/// <param name="alarmFilterPrefix">Optional prefix to filter alarms by.</param>
IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix);
/// <summary>

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