Merge branch 'fix/archreview-p2' into main (P2 tier: completeness & polish)

# Conflicts:
#	archreview/remediation/00-tracking.md
#	clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs
#	clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs
#	src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs
This commit is contained in:
Joseph Doherty
2026-07-12 22:12:41 -04:00
91 changed files with 7680 additions and 681 deletions
@@ -10,7 +10,12 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardCookieOptionsTests
{
/// <summary>Verifies that the application configures secure dashboard authentication cookies.</summary>
/// <summary>
/// Verifies that the application configures secure dashboard authentication cookies.
/// With <c>RequireHttpsCookie</c> defaulting to <see langword="true"/> and no explicit
/// <c>CookieName</c> override, the cookie is named with the <c>__Host-</c> prefix so
/// browsers enforce the Secure/no-Domain/Path=/ guarantees the prefix promises.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Build_ConfiguresSecureDashboardCookie()
@@ -22,7 +27,7 @@ public sealed class DashboardCookieOptionsTests
CookieAuthenticationOptions options = optionsMonitor.Get(
DashboardAuthenticationDefaults.AuthenticationScheme);
Assert.Equal(DashboardAuthenticationDefaults.CookieName, options.Cookie.Name);
Assert.Equal(DashboardAuthenticationDefaults.SecureCookieName, options.Cookie.Name);
Assert.True(options.Cookie.HttpOnly);
Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy);
Assert.Equal(SameSiteMode.Strict, options.Cookie.SameSite);
@@ -35,11 +40,14 @@ public sealed class DashboardCookieOptionsTests
/// <summary>
/// Verifies that setting <c>MxGateway:Dashboard:RequireHttpsCookie=false</c>
/// relaxes the cookie to <see cref="CookieSecurePolicy.SameAsRequest"/> so
/// the dashboard can be reached over plain HTTP in dev.
/// the dashboard can be reached over plain HTTP in dev, and that the plain
/// <see cref="DashboardAuthenticationDefaults.CookieName"/> is used rather than the
/// <c>__Host-</c> name — a <c>__Host-</c> cookie without a guaranteed Secure flag is
/// silently dropped by browsers.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Build_WithRequireHttpsCookieFalse_UsesSameAsRequest()
public async Task Build_WithRequireHttpsCookieFalse_UsesSameAsRequestAndPlainName()
{
await using WebApplication app = GatewayApplication.Build(
["--MxGateway:Dashboard:RequireHttpsCookie=false"]);
@@ -50,12 +58,14 @@ public sealed class DashboardCookieOptionsTests
DashboardAuthenticationDefaults.AuthenticationScheme);
Assert.Equal(CookieSecurePolicy.SameAsRequest, options.Cookie.SecurePolicy);
Assert.Equal(DashboardAuthenticationDefaults.CookieName, options.Cookie.Name);
}
/// <summary>
/// Verifies that <c>MxGateway:Dashboard:CookieName</c> overrides the dashboard auth
/// cookie name, so a gateway instance sharing a hostname with another can be given a
/// distinct name (browser cookies are scoped by host+path, not port).
/// Verifies that an explicit <c>MxGateway:Dashboard:CookieName</c> override wins over the
/// secure <c>__Host-</c> default (this build leaves <c>RequireHttpsCookie</c> at its
/// <see langword="true"/> default), so a gateway instance sharing a hostname with another
/// can be given a distinct name (browser cookies are scoped by host+path, not port).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -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;
}
}