using System.Security.Claims; 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; /// /// Regression tests for CentralUI-020. The Blazor circuit's /// CookieAuthenticationStateProvider serves a frozen constructor-time /// principal, so SessionExpiry could never observe a server-side cookie /// expiry by polling the auth state. The fix adds GET /auth/ping, an /// endpoint evaluated per HTTP request (where the cookie middleware re-validates /// the cookie): it returns 200 while the session is live and 401 once the /// cookie has lapsed, giving SessionExpiry a real signal to redirect on. /// public class AuthPingEndpointTests { private static IReadOnlyList 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() .ToList(); } private static RouteEndpoint? Find(IReadOnlyList endpoints, string pattern, string method) => endpoints.FirstOrDefault(e => e.RoutePattern.RawText == pattern && (e.Metadata.GetMetadata()?.HttpMethods.Contains(method) ?? false)); [Fact] public void AuthPing_GetRoute_IsMapped() { var ping = Find(BuildEndpoints(), "/auth/ping", "GET"); Assert.NotNull(ping); } [Fact] public async Task AuthPing_AnonymousUser_Returns401() { var context = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity()) // not authenticated }; await AuthEndpoints.HandlePing(context); Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); } [Fact] public async Task AuthPing_AuthenticatedUser_Returns200() { var identity = new ClaimsIdentity( new[] { new Claim(ClaimTypes.Name, "alice") }, authenticationType: "TestCookie"); var context = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }; await AuthEndpoints.HandlePing(context); Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); } [Fact] public void AuthPing_DoesNotTriggerCookieRedirect() { // The endpoint must NOT use RequireAuthorization(): that would make the // cookie middleware answer an expired request with a 302 to /login, // which a fetch() follows transparently and reads as a 200 login page — // SessionExpiry would never see the expiry. The endpoint allows // anonymous access and decides 200/401 itself. var ping = Find(BuildEndpoints(), "/auth/ping", "GET"); Assert.NotNull(ping); var authorize = ping!.Metadata .GetOrderedMetadata(); Assert.Empty(authorize); } }