fix(central-ui): resolve CentralUI-020..025 — auth-ping idle logout, DebugView race, push-handler disposal guard, JS-interop catch narrowing, claim-constant helper, SessionExpiry tests
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CentralUI-020. The Blazor circuit's
|
||||
/// <c>CookieAuthenticationStateProvider</c> serves a frozen constructor-time
|
||||
/// principal, so <c>SessionExpiry</c> could never observe a server-side cookie
|
||||
/// expiry by polling the auth state. The fix adds <c>GET /auth/ping</c>, 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 <c>SessionExpiry</c> a real signal to redirect on.
|
||||
/// </summary>
|
||||
public class AuthPingEndpointTests
|
||||
{
|
||||
private static IReadOnlyList<RouteEndpoint> BuildEndpoints()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Services.AddRouting();
|
||||
builder.Services.AddAntiforgery();
|
||||
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 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<Microsoft.AspNetCore.Authorization.IAuthorizeData>();
|
||||
Assert.Empty(authorize);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using ScadaLink.CentralUI.Auth;
|
||||
using ScadaLink.Security;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CentralUI-024. Ten components each copy-pasted a
|
||||
/// <c>GetCurrentUserAsync</c> helper using the magic string
|
||||
/// <c>FindFirst("Username")</c>, and <c>NavMenu</c>/<c>Dashboard</c> used
|
||||
/// <c>FindFirst("DisplayName")</c>. A rename of the claim type in
|
||||
/// <see cref="JwtTokenService"/> (the single source of truth) would have
|
||||
/// silently broken every call site. The shared
|
||||
/// <see cref="ClaimsPrincipalExtensions"/> helpers now resolve the claim type
|
||||
/// through the <c>JwtTokenService</c> constants.
|
||||
/// </summary>
|
||||
public class ClaimsPrincipalExtensionsTests
|
||||
{
|
||||
private static ClaimsPrincipal Principal(params Claim[] claims)
|
||||
=> new(new ClaimsIdentity(claims, authenticationType: "TestCookie"));
|
||||
|
||||
[Fact]
|
||||
public void GetUsername_ResolvesTheJwtTokenServiceUsernameClaim()
|
||||
{
|
||||
var principal = Principal(
|
||||
new Claim(JwtTokenService.UsernameClaimType, "alice"));
|
||||
|
||||
Assert.Equal("alice", principal.GetUsername());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetUsername_FallsBackToUnknown_WhenClaimAbsent()
|
||||
{
|
||||
var principal = Principal();
|
||||
|
||||
Assert.Equal(ClaimsPrincipalExtensions.UnknownUser, principal.GetUsername());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDisplayName_ResolvesTheJwtTokenServiceDisplayNameClaim()
|
||||
{
|
||||
var principal = Principal(
|
||||
new Claim(JwtTokenService.DisplayNameClaimType, "Alice Anderson"));
|
||||
|
||||
Assert.Equal("Alice Anderson", principal.GetDisplayName());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDisplayName_IsNull_WhenClaimAbsent()
|
||||
{
|
||||
Assert.Null(Principal().GetDisplayName());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentUsernameAsync_ReadsUsernameFromAuthState()
|
||||
{
|
||||
var principal = Principal(
|
||||
new Claim(JwtTokenService.UsernameClaimType, "bob"));
|
||||
var provider = new StubAuthStateProvider(
|
||||
new AuthenticationState(principal));
|
||||
|
||||
Assert.Equal("bob", await provider.GetCurrentUsernameAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Username_LookupTracksAJwtTokenServiceRename()
|
||||
{
|
||||
// The lookup must NOT use a hard-coded "Username" literal: if the
|
||||
// constant's *value* is ever changed, the helper must follow it. Build a
|
||||
// principal whose claim carries the JwtTokenService constant's current
|
||||
// value and confirm the helper finds it via that same constant.
|
||||
var principal = Principal(
|
||||
new Claim(JwtTokenService.UsernameClaimType, "carol"));
|
||||
|
||||
Assert.Equal("carol",
|
||||
principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value);
|
||||
Assert.Equal("carol", principal.GetUsername());
|
||||
}
|
||||
|
||||
private sealed class StubAuthStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
private readonly AuthenticationState _state;
|
||||
public StubAuthStateProvider(AuthenticationState state) => _state = state;
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
=> Task.FromResult(_state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Bunit;
|
||||
using Bunit.TestDoubles;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CentralUI-020 and CentralUI-025. <c>SessionExpiry</c>
|
||||
/// used to poll the Blazor <c>AuthenticationStateProvider</c>, which (via
|
||||
/// <c>CookieAuthenticationStateProvider</c>) serves a frozen constructor-time
|
||||
/// principal — so the polled state could never become "expired" and the
|
||||
/// idle-logout redirect never fired. The component now polls the server
|
||||
/// <c>GET /auth/ping</c> endpoint, which reflects the live cookie session: a
|
||||
/// 401 response triggers a redirect to <c>/login</c>. These tests exercise that
|
||||
/// redirect path directly (CentralUI-025: the path was previously untested).
|
||||
/// </summary>
|
||||
public class SessionExpiryComponentTests : BunitContext
|
||||
{
|
||||
private const string ModulePath = "./_content/ScadaLink.CentralUI/js/session-expiry.js";
|
||||
|
||||
[Fact]
|
||||
public async Task CheckSession_ExpiredSession_RedirectsToLogin()
|
||||
{
|
||||
// The server reports the cookie has lapsed: ping returns HTTP 401.
|
||||
var module = JSInterop.SetupModule(ModulePath);
|
||||
module.Setup<int>("ping", "/auth/ping").SetResult(401);
|
||||
|
||||
var nav = Services.GetRequiredService<NavigationManager>();
|
||||
var cut = Render<SessionExpiry>();
|
||||
|
||||
await cut.InvokeAsync(() => cut.Instance.CheckSessionAsync());
|
||||
|
||||
Assert.EndsWith("/login", nav.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckSession_LiveSession_DoesNotRedirect()
|
||||
{
|
||||
// The server reports the session is still valid: ping returns HTTP 200.
|
||||
var module = JSInterop.SetupModule(ModulePath);
|
||||
module.Setup<int>("ping", "/auth/ping").SetResult(200);
|
||||
|
||||
var nav = Services.GetRequiredService<NavigationManager>();
|
||||
var before = nav.Uri;
|
||||
var cut = Render<SessionExpiry>();
|
||||
|
||||
await cut.InvokeAsync(() => cut.Instance.CheckSessionAsync());
|
||||
|
||||
Assert.Equal(before, nav.Uri);
|
||||
Assert.DoesNotContain("/login", nav.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckSession_TransientNetworkFailure_DoesNotRedirect()
|
||||
{
|
||||
// A network blip surfaces as status 0 — inconclusive. The component must
|
||||
// NOT log an authenticated user out on a transient failure.
|
||||
var module = JSInterop.SetupModule(ModulePath);
|
||||
module.Setup<int>("ping", "/auth/ping").SetResult(0);
|
||||
|
||||
var nav = Services.GetRequiredService<NavigationManager>();
|
||||
var before = nav.Uri;
|
||||
var cut = Render<SessionExpiry>();
|
||||
|
||||
await cut.InvokeAsync(() => cut.Instance.CheckSessionAsync());
|
||||
|
||||
Assert.Equal(before, nav.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckSession_OnLoginPage_DoesNotPingOrRedirect()
|
||||
{
|
||||
// On /login the component must neither poll nor redirect (a /login →
|
||||
// /login redirect would loop). JSInterop is left in Strict mode with no
|
||||
// module setup, so any ping call would throw and fail the test.
|
||||
var nav = (BunitNavigationManager)Services
|
||||
.GetRequiredService<NavigationManager>();
|
||||
nav.NavigateTo("login");
|
||||
|
||||
var cut = Render<SessionExpiry>();
|
||||
await cut.InvokeAsync(() => cut.Instance.CheckSessionAsync());
|
||||
|
||||
// No JS module import was attempted and the URL is unchanged.
|
||||
Assert.EndsWith("/login", nav.Uri);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user