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