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:
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user