withMiddleware(ThrottleRequests::class); RateLimiter::clear('login'); }); test('API login throttle eventually blocks after enough failed attempts', function () { // The login endpoint stacks two protections: middleware throttle:10,1 and a // controller-level RateLimiter keyed by IP+email (configurable via // security_auth.login_max_attempts, default 5). After enough hits, one of // them must kick in and return 429. $sawBlock = false; for ($i = 0; $i < 15; $i++) { $r = $this->postJson('/api/v1/login', ['email' => 'x@x.com', 'password' => 'wrong']); if ($r->getStatusCode() === 429) { $sawBlock = true; break; } } expect($sawBlock)->toBeTrue(); }); test('API forgot-password throttle blocks after 5 requests', function () { for ($i = 0; $i < 5; $i++) { $this->postJson('/api/v1/forgot-password', ['email' => 'x@x.com']); } $this->postJson('/api/v1/forgot-password', ['email' => 'x@x.com']) ->assertStatus(429); }); test('API OTP send throttle blocks after 5 requests', function () { for ($i = 0; $i < 5; $i++) { $this->postJson('/api/v1/otp/send', ['email' => 'x@x.com']); } $this->postJson('/api/v1/otp/send', ['email' => 'x@x.com']) ->assertStatus(429); }); test('API register throttle blocks after 5 requests', function () { for ($i = 0; $i < 5; $i++) { $this->postJson('/api/v1/register', []); } $this->postJson('/api/v1/register', [])->assertStatus(429); }); test('2FA verify throttle blocks after 5 requests', function () { for ($i = 0; $i < 5; $i++) { $this->post('/2fa', ['code' => '123456']); } $this->post('/2fa', ['code' => '123456'])->assertStatus(429); }); test('different IPs do not share the same rate-limit bucket', function () { for ($i = 0; $i < 5; $i++) { $this->call('POST', '/api/v1/forgot-password', parameters: ['email' => 'x@x.com'], server: ['REMOTE_ADDR' => '10.0.0.1'] ); } // Same email, different IP — should be fine $r = $this->call('POST', '/api/v1/forgot-password', parameters: ['email' => 'x@x.com'], server: ['REMOTE_ADDR' => '10.0.0.2'] ); expect($r->getStatusCode())->not->toBe(429); });