156 lines
5.8 KiB
PHP
156 lines
5.8 KiB
PHP
|
|
<?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);
|
|||
|
|
}
|
|||
|
|
});
|