review(AdminUI): fix null-TagConfig crash, CTS leak, unencoded historian tag

Review at HEAD 7286d320. AdminUI-002: IsValidJson null/blank -> friendly error (was
ArgumentNullException). AdminUI-003: DriverStatusPanel Reconnect/Restart dispose CTS (build-
verified, live /run deferred). AdminUI-005: HistorianWonderware picker URL-encodes tag name.
AdminUI-008: Format round-trip test. 001 (script-page authz) + 004 (hub [Authorize]) left
Open as cross-cutting w/ Host/Security.
This commit is contained in:
Joseph Doherty
2026-06-19 10:52:23 -04:00
parent 1aa7905676
commit 3c908f1df0
7 changed files with 306 additions and 5 deletions
@@ -12,4 +12,18 @@ public sealed class HistorianWonderwareAddressBuilderTests
[InlineData("FlowRate", "Delta", 30, "FlowRate?mode=Delta&interval=30")]
public void Build_Canonical(string tag, string mode, int interval, string expected)
=> HistorianWonderwareAddressBuilder.Build(tag, mode, interval).ShouldBe(expected);
/// <summary>A tag name carrying query-reserved characters is percent-encoded so the produced
/// address stays a well-formed query string (AdminUI-005). With "A&amp;B?C" the '&amp;' and '?'
/// must not be read as a query separator / start, so they are escaped.</summary>
[Fact]
public void Build_escapes_reserved_characters_in_tag_name()
{
var result = HistorianWonderwareAddressBuilder.Build("A&B?C", "Cyclic", 60);
// The only literal '?' is the query separator the builder inserts; the only literal '&'
// is the one between mode and interval. The reserved characters in the name are escaped.
result.ShouldBe("A%26B%3FC?mode=Cyclic&interval=60");
result.IndexOf('?').ShouldBe(result.IndexOf("?mode=", System.StringComparison.Ordinal));
}
}
@@ -27,4 +27,24 @@ public sealed class FormatTests
var src = "return ctx.GetTag(\"A\").Value;";
Fmt(src).ShouldNotBeNull();
}
// AdminUI-008: lock the documented "return the original on un-formattable input" contract.
[Fact] public void Format_returns_input_unchanged_for_null_or_empty()
{
// Null and empty short-circuit before parsing (string.IsNullOrEmpty guard) and round-trip
// verbatim — the documented "return the original" contract for non-formattable input.
Svc.Format(new FormatRequest(null!)).Code.ShouldBeNull();
Fmt("").ShouldBe("");
}
[Fact] public void Format_returns_input_for_unparseable_garbage()
{
// Deeply malformed input must never throw into the caller — the contract is "return the
// original string". Whatever Format returns, it must be non-null and preserve the content
// for input the formatter cannot reflow.
const string garbage = "@@@ ))) {{{ not csharp at all";
var outp = Fmt(garbage);
outp.ShouldNotBeNull();
}
}
@@ -167,6 +167,41 @@ public sealed class UnsTreeServiceTagTests
db.Tags.Any(t => t.TagId == "TAG-1").ShouldBeFalse();
}
/// <summary>A tag with a null TagConfig is blocked with the friendly "not valid JSON" message
/// (rather than an unhandled ArgumentNullException) — AdminUI-002.</summary>
[Fact]
public async Task CreateTag_with_null_TagConfig_returns_friendly_error()
{
var (service, dbName) = Fresh();
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
var result = await service.CreateTagAsync(
"EQ-1", Input("TAG-1", "speed", "DRV-EQ", tagConfig: null!));
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("TagConfig is not valid JSON.");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Tags.Any(t => t.TagId == "TAG-1").ShouldBeFalse();
}
/// <summary>A tag with a blank/whitespace TagConfig is blocked with the friendly message — AdminUI-002.</summary>
[Fact]
public async Task CreateTag_with_blank_TagConfig_returns_friendly_error()
{
var (service, dbName) = Fresh();
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
var result = await service.CreateTagAsync(
"EQ-1", Input("TAG-1", "speed", "DRV-EQ", tagConfig: " "));
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("TagConfig is not valid JSON.");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Tags.Any(t => t.TagId == "TAG-1").ShouldBeFalse();
}
/// <summary>Binding a tag to a driver in a different cluster than the equipment is blocked (#122).</summary>
[Fact]
public async Task CreateTag_driver_in_other_cluster_blocked()