up
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
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
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user