using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using Shouldly; using Xunit; 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.Configuration.Tests; [Trait("Category", "Unit")] public sealed class LdapGroupRoleMappingServiceTests { private readonly RecordingContextFactory _db = new(); private LdapGroupRoleMapping Make(string group, AdminRole role, string? clusterId = null, bool? isSystemWide = null) => new() { LdapGroup = group, Role = role, ClusterId = clusterId, IsSystemWide = isSystemWide ?? (clusterId is null), }; /// Verifies that Create sets Id and CreatedAtUtc. [Fact] public async Task Create_SetsId_AndCreatedAtUtc() { var svc = new LdapGroupRoleMappingService(_db); var row = Make("cn=fleet,dc=x", AdminRole.Administrator); var saved = await svc.CreateAsync(row, CancellationToken.None); saved.Id.ShouldNotBe(Guid.Empty); saved.CreatedAtUtc.ShouldBeGreaterThan(DateTime.UtcNow.AddMinutes(-1)); } /// Verifies that Create rejects empty LDAP group. [Fact] public async Task Create_Rejects_EmptyLdapGroup() { var svc = new LdapGroupRoleMappingService(_db); var row = Make("", AdminRole.Administrator); await Should.ThrowAsync( () => svc.CreateAsync(row, CancellationToken.None)); } /// Verifies that Create rejects system-wide mapping with ClusterId. [Fact] public async Task Create_Rejects_SystemWide_With_ClusterId() { var svc = new LdapGroupRoleMappingService(_db); var row = Make("cn=g", AdminRole.Viewer, clusterId: "c1", isSystemWide: true); await Should.ThrowAsync( () => svc.CreateAsync(row, CancellationToken.None)); } /// Verifies that Create rejects non-system-wide mapping without ClusterId. [Fact] public async Task Create_Rejects_NonSystemWide_WithoutClusterId() { var svc = new LdapGroupRoleMappingService(_db); var row = Make("cn=g", AdminRole.Viewer, clusterId: null, isSystemWide: false); await Should.ThrowAsync( () => svc.CreateAsync(row, CancellationToken.None)); } /// Verifies that GetByGroups returns only matching grants. [Fact] public async Task GetByGroups_Returns_MatchingGrants_Only() { var svc = new LdapGroupRoleMappingService(_db); await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.Administrator), CancellationToken.None); await svc.CreateAsync(Make("cn=editor,dc=x", AdminRole.Designer), CancellationToken.None); await svc.CreateAsync(Make("cn=viewer,dc=x", AdminRole.Viewer), CancellationToken.None); var results = await svc.GetByGroupsAsync( ["cn=fleet,dc=x", "cn=viewer,dc=x"], CancellationToken.None); results.Count.ShouldBe(2); results.Select(r => r.Role).ShouldBe([AdminRole.Administrator, AdminRole.Viewer], ignoreOrder: true); } /// Verifies that GetByGroups returns empty when input is empty. [Fact] public async Task GetByGroups_Empty_Input_ReturnsEmpty() { var svc = new LdapGroupRoleMappingService(_db); await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.Administrator), CancellationToken.None); var results = await svc.GetByGroupsAsync([], CancellationToken.None); results.ShouldBeEmpty(); } /// Verifies that ListAll orders results by group then cluster. [Fact] public async Task ListAll_Orders_ByGroupThenCluster() { var svc = new LdapGroupRoleMappingService(_db); await svc.CreateAsync(Make("cn=b,dc=x", AdminRole.Administrator), CancellationToken.None); await svc.CreateAsync(Make("cn=a,dc=x", AdminRole.Designer, clusterId: "c2", isSystemWide: false), CancellationToken.None); await svc.CreateAsync(Make("cn=a,dc=x", AdminRole.Designer, clusterId: "c1", isSystemWide: false), CancellationToken.None); var results = await svc.ListAllAsync(CancellationToken.None); results[0].LdapGroup.ShouldBe("cn=a,dc=x"); results[0].ClusterId.ShouldBe("c1"); results[1].ClusterId.ShouldBe("c2"); results[2].LdapGroup.ShouldBe("cn=b,dc=x"); } /// Verifies that Delete removes the matching row. [Fact] public async Task Delete_Removes_Matching_Row() { var svc = new LdapGroupRoleMappingService(_db); var saved = await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.Administrator), CancellationToken.None); await svc.DeleteAsync(saved.Id, CancellationToken.None); var after = await svc.ListAllAsync(CancellationToken.None); after.ShouldBeEmpty(); } /// Verifies that Delete with unknown Id is a no-op. [Fact] public async Task Delete_Unknown_Id_IsNoOp() { var svc = new LdapGroupRoleMappingService(_db); await svc.DeleteAsync(Guid.NewGuid(), CancellationToken.None); // no exception } /// Verifies that a system-wide row (IsSystemWide=true, ClusterId=null) appears in both ListAllAsync and GetByGroupsAsync. [Fact] public async Task SystemWide_Row_AppearsIn_ListAll_And_GetByGroups() { var svc = new LdapGroupRoleMappingService(_db); var saved = await svc.CreateAsync( Make("cn=sysadmins,dc=x", AdminRole.Administrator, clusterId: null, isSystemWide: true), CancellationToken.None); saved.IsSystemWide.ShouldBeTrue(); saved.ClusterId.ShouldBeNull(); var all = await svc.ListAllAsync(CancellationToken.None); all.ShouldContain(r => r.Id == saved.Id); var byGroup = await svc.GetByGroupsAsync(["cn=sysadmins,dc=x"], CancellationToken.None); byGroup.ShouldContain(r => r.Id == saved.Id); } /// /// The service must take its context from the factory PER CALL and dispose it before returning — /// it must never pin one context (and therefore one pooled connection) for its own lifetime. That /// is what makes it safe on the RoleGrants Blazor page, where the DI scope is the circuit and two /// renders can overlap: a pinned context would throw /// "A second operation was started on this context instance" the first time they did. /// [Fact] public async Task Every_call_creates_and_disposes_its_own_context() { var svc = new LdapGroupRoleMappingService(_db); await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.Administrator), CancellationToken.None); await svc.ListAllAsync(CancellationToken.None); await svc.GetByGroupsAsync(["cn=fleet,dc=x"], CancellationToken.None); // One context per call, and no leftovers: a service holding its own context would show 0. _db.Created.Count.ShouldBe(3); foreach (var ctx in _db.Created) Should.Throw(() => ctx.LdapGroupRoleMappings.ToList()); } /// /// Hands out a fresh per call over one shared in-memory store /// (an explicit , so every context genuinely sees the same rows), /// and records what it created so a test can assert the per-call create/dispose discipline. /// private sealed class RecordingContextFactory : IDbContextFactory { private readonly DbContextOptions _options = new DbContextOptionsBuilder() .UseInMemoryDatabase($"ldap-grm-{Guid.NewGuid():N}", new InMemoryDatabaseRoot()) .Options; public List Created { get; } = []; public OtOpcUaConfigDbContext CreateDbContext() { var ctx = new OtOpcUaConfigDbContext(_options); Created.Add(ctx); return ctx; } } }