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