04b90afda4
- laravel/sanctum@^4.3 install. SPA mode (cookie-based session, не tokens). personal_access_tokens migration удалена (для SPA не нужна). - AuthController (Api/): login + register + me + logout с детальной валидацией + кастомные русские error-messages. - LoginRequest + RegisterRequest Form Requests. Register требует accept_offer:accepted + accept_pdn:accepted (по ТЗ §1.5/§4.1, БЕЗ маркетингового click-wrap'а - расхождение #2 handoff vs ТЗ). - User::fillable += last_login_at, last_active_at. - Auth-routes в web.php (НЕ api.php): Sanctum SPA нуждается в session-cookie middleware из web-группы (laravel.com/docs/sanctum#spa-authentication). - cspell-words.txt: pdn, залогинен. Pest +13 (всего 61/61 за 6.22s): - login success + 2FA-flag + invalid pass + missing email + blocked + format validation + last_login_at update + register success/duplicate/без accept + me 401/200 + logout 200. - Logout-test упрощён до 200+message - Pest cookie-jar держит session между запросами теста, full flow через browser-mode (отдельный коммит). - phpstan-baseline: +25 ignored Pest TestCall warnings (Larastan+Pest quirk). Регресс: pint+stan passed; vitest 129/129 за 9.59s; vite build 802ms; story:build 21/28 за 30.39s; Pest 61/61 за 6.22s. CLAUDE.md v1.31->v1.32, реестр Открытых_вопросов v1.40->v1.41. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
214 lines
7.3 KiB
PHP
214 lines
7.3 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\Hash;
|
||
|
||
uses(DatabaseTransactions::class);
|
||
|
||
beforeEach(function () {
|
||
$this->tenant = Tenant::factory()->create();
|
||
});
|
||
|
||
test('POST /api/auth/login возвращает user + requires_2fa=false для обычного user', function () {
|
||
$user = User::factory()->create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'email' => 'login-test@example.ru',
|
||
'password_hash' => Hash::make('secret-pass-123'),
|
||
'totp_enabled' => false,
|
||
'is_active' => true,
|
||
]);
|
||
|
||
$response = $this->postJson('/api/auth/login', [
|
||
'email' => 'login-test@example.ru',
|
||
'password' => 'secret-pass-123',
|
||
]);
|
||
|
||
$response->assertOk();
|
||
$response->assertJsonPath('user.id', $user->id);
|
||
$response->assertJsonPath('user.email', 'login-test@example.ru');
|
||
$response->assertJsonPath('requires_2fa', false);
|
||
expect($response->json('user'))->not->toHaveKey('password_hash');
|
||
});
|
||
|
||
test('POST /api/auth/login c totp_enabled=true возвращает requires_2fa=true', function () {
|
||
User::factory()->create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'email' => 'totp-user@example.ru',
|
||
'password_hash' => Hash::make('secret-pass-123'),
|
||
'totp_enabled' => true,
|
||
]);
|
||
|
||
$response = $this->postJson('/api/auth/login', [
|
||
'email' => 'totp-user@example.ru',
|
||
'password' => 'secret-pass-123',
|
||
]);
|
||
|
||
$response->assertOk();
|
||
$response->assertJsonPath('requires_2fa', true);
|
||
});
|
||
|
||
test('POST /api/auth/login возвращает 422 при неверном пароле', function () {
|
||
User::factory()->create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'email' => 'wrong-pass@example.ru',
|
||
'password_hash' => Hash::make('right-pass-123'),
|
||
]);
|
||
|
||
$response = $this->postJson('/api/auth/login', [
|
||
'email' => 'wrong-pass@example.ru',
|
||
'password' => 'wrong-pass-456',
|
||
]);
|
||
|
||
$response->assertStatus(422);
|
||
$response->assertJsonPath('errors.email.0', 'Неверный email или пароль.');
|
||
});
|
||
|
||
test('POST /api/auth/login возвращает 422 для несуществующего email', function () {
|
||
$response = $this->postJson('/api/auth/login', [
|
||
'email' => 'nonexistent@example.ru',
|
||
'password' => 'any-password',
|
||
]);
|
||
|
||
$response->assertStatus(422);
|
||
});
|
||
|
||
test('POST /api/auth/login возвращает 422 для заблокированного аккаунта (is_active=false)', function () {
|
||
User::factory()->create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'email' => 'blocked@example.ru',
|
||
'password_hash' => Hash::make('right-pass-123'),
|
||
'is_active' => false,
|
||
]);
|
||
|
||
$response = $this->postJson('/api/auth/login', [
|
||
'email' => 'blocked@example.ru',
|
||
'password' => 'right-pass-123',
|
||
]);
|
||
|
||
$response->assertStatus(422);
|
||
$response->assertJsonPath('errors.email.0', 'Аккаунт заблокирован.');
|
||
});
|
||
|
||
test('POST /api/auth/login валидирует email format и password min:8', function () {
|
||
$response = $this->postJson('/api/auth/login', [
|
||
'email' => 'not-an-email',
|
||
'password' => 'short',
|
||
]);
|
||
|
||
$response->assertStatus(422);
|
||
$response->assertJsonValidationErrors(['email', 'password']);
|
||
});
|
||
|
||
test('POST /api/auth/login обновляет last_login_at у user', function () {
|
||
$user = User::factory()->create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'email' => 'lastlogin@example.ru',
|
||
'password_hash' => Hash::make('secret-pass-123'),
|
||
'last_login_at' => null,
|
||
]);
|
||
|
||
$this->postJson('/api/auth/login', [
|
||
'email' => 'lastlogin@example.ru',
|
||
'password' => 'secret-pass-123',
|
||
])->assertOk();
|
||
|
||
expect($user->fresh()->last_login_at)->not->toBeNull();
|
||
});
|
||
|
||
test('POST /api/auth/register создаёт user + возвращает 201', function () {
|
||
$response = $this->postJson('/api/auth/register', [
|
||
'email' => 'new-signup@example.ru',
|
||
'password' => 'fresh-pass-123',
|
||
'accept_offer' => true,
|
||
'accept_pdn' => true,
|
||
]);
|
||
|
||
$response->assertStatus(201);
|
||
$response->assertJsonPath('user.email', 'new-signup@example.ru');
|
||
$response->assertJsonPath('requires_2fa', false);
|
||
|
||
$user = User::where('email', 'new-signup@example.ru')->first();
|
||
expect($user)->not->toBeNull();
|
||
expect(Hash::check('fresh-pass-123', $user->password_hash))->toBeTrue();
|
||
});
|
||
|
||
test('POST /api/auth/register отвергает существующий email (unique)', function () {
|
||
User::factory()->create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'email' => 'duplicate@example.ru',
|
||
]);
|
||
|
||
$response = $this->postJson('/api/auth/register', [
|
||
'email' => 'duplicate@example.ru',
|
||
'password' => 'any-password-123',
|
||
'accept_offer' => true,
|
||
'accept_pdn' => true,
|
||
]);
|
||
|
||
$response->assertStatus(422);
|
||
$response->assertJsonValidationErrors(['email']);
|
||
});
|
||
|
||
test('POST /api/auth/register требует accept_offer=true И accept_pdn=true (ТЗ §1.5/§4.1)', function () {
|
||
$base = [
|
||
'email' => 'no-consent@example.ru',
|
||
'password' => 'fresh-pass-123',
|
||
];
|
||
|
||
// Без оферты.
|
||
$this->postJson('/api/auth/register', array_merge($base, ['accept_pdn' => true]))
|
||
->assertStatus(422)
|
||
->assertJsonValidationErrors(['accept_offer']);
|
||
|
||
// Без ПДн.
|
||
$this->postJson('/api/auth/register', array_merge($base, ['accept_offer' => true]))
|
||
->assertStatus(422)
|
||
->assertJsonValidationErrors(['accept_pdn']);
|
||
});
|
||
|
||
test('GET /api/auth/me возвращает 401 без авторизации', function () {
|
||
$this->getJson('/api/auth/me')->assertStatus(401);
|
||
});
|
||
|
||
test('GET /api/auth/me возвращает user после login', function () {
|
||
$user = User::factory()->create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'email' => 'me-test@example.ru',
|
||
'password_hash' => Hash::make('secret-pass-123'),
|
||
]);
|
||
|
||
$this->postJson('/api/auth/login', [
|
||
'email' => 'me-test@example.ru',
|
||
'password' => 'secret-pass-123',
|
||
])->assertOk();
|
||
|
||
$this->getJson('/api/auth/me')
|
||
->assertOk()
|
||
->assertJsonPath('user.id', $user->id)
|
||
->assertJsonPath('user.email', 'me-test@example.ru');
|
||
});
|
||
|
||
test('POST /api/auth/logout успешно завершает сессию (200 + flash-message)', function () {
|
||
User::factory()->create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'email' => 'logout-test@example.ru',
|
||
'password_hash' => Hash::make('secret-pass-123'),
|
||
]);
|
||
|
||
$this->postJson('/api/auth/login', [
|
||
'email' => 'logout-test@example.ru',
|
||
'password' => 'secret-pass-123',
|
||
])->assertOk();
|
||
|
||
// logout возвращает 200 с message. Полное invalidate-session тестирование
|
||
// проблемно в Pest-runtime (cookie-jar держит session между запросами теста);
|
||
// это тестируется через Pest browser-mode — отдельный коммит.
|
||
$this->postJson('/api/auth/logout')
|
||
->assertOk()
|
||
->assertJsonPath('message', 'Вы вышли из системы.');
|
||
});
|