using System.Security.Claims; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.Security.Tests; /// /// ScadaBridge#46: the mid-session LDAP group re-query. Pins the cadence (its own anchor, independent /// of the role-mapping threshold and of forced circuit refreshes), the failure policy (directory /// unavailable keeps stored groups on every path; a directory answer that would refuse a new sign-in /// ends the session on every path), the per-node answer cache and the switches. /// public class DirectoryGroupRefreshTests { private static readonly DateTimeOffset Start = new(2026, 11, 12, 9, 0, 0, TimeSpan.Zero); private sealed class Clock(DateTimeOffset start) : TimeProvider { private DateTimeOffset _now = start; public override DateTimeOffset GetUtcNow() => _now; public void Advance(TimeSpan by) => _now += by; } /// Maps each group to a role by name (Admins→Administrator, Designers→Designer, else Viewer). private sealed class NamingMapper : IGroupRoleMapper { public int Calls { get; private set; } public IReadOnlyList? LastGroups { get; private set; } public Task> MapAsync(IReadOnlyList groups, CancellationToken ct) { Calls++; LastGroups = groups; var roles = groups.Select(g => g switch { "SCADA-Admins" => Roles.Administrator, "SCADA-Designers" => Roles.Designer, _ => Roles.Viewer, }).Distinct().ToList(); var result = new RoleMappingResult(roles, [], IsSystemWideDeployment: true); return Task.FromResult(new GroupRoleMapping(roles, Scope: result)); } } private sealed class FakeLookup : ILdapGroupLookup { public Func Answer { get; set; } = _ => LdapGroupLookupResult.Of(LdapGroupLookupStatus.DirectoryUnavailable); public Exception? Throw { get; set; } public List Calls { get; } = new(); public Task GetUserGroupsAsync(string username, CancellationToken ct) { Calls.Add(username); if (Throw is not null) throw Throw; return Task.FromResult(Answer(username)); } } private static SecurityOptions Opts(Action? tweak = null) { var o = new SecurityOptions { JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough", IdleTimeoutMinutes = 30, RoleRefreshThresholdMinutes = 15, }; o.LdapGroupRefresh.CacheSeconds = 0; // most tests count lookups; the cache has its own tests tweak?.Invoke(o.LdapGroupRefresh); return o; } private sealed record Rig(CookieSessionValidator Sut, FakeLookup Lookup, NamingMapper Mapper, Clock Clock, DirectoryGroupRefresher Refresher); private static Rig Build(SecurityOptions? options = null, bool withLookup = true) { var clock = new Clock(Start); var lookup = new FakeLookup(); var mapper = new NamingMapper(); var opts = Options.Create(options ?? Opts()); var refresher = new DirectoryGroupRefresher(opts, clock, NullLogger.Instance, withLookup ? lookup : null); var sut = new CookieSessionValidator(mapper, opts, clock, NullLogger.Instance, activity: null, directory: refresher); return new Rig(sut, lookup, mapper, clock, refresher); } private static ClaimsPrincipal Login(IReadOnlyList groups, DateTimeOffset? at = null) => SessionClaimBuilder.Build("alice", "Alice", groups, new RoleMappingResult([Roles.Administrator], [], true), at ?? Start); private static LdapGroupLookupResult FoundWith(params string[] groups) => LdapGroupLookupResult.Found("alice", "Alice", groups); private static IReadOnlyList RolesOf(ClaimsPrincipal p) => p.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList(); private static string? GroupAnchor(ClaimsPrincipal p) => p.FindFirst(JwtTokenService.LastGroupRefreshClaimType)?.Value; // ---- the change lands mid-session ---- [Fact] public async Task DueRefresh_ReReadsGroupsFromTheDirectory_AndReMapsRoles() { var rig = Build(); rig.Lookup.Answer = _ => FoundWith("SCADA-Designers"); rig.Clock.Advance(TimeSpan.FromMinutes(16)); var result = await rig.Sut.ValidateAsync(Login(["SCADA-Admins"])); Assert.Equal(SessionValidationAction.Replace, result.Action); Assert.Equal(["alice"], rig.Lookup.Calls); Assert.Equal(["SCADA-Designers"], rig.Mapper.LastGroups); Assert.Equal([Roles.Designer], RolesOf(result.Principal!)); Assert.Equal(["SCADA-Designers"], SessionClaimBuilder.ReadGroups(result.Principal!)); Assert.Equal((Start + TimeSpan.FromMinutes(16)).ToString("o"), GroupAnchor(result.Principal!)); } [Fact] public async Task NotDue_RoleRefreshUsesStoredGroups_WithoutAskingTheDirectory() { var rig = Build(Opts(o => o.IntervalMinutes = 60)); rig.Lookup.Answer = _ => FoundWith("SCADA-Designers"); rig.Clock.Advance(TimeSpan.FromMinutes(16)); // role refresh due, group refresh not var result = await rig.Sut.ValidateAsync(Login(["SCADA-Admins"])); Assert.Equal(SessionValidationAction.Replace, result.Action); Assert.Empty(rig.Lookup.Calls); Assert.Equal(["SCADA-Admins"], rig.Mapper.LastGroups); Assert.Equal(Start.ToString("o"), GroupAnchor(result.Principal!)); // anchor carried, not advanced } [Fact] public async Task ShorterGroupInterval_TriggersARefreshBeforeTheRoleThreshold() { var rig = Build(Opts(o => o.IntervalMinutes = 5)); rig.Lookup.Answer = _ => FoundWith("SCADA-Viewers"); rig.Clock.Advance(TimeSpan.FromMinutes(6)); // role refresh (15) not due var result = await rig.Sut.ValidateAsync(Login(["SCADA-Admins"])); Assert.Equal(SessionValidationAction.Replace, result.Action); Assert.Single(rig.Lookup.Calls); Assert.Equal([Roles.Viewer], RolesOf(result.Principal!)); } [Fact] public async Task ForcedCircuitRefreshes_AskTheDirectoryOncePerInterval() { var rig = Build(); rig.Lookup.Answer = _ => FoundWith("SCADA-Admins"); var principal = Login(["SCADA-Admins"]); // Inside the interval: a forced (circuit) refresh re-maps but never queries. rig.Clock.Advance(TimeSpan.FromMinutes(5)); principal = (await rig.Sut.ValidateAsync(principal, forceRefresh: true, recordActivity: false)).Principal!; Assert.Empty(rig.Lookup.Calls); // Past it: one query, then the rebuilt principal's advanced anchor stops repeats. rig.Clock.Advance(TimeSpan.FromMinutes(11)); principal = (await rig.Sut.ValidateAsync(principal, forceRefresh: true, recordActivity: false)).Principal!; rig.Clock.Advance(TimeSpan.FromSeconds(10)); await rig.Sut.ValidateAsync(principal, forceRefresh: true, recordActivity: false, failClosed: true); Assert.Single(rig.Lookup.Calls); } [Fact] public async Task CookieIssuedBeforeTheFeature_WithNoAnchor_IsDue() { var rig = Build(); rig.Lookup.Answer = _ => FoundWith("SCADA-Designers"); var old = Login(["SCADA-Admins"]); var identity = (ClaimsIdentity)old.Identity!; identity.RemoveClaim(identity.FindFirst(JwtTokenService.LastGroupRefreshClaimType)!); rig.Clock.Advance(TimeSpan.FromMinutes(1)); var result = await rig.Sut.ValidateAsync(old); Assert.Single(rig.Lookup.Calls); Assert.Equal([Roles.Designer], RolesOf(result.Principal!)); } [Fact] public void Login_StampsTheGroupAnchor() => Assert.Equal(Start.ToString("o"), GroupAnchor(Login(["SCADA-Admins"]))); // ---- failure policy ---- [Theory] [InlineData(false)] [InlineData(true)] public async Task DirectoryUnavailable_KeepsStoredGroups_OnEveryPath(bool failClosed) { var rig = Build(); rig.Lookup.Answer = _ => LdapGroupLookupResult.Of(LdapGroupLookupStatus.DirectoryUnavailable); rig.Clock.Advance(TimeSpan.FromMinutes(16)); var result = await rig.Sut.ValidateAsync(Login(["SCADA-Admins"]), forceRefresh: failClosed, failClosed: failClosed); Assert.Equal(SessionValidationAction.Replace, result.Action); Assert.Equal(["SCADA-Admins"], rig.Mapper.LastGroups); Assert.Equal([Roles.Administrator], RolesOf(result.Principal!)); // The attempt still advances the anchor: an outage costs one query per interval, not per request. Assert.Equal((Start + TimeSpan.FromMinutes(16)).ToString("o"), GroupAnchor(result.Principal!)); } [Fact] public async Task LookupThatThrows_IsTreatedAsUnavailable() { var rig = Build(); rig.Lookup.Throw = new InvalidOperationException("boom"); rig.Clock.Advance(TimeSpan.FromMinutes(16)); var result = await rig.Sut.ValidateAsync(Login(["SCADA-Admins"]), failClosed: true, forceRefresh: true); Assert.Equal(SessionValidationAction.Replace, result.Action); Assert.Equal(["SCADA-Admins"], rig.Mapper.LastGroups); } public static TheoryData Ending => new() { { LdapGroupLookupStatus.UserNotFound, false }, { LdapGroupLookupStatus.UserNotFound, true }, { LdapGroupLookupStatus.UserDisabled, false }, { LdapGroupLookupStatus.UserDisabled, true }, { LdapGroupLookupStatus.AmbiguousUser, false }, { LdapGroupLookupStatus.AmbiguousUser, true }, }; [Theory] [MemberData(nameof(Ending))] public async Task DirectorySaysTheUserCannotSignIn_EndsTheSession(LdapGroupLookupStatus status, bool failClosed) { var rig = Build(); rig.Lookup.Answer = _ => LdapGroupLookupResult.Of(status); rig.Clock.Advance(TimeSpan.FromMinutes(16)); var result = await rig.Sut.ValidateAsync(Login(["SCADA-Admins"]), forceRefresh: failClosed, failClosed: failClosed); Assert.Equal(SessionValidationAction.Reject, result.Action); Assert.Equal(0, rig.Mapper.Calls); } [Fact] public async Task FoundWithNoGroups_EndsTheSession() { var rig = Build(); rig.Lookup.Answer = _ => FoundWith(); rig.Clock.Advance(TimeSpan.FromMinutes(16)); var result = await rig.Sut.ValidateAsync(Login(["SCADA-Admins"])); Assert.Equal(SessionValidationAction.Reject, result.Action); } [Theory] [InlineData(LdapGroupLookupStatus.NotSupported)] [InlineData(LdapGroupLookupStatus.ProviderDisabled)] public async Task LookupNotPossible_KeepsStoredGroups(LdapGroupLookupStatus status) { var rig = Build(); rig.Lookup.Answer = _ => LdapGroupLookupResult.Of(status); rig.Clock.Advance(TimeSpan.FromMinutes(16)); var result = await rig.Sut.ValidateAsync(Login(["SCADA-Admins"])); Assert.Equal(SessionValidationAction.Replace, result.Action); Assert.Equal(["SCADA-Admins"], rig.Mapper.LastGroups); } // ---- switches ---- [Fact] public async Task Disabled_NeverAsksTheDirectory() { var rig = Build(Opts(o => o.Enabled = false)); rig.Lookup.Answer = _ => LdapGroupLookupResult.Of(LdapGroupLookupStatus.UserDisabled); rig.Clock.Advance(TimeSpan.FromMinutes(16)); var result = await rig.Sut.ValidateAsync(Login(["SCADA-Admins"]), forceRefresh: true, failClosed: true); Assert.Equal(SessionValidationAction.Replace, result.Action); Assert.Empty(rig.Lookup.Calls); Assert.False(rig.Sut.IsGroupRefreshDue(Login(["x"]), Start + TimeSpan.FromDays(1))); } [Fact] public async Task NoLookupRegistered_IsInert() { var rig = Build(withLookup: false); rig.Clock.Advance(TimeSpan.FromMinutes(16)); var result = await rig.Sut.ValidateAsync(Login(["SCADA-Admins"])); Assert.False(rig.Refresher.IsActive); Assert.Equal(["SCADA-Admins"], rig.Mapper.LastGroups); Assert.Equal(SessionValidationAction.Replace, result.Action); } // ---- the per-node answer cache ---- [Fact] public async Task Cache_CollapsesRepeatQueriesForOneUser_WithinItsWindow() { var rig = Build(Opts(o => o.CacheSeconds = 60)); rig.Lookup.Answer = _ => FoundWith("SCADA-Admins"); await rig.Refresher.RefreshAsync("alice", default); rig.Clock.Advance(TimeSpan.FromSeconds(30)); await rig.Refresher.RefreshAsync("ALICE", default); Assert.Single(rig.Lookup.Calls); rig.Clock.Advance(TimeSpan.FromSeconds(31)); await rig.Refresher.RefreshAsync("alice", default); Assert.Equal(2, rig.Lookup.Calls.Count); } [Fact] public async Task CacheZero_AsksEveryTime() { var rig = Build(Opts(o => o.CacheSeconds = 0)); rig.Lookup.Answer = _ => FoundWith("SCADA-Admins"); await rig.Refresher.RefreshAsync("alice", default); await rig.Refresher.RefreshAsync("alice", default); Assert.Equal(2, rig.Lookup.Calls.Count); } // ---- options validation ---- [Theory] [InlineData(true, 0, 60, false)] [InlineData(true, 1, 60, true)] [InlineData(false, 0, 60, true)] [InlineData(true, 15, -1, false)] [InlineData(true, 15, 0, true)] public void Validator_ChecksIntervalAndCache(bool enabled, int interval, int cache, bool valid) { var o = Opts(g => { g.Enabled = enabled; g.IntervalMinutes = interval; g.CacheSeconds = cache; }); var result = new SecurityOptionsValidator().Validate(null, o); Assert.Equal(valid, result.Succeeded); } [Fact] public void Defaults_AreOnFifteenMinutesSixtySeconds() { var o = new SecurityOptions().LdapGroupRefresh; Assert.True(o.Enabled); Assert.Equal(15, o.IntervalMinutes); Assert.Equal(60, o.CacheSeconds); } // ---- composition ---- [Fact] public void AddSecurity_PlusAddZbLdapAuth_ResolvesAnActiveRefresherIntoTheValidator() { var config = new Microsoft.Extensions.Configuration.ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Ldap:Server"] = "ldap.example.com", ["Ldap:SearchBase"] = "dc=example,dc=com", ["Ldap:ServiceAccountDn"] = "cn=svc,dc=example,dc=com", }).Build(); var services = new ServiceCollection().AddLogging(); ZB.MOM.WW.Auth.AspNetCore.ServiceCollectionExtensions.AddZbLdapAuth(services, config, "Ldap"); services.AddSecurity(); services.AddScoped, NamingMapper>(); // the real one needs the DB repository using var provider = services.BuildServiceProvider(); using var scope = provider.CreateScope(); Assert.True(provider.GetRequiredService().IsActive); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); } }