using System.Runtime.CompilerServices; 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.Alarms; 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 (TST-16): the alarm value fields of /// both value-bearing payload arms are stripped from the copy broadcast to /// browser clients when the flag is off, present when it is on, and the source /// — shared with the gRPC StreamAlarms /// subscribers and the alarms page — is never mutated. /// public sealed class AlarmsHubPublisherTests { private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); /// Both value-bearing arms lose their values when the flag is off; metadata survives. /// A task that represents the asynchronous operation. [Fact] public async Task ExecuteAsync_WhenShowTagValuesFalse_RedactsBothArmsButKeepsMetadata() { AlarmFeedMessage transition = BuildTransition(); AlarmFeedMessage activeAlarm = BuildActiveAlarm(); CapturingHubContext hubContext = await RunPublisherAsync( showTagValues: false, transition, activeAlarm); Assert.Equal(2, hubContext.Sent.Count); AlarmFeedMessage sentTransition = hubContext.Sent[0]; Assert.Null(sentTransition.Transition.CurrentValue); Assert.Null(sentTransition.Transition.LimitValue); Assert.Equal("Tank01.Level.HiHi", sentTransition.Transition.AlarmFullReference); Assert.Equal("Tank01", sentTransition.Transition.SourceObjectReference); Assert.Equal(AlarmTransitionKind.Raise, sentTransition.Transition.TransitionKind); Assert.Equal(800, sentTransition.Transition.Severity); Assert.Equal("Process", sentTransition.Transition.Category); AlarmFeedMessage sentActive = hubContext.Sent[1]; Assert.Null(sentActive.ActiveAlarm.CurrentValue); Assert.Null(sentActive.ActiveAlarm.LimitValue); Assert.Equal("Tank02.Level.Lo", sentActive.ActiveAlarm.AlarmFullReference); Assert.Equal(AlarmConditionState.Active, sentActive.ActiveAlarm.CurrentState); Assert.Equal(500, sentActive.ActiveAlarm.Severity); } /// /// Redaction applies to a clone: the source message fans out to gRPC /// StreamAlarms subscribers and the alarms page, so it must keep its /// values. /// /// A task that represents the asynchronous operation. [Fact] public async Task ExecuteAsync_WhenShowTagValuesFalse_DoesNotMutateSourceMessage() { AlarmFeedMessage transition = BuildTransition(); AlarmFeedMessage activeAlarm = BuildActiveAlarm(); CapturingHubContext hubContext = await RunPublisherAsync( showTagValues: false, transition, activeAlarm); Assert.NotNull(transition.Transition.CurrentValue); Assert.Equal(88.0, transition.Transition.CurrentValue.DoubleValue); Assert.NotNull(transition.Transition.LimitValue); Assert.NotNull(activeAlarm.ActiveAlarm.CurrentValue); Assert.Equal(12.5, activeAlarm.ActiveAlarm.CurrentValue.DoubleValue); Assert.NotNull(activeAlarm.ActiveAlarm.LimitValue); Assert.NotSame(transition, hubContext.Sent[0]); Assert.NotSame(activeAlarm, hubContext.Sent[1]); } /// Values pass through unredacted — and uncloned — when the flag is on. /// A task that represents the asynchronous operation. [Fact] public async Task ExecuteAsync_WhenShowTagValuesTrue_KeepsValues() { AlarmFeedMessage transition = BuildTransition(); AlarmFeedMessage activeAlarm = BuildActiveAlarm(); CapturingHubContext hubContext = await RunPublisherAsync( showTagValues: true, transition, activeAlarm); Assert.Same(transition, hubContext.Sent[0]); Assert.Same(activeAlarm, hubContext.Sent[1]); Assert.Equal(88.0, hubContext.Sent[0].Transition.CurrentValue.DoubleValue); Assert.Equal(90.0, hubContext.Sent[0].Transition.LimitValue.DoubleValue); Assert.Equal(12.5, hubContext.Sent[1].ActiveAlarm.CurrentValue.DoubleValue); Assert.Equal(10.0, hubContext.Sent[1].ActiveAlarm.LimitValue.DoubleValue); } /// /// A payload arm that carries no value is forwarded as-is — the same /// instance, no clone. This is also the contract for arms added later: the /// switch names only the value-bearing arms, so a new arm passes through. /// /// A task that represents the asynchronous operation. [Fact] public async Task ExecuteAsync_WithValuelessPayloads_ForwardsThemUntouched() { AlarmFeedMessage snapshotComplete = new() { SnapshotComplete = true }; AlarmFeedMessage providerStatus = new() { ProviderStatus = new AlarmProviderStatus { Mode = AlarmProviderMode.Subtag, Degraded = true, Reason = "alarmmgr unavailable", }, }; AlarmFeedMessage snapshotStatus = new() { SnapshotStatus = new AlarmSnapshotStatus { Truncated = true }, }; CapturingHubContext hubContext = await RunPublisherAsync( showTagValues: false, snapshotComplete, providerStatus, snapshotStatus); Assert.Same(snapshotComplete, hubContext.Sent[0]); Assert.Same(providerStatus, hubContext.Sent[1]); Assert.Same(snapshotStatus, hubContext.Sent[2]); Assert.True(hubContext.Sent[2].SnapshotStatus.Truncated); } /// /// Starts the publisher over a scripted feed, waits until every scripted /// message has been broadcast, and stops it. /// /// The Dashboard:ShowTagValues value under test. /// The messages the fake alarm feed yields, in order. /// The hub context holding everything the publisher sent. private static async Task RunPublisherAsync( bool showTagValues, params AlarmFeedMessage[] messages) { ScriptedAlarmService alarmService = new(messages); CapturingHubContext hubContext = new(); GatewayOptions gatewayOptions = new() { Dashboard = new DashboardOptions { ShowTagValues = showTagValues }, }; AlarmsHubPublisher publisher = new( alarmService, hubContext, Options.Create(gatewayOptions), NullLogger.Instance); using CancellationTokenSource cts = new(); await publisher.StartAsync(cts.Token).WaitAsync(TestTimeout); await WaitUntilAsync(() => hubContext.Sent.Count >= messages.Length); await cts.CancelAsync(); await publisher.StopAsync(CancellationToken.None); return hubContext; } private static async Task WaitUntilAsync(Func predicate) { using CancellationTokenSource cancellationTokenSource = new(TestTimeout); while (!predicate()) { await Task.Delay(TimeSpan.FromMilliseconds(5), cancellationTokenSource.Token); } } /// Builds a value-bearing transition feed message. /// The message. private static AlarmFeedMessage BuildTransition() { return new AlarmFeedMessage { Transition = new OnAlarmTransitionEvent { AlarmFullReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", AlarmTypeName = "AnalogLimitAlarm.HiHi", TransitionKind = AlarmTransitionKind.Raise, Severity = 800, Category = "Process", Description = "Level high-high", CurrentValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 88.0 }, LimitValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 90.0 }, }, }; } /// Builds a value-bearing active_alarm feed message. /// The message. private static AlarmFeedMessage BuildActiveAlarm() { return new AlarmFeedMessage { ActiveAlarm = new ActiveAlarmSnapshot { AlarmFullReference = "Tank02.Level.Lo", SourceObjectReference = "Tank02", AlarmTypeName = "AnalogLimitAlarm.Lo", CurrentState = AlarmConditionState.Active, Severity = 500, Category = "Process", Description = "Level low", CurrentValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 12.5 }, LimitValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 10.0 }, }, }; } /// /// Yields a scripted message list once and then stays open until cancelled, /// so the publisher's reconnect loop never re-subscribes mid-test. /// /// The messages to yield, in order. private sealed class ScriptedAlarmService(IReadOnlyList messages) : IGatewayAlarmService { /// public GatewayAlarmMonitorState State => GatewayAlarmMonitorState.Monitoring; /// public string? LastError => null; /// public int? WorkerProcessId => null; /// public IReadOnlyList CurrentAlarms => []; /// public bool SnapshotTruncated => false; /// public async IAsyncEnumerable StreamAsync( string? alarmFilterPrefix, [EnumeratorCancellation] CancellationToken cancellationToken) { foreach (AlarmFeedMessage message in messages) { yield return message; } try { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { } } /// public Task AcknowledgeAsync( AcknowledgeAlarmRequest request, CancellationToken cancellationToken) => throw new NotSupportedException(); } 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 every message the publisher broadcast, in order. public IReadOnlyList Sent => _clients.GroupProxy.Sent; } 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 { private readonly List _sent = []; /// Gets every alarm message sent through this proxy, in order. public IReadOnlyList Sent { get { lock (_sent) { return [.. _sent]; } } } /// Records the broadcast message 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) { if (args.Length > 0 && args[0] is AlarmFeedMessage message) { lock (_sent) { _sent.Add(message); } } 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; } }