using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging.Abstractions; 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.Hubs; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; /// /// Verifies that honours /// MxGateway:Dashboard:ShowTagValues (SEC-25): tag values are stripped /// from the mirrored copy when the flag is off, present when it is on, and the /// shared source event is never mutated. Also verifies the viewer gate — the /// mirror does no work at all for a session nobody is watching. /// public sealed class DashboardEventBroadcasterTests { /// An unwatched session costs neither a redaction clone nor a send. [Fact] public void Publish_WithNoRegisteredViewers_DoesNotCloneOrSend() { CapturingHubContext hubContext = new(); EventsHubViewerRegistry viewers = new(); DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers); MxEvent source = BuildEventWithValue(); broadcaster.Publish("session-1", source); Assert.Equal(0, hubContext.SendCount); Assert.Null(hubContext.LastArgument); } /// A viewer on a different session does not open the gate for this one. [Fact] public void Publish_WithViewersOnAnotherSessionOnly_DoesNotSend() { CapturingHubContext hubContext = new(); EventsHubViewerRegistry viewers = new(); viewers.AddViewer("conn-1", "session-2"); DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers); broadcaster.Publish("session-1", BuildEventWithValue()); Assert.Equal(0, hubContext.SendCount); } /// Once the last viewer leaves, the mirror stops sending again. [Fact] public void Publish_AfterLastViewerLeaves_StopsSending() { CapturingHubContext hubContext = new(); EventsHubViewerRegistry viewers = new(); viewers.AddViewer("conn-1", "session-1"); DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers); broadcaster.Publish("session-1", BuildEventWithValue()); Assert.Equal(1, hubContext.SendCount); viewers.RemoveViewer("conn-1", "session-1"); broadcaster.Publish("session-1", BuildEventWithValue()); Assert.Equal(1, hubContext.SendCount); } /// Values are stripped from the mirror when ShowTagValues is off; metadata survives. [Fact] public void Publish_WhenShowTagValuesFalse_RedactsValuesButKeepsMetadata() { CapturingHubContext hubContext = new(); DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, WatchedSession1()); MxEvent source = BuildEventWithValue(); broadcaster.Publish("session-1", source); MxEvent sent = Assert.IsType(hubContext.LastArgument); Assert.Null(sent.Value); Assert.Null(sent.OnAlarmTransition.CurrentValue); Assert.Null(sent.OnAlarmTransition.LimitValue); // Metadata unrelated to the value survives redaction. Assert.Equal("session-1", sent.SessionId); Assert.Equal(7, sent.ServerHandle); Assert.Equal(11, sent.ItemHandle); Assert.Equal(192, sent.Quality); Assert.Equal("Tank01.Level.HiHi", sent.OnAlarmTransition.AlarmFullReference); } /// Redaction applies to a clone, so the shared source event keeps its values. [Fact] public void Publish_WhenShowTagValuesFalse_DoesNotMutateSourceEvent() { CapturingHubContext hubContext = new(); DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, WatchedSession1()); MxEvent source = BuildEventWithValue(); broadcaster.Publish("session-1", source); // The redaction must apply to a clone; the shared source keeps its values. Assert.NotNull(source.Value); Assert.Equal(42.5, source.Value.DoubleValue); Assert.NotNull(source.OnAlarmTransition.CurrentValue); Assert.NotNull(source.OnAlarmTransition.LimitValue); Assert.NotSame(source, hubContext.LastArgument); } /// Values pass through unredacted when ShowTagValues is on. [Fact] public void Publish_WhenShowTagValuesTrue_KeepsValues() { CapturingHubContext hubContext = new(); DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: true, WatchedSession1()); MxEvent source = BuildEventWithValue(); broadcaster.Publish("session-1", source); MxEvent sent = Assert.IsType(hubContext.LastArgument); Assert.NotNull(sent.Value); Assert.Equal(42.5, sent.Value.DoubleValue); Assert.NotNull(sent.OnAlarmTransition.CurrentValue); Assert.NotNull(sent.OnAlarmTransition.LimitValue); } /// /// An in-process subscriber gets the same redacted clone the hub group gets, /// and the shared source event is still left untouched. /// [Fact] public void Subscribe_WhenShowTagValuesFalse_DeliversRedactedCloneWithoutMutatingSource() { CapturingHubContext hubContext = new(); EventsHubViewerRegistry viewers = new(); DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers); using IDashboardEventSubscription subscription = broadcaster.Subscribe("session-1"); MxEvent source = BuildEventWithValue(); broadcaster.Publish("session-1", source); MxEvent received = ReadOne(subscription); Assert.Null(received.Value); Assert.Null(received.OnAlarmTransition.CurrentValue); Assert.Null(received.OnAlarmTransition.LimitValue); Assert.Equal("Tank01.Level.HiHi", received.OnAlarmTransition.AlarmFullReference); // One clone feeds both audiences — the hub group and the in-process feed. Assert.Same(hubContext.LastArgument, received); // The source is shared with the gRPC stream and the replay ring. Assert.NotSame(source, received); Assert.NotNull(source.Value); Assert.Equal(42.5, source.Value.DoubleValue); Assert.NotNull(source.OnAlarmTransition.CurrentValue); Assert.NotNull(source.OnAlarmTransition.LimitValue); } /// An in-process subscription opens the viewer gate the same way a hub client does. [Fact] public void Subscribe_OpensTheViewerGate() { CapturingHubContext hubContext = new(); EventsHubViewerRegistry viewers = new(); DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers); broadcaster.Publish("session-1", BuildEventWithValue()); Assert.Equal(0, hubContext.SendCount); Assert.Null(hubContext.LastArgument); using IDashboardEventSubscription subscription = broadcaster.Subscribe("session-1"); Assert.True(viewers.HasViewers("session-1")); broadcaster.Publish("session-1", BuildEventWithValue()); Assert.Equal(1, hubContext.SendCount); Assert.NotNull(hubContext.LastArgument); } /// Disposing the last in-process subscription restores the no-viewers short-circuit. [Fact] public void Dispose_OfLastInProcessSubscription_RestoresTheShortCircuit() { CapturingHubContext hubContext = new(); EventsHubViewerRegistry viewers = new(); DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers); IDashboardEventSubscription subscription = broadcaster.Subscribe("session-1"); broadcaster.Publish("session-1", BuildEventWithValue()); Assert.Equal(1, hubContext.SendCount); Assert.Equal("session-1", ReadOne(subscription).SessionId); subscription.Dispose(); Assert.False(viewers.HasViewers("session-1")); broadcaster.Publish("session-1", BuildEventWithValue()); Assert.Equal(1, hubContext.SendCount); Assert.False(subscription.Reader.TryRead(out _)); } /// Hub viewers and in-process subscribers are audiences of their own session only. [Fact] public void Publish_DeliversOnlyToTheSubscribedSessionsAudience() { CapturingHubContext hubContext = new(); EventsHubViewerRegistry viewers = new(); viewers.AddViewer("conn-1", "session-1"); DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers); using IDashboardEventSubscription subscription = broadcaster.Subscribe("session-2"); // The hub viewer's session must not spill into the in-process feed. broadcaster.Publish("session-1", BuildEventWithValue()); Assert.Equal(1, hubContext.SendCount); Assert.False(subscription.Reader.TryRead(out _)); broadcaster.Publish("session-2", BuildEventWithValue("session-2")); Assert.Equal("session-2", ReadOne(subscription).SessionId); // A session with no audience at all still short-circuits. broadcaster.Publish("session-3", BuildEventWithValue("session-3")); Assert.Equal(2, hubContext.SendCount); } /// Disposing twice is a no-op and cannot release a sibling subscription's registration. [Fact] public void Dispose_CalledTwice_IsSafeAndLeavesSiblingSubscriptionsAlone() { CapturingHubContext hubContext = new(); EventsHubViewerRegistry viewers = new(); DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers); IDashboardEventSubscription first = broadcaster.Subscribe("session-1"); using IDashboardEventSubscription second = broadcaster.Subscribe("session-1"); first.Dispose(); first.Dispose(); Assert.True(viewers.HasViewers("session-1")); broadcaster.Publish("session-1", BuildEventWithValue()); Assert.Equal(1, hubContext.SendCount); Assert.False(first.Reader.TryRead(out _)); Assert.Equal("session-1", ReadOne(second).SessionId); } /// Reads exactly one event from a subscription, failing the test if none is queued. /// The subscription to read from. /// The event that was read. private static MxEvent ReadOne(IDashboardEventSubscription subscription) { Assert.True(subscription.Reader.TryRead(out MxEvent? received)); return Assert.IsType(received); } private static DashboardEventBroadcaster Create( CapturingHubContext hubContext, bool showTagValues, EventsHubViewerRegistry viewers) { GatewayOptions gatewayOptions = new() { Dashboard = new DashboardOptions { ShowTagValues = showTagValues }, }; return new DashboardEventBroadcaster( hubContext, viewers, Options.Create(gatewayOptions), NullLogger.Instance); } /// A registry with one hub connection watching session-1. /// The populated registry. private static EventsHubViewerRegistry WatchedSession1() { EventsHubViewerRegistry viewers = new(); viewers.AddViewer("conn-1", "session-1"); return viewers; } /// Builds a value-bearing alarm-transition event for the given session. /// Session id stamped on the event. /// The event. private static MxEvent BuildEventWithValue(string sessionId = "session-1") { return new MxEvent { Family = MxEventFamily.OnAlarmTransition, SessionId = sessionId, ServerHandle = 7, ItemHandle = 11, Quality = 192, Value = new MxValue { DataType = MxDataType.Double, DoubleValue = 42.5 }, OnAlarmTransition = new OnAlarmTransitionEvent { AlarmFullReference = "Tank01.Level.HiHi", CurrentValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 88.0 }, LimitValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 90.0 }, }, }; } private sealed class CapturingHubContext : IHubContext { private readonly CapturingHubClients _clients = new(); /// Gets the hub clients. public IHubClients Clients => _clients; /// Gets the group manager. public IGroupManager Groups { get; } = new NoopGroupManager(); /// Gets the first argument of the most recent send call. public object? LastArgument => _clients.GroupProxy.LastArgument; /// Gets the number of send calls this fake has observed. public int SendCount => _clients.GroupProxy.SendCount; } private sealed class CapturingHubClients : IHubClients { /// Gets the capturing client proxy shared by this fake. public CapturingClientProxy GroupProxy { get; } = new(); public IClientProxy All => GroupProxy; public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => GroupProxy; public IClientProxy Client(string connectionId) => GroupProxy; public IClientProxy Clients(IReadOnlyList connectionIds) => GroupProxy; public IClientProxy Group(string groupName) => GroupProxy; public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => GroupProxy; public IClientProxy Groups(IReadOnlyList groupNames) => GroupProxy; public IClientProxy User(string userId) => GroupProxy; public IClientProxy Users(IReadOnlyList userIds) => GroupProxy; } private sealed class CapturingClientProxy : IClientProxy { /// Gets the first argument of the most recent send call. public object? LastArgument { get; private set; } /// Gets the number of send calls made through this proxy. public int SendCount { get; private set; } /// Records the send call arguments and completes synchronously. /// The SignalR method name. /// The method arguments. /// Token to observe for cancellation. /// A completed task. public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) { SendCount++; LastArgument = args.Length > 0 ? args[0] : null; return Task.CompletedTask; } } private sealed class NoopGroupManager : IGroupManager { public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask; } }