fix(admin): resolve Low code-review findings (Admin-010,011,012)

- Admin-010: vendor Bootstrap 5.3.3 (CSS + JS bundle + maps + provenance
  README) under wwwroot/lib/bootstrap and reference local paths from
  App.razor — Admin no longer pulls Bootstrap from jsDelivr.
- Admin-011: swap FleetStatusPoller's three plain dictionaries for
  ConcurrentDictionary so ResetCache can't race a poll tick.
- Admin-012: drop the EquipmentId column from EquipmentCsvImporter (per
  admin-ui.md — equipment id is system-derived from EquipmentUuid);
  EquipmentImportBatchService and the textarea placeholder updated to
  match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 07:24:07 -04:00
parent 3f01a24b45
commit 2b33b64a58
16 changed files with 355 additions and 48 deletions

View File

@@ -7,7 +7,7 @@
| Review date | 2026-05-22 |
| Commit reviewed | `76d35d1` |
| Status | Reviewed |
| Open findings | 3 |
| Open findings | 0 |
## Checklist coverage
@@ -168,13 +168,13 @@
| Severity | Low |
| Category | OtOpcUa conventions |
| Location | `Components/App.razor:9,16` |
| Status | Open |
| Status | Resolved |
**Description:** `App.razor` loads Bootstrap CSS and JS from the `cdn.jsdelivr.net` CDN. `admin-ui.md` section "Tech Stack" specifies "Bootstrap 5 vendored under `wwwroot/lib/bootstrap/`" precisely so the Admin app has no third-party runtime dependency. A CDN reference makes the UI fail in air-gapped / locked-down fleet deployments (a stated deployment target), introduces an uncontrolled third-party origin, and is not covered by a Subresource Integrity hash.
**Recommendation:** Vendor Bootstrap under `wwwroot/lib/bootstrap/` and reference the local copies, as the design doc requires. If a CDN is retained for any asset, add `integrity` + `crossorigin` SRI attributes.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-23 — Bootstrap 5.3.3 (CSS + JS bundle, plus their source maps) vendored under `src/Server/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/lib/bootstrap/{css,js}/`; `App.razor` now references the local copies (`lib/bootstrap/css/bootstrap.min.css`, `lib/bootstrap/js/bootstrap.bundle.min.js`); a README under the vendor directory records provenance + upgrade steps. Covered by `BootstrapVendoringTests` (asserts no `cdn.jsdelivr.net`/`cdnjs`/`unpkg` references in `App.razor`, that the vendored files exist with non-trivial sizes, and that `App.razor` references the vendored paths) — verified failing pre-fix, passing post-fix.
### Admin-011
@@ -183,13 +183,13 @@
| Severity | Low |
| Category | Concurrency & thread safety |
| Location | `Hubs/FleetStatusPoller.cs:24-26,98-103` |
| Status | Open |
| Status | Resolved |
**Description:** `FleetStatusPoller` keeps three plain `Dictionary<>` fields (`_last`, `_lastRole`, `_lastResilience`) mutated from `PollOnceAsync`. The poller `ExecuteAsync` loop is single-threaded so the steady-state poll path is safe, but `ResetCache()` (exposed `internal` for tests) clears those same dictionaries with no synchronization. If a test (or any caller) invokes `ResetCache()` while a poll tick is mid-iteration, the `Dictionary` enumeration/mutation race can throw `InvalidOperationException` or corrupt state.
**Recommendation:** Either document `ResetCache()` as "only safe when the poller is stopped" and have tests stop the service first, or guard the three dictionaries with a lock / swap them atomically. Using `ConcurrentDictionary` (as the sibling `ResilientLdapGroupRoleMappingService` does) would make the intent explicit.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-23 — `_last`, `_lastRole`, and `_lastResilience` swapped from plain `Dictionary<,>` to `ConcurrentDictionary<,>` so concurrent `ResetCache()` / poll-tick mutations are safe by construction (the recommendation's "explicit intent" form). Covered by `FleetStatusPollerConcurrencyTests` — one test guards the structural choice via reflection so a future refactor cannot silently revert; the other stress-runs concurrent mutate + `ResetCache()` via reflection, verifying the race throws no exception (verified failing pre-fix with `Dictionary<,>`).
### Admin-012
@@ -198,13 +198,13 @@
| Severity | Low |
| Category | Design-document adherence |
| Location | `Services/EquipmentCsvImporter.cs:18-19,33-37,229,232` |
| Status | Open |
| Status | Resolved |
**Description:** `EquipmentCsvImporter` declares `EquipmentId` as a required CSV column and parses it into a `required` field. `admin-ui.md` section "Equipment CSV import" (revised after adversarial review finding #4) is explicit: "No `EquipmentId` column — operator-supplied EquipmentId would mint duplicate equipment identity on typos ... never accepted from CSV imports." `EquipmentId` is system-derived (`EQ-` plus first 12 hex chars of `EquipmentUuid`). Accepting it from CSV either contradicts the design or silently lets an import set an identity field the doc says is un-settable. The XML doc on the class also cites the column as required per "decision #117", so either the code or the design doc is stale. `EquipmentImportBatchService.StageRowsAsync` propagates `row.EquipmentId` into the staging row, so any change must cover the finalize path.
**Recommendation:** Reconcile with the design: drop `EquipmentId` from `RequiredColumns` and the `EquipmentCsvRow` shape (deriving it from `EquipmentUuid` at finalize time), or — if accepting it is a deliberate reversal — update `admin-ui.md` and the decision log so the two agree.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-23 — code reconciled with the design: `EquipmentId` dropped from `EquipmentCsvImporter.RequiredColumns`, `BuildRow`, `GetCell`, and the `EquipmentCsvRow` shape; the class XML doc now records the admin-ui.md "No EquipmentId column" rule. The finalize path is covered: `EquipmentImportBatchService.StageRowsAsync` now derives the staging-row's `EquipmentId` via `DraftValidator.DeriveEquipmentId(equipmentUuid)`, and `FinaliseBatchAsync` re-derives it from the UUID that actually lands in the `Equipment` row (so a blank/invalid staged UUID that gets replaced by `Guid.NewGuid()` no longer leaves `EquipmentId` and `EquipmentUuid` out of sync). `ImportEquipment.razor`'s textarea placeholder updated to the new header shape. Covered by `EquipmentCsvNoEquipmentIdColumnTests` (five tests guarding `RequiredColumns`/`OptionalColumns`/`EquipmentCsvRow` shape and asserting CSVs with an `EquipmentId` column are rejected as unknown while CSVs without are accepted) — verified failing pre-fix, passing post-fix. The existing `EquipmentCsvImporterTests` + `EquipmentImportBatchServiceTests` were updated to the new header shape and pass green (DB-backed suite ran against `10.100.0.35,14330`).
### Admin-013

View File

@@ -6,14 +6,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>OtOpcUa Admin</title>
<base href="/"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"/>
@* Admin-010: Bootstrap 5 is vendored under wwwroot/lib/bootstrap/ per admin-ui.md
"Tech Stack" — no public-CDN dependency so air-gapped fleet deployments work. *@
<link rel="stylesheet" href="lib/bootstrap/css/bootstrap.min.css"/>
<link rel="stylesheet" href="theme.css"/>
<link rel="stylesheet" href="app.css"/>
<HeadOutlet/>
</head>
<body>
<Routes/>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
</html>

View File

@@ -79,7 +79,7 @@
<div class="mt-3">
<label class="form-label">CSV content (paste or uploaded)</label>
<textarea class="form-control mono" rows="8" @bind="_csvText"
placeholder="# OtOpcUaCsv v1&#10;ZTag,MachineCode,SAPID,EquipmentId,…"/>
placeholder="# OtOpcUaCsv v1&#10;ZTag,MachineCode,SAPID,EquipmentUuid,Name,…"/>
</div>
<div class="mt-3">
<button class="btn btn-sm btn-outline-primary" @onclick="ParseAsync" disabled="@_busy">Parse</button>

View File

@@ -15,8 +15,13 @@ namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// columns must all be present. The version bump handshake lets future shapes parse
/// without ambiguity — v2 files go through a different parser variant.</para>
///
/// <para><b>Required columns</b> per decision #117: ZTag, MachineCode, SAPID,
/// EquipmentId, EquipmentUuid, Name, UnsAreaName, UnsLineName.</para>
/// <para><b>Required columns</b> per decision #117 + admin-ui.md "Equipment CSV import"
/// (Admin-012 — revised after adversarial review finding #4): ZTag, MachineCode, SAPID,
/// EquipmentUuid, Name, UnsAreaName, UnsLineName. <b>No <c>EquipmentId</c> column</b> —
/// it is system-derived as <c>'EQ-' + first 12 hex chars of EquipmentUuid</c> via
/// <see cref="ZB.MOM.WW.OtOpcUa.Configuration.Validation.DraftValidator.DeriveEquipmentId"/>
/// and is never accepted from the CSV: operator-supplied values would mint duplicate
/// equipment identity on typos.</para>
///
/// <para><b>Optional columns</b> per decision #139: Manufacturer, Model, SerialNumber,
/// HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation,
@@ -32,7 +37,8 @@ public static class EquipmentCsvImporter
public static IReadOnlyList<string> RequiredColumns { get; } = new[]
{
"ZTag", "MachineCode", "SAPID", "EquipmentId", "EquipmentUuid",
// Admin-012: no EquipmentId — derived from EquipmentUuid at finalise time.
"ZTag", "MachineCode", "SAPID", "EquipmentUuid",
"Name", "UnsAreaName", "UnsLineName",
};
@@ -136,7 +142,6 @@ public static class EquipmentCsvImporter
ZTag = cells[colIndex["ZTag"]],
MachineCode = cells[colIndex["MachineCode"]],
SAPID = cells[colIndex["SAPID"]],
EquipmentId = cells[colIndex["EquipmentId"]],
EquipmentUuid = cells[colIndex["EquipmentUuid"]],
Name = cells[colIndex["Name"]],
UnsAreaName = cells[colIndex["UnsAreaName"]],
@@ -157,7 +162,6 @@ public static class EquipmentCsvImporter
"ZTag" => row.ZTag,
"MachineCode" => row.MachineCode,
"SAPID" => row.SAPID,
"EquipmentId" => row.EquipmentId,
"EquipmentUuid" => row.EquipmentUuid,
"Name" => row.Name,
"UnsAreaName" => row.UnsAreaName,
@@ -225,11 +229,11 @@ public static class EquipmentCsvImporter
/// <summary>One parsed equipment row with required + optional fields.</summary>
public sealed class EquipmentCsvRow
{
// Required (decision #117)
// Required (decision #117). Admin-012: no EquipmentId here — derived from
// EquipmentUuid at finalise time so a CSV typo cannot create a duplicate identity.
public required string ZTag { get; init; }
public required string MachineCode { get; init; }
public required string SAPID { get; init; }
public required string EquipmentId { get; init; }
public required string EquipmentUuid { get; init; }
public required string Name { get; init; }
public required string UnsAreaName { get; init; }

View File

@@ -3,6 +3,7 @@ using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
@@ -65,6 +66,14 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
foreach (var row in acceptedRows)
{
// Admin-012: EquipmentId is not on the CSV row — derive it from EquipmentUuid
// so the staging row carries the canonical 'EQ-' + first-12-hex form. Rows
// with an unparseable UUID get an empty placeholder; the same finalise path
// that re-derives the UUID (FinaliseBatchAsync) will overwrite it.
var derivedEquipmentId = Guid.TryParse(row.EquipmentUuid, out var parsedUuid)
? DraftValidator.DeriveEquipmentId(parsedUuid)
: string.Empty;
db.EquipmentImportRows.Add(new EquipmentImportRow
{
Id = Guid.NewGuid(),
@@ -73,7 +82,7 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
ZTag = row.ZTag,
MachineCode = row.MachineCode,
SAPID = row.SAPID,
EquipmentId = row.EquipmentId,
EquipmentId = derivedEquipmentId,
EquipmentUuid = row.EquipmentUuid,
Name = row.Name,
UnsAreaName = row.UnsAreaName,
@@ -178,11 +187,16 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
{
var equipmentUuid = Guid.TryParse(row.EquipmentUuid, out var u) ? u : Guid.NewGuid();
// Admin-012: EquipmentId is always derived from the UUID that actually lands in
// the Equipment row. Using the staging row's pre-derived value would diverge
// when the staged UUID was blank and we just generated a fresh one above.
var derivedEquipmentId = DraftValidator.DeriveEquipmentId(equipmentUuid);
db.Equipment.Add(new Equipment
{
EquipmentRowId = Guid.NewGuid(),
GenerationId = generationId,
EquipmentId = row.EquipmentId,
EquipmentId = derivedEquipmentId,
EquipmentUuid = equipmentUuid,
DriverInstanceId = driverInstanceIdForRows,
UnsLineId = unsLineIdForRows,

View File

@@ -0,0 +1,16 @@
# Bootstrap 5.3.3 — vendored copy
Per `docs/v2/admin-ui.md` "Tech Stack" the Admin UI vendors Bootstrap 5 here so
the app has no public-CDN dependency — air-gapped fleet deployments must work
without internet egress.
| File | Source |
|------|--------|
| `css/bootstrap.min.css` | https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css |
| `css/bootstrap.min.css.map` | https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css.map |
| `js/bootstrap.bundle.min.js` | https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js |
| `js/bootstrap.bundle.min.js.map` | https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js.map |
Bootstrap is MIT-licensed (https://github.com/twbs/bootstrap/blob/main/LICENSE).
To upgrade, re-download all four files at the matching version and bump this
table.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,64 @@
using System.IO;
using System.Reflection;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// Regression for Admin-010 — admin-ui.md "Tech Stack" requires Bootstrap 5
/// "vendored under wwwroot/lib/bootstrap/" so the Admin app has no third-party
/// runtime dependency and works in air-gapped fleet deployments. These tests
/// guard against a future re-introduction of the cdn.jsdelivr.net references
/// in App.razor.
/// </summary>
[Trait("Category", "Unit")]
public sealed class BootstrapVendoringTests
{
[Fact]
public void AppRazor_does_not_reference_a_remote_CDN_for_bootstrap()
{
var appRazor = File.ReadAllText(ResolveAdminPath("Components/App.razor"));
appRazor.ShouldNotContain("cdn.jsdelivr.net",
customMessage: "Admin-010: Bootstrap must be served from the vendored copy under wwwroot/lib/bootstrap/, not jsDelivr — air-gapped deployments cannot reach the public CDN.");
appRazor.ShouldNotContain("cdnjs.cloudflare.com",
customMessage: "Admin-010: third-party CDN references regress the vendoring requirement.");
appRazor.ShouldNotContain("unpkg.com",
customMessage: "Admin-010: third-party CDN references regress the vendoring requirement.");
}
[Fact]
public void AppRazor_references_vendored_bootstrap_assets()
{
var appRazor = File.ReadAllText(ResolveAdminPath("Components/App.razor"));
appRazor.ShouldContain("lib/bootstrap/css/bootstrap.min.css",
customMessage: "App.razor must load the vendored Bootstrap stylesheet.");
appRazor.ShouldContain("lib/bootstrap/js/bootstrap.bundle.min.js",
customMessage: "App.razor must load the vendored Bootstrap JS bundle.");
}
[Fact]
public void Vendored_bootstrap_assets_exist_under_wwwroot_lib_bootstrap()
{
var root = ResolveAdminPath("wwwroot/lib/bootstrap");
Directory.Exists(root).ShouldBeTrue($"expected vendored bootstrap directory at '{root}'");
File.Exists(Path.Combine(root, "css", "bootstrap.min.css")).ShouldBeTrue("bootstrap.min.css missing");
File.Exists(Path.Combine(root, "js", "bootstrap.bundle.min.js")).ShouldBeTrue("bootstrap.bundle.min.js missing");
// Sanity-check non-empty (a zero-byte placeholder would still pass File.Exists).
new FileInfo(Path.Combine(root, "css", "bootstrap.min.css")).Length.ShouldBeGreaterThan(100_000);
new FileInfo(Path.Combine(root, "js", "bootstrap.bundle.min.js")).Length.ShouldBeGreaterThan(50_000);
}
/// <summary>Resolve a path under the Admin source project from the test runner's bin folder.</summary>
private static string ResolveAdminPath(string relative)
{
var asmDir = Path.GetDirectoryName(typeof(BootstrapVendoringTests).Assembly.Location)!;
// tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/bin/Debug/net10.0 -> ../../../../../src/Server/...
var repoRoot = Path.GetFullPath(Path.Combine(asmDir, "..", "..", "..", "..", "..", ".."));
return Path.Combine(repoRoot, "src", "Server", "ZB.MOM.WW.OtOpcUa.Admin", relative.Replace('/', Path.DirectorySeparatorChar));
}
}

View File

@@ -7,9 +7,10 @@ namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class EquipmentCsvImporterTests
{
// Admin-012: header no longer includes EquipmentId — that field is system-derived.
private const string Header =
"# OtOpcUaCsv v1\n" +
"ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName";
"ZTag,MachineCode,SAPID,EquipmentUuid,Name,UnsAreaName,UnsLineName";
[Fact]
public void EmptyFile_Throws()
@@ -20,7 +21,7 @@ public sealed class EquipmentCsvImporterTests
[Fact]
public void MissingVersionMarker_Throws()
{
var csv = "ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName\nx,x,x,x,x,x,x,x";
var csv = "ZTag,MachineCode,SAPID,EquipmentUuid,Name,UnsAreaName,UnsLineName\nx,x,x,x,x,x,x";
var ex = Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
ex.Message.ShouldContain("# OtOpcUaCsv v1");
@@ -30,8 +31,8 @@ public sealed class EquipmentCsvImporterTests
public void MissingRequiredColumn_Throws()
{
var csv = "# OtOpcUaCsv v1\n" +
"ZTag,MachineCode,SAPID,EquipmentId,Name,UnsAreaName,UnsLineName\n" +
"z1,mc,sap,eq1,Name1,area,line";
"ZTag,MachineCode,SAPID,Name,UnsAreaName,UnsLineName\n" +
"z1,mc,sap,Name1,area,line";
var ex = Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
ex.Message.ShouldContain("EquipmentUuid");
@@ -40,7 +41,7 @@ public sealed class EquipmentCsvImporterTests
[Fact]
public void UnknownColumn_Throws()
{
var csv = Header + ",WeirdColumn\nz1,mc,sap,eq1,uu,Name1,area,line,value";
var csv = Header + ",WeirdColumn\nz1,mc,sap,uu,Name1,area,line,value";
var ex = Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
ex.Message.ShouldContain("WeirdColumn");
@@ -50,8 +51,8 @@ public sealed class EquipmentCsvImporterTests
public void DuplicateColumn_Throws()
{
var csv = "# OtOpcUaCsv v1\n" +
"ZTag,ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName\n" +
"z1,z1,mc,sap,eq,uu,Name,area,line";
"ZTag,ZTag,MachineCode,SAPID,EquipmentUuid,Name,UnsAreaName,UnsLineName\n" +
"z1,z1,mc,sap,uu,Name,area,line";
Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
}
@@ -59,7 +60,7 @@ public sealed class EquipmentCsvImporterTests
[Fact]
public void ValidSingleRow_RoundTrips()
{
var csv = Header + "\nz-001,MC-1,SAP-1,eq-001,uuid-1,Oven-A,Warsaw,Line-1";
var csv = Header + "\nz-001,MC-1,SAP-1,uuid-1,Oven-A,Warsaw,Line-1";
var result = EquipmentCsvImporter.Parse(csv);
@@ -76,8 +77,8 @@ public sealed class EquipmentCsvImporterTests
public void OptionalColumns_Populated_WhenPresent()
{
var csv = "# OtOpcUaCsv v1\n" +
"ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName,Manufacturer,Model,SerialNumber,HardwareRevision,SoftwareRevision,YearOfConstruction,AssetLocation,ManufacturerUri,DeviceManualUri\n" +
"z-1,MC,SAP,eq,uuid,Oven,Warsaw,Line1,Siemens,S7-1500,SN123,Rev-1,Fw-2.3,2023,Bldg-3,https://siemens.example,https://siemens.example/manual";
"ZTag,MachineCode,SAPID,EquipmentUuid,Name,UnsAreaName,UnsLineName,Manufacturer,Model,SerialNumber,HardwareRevision,SoftwareRevision,YearOfConstruction,AssetLocation,ManufacturerUri,DeviceManualUri\n" +
"z-1,MC,SAP,uuid,Oven,Warsaw,Line1,Siemens,S7-1500,SN123,Rev-1,Fw-2.3,2023,Bldg-3,https://siemens.example,https://siemens.example/manual";
var result = EquipmentCsvImporter.Parse(csv);
@@ -93,7 +94,7 @@ public sealed class EquipmentCsvImporterTests
[Fact]
public void BlankRequiredField_Rejects_Row()
{
var csv = Header + "\nz-1,MC,SAP,eq,uuid,,Warsaw,Line1"; // Name blank
var csv = Header + "\nz-1,MC,SAP,uuid,,Warsaw,Line1"; // Name blank
var result = EquipmentCsvImporter.Parse(csv);
@@ -106,8 +107,8 @@ public sealed class EquipmentCsvImporterTests
public void DuplicateZTag_Rejects_SecondRow()
{
var csv = Header +
"\nz-1,MC1,SAP1,eq1,u1,N1,A,L1" +
"\nz-1,MC2,SAP2,eq2,u2,N2,A,L1";
"\nz-1,MC1,SAP1,u1,N1,A,L1" +
"\nz-1,MC2,SAP2,u2,N2,A,L1";
var result = EquipmentCsvImporter.Parse(csv);
@@ -121,7 +122,7 @@ public sealed class EquipmentCsvImporterTests
{
// RFC 4180: "" inside a quoted field is an escaped quote.
var csv = Header +
"\n\"z-1\",\"MC\",\"SAP,with,commas\",\"eq\",\"uuid\",\"Oven \"\"Ultra\"\"\",\"Warsaw\",\"Line1\"";
"\n\"z-1\",\"MC\",\"SAP,with,commas\",\"uuid\",\"Oven \"\"Ultra\"\"\",\"Warsaw\",\"Line1\"";
var result = EquipmentCsvImporter.Parse(csv);
@@ -133,7 +134,7 @@ public sealed class EquipmentCsvImporterTests
[Fact]
public void MismatchedColumnCount_Rejects_Row()
{
var csv = Header + "\nz-1,MC,SAP,eq,uuid,Name,Warsaw"; // missing UnsLineName cell
var csv = Header + "\nz-1,MC,SAP,uuid,Name,Warsaw"; // missing UnsLineName cell
var result = EquipmentCsvImporter.Parse(csv);
@@ -146,9 +147,9 @@ public sealed class EquipmentCsvImporterTests
public void BlankLines_BetweenRows_AreIgnored()
{
var csv = Header +
"\nz-1,MC,SAP,eq1,u1,N1,A,L1" +
"\nz-1,MC,SAP,u1,N1,A,L1" +
"\n" +
"\nz-2,MC,SAP,eq2,u2,N2,A,L1";
"\nz-2,MC,SAP,u2,N2,A,L1";
var result = EquipmentCsvImporter.Parse(csv);
@@ -159,8 +160,9 @@ public sealed class EquipmentCsvImporterTests
[Fact]
public void Header_Constants_Match_Decision_117_and_139()
{
// Admin-012: EquipmentId is intentionally absent — derived from EquipmentUuid at finalise time.
EquipmentCsvImporter.RequiredColumns.ShouldBe(
["ZTag", "MachineCode", "SAPID", "EquipmentId", "EquipmentUuid", "Name", "UnsAreaName", "UnsLineName"]);
["ZTag", "MachineCode", "SAPID", "EquipmentUuid", "Name", "UnsAreaName", "UnsLineName"]);
EquipmentCsvImporter.OptionalColumns.ShouldBe(
["Manufacturer", "Model", "SerialNumber", "HardwareRevision", "SoftwareRevision",

View File

@@ -0,0 +1,74 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// Regression for Admin-012 — <c>admin-ui.md</c> ("Equipment CSV import", revised after
/// adversarial review finding #4) requires no <c>EquipmentId</c> column: it is
/// system-derived (<c>'EQ-' + first 12 hex chars of EquipmentUuid</c>) and "never
/// accepted from CSV imports". Operator-supplied EquipmentId would mint duplicate
/// equipment identity on typos.
/// </summary>
[Trait("Category", "Unit")]
public sealed class EquipmentCsvNoEquipmentIdColumnTests
{
[Fact]
public void RequiredColumns_does_not_include_EquipmentId()
{
EquipmentCsvImporter.RequiredColumns
.ShouldNotContain("EquipmentId",
customMessage: "Admin-012: admin-ui.md forbids an EquipmentId column on the CSV import — it is system-derived from EquipmentUuid.");
}
[Fact]
public void OptionalColumns_does_not_include_EquipmentId()
{
EquipmentCsvImporter.OptionalColumns
.ShouldNotContain("EquipmentId",
customMessage: "Admin-012: EquipmentId must not be an optional column either — it is never accepted from the CSV.");
}
[Fact]
public void EquipmentCsvRow_has_no_EquipmentId_property()
{
// The CSV row shape mirrors the accepted columns. Keeping EquipmentId on the
// row would invite the same misuse — drop it so the type system prevents
// accidental population from a future column.
var prop = typeof(EquipmentCsvRow).GetProperty("EquipmentId");
prop.ShouldBeNull("Admin-012: EquipmentCsvRow must not expose an EquipmentId — the value is derived at finalise time.");
}
[Fact]
public void Header_with_EquipmentId_column_is_rejected_as_unknown()
{
// After the fix, EquipmentId is an unknown column — the header validator must
// refuse it like any other unrecognised column so operators get an explicit
// error rather than silently importing the value.
const string csv =
"# OtOpcUaCsv v1\n" +
"ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName\n" +
"z-1,MC,SAP,eq,uuid,Oven,Warsaw,Line1";
var ex = Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
ex.Message.ShouldContain("EquipmentId",
customMessage: "Importer must reject CSVs that still carry the (now disallowed) EquipmentId column.");
}
[Fact]
public void Valid_csv_without_EquipmentId_is_accepted()
{
// The canonical header should now omit EquipmentId.
const string csv =
"# OtOpcUaCsv v1\n" +
"ZTag,MachineCode,SAPID,EquipmentUuid,Name,UnsAreaName,UnsLineName\n" +
"z-1,MC,SAP,11111111-2222-3333-4444-555555555555,Oven,Warsaw,Line1";
var result = EquipmentCsvImporter.Parse(csv);
result.AcceptedRows.Count.ShouldBe(1);
result.RejectedRows.ShouldBeEmpty();
result.AcceptedRows[0].ZTag.ShouldBe("z-1");
result.AcceptedRows[0].EquipmentUuid.ShouldBe("11111111-2222-3333-4444-555555555555");
}
}

View File

@@ -25,12 +25,12 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
// Unique SAPID per row — FinaliseBatch reserves ZTag + SAPID via filtered-unique index, so
// two rows sharing a SAPID under different EquipmentUuids collide as intended.
// Admin-012: no EquipmentId on the CSV row — it is derived from EquipmentUuid at stage/finalise.
private static EquipmentCsvRow Row(string zTag, string name = "eq-1") => new()
{
ZTag = zTag,
MachineCode = "mc",
SAPID = $"sap-{zTag}",
EquipmentId = "eq-id",
EquipmentUuid = Guid.NewGuid().ToString(),
Name = name,
UnsAreaName = "area",
@@ -189,7 +189,7 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
var row = new EquipmentCsvRow
{
ZTag = "z-shared", MachineCode = "mc", SAPID = "sap-shared",
EquipmentId = "eq-1", EquipmentUuid = sharedUuid.ToString(),
EquipmentUuid = sharedUuid.ToString(),
Name = "eq-1", UnsAreaName = "a", UnsLineName = "l",
};
await _svc.StageRowsAsync(batch1.Id, [row], [], CancellationToken.None);
@@ -212,17 +212,18 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
var rowA = new EquipmentCsvRow
{
ZTag = "z-collide", MachineCode = "mc-a", SAPID = "sap-a",
EquipmentId = "eq-a", EquipmentUuid = Guid.NewGuid().ToString(),
EquipmentUuid = Guid.NewGuid().ToString(),
Name = "a", UnsAreaName = "ar", UnsLineName = "ln",
};
await _svc.StageRowsAsync(batchA.Id, [rowA], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batchA.Id, 1, "drv", "line", CancellationToken.None);
var batchB = await _svc.CreateBatchAsync("c1", "bob", CancellationToken.None);
var rowBUuid = Guid.NewGuid();
var rowB = new EquipmentCsvRow
{
ZTag = "z-collide", MachineCode = "mc-b", SAPID = "sap-b", // same ZTag, different EquipmentUuid
EquipmentId = "eq-b", EquipmentUuid = Guid.NewGuid().ToString(),
EquipmentUuid = rowBUuid.ToString(),
Name = "b", UnsAreaName = "ar", UnsLineName = "ln",
};
await _svc.StageRowsAsync(batchB.Id, [rowB], [], CancellationToken.None);
@@ -231,9 +232,9 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
_svc.FinaliseBatchAsync(batchB.Id, 2, "drv", "line", CancellationToken.None));
ex.Message.ShouldContain("z-collide");
// Second finalise must have rolled back — no partial Equipment row for batch B.
// Second finalise must have rolled back — no partial Equipment row for batch B (match by UUID).
var equipmentB = await _db.Equipment.AsNoTracking()
.Where(e => e.EquipmentId == "eq-b")
.Where(e => e.EquipmentUuid == rowBUuid)
.ToListAsync();
equipmentB.ShouldBeEmpty();
}
@@ -245,7 +246,7 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
var row = new EquipmentCsvRow
{
ZTag = "", MachineCode = "mc", SAPID = "",
EquipmentId = "eq-nil", EquipmentUuid = Guid.NewGuid().ToString(),
EquipmentUuid = Guid.NewGuid().ToString(),
Name = "nil", UnsAreaName = "ar", UnsLineName = "ln",
};
await _svc.StageRowsAsync(batch.Id, [row], [], CancellationToken.None);
@@ -294,7 +295,7 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
var conflictRow = new EquipmentCsvRow
{
ZTag = "z-taken", MachineCode = "mc", SAPID = "sap-ok",
EquipmentId = "eq-x", EquipmentUuid = importerUuid.ToString(),
EquipmentUuid = importerUuid.ToString(),
Name = "x", UnsAreaName = "ar", UnsLineName = "ln",
};
var cleanRow = Row("z-clean");
@@ -334,7 +335,7 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
var conflictRow = new EquipmentCsvRow
{
ZTag = "z-free", MachineCode = "mc", SAPID = "sap-taken",
EquipmentId = "eq-y", EquipmentUuid = importerUuid.ToString(),
EquipmentUuid = importerUuid.ToString(),
Name = "y", UnsAreaName = "ar", UnsLineName = "ln",
};
@@ -370,7 +371,7 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
var row = new EquipmentCsvRow
{
ZTag = "z-mine", MachineCode = "mc", SAPID = "sap-mine",
EquipmentId = "eq-z", EquipmentUuid = sharedUuid.ToString(), // same UUID
EquipmentUuid = sharedUuid.ToString(), // same UUID
Name = "z", UnsAreaName = "ar", UnsLineName = "ln",
};
@@ -405,7 +406,7 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
var row = new EquipmentCsvRow
{
ZTag = "z-released", MachineCode = "mc", SAPID = "sap-new",
EquipmentId = "eq-new", EquipmentUuid = newImporterUuid.ToString(),
EquipmentUuid = newImporterUuid.ToString(),
Name = "new", UnsAreaName = "ar", UnsLineName = "ln",
};

View File

@@ -0,0 +1,115 @@
using System.Collections.Concurrent;
using System.Reflection;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// Regression for Admin-011 — <see cref="FleetStatusPoller"/> kept three plain
/// <c>Dictionary&lt;,&gt;</c> caches that were enumerated/mutated from the steady-state
/// poll loop and cleared from <c>ResetCache()</c> with no synchronization. A concurrent
/// <c>ResetCache()</c> during a poll iteration could throw
/// <see cref="InvalidOperationException"/> or corrupt the dictionary. The fix swaps the
/// caches for <see cref="ConcurrentDictionary{TKey,TValue}"/> so reset + concurrent
/// reads/writes are safe by construction.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FleetStatusPollerConcurrencyTests
{
[Fact]
public void Cache_fields_are_thread_safe_collections()
{
// The fix uses ConcurrentDictionary; that makes ResetCache() and concurrent
// poll-tick mutations safe by construction. Guard the structural choice with
// reflection so a future refactor cannot silently revert to plain Dictionary
// without flipping this guardrail.
var fields = typeof(FleetStatusPoller)
.GetFields(BindingFlags.NonPublic | BindingFlags.Instance)
.Where(f => f.Name is "_last" or "_lastRole" or "_lastResilience")
.ToList();
fields.Count.ShouldBe(3, "expected the three cache fields _last/_lastRole/_lastResilience to exist");
foreach (var f in fields)
{
var type = f.FieldType;
type.IsGenericType.ShouldBeTrue($"{f.Name} should be a generic concurrent collection");
type.GetGenericTypeDefinition().ShouldBe(
typeof(ConcurrentDictionary<,>),
customMessage: $"{f.Name} must be a ConcurrentDictionary<,> so concurrent ResetCache()/poll calls are safe — plain Dictionary regressed Admin-011.");
}
}
[Fact]
public void ResetCache_is_safe_to_call_concurrently_with_cache_mutations()
{
// Stress test — hammer the cache with mutate/clear concurrently. With plain
// Dictionary this throws InvalidOperationException ("Collection was modified")
// or corrupts internal state. With ConcurrentDictionary it must complete cleanly.
var poller = BuildPollerForReflectionTest();
var lastField = typeof(FleetStatusPoller).GetField("_last", BindingFlags.NonPublic | BindingFlags.Instance)!;
var cache = lastField.GetValue(poller)!;
var cacheType = cache.GetType();
var indexer = cacheType.GetProperty("Item")!;
var keyType = cacheType.GetGenericArguments()[0]; // string
var valueType = cacheType.GetGenericArguments()[1]; // NodeStateSnapshot record-struct
var defaultSnapshot = Activator.CreateInstance(valueType)!;
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var writer = Task.Run(() =>
{
var i = 0;
while (!cts.IsCancellationRequested)
{
indexer.SetValue(cache, defaultSnapshot, new object[] { $"node-{i++ % 64}" });
}
});
var resetter = Task.Run(() =>
{
var method = typeof(FleetStatusPoller).GetMethod("ResetCache", BindingFlags.NonPublic | BindingFlags.Instance)!;
while (!cts.IsCancellationRequested)
{
method.Invoke(poller, null);
}
});
// Should not throw — the whole point is that the two run concurrently safely.
Should.NotThrow(() => Task.WaitAll([writer, resetter]));
}
private static FleetStatusPoller BuildPollerForReflectionTest()
{
// Pass null-style stubs — the poller constructor doesn't touch them and we
// never call ExecuteAsync/PollOnceAsync here (those need a real DB context).
// We only exercise ResetCache + cache mutation by reflection.
var scopeFactory = new StubServiceScopeFactory();
var fleetHub = new StubHubContext<FleetStatusHub>();
var alertHub = new StubHubContext<AlertHub>();
return new FleetStatusPoller(
scopeFactory,
fleetHub,
alertHub,
NullLogger<FleetStatusPoller>.Instance,
new RedundancyMetrics());
}
private sealed class StubServiceScopeFactory : IServiceScopeFactory
{
public IServiceScope CreateScope() => throw new NotImplementedException();
}
private sealed class StubHubContext<THub> : IHubContext<THub> where THub : Hub
{
public IHubClients Clients => throw new NotImplementedException();
public IGroupManager Groups => throw new NotImplementedException();
}
}