| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Unit\Http;
- use App\Domain\Time\FixedClock;
- use App\Infrastructure\Http\RateLimiter;
- use PHPUnit\Framework\TestCase;
- /**
- * Token-bucket invariants:
- * - capacity = 2 × refill, so 6 immediate consumes succeed at refill=3.
- * - the 7th fails, then advancing 1s restores 3 tokens.
- * - per-key isolation: two keys do not share a bucket.
- * - keys are arbitrary strings so callers can namespace tokens vs IPs
- * (SEC_REVIEW F27).
- */
- final class RateLimiterTest extends TestCase
- {
- public function testBurstUpToCapacitySucceeds(): void
- {
- $clock = FixedClock::at('2026-04-29T00:00:00Z');
- $limiter = new RateLimiter($clock, refillPerSecond: 3.0, capacity: 6.0);
- for ($i = 0; $i < 6; $i++) {
- self::assertTrue($limiter->tryConsume('token:1'), "consume #{$i} should succeed");
- }
- self::assertFalse($limiter->tryConsume('token:1'), 'consume #7 should be denied');
- }
- public function testRefillRestoresTokensOverTime(): void
- {
- $clock = FixedClock::at('2026-04-29T00:00:00Z');
- $limiter = new RateLimiter($clock, refillPerSecond: 3.0, capacity: 6.0);
- for ($i = 0; $i < 6; $i++) {
- $limiter->tryConsume('token:1');
- }
- self::assertFalse($limiter->tryConsume('token:1'));
- $clock->advance('+1 second');
- // After 1s @ 3/s we should have 3 fresh tokens.
- self::assertTrue($limiter->tryConsume('token:1'));
- self::assertTrue($limiter->tryConsume('token:1'));
- self::assertTrue($limiter->tryConsume('token:1'));
- self::assertFalse($limiter->tryConsume('token:1'));
- }
- public function testKeysAreIsolated(): void
- {
- $clock = FixedClock::at('2026-04-29T00:00:00Z');
- $limiter = new RateLimiter($clock, refillPerSecond: 1.0, capacity: 2.0);
- self::assertTrue($limiter->tryConsume('token:1'));
- self::assertTrue($limiter->tryConsume('token:1'));
- self::assertFalse($limiter->tryConsume('token:1'));
- // Token 2's bucket is untouched.
- self::assertTrue($limiter->tryConsume('token:2'));
- self::assertTrue($limiter->tryConsume('token:2'));
- }
- public function testTokenAndIpNamespacesDoNotShareABucket(): void
- {
- // SEC_REVIEW F27: namespace-prefixed keys mean an authenticated
- // user's `token:42` bucket cannot drain the same shared `ip:…`
- // bucket that throttles unauthenticated traffic from elsewhere.
- $clock = FixedClock::at('2026-04-29T00:00:00Z');
- $limiter = new RateLimiter($clock, refillPerSecond: 1.0, capacity: 2.0);
- self::assertTrue($limiter->tryConsume('token:42'));
- self::assertTrue($limiter->tryConsume('token:42'));
- self::assertFalse($limiter->tryConsume('token:42'));
- // Same numeric tail under a different namespace must be its own
- // bucket — string keys are not silently coerced to int.
- self::assertTrue($limiter->tryConsume('ip:42'));
- self::assertTrue($limiter->tryConsume('ip:42'));
- self::assertFalse($limiter->tryConsume('ip:42'));
- }
- }
|