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; /// /// 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 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 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 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 ─────────────────────────────────────────── /// /// 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 /// . /// /// Role injection works through a singleton that holds a /// simple lock-protected field. This avoids the AsyncLocal-does-not-flow- /// into-TestServer pitfall and the stale-[ThreadStatic] pitfall. /// public sealed class RoleInjectingAppFactory : WebApplicationFactory { /// /// Singleton shared by the factory and the . /// Holds the roles that the next request should authenticate as. /// 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(); // 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( RoleInjectingHandler.SchemeName, _ => { }); services.PostConfigure(opt => { opt.DefaultAuthenticateScheme = RoleInjectingHandler.SchemeName; opt.DefaultChallengeScheme = RoleInjectingHandler.SchemeName; opt.DefaultForbidScheme = RoleInjectingHandler.SchemeName; }); }); return base.CreateHost(builder); } /// /// Returns an that authenticates every request with /// the given . /// public HttpClient CreateClientWithRoles(params string[] roles) { _roleContext!.SetRoles(roles); return CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); } } /// /// Authentication handler that stamps the current request with the roles stored in /// the singleton. /// private sealed class RoleInjectingHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, RoleInjectingAppFactory.RoleContext roleContext) : AuthenticationHandler(options, logger, encoder) { public const string SchemeName = "RoleInjecting"; protected override Task HandleAuthenticateAsync() { var roles = roleContext.GetRoles(); if (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")); } }