import { describe, it, expect, beforeEach, vi } from 'vitest'; import { mount, flushPromises } from '@vue/test-utils'; import { createPinia, setActivePinia } from 'pinia'; import { createVuetify } from 'vuetify'; vi.mock('../../resources/js/api/auth', () => ({ twoFactorInit: vi.fn(), twoFactorConfirm: vi.fn(), twoFactorDisable: vi.fn(), })); import TwoFactorCard from '../../resources/js/components/settings/security/TwoFactorCard.vue'; import * as authApi from '../../resources/js/api/auth'; import { useAuthStore } from '../../resources/js/stores/auth'; const vuetify = createVuetify(); type CardVm = { setupOpen: boolean; setupStep: 'init' | 'confirm' | 'codes'; setupSecret: string; setupQrUrl: string; setupCode: string; setupRecoveryCodes: string[]; setupError: string; setupBusy: boolean; disableOpen: boolean; disablePassword: string; disableError: string; openSetup: () => Promise; confirmSetup: () => Promise; closeSetup: () => void; confirmDisable: () => Promise; }; describe('TwoFactorCard (Q.DEFER.003 sub-B)', () => { beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); }); const factory = (totpEnabled: boolean) => { // Set auth state BEFORE mount — иначе computed has2fa зафиксируется false // на первом render и кнопка не появится reactive'но (Pinia store reactivity quirk, // see Task 2 RecoveryCodesCard.spec.ts learning). const auth = useAuthStore(); auth.user = { id: 1, email: 'test@demo.local', first_name: 'Test', last_name: 'User', tenant_id: 1, totp_enabled: totpEnabled, last_login_at: null, } as unknown as ReturnType['user']; const wrapper = mount(TwoFactorCard, { global: { plugins: [vuetify], stubs: { VDialog: true } }, }); return wrapper; }; it('shows «Включить 2FA» button when 2FA disabled', async () => { const wrapper = factory(false); await flushPromises(); const btn = wrapper.find('[data-testid="enable-2fa-btn"]'); expect(btn.exists()).toBe(true); expect(btn.text()).toContain('Включить 2FA'); }); it('shows «Отключить 2FA» button when 2FA enabled', async () => { const wrapper = factory(true); await flushPromises(); const btn = wrapper.find('[data-testid="disable-2fa-btn"]'); expect(btn.exists()).toBe(true); expect(btn.text()).toContain('Отключить 2FA'); }); it('shows «включена» chip when 2FA enabled', async () => { const wrapper = factory(true); await flushPromises(); expect(wrapper.text()).toContain('включена'); }); it('openSetup() initializes wizard and transitions init→confirm with QR secret', async () => { (authApi.twoFactorInit as ReturnType).mockResolvedValue({ secret: 'JBSWY3DPEHPK3PXP', qr_url: 'otpauth://totp/test', }); const wrapper = factory(false); await flushPromises(); const vm = wrapper.vm as unknown as CardVm; await vm.openSetup(); await flushPromises(); expect(vm.setupOpen).toBe(true); expect(vm.setupStep).toBe('confirm'); expect(vm.setupSecret).toBe('JBSWY3DPEHPK3PXP'); expect(vm.setupQrUrl).toBe('otpauth://totp/test'); expect(authApi.twoFactorInit).toHaveBeenCalled(); }); it('confirmSetup() valid code → step=codes + 8 recovery codes + auth.totp_enabled=true', async () => { const codes = ['A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8']; (authApi.twoFactorConfirm as ReturnType).mockResolvedValue({ recovery_codes: codes, }); const wrapper = factory(false); await flushPromises(); const vm = wrapper.vm as unknown as CardVm; vm.setupCode = '123456'; await vm.confirmSetup(); await flushPromises(); expect(vm.setupStep).toBe('codes'); expect(vm.setupRecoveryCodes).toEqual(codes); const auth = useAuthStore(); expect(auth.user?.totp_enabled).toBe(true); expect(authApi.twoFactorConfirm).toHaveBeenCalledWith('123456'); }); it('confirmSetup() invalid code → setupError set, step stays confirm', async () => { (authApi.twoFactorConfirm as ReturnType).mockRejectedValue(new Error('422')); const wrapper = factory(false); await flushPromises(); const vm = wrapper.vm as unknown as CardVm; vm.setupStep = 'confirm'; vm.setupCode = '999999'; await vm.confirmSetup(); await flushPromises(); expect(vm.setupError).toBe('Неверный код. Проверьте время на устройстве.'); expect(vm.setupStep).toBe('confirm'); }); it('closeSetup() resets all setup state', async () => { const wrapper = factory(false); await flushPromises(); const vm = wrapper.vm as unknown as CardVm; vm.setupOpen = true; vm.setupSecret = 'SECRET'; vm.setupQrUrl = 'url'; vm.setupCode = '123'; vm.closeSetup(); expect(vm.setupOpen).toBe(false); expect(vm.setupSecret).toBe(''); expect(vm.setupQrUrl).toBe(''); expect(vm.setupCode).toBe(''); }); it('confirmDisable() valid password → auth.totp_enabled=false + dialog closes', async () => { (authApi.twoFactorDisable as ReturnType).mockResolvedValue(undefined); const wrapper = factory(true); await flushPromises(); const vm = wrapper.vm as unknown as CardVm; vm.disableOpen = true; vm.disablePassword = 'correctPass'; await vm.confirmDisable(); await flushPromises(); const auth = useAuthStore(); expect(auth.user?.totp_enabled).toBe(false); expect(vm.disableOpen).toBe(false); expect(vm.disablePassword).toBe(''); expect(authApi.twoFactorDisable).toHaveBeenCalledWith('correctPass'); }); it('confirmDisable() invalid password → disableError set, totp stays enabled', async () => { (authApi.twoFactorDisable as ReturnType).mockRejectedValue(new Error('401')); const wrapper = factory(true); await flushPromises(); const vm = wrapper.vm as unknown as CardVm; vm.disablePassword = 'wrongPass'; await vm.confirmDisable(); await flushPromises(); expect(vm.disableError).toBe('Неверный пароль.'); const auth = useAuthStore(); expect(auth.user?.totp_enabled).toBe(true); }); });