diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs
index 7d5ebf61..f3c725a9 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs
@@ -49,7 +49,10 @@ public static class MqttTagDefinitionFactory
///
/// Maps an authored TagConfig object to a typed definition keyed by .
- /// 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 JsonDocument.Parse plus the
+ /// root- 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 (payloadFormat / dataType) and qos are read STRICTLY — a
/// present-but-invalid (typo'd) value rejects the whole tag (returns ⇒ the
/// driver surfaces BadNodeIdUnknown) 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
/// (groupId, edgeNodeId, deviceId?, metricName) tuple; deviceId is optional (absent
/// means the metric is published by the edge node itself), and there is no topic —
/// 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
+ /// JsonDocument.Parse plus the root-object check, so leading
+ /// whitespace does not change the verdict (Gitea #559) — see .
///
///
///
@@ -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 separate lists (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 no known
+ /// vehicle: the leading-whitespace blob that used to reach it is now accepted or refused on its
+ /// content alone, and every remaining return false in either mapper has a matching
+ /// 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).
///
///
///
@@ -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();
var advisories = new List();
- // 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;
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasInspectAcceptGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasInspectAcceptGateTests.cs
index 1e5dbffa..7481f2ee 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasInspectAcceptGateTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasInspectAcceptGateTests.cs
@@ -111,10 +111,12 @@ public sealed class FocasInspectAcceptGateTests
/// — absent / non-string / blank
/// address, invalid dataType — has a matching describe branch, and
/// TryReadEnumStrict and DescribeInvalidEnum 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
- /// MqttInspectAcceptGateTests). 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. MqttInspectAcceptGateTests pins the same count relation.
+ /// What this pins is the property that must survive the next rejection cause someone adds
+ /// without a describe branch.
///
///
[Fact]
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttInspectAcceptGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttInspectAcceptGateTests.cs
index 1af21085..498648f0 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttInspectAcceptGateTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttInspectAcceptGateTests.cs
@@ -25,7 +25,7 @@ public sealed class MqttInspectAcceptGateTests
|| MqttTagDefinitionFactory.FromSparkplugTagConfig(tagConfig, RawPath, out _);
/// Blobs at least one mapper ACCEPTS, with no wildcard advisory — the gate must be silent.
- public static TheoryData 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
];
/// Blobs NEITHER mapper accepts — the gate must produce at least one warning for each.
- public static TheoryData 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
];
+ /// Blobs at least one mapper ACCEPTS, with no wildcard advisory — the gate must be silent.
+ /// The accepted fixtures.
+ public static TheoryData Accepted() => [.. AcceptedBlobs];
+
+ /// Blobs NEITHER mapper accepts — the gate must produce at least one warning for each.
+ /// The rejected fixtures.
+ public static TheoryData 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) ----
/// Padding a blob with leading whitespace.
private const string Pad = " \t\r\n ";
+ /// Every fixture above — accepted and rejected alike — so the padding claim is checked on both.
+ /// The full fixture corpus.
+ public static TheoryData AcceptedAndRejected() => [.. AcceptedBlobs, .. RejectedBlobs];
+
///
- /// MQTT is the one factory of the seven whose RUNTIME mappers carry the same [0] != '{'
- /// 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 BadNodeIdUnknown forever and the deploy gate said nothing at all. Its siblings hand
- /// the blob to a whitespace-tolerant JsonDocument.Parse 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
+ /// [0] != '{' framing gate — run on the UNTRIMMED string — so a blob whose only sin was
+ /// leading whitespace was refused by this driver alone and read BadNodeIdUnknown forever,
+ /// while the identical blob was served by Modbus, S7, AbCip, AbLegacy, TwinCAT and FOCAS (whose
+ /// mappers hand it to a whitespace-tolerant JsonDocument.Parse). 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.
+ ///
+ /// Asserting parity with the unpadded fixture 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.
+ ///
///
- /// A fixture that is accepted unpadded.
+ /// A fixture, padded below.
[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}");
+ }
+
+ ///
+ /// 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).
+ ///
+ [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");
}
///
@@ -130,25 +166,42 @@ public sealed class MqttInspectAcceptGateTests
// ---- Gitea #556 item 2: an advisory can no longer swallow the catch-all rejection ----
///
- /// 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
- /// DescribeRejection 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 warnings.Count == 0, 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 warnings.Count == 0 — and an advisory fires on an ACCEPTED blob too,
+ /// so it says nothing about the refusal while still satisfying that guard. Any refusal no
+ /// DescribeRejection 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.
+ ///
+ /// ⚠️ Gitea #559 made MQTT's own backstop UNREACHABLE, so this replaced a constructed
+ /// vehicle. 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 topic 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
+ /// FocasInspectAcceptGateTests 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.
+ ///
///
[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 ----
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttTagDefinitionFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttTagDefinitionFactoryTests.cs
index 2155beef..9a16f744 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttTagDefinitionFactoryTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttTagDefinitionFactoryTests.cs
@@ -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 ----
+
+ ///
+ /// A pad containing every whitespace form JSON allows before a value — space, tab, CR, LF.
+ ///
+ private const string Pad = " \t\r\n ";
+
+ ///
+ /// Gitea #559. Both mappers used to carry 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 same blob was served by the other six structural-parse
+ /// drivers (whose mappers hand it straight to JsonDocument.Parse, 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.
+ ///
+ /// ⚠️ It asserts the produced definition, 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. is a record, so this is value equality over
+ /// every field.
+ ///
+ ///
+ /// A valid Plain-shape blob, padded below.
+ [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);
+ }
+
+ /// The same claim for the Sparkplug reader — it carried the identical gate (Gitea #559).
+ /// A valid Sparkplug-shape blob, padded below.
+ [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);
+ }
+
+ ///
+ /// 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 — JsonDocument.Parse
+ /// plus the root-ValueKind check is the whole framing authority, and it is not laxer than the
+ /// first-byte check was for anything but whitespace.
+ ///
+ /// A padded reference that is not a TagConfig object.
+ [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();
+ }
+
+ ///
+ /// 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.
+ ///
+ /// A padded blob that is object-framed but unreadable.
+ [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();
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigNonEnumParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigNonEnumParityTests.cs
index 3828cbca..3481c394 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigNonEnumParityTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigNonEnumParityTests.cs
@@ -50,6 +50,13 @@ public sealed class TagConfigNonEnumParityTests
// ---------------------------------------------------------------------------------------------
private const string P = "(test)";
+ ///
+ /// 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.
+ ///
+ private const string Pad = " \t\r\n ";
+
private static readonly IReadOnlyDictionary> RuntimeParses =
new Dictionary>(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"}"""),