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; /// /// Covers the ACL gate on . /// /// /// 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. /// 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")); /// An allowed caller joins the group and registers as a viewer. /// A task that represents the asynchronous operation. [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)); } /// /// A denied caller gets a , is not joined, and is not registered. /// /// A task that represents the asynchronous operation. [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(() => hub.SubscribeSession(SessionId)); Assert.Equal("Not authorized for this session.", error.Message); Assert.Empty(groups.Added); Assert.False(registry.HasViewers(SessionId)); } /// /// 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. /// /// The blank session id supplied by the caller. /// A task that represents the asynchronous operation. [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); } /// The ACL is asked about the session the caller named, with the caller's own principal. /// A task that represents the asynchronous operation. [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 { /// Gets the principal passed to the most recent call. public ClaimsPrincipal? LastPrincipal { get; private set; } /// Gets the session id passed to the most recent call. public string? LastSessionId { get; private set; } /// public bool CanViewSession(ClaimsPrincipal? principal, string sessionId) { LastPrincipal = principal; LastSessionId = sessionId; return allow; } } private sealed class RecordingGroupManager : IGroupManager { /// Gets the (connection id, group name) pairs added, in order. public List<(string ConnectionId, string GroupName)> Added { get; } = []; /// Records the group membership and completes synchronously. /// The connection being added. /// The group the connection joins. /// Token to observe for cancellation. /// A completed task. public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) { Added.Add((connectionId, groupName)); return Task.CompletedTask; } /// Ignores the removal and completes synchronously. /// The connection being removed. /// The group the connection leaves. /// Token to observe for cancellation. /// A completed task. public Task RemoveFromGroupAsync( string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask; } private sealed class StubHubCallerContext(string connectionId, ClaimsPrincipal user) : HubCallerContext { /// public override string ConnectionId { get; } = connectionId; /// public override string? UserIdentifier => User?.Identity?.Name; /// public override ClaimsPrincipal? User { get; } = user; /// public override IDictionary Items { get; } = new Dictionary(); /// public override IFeatureCollection Features { get; } = new FeatureCollection(); /// public override CancellationToken ConnectionAborted => CancellationToken.None; /// public override void Abort() { // Nothing to abort in a unit-constructed context. } } }