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, ); } }