tenant = Tenant::factory()->create(); }); test('POST /api/auth/forgot для существующего email возвращает unified 200 + создаёт password_resets row + шлёт notification', function () { Notification::fake(); $user = User::factory()->create([ 'tenant_id' => $this->tenant->id, 'email' => 'real-user@example.ru', 'password_hash' => Hash::make('old-pass-1234'), ]); $r = $this->postJson('/api/auth/forgot', [ 'email' => 'real-user@example.ru', ]); $r->assertOk(); expect($r->json('message'))->toContain('ссылку для сброса пароля'); // Token записан в password_resets (config: AUTH_PASSWORD_RESET_TOKEN_TABLE=password_resets). $tokenRow = DB::table('password_resets')->where('email', 'real-user@example.ru')->first(); expect($tokenRow)->not->toBeNull(); expect($tokenRow->token)->not->toBeEmpty(); // Notification отправлен. Notification::assertSentTo($user, ResetPassword::class); }); test('POST /api/auth/forgot для НЕсуществующего email возвращает то же 200 (anti-enumeration)', function () { Notification::fake(); $r = $this->postJson('/api/auth/forgot', [ 'email' => 'unknown@example.ru', ]); $r->assertOk(); expect($r->json('message'))->toContain('ссылку для сброса пароля'); // password_resets не должен содержать row для unknown email. expect(DB::table('password_resets')->where('email', 'unknown@example.ru')->exists())->toBeFalse(); Notification::assertNothingSent(); }); test('POST /api/auth/forgot валидация email: 422 при невалидном формате', function () { $r = $this->postJson('/api/auth/forgot', [ 'email' => 'not-an-email', ]); $r->assertStatus(422); expect($r->json('errors.email'))->not->toBeEmpty(); }); test('POST /api/auth/forgot валидация email: 422 при пустом значении', function () { $r = $this->postJson('/api/auth/forgot', []); $r->assertStatus(422); }); test('POST /api/auth/forgot rate-limit: 5 попыток / 15 мин', function () { Notification::fake(); User::factory()->create([ 'tenant_id' => $this->tenant->id, 'email' => 'forgot-locked@example.ru', 'password_hash' => Hash::make('old-pass-1234'), ]); // 5 успешных запросов (200 каждый — unified ответ). for ($i = 1; $i <= 5; $i++) { $this->postJson('/api/auth/forgot', [ 'email' => 'forgot-locked@example.ru', ])->assertOk(); } // 6-я → 429 + Retry-After. $r = $this->postJson('/api/auth/forgot', [ 'email' => 'forgot-locked@example.ru', ]); $r->assertStatus(429); expect($r->headers->get('Retry-After'))->not->toBeNull(); expect($r->json('retry_after'))->toBeGreaterThan(0); }); test('POST /api/auth/forgot rate-limit изолирован по email — Bob не блокируется когда Alice заблокирована', function () { Notification::fake(); User::factory()->create([ 'tenant_id' => $this->tenant->id, 'email' => 'alice-forgot@example.ru', 'password_hash' => Hash::make('alice-pass-1234'), ]); User::factory()->create([ 'tenant_id' => $this->tenant->id, 'email' => 'bob-forgot@example.ru', 'password_hash' => Hash::make('bob-pass-1234'), ]); // Alice — 5 раз → заблокирована. for ($i = 1; $i <= 5; $i++) { $this->postJson('/api/auth/forgot', ['email' => 'alice-forgot@example.ru'])->assertOk(); } $this->postJson('/api/auth/forgot', ['email' => 'alice-forgot@example.ru'])->assertStatus(429); // Bob проходит спокойно. $this->postJson('/api/auth/forgot', ['email' => 'bob-forgot@example.ru'])->assertOk(); });