Files
portal/app/tests/Feature/Auth/TwoFactorSetupTest.php
T
Дмитрий 73e64128dc phase2(2fa-setup): wizard init+confirm+disable+regenerate в SettingsView/SecurityTab
- TwoFactorSetupController (auth:sanctum): /api/2fa/{init,confirm,disable,regenerate-recovery-codes}
- init секрет в session (не в БД), QR-URL otpauth://; confirm активирует 2FA + 8 recovery codes
- disable/regenerate требуют password-confirmation
- User.casts: totp_secret => encrypted

Schema v8.7→v8.8: users.totp_secret VARCHAR(255) → TEXT (encrypted ~256 chars)
Migration fix: explicit ALTER TABLE webhook_dedup_keys ADD FK после DB::unprepared (PDO глотал FK на partitioned)
PartitionsCreateMonthsTest fix: DETACH PARTITION + DROP вместо DROP CASCADE

Frontend: SecurityTab реальная логика (setup wizard 3 шага, disable, regenerate dialogs)

- Pest +10 (101/101 за 13.37с, 364 assertions)
- Vitest 166/166
- CLAUDE.md v1.39→v1.40, реестр v1.48→v1.49, schema v8.7→v8.8

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

156 lines
5.8 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 App\Models\UserRecoveryCode;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use PragmaRX\Google2FA\Google2FA;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create([
'tenant_id' => $this->tenant->id,
'email' => 'setup-2fa@example.ru',
'password_hash' => Hash::make('master-password-9876'),
'totp_enabled' => false,
'totp_secret' => null,
]);
$this->actingAs($this->user);
});
test('POST /api/2fa/init возвращает secret + qr_url и кладёт secret в session', function () {
$r = $this->postJson('/api/2fa/init');
$r->assertOk();
expect($r->json('secret'))->not->toBeEmpty();
expect($r->json('qr_url'))->toContain('otpauth://totp/');
expect($r->json('qr_url'))->toContain(rawurlencode('Лидерра'));
});
test('POST /api/2fa/init 422 если 2FA уже включена', function () {
$this->user->forceFill(['totp_enabled' => true, 'totp_secret' => 'EXISTSECRET'])->save();
$r = $this->postJson('/api/2fa/init');
$r->assertStatus(422);
});
test('POST /api/2fa/confirm с правильным кодом активирует 2FA + возвращает 8 recovery-кодов', function () {
$this->postJson('/api/2fa/init')->assertOk();
$secret = session('auth.pending_totp_secret');
expect($secret)->not->toBeEmpty();
$google2fa = new Google2FA;
$code = $google2fa->getCurrentOtp($secret);
$r = $this->postJson('/api/2fa/confirm', ['code' => $code]);
$r->assertOk();
expect($r->json('recovery_codes'))->toHaveCount(8);
foreach ($r->json('recovery_codes') as $plainCode) {
// Формат XXXX-XXXX (8 hex + дефис посередине = 9 chars).
expect($plainCode)->toMatch('/^[a-z0-9]{4}-[a-z0-9]{4}$/');
}
// Запись в users применилась.
$this->user->refresh();
expect($this->user->totp_enabled)->toBeTrue();
// totp_secret через encrypted-cast возвращает plain.
expect($this->user->totp_secret)->toBe($secret);
// Ровно 8 неиспользованных recovery-кодов в БД.
expect(UserRecoveryCode::query()->where('user_id', $this->user->id)->count())->toBe(8);
});
test('POST /api/2fa/confirm с неверным кодом → 422 и 2FA остаётся выключенной', function () {
$this->postJson('/api/2fa/init')->assertOk();
$r = $this->postJson('/api/2fa/confirm', ['code' => '000000']);
$r->assertStatus(422);
$this->user->refresh();
expect($this->user->totp_enabled)->toBeFalse();
});
test('POST /api/2fa/confirm без init → 422', function () {
$r = $this->postJson('/api/2fa/confirm', ['code' => '123456']);
$r->assertStatus(422);
expect($r->json('message'))->toContain('Не начат setup');
});
test('POST /api/2fa/disable с правильным паролем выключает 2FA + удаляет recovery codes', function () {
// Включаем 2FA через wizard.
$this->postJson('/api/2fa/init')->assertOk();
$secret = session('auth.pending_totp_secret');
$code = (new Google2FA)->getCurrentOtp($secret);
$this->postJson('/api/2fa/confirm', ['code' => $code])->assertOk();
expect(UserRecoveryCode::query()->where('user_id', $this->user->id)->count())->toBe(8);
$r = $this->postJson('/api/2fa/disable', ['password' => 'master-password-9876']);
$r->assertOk();
$this->user->refresh();
expect($this->user->totp_enabled)->toBeFalse();
expect($this->user->totp_secret)->toBeNull();
expect(UserRecoveryCode::query()->where('user_id', $this->user->id)->count())->toBe(0);
});
test('POST /api/2fa/disable с неверным паролем → 422', function () {
$this->user->forceFill(['totp_enabled' => true, 'totp_secret' => 'XXX'])->save();
$r = $this->postJson('/api/2fa/disable', ['password' => 'wrong-password']);
$r->assertStatus(422);
});
test('POST /api/2fa/regenerate-recovery-codes возвращает новые 8 кодов', function () {
// Сначала setup 2FA.
$this->postJson('/api/2fa/init')->assertOk();
$secret = session('auth.pending_totp_secret');
$code = (new Google2FA)->getCurrentOtp($secret);
$confirm = $this->postJson('/api/2fa/confirm', ['code' => $code])->assertOk();
$oldCodes = $confirm->json('recovery_codes');
$oldHashes = DB::table('user_recovery_codes')
->where('user_id', $this->user->id)
->pluck('code_hash')
->toArray();
$r = $this->postJson('/api/2fa/regenerate-recovery-codes', ['password' => 'master-password-9876']);
$r->assertOk();
expect($r->json('recovery_codes'))->toHaveCount(8);
// Старые хеши удалены, новые отличаются.
$newHashes = DB::table('user_recovery_codes')
->where('user_id', $this->user->id)
->pluck('code_hash')
->toArray();
expect($newHashes)->toHaveCount(8);
expect(array_intersect($oldHashes, $newHashes))->toBe([]);
expect($r->json('recovery_codes'))->not->toBe($oldCodes);
});
test('POST /api/2fa/regenerate-recovery-codes 422 если 2FA не включена', function () {
$r = $this->postJson('/api/2fa/regenerate-recovery-codes', ['password' => 'master-password-9876']);
$r->assertStatus(422);
});
test('Все 4 эндпоинта /api/2fa/* требуют auth:sanctum (401 без login)', function () {
auth()->logout();
foreach (['init', 'confirm', 'disable', 'regenerate-recovery-codes'] as $endpoint) {
$this->postJson('/api/2fa/'.$endpoint, ['code' => '000000', 'password' => 'x'])
->assertStatus(401);
}
});