From b191fbe91759136d21e5dba7104243d1b0d1550f Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Tue, 5 May 2026 22:01:32 +0200 Subject: [PATCH] refactor(auth): migrate MfaChallengeCard to useAuthStore.verifyMfa MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card consumed the API directly via useVerifyMfa() (TanStack Query mutation). Per Decision F's intent (store owns business logic, the component consumes typed results), the card now calls useAuthStore.verifyMfa() and pattern-matches on the MfaVerifyResult discriminated union. Changes: - MfaChallengeCard: drop useVerifyMfa import; call authStore.verifyMfa with camelCase args (sessionToken, trustDevice, deviceFingerprint, deviceName); local isVerifying ref replaces verifyMutation.isPending. On result.kind === 'authenticated' emit `verified` (no payload — the store has already refreshed user state); on 'failed' surface result.reason with a generic fallback. - emit signature: `verified: [data: unknown]` → `verified: []`. - login.vue: onMfaVerified no longer calls authStore.refreshUser — authStore.verifyMfa() refreshes internally. Page just routes to resolvePostLoginTarget(). Adds 4 vitest specs in components/auth/__tests__/MfaChallengeCard.spec.ts covering: success path emits `verified` with camelCase args, failure path shows reason and suppresses emit, trustDevice toggle honours fingerprint + device name, fallback message when reason is empty. Test count 209 → 213. Lint + typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/auth/MfaChallengeCard.vue | 38 +++-- .../auth/__tests__/MfaChallengeCard.spec.ts | 161 ++++++++++++++++++ apps/app/src/pages/login.vue | 8 +- 3 files changed, 187 insertions(+), 20 deletions(-) create mode 100644 apps/app/src/components/auth/__tests__/MfaChallengeCard.spec.ts 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() {