fix(tagconfig): a numerically-spelled enum names no member — reject it fleet-wide (Driver.Sql.Contracts-002)

TagConfigJson.ClassifyEnumText classified via Enum.TryParse(text, ignoreCase: true),
which accepts a numeric STRING for any enum: "1" returns the member at that ordinal
and an out-of-range "99" parses just as happily. Every driver contract's strict enum
read inherits that, so a Sql tag authored "type":"99" was ACCEPTED, carried
(DriverDataType)99, and SqlPollReader.TryCoerce fell through its switch to the String
arm — a stringified cell published Good under a type nothing can map.

Refuse a digit- or sign-leading spelling before the parse (judged on the TRIMMED text,
because Enum.TryParse trims), and require Enum.IsDefined — which covers what the
leading-character check cannot, since TryParse also ORs a comma-separated list for a
non-[Flags] enum. Mirrors RawBrowseCommitMapper.ParseNativeType and
MTConnectTagDefinitionFactory.TryParseDataType, which already guard their own parses.

Three AdminUI restatements of the same rule now ask ClassifyEnumText instead of
Enum.TryParse (TagConfigJson.GetEnum, SqlTagConfigModel.ReadEnumStrict,
MqttTagConfigModel.DescribeInvalidEnum/GetEnumNullable) — leaving them would have opened
the #549 direction the moment the runtime tightened: a blob the modal accepts and the
driver skips.

RED first: 10 failing in Core.Abstractions.Tests, 4 in Driver.Sql.Tests, and 10 of the
11 new fleet-wide parity cases (re-confirmed by reverting the Core fix).
This commit is contained in:
Joseph Doherty
2026-08-10 08:02:18 -04:00
parent 94b8633e68
commit 8d4ca5489a
7 changed files with 198 additions and 11 deletions
@@ -220,6 +220,37 @@ public sealed class TagConfigAuthoringDeployParityTests
+ "import or API is unchecked — the exact G-7 asymmetry.");
}
/// <summary>
/// Driver.Sql.Contracts-002, fleet-wide. A numerically-SPELLED enum (<c>"1"</c>, not the JSON
/// number <c>1</c>) used to satisfy <c>Enum.TryParse</c> at every driver, so both doors accepted it
/// and the tag was served under whatever member sat at that ordinal — or, out of range, under a
/// value no member declares. The shared <c>TagConfigJson.ClassifyEnumText</c> now calls it what it
/// is, and because BOTH gates ask that one method, they moved together: the fix is only real if
/// each driver refuses it at the modal AND at the deploy gate.
/// </summary>
[Theory]
// Complete but for the enum spelling, so the refusal can only come from the enum read: "1" is
// SqlTagModel.WideRow's ordinal, and every WideRow-required field is present.
[InlineData(DriverTypeNames.Sql, """{"model":"1","table":"dbo.T","columnName":"c","rowSelector":{"topByTimestamp":"ts"}}""")]
[InlineData(DriverTypeNames.Sql, """{"model":"KeyValue","table":"dbo.T","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"8"}""")]
[InlineData(DriverTypeNames.Modbus, """{"region":"1","address":1}""")]
[InlineData(DriverTypeNames.Modbus, """{"region":"HoldingRegisters","address":1,"dataType":"99"}""")]
[InlineData(DriverTypeNames.S7, """{"address":"DB1.DBW0","dataType":"1"}""")]
[InlineData(DriverTypeNames.AbCip, """{"tagPath":"Program:Main.Speed","dataType":"1"}""")]
[InlineData(DriverTypeNames.AbLegacy, """{"address":"N7:0","dataType":"1"}""")]
[InlineData(DriverTypeNames.TwinCAT, """{"symbolPath":"MAIN.bStart","dataType":"1"}""")]
[InlineData(DriverTypeNames.FOCAS, """{"address":"R100","dataType":"1"}""")]
[InlineData(DriverTypeNames.Mqtt, """{"topic":"otopcua/fixture/a","payloadFormat":"1"}""")]
[InlineData(DriverTypeNames.Mqtt, """{"topic":"otopcua/fixture/a","dataType":"8"}""")]
public void A_numerically_spelled_enum_is_refused_by_both_gates(string driverType, string tagConfig)
{
TagConfigValidator.Validate(driverType, tagConfig).ShouldNotBeNullOrEmpty(
$"{driverType}: the modal would let an operator save an enum spelling the driver refuses.");
EquipmentTagConfigInspector.Inspect(driverType, tagConfig).ShouldNotBeEmpty(
$"{driverType}: the deploy gate accepted a numeric enum spelling, so a CSV-imported or "
+ "API-authored tag binds to a type nobody named.");
}
// -------------------------------------------------------------------------------------------------
// Gitea #550 — MTConnect's second spelling of the coercion type. The assertions below drive the REAL
// runtime reader (MTConnectTagDefinitionFactory.TryFromTagConfig) rather than the deploy gate alone,