Files
portal/app/tests/Feature/Auth/TwoFactorSetupTest.php
T

156 lines
5.8 KiB
PHP
Raw Normal View History

<?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);
}
});