693a78db7d
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.
176 lines
6.5 KiB
C#
176 lines
6.5 KiB
C#
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.
|
|
}
|
|
}
|
|
}
|