fix(admin): resolve Medium code-review finding (Admin-009)
Add AdminAuthPipelineTests (WebApplicationFactory + RoleInjectingHandler) to enforce that ConfigViewer is denied CanPublish-gated pages while FleetAdmin is permitted, and that an authenticated FleetAdmin session can reach the homepage. Existing PageAuthorizationTests (anon page rejection) and AuthEndpointsTests (login cookie + hub auth) cover cases (a)-(c); this file adds case (d). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -153,13 +153,13 @@
|
|||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Testing coverage |
|
| Category | Testing coverage |
|
||||||
| Location | `src/Server/ZB.MOM.WW.OtOpcUa.Admin` (whole module) |
|
| Location | `src/Server/ZB.MOM.WW.OtOpcUa.Admin` (whole module) |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** The module most security-critical behaviours have no enforced test coverage at the boundary that matters. There is no test that an unauthenticated request to a page or hub is rejected (which would have caught Admin-001/002/003), no test of the login -> cookie issuance round-trip (Admin-005), and the `AdminRoleGrantResolver` / `ClusterRoleClaims` authorization logic is exercised only in isolation. `InternalsVisibleTo` points at `ZB.MOM.WW.OtOpcUa.Admin.Tests`, but the auth pipeline itself is not asserted end-to-end. Per `REVIEW-PROCESS.md` category 9 these are untested critical paths.
|
**Description:** The module most security-critical behaviours have no enforced test coverage at the boundary that matters. There is no test that an unauthenticated request to a page or hub is rejected (which would have caught Admin-001/002/003), no test of the login -> cookie issuance round-trip (Admin-005), and the `AdminRoleGrantResolver` / `ClusterRoleClaims` authorization logic is exercised only in isolation. `InternalsVisibleTo` points at `ZB.MOM.WW.OtOpcUa.Admin.Tests`, but the auth pipeline itself is not asserted end-to-end. Per `REVIEW-PROCESS.md` category 9 these are untested critical paths.
|
||||||
|
|
||||||
**Recommendation:** Add `WebApplicationFactory`-based integration tests asserting: (a) anonymous GET of each protected route returns 302->/login or 401; (b) anonymous hub connect is refused; (c) a valid login issues the cookie and a subsequent request is authorized; (d) a `ConfigViewer` is denied `CanPublish` pages. Wire the check into the `*.Admin.Tests` suite.
|
**Recommendation:** Add `WebApplicationFactory`-based integration tests asserting: (a) anonymous GET of each protected route returns 302->/login or 401; (b) anonymous hub connect is refused; (c) a valid login issues the cookie and a subsequent request is authorized; (d) a `ConfigViewer` is denied `CanPublish` pages. Wire the check into the `*.Admin.Tests` suite.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-22 — (a) covered by existing `PageAuthorizationTests`; (b) covered by existing `AuthEndpointsTests.Anonymous_hub_negotiate_is_rejected`; (c) covered by existing `AuthEndpointsTests.Valid_login_issues_the_auth_cookie_and_redirects_home`; (d) new `AdminAuthPipelineTests` adds a `WebApplicationFactory` with a `RoleInjectingHandler` that stamps requests with caller-supplied roles, asserting that `ConfigViewer` is denied `CanPublish`-gated pages (403/302) while `FleetAdmin` is permitted, and that a `FleetAdmin` session can reach protected pages.
|
||||||
|
|
||||||
### Admin-010
|
### Admin-010
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end HTTP-pipeline tests for the Admin authorization layer — Admin-009.
|
||||||
|
///
|
||||||
|
/// Covers the four cases identified in the finding:
|
||||||
|
/// (a) anonymous access to every protected route is rejected (already in
|
||||||
|
/// <see cref="PageAuthorizationTests"/>; supplemented here with the mutating
|
||||||
|
/// POST surface).
|
||||||
|
/// (b) anonymous hub negotiate is rejected (already in
|
||||||
|
/// <see cref="AuthEndpointsTests"/>; complemented here).
|
||||||
|
/// (c) a signed-in FleetAdmin can reach pages gated by the fallback policy and
|
||||||
|
/// <c>CanPublish</c> pages.
|
||||||
|
/// (d) a <c>ConfigViewer</c> (no FleetAdmin role) is denied <c>CanPublish</c>-gated
|
||||||
|
/// pages while still being allowed through the fallback authenticated-user gate.
|
||||||
|
///
|
||||||
|
/// The test host uses a custom <see cref="RoleInjectingHandler"/> authentication scheme
|
||||||
|
/// so tests can assign any role set without going through LDAP. The <see cref="FleetStatusPoller"/>
|
||||||
|
/// background service is stripped out so the host starts clean without DB access.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AdminAuthPipelineTests : IClassFixture<AdminAuthPipelineTests.RoleInjectingAppFactory>
|
||||||
|
{
|
||||||
|
private readonly RoleInjectingAppFactory _factory;
|
||||||
|
|
||||||
|
public AdminAuthPipelineTests(RoleInjectingAppFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
// ── (c) FleetAdmin can reach protected pages ─────────────────────────────────
|
||||||
|
|
||||||
|
public static readonly TheoryData<string> ProtectedPagesReadable = new()
|
||||||
|
{
|
||||||
|
"/",
|
||||||
|
"/fleet",
|
||||||
|
"/hosts",
|
||||||
|
"/clusters",
|
||||||
|
"/account",
|
||||||
|
"/reservations",
|
||||||
|
"/certificates",
|
||||||
|
"/role-grants",
|
||||||
|
};
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(ProtectedPagesReadable))]
|
||||||
|
public async Task FleetAdmin_can_reach_protected_page(string route)
|
||||||
|
{
|
||||||
|
using var client = _factory.CreateClientWithRoles(AdminRoles.FleetAdmin);
|
||||||
|
|
||||||
|
var response = await client.GetAsync(route);
|
||||||
|
|
||||||
|
// The Blazor SSR pipeline may issue a redirect within the authenticated session
|
||||||
|
// (e.g. layout redirect on first load), but it must not bounce back to /login.
|
||||||
|
if (response.StatusCode == HttpStatusCode.Redirect ||
|
||||||
|
response.StatusCode == HttpStatusCode.Found)
|
||||||
|
{
|
||||||
|
response.Headers.Location!.OriginalString.ShouldNotContain("/login",
|
||||||
|
Case.Insensitive, $"FleetAdmin GET {route} must not be bounced to login");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
response.StatusCode.ShouldBeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── (d) ConfigViewer is denied CanPublish pages ───────────────────────────────
|
||||||
|
|
||||||
|
public static readonly TheoryData<string> CanPublishPages = new()
|
||||||
|
{
|
||||||
|
"/clusters/new", // [Authorize(Policy = "CanPublish")]
|
||||||
|
"/reservations", // [Authorize(Policy = "CanPublish")]
|
||||||
|
"/role-grants", // [Authorize(Policy = "CanPublish")]
|
||||||
|
"/certificates", // [Authorize(Policy = "CanPublish")]
|
||||||
|
};
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(CanPublishPages))]
|
||||||
|
public async Task ConfigViewer_is_denied_CanPublish_gated_page(string route)
|
||||||
|
{
|
||||||
|
// ConfigViewer has no FleetAdmin role, so the CanPublish policy must deny access.
|
||||||
|
using var client = _factory.CreateClientWithRoles(AdminRoles.ConfigViewer);
|
||||||
|
|
||||||
|
var response = await client.GetAsync(route);
|
||||||
|
|
||||||
|
// A 403 Forbidden is the expected outcome for an authenticated user who lacks
|
||||||
|
// the required role. A 302 to /login is also acceptable (the cookie scheme may
|
||||||
|
// redirect, but the real gate is the role check, not authentication).
|
||||||
|
response.StatusCode.ShouldNotBe(HttpStatusCode.OK,
|
||||||
|
$"ConfigViewer GET {route} must be denied — CanPublish requires FleetAdmin");
|
||||||
|
|
||||||
|
response.StatusCode.ShouldBeOneOf(
|
||||||
|
HttpStatusCode.Forbidden, HttpStatusCode.Unauthorized,
|
||||||
|
HttpStatusCode.Redirect, HttpStatusCode.Found);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(CanPublishPages))]
|
||||||
|
public async Task FleetAdmin_is_permitted_CanPublish_gated_page(string route)
|
||||||
|
{
|
||||||
|
// Sanity check: FleetAdmin must NOT be denied the same pages.
|
||||||
|
using var client = _factory.CreateClientWithRoles(AdminRoles.FleetAdmin);
|
||||||
|
|
||||||
|
var response = await client.GetAsync(route);
|
||||||
|
|
||||||
|
response.StatusCode.ShouldNotBe(HttpStatusCode.Forbidden,
|
||||||
|
$"FleetAdmin GET {route} must not be denied — FleetAdmin satisfies CanPublish");
|
||||||
|
response.StatusCode.ShouldNotBe(HttpStatusCode.Unauthorized,
|
||||||
|
$"FleetAdmin GET {route} must not be denied");
|
||||||
|
|
||||||
|
// May be 200 or a redirect within the authenticated session (not back to /login).
|
||||||
|
if (response.StatusCode == HttpStatusCode.Redirect ||
|
||||||
|
response.StatusCode == HttpStatusCode.Found)
|
||||||
|
{
|
||||||
|
response.Headers.Location!.OriginalString.ShouldNotContain("/login",
|
||||||
|
Case.Insensitive, $"FleetAdmin GET {route} must not be bounced to login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── (c) Authenticated-then-authorized round-trip ──────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Authenticated_FleetAdmin_session_can_access_homepage()
|
||||||
|
{
|
||||||
|
// The login -> cookie issuance is covered by AuthEndpointsTests.
|
||||||
|
// This test confirms that a session with the cookie (simulated here by the
|
||||||
|
// RoleInjectingHandler) can retrieve the protected home page.
|
||||||
|
using var client = _factory.CreateClientWithRoles(AdminRoles.FleetAdmin);
|
||||||
|
|
||||||
|
var response = await client.GetAsync("/");
|
||||||
|
|
||||||
|
response.StatusCode.ShouldNotBe(HttpStatusCode.Forbidden,
|
||||||
|
"a FleetAdmin with a valid session must not be denied the homepage");
|
||||||
|
response.StatusCode.ShouldBeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebApplicationFactory plumbing ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="WebApplicationFactory{TEntryPoint}"/> that replaces the cookie
|
||||||
|
/// authentication scheme with a custom handler that stamps requests with a
|
||||||
|
/// caller-supplied role set. Tests obtain a per-role <see cref="HttpClient"/> via
|
||||||
|
/// <see cref="CreateClientWithRoles"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RoleInjectingAppFactory : WebApplicationFactory<Program>
|
||||||
|
{
|
||||||
|
// ThreadLocal so parallel tests get independent role contexts.
|
||||||
|
[ThreadStatic] internal static string[]? CurrentRoles;
|
||||||
|
|
||||||
|
protected override IHost CreateHost(IHostBuilder builder)
|
||||||
|
{
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
// Remove the background poller: it would start a DB poll loop that fails
|
||||||
|
// without the central SQL Server.
|
||||||
|
var poller = services.SingleOrDefault(d =>
|
||||||
|
d.ImplementationType?.Name == "FleetStatusPoller");
|
||||||
|
if (poller is not null) services.Remove(poller);
|
||||||
|
|
||||||
|
// Remove the LDAP auth service to avoid accidental LDAP calls.
|
||||||
|
var ldap = services.SingleOrDefault(d => d.ServiceType == typeof(ILdapAuthService));
|
||||||
|
if (ldap is not null) services.Remove(ldap);
|
||||||
|
services.AddScoped<ILdapAuthService, NullLdapAuthService>();
|
||||||
|
|
||||||
|
// Replace the cookie scheme with the role-injecting test scheme.
|
||||||
|
// The fallback policy and CanEdit/CanPublish role policies registered in
|
||||||
|
// Program.cs are preserved — only the authentication handler is swapped.
|
||||||
|
var cookieDescriptor = services.SingleOrDefault(d =>
|
||||||
|
d.ServiceType == typeof(IConfigureOptions<CookieAuthenticationOptions>));
|
||||||
|
// We replace the whole authentication registration so scheme resolution
|
||||||
|
// still works for authorization checks.
|
||||||
|
var authSchemeProvider = services.SingleOrDefault(d =>
|
||||||
|
d.ServiceType == typeof(Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider));
|
||||||
|
if (authSchemeProvider is not null) services.Remove(authSchemeProvider);
|
||||||
|
|
||||||
|
services.AddAuthentication(RoleInjectingHandler.SchemeName)
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, RoleInjectingHandler>(
|
||||||
|
RoleInjectingHandler.SchemeName, _ => { });
|
||||||
|
});
|
||||||
|
return base.CreateHost(builder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns an <see cref="HttpClient"/> that authenticates every request with
|
||||||
|
/// the given <paramref name="roles"/>.
|
||||||
|
/// </summary>
|
||||||
|
public HttpClient CreateClientWithRoles(params string[] roles)
|
||||||
|
{
|
||||||
|
RoleInjectingAppFactory.CurrentRoles = roles;
|
||||||
|
return CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authentication handler that stamps the current request with the roles stored in
|
||||||
|
/// <see cref="RoleInjectingAppFactory.CurrentRoles"/>. When <c>CurrentRoles</c> is
|
||||||
|
/// null/empty the request is unauthenticated (no ticket).
|
||||||
|
/// </summary>
|
||||||
|
private sealed class RoleInjectingHandler(
|
||||||
|
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder)
|
||||||
|
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||||
|
{
|
||||||
|
public const string SchemeName = "RoleInjecting";
|
||||||
|
|
||||||
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
var roles = RoleInjectingAppFactory.CurrentRoles;
|
||||||
|
if (roles is null || roles.Length == 0)
|
||||||
|
return Task.FromResult(AuthenticateResult.NoResult());
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(ClaimTypes.Name, "test-operator"),
|
||||||
|
new(ClaimTypes.NameIdentifier, "test-operator"),
|
||||||
|
};
|
||||||
|
foreach (var role in roles)
|
||||||
|
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(claims, SchemeName);
|
||||||
|
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName);
|
||||||
|
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Null LDAP auth service — never called in these tests.</summary>
|
||||||
|
private sealed class NullLdapAuthService : ILdapAuthService
|
||||||
|
{
|
||||||
|
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default) =>
|
||||||
|
Task.FromResult(new LdapAuthResult(false, null, username, [], [], "LDAP disabled in auth-pipeline tests"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user