fix(mqtt): runtime mappers no longer refuse leading-whitespace TagConfig — JsonDocument.Parse + ValueKind is the single framing authority (#559)
FromTagConfig and FromSparkplugTagConfig each carried a framing pre-check
(`tagConfig[0] != '{'`) evaluated on the UNTRIMMED string, so a TagConfig blob
with leading whitespace was refused by MQTT alone and the tag read
BadNodeIdUnknown forever — while the other six structural-parse drivers
(Modbus, S7, AbCip, AbLegacy, TwinCAT, FOCAS) hand the identical blob to
JsonDocument.Parse, which skips leading whitespace per the JSON spec.
The clause is dropped (the blank guard stays). JsonDocument.Parse plus the
root-ValueKind check now decide framing, exactly as for the six siblings: an
array/number/string root fails ValueKind, non-JSON is a JsonException. The gate
was a validator, not a discriminator — no caller (MqttSubscriptionManager,
SparkplugIngestor, MqttTagConfigModel, Inspect) used the first byte to pick a
format — so verdicts are identical for every input except whitespace-padded
JSON objects, which now get their content's verdict.
Inspect needs no logic change (it has trimmed since #556 and hands the RAW
reference to the mappers), but its comments claimed the padded-blob refusal was
production-reachable; rewritten. That shape was also MQTT's only vehicle for the
catch-all backstop, which is now unreachable-today like FOCAS's — so its test is
replaced by the same count-relation pin, and the backstop code stays as
defence-in-depth.
Tests: both mappers pinned by padded-vs-unpadded DEFINITION equality (a record,
so field-wise), padded non-object/malformed negative controls, an Inspect
verdict+inspection parity theory over the accepted AND rejected corpus with an
anti-vacuity control, and a padded MQTT corpus entry in the AdminUI
authoring/runtime parity suite.
This commit is contained in:
@@ -49,7 +49,10 @@ public static class MqttTagDefinitionFactory
|
||||
|
||||
/// <summary>
|
||||
/// Maps an authored <c>TagConfig</c> object to a typed definition keyed by <paramref name="rawPath"/>.
|
||||
/// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3).
|
||||
/// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3),
|
||||
/// and framing is decided solely by <c>JsonDocument.Parse</c> plus the
|
||||
/// root-<see cref="JsonValueKind.Object"/> check — so leading whitespace is skipped exactly as the JSON
|
||||
/// spec says, and a padded blob reaches the same verdict its unpadded form does (Gitea #559).
|
||||
/// Enum fields (<c>payloadFormat</c> / <c>dataType</c>) and <c>qos</c> are read STRICTLY — a
|
||||
/// present-but-invalid (typo'd) value rejects the whole tag (returns <see langword="false"/> ⇒ the
|
||||
/// driver surfaces <c>BadNodeIdUnknown</c>) rather than silently defaulting to a wrong-typed Good or
|
||||
@@ -76,7 +79,14 @@ public static class MqttTagDefinitionFactory
|
||||
public static bool FromTagConfig(string tagConfig, string rawPath, out MqttTagDefinition def)
|
||||
{
|
||||
def = null!;
|
||||
if (string.IsNullOrWhiteSpace(tagConfig) || tagConfig[0] != '{') return false;
|
||||
// Gitea #559: the blank guard only — NOT a `tagConfig[0] != '{'` framing check. That check ran on
|
||||
// the UNTRIMMED string, so a blob carrying leading whitespace (which a CSV or API authoring path can
|
||||
// introduce invisibly, and which no author ever means as semantic) was refused here while the six
|
||||
// sibling factories' mappers handed the identical blob to JsonDocument.Parse, which skips leading
|
||||
// whitespace per the JSON spec. JsonDocument.Parse + the ValueKind check below are the single framing
|
||||
// authority now, exactly as they are for every other driver: an array/number/string root fails the
|
||||
// ValueKind check, and anything unparseable is caught as a JsonException.
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
@@ -129,7 +139,9 @@ public static class MqttTagDefinitionFactory
|
||||
/// <c>(groupId, edgeNodeId, deviceId?, metricName)</c> tuple; <c>deviceId</c> is optional (absent
|
||||
/// means the metric is published by the edge node itself), and there is <b>no</b> <c>topic</c> —
|
||||
/// a Sparkplug driver subscribes one group-wide filter and routes by the decoded topic + birth
|
||||
/// certificate, never by a per-tag topic. Never throws.
|
||||
/// certificate, never by a per-tag topic. Never throws. Framing is
|
||||
/// <c>JsonDocument.Parse</c> plus the root-object check, so leading
|
||||
/// whitespace does not change the verdict (Gitea #559) — see <see cref="FromTagConfig"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -175,7 +187,10 @@ public static class MqttTagDefinitionFactory
|
||||
public static bool FromSparkplugTagConfig(string tagConfig, string rawPath, out MqttTagDefinition def)
|
||||
{
|
||||
def = null!;
|
||||
if (string.IsNullOrWhiteSpace(tagConfig) || tagConfig[0] != '{') return false;
|
||||
// Gitea #559: the blank guard only — see the identical note on FromTagConfig. JsonDocument.Parse +
|
||||
// the ValueKind check are the single framing authority; a leading-whitespace blob reaches the same
|
||||
// verdict its unpadded form does, which is what every sibling factory has always done.
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
@@ -261,9 +276,12 @@ public static class MqttTagDefinitionFactory
|
||||
/// The two classes are kept in <b>separate lists</b> (Gitea #556 item 2), and the catch-all
|
||||
/// backstop fires on "no REJECTION was produced", never on "no warning at all" — otherwise an
|
||||
/// advisory would silently swallow the only message that reports a refusal nothing can name.
|
||||
/// For MQTT that shape is reachable in production: a leading-whitespace blob is refused by both
|
||||
/// mappers' own framing gates while every rejection branch finds the parsed document perfectly
|
||||
/// well-formed.
|
||||
/// ⚠️ Since Gitea #559 removed the mappers' own framing gate, that shape has <b>no</b> known
|
||||
/// vehicle: the leading-whitespace blob that used to reach it is now accepted or refused on its
|
||||
/// content alone, and every remaining <c>return false</c> in either mapper has a matching
|
||||
/// <see cref="DescribeRejection"/> branch. The backstop stays as defence-in-depth for the next
|
||||
/// refusal cause someone adds without one — the same posture as the FOCAS inspector's, and pinned
|
||||
/// the same way (a count relation, not a constructed vehicle).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -280,18 +298,17 @@ public static class MqttTagDefinitionFactory
|
||||
// Gitea #556 item 2: the two classes live in SEPARATE lists, and the backstop below gates on the
|
||||
// REJECTION list alone. Sharing one list meant an advisory (which fires on an accepted blob too)
|
||||
// could satisfy a `warnings.Count == 0` guard and silently swallow the catch-all rejection message
|
||||
// — the only thing that reports a refusal neither DescribeRejection branch can name, which for
|
||||
// MQTT is a REACHABLE shape, not a theoretical one: see the leading-whitespace note below.
|
||||
// — the only thing that reports a refusal neither DescribeRejection branch can name. That shape has
|
||||
// no vehicle today (see the summary), so this is the structural guarantee, not a live fix.
|
||||
var rejections = new List<string>();
|
||||
var advisories = new List<string>();
|
||||
|
||||
// Gitea #556 item 1: the framing check TRIMS FIRST, so a blob whose only sin is leading whitespace
|
||||
// is no longer waved through in silence. MQTT is the one factory of the seven whose RUNTIME
|
||||
// mappers carry the same `[0] != '{'` gate, so — unlike its siblings, whose mappers hand the blob
|
||||
// to a whitespace-tolerant JsonDocument.Parse — " {…}" is genuinely REFUSED by this driver. Which
|
||||
// is exactly why the untrimmed check was a defect: the tag read BadNodeIdUnknown forever and the
|
||||
// deploy gate said nothing. A reference that still does not start with '{' after trimming is not
|
||||
// an equipment-tag TagConfig at all and keeps drawing nothing.
|
||||
// The framing pre-filter is purely an is-this-even-a-TagConfig filter, exactly as it is for the six
|
||||
// sibling factories: a reference that is not object-framed is a bare name or some other artefact,
|
||||
// not a TagConfig this inspector has anything to say about, so it draws nothing. It TRIMS FIRST
|
||||
// (Gitea #556 item 1) and the mappers no longer carry a framing gate of their own at all (Gitea
|
||||
// #559 — JsonDocument.Parse skips leading whitespace, so " {…}" is served), which means this filter
|
||||
// and the mappers agree on padded blobs instead of the filter being the laxer of the two.
|
||||
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{')
|
||||
return rejections;
|
||||
|
||||
|
||||
@@ -111,10 +111,12 @@ public sealed class FocasInspectAcceptGateTests
|
||||
/// <see cref="FocasTagDefinitionFactory.FromTagConfig"/> — absent / non-string / blank
|
||||
/// <c>address</c>, invalid <c>dataType</c> — has a matching describe branch, and
|
||||
/// <c>TryReadEnumStrict</c> and <c>DescribeInvalidEnum</c> share one classifier so they cannot
|
||||
/// disagree. So the guard is protective here rather than a live fix; MQTT's is live (a padded
|
||||
/// blob its own mappers refuse and every branch finds well-formed — see
|
||||
/// <c>MqttInspectAcceptGateTests</c>). What this pins is the property that must survive the
|
||||
/// next rejection cause someone adds without a describe branch.
|
||||
/// disagree. So the guard is protective here rather than a live fix. It is now protective
|
||||
/// fleet-wide: MQTT's used to be live — a padded blob its own mappers refused and every branch
|
||||
/// found well-formed — until Gitea #559 removed those mappers' framing gates, since the refusal
|
||||
/// was itself the defect. <c>MqttInspectAcceptGateTests</c> pins the same count relation.
|
||||
/// What this pins is the property that must survive the next rejection cause someone adds
|
||||
/// without a describe branch.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
|
||||
@@ -25,7 +25,7 @@ public sealed class MqttInspectAcceptGateTests
|
||||
|| MqttTagDefinitionFactory.FromSparkplugTagConfig(tagConfig, RawPath, out _);
|
||||
|
||||
/// <summary>Blobs at least one mapper ACCEPTS, with no wildcard advisory — the gate must be silent.</summary>
|
||||
public static TheoryData<string> Accepted() =>
|
||||
private static readonly string[] AcceptedBlobs =
|
||||
[
|
||||
"""{"topic":"plant/line1/temp"}""",
|
||||
"""{"topic":"plant/line1/temp","payloadFormat":"Json","dataType":"Float64","qos":1}""",
|
||||
@@ -35,7 +35,7 @@ public sealed class MqttInspectAcceptGateTests
|
||||
];
|
||||
|
||||
/// <summary>Blobs NEITHER mapper accepts — the gate must produce at least one warning for each.</summary>
|
||||
public static TheoryData<string> Rejected() =>
|
||||
private static readonly string[] RejectedBlobs =
|
||||
[
|
||||
"{}", // no address of either shape
|
||||
"""{"payloadFormat":"Json","dataType":"String"}""", // still no address
|
||||
@@ -46,6 +46,14 @@ public sealed class MqttInspectAcceptGateTests
|
||||
"""{"groupId":"G","edgeNodeId":"E","metricName":"M","qos":"high"}""", // non-numeric qos, both modes
|
||||
];
|
||||
|
||||
/// <summary>Blobs at least one mapper ACCEPTS, with no wildcard advisory — the gate must be silent.</summary>
|
||||
/// <returns>The accepted fixtures.</returns>
|
||||
public static TheoryData<string> Accepted() => [.. AcceptedBlobs];
|
||||
|
||||
/// <summary>Blobs NEITHER mapper accepts — the gate must produce at least one warning for each.</summary>
|
||||
/// <returns>The rejected fixtures.</returns>
|
||||
public static TheoryData<string> Rejected() => [.. RejectedBlobs];
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Accepted))]
|
||||
public void A_config_a_mapper_accepts_draws_no_warning(string tagConfig)
|
||||
@@ -86,28 +94,56 @@ public sealed class MqttInspectAcceptGateTests
|
||||
public void Unparseable_json_names_the_json()
|
||||
=> MqttTagDefinitionFactory.Inspect("{oops").ShouldContain(w => w.Contains("not valid JSON"));
|
||||
|
||||
// ---- Gitea #556 item 1: leading whitespace no longer defeats the framing pre-filter ----
|
||||
// ---- Gitea #559: leading whitespace no longer changes ANY verdict (was #556 item 1) ----
|
||||
|
||||
/// <summary>Padding a blob with leading whitespace.</summary>
|
||||
private const string Pad = " \t\r\n ";
|
||||
|
||||
/// <summary>Every fixture above — accepted and rejected alike — so the padding claim is checked on both.</summary>
|
||||
/// <returns>The full fixture corpus.</returns>
|
||||
public static TheoryData<string> AcceptedAndRejected() => [.. AcceptedBlobs, .. RejectedBlobs];
|
||||
|
||||
/// <summary>
|
||||
/// MQTT is the one factory of the seven whose RUNTIME mappers carry the same <c>[0] != '{'</c>
|
||||
/// framing gate the inspector did, so a padded blob is genuinely REFUSED by this driver — which is
|
||||
/// precisely why the untrimmed pre-filter was a defect rather than a cosmetic mismatch: the tag
|
||||
/// read <c>BadNodeIdUnknown</c> forever and the deploy gate said nothing at all. Its siblings hand
|
||||
/// the blob to a whitespace-tolerant <c>JsonDocument.Parse</c> and so keep accepting it; both
|
||||
/// behaviours are correct, because each inspector mirrors its own mapper.
|
||||
/// Gitea #559. MQTT used to be the one factory of the seven whose RUNTIME mappers carried their own
|
||||
/// <c>[0] != '{'</c> framing gate — run on the UNTRIMMED string — so a blob whose only sin was
|
||||
/// leading whitespace was refused by this driver alone and read <c>BadNodeIdUnknown</c> forever,
|
||||
/// while the identical blob was served by Modbus, S7, AbCip, AbLegacy, TwinCAT and FOCAS (whose
|
||||
/// mappers hand it to a whitespace-tolerant <c>JsonDocument.Parse</c>). The mappers now do the same,
|
||||
/// so the pad is inert everywhere: the verdict AND the inspection are whatever the unpadded blob's
|
||||
/// content earns.
|
||||
/// <para>
|
||||
/// Asserting <b>parity with the unpadded fixture</b> rather than hardcoding "accepted" is the
|
||||
/// stronger form — it holds for the rejected fixtures too, and it goes red if a future change
|
||||
/// makes padding matter in either direction.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">A fixture that is accepted unpadded.</param>
|
||||
/// <param name="tagConfig">A fixture, padded below.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(Accepted))]
|
||||
public void A_padded_config_the_readers_refuse_is_no_longer_passed_over_in_silence(string tagConfig)
|
||||
[MemberData(nameof(AcceptedAndRejected))]
|
||||
public void Leading_whitespace_changes_neither_the_verdict_nor_the_inspection(string tagConfig)
|
||||
{
|
||||
var padded = Pad + tagConfig;
|
||||
EitherModeAccepts(tagConfig).ShouldBeTrue("the unpadded fixture must be accepted");
|
||||
EitherModeAccepts(padded).ShouldBeFalse("both MQTT readers carry their own framing gate");
|
||||
MqttTagDefinitionFactory.Inspect(padded).ShouldNotBeEmpty();
|
||||
|
||||
EitherModeAccepts(padded).ShouldBe(
|
||||
EitherModeAccepts(tagConfig),
|
||||
$"leading whitespace must not change the union verdict. Blob: {tagConfig}");
|
||||
MqttTagDefinitionFactory.Inspect(padded).ShouldBe(
|
||||
MqttTagDefinitionFactory.Inspect(tagConfig),
|
||||
$"leading whitespace must not change the inspection. Blob: {tagConfig}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The anti-vacuity control for the theory above: the corpus it runs over must contain both verdicts,
|
||||
/// or "padded == unpadded" could hold across a corpus that is uniformly refused (which is exactly what
|
||||
/// the pre-#559 code did — every padded blob was refused, so a parity claim stated only over the
|
||||
/// accepted fixtures would have been the thing that changed).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_padding_corpus_carries_both_verdicts()
|
||||
{
|
||||
string[] blobs = [.. AcceptedBlobs, .. RejectedBlobs];
|
||||
blobs.ShouldContain(b => EitherModeAccepts(Pad + b), "no padded fixture is ACCEPTED");
|
||||
blobs.ShouldContain(b => !EitherModeAccepts(Pad + b), "no padded fixture is REFUSED");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -130,25 +166,42 @@ public sealed class MqttInspectAcceptGateTests
|
||||
// ---- Gitea #556 item 2: an advisory can no longer swallow the catch-all rejection ----
|
||||
|
||||
/// <summary>
|
||||
/// The advisory-plus-unnameable-rejection shape, and for MQTT it is REACHABLE in production rather
|
||||
/// than theoretical: a padded blob is refused by both readers' own framing gates, while every
|
||||
/// <c>DescribeRejection</c> branch — which walks the PARSED document — finds it perfectly
|
||||
/// well-formed and names nothing. The wildcard advisory fires regardless. Before #556 the backstop
|
||||
/// was gated on <c>warnings.Count == 0</c>, so that advisory satisfied the guard and the operator
|
||||
/// was told only that the topic had a wildcard — never that the tag would not resolve at all.
|
||||
/// The structural invariant the backstop exists to hold: a blob the readers REFUSE always draws
|
||||
/// strictly more than the advisories that same blob would draw if it were accepted. Before #556 the
|
||||
/// backstop was gated on <c>warnings.Count == 0</c> — and an advisory fires on an ACCEPTED blob too,
|
||||
/// so it says nothing about the refusal while still satisfying that guard. Any refusal no
|
||||
/// <c>DescribeRejection</c> branch can name was therefore dropped whenever an advisory was present.
|
||||
/// Comparing the two counts states the property without string-matching a message to guess its class,
|
||||
/// which is the whole point of keeping the two classes in separate lists.
|
||||
/// <para>
|
||||
/// ⚠️ <b>Gitea #559 made MQTT's own backstop UNREACHABLE, so this replaced a constructed
|
||||
/// vehicle.</b> That vehicle was a leading-whitespace blob: refused by both mappers' own framing
|
||||
/// gates while every describe branch — which walks the PARSED document — found it perfectly
|
||||
/// well-formed. #559 deleted those gates (the refusal was the defect), and no other vehicle
|
||||
/// exists: a union refusal carrying a <c>topic</c> requires a strict-field failure, which is
|
||||
/// named; without a topic and without the full Sparkplug tuple the no-address branch fires, which
|
||||
/// is also named. So the guard is protective here rather than a live fix — exactly the posture
|
||||
/// <c>FocasInspectAcceptGateTests</c> pins for FOCAS, and pinned the same way. What this holds is
|
||||
/// the property that must survive the next rejection cause someone adds without a describe branch.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_refusal_nothing_can_name_still_reports_the_backstop_when_an_advisory_is_present()
|
||||
public void A_rejected_config_always_draws_more_than_its_advisories_alone()
|
||||
{
|
||||
var padded = Pad + """{"topic":"plant/+/temp"}""";
|
||||
// Identical but for the out-of-range qos, so both carry the SAME wildcard advisory.
|
||||
const string Accepted = """{"topic":"plant/+/temp","qos":2}""";
|
||||
const string Rejected = """{"topic":"plant/+/temp","qos":5}""";
|
||||
|
||||
EitherModeAccepts(padded).ShouldBeFalse("the framing gate in both readers refuses a padded blob");
|
||||
EitherModeAccepts(Accepted).ShouldBeTrue("control must be accepted");
|
||||
EitherModeAccepts(Rejected).ShouldBeFalse("subject must be rejected");
|
||||
|
||||
var warnings = MqttTagDefinitionFactory.Inspect(padded);
|
||||
warnings.ShouldContain(w => w.Contains("wildcard"), "the advisory still fires");
|
||||
warnings.ShouldContain(
|
||||
w => w.Contains("rejected by the driver's tag reader"),
|
||||
"the backstop must survive the advisory — it is the only message naming the refusal");
|
||||
var advisoriesOnly = MqttTagDefinitionFactory.Inspect(Accepted);
|
||||
var withRejection = MqttTagDefinitionFactory.Inspect(Rejected);
|
||||
|
||||
advisoriesOnly.Count.ShouldBe(1, "the control's only warning is the wildcard advisory");
|
||||
withRejection.Count.ShouldBeGreaterThan(
|
||||
advisoriesOnly.Count,
|
||||
"a refusal must add a rejection-class warning — the backstop supplies one when nothing names it");
|
||||
}
|
||||
|
||||
// ---- Gitea #557 item 2: the payloadFormat mode-asymmetry contract ----
|
||||
|
||||
@@ -148,7 +148,10 @@ public sealed class MqttTagDefinitionFactoryTests
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("factory/oven/temp")] // a bare reference, not a TagConfig blob (no leading '{')
|
||||
// Since Gitea #559 the mapper carries no framing gate of its own: these are refused by
|
||||
// JsonDocument.Parse (a JsonException) and by the root-ValueKind check respectively, which is exactly
|
||||
// how the six sibling factories have always refused them.
|
||||
[InlineData("factory/oven/temp")] // a bare reference, not JSON at all ⇒ JsonException
|
||||
[InlineData("[1,2,3]")] // valid JSON, wrong root kind
|
||||
[InlineData("{ not json at all")] // unparseable — must not throw
|
||||
[InlineData("""{"payloadFormat":"Raw"}""")] // no topic ⇒ nothing to subscribe to
|
||||
@@ -218,7 +221,10 @@ public sealed class MqttTagDefinitionFactoryTests
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("factory/oven/temp")]
|
||||
[InlineData("[1,2,3]")] // no leading '{' ⇒ not a TagConfig blob at all (mirrors Modbus)
|
||||
// Inspect keeps a framing PRE-FILTER (trimmed since #556): a reference that is not object-framed is not
|
||||
// an equipment-tag TagConfig at all, so it draws nothing rather than a rejection. The mappers no longer
|
||||
// carry one (#559) — they reach the same verdict via Parse + ValueKind. Mirrors Modbus.
|
||||
[InlineData("[1,2,3]")]
|
||||
public void Inspect_NotATagConfigBlob_ReturnsEmpty(string? reference)
|
||||
=> MqttTagDefinitionFactory.Inspect(reference!).ShouldBeEmpty();
|
||||
|
||||
@@ -259,4 +265,87 @@ public sealed class MqttTagDefinitionFactoryTests
|
||||
.ShouldBeTrue();
|
||||
plain.Topic.ShouldBe("a/b");
|
||||
}
|
||||
|
||||
// ---- Gitea #559: the runtime mappers are whitespace-tolerant, like every sibling factory ----
|
||||
|
||||
/// <summary>
|
||||
/// A pad containing every whitespace form JSON allows before a value — space, tab, CR, LF.
|
||||
/// </summary>
|
||||
private const string Pad = " \t\r\n ";
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #559. Both mappers used to carry <c>tagConfig[0] != '{'</c> — evaluated on the UNTRIMMED
|
||||
/// string — so a TagConfig blob with leading whitespace was refused by MQTT alone and the tag read
|
||||
/// <c>BadNodeIdUnknown</c> forever, while the same blob was served by the other six structural-parse
|
||||
/// drivers (whose mappers hand it straight to <c>JsonDocument.Parse</c>, which skips leading
|
||||
/// whitespace per the JSON spec). No author means leading whitespace as semantic, and a CSV or API
|
||||
/// authoring path can introduce it invisibly.
|
||||
/// <para>
|
||||
/// ⚠️ It asserts the produced <b>definition</b>, not just the bool: a fix that accepted the padded
|
||||
/// blob but mapped it differently (e.g. by trimming into the wrong field) would pass a
|
||||
/// verdict-only test. <see cref="MqttTagDefinition"/> is a record, so this is value equality over
|
||||
/// every field.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="blob">A valid Plain-shape blob, padded below.</param>
|
||||
[Theory]
|
||||
[InlineData("""{"topic":"factory/oven/temp"}""")]
|
||||
[InlineData("""{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.v","dataType":"Float64","qos":1}""")]
|
||||
[InlineData("""{"topic":"factory/oven/temp","writeTopic":"factory/oven/setpoint","retainSeed":false}""")]
|
||||
public void FromTagConfig_LeadingWhitespace_MapsIdenticallyToTheUnpaddedBlob(string blob)
|
||||
{
|
||||
MqttTagDefinitionFactory.FromTagConfig(blob, RawPath, out var unpadded)
|
||||
.ShouldBeTrue("the unpadded control must be accepted");
|
||||
MqttTagDefinitionFactory.FromTagConfig(Pad + blob, RawPath, out var padded)
|
||||
.ShouldBeTrue("leading whitespace is not semantic — JsonDocument.Parse skips it");
|
||||
|
||||
padded.ShouldBe(unpadded);
|
||||
}
|
||||
|
||||
/// <summary>The same claim for the Sparkplug reader — it carried the identical gate (Gitea #559).</summary>
|
||||
/// <param name="blob">A valid Sparkplug-shape blob, padded below.</param>
|
||||
[Theory]
|
||||
[InlineData("""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Temp"}""")]
|
||||
[InlineData("""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Speed","dataType":"Float64","qos":1}""")]
|
||||
public void FromSparkplugTagConfig_LeadingWhitespace_MapsIdenticallyToTheUnpaddedBlob(string blob)
|
||||
{
|
||||
MqttTagDefinitionFactory.FromSparkplugTagConfig(blob, RawPath, out var unpadded)
|
||||
.ShouldBeTrue("the unpadded control must be accepted");
|
||||
MqttTagDefinitionFactory.FromSparkplugTagConfig(Pad + blob, RawPath, out var padded)
|
||||
.ShouldBeTrue("leading whitespace is not semantic — JsonDocument.Parse skips it");
|
||||
|
||||
padded.ShouldBe(unpadded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The falsification control: trimming widens the mappers ONLY for JSON objects. A padded blank, a
|
||||
/// padded array and padded non-JSON are still refused by both readers — <c>JsonDocument.Parse</c>
|
||||
/// plus the root-<c>ValueKind</c> check is the whole framing authority, and it is not laxer than the
|
||||
/// first-byte check was for anything but whitespace.
|
||||
/// </summary>
|
||||
/// <param name="reference">A padded reference that is not a TagConfig object.</param>
|
||||
[Theory]
|
||||
[InlineData(" \t\r\n ")] // whitespace only
|
||||
[InlineData(" \t\r\n [1,2,3]")] // valid JSON, wrong root kind
|
||||
[InlineData(" \t\r\n garbage")] // not JSON at all
|
||||
[InlineData(" \t\r\n 42")] // number root
|
||||
[InlineData(" \t\r\n \"a\"")] // string root
|
||||
[InlineData(" \t\r\n factory/oven/temp")] // a bare reference, padded
|
||||
public void BothMappers_RefusePaddedNonTagConfigReferences(string reference)
|
||||
{
|
||||
MqttTagDefinitionFactory.FromTagConfig(reference, RawPath, out _).ShouldBeFalse();
|
||||
MqttTagDefinitionFactory.FromSparkplugTagConfig(reference, RawPath, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A padded blob whose CONTENT is bad is still refused for that content — the pad neither rescues a
|
||||
/// malformed blob nor supplies a second reason to refuse a good one.
|
||||
/// </summary>
|
||||
/// <param name="blob">A padded blob that is object-framed but unreadable.</param>
|
||||
[Theory]
|
||||
[InlineData("""{"topic":"a/b","payloadFormat":"Jsonn"}""")]
|
||||
[InlineData("""{"topic":"a/b","qos":5}""")]
|
||||
[InlineData("{}")]
|
||||
public void FromTagConfig_LeadingWhitespace_DoesNotRescueAMalformedBlob(string blob)
|
||||
=> MqttTagDefinitionFactory.FromTagConfig(Pad + blob, RawPath, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
@@ -50,6 +50,13 @@ public sealed class TagConfigNonEnumParityTests
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
private const string P = "(test)";
|
||||
|
||||
/// <summary>
|
||||
/// Leading whitespace, in every form JSON allows before a value. Gitea #559: a CSV or API authoring
|
||||
/// path can introduce it invisibly, and no author means it as semantic — so it must not change any
|
||||
/// verdict on either side of the parity claim.
|
||||
/// </summary>
|
||||
private const string Pad = " \t\r\n ";
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, Func<string, bool>> RuntimeParses =
|
||||
new Dictionary<string, Func<string, bool>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -352,6 +359,14 @@ public sealed class TagConfigNonEnumParityTests
|
||||
(DriverTypeNames.Mqtt, """{"topic":"otopcua/fixture/a","qos":2}"""),
|
||||
(DriverTypeNames.Mqtt, """{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Filler/Speed"}"""),
|
||||
(DriverTypeNames.Mqtt, """{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA"}"""),
|
||||
// Gitea #559 — leading whitespace, in both shapes and in both verdicts. MQTT's runtime mappers used
|
||||
// to carry their own untrimmed `[0] != '{'` framing gate, so these padded blobs were REFUSED by the
|
||||
// driver while the authoring model (whose JsonNode.Parse skips whitespace) happily saved them: the
|
||||
// absolute direction of this suite, broken. The mappers now let JsonDocument.Parse decide framing,
|
||||
// like the six siblings, so the pad is inert on both sides.
|
||||
(DriverTypeNames.Mqtt, Pad + """{"topic":"otopcua/fixture/a","dataType":"Float64"}"""),
|
||||
(DriverTypeNames.Mqtt, Pad + """{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Filler/Speed"}"""),
|
||||
(DriverTypeNames.Mqtt, Pad + """{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA"}"""),
|
||||
|
||||
(DriverTypeNames.Sql, "{}"),
|
||||
(DriverTypeNames.Sql, """{"model":"KeyValue","table":"dbo.T","keyColumn":"k","valueColumn":"v","keyValue":"x"}"""),
|
||||
|
||||
Reference in New Issue
Block a user