feat(auth): add IGroupRoleMapper<string> seam (Task 1.1)

This commit is contained in:
Joseph Doherty
2026-06-02 00:31:00 -04:00
parent ae281d06bb
commit 792e3f9445
6 changed files with 208 additions and 40 deletions
@@ -0,0 +1,104 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary>
/// Tests for <see cref="DashboardGroupRoleMapper"/>, the shared-Auth
/// <see cref="IGroupRoleMapper{TRole}"/> seam over the dashboard's
/// LDAP-group → role mapping. Behaviour must match the existing
/// <c>DashboardAuthenticator.MapGroupsToRoles</c> precedence/case rules.
/// </summary>
public sealed class DashboardGroupRoleMapperTests
{
private static DashboardGroupRoleMapper CreateMapper(Dictionary<string, string> mapping)
{
GatewayOptions options = new()
{
Dashboard = new DashboardOptions
{
GroupToRole = mapping,
},
};
return new DashboardGroupRoleMapper(Options.Create(options));
}
private static Dictionary<string, string> StandardMapping() => new(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = DashboardRoles.Admin,
["GwReader"] = DashboardRoles.Viewer,
};
/// <summary>Verifies full-DN match, leading-RDN fallback, case-insensitivity, and unmapped → empty.</summary>
/// <param name="ldapGroup">The LDAP group name or distinguished name.</param>
/// <param name="expectedRole">The expected role or null if no match.</param>
[Theory]
[InlineData("GwAdmin", DashboardRoles.Admin)]
[InlineData("gwadmin", DashboardRoles.Admin)]
[InlineData("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", DashboardRoles.Admin)]
[InlineData("OtherGroup", null)]
public async Task MapAsync_ResolvesByShortNameAndDistinguishedName(
string ldapGroup,
string? expectedRole)
{
DashboardGroupRoleMapper mapper = CreateMapper(StandardMapping());
GroupRoleMapping<string> result = await mapper.MapAsync([ldapGroup], CancellationToken.None);
if (expectedRole is null)
{
Assert.Empty(result.Roles);
}
else
{
Assert.Equal(expectedRole, Assert.Single(result.Roles));
}
Assert.Null(result.Scope);
}
/// <summary>Verifies that admin and viewer roles are both emitted when both groups are present.</summary>
[Fact]
public async Task MapAsync_AdminPlusViewer_BothRolesEmitted()
{
DashboardGroupRoleMapper mapper = CreateMapper(StandardMapping());
GroupRoleMapping<string> result = await mapper.MapAsync(
["GwAdmin", "GwReader"],
CancellationToken.None);
Assert.Contains(DashboardRoles.Admin, result.Roles);
Assert.Contains(DashboardRoles.Viewer, result.Roles);
}
/// <summary>Verifies that an empty GroupToRole map yields no roles.</summary>
[Fact]
public async Task MapAsync_EmptyMapping_ReturnsNoRoles()
{
DashboardGroupRoleMapper mapper = CreateMapper(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase));
GroupRoleMapping<string> result = await mapper.MapAsync(["GwAdmin"], CancellationToken.None);
Assert.Empty(result.Roles);
}
/// <summary>
/// Verifies the extracted shared helper is the single source of truth: it
/// produces the same roles the mapper does for the same inputs.
/// </summary>
[Fact]
public async Task SharedHelper_MatchesMapperOutput()
{
Dictionary<string, string> mapping = StandardMapping();
DashboardGroupRoleMapper mapper = CreateMapper(mapping);
string[] groups = ["ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", "gwreader"];
IReadOnlyList<string> helperRoles = DashboardGroupRoleMapping.MapGroupsToRoles(groups, mapping);
GroupRoleMapping<string> mapperResult = await mapper.MapAsync(groups, CancellationToken.None);
Assert.Equal([.. helperRoles.OrderBy(r => r)], [.. mapperResult.Roles.OrderBy(r => r)]);
}
}