Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/CanonicalAdminRolesTests.cs
Joseph Doherty bd6c0b4d3d docs: complete XML doc comments via fixdocs (2757 to 131 findings)
Add missing <returns>/<param>/<summary>/<typeparam> tags and clean up
misused inheritdoc across 481 files so the documented API surface is
complete. Documentation-only (zero code lines changed). The 131 remaining
findings are inheritdoc-style warnings deliberately left to preserve
hand-written implementation rationale (plan-decision notes, race-condition
explanations).
2026-06-03 12:34:34 -04:00

184 lines
9.5 KiB
C#

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;
/// <summary>
/// Task 1.7 — control-plane admin roles are standardized on the canonical six
/// (<c>Viewer / Operator / Engineer / Designer / Deployer / Administrator</c>). 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).
/// </summary>
public sealed class CanonicalAdminRolesTests
{
// --- (a) the mapper mints the CANONICAL role claim for each native group ----------------
/// <summary>Verifies that the mapper yields the canonical role claim for each native LDAP group.</summary>
/// <param name="canonicalRole">The canonical role name to test.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<string, string> { ["the-group"] = canonicalRole });
var result = await mapper.MapAsync(["the-group"], CancellationToken.None);
result.Roles.ShouldContain(canonicalRole);
}
/// <summary>Verifies that a system-wide DB row role renders as the canonical role string.</summary>
/// <param name="role">The admin role enum value to test.</param>
/// <param name="expected">The expected canonical role string.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<string, string>(),
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 ---
/// <summary>Verifies that the Deployments role check authorizes Designer and Administrator roles.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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();
}
/// <summary>Verifies that the FleetAdmin policy authorizes only the Administrator role.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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();
}
/// <summary>Verifies that the DriverOperator policy authorizes Operator and Administrator roles.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<IConfiguration>(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<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase("authz-test"));
services.AddDbContext<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase("authz-test"));
services.AddOtOpcUaAuth(new ConfigurationBuilder().Build());
return services.BuildServiceProvider().GetRequiredService<IAuthorizationService>();
}
private static OtOpcUaGroupRoleMapper BuildMapper(
IDictionary<string, string> groupToRole,
params LdapGroupRoleMapping[] dbRows)
{
var options = Microsoft.Extensions.Options.Options.Create(new LdapOptions
{
GroupToRole = new Dictionary<string, string>(groupToRole, StringComparer.OrdinalIgnoreCase),
});
return new OtOpcUaGroupRoleMapper(options, new FakeMappingService(dbRows));
}
private sealed class FakeMappingService(IReadOnlyList<LdapGroupRoleMapping> rows) : ILdapGroupRoleMappingService
{
/// <summary>Returns all seeded rows that belong to any of the specified LDAP groups.</summary>
/// <param name="ldapGroups">The LDAP groups to look up.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that resolves to the matching role mappings.</returns>
public Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
=> Task.FromResult(rows);
/// <summary>Returns all seeded role mapping rows.</summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that resolves to all role mappings.</returns>
public Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
=> Task.FromResult(rows);
/// <summary>Not supported in this stub.</summary>
/// <param name="row">The row to create.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Never returns; always throws.</returns>
public Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
=> throw new NotSupportedException();
/// <summary>Not supported in this stub.</summary>
/// <param name="id">The identifier of the row to delete.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Never returns; always throws.</returns>
public Task DeleteAsync(Guid id, CancellationToken cancellationToken)
=> throw new NotSupportedException();
}
}