using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.Roles; namespace ZB.MOM.WW.ScadaBridge.Security.Tests; public class PassiveSessionRegressionTests { private static readonly DateTimeOffset Start = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); /// Verifies that a passive ping refreshes roles without advancing the activity timestamp and that the session then expires. /// A task that represents the asynchronous operation. [Fact] public async Task PassivePing_RefreshesRolesWithoutAdvancingActivity_ThenExpires() { var clock = new Clock { Now = Start.AddMinutes(16) }; IGroupRoleMapper mapper = new Mapper(); var services = new ServiceCollection().AddSingleton(clock).AddLogging(); services.AddSingleton(mapper); services.AddSingleton(new Auth()); services.AddSingleton(Options.Create(new SecurityOptions())); services.AddScoped(); using var provider = services.BuildServiceProvider(); var principal = SessionClaimBuilder.Build("alice", "Alice", ["group"], new RoleMappingResult([Roles.Deployer], ["1"], false), Start); var ctx = Context(provider, principal, "/auth/ping"); await ServiceCollectionExtensions.OnValidatePrincipalAsync(ctx); Assert.Equal(Start.ToString("o"), ctx.Principal!.FindFirst(JwtTokenService.LastActivityClaimType)!.Value); Assert.Contains(Roles.Viewer, ctx.Principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value)); clock.Now = Start.AddMinutes(31); var expired = Context(provider, ctx.Principal, "/auth/ping"); await ServiceCollectionExtensions.OnValidatePrincipalAsync(expired); Assert.Null(expired.Principal); } private static CookieValidatePrincipalContext Context(IServiceProvider sp, ClaimsPrincipal principal, string path) { var http = new DefaultHttpContext { RequestServices = sp }; http.Request.Path = path; return new CookieValidatePrincipalContext(http, new AuthenticationScheme("Cookies", null, typeof(CookieAuthenticationHandler)), new CookieAuthenticationOptions(), new AuthenticationTicket(principal, new AuthenticationProperties(), "Cookies")); } private sealed class Mapper : IGroupRoleMapper { /// Maps every group to the Viewer role. /// The directory groups. /// The cancellation token. /// The canned role mapping. public Task> MapAsync(IReadOnlyList groups, CancellationToken ct) => Task.FromResult(new GroupRoleMapping([Roles.Viewer], new RoleMappingResult([Roles.Viewer], ["2"], false))); } private sealed class Auth : IAuthenticationService { /// Returns no authentication result. /// The HTTP context. /// The authentication scheme. /// An empty authenticate result. public Task AuthenticateAsync(HttpContext c, string? s) => Task.FromResult(AuthenticateResult.NoResult()); /// Does nothing. /// The HTTP context. /// The authentication scheme. /// The authentication properties. /// A task that represents the asynchronous operation. public Task ChallengeAsync(HttpContext c, string? s, AuthenticationProperties? p) => Task.CompletedTask; /// Does nothing. /// The HTTP context. /// The authentication scheme. /// The authentication properties. /// A task that represents the asynchronous operation. public Task ForbidAsync(HttpContext c, string? s, AuthenticationProperties? p) => Task.CompletedTask; /// Does nothing. /// The HTTP context. /// The authentication scheme. /// The principal to sign in. /// The authentication properties. /// A task that represents the asynchronous operation. public Task SignInAsync(HttpContext c, string? s, ClaimsPrincipal u, AuthenticationProperties? p) => Task.CompletedTask; /// Does nothing. /// The HTTP context. /// The authentication scheme. /// The authentication properties. /// A task that represents the asynchronous operation. public Task SignOutAsync(HttpContext c, string? s, AuthenticationProperties? p) => Task.CompletedTask; } private sealed class Clock : TimeProvider { /// Gets or sets the current time the clock reports. public DateTimeOffset Now { get; set; } /// public override DateTimeOffset GetUtcNow() => Now; } }