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 Code Review Regression Tests /// /// Regression tests for Security-001 (StartTLS dead code), Security-002 (cookie not /// marked Secure), and Security-003 (JWT signing key length never validated). /// public class SecurityReviewRegressionTests { // --- Security-003: JWT signing key length validation --- private static SecurityOptions JwtOptions(string signingKey) => new() { JwtSigningKey = signingKey, JwtExpiryMinutes = 15, IdleTimeoutMinutes = 30, JwtRefreshThresholdMinutes = 5 }; [Fact] public void JwtTokenService_EmptySigningKey_ThrowsAtConstruction() { var ex = Assert.Throws(() => new JwtTokenService(Options.Create(JwtOptions("")), NullLogger.Instance)); Assert.Contains("JwtSigningKey", ex.Message); } [Fact] public void JwtTokenService_ShortSigningKey_ThrowsAtConstruction() { // 31 bytes — one short of the 256-bit HMAC-SHA256 minimum. var shortKey = new string('k', 31); var ex = Assert.Throws(() => new JwtTokenService(Options.Create(JwtOptions(shortKey)), NullLogger.Instance)); Assert.Contains("32", ex.Message); } [Fact] public void JwtTokenService_AdequateSigningKey_ConstructsSuccessfully() { var key = new string('k', 32); var service = new JwtTokenService(Options.Create(JwtOptions(key)), NullLogger.Instance); var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); Assert.False(string.IsNullOrEmpty(token)); } // --- Security-002: authentication cookie must be marked Secure --- [Fact] public void AddSecurity_AuthCookie_IsMarkedSecureAlways() { var services = new ServiceCollection(); services.AddLogging(); services.AddDataProtection(); services.AddSecurity(); using var provider = services.BuildServiceProvider(); var cookieOptions = provider .GetRequiredService>() .Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme); Assert.Equal( Microsoft.AspNetCore.Http.CookieSecurePolicy.Always, cookieOptions.Cookie.SecurePolicy); Assert.True(cookieOptions.Cookie.HttpOnly); } // --- CentralUI-005: cookie auth must use a sliding session window --- // Documented policy (CLAUDE.md Security & Auth): sliding refresh with a // 30-minute idle timeout. The cookie middleware must enable SlidingExpiration // so an active session is renewed on activity and an idle session expires. [Fact] public void AddSecurity_AuthCookie_UsesSlidingExpiration() { var services = new ServiceCollection(); services.AddLogging(); services.AddDataProtection(); services.AddSecurity(); using var provider = services.BuildServiceProvider(); var cookieOptions = provider .GetRequiredService>() .Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme); Assert.True(cookieOptions.SlidingExpiration); } [Fact] public void AddSecurity_AuthCookie_ExpireTimeSpanMatchesIdleTimeout() { var services = new ServiceCollection(); services.AddLogging(); services.AddDataProtection(); services.AddSecurity(); // The idle timeout drives the cookie's expiry window. services.Configure(o => o.IdleTimeoutMinutes = 30); using var provider = services.BuildServiceProvider(); var cookieOptions = provider .GetRequiredService>() .Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme); Assert.Equal(TimeSpan.FromMinutes(30), cookieOptions.ExpireTimeSpan); } [Fact] public void AddSecurity_AuthCookie_ExpireTimeSpanIsConfigurable() { var services = new ServiceCollection(); services.AddLogging(); services.AddDataProtection(); services.AddSecurity(); services.Configure(o => o.IdleTimeoutMinutes = 45); using var provider = services.BuildServiceProvider(); var cookieOptions = provider .GetRequiredService>() .Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme); Assert.Equal(TimeSpan.FromMinutes(45), cookieOptions.ExpireTimeSpan); Assert.True(cookieOptions.SlidingExpiration); } // --- Security-001: StartTLS transport must be reachable --- [Fact] public void SecurityOptions_LdapTransport_DefaultsToLdaps() { var options = new SecurityOptions(); Assert.Equal(LdapTransport.Ldaps, options.LdapTransport); } [Fact] public async Task AuthenticateAsync_StartTlsTransport_AttemptsConnection() { // With StartTLS selected the service must not be blocked by the insecure-LDAP // guard and must reach the connection stage (which fails against a dead host). // This proves the StartTLS path is reachable rather than dead code. var options = new SecurityOptions { LdapServer = "nonexistent.invalid", LdapPort = 389, LdapTransport = LdapTransport.StartTls, LdapSearchBase = "dc=example,dc=com" }; var service = new LdapAuthService(Options.Create(options), NullLogger.Instance); var result = await service.AuthenticateAsync("user", "password"); // Connection fails (host invalid) — but crucially NOT with the insecure-LDAP message. Assert.False(result.Success); Assert.DoesNotContain("Insecure LDAP", result.ErrorMessage ?? string.Empty); } [Fact] public async Task AuthenticateAsync_NoTlsTransport_RejectedWithoutAllowInsecure() { var options = new SecurityOptions { LdapServer = "ldap.example.com", LdapPort = 389, LdapTransport = LdapTransport.None, AllowInsecureLdap = false }; var service = new LdapAuthService(Options.Create(options), NullLogger.Instance); var result = await service.AuthenticateAsync("user", "password"); Assert.False(result.Success); Assert.Contains("Insecure LDAP", result.ErrorMessage); } } #endregion #region Code Review Regression Tests — Security-004/005/006/007 /// /// Regression tests for Security-004 (uid/cn attribute mismatch between search filter /// and fallback DN), Security-005 (DN injection in the no-service-account fallback), /// Security-006 (JWT issuer/audience checks disabled), and Security-007 (idle-timeout /// claim reset on every token refresh). /// public class SecurityReviewRegressionTests2 { private static SecurityOptions JwtOptions() => new() { JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough", JwtExpiryMinutes = 15, IdleTimeoutMinutes = 30, JwtRefreshThresholdMinutes = 5 }; private static JwtTokenService CreateJwtService(SecurityOptions? options = null) => new(Options.Create(options ?? JwtOptions()), NullLogger.Instance); // --- Security-004: search filter and fallback DN must use the same attribute --- [Fact] public void BuildFallbackUserDn_UsesConfiguredUserIdAttribute() { // The default user-id attribute is "uid"; the fallback DN must use it, // not a hard-coded "cn", so search-then-bind and direct-bind are interchangeable. var dn = LdapAuthService.BuildFallbackUserDn("alice", "dc=example,dc=com", "uid"); Assert.Equal("uid=alice,dc=example,dc=com", dn); } [Fact] public void BuildFallbackUserDn_HonoursNonDefaultUserIdAttribute() { var dn = LdapAuthService.BuildFallbackUserDn("alice", "dc=example,dc=com", "sAMAccountName"); Assert.Equal("sAMAccountName=alice,dc=example,dc=com", dn); } [Fact] public void SecurityOptions_LdapUserIdAttribute_DefaultsToUid() { Assert.Equal("uid", new SecurityOptions().LdapUserIdAttribute); } // --- Security-005: DN-component escaping must be applied to the username --- [Fact] public void BuildFallbackUserDn_EscapesDnMetacharacters() { // A hostile username must not be able to alter the DN structure: the comma // that would otherwise start a new RDN ("ou=admins") must be escaped so the // whole string remains a single RDN value. var dn = LdapAuthService.BuildFallbackUserDn("victim,ou=admins", "dc=example,dc=com", "uid"); Assert.Equal(@"uid=victim\,ou=admins,dc=example,dc=com", dn); // The comma from the username is backslash-escaped, so it does not act as an // RDN separator: the only unescaped comma is the one joining RDN and base DN. Assert.Contains(@"victim\,ou=admins", dn); } [Fact] public void EscapeLdapDn_EscapesAllRfc4514Specials() { var escaped = LdapAuthService.EscapeLdapDn("a,b+c\"d\\eg;h"); Assert.Equal(@"a\,b\+c\""d\\e\g\;h", escaped); } [Fact] public void EscapeLdapDn_EscapesLeadingAndTrailingSpaces() { Assert.Equal(@"\ x \ ", LdapAuthService.EscapeLdapDn(" x ")); } // --- Security-006: JWT issuer/audience must be bound and validated --- [Fact] public void GenerateToken_SetsIssuerAndAudience() { var service = CreateJwtService(); var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); var jwt = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().ReadJwtToken(token); Assert.Equal(JwtTokenService.TokenIssuer, jwt.Issuer); Assert.Contains(JwtTokenService.TokenAudience, jwt.Audiences); } [Fact] public void ValidateToken_RejectsTokenWithWrongIssuer() { // A token signed with the same key but a foreign issuer must be rejected. var options = JwtOptions(); var key = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey( System.Text.Encoding.UTF8.GetBytes(options.JwtSigningKey)); var creds = new Microsoft.IdentityModel.Tokens.SigningCredentials( key, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha256); var foreign = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken( issuer: "some-other-system", audience: JwtTokenService.TokenAudience, claims: new[] { new Claim(JwtTokenService.UsernameClaimType, "user") }, expires: DateTime.UtcNow.AddMinutes(10), signingCredentials: creds); var foreignToken = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().WriteToken(foreign); var service = CreateJwtService(options); Assert.Null(service.ValidateToken(foreignToken)); } [Fact] public void ValidateToken_AcceptsOwnIssuerAndAudience() { var service = CreateJwtService(); var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); Assert.NotNull(service.ValidateToken(token)); } // --- Security-007: refresh must preserve the original LastActivity timestamp --- [Fact] public void RefreshToken_PreservesOriginalLastActivityClaim() { var service = CreateJwtService(); // Mint a token whose LastActivity is 20 minutes in the past (still inside the // 30-minute idle window). Refresh must NOT move it forward to "now". var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20); var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(JwtTokenService.DisplayNameClaimType, "User"), new Claim(JwtTokenService.UsernameClaimType, "user"), new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o")) }, "test")); var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null); Assert.NotNull(refreshed); var refreshedPrincipal = service.ValidateToken(refreshed!); Assert.NotNull(refreshedPrincipal); var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType); Assert.NotNull(claim); Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried)); // The carried timestamp must equal the original, not "now". Assert.True(Math.Abs((carried - staleActivity).TotalSeconds) < 2, $"LastActivity was reset on refresh: expected ~{staleActivity:o}, got {carried:o}"); } [Fact] public void RefreshToken_DoesNotResetIdleTimeoutWhenUserIsActuallyIdle() { // A user idle for 25 of the 30-minute window: a refresh fired by some background // request must not make IsIdleTimedOut flip back to false forever. var service = CreateJwtService(); var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-25); var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(JwtTokenService.DisplayNameClaimType, "User"), new Claim(JwtTokenService.UsernameClaimType, "user"), new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o")) }, "test")); var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null); var refreshedPrincipal = service.ValidateToken(refreshed!); // Still 25 min idle after refresh — not reset to 0. Assert.False(service.IsIdleTimedOut(refreshedPrincipal!)); // 25 < 30, still valid var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType); Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried)); Assert.True((DateTimeOffset.UtcNow - carried).TotalMinutes > 20, "Refresh wrongly reset the idle clock to ~now"); } [Fact] public void RecordActivity_UpdatesLastActivityToNow() { // Genuine user activity (a real request) — distinct from a token refresh — // updates LastActivity to the current time. var service = CreateJwtService(); var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20); var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(JwtTokenService.DisplayNameClaimType, "User"), new Claim(JwtTokenService.UsernameClaimType, "user"), new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o")) }, "test")); var touched = service.RecordActivity(principal, new[] { "Admin" }, null); Assert.NotNull(touched); var touchedPrincipal = service.ValidateToken(touched!); var claim = touchedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType); Assert.True(DateTimeOffset.TryParse(claim!.Value, out var updated)); Assert.True((DateTimeOffset.UtcNow - updated).TotalSeconds < 5, "RecordActivity should set LastActivity to ~now"); } } #endregion #region Code Review Regression Tests — Security-009/011 /// /// Regression tests for Security-009 (no LDAP connection timeout — a hung server can /// pin a thread-pool thread indefinitely because ct only guards work-item /// scheduling) and the remaining Security-011 coverage gaps. /// public class SecurityReviewRegressionTests3 { // --- Security-009: LDAP connection timeout must be configurable and bounded --- [Fact] public void SecurityOptions_LdapConnectionTimeout_HasSaneDefault() { var options = new SecurityOptions(); // A positive, finite default so a hung LDAP server cannot pin a thread forever. Assert.True(options.LdapConnectionTimeoutMs > 0); Assert.True(options.LdapConnectionTimeoutMs <= 60_000, "Default LDAP connection timeout should be bounded (<= 60s)."); } [Fact] public async Task AuthenticateAsync_UnreachableHost_FailsWithinConfiguredTimeout() { // A routable-but-non-responsive address would otherwise hang for the OS default // (often minutes). With LdapConnectionTimeoutMs applied to the LdapConnection the // call must give up promptly. 198.51.100.0/24 (TEST-NET-2, RFC 5737) is reserved // and not routed, so the connect attempt stalls until the timeout fires. var options = new SecurityOptions { LdapServer = "198.51.100.1", LdapPort = 389, LdapTransport = LdapTransport.None, AllowInsecureLdap = true, LdapSearchBase = "dc=example,dc=com", LdapConnectionTimeoutMs = 2_000 }; var service = new LdapAuthService(Options.Create(options), NullLogger.Instance); var sw = System.Diagnostics.Stopwatch.StartNew(); var result = await service.AuthenticateAsync("user", "password"); sw.Stop(); Assert.False(result.Success); // Generous ceiling: the 2s timeout plus scheduling/CI overhead, far below the // multi-minute OS default that an unconfigured connection would incur. Assert.True(sw.Elapsed < TimeSpan.FromSeconds(30), $"Authentication did not honour the LDAP connection timeout: took {sw.Elapsed}."); } // --- Security-011: additional coverage for the no-service-account / DN paths --- [Fact] public void BuildFallbackUserDn_NoSearchBase_ReturnsBareRdn() { var dn = LdapAuthService.BuildFallbackUserDn("alice", "", "uid"); Assert.Equal("uid=alice", dn); } [Fact] public void EscapeLdapDn_LeadingHash_IsEscaped() { Assert.Equal(@"\#admin", LdapAuthService.EscapeLdapDn("#admin")); } [Fact] public void EscapeLdapDn_NullOrEmpty_ReturnedUnchanged() { Assert.Equal("", LdapAuthService.EscapeLdapDn("")); Assert.Null(LdapAuthService.EscapeLdapDn(null!)); } } #endregion #region Code Review Regression Tests — Security-012/013/014/015 /// /// Regression tests for Security-012 (a partial LDAP outage during login — bind OK but /// group search failing — silently yields a roleless authenticated session), /// Security-013 (ExtractFirstRdnValue mis-parses group DNs containing an escaped /// comma), Security-014 (RefreshToken re-issues a token without checking the idle /// timeout, so an idle-expired session can be renewed indefinitely), and Security-015 /// (a username with leading/trailing whitespace is not trimmed before use). /// public class SecurityReviewRegressionTests4 { private static SecurityOptions JwtOptions() => new() { JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough", JwtExpiryMinutes = 15, IdleTimeoutMinutes = 30, JwtRefreshThresholdMinutes = 5 }; private static JwtTokenService CreateJwtService(SecurityOptions? options = null) => new(Options.Create(options ?? JwtOptions()), NullLogger.Instance); // --- Security-014: RefreshToken must reject an idle-expired principal --- [Fact] public void RefreshToken_IdleExpiredPrincipal_ReturnsNull() { // A user idle for 31 minutes (past the 30-minute idle window). Even though the // DisplayName/Username claims are present, the refresh must be refused so an // idle-expired session cannot be renewed by a background request. var service = CreateJwtService(); var idleActivity = DateTimeOffset.UtcNow.AddMinutes(-31); var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(JwtTokenService.DisplayNameClaimType, "User"), new Claim(JwtTokenService.UsernameClaimType, "user"), new Claim(JwtTokenService.LastActivityClaimType, idleActivity.ToString("o")) }, "test")); var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null); Assert.Null(refreshed); } [Fact] public void RefreshToken_ActiveSessionWithinIdleWindow_StillRefreshes() { // A user idle for 10 minutes (well inside the 30-minute window): refresh must // still succeed — the idle-timeout guard must not break the normal sliding path. var service = CreateJwtService(); var recentActivity = DateTimeOffset.UtcNow.AddMinutes(-10); var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(JwtTokenService.DisplayNameClaimType, "User"), new Claim(JwtTokenService.UsernameClaimType, "user"), new Claim(JwtTokenService.LastActivityClaimType, recentActivity.ToString("o")) }, "test")); var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null); Assert.NotNull(refreshed); } [Fact] public void RefreshToken_MissingLastActivityClaim_ReturnsNull() { // No LastActivity claim — IsIdleTimedOut treats this as timed out, so the // refresh must be refused rather than minting a fresh session out of nothing. var service = CreateJwtService(); var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(JwtTokenService.DisplayNameClaimType, "User"), new Claim(JwtTokenService.UsernameClaimType, "user") }, "test")); var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null); Assert.Null(refreshed); } // --- Security-013: ExtractFirstRdnValue must honour RFC 4514 escaped commas --- [Fact] public void ExtractFirstRdnValue_EscapedComma_KeepsWholeGroupName() { // A CN that legitimately contains a comma is RFC 4514 backslash-escaped in the // memberOf DN. The extracted group name must be the full unescaped CN value, // not the fragment before the escaped comma. var name = LdapAuthService.ExtractFirstRdnValue( @"cn=Acme\, Inc Operators,ou=groups,dc=example,dc=com"); Assert.Equal("Acme, Inc Operators", name); } [Fact] public void ExtractFirstRdnValue_PlainDn_ReturnsFirstRdnValue() { var name = LdapAuthService.ExtractFirstRdnValue("ou=SCADA-Admins,ou=groups,dc=example,dc=com"); Assert.Equal("SCADA-Admins", name); } [Fact] public void ExtractFirstRdnValue_SingleRdn_ReturnsValue() { var name = LdapAuthService.ExtractFirstRdnValue("cn=Operators"); Assert.Equal("Operators", name); } [Fact] public void ExtractFirstRdnValue_EscapedSpecials_AreUnescaped() { // RFC 4514 escape sequences (escaped '+', '"', '\\') must be unescaped in the // returned value so it matches the configured LdapGroupName verbatim. var name = LdapAuthService.ExtractFirstRdnValue( @"cn=A\+B\\C,ou=groups,dc=example,dc=com"); Assert.Equal(@"A+B\C", name); } // --- Security-015: username must be trimmed before use --- [Fact] public void BuildFallbackUserDn_TrimmedUsername_NoLeadingTrailingSpace() { // The whitespace-edge escaping in EscapeLdapDn only fires when whitespace is NOT // trimmed. AuthenticateAsync trims first; this asserts the trimmed value yields // a clean DN with no escaped edge spaces. var dn = LdapAuthService.BuildFallbackUserDn("alice".Trim(), "dc=example,dc=com", "uid"); Assert.Equal("uid=alice,dc=example,dc=com", dn); } [Fact] public async Task AuthenticateAsync_UsernameWithSurroundingWhitespace_StillRejectedForInsecure() { // Sanity guard: a padded but otherwise-valid username is not rejected by the // IsNullOrWhiteSpace guard — it passes through to the (here, insecure-LDAP) path. var options = new SecurityOptions { LdapServer = "ldap.example.com", LdapPort = 389, LdapTransport = LdapTransport.None, AllowInsecureLdap = false }; var service = new LdapAuthService(Options.Create(options), NullLogger.Instance); var result = await service.AuthenticateAsync(" alice ", "password"); // Reaches the insecure-LDAP guard (not the empty-username guard) — proves the // padded username is treated as a real, non-empty username. Assert.False(result.Success); Assert.Contains("Insecure LDAP", result.ErrorMessage); } [Fact] public void NormalizeUsername_TrimsLeadingAndTrailingWhitespace() { Assert.Equal("alice", LdapAuthService.NormalizeUsername(" alice ")); Assert.Equal("alice", LdapAuthService.NormalizeUsername("alice")); Assert.Equal("alice", LdapAuthService.NormalizeUsername("\talice\n")); } } #endregion #region Code Review Regression Tests — Security-012 (partial LDAP outage) /// /// Regression tests for Security-012: a partial LDAP outage during login (the user bind /// succeeds but the subsequent group/attribute search fails) must fail the login per the /// design's LDAP-failure rule, rather than returning an authenticated session with zero /// roles. These exercise the seam through a stubbed group-lookup so the bind itself can /// be treated as successful. /// public class Security012GroupLookupFailureTests { private static SecurityOptions Options() => new() { LdapServer = "ldap.example.com", LdapPort = 636, LdapTransport = LdapTransport.Ldaps, LdapSearchBase = "dc=example,dc=com" }; [Fact] public void BuildAuthResultFromGroupLookup_LookupFailed_FailsTheLogin() { // When the group lookup failed (directory partially unavailable mid-login) the // result must be a FAILED login — not a success with an empty group list. var result = LdapAuthService.BuildAuthResultFromGroupLookup( username: "alice", displayName: "Alice", groups: new List(), groupLookupSucceeded: false); Assert.False(result.Success); Assert.Null(result.Groups); Assert.False(string.IsNullOrEmpty(result.ErrorMessage)); } [Fact] public void BuildAuthResultFromGroupLookup_LookupSucceededNoGroups_IsAuthenticated() { // A genuine "user belongs to no mapped groups" outcome must remain a successful // login — it must be distinguishable from a failed lookup. var result = LdapAuthService.BuildAuthResultFromGroupLookup( username: "alice", displayName: "Alice", groups: new List(), groupLookupSucceeded: true); Assert.True(result.Success); Assert.NotNull(result.Groups); Assert.Empty(result.Groups!); } [Fact] public void BuildAuthResultFromGroupLookup_LookupSucceededWithGroups_CarriesGroups() { var result = LdapAuthService.BuildAuthResultFromGroupLookup( username: "alice", displayName: "Alice", groups: new List { "SCADA-Admins" }, groupLookupSucceeded: true); Assert.True(result.Success); Assert.Equal(new[] { "SCADA-Admins" }, result.Groups); } } #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