using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Shouldly; using Xunit; using ZB.MOM.WW.Auth.AspNetCore; using ZB.MOM.WW.OtOpcUa.Configuration; 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; /// /// Task 1.7 — control-plane admin roles are standardized on the canonical six /// (Viewer / Operator / Engineer / Designer / Deployer / Administrator). OtOpcUa /// uses four of them: ConfigViewer→Viewer, ConfigEditor→Designer, FleetAdmin→Administrator, /// and the appsettings-only DriverOperator→Operator. These tests pin the canonical role /// VALUES end-to-end (mapper output claims + the real registered authorization policies) and /// prove enforcement semantics are preserved (whoever could deploy/administer/operate before /// still can — it is a rename, not a permission change). /// public sealed class CanonicalAdminRolesTests { // --- (a) the mapper mints the CANONICAL role claim for each native group ---------------- [Theory] [InlineData("Viewer")] // was ConfigViewer [InlineData("Designer")] // was ConfigEditor [InlineData("Administrator")] // was FleetAdmin [InlineData("Operator")] // was DriverOperator (appsettings-only string role) public async Task Mapper_yields_canonical_role_for_native_group(string canonicalRole) { // appsettings GroupToRole baseline carries the canonical value verbatim. var mapper = BuildMapper(new Dictionary { ["the-group"] = canonicalRole }); var result = await mapper.MapAsync(["the-group"], CancellationToken.None); result.Roles.ShouldContain(canonicalRole); } [Theory] [InlineData(AdminRole.Viewer, "Viewer")] [InlineData(AdminRole.Designer, "Designer")] [InlineData(AdminRole.Administrator, "Administrator")] public async Task System_wide_db_row_role_renders_as_canonical_string(AdminRole role, string expected) { // The DB path stringifies the enum member name (row.Role.ToString()); renaming the enum // members is what makes the persisted/emitted string canonical. var mapper = BuildMapper( new Dictionary(), new LdapGroupRoleMapping { LdapGroup = "g", Role = role, IsSystemWide = true }); var result = await mapper.MapAsync(["g"], CancellationToken.None); result.Roles.ShouldContain(expected); } // --- (b)/(c) the REAL registered authorization policies enforce on the canonical values --- [Fact] public async Task Deployments_role_check_authorizes_Designer_and_Administrator() { // Deployments.razor uses [Authorize(Roles="Administrator,Designer")] — a direct role-string // check (not a named policy). Reproduce it via RequireRole and prove both still pass. var policy = new AuthorizationPolicyBuilder() .RequireRole("Administrator", "Designer") .Build(); var authz = BuildAuthorizationService(); (await authz.AuthorizeAsync(UserInRole("Designer"), policy)).Succeeded.ShouldBeTrue(); (await authz.AuthorizeAsync(UserInRole("Administrator"), policy)).Succeeded.ShouldBeTrue(); (await authz.AuthorizeAsync(UserInRole("Viewer"), policy)).Succeeded.ShouldBeFalse(); } [Fact] public async Task FleetAdmin_policy_authorizes_only_Administrator() { var authz = BuildAuthorizationService(); // RoleGrants.razor is gated by the "FleetAdmin" named policy → RequireRole("Administrator"). (await authz.AuthorizeAsync(UserInRole("Administrator"), "FleetAdmin")).Succeeded.ShouldBeTrue(); (await authz.AuthorizeAsync(UserInRole("Designer"), "FleetAdmin")).Succeeded.ShouldBeFalse(); (await authz.AuthorizeAsync(UserInRole("Operator"), "FleetAdmin")).Succeeded.ShouldBeFalse(); (await authz.AuthorizeAsync(UserInRole("Viewer"), "FleetAdmin")).Succeeded.ShouldBeFalse(); } [Fact] public async Task DriverOperator_policy_authorizes_Operator_and_Administrator() { var authz = BuildAuthorizationService(); // DriverStatusPanel/pickers gate on the "DriverOperator" named policy → // RequireRole("Operator","Administrator"). Operator (was DriverOperator) and Administrator // (was FleetAdmin) both pass; a plain Viewer does not. (await authz.AuthorizeAsync(UserInRole("Operator"), "DriverOperator")).Succeeded.ShouldBeTrue(); (await authz.AuthorizeAsync(UserInRole("Administrator"), "DriverOperator")).Succeeded.ShouldBeTrue(); (await authz.AuthorizeAsync(UserInRole("Viewer"), "DriverOperator")).Succeeded.ShouldBeFalse(); } // --- helpers ---------------------------------------------------------------------------- private static ClaimsPrincipal UserInRole(string role) { // ZbClaimTypes.Role aliases ClaimTypes.Role, the default role-claim type, so RequireRole / // IsInRole resolve against it. var identity = new ClaimsIdentity( [new Claim(ZbClaimTypes.Role, role)], authenticationType: "Test"); return new ClaimsPrincipal(identity); } private static IAuthorizationService BuildAuthorizationService() { var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(new ConfigurationBuilder().Build()); // Use the REAL policy registrations from AddOtOpcUaAuth; it needs the ConfigDbContext for // DataProtection key persistence, so register an in-memory one. services.AddDbContextFactory(o => o.UseInMemoryDatabase("authz-test")); services.AddDbContext(o => o.UseInMemoryDatabase("authz-test")); services.AddOtOpcUaAuth(new ConfigurationBuilder().Build()); return services.BuildServiceProvider().GetRequiredService(); } private static OtOpcUaGroupRoleMapper BuildMapper( 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)); } 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(); } }