fix(adminui): never slice a DB-sourced string with the range operator (#504)
`Scripts.razor` rendered a hash prefix as `@s.SourceHash[..12]…`. `SourceHash`
is a plain nvarchar(64) with no length floor, so any value shorter than 12
characters threw ArgumentOutOfRangeException out of BuildRenderTree — unhandled
in Blazor, which takes the WHOLE page to an HTTP 500, not just the offending
row. Live-reproduced on docker-dev, where two Script rows carry `h1` and
`h-abs-hr200`: /scripts was a hard 500.
New `Components/Shared/DisplayText.Abbreviate(string?, int)`:
- null/blank renders the Admin UI's em-dash placeholder rather than throwing;
- a value already within budget renders verbatim with NO ellipsis, so the
display never implies text that isn't there (the old markup appended "…"
unconditionally, outside the slice — `h1` would have shown as `h1…`);
- a maxLength < 1 is clamped, so a display bug cannot become a page crash.
Applied to every unguarded range-operator slice over a DB-sourced string found
by sweeping the AdminUI for `[..N]`:
Scripts.razor SourceHash (the reported crash)
Certificates.razor Thumbprint (read off the on-disk store)
Deployments.razor x2 RevisionHash
Clusters/ClusterOverview.razor RevisionHash
The other `[..N]` hits are safe and untouched: Guid.ToString("N") slices
(always 32 chars) and the `Length > 60 ? [..60]` ternaries that self-guard.
NOTE — the issue text blamed the docker-dev seed; that was wrong and is
corrected on #504. `seed-clusters.sql` inserts only ServerCluster, ClusterNode
and LdapGroupRoleMapping. The short-hash rows came from earlier live-gate
authoring. A freshly seeded rig is fine; the trigger is any Script row that did
not go through ScriptEdit / UnsTreeService.HashSource — REST-API authored,
hand-inserted, or migrated in.
Tests: 14 new in DisplayTextTests, covering the short/null/blank/clamped cases
and a never-throws sweep over every value x budget combination. AdminUI suite
739/739.
Live-verified on docker-dev (AdminUI has no bUnit): /scripts 500 -> 200 across
both central nodes, rendering `hash=h1` and `hash=h-abs-hr200` in full;
/deployments still truncates 64-char hashes at 12 with the ellipsis;
/certificates and /clusters/MAIN unchanged.
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #504 — <see cref="DisplayText.Abbreviate"/>, the null/short-safe replacement for the
|
||||
/// <c>@value[..N]</c> range-operator slices the Admin UI used to abbreviate hashes and thumbprints.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The bug being pinned: a range operator over an unvalidated DB string throws
|
||||
/// <see cref="System.ArgumentOutOfRangeException"/> out of <c>BuildRenderTree</c>, which is unhandled
|
||||
/// in Blazor and takes the WHOLE page to an HTTP 500 — not just the offending row. Nothing enforces a
|
||||
/// minimum length at the schema, entity, or render layer, so the short-input cases below are the ones
|
||||
/// that matter; the happy path is almost incidental.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DisplayTextTests
|
||||
{
|
||||
/// <summary>A value longer than the budget is truncated and marked with an ellipsis — the normal
|
||||
/// case for a 64-char SHA-256.</summary>
|
||||
[Fact]
|
||||
public void A_long_value_is_truncated_and_marked()
|
||||
{
|
||||
DisplayText.Abbreviate(new string('a', 64), 12).ShouldBe(new string('a', 12) + "…");
|
||||
}
|
||||
|
||||
/// <summary>The regression: a value SHORTER than the budget must render, not throw. This is exactly
|
||||
/// what a hand-inserted / API-authored <c>SourceHash</c> like <c>h1</c> hits.</summary>
|
||||
[Theory]
|
||||
[InlineData("h1", 12)]
|
||||
[InlineData("h-abs-hr200", 12)] // 11 chars — one short of the old slice, the original crash
|
||||
[InlineData("abc", 16)]
|
||||
[InlineData("x", 64)]
|
||||
public void A_value_shorter_than_the_budget_renders_verbatim(string value, int maxLength)
|
||||
{
|
||||
DisplayText.Abbreviate(value, maxLength).ShouldBe(value);
|
||||
}
|
||||
|
||||
/// <summary>No ellipsis when nothing was dropped — the display must never imply text that is not
|
||||
/// there. (The old markup appended "…" unconditionally, outside the slice.)</summary>
|
||||
[Fact]
|
||||
public void A_short_value_gets_no_ellipsis()
|
||||
{
|
||||
DisplayText.Abbreviate("h1", 12).ShouldNotContain("…");
|
||||
}
|
||||
|
||||
/// <summary>A value exactly at the budget is the boundary — it fits, so it is neither truncated nor
|
||||
/// marked.</summary>
|
||||
[Fact]
|
||||
public void A_value_exactly_at_the_budget_is_untouched()
|
||||
{
|
||||
DisplayText.Abbreviate("123456789012", 12).ShouldBe("123456789012");
|
||||
}
|
||||
|
||||
/// <summary>Null / blank render as the Admin UI's em-dash placeholder rather than throwing or
|
||||
/// producing a stray ellipsis.</summary>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void A_null_or_blank_value_renders_the_placeholder(string? value)
|
||||
{
|
||||
DisplayText.Abbreviate(value, 12).ShouldBe(DisplayText.Empty);
|
||||
}
|
||||
|
||||
/// <summary>A nonsensical budget is clamped rather than allowed to throw — a display bug must not
|
||||
/// become a page crash, which is the whole point of this helper.</summary>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(int.MinValue)]
|
||||
public void A_non_positive_budget_is_clamped_not_thrown(int maxLength)
|
||||
{
|
||||
DisplayText.Abbreviate("abcdef", maxLength).ShouldBe("a…");
|
||||
}
|
||||
|
||||
/// <summary>Whatever the inputs, it never throws — the property that keeps a bad row from killing a
|
||||
/// page. Includes the shapes that broke the old code.</summary>
|
||||
[Fact]
|
||||
public void It_never_throws_for_any_combination()
|
||||
{
|
||||
string?[] values = [null, "", " ", "h1", "h-abs-hr200", new string('f', 64), "…"];
|
||||
int[] budgets = [int.MinValue, -1, 0, 1, 12, 16, 64, int.MaxValue];
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
Should.NotThrow(() => DisplayText.Abbreviate(value, budget));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user