Files
portal/app/tests/Feature/Auth/AuthFlowIntegrationTest.php
T
Дмитрий 7efe9e3e83
Accessibility (Pa11y live) / a11y (push) Waiting to run
fix/tests: idempotency 2 auth-тестов — SharesSupplierPdo против утечки регистрации мимо отката
AuthFlowIntegrationTest и AuthLogCoverageTest писали регистрацию через BYPASSRLS pgsql_supplier без SharesSupplierPdo. Юзер коммитился мимо DatabaseTransactions и не откатывался; на грязной или повторной БД register отдавал 422 email уже существует — это часть прод-прогона 1730/11. Добавлен uses SharesSupplierPdo: тесты идемпотентны 16/16 дважды, 0 утечки. На свежей migrate-БД весь набор 1757 прошло 0 упало 1 skip. Разбор 11 в findings tails-doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 08:43:30 +03:00

173 lines
8.4 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Password;
use PragmaRX\Google2FA\Google2FA;
use Tests\Concerns\SharesSupplierPdo;
// SharesSupplierPdo: регистрация (RegistrationService) пишет users/tenants/
// email_verifications через BYPASSRLS-подключение pgsql_supplier. Без шаринга PDO
// эти записи коммитятся мимо DatabaseTransactions и не откатываются — тест
// перестаёт быть идемпотентным (повторный прогон/«грязная» БД → 422 «email уже
// существует»). Шаринг PDO кладёт supplier-записи в ту же откатываемую транзакцию.
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/**
* Reset the Auth manager's default guard and cached guard instances back to
* the 'web' SessionGuard.
*
* Necessary because auth:sanctum middleware calls Auth::shouldUse('sanctum')
* on every successfully-authenticated request, which permanently changes
* config('auth.defaults.guard') to 'sanctum' in the shared test application
* instance. Laravel feature tests reuse the same $this->app between HTTP calls,
* so this pollution persists across requests. Any subsequent call to
* Auth::login() (which internally calls Auth::guard()->login()) then resolves
* to the Sanctum RequestGuard — which has no login() method — and throws a
* BadMethodCallException.
*
* The reset must happen *before* any request whose controller calls Auth::login()
* without an explicit guard argument (i.e. login and 2fa/verify routes).
*/
function resetAuthToWebGuard(): void
{
app('auth')->forgetGuards();
app('auth')->setDefaultDriver('web');
}
it('full auth-flow writes all expected auth_log events', function () {
Notification::fake();
$tenant = Tenant::factory()->create();
// ── Step 1: Register ─────────────────────────────────────────────────────
$reg = $this->postJson('/api/auth/register', [
'email' => 'flow-test@example.ru',
'password' => 'secure-pass-1234',
'accept_offer' => true,
'accept_pdn' => true,
'captcha_token' => 'tok-123', // RegisterRequest требует captcha_token (CAPTCHA_FAKE_PASSES в phpunit.xml пропускает)
])->assertStatus(201);
// ── Step 1b: Confirm email (новый флоу G1/SP1) ───────────────────────────
// register создаёт pending-пользователя (is_active=false); до подтверждения
// почты login невозможен. Подтверждаем dev-кодом → активация + register_success.
$this->postJson('/api/auth/confirm-email', [
'email' => 'flow-test@example.ru',
'code' => $reg->json('_dev_plain_code'),
])->assertOk();
// logs: register_success
$user = User::where('email', 'flow-test@example.ru')->first();
expect($user)->not->toBeNull();
// confirm-email уже выполнил Auth::login; выходим, чтобы Step 2 проверил чистый login.
$this->postJson('/api/auth/logout');
resetAuthToWebGuard();
// ── Step 2: Login (no 2FA yet) — establish session auth ──────────────────
// No prior auth:sanctum request, so no reset needed here.
$this->postJson('/api/auth/login', [
'email' => 'flow-test@example.ru',
'password' => 'secure-pass-1234',
])->assertOk();
// logs: login_success (first direct login, 2FA not yet enabled)
// ── Step 3: 2FA init (session-authenticated via web guard) ───────────────
// auth:sanctum middleware → shouldUse('sanctum') → default becomes 'sanctum'
$this->postJson('/api/2fa/init')->assertOk();
// logs: 2fa_setup_init
$secret = session('auth.pending_totp_secret');
expect($secret)->not->toBeNull();
// ── Step 4: 2FA confirm ───────────────────────────────────────────────────
$google2fa = new Google2FA;
$code = $google2fa->getCurrentOtp($secret);
// auth:sanctum middleware → shouldUse('sanctum') again
$this->postJson('/api/2fa/confirm', ['code' => $code])->assertOk();
// logs: 2fa_setup_confirmed (totp_enabled now true)
// ── Step 5: Logout ────────────────────────────────────────────────────────
// auth:sanctum middleware → shouldUse('sanctum') again
$this->postJson('/api/auth/logout')->assertOk();
// logs: logout
// ── Step 6: Login with 2FA enabled ────────────────────────────────────────
// auth.defaults.guard is now 'sanctum' from previous auth:sanctum requests.
// Reset to 'web' so Auth::login() inside AuthController::login() finds the
// SessionGuard (which implements login()) rather than the RequestGuard.
resetAuthToWebGuard();
$this->postJson('/api/auth/login', [
'email' => 'flow-test@example.ru',
'password' => 'secure-pass-1234',
])->assertOk();
// requires_2fa=true, pending_user_id stored in session
// ── Step 7: 2FA verify — completes login ─────────────────────────────────
// No auth:sanctum request happened since the last reset, so no reset needed.
$validCode = $google2fa->getCurrentOtp($secret);
$this->postJson('/api/auth/2fa/verify', ['code' => $validCode])->assertOk();
// logs: 2fa_verify_success
// ── Step 8: 2FA disable (session-authenticated from step 7) ──────────────
// auth:sanctum middleware → shouldUse('sanctum') again
$this->postJson('/api/2fa/disable', ['password' => 'secure-pass-1234'])->assertOk();
// logs: 2fa_disabled
// ── Step 9: Logout ────────────────────────────────────────────────────────
// auth:sanctum middleware → shouldUse('sanctum') again
$this->postJson('/api/auth/logout')->assertOk();
// ── Step 10: Login without 2FA — direct login_success ────────────────────
// Reset again: auth.defaults.guard is 'sanctum' from Step 8+9 auth:sanctum.
resetAuthToWebGuard();
$this->postJson('/api/auth/login', [
'email' => 'flow-test@example.ru',
'password' => 'secure-pass-1234',
])->assertOk();
// logs: login_success (direct login, 2FA now disabled)
// ── Step 11: Forgot password ──────────────────────────────────────────────
$this->postJson('/api/auth/logout')->assertOk();
$this->postJson('/api/auth/forgot', [
'email' => 'flow-test@example.ru',
])->assertOk();
// logs: password_reset_requested
// ── Step 12: Reset password ───────────────────────────────────────────────
$token = Password::createToken($user);
$this->postJson('/api/auth/reset-password', [
'token' => $token,
'email' => 'flow-test@example.ru',
'password' => 'new-secure-pass-5678',
'password_confirmation' => 'new-secure-pass-5678',
])->assertOk();
// logs: password_reset_completed
// ── Assert all expected events were recorded for this user ────────────────
$events = DB::table('auth_log')
->where('user_id', $user->id)
->pluck('event')
->all();
expect($events)->toContain(
'register_success',
'2fa_setup_init',
'2fa_setup_confirmed',
'logout',
'login_success',
'2fa_verify_success',
'2fa_disabled',
'password_reset_requested',
'password_reset_completed',
);
});