Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/AdminAuthPipelineTests.cs
Joseph Doherty a0aa4a4819 fix(admin): complete Admin-006 — inject IAntiforgery into LogoutAsync for explicit token validation
The previous Admin-006 commit added <AntiforgeryToken /> to the logout form
and updated the comment on the endpoint, but did not update LogoutAsync to
actually call IAntiforgery.ValidateRequestAsync. Blazor's UseAntiforgery()
middleware does not automatically validate minimal-API endpoints, so a
tokenless POST still succeeded. This commit injects IAntiforgery into the
handler, wraps ValidateRequestAsync in a try/catch, and returns 400 on
AntiforgeryValidationException. The endpoint keeps .DisableAntiforgery() to
prevent the middleware from also trying to read the body (which would cause
a double-read). The regression test is updated to log in first (to get an
authenticated session) before asserting 400 on a tokenless logout POST.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 07:51:11 -04:00

232 lines
11 KiB
C#

using System.Net;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
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 is NOT rejected by the auth gate ──────────────────────────
//
// These tests verify that a FleetAdmin principal is not refused at the
// authorization boundary. They do NOT assert that the page renders successfully
// (the test host has no DB, so pages that hit the DB will 500 — that is an
// application error, not an auth error). The assertions are:
// • Not 401 Unauthorized (auth failed — user not authenticated)
// • Not 403 Forbidden (auth failed — user lacks required role)
// • If 302/Found, the Location must NOT point to /login (bounced due to auth)
// A 500 or 200 both mean the auth gate was cleared.
public static readonly TheoryData<string> CanPublishPagesForPermitTest = new()
{
"/clusters/new",
"/reservations",
"/certificates",
"/role-grants",
};
[Theory]
[MemberData(nameof(CanPublishPagesForPermitTest))]
public async Task FleetAdmin_is_permitted_CanPublish_gated_page(string route)
{
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, 500 (DB error — not auth error), or a redirect within session.
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");
}
}
// ── (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);
}
// ── 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"/>.
///
/// Role injection works through a singleton <see cref="RoleContext"/> that holds a
/// simple <c>lock</c>-protected field. This avoids the <c>AsyncLocal</c>-does-not-flow-
/// into-TestServer pitfall and the stale-<c>[ThreadStatic]</c> pitfall.
/// </summary>
public sealed class RoleInjectingAppFactory : WebApplicationFactory<Program>
{
/// <summary>
/// Singleton shared by the factory and the <see cref="RoleInjectingHandler"/>.
/// Holds the roles that the next request should authenticate as.
/// </summary>
internal sealed class RoleContext
{
private readonly Lock _lock = new();
private string[] _roles = [];
public void SetRoles(string[] roles) { lock (_lock) { _roles = roles; } }
public void Clear() { lock (_lock) { _roles = []; } }
public string[] GetRoles() { lock (_lock) { return _roles; } }
}
// Initialized here so it is available before CreateHost is invoked (the factory
// builds the host lazily on first client creation; _roleContext must not be null
// at CreateClientWithRoles() time, and the singleton registered in CreateHost
// must be the same instance as this field).
private readonly RoleContext _roleContext = new();
protected override IHost CreateHost(IHostBuilder builder)
{
var ctx = _roleContext;
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>();
// Register the shared RoleContext as a singleton so the handler can read it.
services.AddSingleton(ctx);
// Register the role-injecting test scheme and override the default schemes.
services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, RoleInjectingHandler>(
RoleInjectingHandler.SchemeName, _ => { });
services.PostConfigure<AuthenticationOptions>(opt =>
{
opt.DefaultAuthenticateScheme = RoleInjectingHandler.SchemeName;
opt.DefaultChallengeScheme = RoleInjectingHandler.SchemeName;
opt.DefaultForbidScheme = 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)
{
_roleContext!.SetRoles(roles);
return CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
}
}
/// <summary>
/// Authentication handler that stamps the current request with the roles stored in
/// the <see cref="RoleInjectingAppFactory.RoleContext"/> singleton.
/// </summary>
private sealed class RoleInjectingHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
RoleInjectingAppFactory.RoleContext roleContext)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
public const string SchemeName = "RoleInjecting";
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var roles = roleContext.GetRoles();
if (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"));
}
}