diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor
index c591b86..4f34d37 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor
@@ -1,6 +1,8 @@
@page "/alarms/historian"
+@using Microsoft.AspNetCore.Components.Web
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian
+@rendermode RenderMode.InteractiveServer
@inject HistorianDiagnosticsService Diag
Alarm historian
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor
index 6c41036..a037bea 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor
@@ -1,6 +1,8 @@
@page "/certificates"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = AdminRoles.FleetAdmin)]
+@using Microsoft.AspNetCore.Components.Web
@using ZB.MOM.WW.OtOpcUa.Admin.Services
+@rendermode RenderMode.InteractiveServer
@inject CertTrustService Certs
@inject AuthenticationStateProvider AuthState
@inject ILogger Log
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor
index f0a4f3f..a599117 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor
@@ -223,7 +223,7 @@ else
{
if (!firstRender || _hub is not null) return;
_hub = new HubConnectionBuilder()
- .WithUrl(Nav.ToAbsoluteUri("/hubs/fleet-status"))
+ .WithUrl(Nav.ToAbsoluteUri("/hubs/fleet"))
.WithAutomaticReconnect()
.Build();
_hub.On("NodeAclChanged", async msg =>
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor
index d268331..266266c 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor
@@ -1,8 +1,10 @@
@page "/clusters/{ClusterId}/draft/{GenerationId:long}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
+@using Microsoft.AspNetCore.Components.Web
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@using ZB.MOM.WW.OtOpcUa.Configuration.Validation
+@rendermode RenderMode.InteractiveServer
@inject GenerationService GenerationSvc
@inject DraftValidationService ValidationSvc
@inject NavigationManager Nav
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ImportEquipment.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ImportEquipment.razor
index 11fc42a..d3bf9c4 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ImportEquipment.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ImportEquipment.razor
@@ -1,7 +1,9 @@
@page "/clusters/{ClusterId}/draft/{GenerationId:long}/import-equipment"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@using Microsoft.AspNetCore.Components.Authorization
+@using Microsoft.AspNetCore.Components.Web
@using ZB.MOM.WW.OtOpcUa.Admin.Services
+@rendermode RenderMode.InteractiveServer
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject DriverInstanceService DriverSvc
@@ -32,9 +34,9 @@
Required columns: @string.Join(", ", EquipmentCsvImporter.RequiredColumns).
Optional columns cover the OPC 40010 Identification fields. Paste the file contents
or upload directly — the parser runs client-stream-side and shows a row-level preview
- before anything lands in the draft. ZTag + SAPID uniqueness across the fleet is NOT
- enforced here yet (see task #197); for now the finalise may fail at commit time if a
- reservation conflict exists.
+ before anything lands in the draft. ZTag + SAPID reservation conflicts (task #197) are
+ checked at parse time: rows whose ZTag or SAPID is already reserved by a different
+ EquipmentUuid appear in the Rejected list so you can resolve them before finalising.
@@ -184,14 +186,20 @@
_csvText = await reader.ReadToEndAsync();
}
- private void ParseAsync()
+ private async Task ParseAsync()
{
_parseError = null;
_parseResult = null;
_result = null;
- try { _parseResult = EquipmentCsvImporter.Parse(_csvText); }
+ _busy = true;
+ try
+ {
+ var raw = EquipmentCsvImporter.Parse(_csvText);
+ _parseResult = await BatchSvc.ApplyReservationPreCheckAsync(raw, CancellationToken.None);
+ }
catch (InvalidCsvFormatException ex) { _parseError = ex.Message; }
catch (Exception ex) { _parseError = $"Parse failed: {ex.Message}"; }
+ finally { _busy = false; }
}
private async Task StageAndFinaliseAsync()
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/NewCluster.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/NewCluster.razor
index bb8c31a..d3a4d8e 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/NewCluster.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/NewCluster.razor
@@ -1,8 +1,10 @@
@page "/clusters/new"
@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Components.Web
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
+@rendermode RenderMode.InteractiveServer
@inject ClusterService ClusterSvc
@inject GenerationService GenerationSvc
@inject NavigationManager Nav
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/RedundancyTab.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/RedundancyTab.razor
index 5b2fa0d..655113d 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/RedundancyTab.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/RedundancyTab.razor
@@ -131,7 +131,7 @@ else
private async Task ConnectHubAsync()
{
_hub = new HubConnectionBuilder()
- .WithUrl(Nav.ToAbsoluteUri("/hubs/fleet-status"))
+ .WithUrl(Nav.ToAbsoluteUri("/hubs/fleet"))
.WithAutomaticReconnect()
.Build();
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor
index 946f598..e258558 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor
@@ -1,7 +1,9 @@
@page "/fleet"
+@using Microsoft.AspNetCore.Components.Web
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
+@rendermode RenderMode.InteractiveServer
@inject IServiceScopeFactory ScopeFactory
@implements IDisposable
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Home.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Home.razor
index 036637c..4c14634 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Home.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Home.razor
@@ -1,6 +1,8 @@
@page "/"
+@using Microsoft.AspNetCore.Components.Web
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
+@rendermode RenderMode.InteractiveServer
@inject ClusterService ClusterSvc
@inject GenerationService GenerationSvc
@inject NavigationManager Nav
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Hosts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Hosts.razor
index 8164187..e51a631 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Hosts.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Hosts.razor
@@ -1,9 +1,11 @@
@page "/hosts"
+@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.SignalR.Client
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
+@rendermode RenderMode.InteractiveServer
@inject IServiceScopeFactory ScopeFactory
@inject NavigationManager Nav
@implements IAsyncDisposable
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Modbus/ModbusAddressPreview.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Modbus/ModbusAddressPreview.razor
index 4d99d40..ed64c32 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Modbus/ModbusAddressPreview.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Modbus/ModbusAddressPreview.razor
@@ -1,5 +1,7 @@
@page "/modbus/address-preview"
+@using Microsoft.AspNetCore.Components.Web
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
+@rendermode RenderMode.InteractiveServer
@*
#149 — standalone preview / sanity-check tool for Modbus address strings. The Admin UI
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Modbus/ModbusDiagnostics.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Modbus/ModbusDiagnostics.razor
index 04a66c5..7d14694 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Modbus/ModbusDiagnostics.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Modbus/ModbusDiagnostics.razor
@@ -1,5 +1,7 @@
@page "/modbus/diagnostics/{DriverInstanceId}"
+@using Microsoft.AspNetCore.Components.Web
@using ZB.MOM.WW.OtOpcUa.Admin.Services
+@rendermode RenderMode.InteractiveServer
@inject DriverDiagnosticsClient Diagnostics
@*
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Reservations.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Reservations.razor
index b2c566b..35e6f89 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Reservations.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Reservations.razor
@@ -1,8 +1,10 @@
@page "/reservations"
+@using Microsoft.AspNetCore.Authorization
+@using Microsoft.AspNetCore.Components.Web
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
-@using Microsoft.AspNetCore.Authorization
@attribute [Authorize(Policy = "CanPublish")]
+@rendermode RenderMode.InteractiveServer
@inject ReservationService ReservationSvc
External-ID reservations
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Program.cs
index f159693..5857924 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Program.cs
@@ -65,8 +65,17 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddSingleton();
builder.Services.AddScoped();
-builder.Services.AddScoped();
+// EF-backed inner service registered under the keyed-service key so the resilient
+// singleton decorator resolves it per-scope without a captive-dependency issue.
+builder.Services.AddKeyedScoped(
+ ZB.MOM.WW.OtOpcUa.Admin.Security.ResilientLdapGroupRoleMappingService.InnerServiceKey);
+// Resilient singleton decorator: timeout 2 s → retry 3× jittered → fallback to in-memory snapshot.
+// Uses IServiceScopeFactory to open a short-lived scope for each DB call.
+// The static LdapOptions.GroupToRole bootstrap dictionary in AdminRoleGrantResolver is the
+// lock-out-proof floor; this decorator only guards the DB-backed augmentation rows.
+builder.Services.AddSingleton();
// Phase 7 Stream F — scripting + virtual tag + scripted alarm draft services, test
// harness, and historian diagnostics. The historian sink is the Null variant here —
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Security/ResilientLdapGroupRoleMappingService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Security/ResilientLdapGroupRoleMappingService.cs
new file mode 100644
index 0000000..eb7b144
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Security/ResilientLdapGroupRoleMappingService.cs
@@ -0,0 +1,171 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Logging;
+using Polly;
+using Polly.Retry;
+using Polly.Timeout;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Services;
+
+namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
+
+///
+/// Resilience decorator for that wraps the
+/// hot-path call in the Phase 6.1-style pipeline:
+/// timeout 2 s → retry 3× jittered → fallback to in-memory sealed snapshot.
+///
+///
+/// Registered as a singleton so the in-memory snapshot survives across sign-in
+/// requests. The inner is resolved via the
+/// keyed-service key "inner", allowing the EF-backed scoped service to be
+/// registered as the "inner" implementation while this singleton decorator is the primary
+/// binding.
+///
+/// Because the inner service is scoped (it owns an EF DbContext), this
+/// singleton uses to open a short-lived scope for
+/// each DB call. The scope is disposed immediately after the call completes.
+///
+/// On each successful the result is stored in a
+/// keyed by the canonicalised group set. On
+/// any failure (DB unreachable, SQL exception, timeout) after all retries, the cached
+/// result for that exact group set is returned. When no prior success exists for the group
+/// set, an empty list is returned — the static
+/// bootstrap dictionary in is the lock-out-proof
+/// floor that remains functional regardless of DB state.
+///
+/// Write methods (, ) and
+/// are passed through unchanged — the resilience layer is
+/// read-path only, consistent with the Phase 6.1 design decision that writes must fail
+/// hard on DB outage rather than landing against a stale state.
+///
+public sealed class ResilientLdapGroupRoleMappingService : ILdapGroupRoleMappingService
+{
+ ///
+ /// DI keyed-service key used to register the inner (EF-backed) implementation so the
+ /// decorator can resolve it without creating a circular dependency on itself.
+ ///
+ public const string InnerServiceKey = "LdapGroupRoleMappingService.Inner";
+
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly ResiliencePipeline _pipeline;
+ private readonly ILogger _logger;
+
+ // Keyed by the normalised group set (NUL-separated sorted group names, lower-case).
+ private readonly ConcurrentDictionary> _snapshot =
+ new(StringComparer.Ordinal);
+
+ public ResilientLdapGroupRoleMappingService(
+ IServiceScopeFactory scopeFactory,
+ ILogger logger,
+ TimeSpan? timeout = null,
+ int retryCount = 3)
+ {
+ _scopeFactory = scopeFactory;
+ _logger = logger;
+
+ var builder = new ResiliencePipelineBuilder()
+ .AddTimeout(new TimeoutStrategyOptions
+ {
+ Timeout = timeout ?? TimeSpan.FromSeconds(2),
+ });
+
+ if (retryCount > 0)
+ {
+ builder.AddRetry(new RetryStrategyOptions
+ {
+ MaxRetryAttempts = retryCount,
+ BackoffType = DelayBackoffType.Exponential,
+ UseJitter = true,
+ Delay = TimeSpan.FromMilliseconds(100),
+ MaxDelay = TimeSpan.FromSeconds(1),
+ ShouldHandle = new PredicateBuilder().Handle(
+ ex => ex is not OperationCanceledException),
+ });
+ }
+
+ _pipeline = builder.Build();
+ }
+
+ ///
+ ///
+ /// Executed through the timeout → retry pipeline. On full failure the last snapshot
+ /// for this group set (if any) is returned; otherwise an empty list. The static
+ /// appsettings.json bootstrap dictionary in
+ /// remains the ultimate fallback — a DB outage never causes a total login denial.
+ ///
+ public async Task> GetByGroupsAsync(
+ IEnumerable ldapGroups, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(ldapGroups);
+
+ var groupList = ldapGroups.ToList();
+ if (groupList.Count == 0) return [];
+
+ var cacheKey = CacheKey(groupList);
+
+ try
+ {
+ var result = await _pipeline.ExecuteAsync(async ct =>
+ {
+ await using var scope = _scopeFactory.CreateAsyncScope();
+ var inner = (ILdapGroupRoleMappingService)scope.ServiceProvider
+ .GetRequiredKeyedService(InnerServiceKey);
+ return await inner.GetByGroupsAsync(groupList, ct).ConfigureAwait(false);
+ }, cancellationToken).ConfigureAwait(false);
+
+ // Seal the snapshot so a subsequent DB outage can fall back to it.
+ _snapshot[cacheKey] = result;
+ return result;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogWarning(ex,
+ "LDAP role-mapping DB read failed after retries; falling back to snapshot for group set [{Groups}]",
+ string.Join(", ", groupList));
+
+ return _snapshot.TryGetValue(cacheKey, out var cached)
+ ? cached
+ : [];
+ }
+ }
+
+ ///
+ /// Pass-through — not covered by the resilience pipeline (Admin UI listing only).
+ public async Task> ListAllAsync(CancellationToken cancellationToken)
+ {
+ await using var scope = _scopeFactory.CreateAsyncScope();
+ var inner = (ILdapGroupRoleMappingService)scope.ServiceProvider
+ .GetRequiredKeyedService(InnerServiceKey);
+ return await inner.ListAllAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Pass-through — writes must fail hard on DB outage per Phase 6.1 design decision.
+ public async Task CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
+ {
+ await using var scope = _scopeFactory.CreateAsyncScope();
+ var inner = (ILdapGroupRoleMappingService)scope.ServiceProvider
+ .GetRequiredKeyedService(InnerServiceKey);
+ return await inner.CreateAsync(row, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Pass-through — writes must fail hard on DB outage per Phase 6.1 design decision.
+ public async Task DeleteAsync(Guid id, CancellationToken cancellationToken)
+ {
+ await using var scope = _scopeFactory.CreateAsyncScope();
+ var inner = (ILdapGroupRoleMappingService)scope.ServiceProvider
+ .GetRequiredKeyedService(InnerServiceKey);
+ await inner.DeleteAsync(id, cancellationToken).ConfigureAwait(false);
+ }
+
+ // ── helpers ────────────────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Canonicalise a group set into a stable cache key: sort, lower-case, join with NUL.
+ /// Two calls with the same groups in different orders produce the same key.
+ ///
+ internal static string CacheKey(IEnumerable groups)
+ => string.Join('\0', groups
+ .Select(g => g.ToLowerInvariant())
+ .Order(StringComparer.Ordinal));
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentImportBatchService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentImportBatchService.cs
index eb2c9ba..72ac927 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentImportBatchService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentImportBatchService.cs
@@ -297,6 +297,102 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
return false;
}
+ ///
+ /// Pre-checks active s for the accepted rows in
+ /// . Rows whose ZTag or SAPID is already reserved by a
+ /// different are moved from
+ /// to
+ /// with a descriptive reason so the
+ /// operator sees the conflict in the import preview rather than at finalise time.
+ ///
+ ///
+ /// Rows whose value matches a reservation owned by the same
+ /// are not flagged — that is the
+ /// normal re-publish of an asset keeping its identifier.
+ ///
+ /// Released reservations ( IS NOT NULL)
+ /// are ignored so an explicitly-released value is freely claimable.
+ ///
+ /// One DB round-trip fetches all relevant active reservations before the per-row scan.
+ ///
+ public async Task ApplyReservationPreCheckAsync(
+ EquipmentCsvParseResult parseResult, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(parseResult);
+
+ var accepted = parseResult.AcceptedRows;
+ if (accepted.Count == 0) return parseResult; // nothing to check
+
+ // Collect ZTag + SAPID values that are non-empty across all accepted rows.
+ var zTags = accepted
+ .Where(r => !string.IsNullOrWhiteSpace(r.ZTag))
+ .Select(r => r.ZTag)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ var sapIds = accepted
+ .Where(r => !string.IsNullOrWhiteSpace(r.SAPID))
+ .Select(r => r.SAPID)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ if (zTags.Count == 0 && sapIds.Count == 0) return parseResult;
+
+ // Single round-trip: fetch all active reservations whose value appears in the import.
+ var activeReservations = await db.ExternalIdReservations
+ .AsNoTracking()
+ .Where(r => r.ReleasedAt == null &&
+ ((r.Kind == ReservationKind.ZTag && zTags.Contains(r.Value)) ||
+ (r.Kind == ReservationKind.SAPID && sapIds.Contains(r.Value))))
+ .ToListAsync(ct)
+ .ConfigureAwait(false);
+
+ if (activeReservations.Count == 0) return parseResult;
+
+ // Build lookup: (kind, value-lower) → owning EquipmentUuid.
+ var reservedBy = activeReservations.ToDictionary(
+ r => (r.Kind, r.Value.ToLowerInvariant()),
+ r => r.EquipmentUuid);
+
+ var stillAccepted = new List();
+ var newRejections = new List();
+
+ foreach (var row in accepted)
+ {
+ var rowUuid = Guid.TryParse(row.EquipmentUuid, out var u) ? u : Guid.Empty;
+ string? conflictReason = null;
+
+ if (!string.IsNullOrWhiteSpace(row.ZTag) &&
+ reservedBy.TryGetValue((ReservationKind.ZTag, row.ZTag.ToLowerInvariant()), out var zOwner) &&
+ zOwner != rowUuid)
+ {
+ conflictReason =
+ $"ZTag '{row.ZTag}' is already reserved by EquipmentUuid {zOwner}. " +
+ "Release that reservation via the Reservations admin page before re-assigning this ZTag.";
+ }
+
+ if (conflictReason is null &&
+ !string.IsNullOrWhiteSpace(row.SAPID) &&
+ reservedBy.TryGetValue((ReservationKind.SAPID, row.SAPID.ToLowerInvariant()), out var sOwner) &&
+ sOwner != rowUuid)
+ {
+ conflictReason =
+ $"SAPID '{row.SAPID}' is already reserved by EquipmentUuid {sOwner}. " +
+ "Release that reservation via the Reservations admin page before re-assigning this SAPID.";
+ }
+
+ if (conflictReason is not null)
+ newRejections.Add(new EquipmentCsvRowError(LineNumber: 0, Reason: conflictReason));
+ else
+ stillAccepted.Add(row);
+ }
+
+ if (newRejections.Count == 0) return parseResult; // fast path — no conflicts
+
+ return new EquipmentCsvParseResult(
+ AcceptedRows: stillAccepted,
+ RejectedRows: [..parseResult.RejectedRows, ..newRejections]);
+ }
+
/// List batches created by the given user. Finalised batches are archived; include them on demand.
public async Task> ListByUserAsync(string createdBy, bool includeFinalised, CancellationToken ct)
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentImportBatchServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentImportBatchServiceTests.cs
index d28db99..12246c0 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentImportBatchServiceTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentImportBatchServiceTests.cs
@@ -253,4 +253,180 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
_db.ExternalIdReservations.Count().ShouldBe(0);
}
+
+ // ── ApplyReservationPreCheck tests ──────────────────────────────────────────
+
+ /// No active reservations → the parse result passes through unchanged.
+ [Fact]
+ public async Task PreCheck_NoReservations_ReturnsUnchanged()
+ {
+ var input = new EquipmentCsvParseResult(
+ AcceptedRows: [Row("z-1"), Row("z-2")],
+ RejectedRows: []);
+
+ var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None);
+
+ result.AcceptedRows.Count.ShouldBe(2);
+ result.RejectedRows.Count.ShouldBe(0);
+ }
+
+ ///
+ /// ZTag reserved by a DIFFERENT EquipmentUuid → row moves to rejected with a descriptive reason;
+ /// SAPID of that same row is ignored since the row is already conflicted.
+ ///
+ [Fact]
+ public async Task PreCheck_ZTagConflict_MovesRowToRejected()
+ {
+ // Seed an active reservation for "z-taken" owned by a different UUID.
+ var ownerUuid = Guid.NewGuid();
+ _db.ExternalIdReservations.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation
+ {
+ ReservationId = Guid.NewGuid(),
+ Kind = ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.ZTag,
+ Value = "z-taken",
+ EquipmentUuid = ownerUuid,
+ ClusterId = "c1",
+ FirstPublishedBy = "alice",
+ });
+ await _db.SaveChangesAsync();
+
+ var importerUuid = Guid.NewGuid(); // different UUID — conflict
+ var conflictRow = new EquipmentCsvRow
+ {
+ ZTag = "z-taken", MachineCode = "mc", SAPID = "sap-ok",
+ EquipmentId = "eq-x", EquipmentUuid = importerUuid.ToString(),
+ Name = "x", UnsAreaName = "ar", UnsLineName = "ln",
+ };
+ var cleanRow = Row("z-clean");
+
+ var input = new EquipmentCsvParseResult(
+ AcceptedRows: [conflictRow, cleanRow],
+ RejectedRows: [new EquipmentCsvRowError(99, "pre-existing parser rejection")]);
+
+ var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None);
+
+ result.AcceptedRows.Count.ShouldBe(1, "only the clean row remains accepted");
+ result.AcceptedRows[0].ZTag.ShouldBe("z-clean");
+
+ result.RejectedRows.Count.ShouldBe(2, "pre-existing + the new conflict rejection");
+ var conflictError = result.RejectedRows.Single(e => e.Reason.Contains("z-taken"));
+ conflictError.Reason.ShouldContain(ownerUuid.ToString());
+ conflictError.Reason.ShouldContain("ZTag");
+ }
+
+ /// SAPID reserved by a different EquipmentUuid → row is rejected with a SAPID-specific reason.
+ [Fact]
+ public async Task PreCheck_SAPIDConflict_MovesRowToRejected()
+ {
+ var ownerUuid = Guid.NewGuid();
+ _db.ExternalIdReservations.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation
+ {
+ ReservationId = Guid.NewGuid(),
+ Kind = ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.SAPID,
+ Value = "sap-taken",
+ EquipmentUuid = ownerUuid,
+ ClusterId = "c1",
+ FirstPublishedBy = "alice",
+ });
+ await _db.SaveChangesAsync();
+
+ var importerUuid = Guid.NewGuid();
+ var conflictRow = new EquipmentCsvRow
+ {
+ ZTag = "z-free", MachineCode = "mc", SAPID = "sap-taken",
+ EquipmentId = "eq-y", EquipmentUuid = importerUuid.ToString(),
+ Name = "y", UnsAreaName = "ar", UnsLineName = "ln",
+ };
+
+ var input = new EquipmentCsvParseResult(AcceptedRows: [conflictRow], RejectedRows: []);
+
+ var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None);
+
+ result.AcceptedRows.Count.ShouldBe(0);
+ result.RejectedRows.Count.ShouldBe(1);
+ result.RejectedRows[0].Reason.ShouldContain("sap-taken");
+ result.RejectedRows[0].Reason.ShouldContain("SAPID");
+ result.RejectedRows[0].Reason.ShouldContain(ownerUuid.ToString());
+ }
+
+ ///
+ /// Reservation active for the SAME EquipmentUuid → row is NOT rejected (normal re-publish).
+ ///
+ [Fact]
+ public async Task PreCheck_SameEquipmentUuid_NotFlagged()
+ {
+ var sharedUuid = Guid.NewGuid();
+ _db.ExternalIdReservations.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation
+ {
+ ReservationId = Guid.NewGuid(),
+ Kind = ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.ZTag,
+ Value = "z-mine",
+ EquipmentUuid = sharedUuid,
+ ClusterId = "c1",
+ FirstPublishedBy = "alice",
+ });
+ await _db.SaveChangesAsync();
+
+ var row = new EquipmentCsvRow
+ {
+ ZTag = "z-mine", MachineCode = "mc", SAPID = "sap-mine",
+ EquipmentId = "eq-z", EquipmentUuid = sharedUuid.ToString(), // same UUID
+ Name = "z", UnsAreaName = "ar", UnsLineName = "ln",
+ };
+
+ var input = new EquipmentCsvParseResult(AcceptedRows: [row], RejectedRows: []);
+
+ var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None);
+
+ result.AcceptedRows.Count.ShouldBe(1, "same UUID → not a conflict");
+ result.RejectedRows.Count.ShouldBe(0);
+ }
+
+ /// A released reservation (ReleasedAt IS NOT NULL) does not block the import row.
+ [Fact]
+ public async Task PreCheck_ReleasedReservation_IsIgnored()
+ {
+ var oldOwner = Guid.NewGuid();
+ _db.ExternalIdReservations.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation
+ {
+ ReservationId = Guid.NewGuid(),
+ Kind = ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.ZTag,
+ Value = "z-released",
+ EquipmentUuid = oldOwner,
+ ClusterId = "c1",
+ FirstPublishedBy = "alice",
+ ReleasedAt = DateTime.UtcNow.AddDays(-1),
+ ReleasedBy = "bob",
+ ReleaseReason = "decommissioned",
+ });
+ await _db.SaveChangesAsync();
+
+ var newImporterUuid = Guid.NewGuid();
+ var row = new EquipmentCsvRow
+ {
+ ZTag = "z-released", MachineCode = "mc", SAPID = "sap-new",
+ EquipmentId = "eq-new", EquipmentUuid = newImporterUuid.ToString(),
+ Name = "new", UnsAreaName = "ar", UnsLineName = "ln",
+ };
+
+ var input = new EquipmentCsvParseResult(AcceptedRows: [row], RejectedRows: []);
+
+ var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None);
+
+ result.AcceptedRows.Count.ShouldBe(1, "released reservation is free to claim");
+ result.RejectedRows.Count.ShouldBe(0);
+ }
+
+ /// Empty accepted list short-circuits without hitting the DB.
+ [Fact]
+ public async Task PreCheck_EmptyInput_ReturnsUnchanged()
+ {
+ var input = new EquipmentCsvParseResult(
+ AcceptedRows: [],
+ RejectedRows: [new EquipmentCsvRowError(1, "already rejected")]);
+
+ var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None);
+
+ result.ShouldBeSameAs(input, "same instance when there is nothing to check");
+ }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/ResilientLdapGroupRoleMappingServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/ResilientLdapGroupRoleMappingServiceTests.cs
new file mode 100644
index 0000000..70c14e3
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/ResilientLdapGroupRoleMappingServiceTests.cs
@@ -0,0 +1,278 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Admin.Security;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Configuration.Services;
+
+namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
+
+///
+/// Unit tests for — the Phase 6.2
+/// Stream A.2 resilience decorator (timeout → retry → in-memory-snapshot fallback)
+/// that guards against a transient Config DB outage.
+///
+[Trait("Category", "Unit")]
+public sealed class ResilientLdapGroupRoleMappingServiceTests
+{
+ // ── fake inner service ────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Configurable in-memory . Throws on demand
+ /// so we can exercise the resilience path without a real DB.
+ ///
+ private sealed class FakeInner : ILdapGroupRoleMappingService
+ {
+ private readonly IReadOnlyList _rows;
+ public bool ThrowOnRead { get; set; }
+ public int ReadAttempts { get; private set; }
+
+ public FakeInner(IReadOnlyList? rows = null)
+ => _rows = rows ?? [];
+
+ public Task> GetByGroupsAsync(
+ IEnumerable ldapGroups, CancellationToken cancellationToken)
+ {
+ ReadAttempts++;
+ if (ThrowOnRead) throw new InvalidOperationException("DB unavailable (test)");
+ var set = ldapGroups.ToHashSet(StringComparer.OrdinalIgnoreCase);
+ return Task.FromResult>(
+ _rows.Where(r => set.Contains(r.LdapGroup)).ToList());
+ }
+
+ public Task> ListAllAsync(CancellationToken cancellationToken)
+ => Task.FromResult(_rows);
+
+ public Task CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
+ => Task.FromResult(row);
+
+ public Task DeleteAsync(Guid id, CancellationToken cancellationToken)
+ => Task.CompletedTask;
+ }
+
+ // ── factory helper ────────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Build a backed by a real
+ /// that registers under the
+ /// keyed-service key .
+ ///
+ private static ResilientLdapGroupRoleMappingService Build(
+ FakeInner inner,
+ TimeSpan? timeout = null,
+ int retryCount = 0)
+ {
+ var services = new ServiceCollection();
+ services.AddKeyedSingleton(
+ ResilientLdapGroupRoleMappingService.InnerServiceKey, inner);
+
+ var provider = services.BuildServiceProvider();
+ return new ResilientLdapGroupRoleMappingService(
+ provider.GetRequiredService(),
+ NullLogger.Instance,
+ timeout ?? TimeSpan.FromSeconds(10),
+ retryCount);
+ }
+
+ // ── tests — resilience pipeline ───────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task DbSuccess_returns_result_and_seals_snapshot()
+ {
+ var row = Row("cn=ops", AdminRole.FleetAdmin);
+ var fake = new FakeInner([row]);
+ var svc = Build(fake);
+
+ var result = await svc.GetByGroupsAsync(["cn=ops"], CancellationToken.None);
+
+ result.Count.ShouldBe(1);
+ result[0].LdapGroup.ShouldBe("cn=ops");
+ fake.ReadAttempts.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task DbFailure_with_snapshot_returns_cached_result()
+ {
+ var row = Row("cn=ops", AdminRole.FleetAdmin);
+ var fake = new FakeInner([row]);
+ var svc = Build(fake, retryCount: 0);
+
+ // First call succeeds — populates the snapshot.
+ await svc.GetByGroupsAsync(["cn=ops"], CancellationToken.None);
+
+ // Now break the DB.
+ fake.ThrowOnRead = true;
+
+ var fallback = await svc.GetByGroupsAsync(["cn=ops"], CancellationToken.None);
+
+ fallback.Count.ShouldBe(1);
+ fallback[0].LdapGroup.ShouldBe("cn=ops");
+ }
+
+ [Fact]
+ public async Task DbFailure_without_snapshot_returns_empty_list()
+ {
+ var fake = new FakeInner([Row("cn=ops", AdminRole.FleetAdmin)]) { ThrowOnRead = true };
+ var svc = Build(fake, retryCount: 0);
+
+ var result = await svc.GetByGroupsAsync(["cn=ops"], CancellationToken.None);
+
+ // Empty list — the static LdapOptions.GroupToRole bootstrap in AdminRoleGrantResolver
+ // is the lock-out-proof floor; no DB rows means only static dict grants fire.
+ result.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task DbFailure_retries_before_fallback()
+ {
+ var fake = new FakeInner([Row("cn=ops", AdminRole.FleetAdmin)]) { ThrowOnRead = true };
+ // retryCount=2: 1 initial + 2 retries = 3 attempts total before falling back.
+ var svc = Build(fake, timeout: TimeSpan.FromSeconds(30), retryCount: 2);
+
+ var result = await svc.GetByGroupsAsync(["cn=ops"], CancellationToken.None);
+
+ fake.ReadAttempts.ShouldBe(3, "1 initial + 2 retries before snapshot fallback");
+ result.ShouldBeEmpty("no prior snapshot — empty fallback, not a throw");
+ }
+
+ [Fact]
+ public async Task Empty_groups_bypasses_pipeline_and_returns_empty()
+ {
+ var fake = new FakeInner([Row("cn=ops", AdminRole.FleetAdmin)]);
+ var svc = Build(fake);
+
+ var result = await svc.GetByGroupsAsync([], CancellationToken.None);
+
+ result.ShouldBeEmpty();
+ fake.ReadAttempts.ShouldBe(0, "pipeline must not fire for empty group list");
+ }
+
+ [Fact]
+ public async Task Cancellation_propagates_without_fallback()
+ {
+ var fake = new FakeInner([Row("cn=ops", AdminRole.FleetAdmin)]);
+ var svc = Build(fake, retryCount: 0);
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ await Should.ThrowAsync(
+ () => svc.GetByGroupsAsync(["cn=ops"], cts.Token));
+ }
+
+ // ── tests — snapshot key semantics ────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Snapshot_is_keyed_by_group_set_regardless_of_order()
+ {
+ var row1 = Row("cn=a", AdminRole.FleetAdmin);
+ var row2 = Row("cn=b", AdminRole.ConfigEditor);
+ var fake = new FakeInner([row1, row2]);
+ var svc = Build(fake, retryCount: 0);
+
+ // Seed the snapshot with [b, a] order.
+ await svc.GetByGroupsAsync(["cn=b", "cn=a"], CancellationToken.None);
+ fake.ThrowOnRead = true;
+
+ // Request with [a, b] order — same canonical key → fallback snapshot available.
+ var fallback = await svc.GetByGroupsAsync(["cn=a", "cn=b"], CancellationToken.None);
+ fallback.Count.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task Different_group_sets_have_independent_snapshots()
+ {
+ var row1 = Row("cn=ops", AdminRole.FleetAdmin);
+ var row2 = Row("cn=viewer", AdminRole.ConfigViewer);
+ var fake = new FakeInner([row1, row2]);
+ var svc = Build(fake, retryCount: 0);
+
+ // Seed snapshot for cn=ops only.
+ await svc.GetByGroupsAsync(["cn=ops"], CancellationToken.None);
+ fake.ThrowOnRead = true;
+
+ // cn=viewer never had a successful call → no snapshot → empty fallback.
+ var fallback = await svc.GetByGroupsAsync(["cn=viewer"], CancellationToken.None);
+ fallback.ShouldBeEmpty();
+ }
+
+ // ── tests — CacheKey helper ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void CacheKey_is_order_independent()
+ {
+ var key1 = ResilientLdapGroupRoleMappingService.CacheKey(["cn=a", "cn=b", "cn=c"]);
+ var key2 = ResilientLdapGroupRoleMappingService.CacheKey(["cn=c", "cn=a", "cn=b"]);
+ key1.ShouldBe(key2);
+ }
+
+ [Fact]
+ public void CacheKey_is_case_insensitive()
+ {
+ var key1 = ResilientLdapGroupRoleMappingService.CacheKey(["CN=Ops"]);
+ var key2 = ResilientLdapGroupRoleMappingService.CacheKey(["cn=ops"]);
+ key1.ShouldBe(key2);
+ }
+
+ [Fact]
+ public void CacheKey_distinguishes_different_sets()
+ {
+ var key1 = ResilientLdapGroupRoleMappingService.CacheKey(["cn=a"]);
+ var key2 = ResilientLdapGroupRoleMappingService.CacheKey(["cn=b"]);
+ key1.ShouldNotBe(key2);
+ }
+
+ [Fact]
+ public void CacheKey_single_group_roundtrips()
+ {
+ var key = ResilientLdapGroupRoleMappingService.CacheKey(["cn=fleet-admin"]);
+ key.ShouldBe("cn=fleet-admin");
+ }
+
+ // ── pass-through methods ──────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ListAllAsync_passes_through_to_inner()
+ {
+ var row = Row("cn=ops", AdminRole.FleetAdmin);
+ var fake = new FakeInner([row]);
+ var svc = Build(fake);
+
+ var result = await svc.ListAllAsync(CancellationToken.None);
+
+ result.Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task CreateAsync_passes_through_to_inner()
+ {
+ var row = Row("cn=ops", AdminRole.FleetAdmin);
+ var fake = new FakeInner();
+ var svc = Build(fake);
+
+ var created = await svc.CreateAsync(row, CancellationToken.None);
+ created.ShouldBe(row);
+ }
+
+ [Fact]
+ public async Task DeleteAsync_passes_through_to_inner()
+ {
+ var fake = new FakeInner();
+ var svc = Build(fake);
+
+ // Should not throw.
+ await svc.DeleteAsync(Guid.NewGuid(), CancellationToken.None);
+ }
+
+ // ── helpers ───────────────────────────────────────────────────────────────────────────────
+
+ private static LdapGroupRoleMapping Row(string group, AdminRole role) => new()
+ {
+ Id = Guid.NewGuid(),
+ LdapGroup = group,
+ Role = role,
+ IsSystemWide = true,
+ ClusterId = null,
+ };
+}