fix(management): strict enum parsing for DataType/ElementDataType/AlarmTriggerType (P6-10)

ManagementActor's ParseDataType/ParseElementDataType (bare Enum.TryParse) and
the two AlarmTriggerType parse sites in HandleAddAlarm/HandleUpdateAlarm (raw
Enum.Parse) all admitted ordinals ("2"), undefined values ("99" — a
deploy-time bomb that saves fine and only detonates at deploy), and
comma-joined name lists (bitwise-ORed together regardless of [Flags], e.g.
"String,Int32" silently becoming DateType.DateTime). A typo against the raw
Enum.Parse sites also surfaced as a masked "An internal error occurred"
rather than a curated field-naming error.

Adds ParseEnumStrict<TEnum>, mirroring the existing ParseDeploymentStatus
guard (names only, defined values only, comma-token rejection per the
Medium-wave T8 nuance / AuditQueryParamParsers.ParseEnumListStrict pattern),
and routes all four call sites through it.

Tests: 22 new ManagementActorTests cases per parse site (ordinal, undefined
numeric, comma-joined, and typo all rejected with the curated field-naming
error; every defined name still parses through to the service layer).
Proof of failure recorded in each rejection test's doc comment; verified
live by stashing the ManagementActor.cs fix — exactly the 14 new rejection
tests fail pre-fix (13 acceptance tests pass unchanged), confirming the
defect and the fix.
This commit is contained in:
Joseph Doherty
2026-08-19 16:15:33 -04:00
parent 88fbc9e351
commit 7ad1872454
2 changed files with 297 additions and 10 deletions
@@ -2931,11 +2931,7 @@ public class ManagementActor : ReceiveActor
/// <see cref="ArgumentException"/> be masked as a generic internal error.
/// </summary>
private static Commons.Types.Enums.DataType ParseDataType(string? dataType)
{
if (!Enum.TryParse<Commons.Types.Enums.DataType>(dataType, ignoreCase: true, out var parsed))
throw new ManagementCommandException($"Unrecognised data type '{dataType}'.");
return parsed;
}
=> ParseEnumStrict<Commons.Types.Enums.DataType>("data type", dataType);
/// <summary>
/// Parses an optional element data type token. Returns null when the token is
@@ -2945,9 +2941,51 @@ public class ManagementActor : ReceiveActor
private static Commons.Types.Enums.DataType? ParseElementDataType(string? elementDataType)
{
if (string.IsNullOrWhiteSpace(elementDataType)) return null;
if (!Enum.TryParse<Commons.Types.Enums.DataType>(elementDataType, ignoreCase: true, out var parsed))
throw new ManagementCommandException($"Unrecognised element type '{elementDataType}'.");
return parsed;
return ParseEnumStrict<Commons.Types.Enums.DataType>("element type", elementDataType);
}
/// <summary>
/// Parses a single required enum token by NAME ONLY, rejecting ordinals,
/// comma-joined name lists, and defined-but-out-of-range composites; throws a
/// curated <see cref="ManagementCommandException"/> naming the field and the
/// valid value set on any rejection.
/// </summary>
/// <remarks>
/// Register row 88 pattern (see <see cref="ParseDeploymentStatus"/>). Plain
/// <c>Enum.TryParse</c>/<c>Enum.Parse</c> admits three traps a wire-contract
/// field must not tolerate:
/// <list type="number">
/// <item><b>Ordinals.</b> <c>"2"</c> parses to whatever member sits at that
/// position (e.g. <c>DataType.Float</c>) — a caller-facing field is a
/// name-based contract, never a positional one.</item>
/// <item><b>Undefined ordinals.</b> <c>"99"</c> parses to an undefined enum
/// value that saves without error and only detonates at deploy time.</item>
/// <item><b>Comma-joined tokens.</b> <c>Enum.TryParse</c> bitwise-ORs a
/// comma-separated string's pieces together regardless of whether the enum
/// is <c>[Flags]</c> — <c>"String,Int32"</c> (4|1) silently becomes
/// <c>DateTime</c> (5) for a dense 0-based enum. Any token containing a
/// comma is rejected outright, before <c>TryParse</c> ever sees it.</item>
/// </list>
/// Mirrors <see cref="Commons.Types.Audit.AuditQueryParamParsers.ParseEnumListStrict{TEnum}"/>,
/// adapted to a single required value and <see cref="ManagementCommandException"/>.
/// </remarks>
/// <exception cref="ManagementCommandException">The value is not a defined member name of <typeparamref name="TEnum"/>.</exception>
private static TEnum ParseEnumStrict<TEnum>(string fieldName, string? raw)
where TEnum : struct, Enum
{
var trimmed = raw?.Trim() ?? string.Empty;
var looksNumeric = trimmed.Length > 0
&& (char.IsAsciiDigit(trimmed[0]) || trimmed[0] == '+' || trimmed[0] == '-');
if (!looksNumeric
&& !trimmed.Contains(',')
&& Enum.TryParse<TEnum>(trimmed, ignoreCase: true, out var parsed)
&& Enum.IsDefined(parsed))
{
return parsed;
}
throw new ManagementCommandException(
$"Unrecognised {fieldName} '{raw}'. Valid values: {string.Join(", ", Enum.GetNames<TEnum>())}.");
}
/// <summary>
@@ -3000,7 +3038,7 @@ public class ManagementActor : ReceiveActor
var svc = sp.GetRequiredService<TemplateService>();
var alarm = new TemplateAlarm(cmd.Name)
{
TriggerType = Enum.Parse<ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmTriggerType>(cmd.TriggerType, ignoreCase: true),
TriggerType = ParseEnumStrict<ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmTriggerType>("alarm trigger type", cmd.TriggerType),
PriorityLevel = cmd.PriorityLevel,
Description = cmd.Description,
TriggerConfiguration = cmd.TriggerConfiguration,
@@ -3015,7 +3053,7 @@ public class ManagementActor : ReceiveActor
var svc = sp.GetRequiredService<TemplateService>();
var alarm = new TemplateAlarm(cmd.Name)
{
TriggerType = Enum.Parse<ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmTriggerType>(cmd.TriggerType, ignoreCase: true),
TriggerType = ParseEnumStrict<ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmTriggerType>("alarm trigger type", cmd.TriggerType),
PriorityLevel = cmd.PriorityLevel,
Description = cmd.Description,
TriggerConfiguration = cmd.TriggerConfiguration,