using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.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 LdapOptions_Transport_DefaultsToLdaps() { // Production requires encrypted transport. The shared LdapOptions defaults to // LDAPS (secure-by-default), preserving the donor's LdapUseTls=true default. var options = new LdapOptions(); Assert.Equal(LdapTransport.Ldaps, options.Transport); } [Fact] public void LdapOptions_AllowInsecure_DefaultsToFalse() { var options = new LdapOptions(); Assert.False(options.AllowInsecure); } [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[] { "Administrator" }, 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[] { "Administrator" }, 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[] { "Administrator" }, 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[] { "Administrator", "Designer" }, permittedSiteIds: new[] { "site-1" }); var principal = jwtService.ValidateToken(originalToken); Assert.NotNull(principal); // Refresh the token var refreshedToken = jwtService.RefreshToken( principal!, new[] { "Administrator", "Designer" }, 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 shared LdapOptionsValidator (registered with ValidateOnStart by AddZbLdapAuth) // rejects plaintext transport (Transport=None) unless AllowInsecure is explicitly set, // preserving the donor's production LDAPS-enforcement guarantee. var insecure = new LdapOptions { Server = "ldap.example.com", SearchBase = "dc=example,dc=com", ServiceAccountDn = "cn=admin,dc=example,dc=com", Transport = LdapTransport.None, AllowInsecure = false, }; var result = new ZB.MOM.WW.Auth.Ldap.LdapOptionsValidator().Validate(name: null, insecure); Assert.True(result.Failed); Assert.Contains(nameof(LdapOptions.AllowInsecure), result.FailureMessage); } }