using System.Net; using System.Security.Claims; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; public class SessionActivityHttpIntegrationTests { /// Verifies that a passive ping and a role replacement do not renew the cookie, and the session expires when idle. /// A task that represents the asynchronous operation. [Fact] public async Task PassivePing_RoleReplacementDoesNotRenewCookie_AndIdleExpires() { await using var fixture = await Fixture.CreateAsync(); await fixture.LoginAsync(); fixture.Clock.Advance(16); using var alive = await fixture.SendAsync(HttpMethod.Get, "/auth/ping"); Assert.Equal(HttpStatusCode.OK, alive.StatusCode); Assert.False(alive.Headers.Contains("Set-Cookie")); fixture.Clock.Advance(15); using var expired = await fixture.SendAsync(HttpMethod.Get, "/auth/ping"); Assert.Equal(HttpStatusCode.Unauthorized, expired.StatusCode); Assert.False((await fixture.Circuit!.GetAuthoritativeStateAsync()).User.Identity!.IsAuthenticated); } /// Verifies that antiforgery-protected activity renews both the HTTP session and the existing circuit, and then both expire. /// A task that represents the asynchronous operation. [Fact] public async Task AntiforgeryActivity_RenewsHttpAndExistingCircuit_ThenBothExpire() { await using var fixture = await Fixture.CreateAsync(); await fixture.LoginAsync(); var token = await fixture.TokenAsync(); fixture.Clock.Advance(20); using var activity = await fixture.SendAsync(HttpMethod.Post, "/auth/activity", token); Assert.Equal(HttpStatusCode.NoContent, activity.StatusCode); Assert.True(activity.Headers.Contains("Set-Cookie")); fixture.Clock.Advance(15); using var alive = await fixture.SendAsync(HttpMethod.Get, "/auth/ping"); Assert.Equal(HttpStatusCode.OK, alive.StatusCode); Assert.True((await fixture.Circuit!.GetAuthoritativeStateAsync()).User.Identity!.IsAuthenticated); using var again = await fixture.SendAsync(HttpMethod.Post, "/auth/activity", token); Assert.Equal(HttpStatusCode.NoContent, again.StatusCode); fixture.Clock.Advance(31); using var expired = await fixture.SendAsync(HttpMethod.Get, "/auth/ping"); Assert.Equal(HttpStatusCode.Unauthorized, expired.StatusCode); Assert.False((await fixture.Circuit.GetAuthoritativeStateAsync()).User.Identity!.IsAuthenticated); } /// Verifies that missing antiforgery does not renew the session, and expired activity cannot revive it. /// A task that represents the asynchronous operation. [Fact] public async Task MissingAntiforgeryDoesNotRenew_AndExpiredActivityCannotRevive() { await using var fixture = await Fixture.CreateAsync(); await fixture.LoginAsync(); var token = await fixture.TokenAsync(); fixture.Clock.Advance(20); using var rejected = await fixture.SendAsync(HttpMethod.Post, "/auth/activity"); Assert.Equal(HttpStatusCode.BadRequest, rejected.StatusCode); Assert.False(rejected.Headers.Contains("Set-Cookie")); fixture.Clock.Advance(11); using var expired = await fixture.SendAsync(HttpMethod.Post, "/auth/activity", token); Assert.Equal(HttpStatusCode.Unauthorized, expired.StatusCode); Assert.False((await fixture.Circuit!.GetAuthoritativeStateAsync()).User.Identity!.IsAuthenticated); } private sealed class Fixture : IAsyncDisposable { private readonly WebApplication _app; private HttpClient _client = null!; private readonly Dictionary _cookies = []; /// Gets the fake clock that drives session expiry. public Clock Clock { get; } /// Gets the authentication state provider of the circuit created at login, if any. public CookieAuthenticationStateProvider? Circuit { get; private set; } private Fixture(WebApplication app, Clock clock) { _app = app; Clock = clock; } /// Creates and starts a test server fixture. /// The started fixture. public static async Task CreateAsync() { var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); var clock = new Clock(); builder.Services.AddSingleton(clock); builder.Services.AddSecurity(); builder.Services.Configure(o => { o.RequireHttpsCookie = false; o.JwtSigningKey = new string('x', 64); }); builder.Services.AddSingleton, Mapper>(); builder.Services.AddOptions("Cookies").PostConfigure(o => o.TimeProvider = clock); builder.Services.AddAntiforgery(o => o.HeaderName = "X-CSRF-TOKEN"); var app = builder.Build(); var fixture = new Fixture(app, clock); app.UseAuthentication(); app.UseAuthorization(); app.MapGet("/test/login", async (HttpContext context) => { var principal = SessionClaimBuilder.Build("alice", "Alice", ["group"], new RoleMappingResult([Roles.Deployer], ["1"], false), clock.GetUtcNow()); fixture.Circuit = new CookieAuthenticationStateProvider(new HttpContextAccessor { HttpContext = new DefaultHttpContext { User = principal } }, app.Services.GetRequiredService()); await context.SignInAsync("Cookies", principal); }); app.MapGet("/test/token", (HttpContext context, IAntiforgery antiforgery) => antiforgery.GetAndStoreTokens(context).RequestToken!); app.MapAuthEndpoints(); await app.StartAsync(); fixture._client = app.GetTestClient(); return fixture; } /// Signs in through the test login endpoint. /// A task that represents the asynchronous operation. public async Task LoginAsync() { using var response = await SendAsync(HttpMethod.Get, "/test/login"); response.EnsureSuccessStatusCode(); } /// Fetches an antiforgery request token from the test token endpoint. /// The antiforgery request token. public async Task TokenAsync() { using var response = await SendAsync(HttpMethod.Get, "/test/token"); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(); } /// Sends a request carrying the stored cookies and an optional antiforgery token. /// The HTTP method. /// The request path. /// The antiforgery token to send, or null for none. /// The HTTP response. public async Task SendAsync(HttpMethod method, string path, string? token = null) { using var request = new HttpRequestMessage(method, path); if (_cookies.Count > 0) request.Headers.Add("Cookie", string.Join("; ", _cookies.Select(c => $"{c.Key}={c.Value}"))); if (token is not null) request.Headers.Add("X-CSRF-TOKEN", token); var response = await _client.SendAsync(request); if (response.Headers.TryGetValues("Set-Cookie", out var values)) foreach (var value in values) { var pair = value.Split(';')[0].Split('=', 2); _cookies[pair[0]] = pair[1]; } return response; } /// Disposes the HTTP client and the web application. /// A task that represents the asynchronous operation. public async ValueTask DisposeAsync() { _client.Dispose(); await _app.DisposeAsync(); } } private sealed class Clock : TimeProvider { /// Gets or sets the current fake time. public DateTimeOffset Now { get; set; } = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); /// public override DateTimeOffset GetUtcNow() => Now; /// Advances the fake clock. /// The number of minutes to advance. public void Advance(int minutes) => Now = Now.AddMinutes(minutes); } private sealed class Mapper : IGroupRoleMapper { /// Maps every group set to the Deployer role. /// The directory groups to map (ignored). /// The cancellation token. /// A task yielding a fixed Deployer role mapping. public Task> MapAsync(IReadOnlyList groups, CancellationToken ct) => Task.FromResult(new GroupRoleMapping([Roles.Deployer], new RoleMappingResult([Roles.Deployer], ["1"], false))); } }