377 lines
15 KiB
C#
377 lines
15 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Verifies that <see cref="DashboardEventBroadcaster"/> honours
|
|
/// <c>MxGateway:Dashboard:ShowTagValues</c> (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.
|
|
/// </summary>
|
|
public sealed class DashboardEventBroadcasterTests
|
|
{
|
|
/// <summary>An unwatched session costs neither a redaction clone nor a send.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>A viewer on a different session does not open the gate for this one.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Once the last viewer leaves, the mirror stops sending again.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Values are stripped from the mirror when ShowTagValues is off; metadata survives.</summary>
|
|
[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<MxEvent>(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);
|
|
}
|
|
|
|
/// <summary>Redaction applies to a clone, so the shared source event keeps its values.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Values pass through unredacted when ShowTagValues is on.</summary>
|
|
[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<MxEvent>(hubContext.LastArgument);
|
|
Assert.NotNull(sent.Value);
|
|
Assert.Equal(42.5, sent.Value.DoubleValue);
|
|
Assert.NotNull(sent.OnAlarmTransition.CurrentValue);
|
|
Assert.NotNull(sent.OnAlarmTransition.LimitValue);
|
|
}
|
|
|
|
/// <summary>
|
|
/// An in-process subscriber gets the same redacted clone the hub group gets,
|
|
/// and the shared source event is still left untouched.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>An in-process subscription opens the viewer gate the same way a hub client does.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Disposing the last in-process subscription restores the no-viewers short-circuit.</summary>
|
|
[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 _));
|
|
}
|
|
|
|
/// <summary>Hub viewers and in-process subscribers are audiences of their own session only.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Disposing twice is a no-op and cannot release a sibling subscription's registration.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Reads exactly one event from a subscription, failing the test if none is queued.</summary>
|
|
/// <param name="subscription">The subscription to read from.</param>
|
|
/// <returns>The event that was read.</returns>
|
|
private static MxEvent ReadOne(IDashboardEventSubscription subscription)
|
|
{
|
|
Assert.True(subscription.Reader.TryRead(out MxEvent? received));
|
|
return Assert.IsType<MxEvent>(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<DashboardEventBroadcaster>.Instance);
|
|
}
|
|
|
|
/// <summary>A registry with one hub connection watching <c>session-1</c>.</summary>
|
|
/// <returns>The populated registry.</returns>
|
|
private static EventsHubViewerRegistry WatchedSession1()
|
|
{
|
|
EventsHubViewerRegistry viewers = new();
|
|
viewers.AddViewer("conn-1", "session-1");
|
|
return viewers;
|
|
}
|
|
|
|
/// <summary>Builds a value-bearing alarm-transition event for the given session.</summary>
|
|
/// <param name="sessionId">Session id stamped on the event.</param>
|
|
/// <returns>The event.</returns>
|
|
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<EventsHub>
|
|
{
|
|
private readonly CapturingHubClients _clients = new();
|
|
|
|
/// <summary>Gets the hub clients.</summary>
|
|
public IHubClients Clients => _clients;
|
|
|
|
/// <summary>Gets the group manager.</summary>
|
|
public IGroupManager Groups { get; } = new NoopGroupManager();
|
|
|
|
/// <summary>Gets the first argument of the most recent send call.</summary>
|
|
public object? LastArgument => _clients.GroupProxy.LastArgument;
|
|
|
|
/// <summary>Gets the number of send calls this fake has observed.</summary>
|
|
public int SendCount => _clients.GroupProxy.SendCount;
|
|
}
|
|
|
|
private sealed class CapturingHubClients : IHubClients
|
|
{
|
|
/// <summary>Gets the capturing client proxy shared by this fake.</summary>
|
|
public CapturingClientProxy GroupProxy { get; } = new();
|
|
|
|
public IClientProxy All => GroupProxy;
|
|
|
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => GroupProxy;
|
|
|
|
public IClientProxy Client(string connectionId) => GroupProxy;
|
|
|
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => GroupProxy;
|
|
|
|
public IClientProxy Group(string groupName) => GroupProxy;
|
|
|
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => GroupProxy;
|
|
|
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => GroupProxy;
|
|
|
|
public IClientProxy User(string userId) => GroupProxy;
|
|
|
|
public IClientProxy Users(IReadOnlyList<string> userIds) => GroupProxy;
|
|
}
|
|
|
|
private sealed class CapturingClientProxy : IClientProxy
|
|
{
|
|
/// <summary>Gets the first argument of the most recent send call.</summary>
|
|
public object? LastArgument { get; private set; }
|
|
|
|
/// <summary>Gets the number of send calls made through this proxy.</summary>
|
|
public int SendCount { get; private set; }
|
|
|
|
/// <summary>Records the send call arguments and completes synchronously.</summary>
|
|
/// <param name="method">The SignalR method name.</param>
|
|
/// <param name="args">The method arguments.</param>
|
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
/// <returns>A completed task.</returns>
|
|
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;
|
|
}
|
|
}
|