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