diff --git a/apps/app/src/components/auth/MfaChallengeCard.vue b/apps/app/src/components/auth/MfaChallengeCard.vue index d675c28c..2d88cd01 100644 --- a/apps/app/src/components/auth/MfaChallengeCard.vue +++ b/apps/app/src/components/auth/MfaChallengeCard.vue @@ -1,5 +1,6 @@ @@ -193,7 +201,7 @@ async function handleVerify() {

Verifiëren diff --git a/apps/app/src/components/auth/__tests__/MfaChallengeCard.spec.ts b/apps/app/src/components/auth/__tests__/MfaChallengeCard.spec.ts new file mode 100644 index 00000000..fbed77fd --- /dev/null +++ b/apps/app/src/components/auth/__tests__/MfaChallengeCard.spec.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import type { MfaVerifyResult } from '@/types/auth' + +const mockVerifyMfa = vi.fn<(args: unknown) => Promise>() +const mockSendEmailMutateAsync = vi.fn() + +vi.mock('@/stores/useAuthStore', () => ({ + useAuthStore: () => ({ + verifyMfa: mockVerifyMfa, + }), +})) + +vi.mock('@/composables/api/useMfa', () => ({ + useSendMfaEmailCode: () => ({ + mutateAsync: mockSendEmailMutateAsync, + isPending: { value: false }, + }), +})) + +vi.mock('@/utils/deviceFingerprint', () => ({ + generateDeviceFingerprint: () => 'fp-test', + getDeviceName: () => 'Chrome on macOS', +})) + +const MfaChallengeCard = (await import('../MfaChallengeCard.vue')).default + +const stubs = { + VCard: { template: '
' }, + VCardText: { template: '
' }, + VChip: { template: '' }, + VTabs: { template: '
' }, + VTab: { template: '' }, + VAlert: { template: '
' }, + VForm: { template: '
' }, + VRow: { template: '
' }, + VCol: { template: '
' }, + VOtpInput: { + name: 'VOtpInput', + props: ['modelValue', 'disabled'], + emits: ['update:modelValue', 'finish'], + template: '', + }, + AppTextField: { + props: ['modelValue'], + emits: ['update:modelValue'], + template: '', + }, + VCheckbox: { + name: 'VCheckbox', + props: ['modelValue', 'label'], + emits: ['update:modelValue'], + template: '', + }, + VBtn: { template: '' }, + VIcon: true, +} + +describe('MfaChallengeCard — useAuthStore.verifyMfa migration (WS-3 PR-B2a)', () => { + beforeEach(() => { + mockVerifyMfa.mockReset() + mockSendEmailMutateAsync.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + function makeWrapper() { + return mount(MfaChallengeCard, { + props: { + mfaSessionToken: 'session-abc', + methods: ['totp'], + preferredMethod: 'totp', + expiresIn: 600, + }, + global: { + stubs, + mocks: { + $vuetify: { display: { smAndUp: true } }, + }, + }, + }) + } + + async function triggerVerify(wrapper: ReturnType, code: string) { + const otp = wrapper.findComponent({ name: 'VOtpInput' }) + + otp.vm.$emit('finish', code) + await nextTick() + await nextTick() + } + + it('calls authStore.verifyMfa with camelCase args and emits `verified` on success', async () => { + mockVerifyMfa.mockResolvedValue({ kind: 'authenticated' }) + + const wrapper = makeWrapper() + + await triggerVerify(wrapper, '123456') + + expect(mockVerifyMfa).toHaveBeenCalledTimes(1) + expect(mockVerifyMfa).toHaveBeenCalledWith({ + sessionToken: 'session-abc', + code: '123456', + method: 'totp', + trustDevice: undefined, + deviceFingerprint: undefined, + deviceName: undefined, + }) + + expect(wrapper.emitted('verified')).toHaveLength(1) + expect(wrapper.emitted('verified')?.[0]).toEqual([]) + }) + + it('displays the result.reason on failure and does NOT emit verified', async () => { + mockVerifyMfa.mockResolvedValue({ + kind: 'failed', + reason: 'Code is incorrect.', + }) + + const wrapper = makeWrapper() + + await triggerVerify(wrapper, '999999') + + expect(wrapper.text()).toContain('Code is incorrect.') + expect(wrapper.emitted('verified')).toBeUndefined() + }) + + it('honours trustDevice flag with fingerprint + device name', async () => { + mockVerifyMfa.mockResolvedValue({ kind: 'authenticated' }) + + const wrapper = makeWrapper() + const trust = wrapper.findComponent({ name: 'VCheckbox' }) + + trust.vm.$emit('update:modelValue', true) + await nextTick() + + await triggerVerify(wrapper, '123456') + + expect(mockVerifyMfa).toHaveBeenCalledWith({ + sessionToken: 'session-abc', + code: '123456', + method: 'totp', + trustDevice: true, + deviceFingerprint: 'fp-test', + deviceName: 'Chrome on macOS', + }) + }) + + it('uses a generic fallback message when result.reason is empty', async () => { + mockVerifyMfa.mockResolvedValue({ kind: 'failed', reason: '' }) + + const wrapper = makeWrapper() + + await triggerVerify(wrapper, '999999') + + expect(wrapper.text()).toContain('Verificatie mislukt') + expect(wrapper.emitted('verified')).toBeUndefined() + }) +}) diff --git a/apps/app/src/pages/login.vue b/apps/app/src/pages/login.vue index 009535f6..42ecb448 100644 --- a/apps/app/src/pages/login.vue +++ b/apps/app/src/pages/login.vue @@ -121,11 +121,9 @@ async function handleLogin() { } function onMfaVerified() { - // After MFA verify, the response sets the auth cookie. refreshUser() - // hydrates the store from /auth/me with the new cookie. - authStore.refreshUser().then(() => { - router.replace(resolvePostLoginTarget()) - }) + // useAuthStore.verifyMfa() already refreshed the store post-cookie; + // the page just needs to route to the resolved landing target. + router.replace(resolvePostLoginTarget()) } function onMfaCancelled() {