fix(admin): enforce authentication on all Admin UI routes (Admin-001/002)

Admin-001: Routes.razor used a plain RouteView, so the page-level
[Authorize] attributes on 11 pages were inert — every page, including
mutating ones, was reachable fully unauthenticated.
Admin-002: several pages (e.g. NewCluster, which writes config rows)
carried no auth attribute at all.

- Routes.razor: RouteView → AuthorizeRouteView with NotAuthorized /
  Authorizing slots; add RedirectToLogin component.
- Program.cs: SetFallbackPolicy(RequireAuthenticatedUser) — secure by
  default for new pages/endpoints.
- Login.razor: [AllowAnonymous] so login stays reachable; login page,
  /auth/* endpoints and static assets remain anonymous.
- Add [Authorize] to the previously un-gated pages; NewCluster gated to
  the CanPublish (FleetAdmin) policy.

Regression tests in PageAuthorizationTests pin that anonymous requests
to protected/mutating routes are rejected and that login + static
assets stay anonymously reachable. Admin test suite: 210/210 pass.

Resolves code-review findings Admin-001 and Admin-002 (Critical).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 05:53:58 -04:00
parent 571066130b
commit 973730d0eb
16 changed files with 208 additions and 7 deletions

View File

@@ -0,0 +1,140 @@
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;
/// <summary>
/// Regression coverage for Admin-001 / Admin-002 — the router must enforce page-level
/// <c>[Authorize]</c> attributes and the fallback authorization policy must keep every
/// routable page (and mutating route) secure-by-default, while the login page, the
/// <c>/auth/*</c> endpoints and static assets stay anonymously reachable.
///
/// These are HTTP-pipeline tests: they do not touch the config DB (the
/// <see cref="OtOpcUaConfigDbContext"/> registration is lazy), so they run without the
/// central SQL Server. The <see cref="FleetStatusPoller"/> hosted service is stripped out
/// so the test host does not spin up a background DB poll loop.
/// </summary>
public sealed class PageAuthorizationTests : IClassFixture<PageAuthorizationTests.AdminAppFactory>
{
private readonly AdminAppFactory _factory;
public PageAuthorizationTests(AdminAppFactory factory) => _factory = factory;
/// <summary>
/// A <see cref="WebApplicationFactory{TEntryPoint}"/> over the Admin app's
/// <c>Program</c>. 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.
/// </summary>
public sealed class AdminAppFactory : WebApplicationFactory<Program>
{
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<string> 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<KeyValuePair<string, string>>()));
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);
}
}

View File

@@ -13,6 +13,7 @@
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>