Files
portal/app/tests/Frontend/UseRecoveryCodeView.spec.ts
T
Дмитрий c39d555e6f phase2(recovery-code): POST /api/auth/2fa/recovery-use + UseRecoveryCodeView
- AuthController::useRecoveryCode перебирает unused codes через Hash::check, нормализация (lowercase + remove dash/space)
- UserRecoveryCode Eloquent (UPDATED_AT=null — schema без updated_at)
- Rate-limit auth:recovery:{pending_user_id}|{ip} (5/15мин)
- Returns recovery_codes_remaining для UI-warning'а (sessionStorage на frontend)
- UseRecoveryCodeView.vue → POST /api/auth/2fa/recovery-use, /recovery-use route, autocomplete=one-time-code
- TwoFactorView "резервный код" ссылка /recovery → /recovery-use
- Pest +6 RecoveryCodeTest (91/91 за 12.77с, 319 assertions)
- Vitest +6 (166/166 за 11.47с)
- TODO: #3 2FA setup wizard (после этого /recovery view получит реальный source данных)
- Регресс: lint+type+format OK; build 849ms; story:build 21/28 за 30.36с; Pint+Stan passed
- CLAUDE.md v1.38→v1.39, реестр v1.47→v1.48

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

106 lines
3.8 KiB
TypeScript
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.
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
vi.mock('../../resources/js/api/auth', () => ({
login: vi.fn(),
register: vi.fn(),
me: vi.fn(),
logout: vi.fn(),
verifyTwoFactor: vi.fn(),
forgotPassword: vi.fn(),
resetPassword: vi.fn(),
useRecoveryCode: vi.fn(),
}));
vi.mock('../../resources/js/api/client', () => ({
extractRateLimitRetry: vi.fn(() => null),
extractValidationErrors: vi.fn(() => null),
extractErrorMessage: vi.fn(() => 'Ошибка'),
apiClient: {},
ensureCsrfCookie: vi.fn(),
}));
import * as authApi from '../../resources/js/api/auth';
import { useAuthStore } from '../../resources/js/stores/auth';
import UseRecoveryCodeView from '../../resources/js/views/auth/UseRecoveryCodeView.vue';
const mountView = async () => {
const pinia = createPinia();
setActivePinia(pinia);
// Симулируем pending-2FA state — иначе onMounted редиректит на /login.
const auth = useAuthStore();
auth.requires2fa = true;
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/recovery-use', name: 'recovery-use', component: UseRecoveryCodeView },
{ path: '/login', name: 'login', component: { template: '<div>stub</div>' } },
{ path: '/2fa', name: '2fa', component: { template: '<div>stub</div>' } },
{ path: '/dashboard', name: 'dashboard', component: { template: '<div>stub</div>' } },
],
});
await router.push('/recovery-use');
await router.isReady();
return mount(UseRecoveryCodeView, {
global: { plugins: [pinia, createVuetify(), router] },
});
};
describe('UseRecoveryCodeView.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('монтируется и содержит заголовок «Резервный код»', async () => {
const wrapper = await mountView();
expect(wrapper.text()).toContain('Резервный код');
});
it('содержит input с label XXXX-XXXX и autocomplete=one-time-code', async () => {
const wrapper = await mountView();
const input = wrapper.find('input');
expect(input.exists()).toBe(true);
expect(input.attributes('autocomplete')).toBe('one-time-code');
});
it('успешный submit вызывает auth.useRecoveryCode и сохраняет remaining в sessionStorage', async () => {
vi.mocked(authApi.useRecoveryCode).mockResolvedValue({
user: {
id: 1,
email: 'r@example.ru',
first_name: 'R',
last_name: 'C',
tenant_id: 1,
totp_enabled: true,
last_login_at: null,
},
requires_2fa: false,
recovery_codes_remaining: 3,
});
const wrapper = await mountView();
await wrapper.find('input').setValue('ABCD-1234');
await wrapper.find('form').trigger('submit.prevent');
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(authApi.useRecoveryCode).toHaveBeenCalledWith('ABCD-1234');
expect(window.sessionStorage.getItem('recovery_codes_remaining')).toBe('3');
});
it('при 429 показывает lockout-alert', async () => {
const wrapper = await mountView();
const auth = useAuthStore();
auth.lockoutSeconds = 600;
await wrapper.vm.$nextTick();
const alert = wrapper.find('[data-testid="lockout-alert"]');
expect(alert.exists()).toBe(true);
expect(alert.text()).toContain('10 мин');
});
});