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