Files
portal/app/tests/Frontend/TwoFactorCard.spec.ts
T
Дмитрий c09bff3799 test(security): Q.DEFER.003 sub-B — TwoFactorCard 9 own-spec tests
Coverage uplift от 28% to 80%+: enable button visibility / disable button
visibility / chip status / setup wizard openSetup→confirm→codes / invalid
code error / disable flow valid+invalid password / closeSetup state reset.
vi.mock authApi для 3 endpoint'ов (init/confirm/disable).
2026-05-13 01:46:30 +03:00

176 lines
6.8 KiB
TypeScript

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<void>;
confirmSetup: () => Promise<void>;
closeSetup: () => void;
confirmDisable: () => Promise<void>;
};
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<typeof useAuthStore>['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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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);
});
});