review(b2-waveA): /raw auto-expand enterprise roots; friendly create-race + delete failures; auth-guard classifies /raw + dev demo

Wave-A reviewer findings (no Critical/High):
- M-2: GlobalRaw auto-expands Enterprise roots so the Cluster level is visible on load (mirrors GlobalUns.ExpandStructural); clusters stay lazily collapsed.
- L-1: create mutations route SaveChanges through SaveCreateAsync — a lost sibling-name race (filtered-unique-index clash) becomes a friendly failure instead of an uncaught DbUpdateException.
- L-2: SaveDeleteAsync no longer leaks raw EF/SQL text; operator-friendly guidance.
- L-5: RawNode.ChildCount doc clarified (direct children only).
- Regression fix: PageAuthorizationGuardTests classifies the two new routable pages (/raw → ConfigEditor, /dev/context-menu-demo → AuthenticatedRead).
M-1 (ordinal vs SQL-collation sibling uniqueness) assessed benign: name indexes use the DB default collation and the server-side pre-check runs under that same collation, so pre-check and index agree — no case-variant rows can coexist, so runtime ordinal RawPath keying never collides.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 02:45:06 -04:00
parent fa9d2af430
commit 928e06dd01
4 changed files with 37 additions and 13 deletions
@@ -55,6 +55,12 @@
try
{
_roots = await Svc.LoadRootsAsync();
// Auto-expand the Enterprise roots so the Cluster level (which owns the lazy plumbing) is
// visible on load, mirroring GlobalUns.ExpandStructural. Enterprise children (clusters) are
// eagerly materialised by LoadRootsAsync, so this needs no lazy fetch; clusters stay
// collapsed and load their folders/drivers on first expand.
foreach (var enterprise in _roots)
enterprise.Expanded = true;
}
catch (Exception ex)
{
@@ -69,7 +69,7 @@ public sealed class RawNode
/// <summary>Whether a Driver/Device node is enabled (drives the enable/disable menu label + styling); true otherwise.</summary>
public bool Enabled { get; init; } = true;
/// <summary>Direct child count (for the badge). For a device this is its tag-group + tag count.</summary>
/// <summary>Direct child count (for the badge) — for a device, its root tag-groups + root tags (direct children only, not the whole subtree).</summary>
public int ChildCount { get; set; }
/// <summary>
@@ -370,8 +370,7 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
ParentRawFolderId = parentFolderId,
Name = name,
});
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null, id);
return await SaveCreateAsync(db, "folder", id, ct);
}
/// <inheritdoc />
@@ -510,8 +509,7 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
DeviceConfig = "{}",
});
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null, driverId);
return await SaveCreateAsync(db, "driver", driverId, ct);
}
/// <inheritdoc />
@@ -618,8 +616,7 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
Name = name,
DeviceConfig = string.IsNullOrWhiteSpace(deviceConfigJson) ? "{}" : deviceConfigJson,
});
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null, id);
return await SaveCreateAsync(db, "device", id, ct);
}
/// <inheritdoc />
@@ -703,8 +700,7 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
ParentTagGroupId = parentGroupId,
Name = name,
});
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null, id);
return await SaveCreateAsync(db, "tag group", id, ct);
}
/// <inheritdoc />
@@ -840,10 +836,30 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
{
return new UnsMutationResult(false, $"Another user changed this {noun} while you were viewing it.");
}
catch (Exception ex)
catch (Exception)
{
// Defensive net for a real SQL Server where a child slipped past the pre-check (race).
return new UnsMutationResult(false, $"Delete failed: {ex.Message}.");
// Don't surface the raw EF/SQL text (leaks schema detail); give operator-friendly guidance.
return new UnsMutationResult(false,
$"Delete failed — a related record still references this {noun}. Reload and retry.");
}
}
/// <summary>Commits a create, translating a lost sibling-name race (filtered-unique-index clash) into a friendly failure.</summary>
private static async Task<UnsMutationResult> SaveCreateAsync(
OtOpcUaConfigDbContext db, string noun, string createdId, CancellationToken ct)
{
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null, createdId);
}
catch (DbUpdateException)
{
// The name-uniqueness pre-check narrows but doesn't close the race against a concurrent
// create of the same-name sibling; the DB's filtered-unique index is the backstop.
return new UnsMutationResult(false,
$"A {noun} with that name already exists here (created concurrently). Reload and retry.");
}
}
@@ -29,8 +29,9 @@ public class PageAuthorizationGuardTests
/// </summary>
private static readonly IReadOnlyDictionary<Type, string> ExpectedPolicy = new Dictionary<Type, string>
{
// ── ConfigEditor (20): the config-authoring surface (incl. live ResilienceConfig) ──
// ── ConfigEditor (21): the config-authoring surface (incl. live ResilienceConfig) ──
[typeof(GlobalUns)] = AdminUiPolicies.ConfigEditor,
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Raw.GlobalRaw)] = AdminUiPolicies.ConfigEditor,
[typeof(EquipmentPage)] = AdminUiPolicies.ConfigEditor,
[typeof(NewCluster)] = AdminUiPolicies.ConfigEditor,
[typeof(ClusterEdit)] = AdminUiPolicies.ConfigEditor,
@@ -54,8 +55,9 @@ public class PageAuthorizationGuardTests
// ── FleetAdmin (1) ──
[typeof(RoleGrants)] = AdminUiPolicies.FleetAdmin,
// ── AuthenticatedRead (16): dashboards, lists, live tails (read-only) ──
// ── AuthenticatedRead (17): dashboards, lists, live tails (read-only) + the dev ContextMenu demo ──
[typeof(Home)] = AdminUiPolicies.AuthenticatedRead,
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Dev.ContextMenuDemo)] = AdminUiPolicies.AuthenticatedRead,
[typeof(Fleet)] = AdminUiPolicies.AuthenticatedRead,
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Hosts)] = AdminUiPolicies.AuthenticatedRead,
[typeof(Alerts)] = AdminUiPolicies.AuthenticatedRead,