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 { [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); } [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); } [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 = []; public Clock Clock { get; } public CookieAuthenticationStateProvider? Circuit { get; private set; } private Fixture(WebApplication app, Clock clock) { _app = app; Clock = clock; } 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; } public async Task LoginAsync() { using var response = await SendAsync(HttpMethod.Get, "/test/login"); response.EnsureSuccessStatusCode(); } public async Task TokenAsync() { using var response = await SendAsync(HttpMethod.Get, "/test/token"); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(); } 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; } public async ValueTask DisposeAsync() { _client.Dispose(); await _app.DisposeAsync(); } } private sealed class Clock : TimeProvider { public DateTimeOffset Now { get; set; } = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); public override DateTimeOffset GetUtcNow() => Now; public void Advance(int minutes) => Now = Now.AddMinutes(minutes); } private sealed class Mapper : IGroupRoleMapper { public Task> MapAsync(IReadOnlyList groups, CancellationToken ct) => Task.FromResult(new GroupRoleMapping([Roles.Deployer], new RoleMappingResult([Roles.Deployer], ["1"], false))); } }