using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ScadaLink.Commons.Entities.Security; using ScadaLink.Commons.Entities.Sites; using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase.Repositories; using ScadaLink.Security; namespace ScadaLink.Security.Tests; #region WP-6: LdapAuthService Tests public class LdapAuthServiceTests { private static SecurityOptions CreateOptions(bool useTls = true, bool allowInsecure = false) => new() { LdapServer = "ldap.example.com", LdapPort = 636, LdapUseTls = useTls, AllowInsecureLdap = allowInsecure, LdapSearchBase = "dc=example,dc=com", JwtSigningKey = "test-key-that-is-long-enough-for-hmac-sha256-minimum" }; [Fact] public async Task AuthenticateAsync_EmptyUsername_ReturnsFailed() { var service = new LdapAuthService( Options.Create(CreateOptions()), NullLogger.Instance); var result = await service.AuthenticateAsync("", "password"); Assert.False(result.Success); Assert.Contains("Username is required", result.ErrorMessage); } [Fact] public async Task AuthenticateAsync_EmptyPassword_ReturnsFailed() { var service = new LdapAuthService( Options.Create(CreateOptions()), NullLogger.Instance); var result = await service.AuthenticateAsync("user", ""); Assert.False(result.Success); Assert.Contains("Password is required", result.ErrorMessage); } [Fact] public async Task AuthenticateAsync_InsecureLdapNotAllowed_ReturnsFailed() { var service = new LdapAuthService( Options.Create(CreateOptions(useTls: false, allowInsecure: false)), NullLogger.Instance); var result = await service.AuthenticateAsync("user", "password"); Assert.False(result.Success); Assert.Contains("Insecure LDAP", result.ErrorMessage); } [Fact] public async Task AuthenticateAsync_ConnectionFailure_ReturnsFailed() { // Point to a non-existent server — connection should fail var options = CreateOptions(); options.LdapServer = "nonexistent.invalid"; options.LdapPort = 9999; var service = new LdapAuthService( Options.Create(options), NullLogger.Instance); var result = await service.AuthenticateAsync("user", "password"); Assert.False(result.Success); } } #endregion #region WP-7: JwtTokenService Tests public class JwtTokenServiceTests { private static SecurityOptions CreateOptions() => new() { JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough", JwtExpiryMinutes = 15, IdleTimeoutMinutes = 30, JwtRefreshThresholdMinutes = 5 }; private static JwtTokenService CreateService(SecurityOptions? options = null) { return new JwtTokenService( Options.Create(options ?? CreateOptions()), NullLogger.Instance); } [Fact] public void GenerateToken_ContainsCorrectClaims() { var service = CreateService(); var token = service.GenerateToken( "John Doe", "johnd", new[] { "Admin", "Design" }, new[] { "1", "2" }); var principal = service.ValidateToken(token); Assert.NotNull(principal); Assert.Equal("John Doe", principal!.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value); Assert.Equal("johnd", principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value); var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList(); Assert.Contains("Admin", roles); Assert.Contains("Design", roles); var siteIds = principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToList(); Assert.Contains("1", siteIds); Assert.Contains("2", siteIds); Assert.NotNull(principal.FindFirst(JwtTokenService.LastActivityClaimType)); } [Fact] public void GenerateToken_NullSiteIds_NoSiteIdClaims() { var service = CreateService(); var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); var principal = service.ValidateToken(token); Assert.NotNull(principal); Assert.Empty(principal!.FindAll(JwtTokenService.SiteIdClaimType)); } [Fact] public void ValidateToken_InvalidToken_ReturnsNull() { var service = CreateService(); var result = service.ValidateToken("invalid.token.here"); Assert.Null(result); } [Fact] public void ValidateToken_WrongKey_ReturnsNull() { var service1 = CreateService(); var token = service1.GenerateToken("User", "user", new[] { "Admin" }, null); var service2 = CreateService(new SecurityOptions { JwtSigningKey = "a-completely-different-signing-key-for-hmac-sha256-validation", JwtExpiryMinutes = 15, IdleTimeoutMinutes = 30, JwtRefreshThresholdMinutes = 5 }); var result = service2.ValidateToken(token); Assert.Null(result); } [Fact] public void ValidateToken_UsesHmacSha256() { var service = CreateService(); var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); // Decode header to verify algorithm var parts = token.Split('.'); var headerJson = System.Text.Encoding.UTF8.GetString( Convert.FromBase64String(parts[0].PadRight((parts[0].Length + 3) & ~3, '='))); Assert.Contains("HS256", headerJson); } [Fact] public void ShouldRefresh_TokenNearExpiry_ReturnsTrue() { var options = CreateOptions(); options.JwtExpiryMinutes = 3; // Token expires in 3 min, threshold is 5 min var service = CreateService(options); var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); var principal = service.ValidateToken(token); Assert.True(service.ShouldRefresh(principal!)); } [Fact] public void ShouldRefresh_TokenFarFromExpiry_ReturnsFalse() { var service = CreateService(); // 15 min expiry, 5 min threshold var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); var principal = service.ValidateToken(token); Assert.False(service.ShouldRefresh(principal!)); } [Fact] public void IsIdleTimedOut_RecentActivity_ReturnsFalse() { var service = CreateService(); var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); var principal = service.ValidateToken(token); Assert.False(service.IsIdleTimedOut(principal!)); } [Fact] public void IsIdleTimedOut_NoLastActivityClaim_ReturnsTrue() { var service = CreateService(); var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(JwtTokenService.DisplayNameClaimType, "User") })); Assert.True(service.IsIdleTimedOut(principal)); } [Fact] public void RefreshToken_ReturnsNewTokenWithUpdatedClaims() { var service = CreateService(); var originalToken = service.GenerateToken("User", "user", new[] { "Admin" }, null); var principal = service.ValidateToken(originalToken); var newToken = service.RefreshToken(principal!, new[] { "Admin", "Design" }, new[] { "1" }); Assert.NotNull(newToken); var newPrincipal = service.ValidateToken(newToken!); var roles = newPrincipal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList(); Assert.Contains("Design", roles); } [Fact] public void RefreshToken_MissingClaims_ReturnsNull() { var service = CreateService(); var principal = new ClaimsPrincipal(new ClaimsIdentity()); var result = service.RefreshToken(principal, new[] { "Admin" }, null); Assert.Null(result); } } #endregion #region WP-8: RoleMapper Tests public class RoleMapperTests : IDisposable { private readonly ScadaLinkDbContext _context; private readonly SecurityRepository _securityRepo; private readonly RoleMapper _roleMapper; public RoleMapperTests() { var options = new DbContextOptionsBuilder() .UseSqlite("DataSource=:memory:") .Options; _context = new ScadaLinkDbContext(options); _context.Database.OpenConnection(); _context.Database.EnsureCreated(); _securityRepo = new SecurityRepository(_context); _roleMapper = new RoleMapper(_securityRepo); } public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } [Fact] public async Task MapGroupsToRoles_MultiRoleExtraction() { // Add mappings (note: seed data adds SCADA-Admins -> Admin) _context.LdapGroupMappings.Add(new LdapGroupMapping("Designers", "Design")); _context.LdapGroupMappings.Add(new LdapGroupMapping("Deployers", "Deployment")); await _context.SaveChangesAsync(); var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SCADA-Admins", "Designers" }); Assert.Contains("Admin", result.Roles); Assert.Contains("Design", result.Roles); Assert.DoesNotContain("Deployment", result.Roles); } [Fact] public async Task MapGroupsToRoles_SiteScopedDeployment() { var site1 = new Site("Site1", "S-001"); var site2 = new Site("Site2", "S-002"); _context.Sites.AddRange(site1, site2); _context.LdapGroupMappings.Add(new LdapGroupMapping("SiteDeployers", "Deployment")); await _context.SaveChangesAsync(); var mapping = await _context.LdapGroupMappings.SingleAsync(m => m.LdapGroupName == "SiteDeployers"); _context.SiteScopeRules.AddRange( new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site1.Id }, new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site2.Id }); await _context.SaveChangesAsync(); var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SiteDeployers" }); Assert.Contains("Deployment", result.Roles); Assert.False(result.IsSystemWideDeployment); Assert.Contains(site1.Id.ToString(), result.PermittedSiteIds); Assert.Contains(site2.Id.ToString(), result.PermittedSiteIds); } [Fact] public async Task MapGroupsToRoles_SystemWideDeployment_NoScopeRules() { _context.LdapGroupMappings.Add(new LdapGroupMapping("GlobalDeployers", "Deployment")); await _context.SaveChangesAsync(); var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "GlobalDeployers" }); Assert.Contains("Deployment", result.Roles); Assert.True(result.IsSystemWideDeployment); Assert.Empty(result.PermittedSiteIds); } [Fact] public async Task MapGroupsToRoles_UnrecognizedGroups_Ignored() { var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "NonExistentGroup", "AnotherRandom" }); Assert.Empty(result.Roles); Assert.Empty(result.PermittedSiteIds); Assert.False(result.IsSystemWideDeployment); } [Fact] public async Task MapGroupsToRoles_NoMatchingGroups_NoRoles() { var result = await _roleMapper.MapGroupsToRolesAsync(Array.Empty()); Assert.Empty(result.Roles); } [Fact] public async Task MapGroupsToRoles_CaseInsensitiveGroupMatch() { // "SCADA-Admins" is seeded var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "scada-admins" }); Assert.Contains("Admin", result.Roles); } } #endregion #region WP-9: Authorization Policy Tests public class AuthorizationPolicyTests { [Fact] public async Task AdminPolicy_AdminRole_Succeeds() { var principal = CreatePrincipal(new[] { "Admin" }); var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal); Assert.True(result); } [Fact] public async Task AdminPolicy_DesignRole_Fails() { var principal = CreatePrincipal(new[] { "Design" }); var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal); Assert.False(result); } [Fact] public async Task DesignPolicy_DesignRole_Succeeds() { var principal = CreatePrincipal(new[] { "Design" }); var result = await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal); Assert.True(result); } [Fact] public async Task DeploymentPolicy_DeploymentRole_Succeeds() { var principal = CreatePrincipal(new[] { "Deployment" }); var result = await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal); Assert.True(result); } [Fact] public async Task NoRoles_DeniedAll() { var principal = CreatePrincipal(Array.Empty()); Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal)); Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal)); Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal)); } [Fact] public async Task SiteScope_SystemWideDeployer_Succeeds() { var handler = new SiteScopeAuthorizationHandler(); var claims = new List { new(JwtTokenService.RoleClaimType, "Deployment") // No SiteId claims = system-wide }; var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); var requirement = new SiteScopeRequirement("42"); var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null); await handler.HandleAsync(context); Assert.True(context.HasSucceeded); } [Fact] public async Task SiteScope_PermittedSite_Succeeds() { var handler = new SiteScopeAuthorizationHandler(); var claims = new List { new(JwtTokenService.RoleClaimType, "Deployment"), new(JwtTokenService.SiteIdClaimType, "1"), new(JwtTokenService.SiteIdClaimType, "2") }; var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); var requirement = new SiteScopeRequirement("1"); var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null); await handler.HandleAsync(context); Assert.True(context.HasSucceeded); } [Fact] public async Task SiteScope_UnpermittedSite_Fails() { var handler = new SiteScopeAuthorizationHandler(); var claims = new List { new(JwtTokenService.RoleClaimType, "Deployment"), new(JwtTokenService.SiteIdClaimType, "1") }; var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); var requirement = new SiteScopeRequirement("99"); var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null); await handler.HandleAsync(context); Assert.False(context.HasSucceeded); } [Fact] public async Task SiteScope_NoDeploymentRole_Fails() { var handler = new SiteScopeAuthorizationHandler(); var claims = new List { new(JwtTokenService.RoleClaimType, "Admin") }; var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); var requirement = new SiteScopeRequirement("1"); var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null); await handler.HandleAsync(context); Assert.False(context.HasSucceeded); } private static ClaimsPrincipal CreatePrincipal(string[] roles, string[]? siteIds = null) { var claims = new List(); foreach (var role in roles) claims.Add(new Claim(JwtTokenService.RoleClaimType, role)); if (siteIds != null) foreach (var siteId in siteIds) claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId)); return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); } private static async Task EvaluatePolicy(string policyName, ClaimsPrincipal principal) { var services = new ServiceCollection(); services.AddScadaLinkAuthorization(); services.AddLogging(); using var provider = services.BuildServiceProvider(); var authService = provider.GetRequiredService(); var result = await authService.AuthorizeAsync(principal, null, policyName); return result.Succeeded; } } #endregion