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