Files
scadalink-design/tests/ScadaLink.CentralUI.Tests/Auth/AuthEndpointsCsrfTests.cs
Joseph Doherty cfa8667c78 test(central-ui): fix test-host hang in CentralUI.Tests
DiffDialogTests.SetupBodyLockInterop registered bUnit SetupVoid planned
invocations that were never completed; DisposeAsync_WhileOpen awaited
DiffDialog.DisposeAsync -> TryUnlockBodyAsync -> InvokeVoidAsync on one of
them, suspending the test forever so the test host never exited (regression
from the CentralUI-023 catch-narrowing). SetupBodyLockInterop now uses Loose
JSInterop mode. Also dispose the leaked WebApplication instances in the Auth
tests (FileSystemWatcher + ConsoleLoggerProcessor threads) and the extra
ServiceProvider in the DebugView tests. Suite now runs 281 tests in ~7s and
exits cleanly.
2026-05-17 05:43:05 -04:00

81 lines
3.2 KiB
C#

using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using ScadaLink.CentralUI.Auth;
namespace ScadaLink.CentralUI.Tests.Auth;
/// <summary>
/// Regression tests for CentralUI-017. <c>POST /auth/logout</c> called
/// <c>.DisableAntiforgery()</c> and a plain <c>GET /logout</c> route also
/// signed the user out — either could be triggered cross-site to forcibly log
/// a user out. Logout is a state-changing authenticated action and must be
/// CSRF-protected: the POST keeps antiforgery enabled and the state-changing
/// GET route is removed.
/// </summary>
public class AuthEndpointsCsrfTests
{
private static IReadOnlyList<RouteEndpoint> BuildEndpoints()
{
var builder = WebApplication.CreateBuilder();
builder.Services.AddRouting();
builder.Services.AddAntiforgery();
// Dispose the host: an undisposed WebApplication leaks its config
// PhysicalFileProvider (appsettings reload-watch FileSystemWatcher — a
// process-wide macOS run-loop thread) and a ConsoleLoggerProcessor
// thread, which keep the test host process alive after the run.
using var app = builder.Build();
app.MapAuthEndpoints();
return ((IEndpointRouteBuilder)app).DataSources
.SelectMany(ds => ds.Endpoints)
.OfType<RouteEndpoint>()
.ToList();
}
private static RouteEndpoint? Find(IReadOnlyList<RouteEndpoint> endpoints, string pattern, string method)
=> endpoints.FirstOrDefault(e =>
e.RoutePattern.RawText == pattern &&
(e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains(method) ?? false));
[Fact]
public void PostAuthLogout_DoesNotDisableAntiforgery()
{
var endpoints = BuildEndpoints();
var logout = Find(endpoints, "/auth/logout", "POST");
Assert.NotNull(logout);
// DisableAntiforgery() leaves an IAntiforgeryMetadata with
// RequiresValidation == false. A CSRF-protected POST has either no such
// metadata, or metadata that still requires validation.
var antiforgery = logout!.Metadata.GetMetadata<IAntiforgeryMetadata>();
Assert.True(antiforgery is null || antiforgery.RequiresValidation,
"POST /auth/logout must keep antiforgery validation enabled.");
}
[Fact]
public void GetLogout_StateChangingRoute_IsRemoved()
{
var endpoints = BuildEndpoints();
var getLogout = Find(endpoints, "/logout", "GET");
Assert.Null(getLogout);
}
[Fact]
public void PostAuthLogin_StillDisablesAntiforgery_PreAuthIsAcceptable()
{
// Login is a pre-auth endpoint; disabling antiforgery there is acceptable
// and intentional. This pins that the fix did not over-correct.
var endpoints = BuildEndpoints();
var login = Find(endpoints, "/auth/login", "POST");
Assert.NotNull(login);
var antiforgery = login!.Metadata.GetMetadata<IAntiforgeryMetadata>();
Assert.NotNull(antiforgery);
Assert.False(antiforgery!.RequiresValidation);
}
}