Files
portal/app/tests/Feature/Auth/RecoveryCodeTest.php
T
Дмитрий c39d555e6f phase2(recovery-code): POST /api/auth/2fa/recovery-use + UseRecoveryCodeView
- AuthController::useRecoveryCode перебирает unused codes через Hash::check, нормализация (lowercase + remove dash/space)
- UserRecoveryCode Eloquent (UPDATED_AT=null — schema без updated_at)
- Rate-limit auth:recovery:{pending_user_id}|{ip} (5/15мин)
- Returns recovery_codes_remaining для UI-warning'а (sessionStorage на frontend)
- UseRecoveryCodeView.vue → POST /api/auth/2fa/recovery-use, /recovery-use route, autocomplete=one-time-code
- TwoFactorView "резервный код" ссылка /recovery → /recovery-use
- Pest +6 RecoveryCodeTest (91/91 за 12.77с, 319 assertions)
- Vitest +6 (166/166 за 11.47с)
- TODO: #3 2FA setup wizard (после этого /recovery view получит реальный source данных)
- Регресс: lint+type+format OK; build 849ms; story:build 21/28 за 30.36с; Pint+Stan passed
- CLAUDE.md v1.38→v1.39, реестр v1.47→v1.48

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:43:58 +03:00

125 lines
4.2 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\DB;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create([
'tenant_id' => $this->tenant->id,
'email' => 'recovery-user@example.ru',
'password_hash' => Hash::make('right-password-1234'),
'totp_enabled' => true,
'totp_secret' => 'JBSWY3DPEHPK3PXP', // dummy
]);
// 4 неиспользованных кодов + 1 использованный.
$codes = ['ABCD-1234', 'ZZZZ-9999', 'qqqq-7777', 'XXXX-5555', 'used-code'];
$this->plainCodes = ['abcd1234', 'zzzz9999', 'qqqq7777', 'xxxx5555', 'usedcode'];
foreach ($codes as $i => $code) {
DB::table('user_recovery_codes')->insert([
'user_id' => $this->user->id,
'code_hash' => Hash::make(mb_strtolower(str_replace('-', '', $code))),
'used_at' => $i === 4 ? now() : null,
]);
}
});
/**
* Helper: эмулировать pending-2FA state (как после успешного login).
*/
function startPending(TestCase $self, string $email = 'recovery-user@example.ru'): void
{
$self->postJson('/api/auth/login', [
'email' => $email,
'password' => 'right-password-1234',
])->assertOk()->assertJsonPath('requires_2fa', true);
}
test('POST /api/auth/2fa/recovery-use завершает login по правильному коду + помечает used + считает remaining', function () {
startPending($this);
$r = $this->postJson('/api/auth/2fa/recovery-use', [
'code' => 'ABCD-1234',
]);
$r->assertOk();
expect($r->json('user.email'))->toBe('recovery-user@example.ru');
expect($r->json('requires_2fa'))->toBeFalse();
// Осталось 3 неиспользованных (4 минус первый, который мы только что use'нули).
expect($r->json('recovery_codes_remaining'))->toBe(3);
// Code marked as used.
$usedCount = DB::table('user_recovery_codes')
->where('user_id', $this->user->id)
->whereNotNull('used_at')
->count();
expect($usedCount)->toBe(2); // 1 был изначально + 1 новый.
});
test('POST /api/auth/2fa/recovery-use 422 при неверном коде', function () {
startPending($this);
$r = $this->postJson('/api/auth/2fa/recovery-use', [
'code' => 'WRONG-CODE-9999',
]);
$r->assertStatus(422);
expect($r->json('errors.code'))->not->toBeEmpty();
});
test('POST /api/auth/2fa/recovery-use НЕ принимает уже использованный код', function () {
startPending($this);
$r = $this->postJson('/api/auth/2fa/recovery-use', [
'code' => 'used-code',
]);
$r->assertStatus(422);
});
test('POST /api/auth/2fa/recovery-use 422 без pending-сессии', function () {
// Не делаем login — нет auth.pending_user_id в session.
$r = $this->postJson('/api/auth/2fa/recovery-use', [
'code' => 'ABCD-1234',
]);
$r->assertStatus(422);
expect($r->json('message'))->toContain('Сессия 2FA');
});
test('POST /api/auth/2fa/recovery-use принимает разные форматы (с дефисом, без, разный регистр)', function () {
startPending($this);
// 'ZZZZ-9999' нормализуется в 'zzzz9999' → совпадает с stored.
$r = $this->postJson('/api/auth/2fa/recovery-use', [
'code' => 'zzzz 9999', // пробел вместо дефиса
]);
$r->assertOk();
});
test('POST /api/auth/2fa/recovery-use rate-limit: 5 неверных → 6-я = 429', function () {
startPending($this);
for ($i = 1; $i <= 5; $i++) {
$this->postJson('/api/auth/2fa/recovery-use', [
'code' => 'BAD-FAIL-'.$i,
])->assertStatus(422);
}
$r = $this->postJson('/api/auth/2fa/recovery-use', [
'code' => 'ABCD-1234', // правильный, но уже locked.
]);
$r->assertStatus(429);
expect($r->headers->get('Retry-After'))->not->toBeNull();
});