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>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
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 мин');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user