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; using ZB.MOM.WW.Auth.AspNetCore; using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.Security.Tests; #region M2.19 (#15): cookie session idle-timeout + LDAP-free role-mapping refresh /// /// Tests for the cookie OnValidatePrincipal core () /// and the shared . These pin the always-achievable M2.19 /// layers: idle-timeout enforcement, mid-session role-mapping refresh from STORED groups /// WITHOUT any LDAP call, the fail-soft refresh policy (errors keep the session), and claim /// parity between the login path and the refresh path. /// public class CookieSessionValidatorTests { private static readonly DateTimeOffset Start = new(2026, 6, 15, 12, 0, 0, TimeSpan.Zero); // A controllable clock (the central package list has no FakeTimeProvider, mirroring // the Transport tests' hand-rolled TestTimeProvider). private sealed class TestTimeProvider : TimeProvider { private DateTimeOffset _now; public TestTimeProvider(DateTimeOffset start) => _now = start; public override DateTimeOffset GetUtcNow() => _now; public void Advance(TimeSpan by) => _now = _now.Add(by); } // A fake IGroupRoleMapper that returns a programmable RoleMappingResult and // counts how many times it was invoked — so a test can assert NO LDAP call happens // (this mapper is DB-backed only; if it is hit, the result came from the DB seam, not // LDAP) and that the refresh actually re-ran the mapping. private sealed class FakeGroupRoleMapper : IGroupRoleMapper { private RoleMappingResult _result; private readonly Exception? _throw; public int CallCount { get; private set; } public IReadOnlyList? LastGroups { get; private set; } public FakeGroupRoleMapper(RoleMappingResult result) => _result = result; public FakeGroupRoleMapper(Exception toThrow) => (_result, _throw) = (new RoleMappingResult([], [], false), toThrow); public void SetResult(RoleMappingResult result) => _result = result; public Task> MapAsync(IReadOnlyList groups, CancellationToken ct) { CallCount++; LastGroups = groups; if (_throw is not null) throw _throw; return Task.FromResult(new GroupRoleMapping(_result.Roles, Scope: _result)); } } private static SecurityOptions Options() => new() { JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough", JwtExpiryMinutes = 15, IdleTimeoutMinutes = 30, JwtRefreshThresholdMinutes = 5, RoleRefreshThresholdMinutes = 15, }; private static CookieSessionValidator CreateValidator( IGroupRoleMapper mapper, TimeProvider clock, SecurityOptions? options = null) => new(mapper, Microsoft.Extensions.Options.Options.Create(options ?? Options()), clock, NullLogger.Instance); // Build a session the way login does (via SessionClaimBuilder), anchored at `at`. private static ClaimsPrincipal Session( DateTimeOffset at, IReadOnlyList groups, RoleMappingResult mapping) => SessionClaimBuilder.Build("alice", "Alice", groups, mapping, at); // --- Idle timeout --- [Fact] public async Task ValidateAsync_PastIdleTimeout_Rejects() { var clock = new TestTimeProvider(Start); var mapper = new FakeGroupRoleMapper(new RoleMappingResult([Roles.Administrator], [], true)); var sut = CreateValidator(mapper, clock); var principal = Session(Start, ["SCADA-Admins"], new RoleMappingResult([Roles.Administrator], [], true)); // 31 minutes idle (> 30-minute IdleTimeoutMinutes). clock.Advance(TimeSpan.FromMinutes(31)); var result = await sut.ValidateAsync(principal); Assert.Equal(SessionValidationAction.Reject, result.Action); // An idle-timed-out session must never be refreshed instead of rejected. Assert.Equal(0, mapper.CallCount); } [Fact] public async Task ValidateAsync_WithinIdleWindow_KeptAndNotRejected() { var clock = new TestTimeProvider(Start); var mapper = new FakeGroupRoleMapper(new RoleMappingResult([Roles.Administrator], [], true)); var sut = CreateValidator(mapper, clock); var principal = Session(Start, ["SCADA-Admins"], new RoleMappingResult([Roles.Administrator], [], true)); // 10 minutes idle — well inside the 30-minute window, and BEFORE the 15-minute // role-refresh threshold, so the session is simply kept. clock.Advance(TimeSpan.FromMinutes(10)); var result = await sut.ValidateAsync(principal); Assert.Equal(SessionValidationAction.Keep, result.Action); } [Fact] public async Task ValidateAsync_MissingLastActivityClaim_TreatedAsIdleTimedOut() { var clock = new TestTimeProvider(Start); var mapper = new FakeGroupRoleMapper(new RoleMappingResult([Roles.Administrator], [], true)); var sut = CreateValidator(mapper, clock); // A principal with no LastActivity anchor: fail-closed to rejected. var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "alice"), new Claim(JwtTokenService.UsernameClaimType, "alice"), new Claim(JwtTokenService.RoleClaimType, Roles.Administrator), }, CookieAuthenticationDefaults.AuthenticationScheme); var principal = new ClaimsPrincipal(identity); var result = await sut.ValidateAsync(principal); Assert.Equal(SessionValidationAction.Reject, result.Action); } [Fact] public async Task ValidateAsync_UnauthenticatedPrincipal_Kept() { var clock = new TestTimeProvider(Start); var mapper = new FakeGroupRoleMapper(new RoleMappingResult([], [], false)); var sut = CreateValidator(mapper, clock); Assert.Equal(SessionValidationAction.Keep, (await sut.ValidateAsync(null)).Action); Assert.Equal(SessionValidationAction.Keep, (await sut.ValidateAsync(new ClaimsPrincipal(new ClaimsIdentity()))).Action); } // --- Role-mapping refresh WITHOUT LDAP --- [Fact] public async Task ValidateAsync_AfterRoleRefreshThreshold_RemapsFromStoredGroups_NoLdap() { var clock = new TestTimeProvider(Start); // The user logged in as a system-wide Administrator. var principal = Session(Start, ["SCADA-Admins"], new RoleMappingResult([Roles.Administrator], [], true)); // The DB mapping CHANGED mid-session: the same groups now map to Viewer + site-scoped. var mapper = new FakeGroupRoleMapper( new RoleMappingResult([Roles.Viewer], ["7"], IsSystemWideDeployment: false)); var sut = CreateValidator(mapper, clock); // Past the 15-minute role-refresh threshold (still inside the 30-minute idle window). clock.Advance(TimeSpan.FromMinutes(16)); var result = await sut.ValidateAsync(principal); Assert.Equal(SessionValidationAction.Replace, result.Action); Assert.NotNull(result.Principal); // The mapper was invoked with EXACTLY the STORED groups — no LDAP re-query. Assert.Equal(1, mapper.CallCount); Assert.Equal(new[] { "SCADA-Admins" }, mapper.LastGroups); // The rebuilt principal reflects the NEW DB mapping. var newRoles = result.Principal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList(); Assert.Contains(Roles.Viewer, newRoles); Assert.DoesNotContain(Roles.Administrator, newRoles); Assert.Equal("7", result.Principal.FindFirst(JwtTokenService.SiteIdClaimType)?.Value); // The role-refresh anchor was advanced to now. var refreshClaim = result.Principal.FindFirst(JwtTokenService.LastRoleRefreshClaimType); Assert.NotNull(refreshClaim); Assert.True(DateTimeOffset.TryParse(refreshClaim!.Value, out var refreshedAt)); Assert.Equal(clock.GetUtcNow(), refreshedAt); } [Fact] public async Task ValidateAsync_RevokedGroupUser_LosesRolesOnRefresh() { var clock = new TestTimeProvider(Start); var principal = Session(Start, ["SCADA-Admins"], new RoleMappingResult([Roles.Administrator], [], true)); // The group's mapping was deleted in the DB: the same groups now map to NOTHING. var mapper = new FakeGroupRoleMapper(new RoleMappingResult([], [], IsSystemWideDeployment: false)); var sut = CreateValidator(mapper, clock); clock.Advance(TimeSpan.FromMinutes(16)); var result = await sut.ValidateAsync(principal); Assert.Equal(SessionValidationAction.Replace, result.Action); // The user has lost ALL roles mid-session — every authorization policy will now deny. Assert.Empty(result.Principal!.FindAll(JwtTokenService.RoleClaimType)); Assert.Empty(result.Principal.FindAll(JwtTokenService.SiteIdClaimType)); } [Fact] public async Task ValidateAsync_BeforeRoleRefreshThreshold_DoesNotRefresh() { var clock = new TestTimeProvider(Start); var principal = Session(Start, ["SCADA-Admins"], new RoleMappingResult([Roles.Administrator], [], true)); // Even though the DB mapping would now return Viewer, a refresh BEFORE the // 15-minute threshold must not happen — claims stay as they were at login. var mapper = new FakeGroupRoleMapper(new RoleMappingResult([Roles.Viewer], [], false)); var sut = CreateValidator(mapper, clock); clock.Advance(TimeSpan.FromMinutes(14)); // < 15-minute threshold var result = await sut.ValidateAsync(principal); Assert.Equal(SessionValidationAction.Keep, result.Action); Assert.Equal(0, mapper.CallCount); // no re-map at all } // --- Failure policy: a refresh exception keeps the existing principal --- [Fact] public async Task ValidateAsync_RefreshThrows_KeepsExistingPrincipal_NoSignOut_NoThrow() { var clock = new TestTimeProvider(Start); var principal = Session(Start, ["SCADA-Admins"], new RoleMappingResult([Roles.Administrator], [], true)); // The DB-backed mapper faults (e.g. SQL unreachable) DURING a due refresh. var mapper = new FakeGroupRoleMapper(new InvalidOperationException("db down")); var sut = CreateValidator(mapper, clock); clock.Advance(TimeSpan.FromMinutes(16)); // refresh IS due var result = await sut.ValidateAsync(principal); // must NOT throw // The session is KEPT (current roles preserved) — never rejected, never broadened. Assert.Equal(SessionValidationAction.Keep, result.Action); Assert.Equal(1, mapper.CallCount); } // --- IsIdleTimedOut / IsRoleRefreshDue boundaries --- [Fact] public void IsIdleTimedOut_AtBoundary_NotTimedOut() { var clock = new TestTimeProvider(Start); var sut = CreateValidator(new FakeGroupRoleMapper(new RoleMappingResult([], [], false)), clock); var principal = Session(Start, [], new RoleMappingResult([], [], false)); // Exactly 30 minutes — NOT strictly greater, so not timed out. Assert.False(sut.IsIdleTimedOut(principal, Start.AddMinutes(30))); Assert.True(sut.IsIdleTimedOut(principal, Start.AddMinutes(30).AddSeconds(1))); } [Fact] public void IsRoleRefreshDue_AtBoundary_NotDue() { var clock = new TestTimeProvider(Start); var sut = CreateValidator(new FakeGroupRoleMapper(new RoleMappingResult([], [], false)), clock); var principal = Session(Start, [], new RoleMappingResult([], [], false)); Assert.False(sut.IsRoleRefreshDue(principal, Start.AddMinutes(15))); Assert.True(sut.IsRoleRefreshDue(principal, Start.AddMinutes(15).AddSeconds(1))); } // --- Missing/unparsable zb:lastrolerefresh anchor treated as refresh-due --- [Fact] public async Task ValidateAsync_MissingLastRoleRefreshClaim_TreatedAsRefreshDue_RefreshesAndRestamps() { // Mirrors the existing MissingLastActivityClaim_TreatedAsIdleTimedOut test but for // the role-refresh anchor. A principal whose zb:lastrolerefresh claim is absent or // unparsable must be treated as "refresh due" (refresh now + re-stamp the anchor), // NOT as "never refresh". This prevents a stale principal from coasting forever // without a role re-check just because the claim was missing. var clock = new TestTimeProvider(Start); // The mapper returns a different mapping than the one encoded in the session, so we // can confirm the refresh actually ran. var mapper = new FakeGroupRoleMapper(new RoleMappingResult([Roles.Viewer], [], false)); var sut = CreateValidator(mapper, clock); // Build a principal that has a valid LastActivity (not timed out) but NO lastrolerefresh. var identity = new ClaimsIdentity([ new Claim(ClaimTypes.Name, "alice"), new Claim(JwtTokenService.UsernameClaimType, "alice"), new Claim(JwtTokenService.DisplayNameClaimType, "Alice"), new Claim(JwtTokenService.RoleClaimType, Roles.Administrator), new Claim(JwtTokenService.LastActivityClaimType, Start.ToString("o")), // Deliberately NO zb:lastrolerefresh claim. ], CookieAuthenticationDefaults.AuthenticationScheme, nameType: ClaimTypes.Name, roleType: JwtTokenService.RoleClaimType); var principal = new ClaimsPrincipal(identity); var result = await sut.ValidateAsync(principal); // Must have triggered a refresh (Replace), NOT Keep. Assert.Equal(SessionValidationAction.Replace, result.Action); Assert.Equal(1, mapper.CallCount); // The rebuilt principal carries the new mapping (Viewer, not Administrator). var newRoles = result.Principal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList(); Assert.Contains(Roles.Viewer, newRoles); Assert.DoesNotContain(Roles.Administrator, newRoles); // A new lastrolerefresh anchor was stamped. var refreshClaim = result.Principal.FindFirst(JwtTokenService.LastRoleRefreshClaimType); Assert.NotNull(refreshClaim); Assert.True(DateTimeOffset.TryParse(refreshClaim!.Value, out _)); } [Fact] public void IsRoleRefreshDue_MissingClaim_ReturnsDue() { // Direct helper test: a principal with no zb:lastrolerefresh claim returns true. var clock = new TestTimeProvider(Start); var sut = CreateValidator(new FakeGroupRoleMapper(new RoleMappingResult([], [], false)), clock); var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, "alice")], CookieAuthenticationDefaults.AuthenticationScheme); var principal = new ClaimsPrincipal(identity); Assert.True(sut.IsRoleRefreshDue(principal, Start)); } [Fact] public void IsRoleRefreshDue_UnparsableClaim_ReturnsDue() { // A present but unparsable zb:lastrolerefresh claim is treated as due, not as a // parse error that keeps the session stale forever. var clock = new TestTimeProvider(Start); var sut = CreateValidator(new FakeGroupRoleMapper(new RoleMappingResult([], [], false)), clock); var identity = new ClaimsIdentity([ new Claim(ClaimTypes.Name, "alice"), new Claim(JwtTokenService.LastRoleRefreshClaimType, "not-a-date"), ], CookieAuthenticationDefaults.AuthenticationScheme); var principal = new ClaimsPrincipal(identity); Assert.True(sut.IsRoleRefreshDue(principal, Start)); } } /// /// Claim-parity tests: the shared must produce EXACTLY /// the claim shape the login endpoint historically minted (plus the M2.19 group + /// refresh-anchor additions), and the refresh path — which also goes through the builder — /// must therefore be identical. This is the load-bearing anti-drift guarantee. /// public class SessionClaimBuilderParityTests { private static readonly DateTimeOffset At = new(2026, 6, 15, 9, 0, 0, TimeSpan.Zero); [Fact] public void Build_ProducesCanonicalIdentityRoleAndScopeClaims_LikeLogin() { var mapping = new RoleMappingResult( [Roles.Administrator, Roles.Designer], ["3", "4"], IsSystemWideDeployment: false); var principal = SessionClaimBuilder.Build("janer", "Jane Roe", ["G1", "G2"], mapping, At); // Identity claims — identical to the historical /auth/login set. Assert.Equal("janer", principal.Identity?.Name); // ClaimTypes.Name resolves Identity.Name Assert.Equal("Jane Roe", principal.FindFirst(ZbClaimTypes.DisplayName)?.Value); Assert.Equal("janer", principal.FindFirst(ZbClaimTypes.Username)?.Value); // Roles — one per mapped role, canonical type, and IsInRole resolves. var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList(); Assert.Equal(new[] { Roles.Administrator, Roles.Designer }.OrderBy(r => r), roles.OrderBy(r => r)); Assert.True(principal.IsInRole(Roles.Administrator)); // Scope — one SiteId per permitted site (non-system-wide). var sites = principal.FindAll(ZbClaimTypes.ScopeId).Select(c => c.Value).ToList(); Assert.Equal(new[] { "3", "4" }.OrderBy(s => s), sites.OrderBy(s => s)); } [Fact] public void Build_SystemWideMapping_OmitsSiteIdClaims() { var mapping = new RoleMappingResult([Roles.Deployer], [], IsSystemWideDeployment: true); var principal = SessionClaimBuilder.Build("op", "Operator", ["GlobalDeployers"], mapping, At); // Deny-by-omission preserved: a system-wide mapping stamps NO SiteId claims. Assert.Empty(principal.FindAll(ZbClaimTypes.ScopeId)); Assert.Contains(Roles.Deployer, principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value)); } [Fact] public void Build_StoresGroupsAndRefreshAnchor_M219Additions() { var mapping = new RoleMappingResult([Roles.Administrator], [], true); var principal = SessionClaimBuilder.Build("janer", "Jane Roe", ["G1", "G2"], mapping, At); // M2.19: one zb:group claim per LDAP group, round-trippable via ReadGroups. Assert.Equal(new[] { "G1", "G2" }, SessionClaimBuilder.ReadGroups(principal)); // M2.19: refresh anchor AND idle anchor seeded from the same instant. Assert.Equal(At.ToString("o"), principal.FindFirst(JwtTokenService.LastRoleRefreshClaimType)?.Value); Assert.Equal(At.ToString("o"), principal.FindFirst(JwtTokenService.LastActivityClaimType)?.Value); } [Fact] public void Build_LoginThenRefresh_ProduceIdenticalClaimShape() { // The anti-drift guarantee: login and the refresh path both call Build with the // same inputs, so the resulting role/scope/group claim SETS are identical (only // the timestamp anchors differ by design). var mapping = new RoleMappingResult([Roles.Designer], ["9"], IsSystemWideDeployment: false); var login = SessionClaimBuilder.Build("u", "User", ["G"], mapping, At); var refresh = SessionClaimBuilder.Build("u", "User", ["G"], mapping, At.AddMinutes(16)); static IEnumerable Pairs(ClaimsPrincipal p, string type) => p.FindAll(type).Select(c => c.Value).OrderBy(v => v); Assert.Equal(Pairs(login, ClaimTypes.Name), Pairs(refresh, ClaimTypes.Name)); Assert.Equal(Pairs(login, JwtTokenService.DisplayNameClaimType), Pairs(refresh, JwtTokenService.DisplayNameClaimType)); Assert.Equal(Pairs(login, JwtTokenService.UsernameClaimType), Pairs(refresh, JwtTokenService.UsernameClaimType)); Assert.Equal(Pairs(login, JwtTokenService.RoleClaimType), Pairs(refresh, JwtTokenService.RoleClaimType)); Assert.Equal(Pairs(login, JwtTokenService.SiteIdClaimType), Pairs(refresh, JwtTokenService.SiteIdClaimType)); Assert.Equal(Pairs(login, JwtTokenService.GroupClaimType), Pairs(refresh, JwtTokenService.GroupClaimType)); // The refresh anchors moved forward (that is the ONLY intended difference). Assert.NotEqual( login.FindFirst(JwtTokenService.LastRoleRefreshClaimType)?.Value, refresh.FindFirst(JwtTokenService.LastRoleRefreshClaimType)?.Value); } } #endregion #region M2.19 (#15): OnValidatePrincipalAsync adapter — translation from SessionValidationResult to cookie context calls /// /// Tests for the ApplyValidationResultAsync helper extracted from /// : verifies that each /// case is correctly translated into the /// corresponding mutations. /// The pure decision-application step is tested in isolation — the DI-resolution of /// is a separate concern covered by the integration /// wiring tests in . /// public class OnValidatePrincipalAdapterTests { private static readonly DateTimeOffset AdapterStart = new(2026, 6, 15, 12, 0, 0, TimeSpan.Zero); // A minimal IAuthenticationService stub that records SignOut calls so we can assert // the Reject path actually invokes SignOutAsync. private sealed class StubAuthenticationService : IAuthenticationService { public int SignOutCallCount { get; private set; } public Task AuthenticateAsync(HttpContext context, string? scheme) => Task.FromResult(AuthenticateResult.NoResult()); public Task ChallengeAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) => Task.CompletedTask; public Task ForbidAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) => Task.CompletedTask; public Task SignInAsync(HttpContext context, string? scheme, ClaimsPrincipal principal, AuthenticationProperties? properties) => Task.CompletedTask; public Task SignOutAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) { SignOutCallCount++; return Task.CompletedTask; } } private static (CookieValidatePrincipalContext Context, StubAuthenticationService AuthService) BuildContext(ClaimsPrincipal principal) { var authService = new StubAuthenticationService(); var services = new ServiceCollection(); services.AddSingleton(authService); services.AddLogging(); var serviceProvider = services.BuildServiceProvider(); var httpContext = new DefaultHttpContext { RequestServices = serviceProvider }; var ticket = new AuthenticationTicket( principal, new AuthenticationProperties(), CookieAuthenticationDefaults.AuthenticationScheme); var scheme = new AuthenticationScheme( CookieAuthenticationDefaults.AuthenticationScheme, displayName: null, handlerType: typeof(CookieAuthenticationHandler)); var context = new CookieValidatePrincipalContext( httpContext, scheme, new CookieAuthenticationOptions(), ticket); return (context, authService); } private static ClaimsPrincipal AuthenticatedPrincipal() => SessionClaimBuilder.Build( "alice", "Alice", ["SCADA-Admins"], new RoleMappingResult([Roles.Administrator], [], true), AdapterStart); [Fact] public async Task ApplyResult_Reject_CallsRejectPrincipalAndSignsOut() { // The Reject result is the idle-timeout path: the adapter must call RejectPrincipal // (sets Principal = null) AND SignOutAsync so the cookie is cleared for the next request. var principal = AuthenticatedPrincipal(); var (ctx, authService) = BuildContext(principal); await ServiceCollectionExtensions.ApplyValidationResultAsync(ctx, SessionValidationResult.Reject); // RejectPrincipal() sets Principal to null. Assert.Null(ctx.Principal); // SignOutAsync must have been invoked exactly once. Assert.Equal(1, authService.SignOutCallCount); // ShouldRenew must NOT be set — we are expiring, not renewing. Assert.False(ctx.ShouldRenew); } [Fact] public async Task ApplyResult_Replace_ReplacesAndSetsRenew() { // The Replace result is the role-refresh path: the adapter must swap in the new // principal and set ShouldRenew = true so the cookie is re-issued with the new claims. var newPrincipal = SessionClaimBuilder.Build( "alice", "Alice", ["SCADA-Admins"], new RoleMappingResult([Roles.Viewer], [], false), AdapterStart.AddMinutes(16)); var originalPrincipal = AuthenticatedPrincipal(); var (ctx, authService) = BuildContext(originalPrincipal); await ServiceCollectionExtensions.ApplyValidationResultAsync(ctx, SessionValidationResult.Replace(newPrincipal)); Assert.Same(newPrincipal, ctx.Principal); Assert.True(ctx.ShouldRenew); Assert.Equal(0, authService.SignOutCallCount); } [Fact] public async Task ApplyResult_Keep_LeavesContextUnchanged() { // The Keep result is the no-op path (no refresh due, or a swallowed refresh fault): // the adapter must leave the principal and ShouldRenew completely untouched. var principal = AuthenticatedPrincipal(); var (ctx, authService) = BuildContext(principal); await ServiceCollectionExtensions.ApplyValidationResultAsync(ctx, SessionValidationResult.Keep); Assert.Same(principal, ctx.Principal); Assert.False(ctx.ShouldRenew); Assert.Equal(0, authService.SignOutCallCount); } } #endregion #region M2.19 (#15): SecurityOptionsValidator — config-guard fails startup on misconfiguration /// /// Tests for : the startup validator that prevents /// idle-timeout enforcement from being silently defeated by a misconfigured /// RoleRefreshThresholdMinutes >= IdleTimeoutMinutes. /// public class SecurityOptionsValidatorConfigGuardTests { private static SecurityOptions ValidOptions() => new() { JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough", IdleTimeoutMinutes = 30, RoleRefreshThresholdMinutes = 15, }; [Fact] public void Validate_DefaultOptions_Succeeds() { var result = new SecurityOptionsValidator().Validate(name: null, ValidOptions()); Assert.True(result.Succeeded); } [Fact] public void Validate_RefreshEqualsIdle_Fails() { // Equal is invalid: a refresh cycle at t=30 would advance LastActivity to t=30 which // equals the idle timeout — enforcement is defeated. var options = ValidOptions(); options.RoleRefreshThresholdMinutes = options.IdleTimeoutMinutes; // 30 == 30 var result = new SecurityOptionsValidator().Validate(name: null, options); Assert.True(result.Failed); Assert.Contains(nameof(SecurityOptions.RoleRefreshThresholdMinutes), result.FailureMessage); Assert.Contains(nameof(SecurityOptions.IdleTimeoutMinutes), result.FailureMessage); } [Fact] public void Validate_RefreshGreaterThanIdle_Fails() { // Inverted: refresh threshold LARGER than the idle window is clearly wrong and // must fail loudly at startup. var options = ValidOptions(); options.RoleRefreshThresholdMinutes = 60; // 60 > 30 var result = new SecurityOptionsValidator().Validate(name: null, options); Assert.True(result.Failed); Assert.Contains(nameof(SecurityOptions.RoleRefreshThresholdMinutes), result.FailureMessage); } [Fact] public void Validate_RefreshOneLessThanIdle_Succeeds() { // Boundary: threshold = idle - 1 is the tightest VALID configuration. var options = ValidOptions(); options.IdleTimeoutMinutes = 30; options.RoleRefreshThresholdMinutes = 29; var result = new SecurityOptionsValidator().Validate(name: null, options); Assert.True(result.Succeeded); } [Fact] public void AddSecurity_RegistersSecurityOptionsValidator_WithValidateOnStart() { // End-to-end wiring: AddSecurity registers SecurityOptionsValidator as // IValidateOptions. ValidateOnStart is what makes the DI container // call Validate on startup. We confirm the validator is present in the container. var services = new ServiceCollection(); services.AddLogging(); services.AddDataProtection(); services.AddSecurity(); using var provider = services.BuildServiceProvider(); var validators = provider.GetServices>().ToList(); Assert.Contains(validators, v => v is SecurityOptionsValidator); } [Fact] public void AddSecurity_RegisteredValidator_DetectsMisconfiguration_WhenCalledDirectly() { // Confirm that the SecurityOptionsValidator registered by AddSecurity actually // catches a bad configuration. We resolve the validator from the container and // call it directly — the ValidateOnStart pipeline would fire the same validator // during IHost.StartAsync in a real deployment. var services = new ServiceCollection(); services.AddLogging(); services.AddDataProtection(); services.AddSecurity(); using var provider = services.BuildServiceProvider(); var validator = provider.GetServices>() .OfType() .Single(); // A configuration where RoleRefreshThreshold == IdleTimeout defeats idle enforcement. var badOptions = new SecurityOptions { JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough", IdleTimeoutMinutes = 30, RoleRefreshThresholdMinutes = 30, // == IdleTimeoutMinutes — invalid }; var result = validator.Validate(name: null, badOptions); Assert.True(result.Failed); Assert.Contains(nameof(SecurityOptions.RoleRefreshThresholdMinutes), result.FailureMessage); } } #endregion