| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197 |
- <?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'));
- }
- /**
- * SEC_REVIEW F28: the bucket map must not grow without bound. When
- * the cap is exceeded, the oldest entries (LRU front of the PHP
- * insertion-ordered array) get dropped in a batch.
- */
- public function testBucketMapIsBoundedByMaxBucketsCap(): void
- {
- $clock = FixedClock::at('2026-04-29T00:00:00Z');
- // Cap at 300 (must exceed the 256-entry eviction batch).
- $limiter = new RateLimiter(
- $clock,
- refillPerSecond: 1.0,
- capacity: 2.0,
- maxBuckets: 300,
- );
- // Touch 600 distinct keys — well past the cap.
- for ($i = 0; $i < 600; $i++) {
- $limiter->tryConsume("ip:10.0.0.{$i}");
- }
- self::assertLessThanOrEqual(
- 300,
- $limiter->bucketCount(),
- 'bucket count must stay within the configured cap'
- );
- }
- /**
- * SEC_REVIEW F28: an evicted bucket comes back as a fresh full-capacity
- * bucket on its next consume — equivalent to the bucket having idled
- * long enough to refill ($capacity / $refill seconds), so eviction
- * never gives an attacker more tokens than the rate would already
- * permit. This test asserts the behaviour explicitly so a future
- * "make eviction smarter" change cannot regress it into something
- * stricter (and break the documented refill semantics) or looser
- * (and grant tokens beyond the documented refill).
- */
- public function testEvictedBucketReturnsAtFullCapacityOnReuse(): void
- {
- $clock = FixedClock::at('2026-04-29T00:00:00Z');
- $limiter = new RateLimiter(
- $clock,
- refillPerSecond: 1.0,
- capacity: 2.0,
- maxBuckets: 300,
- );
- // Drain key=A.
- self::assertTrue($limiter->tryConsume('ip:A'));
- self::assertTrue($limiter->tryConsume('ip:A'));
- self::assertFalse($limiter->tryConsume('ip:A'));
- // Force eviction by inserting many fresh keys past the cap.
- for ($i = 0; $i < 600; $i++) {
- $limiter->tryConsume("ip:fan-{$i}");
- }
- // Same wall-clock time — without eviction, A would still be drained.
- // With eviction, A is gone and a re-touch starts a fresh full bucket.
- self::assertTrue($limiter->tryConsume('ip:A'));
- self::assertTrue($limiter->tryConsume('ip:A'));
- self::assertFalse($limiter->tryConsume('ip:A'));
- }
- /**
- * SEC_REVIEW F28: re-touching a key moves it to the end of the PHP
- * insertion-ordered array, so a hot key is the *last* candidate for
- * eviction even under heavy churn from other keys. Asserting this
- * keeps a regression that broke the LRU ordering from silently
- * letting an attacker reset their own bucket by smashing many
- * unrelated keys.
- */
- public function testRecentlyTouchedKeyIsNotEvicted(): void
- {
- $clock = FixedClock::at('2026-04-29T00:00:00Z');
- $limiter = new RateLimiter(
- $clock,
- refillPerSecond: 1.0,
- capacity: 2.0,
- maxBuckets: 300,
- );
- // Drain ip:hot.
- self::assertTrue($limiter->tryConsume('ip:hot'));
- self::assertTrue($limiter->tryConsume('ip:hot'));
- self::assertFalse($limiter->tryConsume('ip:hot'));
- // Heavy fan-out from other keys, but keep re-touching hot so it
- // stays at the LRU tail.
- for ($i = 0; $i < 600; $i++) {
- $limiter->tryConsume("ip:churn-{$i}");
- $limiter->tryConsume('ip:hot'); // returns false; bucket stays drained
- }
- // hot still drained — eviction picked the cold churn keys, not hot.
- self::assertFalse($limiter->tryConsume('ip:hot'));
- }
- public function testMaxBucketsBelowEvictionBatchIsRejected(): void
- {
- // A pathologically small cap would evict the entry we just
- // inserted; refuse to construct.
- $clock = FixedClock::at('2026-04-29T00:00:00Z');
- $this->expectException(\InvalidArgumentException::class);
- new RateLimiter(
- $clock,
- refillPerSecond: 1.0,
- capacity: 2.0,
- maxBuckets: 10,
- );
- }
- }
|