using System.Net; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Admin.Tests; /// /// Regression coverage for Admin-001 / Admin-002 — the router must enforce page-level /// [Authorize] attributes and the fallback authorization policy must keep every /// routable page (and mutating route) secure-by-default, while the login page, the /// /auth/* endpoints and static assets stay anonymously reachable. /// /// These are HTTP-pipeline tests: they do not touch the config DB (the /// registration is lazy), so they run without the /// central SQL Server. The hosted service is stripped out /// so the test host does not spin up a background DB poll loop. /// public sealed class PageAuthorizationTests : IClassFixture { private readonly AdminAppFactory _factory; public PageAuthorizationTests(AdminAppFactory factory) => _factory = factory; /// /// A over the Admin app's /// Program. Removes the background poller so the host starts clean without DB /// access and never follows redirects so the auth gate is observable as a 302. /// public sealed class AdminAppFactory : WebApplicationFactory { protected override IHost CreateHost(IHostBuilder builder) { builder.ConfigureServices(services => { var poller = services.SingleOrDefault(d => d.ImplementationType?.Name == "FleetStatusPoller"); if (poller is not null) services.Remove(poller); }); return base.CreateHost(builder); } public HttpClient CreateNonRedirectingClient() => CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); } public static readonly TheoryData ProtectedRoutes = new() { "/", // Home — fleet overview "/fleet", // Fleet topology "/hosts", // Host status "/clusters", // Cluster list "/alarms/historian", // Historian diagnostics "/clusters/new", // NewCluster — MUTATING write surface (Admin-002) "/reservations", // CanPublish-gated page "/certificates", // FleetAdmin-gated page "/role-grants", // CanPublish-gated page "/account", // Authenticated-user page }; [Theory] [MemberData(nameof(ProtectedRoutes))] public async Task Anonymous_request_to_a_protected_page_is_rejected(string route) { using var client = _factory.CreateNonRedirectingClient(); var response = await client.GetAsync(route); // The cookie auth handler challenges an unauthenticated request with a 302 to // the configured LoginPath; a 401/403 is also an acceptable "not allowed in". if (response.StatusCode == HttpStatusCode.Redirect || response.StatusCode == HttpStatusCode.Found) { response.Headers.Location!.OriginalString.ShouldContain("/login", Case.Insensitive, $"anonymous GET {route} must bounce to the login page"); } else { response.StatusCode.ShouldBeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden); } response.StatusCode.ShouldNotBe(HttpStatusCode.OK, $"anonymous GET {route} must not be served — page-level [Authorize] / fallback policy must gate it"); } [Fact] public async Task Anonymous_post_to_a_mutating_route_does_not_reach_the_handler() { using var client = _factory.CreateNonRedirectingClient(); // /clusters/new is the cluster-creation page; an anonymous POST to it must be // gated before any CreateAsync write path runs. var response = await client.PostAsync("/clusters/new", new FormUrlEncodedContent(Array.Empty>())); response.StatusCode.ShouldNotBe(HttpStatusCode.OK, "anonymous POST to /clusters/new must not be served"); } [Fact] public async Task Login_page_is_anonymously_reachable() { using var client = _factory.CreateNonRedirectingClient(); var response = await client.GetAsync("/login"); response.StatusCode.ShouldBe(HttpStatusCode.OK, "the login page must stay anonymous or operators can never sign in"); var body = await response.Content.ReadAsStringAsync(); body.ShouldContain("sign in", Case.Insensitive); } [Fact] public async Task Static_assets_remain_anonymously_reachable() { using var client = _factory.CreateNonRedirectingClient(); // Vendored CSS served by the static-files middleware (not an endpoint) must not // be caught by the fallback authorization policy. foreach (var asset in new[] { "/app.css", "/theme.css" }) { var response = await client.GetAsync(asset); response.StatusCode.ShouldBeOneOf(HttpStatusCode.OK, HttpStatusCode.NotModified); } } [Fact] public async Task Blazor_framework_script_remains_anonymously_reachable() { using var client = _factory.CreateNonRedirectingClient(); // The Blazor runtime must load before any auth interaction can happen. var response = await client.GetAsync("/_framework/blazor.web.js"); response.StatusCode.ShouldBeOneOf(HttpStatusCode.OK, HttpStatusCode.NotModified); } }