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
@@ -196,4 +196,31 @@ public class SqlEquipmentTagParserTests
=> SqlEquipmentTagParser.TryParse(
"""{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Nonsense"}""",
"raw", out _).ShouldBeFalse();
/// <summary>
/// Driver.Sql.Contracts-002. A numerically-spelled enum is not a member name, and the strict read
/// must refuse it exactly as it refuses a typo. It used to parse — <c>"99"</c> gave
/// <c>(DriverDataType)99</c>, a declared type nothing can map, and <c>SqlPollReader.TryCoerce</c>
/// then fell through its switch to the <c>String</c> arm and published a stringified cell under it.
/// </summary>
[Theory]
[InlineData("""{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"99"}""")]
[InlineData("""{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"8"}""")]
// "1" is SqlTagModel.WideRow's ordinal — an otherwise-complete WideRow blob, so the rejection can only
// come from the enum read and not from a missing field.
[InlineData("""{"driver":"Sql","model":"1","table":"T","columnName":"c","rowSelector":{"topByTimestamp":"ts"}}""")]
public void TryParse_aNumericallySpelledEnum_rejectsTag(string json)
=> SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse();
/// <summary>
/// …and the deploy gate explains it in the same words every other invalid enum draws, rather than
/// falling through to the backstop warning that names no field at all.
/// </summary>
[Fact]
public void Inspect_aNumericallySpelledType_namesTheFieldAndTheValidMembers()
{
var warnings = SqlTagDefinitionFactory.Inspect(
"""{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"99"}""");
warnings.ShouldContain(w => w.Contains("'99'") && w.Contains("type") && w.Contains(nameof(DriverDataType.Float64)));
}
}