setRouteResolver(static fn (): object => new class { public function getName(): string { return 'forum.topic.reply'; } }); $throttle = \Mockery::mock(ThrottleRequests::class); $botProtection = \Mockery::mock(BotProtectionService::class); $exception = new ThrottleRequestsException('Too Many Attempts.', null, [ 'Retry-After' => '42', 'X-RateLimit-Limit' => '3', 'X-RateLimit-Remaining' => '0', ]); $throttle->shouldReceive('handle') ->once() ->with($request, \Mockery::type(Closure::class), 'forum-post-write') ->andThrow($exception); $botProtection->shouldReceive('recordRateLimitViolation') ->once() ->with( $request, 'forum_reply_create', \Mockery::on(static function (array $context): bool { return $context['limiter'] === 'forum-post-write' && $context['bucket'] === 'minute' && $context['max_attempts'] === 3 && $context['retry_after'] === 42; }) ); $middleware = new ForumRateLimitMiddleware($throttle, $botProtection); $next = static fn (): Response => response('ok'); $middleware->handle($request, $next); })->throws(ThrottleRequestsException::class); it('classifies forum hourly limiter violations using the actual limit bucket', function () { $request = Request::create('/forum/topic/example-topic/reply', 'POST'); $request->setRouteResolver(static fn (): object => new class { public function getName(): string { return 'forum.topic.reply'; } }); $throttle = \Mockery::mock(ThrottleRequests::class); $botProtection = \Mockery::mock(BotProtectionService::class); $exception = new ThrottleRequestsException('Too Many Attempts.', null, [ 'Retry-After' => '120', 'X-RateLimit-Limit' => '10', 'X-RateLimit-Remaining' => '0', ]); $throttle->shouldReceive('handle') ->once() ->with($request, \Mockery::type(Closure::class), 'forum-post-write') ->andThrow($exception); $botProtection->shouldReceive('recordRateLimitViolation') ->once() ->with( $request, 'forum_reply_create', \Mockery::on(static function (array $context): bool { return $context['bucket'] === 'hour' && $context['max_attempts'] === 10 && $context['retry_after'] === 120; }) ); $middleware = new ForumRateLimitMiddleware($throttle, $botProtection); $next = static fn (): Response => response('ok'); $middleware->handle($request, $next); })->throws(ThrottleRequestsException::class); it('classifies thread creation minute and hour limiter buckets correctly', function () { $request = Request::create('/forum/example-board/new', 'POST'); $request->setRouteResolver(static fn (): object => new class { public function getName(): string { return 'forum.topic.store'; } }); $throttle = \Mockery::mock(ThrottleRequests::class); $botProtection = \Mockery::mock(BotProtectionService::class); $minuteException = new ThrottleRequestsException('Too Many Attempts.', null, [ 'Retry-After' => '30', 'X-RateLimit-Limit' => '3', 'X-RateLimit-Remaining' => '0', ]); $hourException = new ThrottleRequestsException('Too Many Attempts.', null, [ 'Retry-After' => '120', 'X-RateLimit-Limit' => '10', 'X-RateLimit-Remaining' => '0', ]); $throttle->shouldReceive('handle') ->once() ->with($request, \Mockery::type(Closure::class), 'forum-thread-create') ->andThrow($minuteException); $throttle->shouldReceive('handle') ->once() ->with($request, \Mockery::type(Closure::class), 'forum-thread-create') ->andThrow($hourException); $botProtection->shouldReceive('recordRateLimitViolation') ->once() ->with( $request, 'forum_topic_create', \Mockery::on(static function (array $context): bool { return $context['limiter'] === 'forum-thread-create' && $context['bucket'] === 'minute' && $context['max_attempts'] === 3 && $context['retry_after'] === 30; }) ); $botProtection->shouldReceive('recordRateLimitViolation') ->once() ->with( $request, 'forum_topic_create', \Mockery::on(static function (array $context): bool { return $context['limiter'] === 'forum-thread-create' && $context['bucket'] === 'hour' && $context['max_attempts'] === 10 && $context['retry_after'] === 120; }) ); $middleware = new ForumRateLimitMiddleware($throttle, $botProtection); $next = static fn (): Response => response('ok'); try { $middleware->handle($request, $next); } catch (ThrottleRequestsException) { } $middleware->handle($request, $next); })->throws(ThrottleRequestsException::class);