Stream A.1-A.2 per docs/v2/implementation/phase-6-2-authorization-runtime.md.
Seed-data migration (A.3) is a separate follow-up once production LDAP group
DNs are finalised; until then CRUD via the Admin UI handles the fleet set up.
Configuration:
- New AdminRole enum {ConfigViewer, ConfigEditor, FleetAdmin} — string-stored.
- New LdapGroupRoleMapping entity with Id (surrogate PK), LdapGroup (512 chars),
Role (AdminRole enum), ClusterId (nullable, FK to ServerCluster), IsSystemWide,
CreatedAtUtc, Notes.
- EF config: UX_LdapGroupRoleMapping_Group_Cluster unique index on
(LdapGroup, ClusterId) + IX_LdapGroupRoleMapping_Group hot-path index on
LdapGroup for sign-in lookups. Cluster FK cascades on cluster delete.
- Migration 20260419_..._AddLdapGroupRoleMapping generated via `dotnet ef`.
Configuration.Services:
- ILdapGroupRoleMappingService — CRUD surface. Declared as control-plane only
per decision #150; the OPC UA data-path evaluator must NOT depend on this
interface (Phase 6.2 compliance check on control/data-plane separation).
GetByGroupsAsync is the hot-path sign-in lookup.
- LdapGroupRoleMappingService (EF Core impl) enforces the write-time invariant
"exactly one of (ClusterId populated, IsSystemWide=true)" and surfaces
InvalidLdapGroupRoleMappingException on violation. Create auto-populates Id
+ CreatedAtUtc when omitted.
Tests (9 new, all pass) in Configuration.Tests:
- Create sets Id + CreatedAtUtc.
- Create rejects empty LdapGroup.
- Create rejects IsSystemWide=true with populated ClusterId.
- Create rejects IsSystemWide=false with null ClusterId.
- GetByGroupsAsync returns matching rows only.
- GetByGroupsAsync with empty input returns empty (no full-table scan).
- ListAllAsync orders by group then cluster.
- Delete removes the target row.
- Delete of unknown id is a no-op.
Microsoft.EntityFrameworkCore.InMemory 10.0.0 added to Configuration.Tests for
the service-level tests (schema-compliance tests still use the live SQL
fixture).
SchemaComplianceTests updated to expect the new LdapGroupRoleMapping table.
Full solution dotnet test: 1051 passing (baseline 906, Phase 6.1 shipped at
1042, Phase 6.2 Stream A adds 9 = 1051). Pre-existing Client.CLI Subscribe
flake unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
139 lines
4.9 KiB
C#
139 lines
4.9 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
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 : IDisposable
|
|
{
|
|
private readonly OtOpcUaConfigDbContext _db;
|
|
|
|
public LdapGroupRoleMappingServiceTests()
|
|
{
|
|
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
|
.UseInMemoryDatabase($"ldap-grm-{Guid.NewGuid():N}")
|
|
.Options;
|
|
_db = new OtOpcUaConfigDbContext(options);
|
|
}
|
|
|
|
public void Dispose() => _db.Dispose();
|
|
|
|
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),
|
|
};
|
|
|
|
[Fact]
|
|
public async Task Create_SetsId_AndCreatedAtUtc()
|
|
{
|
|
var svc = new LdapGroupRoleMappingService(_db);
|
|
var row = Make("cn=fleet,dc=x", AdminRole.FleetAdmin);
|
|
|
|
var saved = await svc.CreateAsync(row, CancellationToken.None);
|
|
|
|
saved.Id.ShouldNotBe(Guid.Empty);
|
|
saved.CreatedAtUtc.ShouldBeGreaterThan(DateTime.UtcNow.AddMinutes(-1));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_Rejects_EmptyLdapGroup()
|
|
{
|
|
var svc = new LdapGroupRoleMappingService(_db);
|
|
var row = Make("", AdminRole.FleetAdmin);
|
|
|
|
await Should.ThrowAsync<InvalidLdapGroupRoleMappingException>(
|
|
() => svc.CreateAsync(row, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_Rejects_SystemWide_With_ClusterId()
|
|
{
|
|
var svc = new LdapGroupRoleMappingService(_db);
|
|
var row = Make("cn=g", AdminRole.ConfigViewer, clusterId: "c1", isSystemWide: true);
|
|
|
|
await Should.ThrowAsync<InvalidLdapGroupRoleMappingException>(
|
|
() => svc.CreateAsync(row, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_Rejects_NonSystemWide_WithoutClusterId()
|
|
{
|
|
var svc = new LdapGroupRoleMappingService(_db);
|
|
var row = Make("cn=g", AdminRole.ConfigViewer, clusterId: null, isSystemWide: false);
|
|
|
|
await Should.ThrowAsync<InvalidLdapGroupRoleMappingException>(
|
|
() => svc.CreateAsync(row, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetByGroups_Returns_MatchingGrants_Only()
|
|
{
|
|
var svc = new LdapGroupRoleMappingService(_db);
|
|
await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
|
|
await svc.CreateAsync(Make("cn=editor,dc=x", AdminRole.ConfigEditor), CancellationToken.None);
|
|
await svc.CreateAsync(Make("cn=viewer,dc=x", AdminRole.ConfigViewer), 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.FleetAdmin, AdminRole.ConfigViewer], ignoreOrder: true);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetByGroups_Empty_Input_ReturnsEmpty()
|
|
{
|
|
var svc = new LdapGroupRoleMappingService(_db);
|
|
await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
|
|
|
|
var results = await svc.GetByGroupsAsync([], CancellationToken.None);
|
|
|
|
results.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListAll_Orders_ByGroupThenCluster()
|
|
{
|
|
var svc = new LdapGroupRoleMappingService(_db);
|
|
await svc.CreateAsync(Make("cn=b,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
|
|
await svc.CreateAsync(Make("cn=a,dc=x", AdminRole.ConfigEditor, clusterId: "c2", isSystemWide: false), CancellationToken.None);
|
|
await svc.CreateAsync(Make("cn=a,dc=x", AdminRole.ConfigEditor, 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");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Delete_Removes_Matching_Row()
|
|
{
|
|
var svc = new LdapGroupRoleMappingService(_db);
|
|
var saved = await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
|
|
|
|
await svc.DeleteAsync(saved.Id, CancellationToken.None);
|
|
|
|
var after = await svc.ListAllAsync(CancellationToken.None);
|
|
after.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Delete_Unknown_Id_IsNoOp()
|
|
{
|
|
var svc = new LdapGroupRoleMappingService(_db);
|
|
|
|
await svc.DeleteAsync(Guid.NewGuid(), CancellationToken.None);
|
|
// no exception
|
|
}
|
|
}
|