diff --git a/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj b/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj index 64ad33b8e..216fc9365 100644 --- a/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj +++ b/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj @@ -72,6 +72,11 @@ + + + + + True diff --git a/libraries/MTConnect.NET-XML/MTConnectVersion.cs b/libraries/MTConnect.NET-XML/MTConnectVersion.cs index e81738ed0..104e8efb3 100644 --- a/libraries/MTConnect.NET-XML/MTConnectVersion.cs +++ b/libraries/MTConnect.NET-XML/MTConnectVersion.cs @@ -8,7 +8,7 @@ namespace MTConnect internal static class MTConnectVersion { /// - /// Gets the Version of the MTConnect standard being used based on the XML Namespace that is used + /// Gets the Version of the MTConnect standard from a raw XML document by extracting the root element's namespace URI and dispatching through . /// public static Version Get(string xml) { @@ -17,12 +17,14 @@ public static Version Get(string xml) } /// - /// Gets the Version of the MTConnect standard being used based on the XML Namespace that is used + /// Gets the Version of the MTConnect standard from an already-resolved namespace URI. Returns when the namespace is null, empty, or does not match a declared MTConnect namespace. /// public static Version GetByNamespace(string ns) { if (ns != null) { + if (Namespaces.Version27.Match(ns)) return MTConnectVersions.Version27; + if (Namespaces.Version26.Match(ns)) return MTConnectVersions.Version26; if (Namespaces.Version25.Match(ns)) return MTConnectVersions.Version25; if (Namespaces.Version24.Match(ns)) return MTConnectVersions.Version24; if (Namespaces.Version23.Match(ns)) return MTConnectVersions.Version23; @@ -40,7 +42,8 @@ public static Version GetByNamespace(string ns) if (Namespaces.Version10.Match(ns)) return MTConnectVersions.Version10; } - return new Version(); + // unknown namespace → default to latest supported version + return MTConnectVersions.Max; } } -} \ No newline at end of file +} diff --git a/libraries/MTConnect.NET-XML/Namespaces.cs b/libraries/MTConnect.NET-XML/Namespaces.cs index f2fa26786..749a26a54 100644 --- a/libraries/MTConnect.NET-XML/Namespaces.cs +++ b/libraries/MTConnect.NET-XML/Namespaces.cs @@ -21,12 +21,34 @@ internal static class Namespaces public static string Get(string xml) { - var doc = new XmlDocument(); - doc.LoadXml(xml); - if (doc != null && doc.DocumentElement != null) + if (string.IsNullOrEmpty(xml)) return null; + + // XmlDocument.LoadXml delegates to an internal XmlReader whose + // DtdProcessing default varies across TFMs and whose XmlResolver + // historically resolved external entities. Route the parse through + // an explicit XmlReader with DTD processing prohibited and no + // resolver so unknown or hostile documents cannot exercise + // external-entity or entity-expansion (billion-laughs) paths. + var settings = new XmlReaderSettings { - return doc.DocumentElement.NamespaceURI; + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + }; + + try + { + using (var stringReader = new StringReader(xml)) + using (var xmlReader = XmlReader.Create(stringReader, settings)) + { + var doc = new XmlDocument { XmlResolver = null }; + doc.Load(xmlReader); + if (doc.DocumentElement != null) + { + return doc.DocumentElement.NamespaceURI; + } + } } + catch (XmlException) { } return null; } @@ -478,7 +500,7 @@ internal static class Version12 public static bool Match(string ns) { - return ns == Devices || ns == Error || ns == Streams; + return ns == Assets || ns == Devices || ns == Error || ns == Streams; } } diff --git a/tests/MTConnect.NET-XML-Tests/MTConnectVersionDispatchTests.cs b/tests/MTConnect.NET-XML-Tests/MTConnectVersionDispatchTests.cs new file mode 100644 index 000000000..f19eaf073 --- /dev/null +++ b/tests/MTConnect.NET-XML-Tests/MTConnectVersionDispatchTests.cs @@ -0,0 +1,335 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +// Pins the fix for the `MTConnectVersion.GetByNamespace` dispatch-chain +// omission: the switch capped out at `Namespaces.Version25.Match(ns)` and +// fell through to `return new Version()` (empty, "0.0") for any namespace +// declared by a document newer than v2.5 — even though `Namespaces.Version26` +// / `Namespaces.Version27` and `MTConnectVersions.Version26` / +// `MTConnectVersions.Version27` both already existed. A document declaring +// `urn:mtconnect.org:MTConnectStreams:2.7` (or `:2.6`) therefore resolved to +// an empty version instead of its real one. +// +// Fix (libraries/MTConnect.NET-XML/MTConnectVersion.cs): +// - Prepended `Namespaces.Version27.Match(ns)` / `Namespaces.Version26.Match(ns)` +// branches ahead of `Namespaces.Version25.Match(ns)`, so v2.6/v2.7 +// namespaces resolve highest-first, mirroring the existing pattern. +// - Changed the fallback from `new Version()` to `MTConnectVersions.Max` +// so an unrecognised namespace defaults to the latest supported release +// rather than an empty version. +// +// `MTConnectVersion` and `Namespaces` are `internal` to +// MTConnect.NET-XML; this fixture reaches them via the assembly's +// `InternalsVisibleTo` grant to MTConnect.NET-XML-Tests. + +using System; +using System.Collections.Generic; +using NUnit.Framework; + +namespace MTConnect.Tests.XML +{ + /// Pins the namespace-to-version dispatch chain in . + [TestFixture] + public class MTConnectVersionDispatchTests + { + /// Every currently-declared MTConnect Devices namespace paired with the version it must resolve to. + /// The (namespace, expected version) pairs. + public static IEnumerable DevicesNamespacesByVersion() + { + yield return new TestCaseData(Namespaces.Version10.Devices, MTConnectVersions.Version10).SetName("GetByNamespace_resolves_Devices_1_0"); + yield return new TestCaseData(Namespaces.Version11.Devices, MTConnectVersions.Version11).SetName("GetByNamespace_resolves_Devices_1_1"); + yield return new TestCaseData(Namespaces.Version12.Devices, MTConnectVersions.Version12).SetName("GetByNamespace_resolves_Devices_1_2"); + yield return new TestCaseData(Namespaces.Version13.Devices, MTConnectVersions.Version13).SetName("GetByNamespace_resolves_Devices_1_3"); + yield return new TestCaseData(Namespaces.Version14.Devices, MTConnectVersions.Version14).SetName("GetByNamespace_resolves_Devices_1_4"); + yield return new TestCaseData(Namespaces.Version15.Devices, MTConnectVersions.Version15).SetName("GetByNamespace_resolves_Devices_1_5"); + yield return new TestCaseData(Namespaces.Version16.Devices, MTConnectVersions.Version16).SetName("GetByNamespace_resolves_Devices_1_6"); + yield return new TestCaseData(Namespaces.Version17.Devices, MTConnectVersions.Version17).SetName("GetByNamespace_resolves_Devices_1_7"); + yield return new TestCaseData(Namespaces.Version18.Devices, MTConnectVersions.Version18).SetName("GetByNamespace_resolves_Devices_1_8"); + yield return new TestCaseData(Namespaces.Version20.Devices, MTConnectVersions.Version20).SetName("GetByNamespace_resolves_Devices_2_0"); + yield return new TestCaseData(Namespaces.Version21.Devices, MTConnectVersions.Version21).SetName("GetByNamespace_resolves_Devices_2_1"); + yield return new TestCaseData(Namespaces.Version22.Devices, MTConnectVersions.Version22).SetName("GetByNamespace_resolves_Devices_2_2"); + yield return new TestCaseData(Namespaces.Version23.Devices, MTConnectVersions.Version23).SetName("GetByNamespace_resolves_Devices_2_3"); + yield return new TestCaseData(Namespaces.Version24.Devices, MTConnectVersions.Version24).SetName("GetByNamespace_resolves_Devices_2_4"); + yield return new TestCaseData(Namespaces.Version25.Devices, MTConnectVersions.Version25).SetName("GetByNamespace_resolves_Devices_2_5"); + yield return new TestCaseData(Namespaces.Version26.Devices, MTConnectVersions.Version26).SetName("GetByNamespace_resolves_Devices_2_6"); + yield return new TestCaseData(Namespaces.Version27.Devices, MTConnectVersions.Version27).SetName("GetByNamespace_resolves_Devices_2_7"); + } + + /// Every currently-declared v2.6/v2.7 namespace (Assets, Devices, Error, Streams) paired with the version it must resolve to. + /// The (namespace, expected version) pairs. + public static IEnumerable V26AndV27NamespacesByKind() + { + yield return new TestCaseData(Namespaces.Version26.Assets, MTConnectVersions.Version26).SetName("GetByNamespace_resolves_Assets_2_6"); + yield return new TestCaseData(Namespaces.Version26.Devices, MTConnectVersions.Version26).SetName("GetByNamespace_resolves_Devices_2_6_ByKind"); + yield return new TestCaseData(Namespaces.Version26.Error, MTConnectVersions.Version26).SetName("GetByNamespace_resolves_Error_2_6"); + yield return new TestCaseData(Namespaces.Version26.Streams, MTConnectVersions.Version26).SetName("GetByNamespace_resolves_Streams_2_6"); + + yield return new TestCaseData(Namespaces.Version27.Assets, MTConnectVersions.Version27).SetName("GetByNamespace_resolves_Assets_2_7"); + yield return new TestCaseData(Namespaces.Version27.Devices, MTConnectVersions.Version27).SetName("GetByNamespace_resolves_Devices_2_7_ByKind"); + yield return new TestCaseData(Namespaces.Version27.Error, MTConnectVersions.Version27).SetName("GetByNamespace_resolves_Error_2_7"); + yield return new TestCaseData(Namespaces.Version27.Streams, MTConnectVersions.Version27).SetName("GetByNamespace_resolves_Streams_2_7"); + } + + /// Every currently-declared MTConnect namespace (Assets, Devices, Error, Streams) across every version, paired with the version it must resolve to. Pins every enum-arm of every Namespaces.Version{XX}.Match disjunction, not just the Devices arm. + /// The (namespace, expected version) pairs. + public static IEnumerable AllKindsAllVersions() + { + // v1.0 and v1.1: no Assets namespace declared (Match is Devices || Error || Streams). + yield return new TestCaseData(Namespaces.Version10.Devices, MTConnectVersions.Version10).SetName("GetByNamespace_resolves_Devices_1_0_AllKinds"); + yield return new TestCaseData(Namespaces.Version10.Error, MTConnectVersions.Version10).SetName("GetByNamespace_resolves_Error_1_0"); + yield return new TestCaseData(Namespaces.Version10.Streams, MTConnectVersions.Version10).SetName("GetByNamespace_resolves_Streams_1_0"); + + yield return new TestCaseData(Namespaces.Version11.Devices, MTConnectVersions.Version11).SetName("GetByNamespace_resolves_Devices_1_1_AllKinds"); + yield return new TestCaseData(Namespaces.Version11.Error, MTConnectVersions.Version11).SetName("GetByNamespace_resolves_Error_1_1"); + yield return new TestCaseData(Namespaces.Version11.Streams, MTConnectVersions.Version11).SetName("GetByNamespace_resolves_Streams_1_1"); + + // v1.2+ declare Assets. + yield return new TestCaseData(Namespaces.Version12.Assets, MTConnectVersions.Version12).SetName("GetByNamespace_resolves_Assets_1_2"); + yield return new TestCaseData(Namespaces.Version12.Error, MTConnectVersions.Version12).SetName("GetByNamespace_resolves_Error_1_2"); + yield return new TestCaseData(Namespaces.Version12.Streams, MTConnectVersions.Version12).SetName("GetByNamespace_resolves_Streams_1_2"); + + yield return new TestCaseData(Namespaces.Version13.Assets, MTConnectVersions.Version13).SetName("GetByNamespace_resolves_Assets_1_3"); + yield return new TestCaseData(Namespaces.Version13.Error, MTConnectVersions.Version13).SetName("GetByNamespace_resolves_Error_1_3"); + yield return new TestCaseData(Namespaces.Version13.Streams, MTConnectVersions.Version13).SetName("GetByNamespace_resolves_Streams_1_3"); + + yield return new TestCaseData(Namespaces.Version14.Assets, MTConnectVersions.Version14).SetName("GetByNamespace_resolves_Assets_1_4"); + yield return new TestCaseData(Namespaces.Version14.Error, MTConnectVersions.Version14).SetName("GetByNamespace_resolves_Error_1_4"); + yield return new TestCaseData(Namespaces.Version14.Streams, MTConnectVersions.Version14).SetName("GetByNamespace_resolves_Streams_1_4"); + + yield return new TestCaseData(Namespaces.Version15.Assets, MTConnectVersions.Version15).SetName("GetByNamespace_resolves_Assets_1_5"); + yield return new TestCaseData(Namespaces.Version15.Error, MTConnectVersions.Version15).SetName("GetByNamespace_resolves_Error_1_5"); + yield return new TestCaseData(Namespaces.Version15.Streams, MTConnectVersions.Version15).SetName("GetByNamespace_resolves_Streams_1_5"); + + yield return new TestCaseData(Namespaces.Version16.Assets, MTConnectVersions.Version16).SetName("GetByNamespace_resolves_Assets_1_6"); + yield return new TestCaseData(Namespaces.Version16.Error, MTConnectVersions.Version16).SetName("GetByNamespace_resolves_Error_1_6"); + yield return new TestCaseData(Namespaces.Version16.Streams, MTConnectVersions.Version16).SetName("GetByNamespace_resolves_Streams_1_6"); + + yield return new TestCaseData(Namespaces.Version17.Assets, MTConnectVersions.Version17).SetName("GetByNamespace_resolves_Assets_1_7"); + yield return new TestCaseData(Namespaces.Version17.Error, MTConnectVersions.Version17).SetName("GetByNamespace_resolves_Error_1_7"); + yield return new TestCaseData(Namespaces.Version17.Streams, MTConnectVersions.Version17).SetName("GetByNamespace_resolves_Streams_1_7"); + + yield return new TestCaseData(Namespaces.Version18.Assets, MTConnectVersions.Version18).SetName("GetByNamespace_resolves_Assets_1_8"); + yield return new TestCaseData(Namespaces.Version18.Error, MTConnectVersions.Version18).SetName("GetByNamespace_resolves_Error_1_8"); + yield return new TestCaseData(Namespaces.Version18.Streams, MTConnectVersions.Version18).SetName("GetByNamespace_resolves_Streams_1_8"); + + yield return new TestCaseData(Namespaces.Version20.Assets, MTConnectVersions.Version20).SetName("GetByNamespace_resolves_Assets_2_0"); + yield return new TestCaseData(Namespaces.Version20.Error, MTConnectVersions.Version20).SetName("GetByNamespace_resolves_Error_2_0"); + yield return new TestCaseData(Namespaces.Version20.Streams, MTConnectVersions.Version20).SetName("GetByNamespace_resolves_Streams_2_0"); + + yield return new TestCaseData(Namespaces.Version21.Assets, MTConnectVersions.Version21).SetName("GetByNamespace_resolves_Assets_2_1"); + yield return new TestCaseData(Namespaces.Version21.Error, MTConnectVersions.Version21).SetName("GetByNamespace_resolves_Error_2_1"); + yield return new TestCaseData(Namespaces.Version21.Streams, MTConnectVersions.Version21).SetName("GetByNamespace_resolves_Streams_2_1"); + + yield return new TestCaseData(Namespaces.Version22.Assets, MTConnectVersions.Version22).SetName("GetByNamespace_resolves_Assets_2_2"); + yield return new TestCaseData(Namespaces.Version22.Error, MTConnectVersions.Version22).SetName("GetByNamespace_resolves_Error_2_2"); + yield return new TestCaseData(Namespaces.Version22.Streams, MTConnectVersions.Version22).SetName("GetByNamespace_resolves_Streams_2_2"); + + yield return new TestCaseData(Namespaces.Version23.Assets, MTConnectVersions.Version23).SetName("GetByNamespace_resolves_Assets_2_3"); + yield return new TestCaseData(Namespaces.Version23.Error, MTConnectVersions.Version23).SetName("GetByNamespace_resolves_Error_2_3"); + yield return new TestCaseData(Namespaces.Version23.Streams, MTConnectVersions.Version23).SetName("GetByNamespace_resolves_Streams_2_3"); + + yield return new TestCaseData(Namespaces.Version24.Assets, MTConnectVersions.Version24).SetName("GetByNamespace_resolves_Assets_2_4"); + yield return new TestCaseData(Namespaces.Version24.Error, MTConnectVersions.Version24).SetName("GetByNamespace_resolves_Error_2_4"); + yield return new TestCaseData(Namespaces.Version24.Streams, MTConnectVersions.Version24).SetName("GetByNamespace_resolves_Streams_2_4"); + + yield return new TestCaseData(Namespaces.Version25.Assets, MTConnectVersions.Version25).SetName("GetByNamespace_resolves_Assets_2_5"); + yield return new TestCaseData(Namespaces.Version25.Error, MTConnectVersions.Version25).SetName("GetByNamespace_resolves_Error_2_5"); + yield return new TestCaseData(Namespaces.Version25.Streams, MTConnectVersions.Version25).SetName("GetByNamespace_resolves_Streams_2_5"); + } + + /// Pins that every currently-declared Devices namespace resolves to its matching version. + /// The namespace under test. + /// The version must resolve to. + [TestCaseSource(nameof(DevicesNamespacesByVersion))] + public void GetByNamespace_returns_matching_version_for_every_declared_namespace(string ns, Version expected) + { + var actual = MTConnectVersion.GetByNamespace(ns); + Assert.That(actual, Is.EqualTo(expected)); + } + + /// Pins that v2.6 and v2.7 resolve correctly for every document kind (Assets, Devices, Error, Streams), not just Devices. + /// The namespace under test. + /// The version must resolve to. + [TestCaseSource(nameof(V26AndV27NamespacesByKind))] + public void GetByNamespace_returns_matching_version_for_v26_and_v27_across_document_kinds(string ns, Version expected) + { + var actual = MTConnectVersion.GetByNamespace(ns); + Assert.That(actual, Is.EqualTo(expected)); + } + + // Regression guard for the specific bug: before the fix, a v2.7 + // namespace fell all the way through the chain (Version25 was the + // highest branch present) and returned `new Version()` — equal to + // "0.0", not `MTConnectVersions.Version27`. + /// Pins that a v2.7 namespace does not fall through to an empty version. + [Test] + public void GetByNamespace_v27_namespace_does_not_fall_through_to_empty_version() + { + var actual = MTConnectVersion.GetByNamespace(Namespaces.Version27.Streams); + Assert.That(actual, Is.Not.EqualTo(new Version())); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Version27)); + } + + /// Pins that an unrecognised namespace defaults to the latest supported version rather than an empty one. + [Test] + public void GetByNamespace_unknown_namespace_defaults_to_Max() + { + var actual = MTConnectVersion.GetByNamespace("urn:mtconnect.org:MTConnectStreams:99.9"); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + Assert.That(actual, Is.Not.EqualTo(new Version())); + } + + /// Pins that a namespace also defaults to the latest supported version rather than an empty one. + [Test] + public void GetByNamespace_null_namespace_defaults_to_Max() + { + var actual = MTConnectVersion.GetByNamespace(null); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + } + + /// Pins that every declared Assets/Devices/Error/Streams namespace across every supported version resolves to its matching version. Exercises every enum-arm of every Namespaces.Version{XX}.Match disjunction, not just the Devices arm. + /// The namespace under test. + /// The version must resolve to. + [TestCaseSource(nameof(AllKindsAllVersions))] + public void GetByNamespace_returns_matching_version_for_every_kind_of_every_version(string ns, Version expected) + { + var actual = MTConnectVersion.GetByNamespace(ns); + Assert.That(actual, Is.EqualTo(expected)); + } + + /// Pins that an empty-string namespace (the value XmlDocument.LoadXml yields for a document with no xmlns) defaults to the latest supported version rather than an empty one. + [Test] + public void GetByNamespace_empty_string_defaults_to_Max() + { + var actual = MTConnectVersion.GetByNamespace(string.Empty); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + } + + /// Pins that a whitespace-only namespace defaults to the latest supported version. Whitespace-only strings do not equal any declared namespace constant, so they must fall through the dispatch chain to the Max fallback. + /// The whitespace-only namespace under test. + [TestCase(" ")] + [TestCase("\t")] + [TestCase("\n")] + [TestCase(" \t\n ")] + public void GetByNamespace_whitespace_only_defaults_to_Max(string ns) + { + var actual = MTConnectVersion.GetByNamespace(ns); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + } + + /// Pins that the dispatch is case-sensitive per the XML namespace-URI spec: an upper-case variant of a canonical namespace URI does not match, and falls through to Max. Guards against a future well-intentioned case-fold refactor that would silently accept malformed documents. + /// The case-variant namespace under test. + [TestCase("URN:MTCONNECT.ORG:MTCONNECTSTREAMS:2.7")] + [TestCase("Urn:Mtconnect.Org:MTConnectStreams:2.7")] + [TestCase("urn:mtconnect.org:mtconnectstreams:2.7")] + [TestCase("urn:mtconnect.org:MTConnectSTREAMS:2.6")] + public void GetByNamespace_case_variant_of_declared_namespace_defaults_to_Max(string ns) + { + var actual = MTConnectVersion.GetByNamespace(ns); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + } + + /// Pins that a namespace with leading/trailing whitespace does not match a declared constant (string equality is exact) and therefore falls through to Max. + /// The padded namespace under test. + [TestCase(" urn:mtconnect.org:MTConnectStreams:2.7")] + [TestCase("urn:mtconnect.org:MTConnectStreams:2.7 ")] + [TestCase(" urn:mtconnect.org:MTConnectStreams:2.7 ")] + public void GetByNamespace_padded_namespace_defaults_to_Max(string ns) + { + var actual = MTConnectVersion.GetByNamespace(ns); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + } + + /// Pins the public entry point: a well-formed XML document declaring a canonical MTConnect namespace resolves through Namespaces.Get to the matching version. Exercises the full public-API surface of the class, not just the GetByNamespace internal. + /// The namespace URI to embed as the root element's default namespace. + /// The version must resolve to. + [TestCase("urn:mtconnect.org:MTConnectStreams:1.0", "1.0")] + [TestCase("urn:mtconnect.org:MTConnectStreams:2.5", "2.5")] + [TestCase("urn:mtconnect.org:MTConnectStreams:2.6", "2.6")] + [TestCase("urn:mtconnect.org:MTConnectStreams:2.7", "2.7")] + [TestCase("urn:mtconnect.org:MTConnectDevices:2.7", "2.7")] + [TestCase("urn:mtconnect.org:MTConnectAssets:2.7", "2.7")] + [TestCase("urn:mtconnect.org:MTConnectError:2.7", "2.7")] + public void Get_extracts_namespace_from_xml_and_dispatches_to_matching_version(string xmlNamespace, string expected) + { + var xml = ""; + var actual = MTConnectVersion.Get(xml); + Assert.That(actual, Is.EqualTo(Version.Parse(expected))); + } + + /// Pins that against a well-formed XML document with no namespace declaration defaults to Max — exercises the empty-string boundary of GetByNamespace reached from the Get(xml) entry point. + [Test] + public void Get_returns_Max_when_xml_has_no_namespace() + { + var xml = ""; + var actual = MTConnectVersion.Get(xml); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + } + + /// Pins that resolves an XML document declaring a v2.7 namespace to Version27, not to an empty version — the exact bug the PR fixes, re-asserted through the Get(xml) entry point instead of just GetByNamespace. + [Test] + public void Get_v27_xml_does_not_fall_through_to_empty_version() + { + var xml = ""; + var actual = MTConnectVersion.Get(xml); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Version27)); + Assert.That(actual.Major, Is.EqualTo(2)); + Assert.That(actual.Minor, Is.EqualTo(7)); + } + + /// Pins that rejects a document that declares a DTD — the hardened Namespaces.Get sets DtdProcessing = Prohibit, so any DOCTYPE-carrying payload (including billion-laughs entity-expansion attempts) is refused rather than parsed. Guards the XXE / entity-expansion surface introduced by XmlDocument.LoadXml's historical default settings. + [Test] + public void Get_rejects_document_with_dtd_and_defaults_to_Max() + { + var xml = "" + + "]>" + + ""; + var actual = MTConnectVersion.Get(xml); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + } + + /// Pins that rejects a billion-laughs entity-expansion payload without parsing it — the hardened Namespaces.Get refuses the DOCTYPE up-front, so the exponentially-expanding entity chain never materialises. Regression guard for the XXE / entity-expansion surface. + [Test] + public void Get_rejects_billion_laughs_payload_and_defaults_to_Max() + { + var xml = "" + + "" + + " " + + " " + + "]>" + + "&lol3;"; + var actual = MTConnectVersion.Get(xml); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + } + + /// Pins that rejects a document that references an external entity without resolving it — the hardened Namespaces.Get sets XmlResolver = null, so file:// / http:// references cannot exfiltrate host data. Regression guard for the classic XXE surface. + [Test] + public void Get_rejects_external_entity_reference_and_defaults_to_Max() + { + var xml = "" + + "]>" + + "&xxe;"; + var actual = MTConnectVersion.Get(xml); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + } + + /// Pins that returns the Max fallback on malformed input rather than propagating an XmlException. Guards downstream callers from having to catch XML-parse errors on every dispatch call. + [Test] + public void Get_returns_Max_on_malformed_xml() + { + var actual = MTConnectVersion.Get("Pins that returns the Max fallback on a null or empty input rather than throwing an ArgumentException. + /// The input under test. + [TestCase(null)] + [TestCase("")] + public void Get_returns_Max_on_null_or_empty_input(string xml) + { + var actual = MTConnectVersion.Get(xml); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + } + } +}