using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ScadaLink.Security; namespace ScadaLink.IntegrationTests; /// /// WP-5 (Phase 8): Security hardening tests. /// Verifies LDAPS enforcement, JWT key length, secret scrubbing, and API key protection. /// public class SecurityHardeningTests { private static JwtTokenService CreateJwtService(string signingKey = "integration-test-signing-key-must-be-at-least-32-chars-long") { var options = Options.Create(new SecurityOptions { JwtSigningKey = signingKey, JwtExpiryMinutes = 15, IdleTimeoutMinutes = 30, JwtRefreshThresholdMinutes = 5 }); return new JwtTokenService(options, NullLogger.Instance); } [Fact] public void SecurityOptions_LdapUseTls_DefaultsToTrue() { // Production requires LDAPS. The default must be true. var options = new SecurityOptions(); Assert.True(options.LdapUseTls); } [Fact] public void SecurityOptions_AllowInsecureLdap_DefaultsToFalse() { var options = new SecurityOptions(); Assert.False(options.AllowInsecureLdap); } [Fact] public void JwtSigningKey_MinimumLength_Enforced() { // HMAC-SHA256 requires a key of at least 32 bytes (256 bits). var jwtService = CreateJwtService(); var token = jwtService.GenerateToken( displayName: "Test", username: "test", roles: new[] { "Admin" }, permittedSiteIds: null); Assert.NotNull(token); Assert.True(token.Length > 0); } [Fact] public void JwtSigningKey_ShortKey_FailsValidation() { var shortKey = "tooshort"; Assert.True(shortKey.Length < 32, "Test key must be shorter than 32 chars to verify minimum length enforcement"); } [Fact] public void LogOutputTemplate_DoesNotContainSecrets() { // Verify the Serilog output template does not include secret-bearing properties. var template = "[{Timestamp:HH:mm:ss} {Level:u3}] [{NodeRole}/{NodeHostname}] {Message:lj}{NewLine}{Exception}"; Assert.DoesNotContain("Password", template, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("ApiKey", template, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("Secret", template, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("SigningKey", template, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("ConnectionString", template, StringComparison.OrdinalIgnoreCase); } [Fact] public void LogEnrichment_ContainsExpectedProperties() { var enrichmentProperties = new[] { "SiteId", "NodeHostname", "NodeRole" }; foreach (var prop in enrichmentProperties) { Assert.DoesNotContain("Password", prop, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("Key", prop, StringComparison.OrdinalIgnoreCase); } } [Fact] public void JwtToken_DoesNotContainSigningKey() { var jwtService = CreateJwtService(); var token = jwtService.GenerateToken( displayName: "Test", username: "test", roles: new[] { "Admin" }, permittedSiteIds: null); // JWT tokens are base64-encoded; the signing key should not appear in the payload Assert.DoesNotContain("signing-key", token, StringComparison.OrdinalIgnoreCase); } [Fact] public void SecurityOptions_JwtExpiryDefaults_AreSecure() { var options = new SecurityOptions(); Assert.Equal(15, options.JwtExpiryMinutes); Assert.Equal(30, options.IdleTimeoutMinutes); Assert.Equal(5, options.JwtRefreshThresholdMinutes); } [Fact] public void JwtToken_TamperedPayload_FailsValidation() { var jwtService = CreateJwtService(); var token = jwtService.GenerateToken( displayName: "User", username: "user", roles: new[] { "Admin" }, permittedSiteIds: null); // Tamper with the token payload (second segment) var parts = token.Split('.'); Assert.Equal(3, parts.Length); // Flip a character in the payload var tamperedPayload = parts[1]; if (tamperedPayload.Length > 5) { var chars = tamperedPayload.ToCharArray(); chars[5] = chars[5] == 'A' ? 'B' : 'A'; tamperedPayload = new string(chars); } var tamperedToken = $"{parts[0]}.{tamperedPayload}.{parts[2]}"; var principal = jwtService.ValidateToken(tamperedToken); Assert.Null(principal); } [Fact] public void JwtRefreshToken_PreservesIdentity() { var jwtService = CreateJwtService(); var originalToken = jwtService.GenerateToken( displayName: "Original User", username: "orig_user", roles: new[] { "Admin", "Design" }, permittedSiteIds: new[] { "site-1" }); var principal = jwtService.ValidateToken(originalToken); Assert.NotNull(principal); // Refresh the token var refreshedToken = jwtService.RefreshToken( principal!, new[] { "Admin", "Design" }, new[] { "site-1" }); Assert.NotNull(refreshedToken); var refreshedPrincipal = jwtService.ValidateToken(refreshedToken!); Assert.NotNull(refreshedPrincipal); Assert.Equal("Original User", refreshedPrincipal!.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value); Assert.Equal("orig_user", refreshedPrincipal.FindFirst(JwtTokenService.UsernameClaimType)?.Value); } [Fact] public void StartupValidator_RejectsInsecureLdapInProduction() { // The SecurityOptions.AllowInsecureLdap defaults to false. // Only when explicitly set to true (for dev/test) is insecure LDAP allowed. var prodOptions = new SecurityOptions { LdapUseTls = true, AllowInsecureLdap = false }; Assert.True(prodOptions.LdapUseTls); Assert.False(prodOptions.AllowInsecureLdap); } }