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)));
}
}