Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/EventsHubTests.cs
T
Joseph Doherty f40409a688
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m16s
ci / java (push) Successful in 2m5s
ci / portable (push) Failing after 5m9s
docs: strip task-tracking IDs from code comments and fill missing XML docs
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
2026-10-07 15:34:32 -04:00

184 lines
7.1 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"/> .
/// </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; } = [];
/// <summary>Records the group membership and completes synchronously.</summary>
/// <param name="connectionId">The connection being added.</param>
/// <param name="groupName">The group the connection joins.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A completed task.</returns>
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
{
Added.Add((connectionId, groupName));
return Task.CompletedTask;
}
/// <summary>Ignores the removal and completes synchronously.</summary>
/// <param name="connectionId">The connection being removed.</param>
/// <param name="groupName">The group the connection leaves.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A completed task.</returns>
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.
}
}
}