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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user