using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Extensions.Time.Testing; using StellaOps.Notifier.Worker.Security; namespace StellaOps.Notifier.Tests.Security; public class WebhookSecurityServiceTests { private readonly FakeTimeProvider _timeProvider; private readonly WebhookSecurityOptions _options; private readonly InMemoryWebhookSecurityService _webhookService; public WebhookSecurityServiceTests() { _timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); _options = new WebhookSecurityOptions { DefaultAlgorithm = "SHA256", EnableReplayProtection = true, NonceCacheExpiry = TimeSpan.FromMinutes(10) }; _webhookService = new InMemoryWebhookSecurityService( Options.Create(_options), _timeProvider, NullLogger.Instance); } [Fact] public async Task ValidateAsync_NoConfig_ReturnsValidWithWarning() { // Arrange var request = new WebhookValidationRequest { TenantId = "tenant1", ChannelId = "channel1", Body = "{\"test\": \"data\"}" }; // Act var result = await _webhookService.ValidateAsync(request); // Assert Assert.True(result.IsValid); Assert.Contains(result.Warnings, w => w.Contains("No webhook security configuration")); } [Fact] public async Task ValidateAsync_ValidSignature_ReturnsValid() { // Arrange var config = new WebhookSecurityConfig { ConfigId = "config-001", TenantId = "tenant1", ChannelId = "channel1", SecretKey = "test-secret-key", Algorithm = "SHA256", RequireSignature = true }; await _webhookService.RegisterWebhookAsync(config); var body = "{\"test\": \"data\"}"; var signature = _webhookService.GenerateSignature(body, config.SecretKey); var request = new WebhookValidationRequest { TenantId = "tenant1", ChannelId = "channel1", Body = body, Signature = signature }; // Act var result = await _webhookService.ValidateAsync(request); // Assert Assert.True(result.IsValid); Assert.True(result.PassedChecks.HasFlag(WebhookValidationChecks.SignatureValid)); } [Fact] public async Task ValidateAsync_InvalidSignature_ReturnsDenied() { // Arrange var config = new WebhookSecurityConfig { ConfigId = "config-001", TenantId = "tenant1", ChannelId = "channel1", SecretKey = "test-secret-key", RequireSignature = true }; await _webhookService.RegisterWebhookAsync(config); var request = new WebhookValidationRequest { TenantId = "tenant1", ChannelId = "channel1", Body = "{\"test\": \"data\"}", Signature = "invalid-signature" }; // Act var result = await _webhookService.ValidateAsync(request); // Assert Assert.False(result.IsValid); Assert.True(result.FailedChecks.HasFlag(WebhookValidationChecks.SignatureValid)); } [Fact] public async Task ValidateAsync_MissingSignature_ReturnsDenied() { // Arrange var config = new WebhookSecurityConfig { ConfigId = "config-001", TenantId = "tenant1", ChannelId = "channel1", SecretKey = "test-secret-key", RequireSignature = true }; await _webhookService.RegisterWebhookAsync(config); var request = new WebhookValidationRequest { TenantId = "tenant1", ChannelId = "channel1", Body = "{\"test\": \"data\"}" }; // Act var result = await _webhookService.ValidateAsync(request); // Assert Assert.False(result.IsValid); Assert.Contains(result.Errors, e => e.Contains("Missing signature")); } [Fact] public async Task ValidateAsync_IpNotInAllowlist_ReturnsDenied() { // Arrange var config = new WebhookSecurityConfig { ConfigId = "config-001", TenantId = "tenant1", ChannelId = "channel1", SecretKey = "test-secret-key", RequireSignature = false, EnforceIpAllowlist = true, AllowedIps = ["192.168.1.0/24"] }; await _webhookService.RegisterWebhookAsync(config); var request = new WebhookValidationRequest { TenantId = "tenant1", ChannelId = "channel1", Body = "{\"test\": \"data\"}", SourceIp = "10.0.0.1" }; // Act var result = await _webhookService.ValidateAsync(request); // Assert Assert.False(result.IsValid); Assert.True(result.FailedChecks.HasFlag(WebhookValidationChecks.IpAllowed)); } [Fact] public async Task ValidateAsync_IpInAllowlist_ReturnsValid() { // Arrange var config = new WebhookSecurityConfig { ConfigId = "config-001", TenantId = "tenant1", ChannelId = "channel1", SecretKey = "test-secret-key", RequireSignature = false, EnforceIpAllowlist = true, AllowedIps = ["192.168.1.0/24"] }; await _webhookService.RegisterWebhookAsync(config); var request = new WebhookValidationRequest { TenantId = "tenant1", ChannelId = "channel1", Body = "{\"test\": \"data\"}", SourceIp = "192.168.1.100" }; // Act var result = await _webhookService.ValidateAsync(request); // Assert Assert.True(result.IsValid); Assert.True(result.PassedChecks.HasFlag(WebhookValidationChecks.IpAllowed)); } [Fact] public async Task ValidateAsync_ExpiredTimestamp_ReturnsDenied() { // Arrange var config = new WebhookSecurityConfig { ConfigId = "config-001", TenantId = "tenant1", ChannelId = "channel1", SecretKey = "test-secret-key", RequireSignature = false, MaxRequestAge = TimeSpan.FromMinutes(5) }; await _webhookService.RegisterWebhookAsync(config); var request = new WebhookValidationRequest { TenantId = "tenant1", ChannelId = "channel1", Body = "{\"test\": \"data\"}", Timestamp = _timeProvider.GetUtcNow().AddMinutes(-10) }; // Act var result = await _webhookService.ValidateAsync(request); // Assert Assert.False(result.IsValid); Assert.True(result.FailedChecks.HasFlag(WebhookValidationChecks.NotExpired)); } [Fact] public async Task ValidateAsync_ReplayAttack_ReturnsDenied() { // Arrange var config = new WebhookSecurityConfig { ConfigId = "config-001", TenantId = "tenant1", ChannelId = "channel1", SecretKey = "test-secret-key", RequireSignature = true }; await _webhookService.RegisterWebhookAsync(config); var body = "{\"test\": \"data\"}"; var signature = _webhookService.GenerateSignature(body, config.SecretKey); var request = new WebhookValidationRequest { TenantId = "tenant1", ChannelId = "channel1", Body = body, Signature = signature, Timestamp = _timeProvider.GetUtcNow() }; // First request should succeed var result1 = await _webhookService.ValidateAsync(request); Assert.True(result1.IsValid); // Act - second request with same signature should fail var result2 = await _webhookService.ValidateAsync(request); // Assert Assert.False(result2.IsValid); Assert.True(result2.FailedChecks.HasFlag(WebhookValidationChecks.NotReplay)); } [Fact] public void GenerateSignature_ProducesConsistentOutput() { // Arrange var payload = "{\"test\": \"data\"}"; var secretKey = "test-secret"; // Act var sig1 = _webhookService.GenerateSignature(payload, secretKey); var sig2 = _webhookService.GenerateSignature(payload, secretKey); // Assert Assert.Equal(sig1, sig2); } [Fact] public async Task UpdateAllowlistAsync_UpdatesConfig() { // Arrange var config = new WebhookSecurityConfig { ConfigId = "config-001", TenantId = "tenant1", ChannelId = "channel1", SecretKey = "test-secret-key", EnforceIpAllowlist = true, AllowedIps = ["192.168.1.0/24"] }; await _webhookService.RegisterWebhookAsync(config); // Act await _webhookService.UpdateAllowlistAsync( "tenant1", "channel1", ["10.0.0.0/8"], "admin"); // Assert var updatedConfig = await _webhookService.GetConfigAsync("tenant1", "channel1"); Assert.NotNull(updatedConfig); Assert.Single(updatedConfig.AllowedIps); Assert.Equal("10.0.0.0/8", updatedConfig.AllowedIps[0]); } [Fact] public async Task IsIpAllowedAsync_NoConfig_ReturnsTrue() { // Act var allowed = await _webhookService.IsIpAllowedAsync("tenant1", "channel1", "192.168.1.1"); // Assert Assert.True(allowed); } [Fact] public async Task IsIpAllowedAsync_CidrMatch_ReturnsTrue() { // Arrange var config = new WebhookSecurityConfig { ConfigId = "config-001", TenantId = "tenant1", ChannelId = "channel1", SecretKey = "test-secret-key", EnforceIpAllowlist = true, AllowedIps = ["192.168.1.0/24"] }; await _webhookService.RegisterWebhookAsync(config); // Act var allowed = await _webhookService.IsIpAllowedAsync("tenant1", "channel1", "192.168.1.50"); // Assert Assert.True(allowed); } [Fact] public async Task IsIpAllowedAsync_ExactMatch_ReturnsTrue() { // Arrange var config = new WebhookSecurityConfig { ConfigId = "config-001", TenantId = "tenant1", ChannelId = "channel1", SecretKey = "test-secret-key", EnforceIpAllowlist = true, AllowedIps = ["192.168.1.100"] }; await _webhookService.RegisterWebhookAsync(config); // Act var allowed = await _webhookService.IsIpAllowedAsync("tenant1", "channel1", "192.168.1.100"); // Assert Assert.True(allowed); } }