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:
@@ -7,7 +7,7 @@
|
|||||||
| Review date | 2026-05-22 |
|
| Review date | 2026-05-22 |
|
||||||
| Commit reviewed | `76d35d1` |
|
| Commit reviewed | `76d35d1` |
|
||||||
| Status | Reviewed |
|
| Status | Reviewed |
|
||||||
| Open findings | 3 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Checklist coverage
|
## Checklist coverage
|
||||||
|
|
||||||
@@ -168,13 +168,13 @@
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | OtOpcUa conventions |
|
| Category | OtOpcUa conventions |
|
||||||
| Location | `Components/App.razor:9,16` |
|
| 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.
|
**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.
|
**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
|
### Admin-011
|
||||||
|
|
||||||
@@ -183,13 +183,13 @@
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Location | `Hubs/FleetStatusPoller.cs:24-26,98-103` |
|
| 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.
|
**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.
|
**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
|
### Admin-012
|
||||||
|
|
||||||
@@ -198,13 +198,13 @@
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Design-document adherence |
|
| Category | Design-document adherence |
|
||||||
| Location | `Services/EquipmentCsvImporter.cs:18-19,33-37,229,232` |
|
| 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.
|
**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.
|
**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
|
### Admin-013
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,16 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>OtOpcUa Admin</title>
|
<title>OtOpcUa Admin</title>
|
||||||
<base href="/"/>
|
<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="theme.css"/>
|
||||||
<link rel="stylesheet" href="app.css"/>
|
<link rel="stylesheet" href="app.css"/>
|
||||||
<HeadOutlet/>
|
<HeadOutlet/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Routes/>
|
<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>
|
<script src="_framework/blazor.web.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<label class="form-label">CSV content (paste or uploaded)</label>
|
<label class="form-label">CSV content (paste or uploaded)</label>
|
||||||
<textarea class="form-control mono" rows="8" @bind="_csvText"
|
<textarea class="form-control mono" rows="8" @bind="_csvText"
|
||||||
placeholder="# OtOpcUaCsv v1 ZTag,MachineCode,SAPID,EquipmentId,…"/>
|
placeholder="# OtOpcUaCsv v1 ZTag,MachineCode,SAPID,EquipmentUuid,Name,…"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<button class="btn btn-sm btn-outline-primary" @onclick="ParseAsync" disabled="@_busy">Parse</button>
|
<button class="btn btn-sm btn-outline-primary" @onclick="ParseAsync" disabled="@_busy">Parse</button>
|
||||||
|
|||||||
Binary file not shown.
@@ -15,8 +15,13 @@ namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
|||||||
/// columns must all be present. The version bump handshake lets future shapes parse
|
/// columns must all be present. The version bump handshake lets future shapes parse
|
||||||
/// without ambiguity — v2 files go through a different parser variant.</para>
|
/// without ambiguity — v2 files go through a different parser variant.</para>
|
||||||
///
|
///
|
||||||
/// <para><b>Required columns</b> per decision #117: ZTag, MachineCode, SAPID,
|
/// <para><b>Required columns</b> per decision #117 + admin-ui.md "Equipment CSV import"
|
||||||
/// EquipmentId, EquipmentUuid, Name, UnsAreaName, UnsLineName.</para>
|
/// (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,
|
/// <para><b>Optional columns</b> per decision #139: Manufacturer, Model, SerialNumber,
|
||||||
/// HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation,
|
/// HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation,
|
||||||
@@ -32,7 +37,8 @@ public static class EquipmentCsvImporter
|
|||||||
|
|
||||||
public static IReadOnlyList<string> RequiredColumns { get; } = new[]
|
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",
|
"Name", "UnsAreaName", "UnsLineName",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,7 +142,6 @@ public static class EquipmentCsvImporter
|
|||||||
ZTag = cells[colIndex["ZTag"]],
|
ZTag = cells[colIndex["ZTag"]],
|
||||||
MachineCode = cells[colIndex["MachineCode"]],
|
MachineCode = cells[colIndex["MachineCode"]],
|
||||||
SAPID = cells[colIndex["SAPID"]],
|
SAPID = cells[colIndex["SAPID"]],
|
||||||
EquipmentId = cells[colIndex["EquipmentId"]],
|
|
||||||
EquipmentUuid = cells[colIndex["EquipmentUuid"]],
|
EquipmentUuid = cells[colIndex["EquipmentUuid"]],
|
||||||
Name = cells[colIndex["Name"]],
|
Name = cells[colIndex["Name"]],
|
||||||
UnsAreaName = cells[colIndex["UnsAreaName"]],
|
UnsAreaName = cells[colIndex["UnsAreaName"]],
|
||||||
@@ -157,7 +162,6 @@ public static class EquipmentCsvImporter
|
|||||||
"ZTag" => row.ZTag,
|
"ZTag" => row.ZTag,
|
||||||
"MachineCode" => row.MachineCode,
|
"MachineCode" => row.MachineCode,
|
||||||
"SAPID" => row.SAPID,
|
"SAPID" => row.SAPID,
|
||||||
"EquipmentId" => row.EquipmentId,
|
|
||||||
"EquipmentUuid" => row.EquipmentUuid,
|
"EquipmentUuid" => row.EquipmentUuid,
|
||||||
"Name" => row.Name,
|
"Name" => row.Name,
|
||||||
"UnsAreaName" => row.UnsAreaName,
|
"UnsAreaName" => row.UnsAreaName,
|
||||||
@@ -225,11 +229,11 @@ public static class EquipmentCsvImporter
|
|||||||
/// <summary>One parsed equipment row with required + optional fields.</summary>
|
/// <summary>One parsed equipment row with required + optional fields.</summary>
|
||||||
public sealed class EquipmentCsvRow
|
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 ZTag { get; init; }
|
||||||
public required string MachineCode { get; init; }
|
public required string MachineCode { get; init; }
|
||||||
public required string SAPID { get; init; }
|
public required string SAPID { get; init; }
|
||||||
public required string EquipmentId { get; init; }
|
|
||||||
public required string EquipmentUuid { get; init; }
|
public required string EquipmentUuid { get; init; }
|
||||||
public required string Name { get; init; }
|
public required string Name { get; init; }
|
||||||
public required string UnsAreaName { get; init; }
|
public required string UnsAreaName { get; init; }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
@@ -65,6 +66,14 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
|
|||||||
|
|
||||||
foreach (var row in acceptedRows)
|
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
|
db.EquipmentImportRows.Add(new EquipmentImportRow
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -73,7 +82,7 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
|
|||||||
ZTag = row.ZTag,
|
ZTag = row.ZTag,
|
||||||
MachineCode = row.MachineCode,
|
MachineCode = row.MachineCode,
|
||||||
SAPID = row.SAPID,
|
SAPID = row.SAPID,
|
||||||
EquipmentId = row.EquipmentId,
|
EquipmentId = derivedEquipmentId,
|
||||||
EquipmentUuid = row.EquipmentUuid,
|
EquipmentUuid = row.EquipmentUuid,
|
||||||
Name = row.Name,
|
Name = row.Name,
|
||||||
UnsAreaName = row.UnsAreaName,
|
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();
|
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
|
db.Equipment.Add(new Equipment
|
||||||
{
|
{
|
||||||
EquipmentRowId = Guid.NewGuid(),
|
EquipmentRowId = Guid.NewGuid(),
|
||||||
GenerationId = generationId,
|
GenerationId = generationId,
|
||||||
EquipmentId = row.EquipmentId,
|
EquipmentId = derivedEquipmentId,
|
||||||
EquipmentUuid = equipmentUuid,
|
EquipmentUuid = equipmentUuid,
|
||||||
DriverInstanceId = driverInstanceIdForRows,
|
DriverInstanceId = driverInstanceIdForRows,
|
||||||
UnsLineId = unsLineIdForRows,
|
UnsLineId = unsLineIdForRows,
|
||||||
|
|||||||
@@ -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.
|
||||||
6
src/Server/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/lib/bootstrap/css/bootstrap.min.css
vendored
Normal file
6
src/Server/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/lib/bootstrap/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
7
src/Server/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/lib/bootstrap/js/bootstrap.bundle.min.js
vendored
Normal file
7
src/Server/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/lib/bootstrap/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,10 @@ namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
|||||||
[Trait("Category", "Unit")]
|
[Trait("Category", "Unit")]
|
||||||
public sealed class EquipmentCsvImporterTests
|
public sealed class EquipmentCsvImporterTests
|
||||||
{
|
{
|
||||||
|
// Admin-012: header no longer includes EquipmentId — that field is system-derived.
|
||||||
private const string Header =
|
private const string Header =
|
||||||
"# OtOpcUaCsv v1\n" +
|
"# OtOpcUaCsv v1\n" +
|
||||||
"ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName";
|
"ZTag,MachineCode,SAPID,EquipmentUuid,Name,UnsAreaName,UnsLineName";
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EmptyFile_Throws()
|
public void EmptyFile_Throws()
|
||||||
@@ -20,7 +21,7 @@ public sealed class EquipmentCsvImporterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void MissingVersionMarker_Throws()
|
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));
|
var ex = Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
|
||||||
ex.Message.ShouldContain("# OtOpcUaCsv v1");
|
ex.Message.ShouldContain("# OtOpcUaCsv v1");
|
||||||
@@ -30,8 +31,8 @@ public sealed class EquipmentCsvImporterTests
|
|||||||
public void MissingRequiredColumn_Throws()
|
public void MissingRequiredColumn_Throws()
|
||||||
{
|
{
|
||||||
var csv = "# OtOpcUaCsv v1\n" +
|
var csv = "# OtOpcUaCsv v1\n" +
|
||||||
"ZTag,MachineCode,SAPID,EquipmentId,Name,UnsAreaName,UnsLineName\n" +
|
"ZTag,MachineCode,SAPID,Name,UnsAreaName,UnsLineName\n" +
|
||||||
"z1,mc,sap,eq1,Name1,area,line";
|
"z1,mc,sap,Name1,area,line";
|
||||||
|
|
||||||
var ex = Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
|
var ex = Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
|
||||||
ex.Message.ShouldContain("EquipmentUuid");
|
ex.Message.ShouldContain("EquipmentUuid");
|
||||||
@@ -40,7 +41,7 @@ public sealed class EquipmentCsvImporterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void UnknownColumn_Throws()
|
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));
|
var ex = Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
|
||||||
ex.Message.ShouldContain("WeirdColumn");
|
ex.Message.ShouldContain("WeirdColumn");
|
||||||
@@ -50,8 +51,8 @@ public sealed class EquipmentCsvImporterTests
|
|||||||
public void DuplicateColumn_Throws()
|
public void DuplicateColumn_Throws()
|
||||||
{
|
{
|
||||||
var csv = "# OtOpcUaCsv v1\n" +
|
var csv = "# OtOpcUaCsv v1\n" +
|
||||||
"ZTag,ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName\n" +
|
"ZTag,ZTag,MachineCode,SAPID,EquipmentUuid,Name,UnsAreaName,UnsLineName\n" +
|
||||||
"z1,z1,mc,sap,eq,uu,Name,area,line";
|
"z1,z1,mc,sap,uu,Name,area,line";
|
||||||
|
|
||||||
Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
|
Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
|
||||||
}
|
}
|
||||||
@@ -59,7 +60,7 @@ public sealed class EquipmentCsvImporterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ValidSingleRow_RoundTrips()
|
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);
|
var result = EquipmentCsvImporter.Parse(csv);
|
||||||
|
|
||||||
@@ -76,8 +77,8 @@ public sealed class EquipmentCsvImporterTests
|
|||||||
public void OptionalColumns_Populated_WhenPresent()
|
public void OptionalColumns_Populated_WhenPresent()
|
||||||
{
|
{
|
||||||
var csv = "# OtOpcUaCsv v1\n" +
|
var csv = "# OtOpcUaCsv v1\n" +
|
||||||
"ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName,Manufacturer,Model,SerialNumber,HardwareRevision,SoftwareRevision,YearOfConstruction,AssetLocation,ManufacturerUri,DeviceManualUri\n" +
|
"ZTag,MachineCode,SAPID,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";
|
"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);
|
var result = EquipmentCsvImporter.Parse(csv);
|
||||||
|
|
||||||
@@ -93,7 +94,7 @@ public sealed class EquipmentCsvImporterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void BlankRequiredField_Rejects_Row()
|
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);
|
var result = EquipmentCsvImporter.Parse(csv);
|
||||||
|
|
||||||
@@ -106,8 +107,8 @@ public sealed class EquipmentCsvImporterTests
|
|||||||
public void DuplicateZTag_Rejects_SecondRow()
|
public void DuplicateZTag_Rejects_SecondRow()
|
||||||
{
|
{
|
||||||
var csv = Header +
|
var csv = Header +
|
||||||
"\nz-1,MC1,SAP1,eq1,u1,N1,A,L1" +
|
"\nz-1,MC1,SAP1,u1,N1,A,L1" +
|
||||||
"\nz-1,MC2,SAP2,eq2,u2,N2,A,L1";
|
"\nz-1,MC2,SAP2,u2,N2,A,L1";
|
||||||
|
|
||||||
var result = EquipmentCsvImporter.Parse(csv);
|
var result = EquipmentCsvImporter.Parse(csv);
|
||||||
|
|
||||||
@@ -121,7 +122,7 @@ public sealed class EquipmentCsvImporterTests
|
|||||||
{
|
{
|
||||||
// RFC 4180: "" inside a quoted field is an escaped quote.
|
// RFC 4180: "" inside a quoted field is an escaped quote.
|
||||||
var csv = Header +
|
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);
|
var result = EquipmentCsvImporter.Parse(csv);
|
||||||
|
|
||||||
@@ -133,7 +134,7 @@ public sealed class EquipmentCsvImporterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void MismatchedColumnCount_Rejects_Row()
|
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);
|
var result = EquipmentCsvImporter.Parse(csv);
|
||||||
|
|
||||||
@@ -146,9 +147,9 @@ public sealed class EquipmentCsvImporterTests
|
|||||||
public void BlankLines_BetweenRows_AreIgnored()
|
public void BlankLines_BetweenRows_AreIgnored()
|
||||||
{
|
{
|
||||||
var csv = Header +
|
var csv = Header +
|
||||||
"\nz-1,MC,SAP,eq1,u1,N1,A,L1" +
|
"\nz-1,MC,SAP,u1,N1,A,L1" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"\nz-2,MC,SAP,eq2,u2,N2,A,L1";
|
"\nz-2,MC,SAP,u2,N2,A,L1";
|
||||||
|
|
||||||
var result = EquipmentCsvImporter.Parse(csv);
|
var result = EquipmentCsvImporter.Parse(csv);
|
||||||
|
|
||||||
@@ -159,8 +160,9 @@ public sealed class EquipmentCsvImporterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Header_Constants_Match_Decision_117_and_139()
|
public void Header_Constants_Match_Decision_117_and_139()
|
||||||
{
|
{
|
||||||
|
// Admin-012: EquipmentId is intentionally absent — derived from EquipmentUuid at finalise time.
|
||||||
EquipmentCsvImporter.RequiredColumns.ShouldBe(
|
EquipmentCsvImporter.RequiredColumns.ShouldBe(
|
||||||
["ZTag", "MachineCode", "SAPID", "EquipmentId", "EquipmentUuid", "Name", "UnsAreaName", "UnsLineName"]);
|
["ZTag", "MachineCode", "SAPID", "EquipmentUuid", "Name", "UnsAreaName", "UnsLineName"]);
|
||||||
|
|
||||||
EquipmentCsvImporter.OptionalColumns.ShouldBe(
|
EquipmentCsvImporter.OptionalColumns.ShouldBe(
|
||||||
["Manufacturer", "Model", "SerialNumber", "HardwareRevision", "SoftwareRevision",
|
["Manufacturer", "Model", "SerialNumber", "HardwareRevision", "SoftwareRevision",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,12 +25,12 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
|||||||
|
|
||||||
// Unique SAPID per row — FinaliseBatch reserves ZTag + SAPID via filtered-unique index, so
|
// Unique SAPID per row — FinaliseBatch reserves ZTag + SAPID via filtered-unique index, so
|
||||||
// two rows sharing a SAPID under different EquipmentUuids collide as intended.
|
// 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()
|
private static EquipmentCsvRow Row(string zTag, string name = "eq-1") => new()
|
||||||
{
|
{
|
||||||
ZTag = zTag,
|
ZTag = zTag,
|
||||||
MachineCode = "mc",
|
MachineCode = "mc",
|
||||||
SAPID = $"sap-{zTag}",
|
SAPID = $"sap-{zTag}",
|
||||||
EquipmentId = "eq-id",
|
|
||||||
EquipmentUuid = Guid.NewGuid().ToString(),
|
EquipmentUuid = Guid.NewGuid().ToString(),
|
||||||
Name = name,
|
Name = name,
|
||||||
UnsAreaName = "area",
|
UnsAreaName = "area",
|
||||||
@@ -189,7 +189,7 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
|||||||
var row = new EquipmentCsvRow
|
var row = new EquipmentCsvRow
|
||||||
{
|
{
|
||||||
ZTag = "z-shared", MachineCode = "mc", SAPID = "sap-shared",
|
ZTag = "z-shared", MachineCode = "mc", SAPID = "sap-shared",
|
||||||
EquipmentId = "eq-1", EquipmentUuid = sharedUuid.ToString(),
|
EquipmentUuid = sharedUuid.ToString(),
|
||||||
Name = "eq-1", UnsAreaName = "a", UnsLineName = "l",
|
Name = "eq-1", UnsAreaName = "a", UnsLineName = "l",
|
||||||
};
|
};
|
||||||
await _svc.StageRowsAsync(batch1.Id, [row], [], CancellationToken.None);
|
await _svc.StageRowsAsync(batch1.Id, [row], [], CancellationToken.None);
|
||||||
@@ -212,17 +212,18 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
|||||||
var rowA = new EquipmentCsvRow
|
var rowA = new EquipmentCsvRow
|
||||||
{
|
{
|
||||||
ZTag = "z-collide", MachineCode = "mc-a", SAPID = "sap-a",
|
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",
|
Name = "a", UnsAreaName = "ar", UnsLineName = "ln",
|
||||||
};
|
};
|
||||||
await _svc.StageRowsAsync(batchA.Id, [rowA], [], CancellationToken.None);
|
await _svc.StageRowsAsync(batchA.Id, [rowA], [], CancellationToken.None);
|
||||||
await _svc.FinaliseBatchAsync(batchA.Id, 1, "drv", "line", CancellationToken.None);
|
await _svc.FinaliseBatchAsync(batchA.Id, 1, "drv", "line", CancellationToken.None);
|
||||||
|
|
||||||
var batchB = await _svc.CreateBatchAsync("c1", "bob", CancellationToken.None);
|
var batchB = await _svc.CreateBatchAsync("c1", "bob", CancellationToken.None);
|
||||||
|
var rowBUuid = Guid.NewGuid();
|
||||||
var rowB = new EquipmentCsvRow
|
var rowB = new EquipmentCsvRow
|
||||||
{
|
{
|
||||||
ZTag = "z-collide", MachineCode = "mc-b", SAPID = "sap-b", // same ZTag, different EquipmentUuid
|
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",
|
Name = "b", UnsAreaName = "ar", UnsLineName = "ln",
|
||||||
};
|
};
|
||||||
await _svc.StageRowsAsync(batchB.Id, [rowB], [], CancellationToken.None);
|
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));
|
_svc.FinaliseBatchAsync(batchB.Id, 2, "drv", "line", CancellationToken.None));
|
||||||
ex.Message.ShouldContain("z-collide");
|
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()
|
var equipmentB = await _db.Equipment.AsNoTracking()
|
||||||
.Where(e => e.EquipmentId == "eq-b")
|
.Where(e => e.EquipmentUuid == rowBUuid)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
equipmentB.ShouldBeEmpty();
|
equipmentB.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
@@ -245,7 +246,7 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
|||||||
var row = new EquipmentCsvRow
|
var row = new EquipmentCsvRow
|
||||||
{
|
{
|
||||||
ZTag = "", MachineCode = "mc", SAPID = "",
|
ZTag = "", MachineCode = "mc", SAPID = "",
|
||||||
EquipmentId = "eq-nil", EquipmentUuid = Guid.NewGuid().ToString(),
|
EquipmentUuid = Guid.NewGuid().ToString(),
|
||||||
Name = "nil", UnsAreaName = "ar", UnsLineName = "ln",
|
Name = "nil", UnsAreaName = "ar", UnsLineName = "ln",
|
||||||
};
|
};
|
||||||
await _svc.StageRowsAsync(batch.Id, [row], [], CancellationToken.None);
|
await _svc.StageRowsAsync(batch.Id, [row], [], CancellationToken.None);
|
||||||
@@ -294,7 +295,7 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
|||||||
var conflictRow = new EquipmentCsvRow
|
var conflictRow = new EquipmentCsvRow
|
||||||
{
|
{
|
||||||
ZTag = "z-taken", MachineCode = "mc", SAPID = "sap-ok",
|
ZTag = "z-taken", MachineCode = "mc", SAPID = "sap-ok",
|
||||||
EquipmentId = "eq-x", EquipmentUuid = importerUuid.ToString(),
|
EquipmentUuid = importerUuid.ToString(),
|
||||||
Name = "x", UnsAreaName = "ar", UnsLineName = "ln",
|
Name = "x", UnsAreaName = "ar", UnsLineName = "ln",
|
||||||
};
|
};
|
||||||
var cleanRow = Row("z-clean");
|
var cleanRow = Row("z-clean");
|
||||||
@@ -334,7 +335,7 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
|||||||
var conflictRow = new EquipmentCsvRow
|
var conflictRow = new EquipmentCsvRow
|
||||||
{
|
{
|
||||||
ZTag = "z-free", MachineCode = "mc", SAPID = "sap-taken",
|
ZTag = "z-free", MachineCode = "mc", SAPID = "sap-taken",
|
||||||
EquipmentId = "eq-y", EquipmentUuid = importerUuid.ToString(),
|
EquipmentUuid = importerUuid.ToString(),
|
||||||
Name = "y", UnsAreaName = "ar", UnsLineName = "ln",
|
Name = "y", UnsAreaName = "ar", UnsLineName = "ln",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -370,7 +371,7 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
|||||||
var row = new EquipmentCsvRow
|
var row = new EquipmentCsvRow
|
||||||
{
|
{
|
||||||
ZTag = "z-mine", MachineCode = "mc", SAPID = "sap-mine",
|
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",
|
Name = "z", UnsAreaName = "ar", UnsLineName = "ln",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -405,7 +406,7 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
|||||||
var row = new EquipmentCsvRow
|
var row = new EquipmentCsvRow
|
||||||
{
|
{
|
||||||
ZTag = "z-released", MachineCode = "mc", SAPID = "sap-new",
|
ZTag = "z-released", MachineCode = "mc", SAPID = "sap-new",
|
||||||
EquipmentId = "eq-new", EquipmentUuid = newImporterUuid.ToString(),
|
EquipmentUuid = newImporterUuid.ToString(),
|
||||||
Name = "new", UnsAreaName = "ar", UnsLineName = "ln",
|
Name = "new", UnsAreaName = "ar", UnsLineName = "ln",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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<,></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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user