tenant = Tenant::factory()->create(); // CACHE_STORE=array, но процесс PHPUnit один — на всякий случай чистим. RateLimiter::clear('auth:login:locked@example.ru|127.0.0.1'); }); test('login: после 5 неуспешных попыток 6-я возвращает 429 + Retry-After', function () { User::factory()->create([ 'tenant_id' => $this->tenant->id, 'email' => 'locked@example.ru', 'password_hash' => Hash::make('right-pass'), ]); // 5 неудач → каждая 422. for ($i = 1; $i <= 5; $i++) { $r = $this->postJson('/api/auth/login', [ 'email' => 'locked@example.ru', 'password' => 'wrong-pass-'.$i, ]); expect($r->status())->toBe(422); } // 6-я с правильным паролем — всё равно 429 (lockout). $r = $this->postJson('/api/auth/login', [ 'email' => 'locked@example.ru', 'password' => 'right-pass', ]); $r->assertStatus(429); expect($r->headers->get('Retry-After'))->not->toBeNull(); expect((int) $r->headers->get('Retry-After'))->toBeGreaterThan(0); expect((int) $r->headers->get('Retry-After'))->toBeLessThanOrEqual(900); expect($r->json('retry_after'))->toBeGreaterThan(0); expect($r->json('message'))->toContain('Слишком много попыток'); }); test('login: успешный вход чистит throttle-счётчик', function () { User::factory()->create([ 'tenant_id' => $this->tenant->id, 'email' => 'success@example.ru', 'password_hash' => Hash::make('right-pass'), ]); // 4 неудачи (под лимитом). for ($i = 1; $i <= 4; $i++) { $this->postJson('/api/auth/login', [ 'email' => 'success@example.ru', 'password' => 'wrong-'.$i, ])->assertStatus(422); } // 5-я — успешный → счётчик чистится. $this->postJson('/api/auth/login', [ 'email' => 'success@example.ru', 'password' => 'right-pass', ])->assertOk(); // Logout чтобы не мешать следующему login (Pest cookie-jar). $this->postJson('/api/auth/logout'); // 5 новых неудач должны проходить (а не сразу падать в 429). for ($i = 1; $i <= 5; $i++) { $r = $this->postJson('/api/auth/login', [ 'email' => 'success@example.ru', 'password' => 'still-wrong-'.$i, ]); expect($r->status())->toBe(422); } // А вот 6-я уже должна быть 429. $this->postJson('/api/auth/login', [ 'email' => 'success@example.ru', 'password' => 'still-wrong-final', ])->assertStatus(429); }); test('login: throttle ключ изолирован по email — два разных user не блокируют друг друга', function () { User::factory()->create([ 'tenant_id' => $this->tenant->id, 'email' => 'alice@example.ru', 'password_hash' => Hash::make('alice-pass'), ]); User::factory()->create([ 'tenant_id' => $this->tenant->id, 'email' => 'bob@example.ru', 'password_hash' => Hash::make('bob-pass'), ]); // Alice — 5 неудач. for ($i = 1; $i <= 5; $i++) { $this->postJson('/api/auth/login', [ 'email' => 'alice@example.ru', 'password' => 'wrong-pass-attempt', ])->assertStatus(422); } $aliceKey = 'auth:login:alice@example.ru|127.0.0.1'; expect(RateLimiter::attempts($aliceKey))->toBe(5); expect(RateLimiter::tooManyAttempts($aliceKey, 5))->toBeTrue(); // 6-я попытка Alice (даже с правильным паролем) → 429. $this->postJson('/api/auth/login', [ 'email' => 'alice@example.ru', 'password' => 'alice-pass', ])->assertStatus(429); // Bob — нет. $this->postJson('/api/auth/login', [ 'email' => 'bob@example.ru', 'password' => 'bob-pass', ])->assertOk(); }); test('login: account_locked (is_active=false) тоже расходует попытки', function () { User::factory()->create([ 'tenant_id' => $this->tenant->id, 'email' => 'inactive@example.ru', 'password_hash' => Hash::make('right-pass'), 'is_active' => false, ]); // 5 раз получаем 422 «Аккаунт заблокирован». for ($i = 1; $i <= 5; $i++) { $this->postJson('/api/auth/login', [ 'email' => 'inactive@example.ru', 'password' => 'right-pass', ])->assertStatus(422); } // 6-я → 429. $this->postJson('/api/auth/login', [ 'email' => 'inactive@example.ru', 'password' => 'right-pass', ])->assertStatus(429); }); test('2FA verify: после 5 неверных кодов 6-й возвращает 429', function () { $google2fa = new Google2FA; $secret = $google2fa->generateSecretKey(); User::factory()->create([ 'tenant_id' => $this->tenant->id, 'email' => '2fa-locked@example.ru', 'password_hash' => Hash::make('right-pass'), 'totp_enabled' => true, 'totp_secret' => $secret, ]); // Login → pending_user_id в session (Pest cookie-jar держит её между запросами). $this->postJson('/api/auth/login', [ 'email' => '2fa-locked@example.ru', 'password' => 'right-pass', ])->assertOk()->assertJsonPath('requires_2fa', true); // 5 неверных кодов. for ($i = 0; $i < 5; $i++) { $r = $this->postJson('/api/auth/2fa/verify', [ 'code' => '000000', ]); expect($r->status())->toBe(422); } // 6-я попытка даже с правильным TOTP — 429. $validCode = $google2fa->getCurrentOtp($secret); $r = $this->postJson('/api/auth/2fa/verify', [ 'code' => $validCode, ]); $r->assertStatus(429); expect($r->headers->get('Retry-After'))->not->toBeNull(); }); test('2FA verify: успех чистит throttle', function () { $google2fa = new Google2FA; $secret = $google2fa->generateSecretKey(); User::factory()->create([ 'tenant_id' => $this->tenant->id, 'email' => '2fa-success@example.ru', 'password_hash' => Hash::make('right-pass'), 'totp_enabled' => true, 'totp_secret' => $secret, ]); $this->postJson('/api/auth/login', [ 'email' => '2fa-success@example.ru', 'password' => 'right-pass', ])->assertOk(); // 3 неудачи (под лимитом). for ($i = 0; $i < 3; $i++) { $this->postJson('/api/auth/2fa/verify', ['code' => '000000']) ->assertStatus(422); } // Успех. $this->postJson('/api/auth/2fa/verify', [ 'code' => $google2fa->getCurrentOtp($secret), ])->assertOk(); });