Merge branch 'feat/tasks-9-10-11-admin-hardening'
Wave 1 of the task-list run — three Admin hardening tasks: - #9 resilient LDAP role-grant reads (ResilientLdapGroupRoleMappingService) - #10 InteractiveServer render mode on 13 interactive pages + hub-URL fixes - #11 ZTag/SAPID reservation pre-check in equipment CSV import (task #197)
This commit is contained in:
@@ -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
|
||||
|
||||
<h1 class="page-title">Alarm historian</h1>
|
||||
|
||||
@@ -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<Certificates> Log
|
||||
|
||||
@@ -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<NodeAclChangedMessage>("NodeAclChanged", async msg =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
</section>
|
||||
|
||||
<section class="panel notice rise mt-2" style="animation-delay:.08s">
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@*
|
||||
|
||||
@@ -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
|
||||
|
||||
<h1 class="page-title">External-ID reservations</h1>
|
||||
|
||||
@@ -65,8 +65,17 @@ builder.Services.AddScoped<HostStatusService>();
|
||||
builder.Services.AddScoped<ClusterNodeService>();
|
||||
builder.Services.AddSingleton<RedundancyMetrics>();
|
||||
builder.Services.AddScoped<EquipmentImportBatchService>();
|
||||
builder.Services.AddScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
|
||||
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>();
|
||||
// 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.Configuration.Services.ILdapGroupRoleMappingService,
|
||||
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>(
|
||||
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<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
|
||||
ZB.MOM.WW.OtOpcUa.Admin.Security.ResilientLdapGroupRoleMappingService>();
|
||||
|
||||
// Phase 7 Stream F — scripting + virtual tag + scripted alarm draft services, test
|
||||
// harness, and historian diagnostics. The historian sink is the Null variant here —
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Resilience decorator for <see cref="ILdapGroupRoleMappingService"/> that wraps the
|
||||
/// hot-path <see cref="GetByGroupsAsync"/> call in the Phase 6.1-style pipeline:
|
||||
/// <b>timeout 2 s → retry 3× jittered → fallback to in-memory sealed snapshot</b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Registered as a singleton so the in-memory snapshot survives across sign-in
|
||||
/// requests. The inner <see cref="ILdapGroupRoleMappingService"/> is resolved via the
|
||||
/// keyed-service key <c>"inner"</c>, allowing the EF-backed scoped service to be
|
||||
/// registered as the "inner" implementation while this singleton decorator is the primary
|
||||
/// <see cref="ILdapGroupRoleMappingService"/> binding.</para>
|
||||
///
|
||||
/// <para>Because the inner service is scoped (it owns an EF <c>DbContext</c>), this
|
||||
/// singleton uses <see cref="IServiceScopeFactory"/> to open a short-lived scope for
|
||||
/// each DB call. The scope is disposed immediately after the call completes.</para>
|
||||
///
|
||||
/// <para>On each successful <see cref="GetByGroupsAsync"/> the result is stored in a
|
||||
/// <see cref="ConcurrentDictionary{TKey,TValue}"/> 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 <see cref="LdapOptions.GroupToRole"/>
|
||||
/// bootstrap dictionary in <see cref="AdminRoleGrantResolver"/> is the lock-out-proof
|
||||
/// floor that remains functional regardless of DB state.</para>
|
||||
///
|
||||
/// <para>Write methods (<see cref="CreateAsync"/>, <see cref="DeleteAsync"/>) and
|
||||
/// <see cref="ListAllAsync"/> 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.</para>
|
||||
/// </remarks>
|
||||
public sealed class ResilientLdapGroupRoleMappingService : ILdapGroupRoleMappingService
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public const string InnerServiceKey = "LdapGroupRoleMappingService.Inner";
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ResiliencePipeline _pipeline;
|
||||
private readonly ILogger<ResilientLdapGroupRoleMappingService> _logger;
|
||||
|
||||
// Keyed by the normalised group set (NUL-separated sorted group names, lower-case).
|
||||
private readonly ConcurrentDictionary<string, IReadOnlyList<LdapGroupRoleMapping>> _snapshot =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public ResilientLdapGroupRoleMappingService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<ResilientLdapGroupRoleMappingService> 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<Exception>(
|
||||
ex => ex is not OperationCanceledException),
|
||||
});
|
||||
}
|
||||
|
||||
_pipeline = builder.Build();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// <c>appsettings.json</c> bootstrap dictionary in <see cref="AdminRoleGrantResolver"/>
|
||||
/// remains the ultimate fallback — a DB outage never causes a total login denial.
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
||||
IEnumerable<string> 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<ILdapGroupRoleMappingService>(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
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>Pass-through — not covered by the resilience pipeline (Admin UI listing only).</remarks>
|
||||
public async Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var inner = (ILdapGroupRoleMappingService)scope.ServiceProvider
|
||||
.GetRequiredKeyedService<ILdapGroupRoleMappingService>(InnerServiceKey);
|
||||
return await inner.ListAllAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>Pass-through — writes must fail hard on DB outage per Phase 6.1 design decision.</remarks>
|
||||
public async Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var inner = (ILdapGroupRoleMappingService)scope.ServiceProvider
|
||||
.GetRequiredKeyedService<ILdapGroupRoleMappingService>(InnerServiceKey);
|
||||
return await inner.CreateAsync(row, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>Pass-through — writes must fail hard on DB outage per Phase 6.1 design decision.</remarks>
|
||||
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var inner = (ILdapGroupRoleMappingService)scope.ServiceProvider
|
||||
.GetRequiredKeyedService<ILdapGroupRoleMappingService>(InnerServiceKey);
|
||||
await inner.DeleteAsync(id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static string CacheKey(IEnumerable<string> groups)
|
||||
=> string.Join('\0', groups
|
||||
.Select(g => g.ToLowerInvariant())
|
||||
.Order(StringComparer.Ordinal));
|
||||
}
|
||||
@@ -297,6 +297,102 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-checks active <see cref="ExternalIdReservation"/>s for the accepted rows in
|
||||
/// <paramref name="parseResult"/>. Rows whose ZTag or SAPID is already reserved by a
|
||||
/// <em>different</em> <see cref="ExternalIdReservation.EquipmentUuid"/> are moved from
|
||||
/// <see cref="EquipmentCsvParseResult.AcceptedRows"/> to
|
||||
/// <see cref="EquipmentCsvParseResult.RejectedRows"/> with a descriptive reason so the
|
||||
/// operator sees the conflict in the import preview rather than at finalise time.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Rows whose value matches a reservation owned by the <em>same</em>
|
||||
/// <see cref="ExternalIdReservation.EquipmentUuid"/> are not flagged — that is the
|
||||
/// normal re-publish of an asset keeping its identifier.
|
||||
///
|
||||
/// Released reservations (<see cref="ExternalIdReservation.ReleasedAt"/> 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.
|
||||
/// </remarks>
|
||||
public async Task<EquipmentCsvParseResult> 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<EquipmentCsvRow>();
|
||||
var newRejections = new List<EquipmentCsvRowError>();
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
/// <summary>List batches created by the given user. Finalised batches are archived; include them on demand.</summary>
|
||||
public async Task<IReadOnlyList<EquipmentImportBatch>> ListByUserAsync(string createdBy, bool includeFinalised, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -253,4 +253,180 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
||||
|
||||
_db.ExternalIdReservations.Count().ShouldBe(0);
|
||||
}
|
||||
|
||||
// ── ApplyReservationPreCheck tests ──────────────────────────────────────────
|
||||
|
||||
/// <summary>No active reservations → the parse result passes through unchanged.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>SAPID reserved by a different EquipmentUuid → row is rejected with a SAPID-specific reason.</summary>
|
||||
[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());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reservation active for the SAME EquipmentUuid → row is NOT rejected (normal re-publish).
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>A released reservation (ReleasedAt IS NOT NULL) does not block the import row.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Empty accepted list short-circuits without hitting the DB.</summary>
|
||||
[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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ResilientLdapGroupRoleMappingService"/> — the Phase 6.2
|
||||
/// Stream A.2 resilience decorator (timeout → retry → in-memory-snapshot fallback)
|
||||
/// that guards <see cref="AdminRoleGrantResolver"/> against a transient Config DB outage.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ResilientLdapGroupRoleMappingServiceTests
|
||||
{
|
||||
// ── fake inner service ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Configurable in-memory <see cref="ILdapGroupRoleMappingService"/>. Throws on demand
|
||||
/// so we can exercise the resilience path without a real DB.
|
||||
/// </summary>
|
||||
private sealed class FakeInner : ILdapGroupRoleMappingService
|
||||
{
|
||||
private readonly IReadOnlyList<LdapGroupRoleMapping> _rows;
|
||||
public bool ThrowOnRead { get; set; }
|
||||
public int ReadAttempts { get; private set; }
|
||||
|
||||
public FakeInner(IReadOnlyList<LdapGroupRoleMapping>? rows = null)
|
||||
=> _rows = rows ?? [];
|
||||
|
||||
public Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
||||
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
|
||||
{
|
||||
ReadAttempts++;
|
||||
if (ThrowOnRead) throw new InvalidOperationException("DB unavailable (test)");
|
||||
var set = ldapGroups.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return Task.FromResult<IReadOnlyList<LdapGroupRoleMapping>>(
|
||||
_rows.Where(r => set.Contains(r.LdapGroup)).ToList());
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_rows);
|
||||
|
||||
public Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(row);
|
||||
|
||||
public Task DeleteAsync(Guid id, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── factory helper ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="ResilientLdapGroupRoleMappingService"/> backed by a real
|
||||
/// <see cref="ServiceCollection"/> that registers <paramref name="inner"/> under the
|
||||
/// keyed-service key <see cref="ResilientLdapGroupRoleMappingService.InnerServiceKey"/>.
|
||||
/// </summary>
|
||||
private static ResilientLdapGroupRoleMappingService Build(
|
||||
FakeInner inner,
|
||||
TimeSpan? timeout = null,
|
||||
int retryCount = 0)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddKeyedSingleton<ILdapGroupRoleMappingService>(
|
||||
ResilientLdapGroupRoleMappingService.InnerServiceKey, inner);
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
return new ResilientLdapGroupRoleMappingService(
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
NullLogger<ResilientLdapGroupRoleMappingService>.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<OperationCanceledException>(
|
||||
() => 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user