- Add comprehensive health check system with multiple endpoints - Add Prometheus metrics endpoint - Add production logging configurations (5 strategies) - Add complete deployment documentation suite: * QUICKSTART.md - 30-minute deployment guide * DEPLOYMENT_CHECKLIST.md - Printable verification checklist * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference * production-logging.md - Logging configuration guide * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation * README.md - Navigation hub * DEPLOYMENT_SUMMARY.md - Executive summary - Add deployment scripts and automation - Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment - Update README with production-ready features All production infrastructure is now complete and ready for deployment.
450 lines
16 KiB
PHP
450 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Unit\Framework\RateLimit;
|
|
|
|
use App\Framework\RateLimit\RateLimiter;
|
|
use App\Framework\RateLimit\RateLimitResult;
|
|
use App\Framework\RateLimit\Storage\StorageInterface;
|
|
use App\Framework\RateLimit\TimeProvider\TimeProviderInterface;
|
|
use App\Framework\RateLimit\TokenBucket;
|
|
|
|
describe('Token Bucket Rate Limiter', function () {
|
|
beforeEach(function () {
|
|
$this->currentTime = 1000;
|
|
|
|
// Create test time provider
|
|
$this->timeProvider = new class($this->currentTime) implements TimeProviderInterface {
|
|
public function __construct(private int &$time) {}
|
|
|
|
public function getCurrentTime(): int
|
|
{
|
|
return $this->time;
|
|
}
|
|
};
|
|
|
|
// Create in-memory storage
|
|
$this->storage = new class implements StorageInterface {
|
|
private array $requests = [];
|
|
private array $buckets = [];
|
|
private array $baselines = [];
|
|
|
|
public function getRequestsInWindow(string $key, int $windowStart, int $windowEnd): array
|
|
{
|
|
if (!isset($this->requests[$key])) {
|
|
return [];
|
|
}
|
|
|
|
return array_filter(
|
|
$this->requests[$key],
|
|
fn($timestamp) => $timestamp >= $windowStart && $timestamp <= $windowEnd
|
|
);
|
|
}
|
|
|
|
public function addRequest(string $key, int $timestamp, int $ttl): void
|
|
{
|
|
if (!isset($this->requests[$key])) {
|
|
$this->requests[$key] = [];
|
|
}
|
|
$this->requests[$key][] = $timestamp;
|
|
}
|
|
|
|
public function getTokenBucket(string $key): ?TokenBucket
|
|
{
|
|
return $this->buckets[$key] ?? null;
|
|
}
|
|
|
|
public function saveTokenBucket(string $key, TokenBucket $bucket): void
|
|
{
|
|
$this->buckets[$key] = $bucket;
|
|
}
|
|
|
|
public function clear(string $key): void
|
|
{
|
|
unset($this->requests[$key]);
|
|
unset($this->buckets[$key]);
|
|
unset($this->baselines[$key]);
|
|
}
|
|
|
|
public function getBaseline(string $key): ?array
|
|
{
|
|
return $this->baselines[$key] ?? null;
|
|
}
|
|
|
|
public function updateBaseline(string $key, int $rate): void
|
|
{
|
|
if (!isset($this->baselines[$key])) {
|
|
$this->baselines[$key] = [];
|
|
}
|
|
$this->baselines[$key][] = $rate;
|
|
}
|
|
};
|
|
|
|
$this->rateLimiter = new RateLimiter($this->storage, $this->timeProvider);
|
|
});
|
|
|
|
describe('TokenBucket Value Object', function () {
|
|
it('creates token bucket with capacity and tokens', function () {
|
|
$bucket = new TokenBucket(
|
|
capacity: 100,
|
|
tokens: 75,
|
|
lastRefill: 1000
|
|
);
|
|
|
|
expect($bucket->capacity)->toBe(100);
|
|
expect($bucket->tokens)->toBe(75);
|
|
expect($bucket->lastRefill)->toBe(1000);
|
|
});
|
|
|
|
it('checks if can consume tokens', function () {
|
|
$bucket = new TokenBucket(100, 75, 1000);
|
|
|
|
expect($bucket->canConsume(50))->toBeTrue();
|
|
expect($bucket->canConsume(75))->toBeTrue();
|
|
expect($bucket->canConsume(76))->toBeFalse();
|
|
expect($bucket->canConsume(100))->toBeFalse();
|
|
});
|
|
|
|
it('consumes tokens and returns new bucket', function () {
|
|
$bucket = new TokenBucket(100, 75, 1000);
|
|
$newBucket = $bucket->consume(25);
|
|
|
|
// Original bucket unchanged (immutable)
|
|
expect($bucket->tokens)->toBe(75);
|
|
|
|
// New bucket has reduced tokens
|
|
expect($newBucket->tokens)->toBe(50);
|
|
expect($newBucket->capacity)->toBe(100);
|
|
expect($newBucket->lastRefill)->toBe(1000);
|
|
});
|
|
|
|
it('throws when consuming more tokens than available', function () {
|
|
$bucket = new TokenBucket(100, 50, 1000);
|
|
|
|
expect(fn() => $bucket->consume(51))
|
|
->toThrow(\InvalidArgumentException::class, 'Not enough tokens available');
|
|
});
|
|
|
|
it('refills tokens up to capacity', function () {
|
|
$bucket = new TokenBucket(100, 25, 1000);
|
|
$refilled = $bucket->refill(50, 1050);
|
|
|
|
// Tokens refilled but capped at capacity
|
|
expect($refilled->tokens)->toBe(75);
|
|
expect($refilled->lastRefill)->toBe(1050);
|
|
});
|
|
|
|
it('caps refill at capacity', function () {
|
|
$bucket = new TokenBucket(100, 80, 1000);
|
|
$refilled = $bucket->refill(50, 1050);
|
|
|
|
// Should cap at 100, not go to 130
|
|
expect($refilled->tokens)->toBe(100);
|
|
expect($refilled->capacity)->toBe(100);
|
|
});
|
|
|
|
it('allows zero token consumption', function () {
|
|
$bucket = new TokenBucket(100, 50, 1000);
|
|
$newBucket = $bucket->consume(0);
|
|
|
|
expect($newBucket->tokens)->toBe(50);
|
|
});
|
|
|
|
it('handles full bucket consumption', function () {
|
|
$bucket = new TokenBucket(100, 100, 1000);
|
|
$empty = $bucket->consume(100);
|
|
|
|
expect($empty->tokens)->toBe(0);
|
|
expect($empty->canConsume(1))->toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('RateLimiter Token Bucket Integration', function () {
|
|
it('allows request when tokens available', function () {
|
|
$result = $this->rateLimiter->checkTokenBucket(
|
|
key: 'user:123',
|
|
capacity: 10,
|
|
refillRate: 1,
|
|
tokens: 1
|
|
);
|
|
|
|
expect($result->isAllowed())->toBeTrue();
|
|
expect($result->getLimit())->toBe(10);
|
|
expect($result->getCurrent())->toBe(9); // 10 - 1 consumed
|
|
});
|
|
|
|
it('blocks request when tokens exhausted', function () {
|
|
// Consume all tokens
|
|
for ($i = 0; $i < 10; $i++) {
|
|
$this->rateLimiter->checkTokenBucket('user:123', 10, 1, 1);
|
|
}
|
|
|
|
// Next request should be blocked
|
|
$result = $this->rateLimiter->checkTokenBucket('user:123', 10, 1, 1);
|
|
|
|
expect($result->isAllowed())->toBeFalse();
|
|
expect($result->isExceeded())->toBeTrue();
|
|
expect($result->getCurrent())->toBe(0);
|
|
expect($result->getRetryAfter())->toBeGreaterThan(0);
|
|
});
|
|
|
|
it('refills tokens over time', function () {
|
|
// Consume 5 tokens
|
|
$this->rateLimiter->checkTokenBucket('user:456', 10, 1, 5);
|
|
|
|
// Advance time by 3 seconds (refill rate: 1 token/second)
|
|
$this->currentTime += 3;
|
|
|
|
// Should have refilled 3 tokens (5 consumed - 3 refilled = 2 consumed)
|
|
$result = $this->rateLimiter->checkTokenBucket('user:456', 10, 1, 0);
|
|
|
|
expect($result->isAllowed())->toBeTrue();
|
|
expect($result->getCurrent())->toBe(8); // 10 capacity - 2 net consumed
|
|
});
|
|
|
|
it('calculates correct retry after when tokens exhausted', function () {
|
|
// Exhaust all tokens
|
|
$this->rateLimiter->checkTokenBucket('user:789', 10, 2, 10);
|
|
|
|
// Try to consume 5 more tokens
|
|
$result = $this->rateLimiter->checkTokenBucket('user:789', 10, 2, 5);
|
|
|
|
// Need 5 tokens, refill rate is 2 tokens/second
|
|
// intval(5/2) = 2 seconds (not ceil, but floor/truncate)
|
|
expect($result->getRetryAfter())->toBe(2);
|
|
});
|
|
|
|
it('allows consuming multiple tokens at once', function () {
|
|
$result = $this->rateLimiter->checkTokenBucket(
|
|
key: 'batch:user',
|
|
capacity: 100,
|
|
refillRate: 10,
|
|
tokens: 25
|
|
);
|
|
|
|
expect($result->isAllowed())->toBeTrue();
|
|
expect($result->getCurrent())->toBe(75); // 100 - 25
|
|
});
|
|
|
|
it('handles concurrent requests correctly', function () {
|
|
// First request consumes 3 tokens
|
|
$result1 = $this->rateLimiter->checkTokenBucket('user:concurrent', 10, 1, 3);
|
|
expect($result1->isAllowed())->toBeTrue();
|
|
|
|
// Second request (same time) consumes 4 tokens
|
|
$result2 = $this->rateLimiter->checkTokenBucket('user:concurrent', 10, 1, 4);
|
|
expect($result2->isAllowed())->toBeTrue();
|
|
expect($result2->getCurrent())->toBe(3); // 10 - 3 - 4 = 3
|
|
});
|
|
|
|
it('creates new bucket for first request', function () {
|
|
$result = $this->rateLimiter->checkTokenBucket('new:user', 50, 5, 1);
|
|
|
|
// First request should have full capacity minus consumed
|
|
expect($result->isAllowed())->toBeTrue();
|
|
expect($result->getCurrent())->toBe(49); // 50 - 1
|
|
});
|
|
|
|
it('respects capacity limit during refill', function () {
|
|
// Start with partial tokens
|
|
$this->rateLimiter->checkTokenBucket('user:refill', 10, 1, 8);
|
|
|
|
// Advance time significantly (should refill to capacity, not beyond)
|
|
$this->currentTime += 100;
|
|
|
|
$result = $this->rateLimiter->checkTokenBucket('user:refill', 10, 1, 1);
|
|
|
|
// Should have capacity minus current consumption, not more
|
|
expect($result->getCurrent())->toBe(9); // Capped at 10, then -1
|
|
});
|
|
|
|
it('handles zero refill rate edge case', function () {
|
|
// Consume 5 tokens
|
|
$this->rateLimiter->checkTokenBucket('user:zero', 10, 0, 5);
|
|
|
|
// Advance time
|
|
$this->currentTime += 10;
|
|
|
|
// With refill rate 0, no tokens should refill
|
|
$result = $this->rateLimiter->checkTokenBucket('user:zero', 10, 0, 0);
|
|
|
|
expect($result->getCurrent())->toBe(5); // Still only 5 tokens
|
|
});
|
|
|
|
it('calculates remaining requests correctly', function () {
|
|
$this->rateLimiter->checkTokenBucket('user:remaining', 20, 2, 7);
|
|
|
|
$result = $this->rateLimiter->checkTokenBucket('user:remaining', 20, 2, 0);
|
|
|
|
expect($result->getRemainingRequests())->toBe(7); // 20 - 13 consumed
|
|
});
|
|
});
|
|
|
|
describe('RateLimitResult', function () {
|
|
it('creates allowed result', function () {
|
|
$result = RateLimitResult::allowed(100, 75);
|
|
|
|
expect($result->isAllowed())->toBeTrue();
|
|
expect($result->isExceeded())->toBeFalse();
|
|
expect($result->getLimit())->toBe(100);
|
|
expect($result->getCurrent())->toBe(75);
|
|
expect($result->getRemainingRequests())->toBe(25);
|
|
expect($result->getRetryAfter())->toBeNull();
|
|
});
|
|
|
|
it('creates exceeded result', function () {
|
|
$result = RateLimitResult::exceeded(100, 0, 60);
|
|
|
|
expect($result->isAllowed())->toBeFalse();
|
|
expect($result->isExceeded())->toBeTrue();
|
|
expect($result->getLimit())->toBe(100);
|
|
expect($result->getCurrent())->toBe(0);
|
|
expect($result->getRemainingRequests())->toBe(100);
|
|
expect($result->getRetryAfter())->toBe(60);
|
|
});
|
|
|
|
it('handles zero remaining requests', function () {
|
|
$result = RateLimitResult::allowed(10, 10);
|
|
|
|
expect($result->getRemainingRequests())->toBe(0);
|
|
});
|
|
|
|
it('prevents negative remaining requests', function () {
|
|
$result = RateLimitResult::exceeded(10, 15, 60);
|
|
|
|
// Should never return negative
|
|
expect($result->getRemainingRequests())->toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('Edge Cases and Stress Testing', function () {
|
|
it('handles rapid successive requests', function () {
|
|
$key = 'stress:rapid';
|
|
$allowed = 0;
|
|
$blocked = 0;
|
|
|
|
// Make 100 rapid requests
|
|
for ($i = 0; $i < 100; $i++) {
|
|
$result = $this->rateLimiter->checkTokenBucket($key, 50, 10, 1);
|
|
if ($result->isAllowed()) {
|
|
$allowed++;
|
|
} else {
|
|
$blocked++;
|
|
}
|
|
}
|
|
|
|
// First 50 should be allowed, rest blocked
|
|
expect($allowed)->toBe(50);
|
|
expect($blocked)->toBe(50);
|
|
});
|
|
|
|
it('handles burst then recovery pattern', function () {
|
|
$key = 'burst:recovery';
|
|
|
|
// Burst: consume all tokens
|
|
$this->rateLimiter->checkTokenBucket($key, 20, 5, 20);
|
|
|
|
// Immediate request: should fail
|
|
$result1 = $this->rateLimiter->checkTokenBucket($key, 20, 5, 1);
|
|
expect($result1->isAllowed())->toBeFalse();
|
|
|
|
// Wait for refill (4 seconds = 20 tokens at rate 5/sec)
|
|
$this->currentTime += 4;
|
|
|
|
// Should now succeed
|
|
$result2 = $this->rateLimiter->checkTokenBucket($key, 20, 5, 1);
|
|
expect($result2->isAllowed())->toBeTrue();
|
|
});
|
|
|
|
it('handles very large capacity buckets', function () {
|
|
$result = $this->rateLimiter->checkTokenBucket(
|
|
'large:bucket',
|
|
capacity: 1000000,
|
|
refillRate: 10000,
|
|
tokens: 50000
|
|
);
|
|
|
|
expect($result->isAllowed())->toBeTrue();
|
|
expect($result->getCurrent())->toBe(950000);
|
|
});
|
|
|
|
it('handles very small refill rates', function () {
|
|
// Rate of 1 token/sec (slow but not zero to avoid division by zero)
|
|
$this->rateLimiter->checkTokenBucket('slow:refill', 10, 1, 10);
|
|
|
|
// Advance 5 seconds (should refill 5 tokens)
|
|
$this->currentTime += 5;
|
|
|
|
$result = $this->rateLimiter->checkTokenBucket('slow:refill', 10, 1, 0);
|
|
|
|
// Should have 5 tokens after refill
|
|
expect($result->getCurrent())->toBe(5);
|
|
});
|
|
|
|
it('maintains bucket state across multiple operations', function () {
|
|
$key = 'stateful:bucket';
|
|
|
|
// Operation 1: consume 3
|
|
$r1 = $this->rateLimiter->checkTokenBucket($key, 10, 1, 3);
|
|
expect($r1->getCurrent())->toBe(7);
|
|
|
|
// Operation 2: consume 2 (no time passed)
|
|
$r2 = $this->rateLimiter->checkTokenBucket($key, 10, 1, 2);
|
|
expect($r2->getCurrent())->toBe(5);
|
|
|
|
// Advance time: refill 3 tokens
|
|
$this->currentTime += 3;
|
|
|
|
// Operation 3: consume 1 (should have 5 + 3 - 1 = 7)
|
|
$r3 = $this->rateLimiter->checkTokenBucket($key, 10, 1, 1);
|
|
expect($r3->getCurrent())->toBe(7);
|
|
});
|
|
});
|
|
|
|
describe('Different User Isolation', function () {
|
|
it('maintains separate buckets for different users', function () {
|
|
// User A consumes tokens
|
|
$this->rateLimiter->checkTokenBucket('user:A', 10, 1, 8);
|
|
|
|
// User B should have full bucket
|
|
$resultB = $this->rateLimiter->checkTokenBucket('user:B', 10, 1, 1);
|
|
expect($resultB->getCurrent())->toBe(9);
|
|
|
|
// User A should still have reduced tokens
|
|
$resultA = $this->rateLimiter->checkTokenBucket('user:A', 10, 1, 0);
|
|
expect($resultA->getCurrent())->toBe(2);
|
|
});
|
|
|
|
it('allows same capacity but different refill rates per user', function () {
|
|
// Fast refill user
|
|
$this->rateLimiter->checkTokenBucket('user:fast', 10, 10, 10);
|
|
$this->currentTime += 1;
|
|
$fastResult = $this->rateLimiter->checkTokenBucket('user:fast', 10, 10, 0);
|
|
expect($fastResult->getCurrent())->toBe(10); // Fully refilled in 1 second
|
|
|
|
// Slow refill user
|
|
$this->rateLimiter->checkTokenBucket('user:slow', 10, 1, 10);
|
|
$this->currentTime += 1;
|
|
$slowResult = $this->rateLimiter->checkTokenBucket('user:slow', 10, 1, 0);
|
|
expect($slowResult->getCurrent())->toBe(1); // Only 1 token refilled
|
|
});
|
|
});
|
|
|
|
describe('Reset Functionality', function () {
|
|
it('resets bucket to initial state', function () {
|
|
$key = 'user:reset';
|
|
|
|
// Consume some tokens
|
|
$this->rateLimiter->checkTokenBucket($key, 10, 1, 7);
|
|
|
|
// Reset
|
|
$this->rateLimiter->reset($key);
|
|
|
|
// Should have full capacity again
|
|
$result = $this->rateLimiter->checkTokenBucket($key, 10, 1, 1);
|
|
expect($result->getCurrent())->toBe(9); // Full bucket minus current consumption
|
|
});
|
|
});
|
|
});
|