feat(admin): wrap LdapGroupRoleMappingService in Phase 6.1-style resilience pipeline (Phase 6.2 Stream A.2)
Add ResilientLdapGroupRoleMappingService — a singleton decorator that wraps the hot-path GetByGroupsAsync call in a Polly pipeline (timeout 2s → retry 3× jittered → fallback to in-memory sealed snapshot) so a transient Config DB outage at Admin sign-in falls back to the last-known-good mapping set rather than denying every login. The static LdapOptions.GroupToRole bootstrap dictionary in AdminRoleGrantResolver remains the lock-out-proof floor regardless of DB state. DI wiring uses keyed services: LdapGroupRoleMappingService (EF, scoped) is registered under key "LdapGroupRoleMappingService.Inner"; the resilient singleton decorator is the primary ILdapGroupRoleMappingService binding. The singleton avoids the captive-dependency anti-pattern by using IServiceScopeFactory to open a short-lived scope for each DB call. Write methods (CreateAsync, DeleteAsync, ListAllAsync) pass through unchanged — resilience is read-path only per Phase 6.1 design decision. 15 new unit tests cover: DB success/failure/retry paths, snapshot sealing and per-group-set isolation, order-independent cache key normalisation, cancellation propagation, and pass-through method routing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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