mbproxy: fix dashboard review findings, add named BCD tags + fleet config
Reviewed the new SignalR dashboard and fixed its two top findings: a stored XSS on the connection-detail page (unescaped tag name / direction / timestamp rendered into innerHTML) and FC03/FC04 cache hits bypassing the debug-view capture, which left cached tags frozen while their age climbed. Also adds an optional human-friendly Name to BCD tags surfaced on the debug view, and loads the real fleet config from tags.txt (12 named BCD tags, PLC Z28061) so the published appsettings.json is deploy-ready. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,29 @@ public sealed class BcdTagMapBuilderTests
|
||||
t32.Width.ShouldBe((byte)32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_CarriesOptionalTagName_IntoResolvedMap()
|
||||
{
|
||||
// The optional human-friendly Name flows from config options through to the
|
||||
// resolved BcdTag; an omitted Name resolves to null.
|
||||
var global = new BcdTagListOptions
|
||||
{
|
||||
Global =
|
||||
[
|
||||
new BcdTagOptions { Address = 1548, Width = 16, Name = "Left AirSP" },
|
||||
new BcdTagOptions { Address = 1080, Width = 32 },
|
||||
],
|
||||
};
|
||||
|
||||
var result = BcdTagMapBuilder.Build(global, perPlc: null);
|
||||
|
||||
result.Errors.ShouldBeEmpty();
|
||||
result.Map.TryGet(1548, out var named).ShouldBeTrue();
|
||||
named.Name.ShouldBe("Left AirSP");
|
||||
result.Map.TryGet(1080, out var unnamed).ShouldBeTrue();
|
||||
unnamed.Name.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_PerPlcAdd_AppendsToGlobal()
|
||||
{
|
||||
|
||||
@@ -45,6 +45,7 @@ public sealed class MbproxyOptionsBindingTests
|
||||
{
|
||||
["Mbproxy:BcdTags:Global:0:Address"] = "1072",
|
||||
["Mbproxy:BcdTags:Global:0:Width"] = "16",
|
||||
["Mbproxy:BcdTags:Global:0:Name"] = "Left AirSP",
|
||||
["Mbproxy:BcdTags:Global:1:Address"] = "1080",
|
||||
["Mbproxy:BcdTags:Global:1:Width"] = "32",
|
||||
});
|
||||
@@ -52,8 +53,10 @@ public sealed class MbproxyOptionsBindingTests
|
||||
options.BcdTags.Global.Count.ShouldBe(2);
|
||||
options.BcdTags.Global[0].Address.ShouldBe((ushort)1072);
|
||||
options.BcdTags.Global[0].Width.ShouldBe((byte)16);
|
||||
options.BcdTags.Global[0].Name.ShouldBe("Left AirSP");
|
||||
options.BcdTags.Global[1].Address.ShouldBe((ushort)1080);
|
||||
options.BcdTags.Global[1].Width.ShouldBe((byte)32);
|
||||
options.BcdTags.Global[1].Name.ShouldBeNull("Name is optional — an omitted entry binds to null");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -542,4 +542,75 @@ public sealed class ResponseCacheMultiplexerTests
|
||||
l.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CacheHit_RecordsServedRead_IntoArmedDebugCapture()
|
||||
{
|
||||
// C3 regression guard: a cache hit bypasses the BCD pipeline, so without the
|
||||
// cache-entry observation replay the connection-detail debug view would freeze
|
||||
// for the whole TTL on a cached tag. A hit must re-record the served read into
|
||||
// the (armed) capture so the debug view reflects what the client receives.
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new StubBackend(backendPort) { RegisterValue = 0x1234 };
|
||||
|
||||
using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
|
||||
var tag = BcdTag.Create(100, 16, cacheTtlMs: 5000);
|
||||
|
||||
// A detail-page viewer has armed this PLC's debug-view capture.
|
||||
var capture = new TagValueCapture([tag]);
|
||||
capture.Arm();
|
||||
|
||||
var frozen = new[] { tag }.ToDictionary(t => t.Address).ToFrozenDictionary();
|
||||
var ctx = new PerPlcContext
|
||||
{
|
||||
PlcName = "PLC1",
|
||||
TagMap = new BcdTagMap(frozen),
|
||||
Counters = new ProxyCounters(),
|
||||
Logger = NullLogger.Instance,
|
||||
Cache = cache,
|
||||
Capture = capture,
|
||||
};
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = BuildMux(plc, ctx);
|
||||
|
||||
var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
// First read — cache miss. The pipeline records the observation and the
|
||||
// entry is stored with the per-tag observations attached.
|
||||
await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
|
||||
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
|
||||
|
||||
var afterMiss = capture.Snapshot().Single(o => o.Address == 100);
|
||||
afterMiss.UpdatedAtUtc.ShouldNotBeNull("the cache-miss read must record an observation");
|
||||
afterMiss.DecodedValue.ShouldBe(1234);
|
||||
afterMiss.RawLow.ShouldBe((ushort)0x1234);
|
||||
|
||||
// Clear the capture's slots (models the debug view holding no fresh data),
|
||||
// then re-arm. Only the cache-hit replay can repopulate slot 100 now — the
|
||||
// backend is not contacted again.
|
||||
capture.Disarm();
|
||||
capture.Arm();
|
||||
capture.Snapshot().Single(o => o.Address == 100).UpdatedAtUtc
|
||||
.ShouldBeNull("Disarm must clear the slot");
|
||||
|
||||
// Second read — cache hit. No backend round-trip; the pipeline is bypassed.
|
||||
await c.SendAsync(BuildFc03(0x0002, 100, 1), SocketFlags.None);
|
||||
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
|
||||
backend.RequestCount.ShouldBe(1, "the second read must be served from the cache");
|
||||
|
||||
var afterHit = capture.Snapshot().Single(o => o.Address == 100);
|
||||
afterHit.UpdatedAtUtc.ShouldNotBeNull(
|
||||
"a cache hit must re-record the served read so the debug view does not freeze");
|
||||
afterHit.DecodedValue.ShouldBe(1234, "the replayed observation carries the decoded value");
|
||||
afterHit.RawLow.ShouldBe((ushort)0x1234, "the replayed observation carries the raw BCD nibbles");
|
||||
afterHit.Direction.ShouldBe(CaptureDirection.Read);
|
||||
}
|
||||
finally
|
||||
{
|
||||
c.Dispose();
|
||||
await p.DisposeAsync();
|
||||
l.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,27 @@ public sealed class TagValueCaptureTests
|
||||
slot.UpdatedAtUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_CarriesTagName_FromConfiguredTag()
|
||||
{
|
||||
// The capture surfaces a BCD tag's optional friendly name on every
|
||||
// observation — placeholder rows (no traffic) and recorded values alike —
|
||||
// so the debug view can label rows. An unnamed tag surfaces a null Name.
|
||||
var capture = new TagValueCapture(
|
||||
[
|
||||
BcdTag.Create(100, 16, name: "Left AirSP"),
|
||||
BcdTag.Create(200, 32),
|
||||
]);
|
||||
|
||||
var before = capture.Snapshot();
|
||||
before.Single(o => o.Address == 100).Name.ShouldBe("Left AirSP");
|
||||
before.Single(o => o.Address == 200).Name.ShouldBeNull();
|
||||
|
||||
capture.Arm();
|
||||
capture.Record(100, 0x1234, 0, 1234, CaptureDirection.Read);
|
||||
capture.Snapshot().Single(o => o.Address == 100).Name.ShouldBe("Left AirSP");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Armed_Record_UnknownAddress_IsIgnored()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user