fix(dashboard): redact mirror tag values when ShowTagValues off; trim value-log doc (SEC-25, SEC-30)
SEC-25 (near-term hardening; full per-session EventsHub ACL stays deferred to roadmap item 12): DashboardEventBroadcaster now redacts tag values from a deep CLONE of the event before mirroring to SignalR when Dashboard:ShowTagValues is false (the default). Clears MxEvent.value (covers OnDataChange/OnWriteComplete/ OperationComplete/OnBufferedDataChange — their bodies are empty discriminators, values ride in the top-level field incl. buffered arrays) and the alarm body's current_value/limit_value. Source event never mutated (shared with the gRPC path + replay ring) - verified by a source-not-mutated test. Makes the formerly dead ShowTagValues flag live for the mirror. EventsHub TODO(per-session-acl) kept and tied to roadmap item 12. Tests: DashboardEventBroadcasterTests (3). SEC-30: trim docs/Diagnostics.md to mark opt-in command-value logging as NOT YET IMPLEMENTED (no LogCommandValues knob, RedactCommandValue has no call sites, no values logged); wiring deferred pending secured-bulk redaction coverage (SEC-13). No option/call sites added. Server build clean (0 warnings); Dashboard tests 152/152. Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
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.
|
||||
/// </summary>
|
||||
public sealed class DashboardEventBroadcasterTests
|
||||
{
|
||||
/// <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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
private static DashboardEventBroadcaster Create(CapturingHubContext hubContext, bool showTagValues)
|
||||
{
|
||||
GatewayOptions gatewayOptions = new()
|
||||
{
|
||||
Dashboard = new DashboardOptions { ShowTagValues = showTagValues },
|
||||
};
|
||||
|
||||
return new DashboardEventBroadcaster(
|
||||
hubContext,
|
||||
Options.Create(gatewayOptions),
|
||||
NullLogger<DashboardEventBroadcaster>.Instance);
|
||||
}
|
||||
|
||||
private static MxEvent BuildEventWithValue()
|
||||
{
|
||||
return new MxEvent
|
||||
{
|
||||
Family = MxEventFamily.OnAlarmTransition,
|
||||
SessionId = "session-1",
|
||||
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;
|
||||
}
|
||||
|
||||
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>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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user