564d984f2a
Backend-ревью: register/resend применял только cooldown 60с, но не часовой лимит 5/час (spec §7.6) — можно было слать код 1/мин бесконечно. Добавлен RateLimiter по ключу email|ip (как в registerStart). +тест throttle для start. Larastan baseline перегенерирован (новый тест добавил postJson-вызовы). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
201 lines
7.8 KiB
PHP
201 lines
7.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Mail\RegisterEmailVerificationCode;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Tests\TestCase;
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
beforeEach(function () {
|
|
$this->tenant = Tenant::factory()->create();
|
|
Mail::fake();
|
|
});
|
|
|
|
test('RegisterEmailVerificationCode содержит код и тему', function () {
|
|
$mailable = new RegisterEmailVerificationCode('123456');
|
|
|
|
$mailable->assertHasSubject('Код подтверждения регистрации — Лидерра');
|
|
$mailable->assertSeeInHtml('123456');
|
|
});
|
|
|
|
test('register/start принимает валидную форму, шлёт код, аккаунт ещё не создан', function () {
|
|
$response = $this->postJson('/api/auth/register/start', [
|
|
'email' => 'newcomer@example.ru',
|
|
'phone' => '+7 (912) 345-67-89',
|
|
'password' => 'fresh-pass-123',
|
|
'accept_offer' => true,
|
|
'accept_pdn' => true,
|
|
]);
|
|
|
|
$response->assertOk();
|
|
$response->assertJsonPath('email', 'newcomer@example.ru');
|
|
|
|
Mail::assertSent(RegisterEmailVerificationCode::class);
|
|
expect(User::where('email', 'newcomer@example.ru')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('register/start отвергает существующий email', function () {
|
|
User::factory()->create(['tenant_id' => $this->tenant->id, 'email' => 'dup@example.ru']);
|
|
|
|
$this->postJson('/api/auth/register/start', [
|
|
'email' => 'dup@example.ru',
|
|
'phone' => '+7 (912) 345-67-89',
|
|
'password' => 'fresh-pass-123',
|
|
'accept_offer' => true,
|
|
'accept_pdn' => true,
|
|
])->assertStatus(422)->assertJsonValidationErrors(['email']);
|
|
});
|
|
|
|
test('register/start требует корректный телефон', function () {
|
|
$this->postJson('/api/auth/register/start', [
|
|
'email' => 'badphone@example.ru',
|
|
'phone' => '12345',
|
|
'password' => 'fresh-pass-123',
|
|
'accept_offer' => true,
|
|
'accept_pdn' => true,
|
|
])->assertStatus(422)->assertJsonValidationErrors(['phone']);
|
|
|
|
$this->postJson('/api/auth/register/start', [
|
|
'email' => 'nophone@example.ru',
|
|
'password' => 'fresh-pass-123',
|
|
'accept_offer' => true,
|
|
'accept_pdn' => true,
|
|
])->assertStatus(422)->assertJsonValidationErrors(['phone']);
|
|
});
|
|
|
|
test('register/start требует пароль ≥8 и оба согласия', function () {
|
|
$this->postJson('/api/auth/register/start', [
|
|
'email' => 'weak@example.ru', 'phone' => '+7 (912) 345-67-89',
|
|
'password' => 'short', 'accept_offer' => true, 'accept_pdn' => true,
|
|
])->assertStatus(422)->assertJsonValidationErrors(['password']);
|
|
|
|
$this->postJson('/api/auth/register/start', [
|
|
'email' => 'noconsent@example.ru', 'phone' => '+7 (912) 345-67-89',
|
|
'password' => 'fresh-pass-123', 'accept_pdn' => true,
|
|
])->assertStatus(422)->assertJsonValidationErrors(['accept_offer']);
|
|
});
|
|
|
|
test('register/start ограничивает число отправок кода (5/час по email|ip)', function () {
|
|
$payload = [
|
|
'email' => 'throttle@example.ru',
|
|
'phone' => '+7 (912) 345-67-89',
|
|
'password' => 'fresh-pass-123',
|
|
'accept_offer' => true,
|
|
'accept_pdn' => true,
|
|
];
|
|
|
|
// 5 отправок разрешены (аккаунт не создаётся до verify, email остаётся свободным).
|
|
for ($i = 0; $i < 5; $i++) {
|
|
$this->postJson('/api/auth/register/start', $payload)->assertOk();
|
|
}
|
|
|
|
// 6-я — превышение лимита.
|
|
$this->postJson('/api/auth/register/start', $payload)->assertStatus(429);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task 4: register/verify
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Делает register/start и возвращает 6-значный код из отправленного письма.
|
|
*
|
|
* @param array<string, mixed> $overrides
|
|
*/
|
|
$startAndGetCode = function (array $overrides = []): string {
|
|
/** @var TestCase $this */
|
|
$payload = array_merge([
|
|
'email' => 'verify-flow@example.ru',
|
|
'phone' => '+7 (912) 345-67-89',
|
|
'password' => 'fresh-pass-123',
|
|
'accept_offer' => true,
|
|
'accept_pdn' => true,
|
|
], $overrides);
|
|
|
|
test()->postJson('/api/auth/register/start', $payload)->assertOk();
|
|
|
|
return Mail::sent(RegisterEmailVerificationCode::class)->first()->code;
|
|
};
|
|
|
|
test('register/verify создаёт аккаунт с подтверждённой почтой и нормализованным телефоном', function () use ($startAndGetCode) {
|
|
$code = $startAndGetCode();
|
|
|
|
$response = $this->postJson('/api/auth/register/verify', ['code' => $code]);
|
|
|
|
$response->assertStatus(201);
|
|
$response->assertJsonPath('user.email', 'verify-flow@example.ru');
|
|
$response->assertJsonPath('requires_2fa', false);
|
|
|
|
$user = User::where('email', 'verify-flow@example.ru')->first();
|
|
expect($user)->not->toBeNull();
|
|
expect($user->phone)->toBe('79123456789');
|
|
expect($user->email_verified_at)->not->toBeNull();
|
|
$this->assertAuthenticatedAs($user);
|
|
});
|
|
|
|
test('register/verify отклоняет неверный код и считает попытки', function () use ($startAndGetCode) {
|
|
$startAndGetCode();
|
|
|
|
$this->postJson('/api/auth/register/verify', ['code' => '000000'])
|
|
->assertStatus(422)->assertJsonValidationErrors(['code']);
|
|
|
|
expect(User::where('email', 'verify-flow@example.ru')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('register/verify сбрасывает pending после 5 неверных попыток', function () use ($startAndGetCode) {
|
|
$startAndGetCode();
|
|
|
|
for ($i = 0; $i < 5; $i++) {
|
|
$this->postJson('/api/auth/register/verify', ['code' => '000000'])->assertStatus(422);
|
|
}
|
|
|
|
// 6-я попытка — pending уже сброшен (нет сессии регистрации).
|
|
$this->postJson('/api/auth/register/verify', ['code' => '000000'])
|
|
->assertStatus(422)->assertJsonValidationErrors(['code']);
|
|
});
|
|
|
|
test('register/verify без начатой регистрации возвращает 422', function () {
|
|
$this->postJson('/api/auth/register/verify', ['code' => '123456'])
|
|
->assertStatus(422)->assertJsonValidationErrors(['code']);
|
|
});
|
|
|
|
test('register/verify отклоняет истёкший код', function () use ($startAndGetCode) {
|
|
$code = $startAndGetCode();
|
|
|
|
$this->travel(16)->minutes();
|
|
|
|
$this->postJson('/api/auth/register/verify', ['code' => $code])
|
|
->assertStatus(422)->assertJsonValidationErrors(['code']);
|
|
expect(User::where('email', 'verify-flow@example.ru')->exists())->toBeFalse();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task 5: register/resend
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('register/resend в течение cooldown возвращает 429', function () use ($startAndGetCode) {
|
|
$startAndGetCode();
|
|
|
|
$this->postJson('/api/auth/register/resend')->assertStatus(429);
|
|
});
|
|
|
|
test('register/resend после cooldown шлёт новый код', function () use ($startAndGetCode) {
|
|
$startAndGetCode();
|
|
Mail::fake(); // сбрасываем счётчик отправок
|
|
|
|
$this->travel(61)->seconds();
|
|
|
|
$this->postJson('/api/auth/register/resend')->assertOk();
|
|
Mail::assertSent(RegisterEmailVerificationCode::class, 1);
|
|
});
|
|
|
|
test('register/resend без начатой регистрации возвращает 422', function () {
|
|
$this->postJson('/api/auth/register/resend')
|
|
->assertStatus(422)->assertJsonValidationErrors(['code']);
|
|
});
|