170382878b
- AuthController::forgotPassword использует Password::sendResetLink (anti-enumeration: всегда 200)
- AUTH_PASSWORD_RESET_TOKEN_TABLE=password_resets — указывает на нашу таблицу из schema v8.7
- Rate-limit 5/15мин по auth:forgot:{email}|{ip} — hit ставится ДО sendResetLink (защита перебора через unknown email)
- Frontend: authApi.forgotPassword, auth-store.requestPasswordReset, ForgotPasswordView success-state
- Pest +6 в ForgotPasswordTest (79/79 за 10.55с, 273 assertions)
- Vitest +4 (153/153 за 11.11с)
- TODO: POST /api/auth/reset-password + UI-форма new_password (deep-link)
- Регресс: lint+type+format OK; build 862ms; story:build 21/28 за 32с; Pint+Stan passed
- CLAUDE.md v1.36→v1.37, реестр v1.45→v1.46
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
124 lines
4.2 KiB
PHP
124 lines
4.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use Illuminate\Auth\Notifications\ResetPassword;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Notification;
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
beforeEach(function () {
|
|
$this->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();
|
|
});
|