Files
git.stella-ops.org/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/Security/WebhookSecurityServiceTests.cs
master e950474a77
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
up
2025-11-27 15:16:31 +02:00

369 lines
11 KiB
C#

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<InMemoryWebhookSecurityService>.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);
}
}