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;
///
/// 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
/// ; supplemented here with the mutating
/// POST surface).
/// (b) anonymous hub negotiate is rejected (already in
/// ; complemented here).
/// (c) a signed-in FleetAdmin can reach pages gated by the fallback policy and
/// CanPublish pages.
/// (d) a ConfigViewer (no FleetAdmin role) is denied CanPublish-gated
/// pages while still being allowed through the fallback authenticated-user gate.
///
/// The test host uses a custom authentication scheme
/// so tests can assign any role set without going through LDAP. The
/// background service is stripped out so the host starts clean without DB access.
///
public sealed class AdminAuthPipelineTests : IClassFixture
{
private readonly RoleInjectingAppFactory _factory;
public AdminAuthPipelineTests(RoleInjectingAppFactory factory) => _factory = factory;
// ── (c) FleetAdmin can reach protected pages ─────────────────────────────────
public static readonly TheoryData 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 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 ───────────────────────────────────────────
///
/// A that replaces the cookie
/// authentication scheme with a custom handler that stamps requests with a
/// caller-supplied role set. Tests obtain a per-role via
/// .
///
public sealed class RoleInjectingAppFactory : WebApplicationFactory
{
// 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();
// 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));
// 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(
RoleInjectingHandler.SchemeName, _ => { });
});
return base.CreateHost(builder);
}
///
/// Returns an that authenticates every request with
/// the given .
///
public HttpClient CreateClientWithRoles(params string[] roles)
{
RoleInjectingAppFactory.CurrentRoles = roles;
return CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
}
}
///
/// Authentication handler that stamps the current request with the roles stored in
/// . When CurrentRoles is
/// null/empty the request is unauthenticated (no ticket).
///
private sealed class RoleInjectingHandler(
IOptionsMonitor options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler(options, logger, encoder)
{
public const string SchemeName = "RoleInjecting";
protected override Task HandleAuthenticateAsync()
{
var roles = RoleInjectingAppFactory.CurrentRoles;
if (roles is null || roles.Length == 0)
return Task.FromResult(AuthenticateResult.NoResult());
var claims = new List
{
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));
}
}
/// Null LDAP auth service — never called in these tests.
private sealed class NullLdapAuthService : ILdapAuthService
{
public Task AuthenticateAsync(string username, string password, CancellationToken ct = default) =>
Task.FromResult(new LdapAuthResult(false, null, username, [], [], "LDAP disabled in auth-pipeline tests"));
}
}