php-commerce/tests/Feature/WebhookRateLimitTest.php

300 lines
11 KiB
PHP
Raw Permalink Normal View History

<?php
declare(strict_types=1);
use Illuminate\Cache\RateLimiter;
use Illuminate\Http\Request;
use Core\Mod\Commerce\Services\WebhookRateLimiter;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
// ============================================================================
// WebhookRateLimiter Unit Tests
// ============================================================================
describe('WebhookRateLimiter', function () {
beforeEach(function () {
// Clear rate limiter cache before each test
app(RateLimiter::class)->clear('webhook:stripe:ip:127.0.0.1');
app(RateLimiter::class)->clear('webhook:btcpay:ip:127.0.0.1');
app(RateLimiter::class)->clear('webhook:stripe:ip:3.18.12.63');
});
describe('basic rate limiting', function () {
it('allows requests under the limit', function () {
$limiter = app(WebhookRateLimiter::class);
$request = Request::create('/webhooks/stripe', 'POST');
$request->server->set('REMOTE_ADDR', '127.0.0.1');
// First request should be allowed
expect($limiter->tooManyAttempts($request, 'stripe'))->toBeFalse();
});
it('blocks requests over the limit', function () {
$limiter = app(WebhookRateLimiter::class);
$request = Request::create('/webhooks/stripe', 'POST');
$request->server->set('REMOTE_ADDR', '127.0.0.1');
// Use a low limit for testing
config(['commerce.webhooks.rate_limits.default' => 3]);
// Make requests up to the limit
for ($i = 0; $i < 3; $i++) {
$limiter->increment($request, 'stripe');
}
// Next request should be blocked
expect($limiter->tooManyAttempts($request, 'stripe'))->toBeTrue();
});
it('tracks attempts per gateway separately', function () {
$limiter = app(WebhookRateLimiter::class);
$request = Request::create('/webhooks/test', 'POST');
$request->server->set('REMOTE_ADDR', '127.0.0.1');
config(['commerce.webhooks.rate_limits.default' => 3]);
// Exhaust Stripe limit
for ($i = 0; $i < 3; $i++) {
$limiter->increment($request, 'stripe');
}
// Stripe should be blocked
expect($limiter->tooManyAttempts($request, 'stripe'))->toBeTrue();
// BTCPay should still be allowed (separate counter)
expect($limiter->tooManyAttempts($request, 'btcpay'))->toBeFalse();
});
it('tracks attempts per IP separately', function () {
$limiter = app(WebhookRateLimiter::class);
config(['commerce.webhooks.rate_limits.default' => 3]);
$request1 = Request::create('/webhooks/stripe', 'POST');
$request1->server->set('REMOTE_ADDR', '192.168.1.1');
$request2 = Request::create('/webhooks/stripe', 'POST');
$request2->server->set('REMOTE_ADDR', '192.168.1.2');
// Exhaust limit for IP 1
for ($i = 0; $i < 3; $i++) {
$limiter->increment($request1, 'stripe');
}
// IP 1 should be blocked
expect($limiter->tooManyAttempts($request1, 'stripe'))->toBeTrue();
// IP 2 should still be allowed
expect($limiter->tooManyAttempts($request2, 'stripe'))->toBeFalse();
});
it('reports remaining attempts correctly', function () {
$limiter = app(WebhookRateLimiter::class);
$request = Request::create('/webhooks/stripe', 'POST');
$request->server->set('REMOTE_ADDR', '127.0.0.1');
config(['commerce.webhooks.rate_limits.default' => 10]);
expect($limiter->remainingAttempts($request, 'stripe'))->toBe(10);
$limiter->increment($request, 'stripe');
expect($limiter->remainingAttempts($request, 'stripe'))->toBe(9);
$limiter->increment($request, 'stripe');
expect($limiter->remainingAttempts($request, 'stripe'))->toBe(8);
});
it('returns retry-after time when rate limited', function () {
$limiter = app(WebhookRateLimiter::class);
$request = Request::create('/webhooks/stripe', 'POST');
$request->server->set('REMOTE_ADDR', '127.0.0.1');
config(['commerce.webhooks.rate_limits.default' => 1]);
$limiter->increment($request, 'stripe');
// Should have a retry-after time
$retryAfter = $limiter->availableIn($request, 'stripe');
expect($retryAfter)->toBeGreaterThan(0)
->and($retryAfter)->toBeLessThanOrEqual(60);
});
});
describe('trusted gateway IPs', function () {
it('identifies trusted Stripe IPs', function () {
$limiter = app(WebhookRateLimiter::class);
// Configure a trusted IP
config(['commerce.webhooks.trusted_ips.stripe' => ['3.18.12.63']]);
$trustedRequest = Request::create('/webhooks/stripe', 'POST');
$trustedRequest->server->set('REMOTE_ADDR', '3.18.12.63');
$untrustedRequest = Request::create('/webhooks/stripe', 'POST');
$untrustedRequest->server->set('REMOTE_ADDR', '192.168.1.1');
expect($limiter->isTrustedGatewayIp($trustedRequest, 'stripe'))->toBeTrue();
expect($limiter->isTrustedGatewayIp($untrustedRequest, 'stripe'))->toBeFalse();
});
it('gives higher limits to trusted IPs', function () {
$limiter = app(WebhookRateLimiter::class);
config([
'commerce.webhooks.rate_limits.default' => 5,
'commerce.webhooks.rate_limits.trusted' => 100,
'commerce.webhooks.trusted_ips.stripe' => ['3.18.12.63'],
]);
$trustedRequest = Request::create('/webhooks/stripe', 'POST');
$trustedRequest->server->set('REMOTE_ADDR', '3.18.12.63');
$untrustedRequest = Request::create('/webhooks/stripe', 'POST');
$untrustedRequest->server->set('REMOTE_ADDR', '192.168.1.1');
// Untrusted IP should have 5 remaining attempts
expect($limiter->remainingAttempts($untrustedRequest, 'stripe'))->toBe(5);
// Trusted IP should have 100 remaining attempts
expect($limiter->remainingAttempts($trustedRequest, 'stripe'))->toBe(100);
});
it('supports CIDR ranges for trusted IPs', function () {
$limiter = app(WebhookRateLimiter::class);
config(['commerce.webhooks.trusted_ips.stripe' => ['10.0.0.0/24']]);
$inRangeRequest = Request::create('/webhooks/stripe', 'POST');
$inRangeRequest->server->set('REMOTE_ADDR', '10.0.0.50');
$outOfRangeRequest = Request::create('/webhooks/stripe', 'POST');
$outOfRangeRequest->server->set('REMOTE_ADDR', '10.0.1.50');
expect($limiter->isTrustedGatewayIp($inRangeRequest, 'stripe'))->toBeTrue();
expect($limiter->isTrustedGatewayIp($outOfRangeRequest, 'stripe'))->toBeFalse();
});
it('supports global trusted IPs', function () {
$limiter = app(WebhookRateLimiter::class);
config([
'commerce.webhooks.trusted_ips.global' => ['10.10.10.10'],
'commerce.webhooks.trusted_ips.stripe' => [],
'commerce.webhooks.trusted_ips.btcpay' => [],
]);
$request = Request::create('/webhooks/test', 'POST');
$request->server->set('REMOTE_ADDR', '10.10.10.10');
// Global IP should be trusted for both gateways
expect($limiter->isTrustedGatewayIp($request, 'stripe'))->toBeTrue();
expect($limiter->isTrustedGatewayIp($request, 'btcpay'))->toBeTrue();
});
});
describe('gateway-specific configuration', function () {
it('uses gateway-specific rate limits when configured', function () {
$limiter = app(WebhookRateLimiter::class);
config([
'commerce.webhooks.rate_limits.default' => 60,
'commerce.webhooks.rate_limits.stripe' => [
'default' => 30,
'trusted' => 150,
],
'commerce.webhooks.rate_limits.btcpay' => [
'default' => 40,
'trusted' => 200,
],
]);
$stripeRequest = Request::create('/webhooks/stripe', 'POST');
$stripeRequest->server->set('REMOTE_ADDR', '127.0.0.1');
$btcpayRequest = Request::create('/webhooks/btcpay', 'POST');
$btcpayRequest->server->set('REMOTE_ADDR', '127.0.0.2');
// Stripe should have 30 limit
expect($limiter->remainingAttempts($stripeRequest, 'stripe'))->toBe(30);
// BTCPay should have 40 limit
expect($limiter->remainingAttempts($btcpayRequest, 'btcpay'))->toBe(40);
});
});
});
// ============================================================================
// Webhook Controller Rate Limiting Integration Tests
// ============================================================================
describe('Webhook Controller Rate Limiting', function () {
beforeEach(function () {
// Clear rate limiters
app(RateLimiter::class)->clear('webhook:stripe:ip:127.0.0.1');
app(RateLimiter::class)->clear('webhook:btcpay:ip:127.0.0.1');
});
it('returns 429 when Stripe webhook rate limit exceeded', function () {
config(['commerce.webhooks.rate_limits.default' => 2]);
// Make requests until rate limited
for ($i = 0; $i < 2; $i++) {
$this->postJson(route('api.webhook.stripe'), [], [
'Stripe-Signature' => 'invalid',
]);
}
// Next request should be rate limited
$response = $this->postJson(route('api.webhook.stripe'), [], [
'Stripe-Signature' => 'invalid',
]);
$response->assertStatus(429);
$response->assertHeader('Retry-After');
$response->assertHeader('X-RateLimit-Remaining', '0');
});
it('returns 429 when BTCPay webhook rate limit exceeded', function () {
config(['commerce.webhooks.rate_limits.default' => 2]);
// Make requests until rate limited
for ($i = 0; $i < 2; $i++) {
$this->postJson(route('api.webhook.btcpay'), [], [
'BTCPay-Sig' => 'invalid',
]);
}
// Next request should be rate limited
$response = $this->postJson(route('api.webhook.btcpay'), [], [
'BTCPay-Sig' => 'invalid',
]);
$response->assertStatus(429);
$response->assertHeader('Retry-After');
});
it('allows requests from trusted IPs with higher limits', function () {
config([
'commerce.webhooks.rate_limits.default' => 2,
'commerce.webhooks.rate_limits.trusted' => 100,
'commerce.webhooks.trusted_ips.stripe' => ['127.0.0.1'],
]);
// Trusted IP should be able to make many requests
for ($i = 0; $i < 10; $i++) {
$response = $this->postJson(route('api.webhook.stripe'), [], [
'Stripe-Signature' => 'invalid',
]);
// Should get 401 (invalid signature) not 429 (rate limited)
$response->assertStatus(401);
}
});
});