mbproxy: replace status page with a live SignalR web dashboard

The single auto-refreshing zero-JS status page gave operators a 25-column
wall and no way to drill into one connection. This adds a Bootstrap fleet
dashboard (filterable/sortable KPI table) and a per-PLC detail page with a
real-time debug view of raw PLC-side BCD vs. decoded client-side values,
streamed live over a SignalR feed. The debug view is fed by an on-demand
per-tag value capture, armed only while a detail page is open. All assets
(Bootstrap, SignalR client, fonts) are embedded so the UI works unchanged
on firewalled networks; GET /status.json is untouched for scrapers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-15 10:40:21 -04:00
parent b330faff03
commit e719dd51c1
49 changed files with 3539 additions and 424 deletions
@@ -168,10 +168,10 @@ public sealed class AdminEndpointTests
after.ShouldBeGreaterThan(before, "partialBcdWarnings should increment after partial overlap read");
}
// ── 4. GET / returns 200 text/html with meta-refresh ─────────────────────
// ── 4. GET / and GET /plc/{name} serve the embedded SPA shells ───────────
[Fact(Timeout = 5_000)]
public async Task Get_Root_ReturnsHtml_WithMetaRefresh()
public async Task Get_Root_ReturnsDashboardShell()
{
int adminPort = PickFreePort();
int proxyPort = PickFreePort();
@@ -190,8 +190,79 @@ public sealed class AdminEndpointTests
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html");
string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
body.ShouldContain("<meta http-equiv=\"refresh\" content=\"5\">");
body.ShouldContain("<!DOCTYPE html>");
body.ShouldContain("<!doctype html>");
body.ShouldContain("/assets/dashboard.js");
}
[Fact(Timeout = 5_000)]
public async Task Get_PlcDetailRoute_ReturnsDetailShell()
{
int adminPort = PickFreePort();
int proxyPort = PickFreePort();
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
proxyPort: proxyPort, bcd16Addresses: []);
await using var _ = new AsyncHostDispose(host);
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await host.StartAsync(startCts.Token);
await WaitForAdminAsync(adminPort);
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/plc/anything",
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html");
string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
body.ShouldContain("/assets/detail.js");
}
[Theory(Timeout = 5_000)]
[InlineData("bootstrap.min.css", "text/css")]
[InlineData("signalr.min.js", "text/javascript")]
[InlineData("dashboard.js", "text/javascript")]
[InlineData("theme.css", "text/css")]
[InlineData("ibm-plex-mono-500.woff2", "font/woff2")]
public async Task Get_Asset_ReturnsCorrectContentType(string file, string expectedType)
{
int adminPort = PickFreePort();
int proxyPort = PickFreePort();
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
proxyPort: proxyPort, bcd16Addresses: []);
await using var _ = new AsyncHostDispose(host);
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await host.StartAsync(startCts.Token);
await WaitForAdminAsync(adminPort);
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/assets/{file}",
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.ShouldBe(expectedType);
response.Headers.CacheControl?.ToString().ShouldContain("immutable");
var bytes = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
bytes.Length.ShouldBeGreaterThan(0);
}
[Fact(Timeout = 5_000)]
public async Task Get_UnknownAsset_Returns404()
{
int adminPort = PickFreePort();
int proxyPort = PickFreePort();
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
proxyPort: proxyPort, bcd16Addresses: []);
await using var _ = new AsyncHostDispose(host);
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await host.StartAsync(startCts.Token);
await WaitForAdminAsync(adminPort);
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/assets/no-such-file.js",
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound);
}
// ── 5. AdminPort collision → proxy still runs + bind.failed logged ────────
@@ -334,9 +405,9 @@ public sealed class AdminEndpointTests
/// <summary>
/// Verifies the admin endpoint rejects non-GET methods (POST / PUT / DELETE)
/// with HTTP 405 Method Not Allowed. The design intentionally exposes only `GET /`
/// and `GET /status.json`; this test guards against an accidental MapPost/Map* being
/// added later.
/// against the read-only routes `GET /` and `GET /status.json` with HTTP 405.
/// (The SignalR hub at `/hub/status` legitimately accepts POST and is not tested
/// here.) Guards against an accidental MapPost/Map* being added later.
/// </summary>
[Theory(Timeout = 5_000)]
[InlineData("POST")]
@@ -0,0 +1,66 @@
using System.Collections.Concurrent;
using System.Security.Claims;
using Mbproxy.Admin;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.SignalR;
namespace Mbproxy.Tests.Admin;
/// <summary>
/// Minimal hand-written test doubles for the SignalR surface <see cref="StatusHub"/>
/// and <see cref="StatusBroadcaster"/> touch. The project carries no mocking framework,
/// so these record just enough to assert behaviour.
/// </summary>
internal sealed class FakeHubCallerContext : HubCallerContext
{
public FakeHubCallerContext(string connectionId) => ConnectionId = connectionId;
public override string ConnectionId { get; }
public override string? UserIdentifier => null;
public override ClaimsPrincipal? User => null;
public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
public override IFeatureCollection Features { get; } = new FeatureCollection();
public override CancellationToken ConnectionAborted => CancellationToken.None;
public override void Abort() { }
}
/// <summary>Records every group join/leave so tests can assert membership changes.</summary>
internal sealed class FakeGroupManager : IGroupManager
{
public List<(string ConnectionId, string Group)> Added { get; } = [];
public List<(string ConnectionId, string Group)> Removed { get; } = [];
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
{
Added.Add((connectionId, groupName));
return Task.CompletedTask;
}
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
{
Removed.Add((connectionId, groupName));
return Task.CompletedTask;
}
}
/// <summary>Records every push so <see cref="StatusBroadcaster"/> tests can assert routing.</summary>
internal sealed class FakeStatusPushSink : IStatusPushSink
{
private readonly ConcurrentBag<StatusResponse> _fleet = [];
private readonly ConcurrentBag<(string Plc, PlcDetailResponse Detail)> _plc = [];
public IReadOnlyCollection<StatusResponse> FleetPushes => _fleet;
public IReadOnlyCollection<(string Plc, PlcDetailResponse Detail)> PlcPushes => _plc;
public Task PushFleetAsync(StatusResponse snapshot, CancellationToken ct)
{
_fleet.Add(snapshot);
return Task.CompletedTask;
}
public Task PushPlcAsync(string plcName, PlcDetailResponse detail, CancellationToken ct)
{
_plc.Add((plcName, detail));
return Task.CompletedTask;
}
}
@@ -0,0 +1,120 @@
using Mbproxy.Admin;
using Mbproxy.Bcd;
using Mbproxy.Options;
using Mbproxy.Proxy;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Serilog;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Admin;
/// <summary>
/// Unit tests for <see cref="StatusBroadcaster"/>'s push-cycle logic — fleet always
/// pushed, per-PLC pushed only for PLCs with a detail-page subscriber, and every
/// capture disarmed on stop. The SignalR sink is faked; a real
/// <see cref="StatusSnapshotBuilder"/> is resolved from a minimal in-process host.
/// </summary>
[Trait("Category", "Unit")]
public sealed class StatusBroadcasterTests
{
private sealed record Harness(
IHost Host,
StatusBroadcaster Broadcaster,
FakeStatusPushSink Sink,
StatusSnapshotBuilder Builder,
TagCaptureRegistry Registry,
PlcSubscriptionTracker Tracker) : IAsyncDisposable
{
public async ValueTask DisposeAsync()
{
await Broadcaster.DisposeAsync();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try { await Host.StopAsync(cts.Token); } catch { }
Host.Dispose();
}
}
private static async Task<Harness> BuildAsync()
{
var hostBuilder = Host.CreateApplicationBuilder();
hostBuilder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Mbproxy:AdminPort"] = "0",
});
hostBuilder.Services.AddSerilog(
new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(), dispose: false);
hostBuilder.AddMbproxyOptions();
hostBuilder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>();
hostBuilder.Services.AddSingleton<ProxyWorker>();
hostBuilder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());
hostBuilder.Services.AddSingleton<AssemblyVersionAccessor>();
hostBuilder.Services.AddSingleton<StatusSnapshotBuilder>();
var host = hostBuilder.Build();
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
await host.StartAsync(startCts.Token);
var builder = host.Services.GetRequiredService<StatusSnapshotBuilder>();
var registry = host.Services.GetRequiredService<TagCaptureRegistry>();
var options = host.Services.GetRequiredService<IOptionsMonitor<MbproxyOptions>>();
var tracker = new PlcSubscriptionTracker();
var sink = new FakeStatusPushSink();
var broadcaster = new StatusBroadcaster(
sink, builder, tracker, registry, options, NullLogger.Instance);
return new Harness(host, broadcaster, sink, builder, registry, tracker);
}
[Fact]
public async Task PushOnce_AlwaysPushesFleet()
{
await using var h = await BuildAsync();
await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
h.Sink.FleetPushes.Count.ShouldBe(1);
}
[Fact]
public async Task PushOnce_NoActivePlcs_SkipsPerPlcPush()
{
await using var h = await BuildAsync();
await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
h.Sink.PlcPushes.ShouldBeEmpty();
}
[Fact]
public async Task PushOnce_ActivePlc_PushesDetailWithDebugSnapshot()
{
await using var h = await BuildAsync();
h.Registry.GetOrCreate("plc-x", BcdTagMap.Empty);
h.Tracker.Add("conn-1", "plc-x");
await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
var push = h.Sink.PlcPushes.ShouldHaveSingleItem();
push.Plc.ShouldBe("plc-x");
push.Detail.Debug.ShouldNotBeNull();
}
[Fact]
public async Task StopAsync_DisarmsEveryCapture()
{
await using var h = await BuildAsync();
h.Registry.GetOrCreate("plc-x", BcdTagMap.Empty);
h.Registry.Arm("plc-x");
await h.Broadcaster.StopAsync();
h.Registry.TryGet("plc-x", out var capture).ShouldBeTrue();
capture.IsArmed.ShouldBeFalse();
}
}
@@ -1,128 +0,0 @@
using Mbproxy.Admin;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Admin;
/// <summary>
/// Unit tests for <see cref="StatusHtmlRenderer"/>.
/// All tests are pure: no network, no host, no DI.
/// </summary>
[Trait("Category", "Unit")]
public sealed class StatusHtmlRendererTests
{
// ── Helpers ───────────────────────────────────────────────────────────────
private static StatusResponse MakeStatus(
IReadOnlyList<PlcStatus>? plcs = null,
int uptimeSeconds = 42,
string version = "1.2.3")
{
var service = new ServiceFields(
UptimeSeconds: uptimeSeconds,
Version: version,
ConfigLastReloadUtc: null,
ConfigReloadCount: 0,
ConfigReloadRejectedCount: 0);
var listeners = new ListenersAggregate(Bound: plcs?.Count ?? 0, Configured: plcs?.Count ?? 0);
return new StatusResponse(service, listeners, plcs ?? []);
}
private static PlcStatus MakePlc(
string name = "PLC-A",
string state = "bound",
string? lastBindError = null,
int recoveryAttempts = 0,
IReadOnlyList<ClientSnapshot>? clients = null)
{
var noClients = (IReadOnlyList<ClientSnapshot>)[];
return new PlcStatus(
Name: name,
Host: "10.0.0.1",
ListenPort: 5020,
Listener: new PlcListenerStatus(state, lastBindError, recoveryAttempts),
Clients: new PlcClientsStatus(clients?.Count ?? 0, clients ?? noClients),
Pdus: new PlcPdusStatus(100, new FcCounts(50, 10, 20, 15, 5), 30, 2, 0),
Backend: new PlcBackendStatus(
ConnectsSuccess: 0, ConnectsFailed: 0,
ExceptionsByCode: new ExceptionCounts(1, 0, 0, 0, 0),
LastRoundTripMs: 3.5,
InFlight: 0, MaxInFlight: 0, TxIdWraps: 0,
DisconnectCascades: 0, QueueDepth: 0,
CoalescedHitCount: 0, CoalescedMissCount: 0,
CoalescedResponseToDeadUpstream: 0,
CacheHitCount: 0, CacheMissCount: 0,
CacheInvalidations: 0, CacheEntryCount: 0, CacheBytes: 0,
BackendHeartbeatsSent: 0, BackendHeartbeatsFailed: 0,
BackendIdleDisconnects: 0),
Bytes: new PlcBytesStatus(1024, 2048));
}
// ── 1. Valid HTML with meta-refresh for a single PLC ─────────────────────
[Fact]
public void Render_OnePlc_ProducesValidHtml_WithMetaRefresh()
{
var status = MakeStatus([MakePlc("PLC-A", "bound")]);
string html = StatusHtmlRenderer.Render(status);
html.ShouldContain("<meta http-equiv=\"refresh\" content=\"5\">");
html.ShouldContain("<!DOCTYPE html>");
html.ShouldContain("</html>");
html.ShouldContain("PLC-A");
html.ShouldContain("bound");
}
// ── 2. Recovering state highlights error ─────────────────────────────────
[Fact]
public void Render_RecoveringPlc_HighlightsState()
{
var plc = MakePlc("PLC-B", "recovering", lastBindError: "Address already in use", recoveryAttempts: 3);
var status = MakeStatus([plc]);
string html = StatusHtmlRenderer.Render(status);
// State should be orange.
html.ShouldContain("class=\"recovering\"");
html.ShouldContain("Address already in use");
html.ShouldContain("attempt 3");
}
// ── 3. Page weight under 50 KB for 54 PLCs ───────────────────────────────
[Fact]
public void Render_PageWeightUnder50KB_For54Plcs()
{
const int plcCount = 54;
// Build 54 realistic PLC rows with 2 clients each.
var plcs = new List<PlcStatus>(plcCount);
for (int i = 0; i < plcCount; i++)
{
var clients = new List<ClientSnapshot>
{
new ClientSnapshot($"10.0.0.{i + 1}:49123", DateTimeOffset.UtcNow, 42),
new ClientSnapshot($"10.0.0.{i + 1}:49124", DateTimeOffset.UtcNow, 17),
};
plcs.Add(MakePlc(
name: $"Line{i / 10 + 1}-Station{i % 10 + 1:D2}",
state: i % 5 == 0 ? "recovering" : "bound",
lastBindError: i % 5 == 0 ? "EADDRINUSE" : null,
recoveryAttempts: i % 5 == 0 ? 2 : 0,
clients: clients));
}
var status = MakeStatus(plcs);
string html = StatusHtmlRenderer.Render(status);
int byteCount = System.Text.Encoding.UTF8.GetByteCount(html);
// Assert ≤ 50 KB.
byteCount.ShouldBeLessThanOrEqualTo(50 * 1024,
$"Page weight {byteCount} bytes exceeds 50 KB limit for {plcCount} PLCs");
}
}
@@ -0,0 +1,91 @@
using Mbproxy.Admin;
using Mbproxy.Bcd;
using Mbproxy.Proxy;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Admin;
/// <summary>
/// Unit tests for <see cref="StatusHub"/> — group joins and on-demand capture
/// arming. Uses hand-written SignalR test doubles (see <see cref="SignalRFakes"/>);
/// no SignalR host is started.
/// </summary>
[Trait("Category", "Unit")]
public sealed class StatusHubTests
{
private static StatusHub MakeHub(
string connectionId,
PlcSubscriptionTracker tracker,
TagCaptureRegistry registry,
out FakeGroupManager groups)
{
groups = new FakeGroupManager();
return new StatusHub(tracker, registry)
{
Context = new FakeHubCallerContext(connectionId),
Groups = groups,
};
}
[Fact]
public async Task SubscribeFleet_JoinsFleetGroup()
{
var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), new TagCaptureRegistry(), out var groups);
await hub.SubscribeFleet();
groups.Added.ShouldContain(("conn-1", StatusHub.FleetGroup));
}
[Fact]
public async Task SubscribePlc_JoinsPlcGroup_AndArmsCapture()
{
var registry = new TagCaptureRegistry();
registry.GetOrCreate("plc-1", BcdTagMap.Empty);
var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), registry, out var groups);
await hub.SubscribePlc("plc-1");
groups.Added.ShouldContain(("conn-1", StatusHub.PlcGroup("plc-1")));
registry.TryGet("plc-1", out var capture).ShouldBeTrue();
capture.IsArmed.ShouldBeTrue();
}
[Fact]
public async Task SecondSubscriber_FirstLeaveKeepsArmed_LastLeaveDisarms()
{
var tracker = new PlcSubscriptionTracker();
var registry = new TagCaptureRegistry();
registry.GetOrCreate("plc-1", BcdTagMap.Empty);
var hub1 = MakeHub("conn-1", tracker, registry, out _);
var hub2 = MakeHub("conn-2", tracker, registry, out _);
await hub1.SubscribePlc("plc-1");
await hub2.SubscribePlc("plc-1");
registry.TryGet("plc-1", out var capture).ShouldBeTrue();
capture.IsArmed.ShouldBeTrue();
// First viewer leaves — a second viewer remains, so capture stays armed.
await hub1.OnDisconnectedAsync(null);
capture.IsArmed.ShouldBeTrue("capture must stay armed while another detail page is open");
// Last viewer leaves — capture disarms.
await hub2.OnDisconnectedAsync(null);
capture.IsArmed.ShouldBeFalse("capture must disarm when the last viewer leaves");
}
[Fact]
public async Task SubscribePlc_UnknownPlc_DoesNotThrow_AndArmsNothing()
{
var registry = new TagCaptureRegistry(); // no captures registered
var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), registry, out var groups);
await Should.NotThrowAsync(async () => await hub.SubscribePlc("ghost"));
groups.Added.ShouldContain(("conn-1", StatusHub.PlcGroup("ghost")));
registry.TryGet("ghost", out _).ShouldBeFalse();
}
}
@@ -218,18 +218,60 @@ public sealed class StatusSnapshotBuilderTests
result.Service.ConfigReloadCount.ShouldBe(1);
}
// ── 7. BuildDebug: unknown PLC → empty, disarmed snapshot ────────────────
[Fact]
public async Task BuildDebug_UnknownPlc_ReturnsEmptyDisarmedSnapshot()
{
var (host, builder) = await BuildAsync([]);
await using var _ = new AsyncHostDispose(host);
var debug = builder.BuildDebug("no-such-plc");
debug.CaptureArmed.ShouldBeFalse();
debug.Tags.ShouldBeEmpty();
}
// ── 8. BuildDebug: configured PLC → one row per BCD tag, no traffic ──────
[Fact]
public async Task BuildDebug_ConfiguredPlc_ReturnsTagRows_DisarmedByDefault()
{
int port = PickFreePort();
var (host, builder) = await BuildAsync([("PLC-A", port)], bcd16Address: 1072);
await using var _ = new AsyncHostDispose(host);
await WaitForAsync(() => CanConnect(port), TimeSpan.FromSeconds(5), "PLC-A should bind");
var debug = builder.BuildDebug("PLC-A");
debug.CaptureArmed.ShouldBeFalse(); // no detail page open
var tag = debug.Tags.ShouldHaveSingleItem();
tag.Address.ShouldBe(1072);
tag.Width.ShouldBe(16);
tag.HasValue.ShouldBeFalse();
tag.RawHex.ShouldBe("—");
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static async Task<(IHost host, StatusSnapshotBuilder builder)> BuildAsync(
(string name, int port)[] plcs,
int startupWaitMs = 200,
int backendPort = 502)
int backendPort = 502,
int? bcd16Address = null)
{
var config = new Dictionary<string, string?>
{
["Mbproxy:AdminPort"] = "0", // disable admin for unit tests
};
if (bcd16Address is { } addr)
{
config["Mbproxy:BcdTags:Global:0:Address"] = addr.ToString();
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
}
for (int i = 0; i < plcs.Length; i++)
{
config[$"Mbproxy:Plcs:{i}:Name"] = plcs[i].name;
@@ -73,7 +73,8 @@ public sealed class ConfigReconcilerTests : IAsyncDisposable
return new ConfigReconciler(
monitor,
NullLoggerFactory.Instance,
counters ?? new ServiceCounters());
counters ?? new ServiceCounters(),
new Mbproxy.Proxy.TagCaptureRegistry());
}
// The reconciler and supervisors tracked for cleanup.
@@ -334,4 +334,36 @@ public sealed class ReloadValidatorTests
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("BackendHeartbeatIdleMs"));
}
// ── AdminPushIntervalMs ────────────────────────────────────────────────────
[Fact]
public void Validate_AdminPushIntervalMs_Zero_Fails()
{
var opts = new MbproxyOptions
{
Plcs = [MakePlc("PLC-A", 5020)],
AdminPushIntervalMs = 0,
};
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("AdminPushIntervalMs"));
}
[Fact]
public void Validate_AdminPushIntervalMs_Negative_Fails()
{
var opts = new MbproxyOptions
{
Plcs = [MakePlc("PLC-A", 5020)],
AdminPushIntervalMs = -5,
};
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("AdminPushIntervalMs"));
}
}
@@ -190,6 +190,28 @@ public sealed class MbproxyOptionsBindingTests
string.Join("; ", result.Failures ?? []));
}
// -------------------------------------------------------------------------
// Test 7 — AdminPushIntervalMs (SignalR dashboard push cadence)
// -------------------------------------------------------------------------
[Fact]
public void MbproxyOptionsBinding_AdminPushIntervalMs_DefaultsTo1000()
{
var options = BindOptions(new Dictionary<string, string?>());
options.AdminPushIntervalMs.ShouldBe(1000);
}
[Fact]
public void MbproxyOptionsBinding_AdminPushIntervalMs_BindsConfiguredValue()
{
var options = BindOptions(new Dictionary<string, string?>
{
["Mbproxy:AdminPushIntervalMs"] = "250",
});
options.AdminPushIntervalMs.ShouldBe(250);
}
/// <summary>
/// Resolves an <c>install/</c> file by walking up from the test assembly directory.
/// Works from both the Windows dev box and the Linux test box.
@@ -0,0 +1,181 @@
using System.Collections.Frozen;
using Mbproxy.Bcd;
using Mbproxy.Proxy;
using Mbproxy.Proxy.Multiplexing;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Proxy;
/// <summary>
/// Unit tests for the <see cref="TagValueCapture"/> recording hooks in
/// <see cref="BcdPduPipeline"/>. Verifies that an armed capture records raw PLC-side
/// and decoded client-side values, and — as a regression guard — that a disarmed or
/// absent capture leaves the rewrite behaviour byte-identical.
/// </summary>
[Trait("Category", "Unit")]
public sealed class BcdPduPipelineCaptureTests
{
private static readonly BcdPduPipeline Pipeline = new();
private static BcdTagMap BuildMap(params BcdTag[] tags)
{
var frozen = tags.ToDictionary(t => t.Address).ToFrozenDictionary();
return frozen.Count > 0 ? new BcdTagMap(frozen) : BcdTagMap.Empty;
}
private static PerPlcContext MakeContext(TagValueCapture? capture, params BcdTag[] tags)
=> new()
{
PlcName = "TestPLC",
TagMap = BuildMap(tags),
Counters = new ProxyCounters(),
Logger = NullLogger.Instance,
Capture = capture,
};
private static InFlightRequest MakeInFlight(byte fc, ushort start, ushort qty)
=> new(1, fc, start, qty, Array.Empty<InterestedParty>(), DateTimeOffset.UtcNow);
private static byte[] Fc03Response(params ushort[] regs)
{
var pdu = new byte[2 + regs.Length * 2];
pdu[0] = 0x03;
pdu[1] = (byte)(regs.Length * 2);
for (int i = 0; i < regs.Length; i++)
{
pdu[2 + i * 2] = (byte)(regs[i] >> 8);
pdu[2 + i * 2 + 1] = (byte)(regs[i] & 0xFF);
}
return pdu;
}
private static byte[] Fc06Request(ushort address, ushort value)
=> [0x06, (byte)(address >> 8), (byte)(address & 0xFF), (byte)(value >> 8), (byte)(value & 0xFF)];
private static byte[] Fc16Request(ushort start, params ushort[] regs)
{
var pdu = new byte[6 + regs.Length * 2];
pdu[0] = 0x10;
pdu[1] = (byte)(start >> 8);
pdu[2] = (byte)(start & 0xFF);
pdu[3] = (byte)((ushort)regs.Length >> 8);
pdu[4] = (byte)(regs.Length & 0xFF);
pdu[5] = (byte)(regs.Length * 2);
for (int i = 0; i < regs.Length; i++)
{
pdu[6 + i * 2] = (byte)(regs[i] >> 8);
pdu[6 + i * 2 + 1] = (byte)(regs[i] & 0xFF);
}
return pdu;
}
private static void ProcessFc03Response(PerPlcContext ctx, ushort start, ushort qty, byte[] response)
{
var responseCtx = ctx.WithCurrentRequest(MakeInFlight(0x03, start, qty));
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, response.AsSpan(), responseCtx);
}
private static ushort ReadReg(byte[] pdu, int offsetWords)
=> (ushort)((pdu[2 + offsetWords * 2] << 8) | pdu[2 + offsetWords * 2 + 1]);
// ── Read path (FC03/FC04 response) ───────────────────────────────────────
[Fact]
public void FC03_16Bit_ArmedCapture_RecordsRawAndDecoded()
{
var capture = new TagValueCapture([BcdTag.Create(100, 16)]);
capture.Arm();
var ctx = MakeContext(capture, BcdTag.Create(100, 16));
ProcessFc03Response(ctx, 100, 1, Fc03Response(0x1234));
var slot = capture.Snapshot().ShouldHaveSingleItem();
slot.Address.ShouldBe((ushort)100);
slot.RawLow.ShouldBe((ushort)0x1234); // BCD nibbles on the PLC wire
slot.DecodedValue.ShouldBe(1234); // binary the client receives
slot.Direction.ShouldBe(CaptureDirection.Read);
slot.UpdatedAtUtc.ShouldNotBeNull();
}
[Fact]
public void FC03_32Bit_ArmedCapture_RecordsBothRawWords()
{
var capture = new TagValueCapture([BcdTag.Create(100, 32)]);
capture.Arm();
var ctx = MakeContext(capture, BcdTag.Create(100, 32));
// CDAB: low word 0x5678, high word 0x1234 → decoded 1234*10000 + 5678.
ProcessFc03Response(ctx, 100, 2, Fc03Response(0x5678, 0x1234));
var slot = capture.Snapshot().ShouldHaveSingleItem();
slot.Width.ShouldBe((byte)32);
slot.RawLow.ShouldBe((ushort)0x5678);
slot.RawHigh.ShouldBe((ushort)0x1234);
slot.DecodedValue.ShouldBe(12345678);
slot.Direction.ShouldBe(CaptureDirection.Read);
}
// ── Write path (FC06 / FC16 request) ─────────────────────────────────────
[Fact]
public void FC06_ArmedCapture_RecordsEncodedBcdAndClientValue()
{
var capture = new TagValueCapture([BcdTag.Create(100, 16)]);
capture.Arm();
var ctx = MakeContext(capture, BcdTag.Create(100, 16));
// Client writes binary 1234; proxy encodes to BCD 0x1234 for the PLC.
var req = Fc06Request(100, 1234);
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, req.AsSpan(), ctx);
var slot = capture.Snapshot().ShouldHaveSingleItem();
slot.RawLow.ShouldBe((ushort)0x1234); // BCD nibbles sent to the PLC
slot.DecodedValue.ShouldBe(1234); // binary the client wrote
slot.Direction.ShouldBe(CaptureDirection.Write);
}
[Fact]
public void FC16_16Bit_ArmedCapture_RecordsWrite()
{
var capture = new TagValueCapture([BcdTag.Create(100, 16)]);
capture.Arm();
var ctx = MakeContext(capture, BcdTag.Create(100, 16));
var req = Fc16Request(100, 4321);
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, req.AsSpan(), ctx);
var slot = capture.Snapshot().ShouldHaveSingleItem();
slot.RawLow.ShouldBe((ushort)0x4321);
slot.DecodedValue.ShouldBe(4321);
slot.Direction.ShouldBe(CaptureDirection.Write);
}
// ── Regression guards: disarmed / absent capture ─────────────────────────
[Fact]
public void FC03_DisarmedCapture_StillRewrites_ButCapturesNothing()
{
var capture = new TagValueCapture([BcdTag.Create(100, 16)]);
// Not armed.
var ctx = MakeContext(capture, BcdTag.Create(100, 16));
var rsp = Fc03Response(0x1234);
ProcessFc03Response(ctx, 100, 1, rsp);
ReadReg(rsp, 0).ShouldBe((ushort)1234); // rewrite still happened
capture.Snapshot().ShouldHaveSingleItem().UpdatedAtUtc.ShouldBeNull();
}
[Fact]
public void FC03_NullCapture_DoesNotThrow_AndStillRewrites()
{
var ctx = MakeContext(capture: null, BcdTag.Create(100, 16));
var rsp = Fc03Response(0x1234);
Should.NotThrow(() => ProcessFc03Response(ctx, 100, 1, rsp));
ReadReg(rsp, 0).ShouldBe((ushort)1234);
}
}
@@ -0,0 +1,112 @@
using System.Collections.Frozen;
using Mbproxy.Bcd;
using Mbproxy.Proxy;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Proxy;
/// <summary>
/// Unit tests for <see cref="TagCaptureRegistry"/> — the shared seam that arms and
/// disarms per-PLC <see cref="TagValueCapture"/> instances.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TagCaptureRegistryTests
{
private static BcdTagMap Map(params (ushort addr, byte width)[] tags)
{
if (tags.Length == 0)
return BcdTagMap.Empty;
var frozen = tags
.Select(t => BcdTag.Create(t.addr, t.width))
.ToDictionary(t => t.Address)
.ToFrozenDictionary();
return new BcdTagMap(frozen);
}
[Fact]
public void GetOrCreate_ReturnsSameInstance_OnRepeatCall_WhenTagSetUnchanged()
{
var registry = new TagCaptureRegistry();
var first = registry.GetOrCreate("plc-1", Map((100, 16)));
var second = registry.GetOrCreate("plc-1", Map((100, 16)));
// AddOrUpdate's update path rebuilds; both must be live and consistent.
second.TagCount.ShouldBe(1);
registry.TryGet("plc-1", out var current).ShouldBeTrue();
current.ShouldBeSameAs(second);
}
[Fact]
public void GetOrCreate_Rebuild_PreservesArmedFlag()
{
var registry = new TagCaptureRegistry();
var capture = registry.GetOrCreate("plc-1", Map((100, 16)));
capture.Arm();
// Hot-reload reseat: same PLC, changed tag set.
var rebuilt = registry.GetOrCreate("plc-1", Map((100, 16), (200, 32)));
rebuilt.ShouldNotBeSameAs(capture);
rebuilt.IsArmed.ShouldBeTrue("a rebuilt capture must keep capturing for an open detail page");
rebuilt.TagCount.ShouldBe(2);
}
[Fact]
public void Arm_And_Disarm_ReachTheRightCapture()
{
var registry = new TagCaptureRegistry();
registry.GetOrCreate("plc-1", Map((100, 16)));
registry.GetOrCreate("plc-2", Map((100, 16)));
registry.Arm("plc-1");
registry.TryGet("plc-1", out var c1).ShouldBeTrue();
registry.TryGet("plc-2", out var c2).ShouldBeTrue();
c1.IsArmed.ShouldBeTrue();
c2.IsArmed.ShouldBeFalse();
registry.Disarm("plc-1");
c1.IsArmed.ShouldBeFalse();
}
[Fact]
public void DisarmAll_DisarmsEveryCapture()
{
var registry = new TagCaptureRegistry();
registry.GetOrCreate("plc-1", Map((100, 16)));
registry.GetOrCreate("plc-2", Map((100, 16)));
registry.Arm("plc-1");
registry.Arm("plc-2");
registry.DisarmAll();
registry.TryGet("plc-1", out var c1).ShouldBeTrue();
registry.TryGet("plc-2", out var c2).ShouldBeTrue();
c1.IsArmed.ShouldBeFalse();
c2.IsArmed.ShouldBeFalse();
}
[Fact]
public void UnknownPlc_Operations_AreSafeNoOps()
{
var registry = new TagCaptureRegistry();
Should.NotThrow(() => registry.Arm("ghost"));
Should.NotThrow(() => registry.Disarm("ghost"));
Should.NotThrow(() => registry.Remove("ghost"));
registry.TryGet("ghost", out _).ShouldBeFalse();
}
[Fact]
public void Remove_DropsTheCapture()
{
var registry = new TagCaptureRegistry();
registry.GetOrCreate("plc-1", Map((100, 16)));
registry.TryGet("plc-1", out _).ShouldBeTrue();
registry.Remove("plc-1");
registry.TryGet("plc-1", out _).ShouldBeFalse();
}
}
@@ -0,0 +1,156 @@
using Mbproxy.Bcd;
using Mbproxy.Proxy;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Proxy;
/// <summary>
/// Unit tests for <see cref="TagValueCapture"/> — the on-demand per-tag value store
/// behind the connection-detail debug view.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TagValueCaptureTests
{
private static TagValueCapture Make(params (ushort addr, byte width)[] tags)
=> new(tags.Select(t => BcdTag.Create(t.addr, t.width)));
[Fact]
public void Disarmed_Record_IsNoOp()
{
var capture = Make((100, 16));
// No Arm() call — capture starts disarmed.
capture.Record(100, 0x1234, 0, 1234, CaptureDirection.Read);
capture.IsArmed.ShouldBeFalse();
var slot = capture.Snapshot().ShouldHaveSingleItem();
slot.UpdatedAtUtc.ShouldBeNull();
}
[Fact]
public void Armed_Record_UpdatesMatchingSlot()
{
var capture = Make((100, 16));
capture.Arm();
capture.Record(100, 0x1234, 0, 1234, CaptureDirection.Read);
var slot = capture.Snapshot().ShouldHaveSingleItem();
slot.Address.ShouldBe((ushort)100);
slot.Width.ShouldBe((byte)16);
slot.RawLow.ShouldBe((ushort)0x1234);
slot.DecodedValue.ShouldBe(1234);
slot.Direction.ShouldBe(CaptureDirection.Read);
slot.UpdatedAtUtc.ShouldNotBeNull();
}
[Fact]
public void Armed_Record_UnknownAddress_IsIgnored()
{
var capture = Make((100, 16));
capture.Arm();
capture.Record(999, 0x1111, 0, 1111, CaptureDirection.Read);
capture.Snapshot().ShouldAllBe(s => s.UpdatedAtUtc == null);
}
[Fact]
public void Disarm_ClearsAllSlots()
{
var capture = Make((100, 16), (200, 16));
capture.Arm();
capture.Record(100, 0x0042, 0, 42, CaptureDirection.Read);
capture.Record(200, 0x0099, 0, 99, CaptureDirection.Read);
capture.Disarm();
capture.IsArmed.ShouldBeFalse();
capture.Snapshot().ShouldAllBe(s => s.UpdatedAtUtc == null);
}
[Fact]
public void ReArm_AfterDisarm_StartsEmpty()
{
var capture = Make((100, 16));
capture.Arm();
capture.Record(100, 0x0042, 0, 42, CaptureDirection.Read);
capture.Disarm();
capture.Arm();
// No new traffic since re-arm — slot must read as empty, not the pre-disarm value.
capture.Snapshot().ShouldHaveSingleItem().UpdatedAtUtc.ShouldBeNull();
}
[Fact]
public void ThirtyTwoBitTag_RecordsBothRawWords()
{
var capture = Make((100, 32));
capture.Arm();
capture.Record(100, 0x5678, 0x1234, 12345678, CaptureDirection.Read);
var slot = capture.Snapshot().ShouldHaveSingleItem();
slot.Width.ShouldBe((byte)32);
slot.RawLow.ShouldBe((ushort)0x5678);
slot.RawHigh.ShouldBe((ushort)0x1234);
slot.DecodedValue.ShouldBe(12345678);
}
[Fact]
public void Snapshot_ReturnsOneRowPerTag_OrderedByAddress()
{
var capture = Make((300, 16), (100, 32), (200, 16));
capture.TagCount.ShouldBe(3);
var snap = capture.Snapshot();
snap.Select(s => s.Address).ShouldBe([(ushort)100, (ushort)200, (ushort)300]);
}
[Fact]
public void WriteDirection_IsPreserved()
{
var capture = Make((100, 16));
capture.Arm();
capture.Record(100, 0x0500, 0, 500, CaptureDirection.Write);
capture.Snapshot().ShouldHaveSingleItem().Direction.ShouldBe(CaptureDirection.Write);
}
[Fact]
public async Task ConcurrentRecordAndSnapshot_NeverYieldsTornSlot()
{
// Invariant maintained by every Record: DecodedValue == RawLow + RawHigh.
// A torn read (fields from two different Record calls) would break it.
var capture = Make((100, 32));
capture.Arm();
var ct = TestContext.Current.CancellationToken;
bool tornObserved = false;
var writers = Enumerable.Range(0, 4).Select(seed => Task.Run(() =>
{
var rng = new Random(seed + 1);
for (int i = 0; i < 200_000; i++)
{
ushort lo = (ushort)rng.Next(0, 60000);
ushort hi = (ushort)rng.Next(0, 5000);
capture.Record(100, lo, hi, lo + hi, CaptureDirection.Read);
}
}, ct)).ToArray();
var reader = Task.Run(() =>
{
for (int i = 0; i < 200_000; i++)
{
foreach (var slot in capture.Snapshot())
{
if (slot.UpdatedAtUtc is null)
continue;
if (slot.DecodedValue != slot.RawLow + slot.RawHigh)
tornObserved = true;
}
}
}, ct);
await Task.WhenAll([.. writers, reader]);
tornObserved.ShouldBeFalse("Snapshot must never observe a torn (half-updated) slot");
}
}