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")); } }