feat(alarms): structural degraded-status signal for truncated alarm snapshots
The truncation-cliff fix made alarm transitions truncation-safe but silent:
when GetXmlCurrentAlarms2 returns exactly maxAlmCnt records the worker
suppresses absence-implies-Clear inference and says so only in a rate-limited
stderr warning. No client and no operator could tell a complete active set
from a capped one.
Two additive proto3 booleans carry the verdict out:
- QueryActiveAlarmsReplyPayload.snapshot_truncated = 2 (worker IPC reply)
- ActiveAlarmSnapshot.from_truncated_snapshot = 16 (per record)
The per-record field is not an aesthetic choice. QueryActiveAlarms returns a
bare `stream ActiveAlarmSnapshot` with no envelope, header, or trailer, so a
per-record boolean is the only carrier that stays wire-compatible; an envelope
message would change every existing client's stream element type. The reply
payload states it too because a prefix filter can leave zero records and a
truncated fetch with nothing to report still has to say so. The flag means
"this set may be incomplete", never "this record is unreliable" — it is
independent of the subtag-fallback `degraded` field.
Detection is deliberately UNCHANGED: IsTruncatedFetch remains
`fetchedRecordCount >= maxAlarmsPerFetch`. The live probe (docs/AlarmProbeFindings.md,
ce5d8ae) could not verify whether ALARM_RECORDS/@COUNT reports the total active
count or only the records in the reply, so @COUNT is not parsed for detection;
switching to it stays blocked on probe evidence. The probe's comment
annotations in WnWrapAlarmConsumer.cs are preserved.
Reset semantics: not latched. WnWrapAlarmConsumer.FoldFetch replaces the
verdict on every poll under the same lock as the snapshot merge, so the first
sub-cap fetch clears it; GatewayAlarmMonitor.ClearCache drops it with the cache
generation it describes. A caveat that never turns off is one operators learn
to ignore.
Flow: WnWrapAlarmConsumer.LastSnapshotTruncated -> AlarmDispatcher (stamps every
record) / IAlarmCommandHandler (payload) -> MxAccessCommandExecutor reply ->
GatewayAlarmMonitor._snapshotTruncated -> IGatewayAlarmService.SnapshotTruncated
-> DashboardAlarmQueryResult -> AlarmsPage warning banner (render-side only; the
poll loop and DisposeAsync drain are untouched). The public QueryActiveAlarms
RPC forwards worker snapshots unmodified, so the per-record flag needed no
mapper change — a test pins that.
Parity: this describes OUR fetch mechanics — additive gateway metadata — not
MXAccess provider behavior. No event is synthesized and no MXAccess-observable
semantics change, so it is not a parity deviation.
Tests: worker LastSnapshotTruncated set/reset/consecutive-burst (windev-run);
gateway end-to-end truncated reply -> monitor -> public stream, with the
complete-reply control as the load-bearing assertion; AlarmsPage banner
present/absent. Docs: gateway.md alarm surface, docs/DesignDecisions.md entry.
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Grpc;
|
||||
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Alarms;
|
||||
|
||||
/// <summary>
|
||||
/// Carries the worker's truncated-fetch verdict across the gateway: worker
|
||||
/// reply payload → <see cref="GatewayAlarmMonitor"/> → the public
|
||||
/// <c>QueryActiveAlarms</c> stream.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The truncation guard itself (the worker merging rather than replacing
|
||||
/// a capped snapshot) is already covered in the worker suite. What was
|
||||
/// missing is that the guard is <em>silent</em>: a capped fetch suppresses
|
||||
/// absence-implies-Clear inference and says so only in a rate-limited
|
||||
/// stderr warning, so a consumer of the alarm surface could not tell a
|
||||
/// complete active set from a capped one. These tests pin the structural
|
||||
/// signal that replaces the guesswork.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The load-bearing assertion is the <em>false</em> one
|
||||
/// (<see cref="QueryActiveAlarms_WithCompleteWorkerReply_LeavesFlagUnset"/>).
|
||||
/// "Truncated reply sets the flag" would also pass against a field
|
||||
/// hard-wired to true; only the complete-reply case proves the flag is
|
||||
/// actually derived from the worker's verdict.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AlarmTruncationSignalTests
|
||||
{
|
||||
private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
/// A capped worker reply sets the monitor's completeness caveat and
|
||||
/// stamps every cached snapshot, so both the dashboard (which reads the
|
||||
/// service flag) and the RPC (which reads the records) can surface it.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Reconcile_WithTruncatedWorkerReply_SurfacesTheFlagOnMonitorAndSnapshots()
|
||||
{
|
||||
using GatewayMetrics metrics = new();
|
||||
StubSessionManager sessions = new()
|
||||
{
|
||||
SnapshotTruncated = true,
|
||||
Snapshots = [NewSnapshot("Galaxy!Area.Tank01.Level.HiHi", fromTruncatedSnapshot: true)],
|
||||
};
|
||||
using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics);
|
||||
|
||||
using CancellationTokenSource cts = new();
|
||||
await monitor.StartAsync(cts.Token);
|
||||
await sessions.WaitForReconcileAsync(WaitTimeout);
|
||||
await WaitUntilAsync(() => monitor.CurrentAlarms.Count == 1, WaitTimeout);
|
||||
|
||||
Assert.True(monitor.SnapshotTruncated);
|
||||
ActiveAlarmSnapshot cached = Assert.Single(monitor.CurrentAlarms);
|
||||
Assert.True(cached.FromTruncatedSnapshot);
|
||||
// Truncation is about the completeness of the SET, not the fidelity of
|
||||
// the record — the subtag-fallback flag must stay independent of it.
|
||||
Assert.False(cached.Degraded);
|
||||
|
||||
await cts.CancelAsync();
|
||||
await monitor.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The public <c>QueryActiveAlarms</c> stream carries the per-record flag
|
||||
/// through untouched. That RPC returns a bare
|
||||
/// <c>stream ActiveAlarmSnapshot</c> with no envelope message, so the
|
||||
/// per-record boolean is the only place set-level degraded status can
|
||||
/// ride — if the service ever starts re-projecting records instead of
|
||||
/// forwarding them, this is what catches the dropped field.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task QueryActiveAlarms_WithTruncatedSnapshot_StreamsTheFlagToTheClient()
|
||||
{
|
||||
FakeGatewayAlarmService alarms = new()
|
||||
{
|
||||
SnapshotTruncated = true,
|
||||
CurrentAlarms = [NewSnapshot("Galaxy!Area.Tank01.Level.HiHi", fromTruncatedSnapshot: true)],
|
||||
};
|
||||
MxAccessGatewayService service = CreateService(alarms);
|
||||
RecordingServerStreamWriter<ActiveAlarmSnapshot> sink = new();
|
||||
|
||||
await service.QueryActiveAlarms(
|
||||
new QueryActiveAlarmsRequest(),
|
||||
sink,
|
||||
new TestServerCallContext());
|
||||
|
||||
ActiveAlarmSnapshot streamed = Assert.Single(sink.Messages);
|
||||
Assert.True(streamed.FromTruncatedSnapshot);
|
||||
Assert.Equal("Galaxy!Area.Tank01.Level.HiHi", streamed.AlarmFullReference);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The control. A complete worker reply must leave both the monitor flag
|
||||
/// and the streamed records unset — otherwise every snapshot would read
|
||||
/// as possibly-incomplete and the signal would carry no information.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task QueryActiveAlarms_WithCompleteWorkerReply_LeavesFlagUnset()
|
||||
{
|
||||
using GatewayMetrics metrics = new();
|
||||
StubSessionManager sessions = new()
|
||||
{
|
||||
SnapshotTruncated = false,
|
||||
Snapshots = [NewSnapshot("Galaxy!Area.Tank01.Level.HiHi", fromTruncatedSnapshot: false)],
|
||||
};
|
||||
using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics);
|
||||
|
||||
using CancellationTokenSource cts = new();
|
||||
await monitor.StartAsync(cts.Token);
|
||||
await sessions.WaitForReconcileAsync(WaitTimeout);
|
||||
await WaitUntilAsync(() => monitor.CurrentAlarms.Count == 1, WaitTimeout);
|
||||
|
||||
Assert.False(monitor.SnapshotTruncated);
|
||||
|
||||
MxAccessGatewayService service = CreateService(new FakeGatewayAlarmService
|
||||
{
|
||||
SnapshotTruncated = monitor.SnapshotTruncated,
|
||||
CurrentAlarms = monitor.CurrentAlarms,
|
||||
});
|
||||
RecordingServerStreamWriter<ActiveAlarmSnapshot> sink = new();
|
||||
|
||||
await service.QueryActiveAlarms(
|
||||
new QueryActiveAlarmsRequest(),
|
||||
sink,
|
||||
new TestServerCallContext());
|
||||
|
||||
Assert.False(Assert.Single(sink.Messages).FromTruncatedSnapshot);
|
||||
|
||||
await cts.CancelAsync();
|
||||
await monitor.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static ActiveAlarmSnapshot NewSnapshot(string reference, bool fromTruncatedSnapshot)
|
||||
{
|
||||
return new ActiveAlarmSnapshot
|
||||
{
|
||||
AlarmFullReference = reference,
|
||||
SourceObjectReference = "Tank01.Level",
|
||||
AlarmTypeName = "HiHi",
|
||||
Category = "Area",
|
||||
Severity = 500,
|
||||
CurrentState = AlarmConditionState.Active,
|
||||
SourceProvider = AlarmProviderMode.Alarmmgr,
|
||||
FromTruncatedSnapshot = fromTruncatedSnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
private static GatewayAlarmMonitor CreateMonitor(StubSessionManager sessions, GatewayMetrics metrics)
|
||||
{
|
||||
AlarmsOptions options = new()
|
||||
{
|
||||
Enabled = true,
|
||||
SubscriptionExpression = @"\\NODE\Galaxy!Area",
|
||||
};
|
||||
return new GatewayAlarmMonitor(
|
||||
sessions,
|
||||
new StubWatchListResolver(),
|
||||
metrics,
|
||||
Microsoft.Extensions.Options.Options.Create(new GatewayOptions { Alarms = options }),
|
||||
NullLogger<GatewayAlarmMonitor>.Instance);
|
||||
}
|
||||
|
||||
private static MxAccessGatewayService CreateService(FakeGatewayAlarmService alarms)
|
||||
{
|
||||
StubSessionManager sessions = new();
|
||||
return new MxAccessGatewayService(
|
||||
sessions,
|
||||
new GatewayRequestIdentityAccessor(),
|
||||
new AllowAllConstraintEnforcer(),
|
||||
new MxAccessGrpcRequestValidator(),
|
||||
new MxAccessGrpcMapper(),
|
||||
new StubEventStreamService(),
|
||||
new GatewayMetrics(),
|
||||
NullLogger<MxAccessGatewayService>.Instance,
|
||||
alarms);
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
DateTime deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (condition())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(25);
|
||||
}
|
||||
|
||||
throw new TimeoutException("Condition was not met in time.");
|
||||
}
|
||||
|
||||
/// <summary><see cref="IAlarmWatchListResolver"/> that resolves an empty watch-list.</summary>
|
||||
private sealed class StubWatchListResolver : IAlarmWatchListResolver
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AlarmSubtagTarget>> ResolveAsync(
|
||||
AlarmsOptions options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<AlarmSubtagTarget>>([]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="ISessionManager"/> that answers the monitor's
|
||||
/// QueryActiveAlarms with a scripted reply payload — the seam this suite
|
||||
/// drives the truncation verdict through.
|
||||
/// </summary>
|
||||
private sealed class StubSessionManager : ISessionManager
|
||||
{
|
||||
private readonly Channel<WorkerEvent> _events = Channel.CreateUnbounded<WorkerEvent>();
|
||||
private readonly TaskCompletionSource _reconciled =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Gets or sets the truncation verdict the scripted reply carries.</summary>
|
||||
public bool SnapshotTruncated { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the snapshots the scripted reply carries.</summary>
|
||||
public IReadOnlyList<ActiveAlarmSnapshot> Snapshots { get; init; } = [];
|
||||
|
||||
/// <summary>Completes once the monitor has issued its first QueryActiveAlarms.</summary>
|
||||
/// <param name="timeout">The maximum time to wait.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task WaitForReconcileAsync(TimeSpan timeout) => _reconciled.Task.WaitAsync(timeout);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<GatewaySession> OpenSessionAsync(
|
||||
SessionOpenRequest request,
|
||||
string? clientIdentity,
|
||||
string? ownerKeyId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
GatewaySession session = new(
|
||||
Guid.NewGuid().ToString("N"),
|
||||
"Galaxy",
|
||||
"pipe-test",
|
||||
"nonce-test",
|
||||
clientIdentity,
|
||||
null,
|
||||
null,
|
||||
TimeSpan.FromSeconds(30),
|
||||
TimeSpan.FromSeconds(30),
|
||||
TimeSpan.FromSeconds(30),
|
||||
DateTimeOffset.UtcNow);
|
||||
session.AttachWorkerClient(new ChannelWorkerClient(session.SessionId, _events.Reader));
|
||||
session.MarkReady();
|
||||
return Task.FromResult(session);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<WorkerCommandReply> InvokeAsync(
|
||||
string sessionId,
|
||||
WorkerCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
MxCommandReply reply = new()
|
||||
{
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
};
|
||||
|
||||
if (command.Command?.Kind == MxCommandKind.QueryActiveAlarms)
|
||||
{
|
||||
QueryActiveAlarmsReplyPayload payload = new() { SnapshotTruncated = SnapshotTruncated };
|
||||
payload.Snapshots.AddRange(Snapshots.Select(snapshot => snapshot.Clone()));
|
||||
reply.QueryActiveAlarms = payload;
|
||||
_reconciled.TrySetResult();
|
||||
}
|
||||
|
||||
return Task.FromResult(new WorkerCommandReply { Reply = reply });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
|
||||
{
|
||||
session = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SessionCloseResult> CloseSessionAsync(string sessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
_events.Writer.TryComplete();
|
||||
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SessionCloseResult> KillWorkerAsync(string sessionId, string reason, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CloseExpiredLeasesAsync(DateTimeOffset now, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IEventStreamService"/> stub — QueryActiveAlarms never
|
||||
/// touches the event path, but the service constructor requires one.
|
||||
/// </summary>
|
||||
private sealed class StubEventStreamService : IEventStreamService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||
StreamEventsRequest request,
|
||||
string? callerKeyId,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Pages;
|
||||
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||
using HtmlRenderer = Microsoft.AspNetCore.Components.Web.HtmlRenderer;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Renders <see cref="AlarmsPage"/> and asserts the truncated-snapshot
|
||||
/// caveat banner appears exactly when the alarm query reports a capped
|
||||
/// provider fetch.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The absence assertion is the load-bearing one: a banner that renders
|
||||
/// unconditionally would satisfy the positive case while telling every
|
||||
/// operator, on every normal day, that the alarm list might be missing
|
||||
/// alarms. A caveat that is always on is a caveat nobody reads.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Static rendering via the framework's <see cref="HtmlRenderer"/>, as in
|
||||
/// <c>SecretsNavRenderTests</c> — the assertion is about markup the server
|
||||
/// emits, so no component-testing dependency is warranted. The page's
|
||||
/// poll loop runs its first pass inline during <c>OnInitialized</c>
|
||||
/// (the stub query completes synchronously), so the rendered markup
|
||||
/// already reflects the query result.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AlarmsPageTruncationBannerTests
|
||||
{
|
||||
private const string BannerMarker = "Alarm snapshot may be incomplete";
|
||||
|
||||
/// <summary>A capped provider fetch puts the completeness caveat on the page.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AlarmsPage_WhenSnapshotTruncated_RendersTheCaveatBanner()
|
||||
{
|
||||
string html = await RenderAsync(snapshotTruncated: true);
|
||||
|
||||
Assert.Contains(BannerMarker, html, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The proof the banner is gated. A complete fetch must render no caveat
|
||||
/// at all, while the page itself still renders — the alarm-table heading
|
||||
/// is the control that keeps this from passing over a blank page.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AlarmsPage_WhenSnapshotComplete_OmitsTheCaveatBanner()
|
||||
{
|
||||
string html = await RenderAsync(snapshotTruncated: false);
|
||||
|
||||
Assert.DoesNotContain(BannerMarker, html, StringComparison.Ordinal);
|
||||
Assert.Contains("Active Alarms", html, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> RenderAsync(bool snapshotTruncated)
|
||||
{
|
||||
ServiceCollection services = new();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IDashboardLiveDataService>(
|
||||
new StubLiveDataService(snapshotTruncated));
|
||||
services.AddSingleton<IGatewayAlarmService>(
|
||||
new FakeGatewayAlarmService { SnapshotTruncated = snapshotTruncated });
|
||||
services.AddSingleton<IOptions<GatewayOptions>>(
|
||||
Options.Create(new GatewayOptions { Alarms = new AlarmsOptions { Enabled = true } }));
|
||||
|
||||
await using ServiceProvider provider = services.BuildServiceProvider();
|
||||
await using HtmlRenderer renderer = new(
|
||||
provider,
|
||||
provider.GetRequiredService<ILoggerFactory>());
|
||||
|
||||
return await renderer.Dispatcher.InvokeAsync(async () =>
|
||||
{
|
||||
HtmlRootComponent output = await renderer.RenderComponentAsync<AlarmsPage>();
|
||||
return output.ToHtmlString();
|
||||
});
|
||||
}
|
||||
|
||||
// Answers the page's 3-second poll synchronously, so the first pass completes
|
||||
// inline inside OnInitialized and the rendered markup reflects it.
|
||||
private sealed class StubLiveDataService(bool snapshotTruncated) : IDashboardLiveDataService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<DashboardLiveReadResult> ReadAsync(
|
||||
IReadOnlyCollection<string> tagAddresses,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(DashboardLiveReadResult.Empty);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DashboardAlarmQueryResult> QueryAlarmsAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new DashboardAlarmQueryResult(
|
||||
Alarms: [],
|
||||
Error: null,
|
||||
WorkerProcessId: null,
|
||||
SnapshotTruncated: snapshotTruncated));
|
||||
}
|
||||
}
|
||||
@@ -291,6 +291,7 @@ public sealed class DashboardAuthenticatorTests
|
||||
return new DashboardAuthenticator(
|
||||
ldapAuthService,
|
||||
roleMapper,
|
||||
Options.Create(options),
|
||||
NullLogger<DashboardAuthenticator>.Instance);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="DashboardSessionAcl"/>, the single decision both dashboard subscribe seams
|
||||
/// consult (SEC-25 / TST-15).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every branch is asserted in its denying direction as well as its allowing one, because the
|
||||
/// pre-ACL behaviour was "allow everything": an assertion that a permitted caller is permitted
|
||||
/// cannot distinguish a working gate from no gate at all.
|
||||
/// </remarks>
|
||||
public sealed class DashboardSessionAclTests
|
||||
{
|
||||
private const string TaggedSessionId = "session-tagged";
|
||||
private const string UntaggedSessionId = "session-untagged";
|
||||
|
||||
/// <summary>An Administrator bypasses the tag check entirely, including for a tag they hold none of.</summary>
|
||||
[Fact]
|
||||
public void CanViewSession_Administrator_BypassesTagCheck()
|
||||
{
|
||||
DashboardSessionAcl acl = CreateAcl();
|
||||
|
||||
Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), TaggedSessionId));
|
||||
Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), UntaggedSessionId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The admin bypass is what keeps <c>Dashboard:DisableLogin</c> auto-login (which stamps both
|
||||
/// roles and no tags) working exactly as before this change.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CanViewSession_AutoLoginStyleBothRolesNoTags_Allowed()
|
||||
{
|
||||
DashboardSessionAcl acl = CreateAcl();
|
||||
|
||||
Assert.True(acl.CanViewSession(
|
||||
Principal(roles: [DashboardRoles.Admin, DashboardRoles.Viewer]),
|
||||
TaggedSessionId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An unknown session id is denied even for a caller holding every configured tag: no
|
||||
/// subscription is created for a session the registry does not have.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CanViewSession_UnknownSession_Denied()
|
||||
{
|
||||
DashboardSessionAcl acl = CreateAcl();
|
||||
|
||||
Assert.False(acl.CanViewSession(
|
||||
Principal(roles: [DashboardRoles.Viewer], tags: ["team-a", "team-b"]),
|
||||
"session-does-not-exist"));
|
||||
}
|
||||
|
||||
/// <summary>A blank session id is denied without consulting anything.</summary>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void CanViewSession_BlankSessionId_Denied(string sessionId)
|
||||
{
|
||||
DashboardSessionAcl acl = CreateAcl();
|
||||
|
||||
Assert.False(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), sessionId));
|
||||
}
|
||||
|
||||
/// <summary>A null principal denies — the fail-closed reading of an unauthenticated hub context.</summary>
|
||||
[Fact]
|
||||
public void CanViewSession_NullPrincipal_Denied()
|
||||
{
|
||||
DashboardSessionAcl acl = CreateAcl();
|
||||
|
||||
Assert.False(acl.CanViewSession(null, UntaggedSessionId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Untagged sessions follow <c>Dashboard:UntaggedSessionVisibility</c>: hidden from Viewers
|
||||
/// under the shipped <see cref="UntaggedSessionVisibility.AdminOnly"/> default, visible under
|
||||
/// the opt-in <see cref="UntaggedSessionVisibility.AllViewers"/>.
|
||||
/// </summary>
|
||||
/// <param name="visibility">The configured untagged-session visibility.</param>
|
||||
/// <param name="expected">Whether a tagless Viewer may observe the untagged session.</param>
|
||||
[Theory]
|
||||
[InlineData(UntaggedSessionVisibility.AdminOnly, false)]
|
||||
[InlineData(UntaggedSessionVisibility.AllViewers, true)]
|
||||
public void CanViewSession_UntaggedSession_FollowsConfiguredVisibility(
|
||||
UntaggedSessionVisibility visibility,
|
||||
bool expected)
|
||||
{
|
||||
DashboardSessionAcl acl = CreateAcl(visibility);
|
||||
|
||||
Assert.Equal(
|
||||
expected,
|
||||
acl.CanViewSession(Principal(roles: [DashboardRoles.Viewer]), UntaggedSessionId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Viewer whose grant intersects the session's tags is allowed; the comparison is
|
||||
/// ordinal-ignore-case, matching the session's tag set and the config map.
|
||||
/// </summary>
|
||||
/// <param name="grantedTag">The single tag the Viewer holds.</param>
|
||||
[Theory]
|
||||
[InlineData("team-a")]
|
||||
[InlineData("TEAM-A")]
|
||||
public void CanViewSession_ViewerGrantIntersectsSessionTags_Allowed(string grantedTag)
|
||||
{
|
||||
DashboardSessionAcl acl = CreateAcl();
|
||||
|
||||
Assert.True(acl.CanViewSession(
|
||||
Principal(roles: [DashboardRoles.Viewer], tags: [grantedTag]),
|
||||
TaggedSessionId));
|
||||
}
|
||||
|
||||
/// <summary>A Viewer holding only another tenant's tag is denied — the load-bearing negative.</summary>
|
||||
[Fact]
|
||||
public void CanViewSession_ViewerGrantDisjointFromSessionTags_Denied()
|
||||
{
|
||||
DashboardSessionAcl acl = CreateAcl();
|
||||
|
||||
Assert.False(acl.CanViewSession(
|
||||
Principal(roles: [DashboardRoles.Viewer], tags: ["team-b"]),
|
||||
TaggedSessionId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A principal carrying no tag claims — the anonymous-localhost / empty-grant Viewer of
|
||||
/// SEC-02 — sees a tagged session never, and an untagged one only when the operator opted
|
||||
/// into <see cref="UntaggedSessionVisibility.AllViewers"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CanViewSession_NoTagClaims_IsEmptyGrantViewer()
|
||||
{
|
||||
ClaimsPrincipal anonymous = new(new ClaimsIdentity());
|
||||
|
||||
Assert.False(CreateAcl().CanViewSession(anonymous, TaggedSessionId));
|
||||
Assert.False(CreateAcl(UntaggedSessionVisibility.AdminOnly).CanViewSession(anonymous, UntaggedSessionId));
|
||||
Assert.True(CreateAcl(UntaggedSessionVisibility.AllViewers).CanViewSession(anonymous, UntaggedSessionId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An unauthenticated principal that nonetheless carries an Administrator role claim does not
|
||||
/// get the bypass: the bypass requires a real authenticated identity, as elsewhere in the
|
||||
/// dashboard (<c>DashboardSessionAdminService.CanManage</c>).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CanViewSession_UnauthenticatedAdminRoleClaim_DoesNotBypass()
|
||||
{
|
||||
// No authentication type => IsAuthenticated is false.
|
||||
ClaimsPrincipal principal = new(new ClaimsIdentity(
|
||||
[new Claim(ClaimTypes.Role, DashboardRoles.Admin)],
|
||||
authenticationType: null,
|
||||
nameType: ClaimTypes.Name,
|
||||
roleType: ClaimTypes.Role));
|
||||
|
||||
Assert.False(CreateAcl().CanViewSession(principal, TaggedSessionId));
|
||||
}
|
||||
|
||||
private static DashboardSessionAcl CreateAcl(
|
||||
UntaggedSessionVisibility visibility = UntaggedSessionVisibility.AdminOnly)
|
||||
{
|
||||
GatewayOptions options = new()
|
||||
{
|
||||
Dashboard = new DashboardOptions { UntaggedSessionVisibility = visibility },
|
||||
};
|
||||
|
||||
return new DashboardSessionAcl(
|
||||
new TwoSessionManager(
|
||||
CreateSession(TaggedSessionId, ["team-a"]),
|
||||
CreateSession(UntaggedSessionId, tags: null)),
|
||||
Options.Create(options));
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal Principal(string[] roles, string[]? tags = null)
|
||||
{
|
||||
List<Claim> claims = [new Claim(ClaimTypes.Name, "viewer-user")];
|
||||
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
||||
claims.AddRange((tags ?? []).Select(tag => new Claim(
|
||||
DashboardAuthenticationDefaults.DashboardTagClaimType,
|
||||
tag)));
|
||||
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(
|
||||
claims,
|
||||
authenticationType: "test",
|
||||
nameType: ClaimTypes.Name,
|
||||
roleType: ClaimTypes.Role));
|
||||
}
|
||||
|
||||
private static GatewaySession CreateSession(string sessionId, string[]? tags)
|
||||
{
|
||||
return new GatewaySession(
|
||||
sessionId: sessionId,
|
||||
backendName: "backend",
|
||||
pipeName: $"pipe-{sessionId}",
|
||||
nonce: "nonce",
|
||||
clientIdentity: "client",
|
||||
ownerKeyId: "key-1",
|
||||
clientSessionName: "client-session",
|
||||
clientCorrelationId: "correlation",
|
||||
commandTimeout: TimeSpan.FromSeconds(5),
|
||||
startupTimeout: TimeSpan.FromSeconds(5),
|
||||
shutdownTimeout: TimeSpan.FromSeconds(5),
|
||||
leaseDuration: TimeSpan.FromMinutes(30),
|
||||
openedAt: DateTimeOffset.UnixEpoch,
|
||||
ownerDashboardTags: tags);
|
||||
}
|
||||
|
||||
/// <summary>Registry double serving exactly the two sessions the ACL cases need.</summary>
|
||||
private sealed class TwoSessionManager(GatewaySession tagged, GatewaySession untagged) : ISessionManager
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<GatewaySession> OpenSessionAsync(
|
||||
SessionOpenRequest request,
|
||||
string? clientIdentity,
|
||||
string? ownerKeyId,
|
||||
CancellationToken cancellationToken) => Task.FromResult(tagged);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
|
||||
{
|
||||
session = sessionId switch
|
||||
{
|
||||
TaggedSessionId => tagged,
|
||||
UntaggedSessionId => untagged,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return session is not null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<WorkerCommandReply> InvokeAsync(
|
||||
string sessionId,
|
||||
WorkerCommand command,
|
||||
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SessionCloseResult> CloseSessionAsync(
|
||||
string sessionId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SessionCloseResult> KillWorkerAsync(
|
||||
string sessionId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CloseExpiredLeasesAsync(
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken) => Task.FromResult(0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the ACL gate on <see cref="EventsHub.SubscribeSession"/> (SEC-25 / TST-15).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The denial assertions are the load-bearing ones — before the gate existed every caller was
|
||||
/// joined, so "an allowed caller is joined" is indistinguishable from no gate. They assert the
|
||||
/// absence of BOTH effects of a join: the SignalR group membership and the viewer registration
|
||||
/// that turns the broadcaster's mirror on for the session. Leaving either behind would keep the
|
||||
/// event clone running for a caller who may not observe it.
|
||||
/// </remarks>
|
||||
public sealed class EventsHubTests
|
||||
{
|
||||
private const string SessionId = "session-1";
|
||||
private const string ConnectionId = "connection-1";
|
||||
|
||||
private static readonly ClaimsPrincipal TestPrincipal = new(new ClaimsIdentity(
|
||||
[new Claim(ClaimTypes.Name, "viewer-user")],
|
||||
authenticationType: "test"));
|
||||
|
||||
/// <summary>An allowed caller joins the group and registers as a viewer.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscribeSession_WhenAclAllows_JoinsGroupAndRegistersViewer()
|
||||
{
|
||||
EventsHubViewerRegistry registry = new();
|
||||
RecordingGroupManager groups = new();
|
||||
EventsHub hub = CreateHub(registry, groups, allow: true);
|
||||
|
||||
await hub.SubscribeSession(SessionId);
|
||||
|
||||
Assert.Equal([(ConnectionId, EventsHub.GroupName(SessionId))], groups.Added);
|
||||
Assert.True(registry.HasViewers(SessionId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A denied caller gets a <see cref="HubException"/>, is not joined, and is not registered.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscribeSession_WhenAclDenies_ThrowsAndDoesNotJoin()
|
||||
{
|
||||
EventsHubViewerRegistry registry = new();
|
||||
RecordingGroupManager groups = new();
|
||||
EventsHub hub = CreateHub(registry, groups, allow: false);
|
||||
|
||||
HubException error = await Assert.ThrowsAsync<HubException>(() => hub.SubscribeSession(SessionId));
|
||||
|
||||
Assert.Equal("Not authorized for this session.", error.Message);
|
||||
Assert.Empty(groups.Added);
|
||||
Assert.False(registry.HasViewers(SessionId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A blank session id is still a no-op rather than a denial, so a client that sends one is not
|
||||
/// told it lacks authorization for a session it never named.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The blank session id supplied by the caller.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task SubscribeSession_BlankSessionId_IsNoOp(string sessionId)
|
||||
{
|
||||
EventsHubViewerRegistry registry = new();
|
||||
RecordingGroupManager groups = new();
|
||||
EventsHub hub = CreateHub(registry, groups, allow: false);
|
||||
|
||||
await hub.SubscribeSession(sessionId);
|
||||
|
||||
Assert.Empty(groups.Added);
|
||||
}
|
||||
|
||||
/// <summary>The ACL is asked about the session the caller named, with the caller's own principal.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscribeSession_AsksAclAboutTheRequestedSession()
|
||||
{
|
||||
StubSessionAcl acl = new(allow: true);
|
||||
EventsHub hub = new(new EventsHubViewerRegistry(), acl)
|
||||
{
|
||||
Groups = new RecordingGroupManager(),
|
||||
Context = new StubHubCallerContext(ConnectionId, TestPrincipal),
|
||||
};
|
||||
|
||||
await hub.SubscribeSession(SessionId);
|
||||
|
||||
Assert.Equal(SessionId, acl.LastSessionId);
|
||||
Assert.Same(TestPrincipal, acl.LastPrincipal);
|
||||
}
|
||||
|
||||
private static EventsHub CreateHub(
|
||||
EventsHubViewerRegistry registry,
|
||||
RecordingGroupManager groups,
|
||||
bool allow)
|
||||
{
|
||||
return new EventsHub(registry, new StubSessionAcl(allow))
|
||||
{
|
||||
Groups = groups,
|
||||
Context = new StubHubCallerContext(ConnectionId, TestPrincipal),
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubSessionAcl(bool allow) : IDashboardSessionAcl
|
||||
{
|
||||
/// <summary>Gets the principal passed to the most recent call.</summary>
|
||||
public ClaimsPrincipal? LastPrincipal { get; private set; }
|
||||
|
||||
/// <summary>Gets the session id passed to the most recent call.</summary>
|
||||
public string? LastSessionId { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanViewSession(ClaimsPrincipal? principal, string sessionId)
|
||||
{
|
||||
LastPrincipal = principal;
|
||||
LastSessionId = sessionId;
|
||||
|
||||
return allow;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingGroupManager : IGroupManager
|
||||
{
|
||||
/// <summary>Gets the (connection id, group name) pairs added, in order.</summary>
|
||||
public List<(string ConnectionId, string GroupName)> Added { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Added.Add((connectionId, groupName));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RemoveFromGroupAsync(
|
||||
string connectionId,
|
||||
string groupName,
|
||||
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class StubHubCallerContext(string connectionId, ClaimsPrincipal user) : HubCallerContext
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string ConnectionId { get; } = connectionId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string? UserIdentifier => User?.Identity?.Name;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ClaimsPrincipal? User { get; } = user;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IFeatureCollection Features { get; } = new FeatureCollection();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override CancellationToken ConnectionAborted => CancellationToken.None;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Abort()
|
||||
{
|
||||
// Nothing to abort in a unit-constructed context.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
@@ -19,7 +21,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_TokenWithNullNameAndNullNameIdentifier_ReturnsNull()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
|
||||
// Issue from a principal with NO Name claim and NO NameIdentifier
|
||||
// claim. The Issue method's payload will then carry
|
||||
@@ -43,7 +45,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_TokenWithName_ReturnsAuthenticatedPrincipal()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
|
||||
ClaimsIdentity identity = new(
|
||||
[
|
||||
@@ -72,7 +74,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_TokenWithOnlyNameIdentifier_ReturnsPrincipal()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
|
||||
ClaimsIdentity identity = new(
|
||||
[
|
||||
@@ -93,7 +95,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_NullToken_ReturnsNull()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
|
||||
Assert.Null(service.Validate(null));
|
||||
}
|
||||
@@ -102,7 +104,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_EmptyToken_ReturnsNull()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
|
||||
Assert.Null(service.Validate(string.Empty));
|
||||
}
|
||||
@@ -111,7 +113,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_GarbageToken_ReturnsNull()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
|
||||
Assert.Null(service.Validate("this-is-not-a-protected-payload"));
|
||||
}
|
||||
@@ -123,7 +125,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void IssueThenValidate_FreshToken_RoundTripsIdentityAndRoles()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
ClaimsIdentity identity = new(
|
||||
[
|
||||
new Claim(ClaimTypes.Name, "bob"),
|
||||
@@ -163,7 +165,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_ExpiredToken_ReturnsNull()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
ClaimsIdentity identity = new(
|
||||
[new Claim(ClaimTypes.Name, "carol")],
|
||||
authenticationType: "test");
|
||||
@@ -174,4 +176,85 @@ public sealed class HubTokenServiceTests
|
||||
|
||||
Assert.Null(service.Validate(expiredToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The dashboard visibility grant (SEC-25) survives the mint/validate round-trip: tags are
|
||||
/// resolved from the caller's LDAP-group claims through <c>Dashboard:GroupToTag</c> at
|
||||
/// <see cref="HubTokenService.Issue(ClaimsPrincipal)"/> and rehydrated as
|
||||
/// <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/> claims on the principal
|
||||
/// <see cref="HubTokenService.Validate"/> reconstructs — which is the principal
|
||||
/// <c>IDashboardSessionAcl</c> reads on the hub path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IssueThenValidate_ResolvesAndRoundTripsGrantedTags()
|
||||
{
|
||||
HubTokenService service = CreateService(new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["GwViewer"] = ["team-a"],
|
||||
["TeamBViewers"] = ["team-b"],
|
||||
});
|
||||
|
||||
ClaimsIdentity identity = new(
|
||||
[
|
||||
new Claim(ClaimTypes.Name, "dana"),
|
||||
new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
|
||||
new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwViewer"),
|
||||
new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "TeamBViewers"),
|
||||
],
|
||||
authenticationType: "test",
|
||||
nameType: ClaimTypes.Name,
|
||||
roleType: ClaimTypes.Role);
|
||||
|
||||
ClaimsPrincipal? result = service.Validate(service.Issue(new ClaimsPrincipal(identity)));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(
|
||||
["team-a", "team-b"],
|
||||
result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType)
|
||||
.Select(c => c.Value)
|
||||
.Order(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A caller whose groups map to nothing mints a token with no tags, and validating it yields a
|
||||
/// principal carrying no tag claims — the empty grant the ACL denies tagged sessions on. This
|
||||
/// is also the shape of every token minted before the tag field existed (the payload field
|
||||
/// deserializes to null), so the fail-closed direction is covered for both.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IssueThenValidate_WithNoMatchingGroups_ProducesEmptyGrant()
|
||||
{
|
||||
HubTokenService service = CreateService(new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["SomeOtherGroup"] = ["team-a"],
|
||||
});
|
||||
|
||||
ClaimsIdentity identity = new(
|
||||
[
|
||||
new Claim(ClaimTypes.Name, "erin"),
|
||||
new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
|
||||
new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwViewer"),
|
||||
],
|
||||
authenticationType: "test",
|
||||
nameType: ClaimTypes.Name,
|
||||
roleType: ClaimTypes.Role);
|
||||
|
||||
ClaimsPrincipal? result = service.Validate(service.Issue(new ClaimsPrincipal(identity)));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType));
|
||||
}
|
||||
|
||||
private static HubTokenService CreateService(Dictionary<string, string[]>? groupToTag = null)
|
||||
{
|
||||
GatewayOptions options = new()
|
||||
{
|
||||
Dashboard = new DashboardOptions
|
||||
{
|
||||
GroupToTag = groupToTag ?? new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase),
|
||||
},
|
||||
};
|
||||
|
||||
return new HubTokenService(new EphemeralDataProtectionProvider(), Options.Create(options));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Pages;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the ACL gate on the session-details page's in-process subscribe seam (SEC-25 / TST-15).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The 2026-08 in-process feed refactor gave the dashboard a second way to subscribe to a
|
||||
/// session's events — <see cref="IDashboardSessionEventSubscriber"/>, used by this page — so
|
||||
/// gating the hub join alone would leave the page as an ungated path to the same feed. The
|
||||
/// denial assertion here is the one that proves the second seam is closed: it asserts that
|
||||
/// <see cref="IDashboardSessionEventSubscriber.Subscribe"/> is never called, not merely that the
|
||||
/// panel renders differently.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Rendered through the framework's static <see cref="HtmlRenderer"/>, the same idiom
|
||||
/// <c>SecretsNavRenderTests</c> uses — no component-testing package, because the assertions are
|
||||
/// about the emitted markup and the calls the lifecycle makes, not about interactivity.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SessionDetailsPageEventAclTests
|
||||
{
|
||||
private const string SessionId = "session-1";
|
||||
// Matched without the trailing possessive so the assertion does not depend on how the
|
||||
// renderer escapes the apostrophe.
|
||||
private const string DeniedMessage = "Not authorized for this session";
|
||||
private const string WaitingMarker = "Waiting for events.";
|
||||
|
||||
/// <summary>A denied caller gets the message and no subscription is opened.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Page_WhenAclDenies_RendersMessageAndDoesNotSubscribe()
|
||||
{
|
||||
RecordingEventSubscriber subscriber = new();
|
||||
|
||||
string html = await RenderAsync(subscriber, allow: false);
|
||||
|
||||
Assert.Contains(DeniedMessage, html, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(WaitingMarker, html, StringComparison.Ordinal);
|
||||
Assert.Empty(subscriber.SubscribedSessionIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The control for the denial above: an allowed caller subscribes and sees the ordinary
|
||||
/// waiting state. Without this, a page that failed to render its events panel at all would
|
||||
/// satisfy the "no subscription" assertion and the suite would report a working gate over a
|
||||
/// broken panel.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Page_WhenAclAllows_SubscribesAndRendersWaitingState()
|
||||
{
|
||||
RecordingEventSubscriber subscriber = new();
|
||||
|
||||
string html = await RenderAsync(subscriber, allow: true);
|
||||
|
||||
Assert.Equal([SessionId], subscriber.SubscribedSessionIds);
|
||||
Assert.Contains(WaitingMarker, html, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(DeniedMessage, html, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> RenderAsync(RecordingEventSubscriber subscriber, bool allow)
|
||||
{
|
||||
ServiceCollection services = new();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IDashboardSnapshotService>(new StubSnapshotService());
|
||||
services.AddSingleton<IDashboardSnapshotFeed>(new IdleSnapshotFeed());
|
||||
services.AddSingleton<IDashboardSessionAdminService>(new NonManagingSessionAdminService());
|
||||
services.AddSingleton<IDashboardSessionEventSubscriber>(subscriber);
|
||||
services.AddSingleton<IDashboardSessionAcl>(new StubSessionAcl(allow));
|
||||
services.AddSingleton<AuthenticationStateProvider>(new StubAuthenticationStateProvider());
|
||||
|
||||
await using ServiceProvider provider = services.BuildServiceProvider();
|
||||
await using HtmlRenderer renderer = new(provider, provider.GetRequiredService<ILoggerFactory>());
|
||||
|
||||
return await renderer.Dispatcher.InvokeAsync(async () =>
|
||||
{
|
||||
HtmlRootComponent output = await renderer.RenderComponentAsync<SessionDetailsPage>(
|
||||
ParameterView.FromDictionary(new Dictionary<string, object?>
|
||||
{
|
||||
[nameof(SessionDetailsPage.SessionId)] = SessionId,
|
||||
}));
|
||||
|
||||
return output.ToHtmlString();
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class StubSessionAcl(bool allow) : IDashboardSessionAcl
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool CanViewSession(ClaimsPrincipal? principal, string sessionId) => allow;
|
||||
}
|
||||
|
||||
private sealed class RecordingEventSubscriber : IDashboardSessionEventSubscriber
|
||||
{
|
||||
/// <summary>Gets the session ids <see cref="Subscribe"/> was called with, in order.</summary>
|
||||
public List<string> SubscribedSessionIds { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDashboardEventSubscription Subscribe(string sessionId)
|
||||
{
|
||||
SubscribedSessionIds.Add(sessionId);
|
||||
|
||||
return new IdleSubscription();
|
||||
}
|
||||
|
||||
// A subscription whose channel never yields and never completes, so the page's pump parks
|
||||
// exactly as it would against a quiet session.
|
||||
private sealed class IdleSubscription : IDashboardEventSubscription
|
||||
{
|
||||
private readonly Channel<MxEvent> _channel = Channel.CreateUnbounded<MxEvent>();
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChannelReader<MxEvent> Reader => _channel.Reader;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => _channel.Writer.TryComplete();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubSnapshotService : IDashboardSnapshotService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public DashboardSnapshot GetSnapshot() => new(
|
||||
GeneratedAt: DateTimeOffset.UnixEpoch,
|
||||
GatewayStartedAt: DateTimeOffset.UnixEpoch,
|
||||
GatewayUptime: TimeSpan.Zero,
|
||||
GatewayStatus: "Healthy",
|
||||
GatewayVersion: "test",
|
||||
Sessions:
|
||||
[
|
||||
new DashboardSessionSummary(
|
||||
SessionId: SessionId,
|
||||
BackendName: "backend",
|
||||
State: SessionState.Ready,
|
||||
ClientIdentity: "client",
|
||||
ClientSessionName: "client-session",
|
||||
ClientCorrelationId: "correlation",
|
||||
OpenedAt: DateTimeOffset.UnixEpoch,
|
||||
LastClientActivityAt: DateTimeOffset.UnixEpoch,
|
||||
LeaseExpiresAt: null,
|
||||
WorkerProcessId: null,
|
||||
WorkerState: null,
|
||||
LastWorkerHeartbeatAt: null,
|
||||
EventsReceived: 0,
|
||||
LastFault: null),
|
||||
],
|
||||
Workers: [],
|
||||
Metrics: [],
|
||||
Faults: [],
|
||||
ApiKeys: [],
|
||||
Configuration: null!,
|
||||
Galaxy: null!);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken) =>
|
||||
new IdleSnapshotFeed().WatchAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// Parks until the page is disposed, so the base page's watch loop neither spins nor pushes a
|
||||
// second snapshot mid-assertion.
|
||||
private sealed class IdleSnapshotFeed : IDashboardSnapshotFeed
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<DashboardSnapshot> WatchAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NonManagingSessionAdminService : IDashboardSessionAdminService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool CanManage(ClaimsPrincipal user) => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DashboardSessionAdminResult> CloseSessionAsync(
|
||||
ClaimsPrincipal user,
|
||||
string sessionId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(DashboardSessionAdminResult.Fail("not supported"));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DashboardSessionAdminResult> KillWorkerAsync(
|
||||
ClaimsPrincipal user,
|
||||
string sessionId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(DashboardSessionAdminResult.Fail("not supported"));
|
||||
}
|
||||
|
||||
private sealed class StubAuthenticationStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
|
||||
Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(
|
||||
[new Claim(ClaimTypes.Name, "viewer-user"), new Claim(ClaimTypes.Role, DashboardRoles.Viewer)],
|
||||
authenticationType: "test",
|
||||
nameType: ClaimTypes.Name,
|
||||
roleType: ClaimTypes.Role))));
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@ public sealed class FakeGatewayAlarmService : IGatewayAlarmService
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ActiveAlarmSnapshot> CurrentAlarms { get; set; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool SnapshotTruncated { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
|
||||
string? alarmFilterPrefix,
|
||||
|
||||
Reference in New Issue
Block a user