Files
portal/app/tests/Feature/Auth/TwoFactorTest.php
T
Дмитрий 374724a7a3 phase2(auth-2fa): TOTP-verify endpoint + TwoFactorView интеграция
- pragmarx/google2fa@^9.0 для TOTP RFC 6238.
- AuthController::login изменён: при totp_enabled=true НЕ делает Auth::login,
  сохраняет auth.pending_user_id+pending_remember в session, возвращает
  requires_2fa=true. /me=401 пока 2FA не пройдена.
- AuthController::verifyTwoFactor: читает pending_user_id, верифицирует TOTP
  через Google2FA::verifyKey($secret, $code, window: 1) (окно ±1 = 30s).
  Success → Auth::login + regenerate + clear pending + last_login_at.
- VerifyTwoFactorRequest: regex /^\d{6}$/.
- /api/auth/2fa/verify публичный (нет session-auth до verify).

Frontend:
- auth-store::login: при requires_2fa=true user остаётся null (иначе
  isAuthenticated=true и guard пустит на /dashboard минуя 2FA).
- auth-store::verifyTwoFactor action.
- api/auth.ts::verifyTwoFactor(code).
- TwoFactorView: onMounted redirect на /login если нет pending state;
  submit → verify → /dashboard; на error - clear code + focus first cell.
  userEmail из auth.user?.email.

Pest +6 (всего 67/67 за 6.97s, 194 assertions): login для 2FA НЕ создаёт
session + verify success/неверный код/без login/валидация формата +
после verify /me=200.

Vitest +3 (всего 142/142 за 10.75s): login pending vs success state +
verifyTwoFactor success/reject. TwoFactorView spec получил setActivePinia
+ requires2fa=true для bypass onMounted-redirect.

PHPStan baseline +26 Pest TestCall warnings (накопительно).

Регресс: pint+stan passed; vitest 142/142; vite build 908ms;
story:build 21/28 за 31.28s; Pest 67/67 за 6.97s.

CLAUDE.md v1.33->v1.34, реестр Открытых_вопросов v1.42->v1.43.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:14:33 +03:00

115 lines
3.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Hash;
use PragmaRX\Google2FA\Google2FA;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->google2fa = new Google2FA;
$this->totpSecret = $this->google2fa->generateSecretKey();
$this->user = User::factory()->create([
'tenant_id' => $this->tenant->id,
'email' => '2fa-test@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'totp_enabled' => true,
'totp_secret' => $this->totpSecret,
]);
});
test('login для 2FA-user НЕ создаёт authenticated session, возвращает requires_2fa=true', function () {
$response = $this->postJson('/api/auth/login', [
'email' => '2fa-test@example.ru',
'password' => 'secret-pass-123',
]);
$response->assertOk();
$response->assertJsonPath('requires_2fa', true);
$response->assertJsonPath('user.id', $this->user->id);
// /me должен вернуть 401 — Auth::login НЕ был вызван при login для 2FA.
$this->getJson('/api/auth/me')->assertStatus(401);
});
test('verify2fa с правильным TOTP-кодом завершает login (200 + requires_2fa=false)', function () {
// Step 1: login для 2FA-user.
$this->postJson('/api/auth/login', [
'email' => '2fa-test@example.ru',
'password' => 'secret-pass-123',
])->assertOk();
// Step 2: генерируем валидный TOTP-код для текущего timestep.
$validCode = $this->google2fa->getCurrentOtp($this->totpSecret);
$response = $this->postJson('/api/auth/2fa/verify', [
'code' => $validCode,
]);
$response->assertOk();
$response->assertJsonPath('requires_2fa', false);
$response->assertJsonPath('user.id', $this->user->id);
});
test('verify2fa с неверным кодом возвращает 422', function () {
$this->postJson('/api/auth/login', [
'email' => '2fa-test@example.ru',
'password' => 'secret-pass-123',
])->assertOk();
$response = $this->postJson('/api/auth/2fa/verify', [
'code' => '000000',
]);
$response->assertStatus(422);
$response->assertJsonPath('errors.code.0', 'Неверный код.');
});
test('verify2fa без предварительного login возвращает 422 (нет pending_user_id)', function () {
$validCode = $this->google2fa->getCurrentOtp($this->totpSecret);
$response = $this->postJson('/api/auth/2fa/verify', [
'code' => $validCode,
]);
$response->assertStatus(422);
$response->assertJsonPath('message', 'Сессия 2FA истекла. Войдите снова.');
});
test('verify2fa валидирует формат кода: 6 цифр', function () {
$this->postJson('/api/auth/login', [
'email' => '2fa-test@example.ru',
'password' => 'secret-pass-123',
])->assertOk();
// Слишком короткий.
$this->postJson('/api/auth/2fa/verify', ['code' => '12345'])
->assertStatus(422)
->assertJsonValidationErrors(['code']);
// Не цифры.
$this->postJson('/api/auth/2fa/verify', ['code' => 'ABC123'])
->assertStatus(422)
->assertJsonValidationErrors(['code']);
});
test('после успешной 2FA verify — последующий /me возвращает user (200)', function () {
$this->postJson('/api/auth/login', [
'email' => '2fa-test@example.ru',
'password' => 'secret-pass-123',
])->assertOk();
$validCode = $this->google2fa->getCurrentOtp($this->totpSecret);
$this->postJson('/api/auth/2fa/verify', ['code' => $validCode])->assertOk();
$this->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('user.id', $this->user->id);
});