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, + }; +}