fix(tagconfig): close the four #551 residuals — accept-gate-first Inspect, three authoring rules, CSV/service gate, doc sweep

Gitea #551.

1. CSV import ran no driver-typed config gate. Its typed columns were always
   strictly parsed, but the free-form TagConfigJson column reached the database
   unread, so a CSV could author a tag the driver rejects — one that deploys
   green and reads BadNodeIdUnknown forever. RawTagCsvMapper.Parse now names the
   offending row in the staged review grid, and RawTreeService enforces the same
   gate on CreateTag/UpdateTag/ImportTags: a form cannot be the guarantee (the
   API and any future caller reach none of them, and this repo has no bUnit).
   A rejected import row is an ERROR — the batch inserts nothing and the row is
   named — never a skip.

   Note the brief's premise that manual entry rides ImportTagsAsync is wrong:
   RawManualTagEntryModal commits row-by-row via CreateTagAsync, which is why
   the gate had to land on all three write paths.

2/3. The seven pre-#549 Inspect methods ran a hand-list of DescribeInvalidEnum
   checks, so every rejection cause not on the list was invisible. They are now
   ACCEPT-GATE-FIRST (the MTConnect/Sql/OpcUaClient/Calculation shape): ask the
   driver's own reader for the verdict, then explain the refusal, with a
   catch-all so "the parse rejects but Inspect said nothing" is unreachable.
   That closes S7's absent dataType, AbCip members[], Modbus stringByteOrder and
   the type-dependent guards, plus causes the issue did not name (absent
   region/address/tagPath/symbolPath/topic, TwinCAT Structure, MQTT's Sparkplug
   tuple). FOCAS and MQTT keep advisories about ACCEPTED blobs, so they
   diagnose rather than return empty.

   Authoring half: ModbusTagConfigModel gains stringByteOrder;
   AbCipTagConfigModel asks the new AbCipTagDefinitionFactory.DescribeInvalidMember;
   S7TagConfigModel captures whether the SOURCE blob declared dataType and asks
   S7TagDefinitionFactory.DataTypeKey/DeclaresDataType. One authority per rule.

4. Doc sweep: the six factory Inspect summaries, EquipmentTagConfigInspector's
   class remarks and AdminOperationsActor's comment all claimed invalid enums are
   "silently defaulted by the lenient runtime" — false since R2-11 Phase C made
   the runtime reads strict. They describe rejections now.
This commit is contained in:
Joseph Doherty
2026-08-10 00:08:06 -04:00
parent b0d2fa30e3
commit 05b424788f
24 changed files with 1846 additions and 100 deletions
@@ -1,5 +1,6 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
@@ -834,6 +835,9 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
t => t.DeviceId == deviceId && t.TagGroupId == tagGroupId && t.Name == input.Name, ct))
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists here.");
if (await ValidateTagConfigAsync(db, deviceId, tagConfig, ct) is { } configError)
return new UnsMutationResult(false, configError);
var id = NewId("TAG");
db.Tags.Add(new Tag
{
@@ -872,6 +876,9 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
&& t.Name == input.Name && t.TagId != tagId, ct))
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists here.");
if (await ValidateTagConfigAsync(db, entity.DeviceId, tagConfig, ct) is { } configError)
return new UnsMutationResult(false, configError);
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
entity.Name = input.Name;
entity.DataType = input.DataType;
@@ -902,6 +909,10 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
if (!await db.Devices.AnyAsync(dev => dev.DeviceId == deviceId, ct))
return new RawTagImportResult(0, new[] { $"Device '{deviceId}' not found." });
// The device's driver type, resolved ONCE: an import is per-device, and a device hangs off exactly
// one driver, so every row in the batch is judged by the same driver's rules (Gitea #551 item 1).
var driverType = await ResolveDriverTypeAsync(db, deviceId, ct);
// Existing groups on the device: a (parent-group-id | null, name) → group-id lookup we extend with
// pending (auto-created) groups so a repeated path within the batch resolves to one group.
var groupLookup = (await db.TagGroups.AsNoTracking()
@@ -951,6 +962,15 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
continue;
}
// The driver-typed config gate. A rejected row is an ERROR — the import is all-or-nothing, so
// the batch inserts nothing and the row is NAMED; it is never skipped ("skipped" would claim
// the tag is already present). See ValidateTagConfigAsync for why this lives in the service.
if (TagConfigValidator.Validate(driverType, tagConfig) is { } configError)
{
errors.Add($"{rowLabel}: TagConfig: {configError}");
continue;
}
if (!takenNames.Add((groupId, row.Tag.Name)))
{
errors.Add($"{rowLabel}: a tag named '{row.Tag.Name}' already exists at that path.");
@@ -1196,6 +1216,55 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
}
}
/// <summary>
/// The driver-type string of the driver a device hangs off, or <see langword="null"/> when the
/// device (or its driver) is gone. Joined in exactly as <c>LoadDeviceForEditAsync</c> does — a
/// device carries no driver type of its own.
/// </summary>
/// <param name="db">The open context.</param>
/// <param name="deviceId">The device whose driver type to resolve.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The canonical driver-type string, or null.</returns>
private static Task<string?> ResolveDriverTypeAsync(
OtOpcUaConfigDbContext db, string deviceId, CancellationToken ct)
=> db.Devices.AsNoTracking()
.Where(dev => dev.DeviceId == deviceId)
.Join(db.DriverInstances.AsNoTracking(),
dev => dev.DriverInstanceId,
drv => drv.DriverInstanceId,
(dev, drv) => drv.DriverType)
.FirstOrDefaultAsync(ct);
/// <summary>
/// The driver-typed <c>TagConfig</c> gate, applied on the SERVER for every tag-writing path —
/// create, update and bulk import alike (Gitea #551 item 1).
/// <para>
/// <b>It lives here rather than in the modals because a form cannot be the guarantee.</b> The
/// tag modal and the manual-entry grid already run <see cref="TagConfigValidator"/> for fast
/// feedback, but the CSV import did not, and the API and any future caller reach none of them —
/// so a blob the driver rejects could be persisted, deploy clean, and read
/// <c>BadNodeIdUnknown</c> forever. This repo also has no bUnit, so a rule only a page enforces
/// is a rule no test can reach.
/// </para>
/// <para>
/// The rule itself is not stated here: <see cref="TagConfigValidator"/> dispatches to the
/// driver's typed model, which in turn asks the driver's own reader. An unmapped driver type
/// (Galaxy) validates to null, exactly as it does in the modals.
/// </para>
/// </summary>
/// <param name="db">The open context.</param>
/// <param name="deviceId">The device the tag belongs to (supplies the driver type).</param>
/// <param name="tagConfig">The normalised TagConfig JSON about to be persisted.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>An operator-facing error, or <see langword="null"/> when the config is acceptable.</returns>
private static async Task<string?> ValidateTagConfigAsync(
OtOpcUaConfigDbContext db, string deviceId, string tagConfig, CancellationToken ct)
{
var driverType = await ResolveDriverTypeAsync(db, deviceId, ct);
var error = TagConfigValidator.Validate(driverType, tagConfig);
return error is null ? null : $"TagConfig: {error}";
}
/// <summary>Ordinal (case-sensitive) equality for a nullable-parent + name tag-group key.</summary>
private sealed class GroupKeyComparer : IEqualityComparer<(string? Parent, string Name)>
{