using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.Security.Tests; /// /// + (review 2026-10-21 05/P1, 07/P-3): /// a fresh snapshot serves mappings with no repository reads, it expires after /// , /// forces a re-read, and the scope rules are resolved once per refresh. /// public class RoleMappingCacheTests { private sealed class MutableClock : TimeProvider { public DateTimeOffset Now { get; set; } = new(2026, 10, 22, 12, 0, 0, TimeSpan.Zero); public override DateTimeOffset GetUtcNow() => Now; } // Counts the two reads RoleMapper uses; every other member is unused here. private sealed class CountingRepository : ISecurityRepository { public List Mappings { get; } = []; public Dictionary> ScopeRules { get; } = []; public int MappingReads { get; private set; } public Func? BeforeMappingRead { get; set; } public int ScopeReads { get; private set; } /// public async Task> GetAllMappingsAsync(CancellationToken cancellationToken = default) { MappingReads++; var snapshot = Mappings.ToList(); if (BeforeMappingRead is not null) await BeforeMappingRead(); return snapshot; } /// public Task> GetScopeRulesForMappingAsync(int ldapGroupMappingId, CancellationToken cancellationToken = default) { ScopeReads++; return Task.FromResult(ScopeRules.TryGetValue(ldapGroupMappingId, out var rules) ? rules : (IReadOnlyList)Array.Empty()); } /// public Task GetMappingByIdAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); /// public Task> GetMappingsByRoleAsync(string role, CancellationToken cancellationToken = default) => throw new NotSupportedException(); /// public Task AddMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default) => throw new NotSupportedException(); /// public Task UpdateMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default) => throw new NotSupportedException(); /// public Task DeleteMappingAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); /// public Task GetScopeRuleByIdAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); /// public Task AddScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default) => throw new NotSupportedException(); /// public Task UpdateScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default) => throw new NotSupportedException(); /// public Task DeleteScopeRuleAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); /// public Task SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); } private sealed class StaticMonitor(SecurityOptions value) : IOptionsMonitor { public SecurityOptions CurrentValue => value; public SecurityOptions Get(string? name) => value; public IDisposable? OnChange(Action listener) => null; } private static LdapGroupMapping Mapping(int id, string group, string role) => new(group, role) { Id = id }; private static (CountingRepository Repo, MutableClock Clock, RoleMappingCache Cache, RoleMapper Mapper) Create(int ttlSeconds = 30) { var repo = new CountingRepository(); repo.Mappings.Add(Mapping(1, "Admins", Roles.Administrator)); repo.Mappings.Add(Mapping(2, "SiteDeployers", Roles.Deployer)); repo.ScopeRules[2] = [new SiteScopeRule { LdapGroupMappingId = 2, SiteId = 5 }]; var clock = new MutableClock(); var cache = new RoleMappingCache(clock, new StaticMonitor(new SecurityOptions { RoleMappingCacheSeconds = ttlSeconds })); return (repo, clock, cache, new RoleMapper(repo, cache)); } /// A second mapping within the TTL reads nothing from the repository. /// A task that represents the asynchronous operation. [Fact] public async Task SecondMapping_WithinTtl_IsServedFromCache() { var (repo, _, _, mapper) = Create(); var first = await mapper.MapGroupsToRolesAsync(["SiteDeployers"]); var second = await mapper.MapGroupsToRolesAsync(["SiteDeployers", "Admins"]); Assert.Equal(1, repo.MappingReads); Assert.Equal(1, repo.ScopeReads); // one Deployer mapping, read once per refresh Assert.Equal(new[] { "5" }, first.PermittedSiteIds); Assert.Contains(Roles.Administrator, second.Roles); Assert.Contains(Roles.Deployer, second.Roles); } /// The snapshot expires after the TTL and the next mapping re-reads the table. /// A task that represents the asynchronous operation. [Fact] public async Task Mapping_AfterTtl_RereadsRepository() { var (repo, clock, _, mapper) = Create(ttlSeconds: 30); await mapper.MapGroupsToRolesAsync(["Admins"]); repo.Mappings.Add(Mapping(3, "Designers", Roles.Designer)); clock.Now = clock.Now.AddSeconds(31); var result = await mapper.MapGroupsToRolesAsync(["Designers"]); Assert.Equal(2, repo.MappingReads); Assert.Contains(Roles.Designer, result.Roles); } /// Invalidate drops the snapshot immediately, so a revocation is seen on the next mapping. /// A task that represents the asynchronous operation. [Fact] public async Task Invalidate_ForcesReread_RevocationVisibleImmediately() { var (repo, _, cache, mapper) = Create(); Assert.Contains(Roles.Administrator, (await mapper.MapGroupsToRolesAsync(["Admins"])).Roles); repo.Mappings.RemoveAll(m => m.Id == 1); cache.Invalidate(); var after = await mapper.MapGroupsToRolesAsync(["Admins"]); Assert.Equal(2, repo.MappingReads); Assert.Empty(after.Roles); } /// /// A zero TTL disables caching entirely and reads only the MATCHED Deployer mappings' scope /// rules, not every Deployer mapping's (review L6). /// /// A task that represents the asynchronous operation. [Fact] public async Task ZeroTtl_DisablesCache_AndReadsOnlyMatchedScopeRules() { var (repo, _, _, mapper) = Create(ttlSeconds: 0); await mapper.MapGroupsToRolesAsync(["Admins"]); await mapper.MapGroupsToRolesAsync(["Admins"]); Assert.Equal(2, repo.MappingReads); Assert.Equal(0, repo.ScopeReads); // "Admins" matches no Deployer mapping } /// /// A refresh that read the table before an is not /// published over it: the next mapping re-reads (review L5). /// /// A task that represents the asynchronous operation. [Fact] public async Task RefreshRacingInvalidate_IsNotPublished() { var (repo, _, cache, mapper) = Create(); var readStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); repo.BeforeMappingRead = async () => { readStarted.TrySetResult(); await release.Task; }; // Refresh #1 snapshots the table WITH the Admins mapping, then stalls. var inFlight = mapper.MapGroupsToRolesAsync(["Admins"]); await readStarted.Task; repo.BeforeMappingRead = null; // The mapping is revoked and the cache invalidated while #1 is still in flight. repo.Mappings.RemoveAll(m => m.Id == 1); cache.Invalidate(); release.SetResult(); Assert.Contains(Roles.Administrator, (await inFlight).Roles); // #1 answers its own caller var after = await mapper.MapGroupsToRolesAsync(["Admins"]); Assert.Empty(after.Roles); // the stale snapshot was never published Assert.Equal(2, repo.MappingReads); } /// The cache is shared by mappers built over different (scoped) repositories. /// A task that represents the asynchronous operation. [Fact] public async Task Cache_IsSharedAcrossScopedMappers() { var (repo, _, cache, mapper) = Create(); await mapper.MapGroupsToRolesAsync(["Admins"]); var otherScopeRepo = new CountingRepository(); var result = await new RoleMapper(otherScopeRepo, cache).MapGroupsToRolesAsync(["Admins"]); Assert.Equal(0, otherScopeRepo.MappingReads); Assert.Contains(Roles.Administrator, result.Roles); Assert.Equal(1, repo.MappingReads); } } /// /// Bearer-token hardening of (review 2026-10-21 05/U1): lifetime /// comes from , expired and unsigned tokens are /// rejected. /// public class JwtBearerHardeningTests { private const string Key = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough"; private sealed class FixedClock(DateTimeOffset now) : TimeProvider { public override DateTimeOffset GetUtcNow() => now; } private static JwtTokenService Service(int expiryMinutes = 15, TimeProvider? clock = null) => new(Options.Create(new SecurityOptions { JwtSigningKey = Key, JwtExpiryMinutes = expiryMinutes }), NullLogger.Instance, clock); /// The token's exp claim is JwtExpiryMinutes after issuance. [Fact] public void GenerateToken_ExpiryHonoursJwtExpiryMinutes() { var issuedAt = new DateTimeOffset(2026, 10, 22, 12, 0, 0, TimeSpan.Zero); var token = Service(expiryMinutes: 7, clock: new FixedClock(issuedAt)) .GenerateToken("U", "u", [Roles.Viewer], null); var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token); Assert.Equal(issuedAt.UtcDateTime.AddMinutes(7), jwt.ValidTo); } /// A token past its expiry (beyond the small skew) is rejected. [Fact] public void ValidateToken_Expired_ReturnsNull() { var token = Service(clock: new FixedClock(DateTimeOffset.UtcNow.AddMinutes(-16))) .GenerateToken("U", "u", [Roles.Viewer], null); Assert.Null(Service().ValidateToken(token)); } /// An alg=none token carrying our issuer/audience is rejected. [Fact] public void ValidateToken_AlgNone_ReturnsNull() { var unsigned = new JwtSecurityToken( issuer: JwtTokenService.TokenIssuer, audience: JwtTokenService.TokenAudience, claims: [new Claim(JwtTokenService.UsernameClaimType, "admin")], expires: DateTime.UtcNow.AddMinutes(10)); Assert.Null(Service().ValidateToken(new JwtSecurityTokenHandler().WriteToken(unsigned))); } /// A blank token is rejected without throwing. [Fact] public void ValidateToken_Blank_ReturnsNull() { Assert.Null(Service().ValidateToken(" ")); } /// A non-positive JwtExpiryMinutes fails options validation. [Fact] public void Validator_NonPositiveExpiry_Fails() { var result = new SecurityOptionsValidator().Validate(null, new SecurityOptions { JwtExpiryMinutes = 0 }); Assert.True(result.Failed); } /// A negative RoleMappingCacheSeconds fails options validation. [Fact] public void Validator_NegativeRoleMappingCacheSeconds_Fails() { var result = new SecurityOptionsValidator().Validate(null, new SecurityOptions { RoleMappingCacheSeconds = -1 }); Assert.True(result.Failed); } }