f40409a688
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
184 lines
7.1 KiB
C#
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.
|
|
}
|
|
}
|
|
}
|