Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LdapGroupRoleMappingServiceTests.cs
T

202 lines
8.2 KiB
C#

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),
};
/// <summary>Verifies that Create sets Id and CreatedAtUtc.</summary>
[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));
}
/// <summary>Verifies that Create rejects empty LDAP group.</summary>
[Fact]
public async Task Create_Rejects_EmptyLdapGroup()
{
var svc = new LdapGroupRoleMappingService(_db);
var row = Make("", AdminRole.Administrator);
await Should.ThrowAsync<InvalidLdapGroupRoleMappingException>(
() => svc.CreateAsync(row, CancellationToken.None));
}
/// <summary>Verifies that Create rejects system-wide mapping with ClusterId.</summary>
[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<InvalidLdapGroupRoleMappingException>(
() => svc.CreateAsync(row, CancellationToken.None));
}
/// <summary>Verifies that Create rejects non-system-wide mapping without ClusterId.</summary>
[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<InvalidLdapGroupRoleMappingException>(
() => svc.CreateAsync(row, CancellationToken.None));
}
/// <summary>Verifies that GetByGroups returns only matching grants.</summary>
[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);
}
/// <summary>Verifies that GetByGroups returns empty when input is empty.</summary>
[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();
}
/// <summary>Verifies that ListAll orders results by group then cluster.</summary>
[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");
}
/// <summary>Verifies that Delete removes the matching row.</summary>
[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();
}
/// <summary>Verifies that Delete with unknown Id is a no-op.</summary>
[Fact]
public async Task Delete_Unknown_Id_IsNoOp()
{
var svc = new LdapGroupRoleMappingService(_db);
await svc.DeleteAsync(Guid.NewGuid(), CancellationToken.None);
// no exception
}
/// <summary>Verifies that a system-wide row (IsSystemWide=true, ClusterId=null) appears in both ListAllAsync and GetByGroupsAsync.</summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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<ObjectDisposedException>(() => ctx.LdapGroupRoleMappings.ToList());
}
/// <summary>
/// Hands out a fresh <see cref="OtOpcUaConfigDbContext"/> per call over one shared in-memory store
/// (an explicit <see cref="InMemoryDatabaseRoot"/>, so every context genuinely sees the same rows),
/// and records what it created so a test can assert the per-call create/dispose discipline.
/// </summary>
private sealed class RecordingContextFactory : IDbContextFactory<OtOpcUaConfigDbContext>
{
private readonly DbContextOptions<OtOpcUaConfigDbContext> _options =
new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase($"ldap-grm-{Guid.NewGuid():N}", new InMemoryDatabaseRoot())
.Options;
public List<OtOpcUaConfigDbContext> Created { get; } = [];
public OtOpcUaConfigDbContext CreateDbContext()
{
var ctx = new OtOpcUaConfigDbContext(_options);
Created.Add(ctx);
return ctx;
}
}
}