513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
166 lines
9.5 KiB
C#
166 lines
9.5 KiB
C#
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
|
|
{
|
|
/// <summary>Verifies that a passive ping and a role replacement do not renew the cookie, and the session expires when idle.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that antiforgery-protected activity renews both the HTTP session and the existing circuit, and then both expire.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that missing antiforgery does not renew the session, and expired activity cannot revive it.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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<string, string> _cookies = [];
|
|
/// <summary>Gets the fake clock that drives session expiry.</summary>
|
|
public Clock Clock { get; }
|
|
/// <summary>Gets the authentication state provider of the circuit created at login, if any.</summary>
|
|
public CookieAuthenticationStateProvider? Circuit { get; private set; }
|
|
private Fixture(WebApplication app, Clock clock) { _app = app; Clock = clock; }
|
|
|
|
/// <summary>Creates and starts a test server fixture.</summary>
|
|
/// <returns>The started fixture.</returns>
|
|
public static async Task<Fixture> CreateAsync()
|
|
{
|
|
var builder = WebApplication.CreateBuilder();
|
|
builder.WebHost.UseTestServer();
|
|
var clock = new Clock();
|
|
builder.Services.AddSingleton<TimeProvider>(clock);
|
|
builder.Services.AddSecurity();
|
|
builder.Services.Configure<SecurityOptions>(o => { o.RequireHttpsCookie = false; o.JwtSigningKey = new string('x', 64); });
|
|
builder.Services.AddSingleton<IGroupRoleMapper<string>, Mapper>();
|
|
builder.Services.AddOptions<CookieAuthenticationOptions>("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<IServiceScopeFactory>());
|
|
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;
|
|
}
|
|
/// <summary>Signs in through the test login endpoint.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async Task LoginAsync() { using var response = await SendAsync(HttpMethod.Get, "/test/login"); response.EnsureSuccessStatusCode(); }
|
|
/// <summary>Fetches an antiforgery request token from the test token endpoint.</summary>
|
|
/// <returns>The antiforgery request token.</returns>
|
|
public async Task<string> TokenAsync() { using var response = await SendAsync(HttpMethod.Get, "/test/token"); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(); }
|
|
/// <summary>Sends a request carrying the stored cookies and an optional antiforgery token.</summary>
|
|
/// <param name="method">The HTTP method.</param>
|
|
/// <param name="path">The request path.</param>
|
|
/// <param name="token">The antiforgery token to send, or null for none.</param>
|
|
/// <returns>The HTTP response.</returns>
|
|
public async Task<HttpResponseMessage> 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;
|
|
}
|
|
/// <summary>Disposes the HTTP client and the web application.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async ValueTask DisposeAsync() { _client.Dispose(); await _app.DisposeAsync(); }
|
|
}
|
|
private sealed class Clock : TimeProvider
|
|
{
|
|
/// <summary>Gets or sets the current fake time.</summary>
|
|
public DateTimeOffset Now { get; set; } = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero);
|
|
/// <inheritdoc />
|
|
public override DateTimeOffset GetUtcNow() => Now;
|
|
/// <summary>Advances the fake clock.</summary>
|
|
/// <param name="minutes">The number of minutes to advance.</param>
|
|
public void Advance(int minutes) => Now = Now.AddMinutes(minutes);
|
|
}
|
|
private sealed class Mapper : IGroupRoleMapper<string>
|
|
{
|
|
/// <summary>Maps every group set to the Deployer role.</summary>
|
|
/// <param name="groups">The directory groups to map (ignored).</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>A task yielding a fixed Deployer role mapping.</returns>
|
|
public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct)
|
|
=> Task.FromResult(new GroupRoleMapping<string>([Roles.Deployer], new RoleMappingResult([Roles.Deployer], ["1"], false)));
|
|
}
|
|
}
|