using Microsoft.Extensions.Options; using Shouldly; using Xunit; using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Configuration.Services; using ZB.MOM.WW.OtOpcUa.Security.Ldap; namespace ZB.MOM.WW.OtOpcUa.Security.Tests; /// /// Proves is a behaviour-preserving wrapper over the /// existing + logic: config /// baseline + system-wide DB grants, cluster-scoped DB rows ignored, unmapped groups dropped, /// and Scope always null. /// public sealed class OtOpcUaGroupRoleMapperTests { private static OtOpcUaGroupRoleMapper Build( IDictionary groupToRole, params LdapGroupRoleMapping[] dbRows) { var options = Microsoft.Extensions.Options.Options.Create(new LdapOptions { GroupToRole = new Dictionary(groupToRole, StringComparer.OrdinalIgnoreCase), }); return new OtOpcUaGroupRoleMapper(options, new FakeMappingService(dbRows)); } [Fact] public async Task Maps_config_group_and_drops_unmapped_group() { var mapper = Build(new Dictionary { ["AdminGroup"] = "Administrator" }); var result = await mapper.MapAsync(["AdminGroup", "UnmappedGroup"], CancellationToken.None); result.Roles.ShouldBe(["Administrator"]); result.Scope.ShouldBeNull(); } [Fact] public async Task System_wide_db_row_adds_role_on_top_of_config_baseline() { var mapper = Build( new Dictionary { ["viewers"] = "Viewer" }, new LdapGroupRoleMapping { LdapGroup = "admins", Role = AdminRole.Administrator, IsSystemWide = true }); var result = await mapper.MapAsync(["viewers", "admins"], CancellationToken.None); result.Roles.ShouldContain("Viewer"); result.Roles.ShouldContain("Administrator"); result.Scope.ShouldBeNull(); } [Fact] public async Task Cluster_scoped_db_row_is_ignored() { var mapper = Build( new Dictionary(), new LdapGroupRoleMapping { LdapGroup = "site-a-editors", Role = AdminRole.Designer, IsSystemWide = false, ClusterId = "SITE-A", }); var result = await mapper.MapAsync(["site-a-editors"], CancellationToken.None); result.Roles.ShouldNotContain("Designer"); result.Roles.ShouldBeEmpty(); } [Fact] public async Task Driver_only_node_falls_back_to_appsettings_baseline_via_null_mapping_service() { // Simulates a driver-only node: no ConfigDb, so ILdapGroupRoleMappingService is bound to // NullLdapGroupRoleMappingService instead of the EF-backed one. MapAsync must still resolve // roles from Security:Ldap:GroupToRole alone, and must not throw for lack of a DB. var options = Options.Create(new LdapOptions { GroupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["operators"] = "WriteOperate", }, }); var mapper = new OtOpcUaGroupRoleMapper(options, new NullLdapGroupRoleMappingService()); var result = await mapper.MapAsync(["operators", "unmapped-group"], CancellationToken.None); result.Roles.ShouldBe(["WriteOperate"]); result.Scope.ShouldBeNull(); } [Fact] public async Task Reproduces_RoleMapper_Map_plus_Merge_for_representative_inputs() { var groupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["viewers"] = "Viewer", ["editors"] = "Designer", }; var dbRows = new[] { new LdapGroupRoleMapping { LdapGroup = "admins", Role = AdminRole.Administrator, IsSystemWide = true }, new LdapGroupRoleMapping { LdapGroup = "site-a", Role = AdminRole.Designer, IsSystemWide = false, ClusterId = "SITE-A" }, }; var groups = new[] { "viewers", "editors", "admins", "site-a", "noise" }; var mapper = Build(groupToRole, dbRows); // Oracle: exactly what the legacy login path computes today. var baseline = RoleMapper.Map(groups, groupToRole); var expected = RoleMapper.Merge(baseline, dbRows); var result = await mapper.MapAsync(groups, CancellationToken.None); result.Roles.OrderBy(r => r).ShouldBe(expected.OrderBy(r => r)); result.Scope.ShouldBeNull(); } /// In-memory stand-in for the EF-backed DB service; returns the configured rows verbatim. private sealed class FakeMappingService(IReadOnlyList rows) : ILdapGroupRoleMappingService { public Task> GetByGroupsAsync( IEnumerable ldapGroups, CancellationToken cancellationToken) => Task.FromResult(rows); public Task> ListAllAsync(CancellationToken cancellationToken) => Task.FromResult(rows); public Task CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task DeleteAsync(Guid id, CancellationToken cancellationToken) => throw new NotSupportedException(); } } /// /// Proves — the driver-only-node stand-in for the /// EF-backed ILdapGroupRoleMappingService — always reports "no DB grants" rather than /// throwing on the hot sign-in path, while its control-plane write surface (never called /// without an AdminUI) fails loudly instead of silently no-opping. /// public sealed class NullLdapGroupRoleMappingServiceTests { [Fact] public async Task GetByGroupsAsync_returns_empty() { var sut = new NullLdapGroupRoleMappingService(); var result = await sut.GetByGroupsAsync(["any-group"], CancellationToken.None); result.ShouldBeEmpty(); } [Fact] public async Task ListAllAsync_returns_empty() { var sut = new NullLdapGroupRoleMappingService(); var result = await sut.ListAllAsync(CancellationToken.None); result.ShouldBeEmpty(); } [Fact] public async Task CreateAsync_throws_NotSupportedException() { var sut = new NullLdapGroupRoleMappingService(); var row = new LdapGroupRoleMapping { LdapGroup = "g", Role = AdminRole.Administrator, IsSystemWide = true }; await Should.ThrowAsync(() => sut.CreateAsync(row, CancellationToken.None)); } [Fact] public async Task DeleteAsync_throws_NotSupportedException() { var sut = new NullLdapGroupRoleMappingService(); await Should.ThrowAsync(() => sut.DeleteAsync(Guid.NewGuid(), CancellationToken.None)); } }