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