refactor(auth): migrate MfaChallengeCard to useAuthStore.verifyMfa
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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useSendMfaEmailCode, useVerifyMfa } from '@/composables/api/useMfa'
|
||||
import { useSendMfaEmailCode } from '@/composables/api/useMfa'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { generateDeviceFingerprint, getDeviceName } from '@/utils/deviceFingerprint'
|
||||
import type { MfaMethod } from '@/types/mfa'
|
||||
|
||||
@@ -11,12 +12,13 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
verified: [data: unknown]
|
||||
verified: []
|
||||
cancelled: []
|
||||
}>()
|
||||
|
||||
const verifyMutation = useVerifyMfa()
|
||||
const authStore = useAuthStore()
|
||||
const sendEmailMutation = useSendMfaEmailCode()
|
||||
const isVerifying = ref(false)
|
||||
|
||||
const selectedMethod = ref<string>(props.preferredMethod)
|
||||
const otpCode = ref('')
|
||||
@@ -93,25 +95,31 @@ async function handleVerify() {
|
||||
if (!code)
|
||||
return
|
||||
|
||||
isVerifying.value = true
|
||||
|
||||
try {
|
||||
const data = await verifyMutation.mutateAsync({
|
||||
mfa_session_token: props.mfaSessionToken,
|
||||
const result = await authStore.verifyMfa({
|
||||
sessionToken: props.mfaSessionToken,
|
||||
code,
|
||||
method: selectedMethod.value as MfaMethod,
|
||||
trust_device: trustDevice.value || undefined,
|
||||
device_fingerprint: trustDevice.value ? generateDeviceFingerprint() : undefined,
|
||||
device_name: trustDevice.value ? getDeviceName() : undefined,
|
||||
trustDevice: trustDevice.value || undefined,
|
||||
deviceFingerprint: trustDevice.value ? generateDeviceFingerprint() : undefined,
|
||||
deviceName: trustDevice.value ? getDeviceName() : undefined,
|
||||
})
|
||||
|
||||
emit('verified', data)
|
||||
}
|
||||
catch (err: unknown) {
|
||||
const ax = err as { response?: { data?: { message?: string } } }
|
||||
if (result.kind === 'authenticated') {
|
||||
emit('verified')
|
||||
|
||||
errorMessage.value = ax.response?.data?.message ?? 'Verificatie mislukt. Probeer het opnieuw.'
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = result.reason || 'Verificatie mislukt. Probeer het opnieuw.'
|
||||
otpCode.value = ''
|
||||
backupCode.value = ''
|
||||
}
|
||||
finally {
|
||||
isVerifying.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -193,7 +201,7 @@ async function handleVerify() {
|
||||
</p>
|
||||
<VOtpInput
|
||||
v-model="otpCode"
|
||||
:disabled="verifyMutation.isPending.value || timeLeft <= 0"
|
||||
:disabled="isVerifying || timeLeft <= 0"
|
||||
type="number"
|
||||
class="pa-0"
|
||||
@finish="onOtpFinish"
|
||||
@@ -230,7 +238,7 @@ async function handleVerify() {
|
||||
<VBtn
|
||||
block
|
||||
type="submit"
|
||||
:loading="verifyMutation.isPending.value"
|
||||
:loading="isVerifying"
|
||||
:disabled="timeLeft <= 0"
|
||||
>
|
||||
Verifiëren
|
||||
|
||||
161
apps/app/src/components/auth/__tests__/MfaChallengeCard.spec.ts
Normal file
161
apps/app/src/components/auth/__tests__/MfaChallengeCard.spec.ts
Normal file
@@ -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<MfaVerifyResult>>()
|
||||
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: '<div><slot /></div>' },
|
||||
VCardText: { template: '<div><slot /></div>' },
|
||||
VChip: { template: '<span><slot /></span>' },
|
||||
VTabs: { template: '<div><slot /></div>' },
|
||||
VTab: { template: '<button><slot /></button>' },
|
||||
VAlert: { template: '<div data-test="alert"><slot /></div>' },
|
||||
VForm: { template: '<form><slot /></form>' },
|
||||
VRow: { template: '<div><slot /></div>' },
|
||||
VCol: { template: '<div><slot /></div>' },
|
||||
VOtpInput: {
|
||||
name: 'VOtpInput',
|
||||
props: ['modelValue', 'disabled'],
|
||||
emits: ['update:modelValue', 'finish'],
|
||||
template: '<input data-test="otp" />',
|
||||
},
|
||||
AppTextField: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<input data-test="backup" />',
|
||||
},
|
||||
VCheckbox: {
|
||||
name: 'VCheckbox',
|
||||
props: ['modelValue', 'label'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<input type="checkbox" data-test="trust" />',
|
||||
},
|
||||
VBtn: { template: '<button data-test="verify-btn"><slot /></button>' },
|
||||
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<typeof makeWrapper>, 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()
|
||||
})
|
||||
})
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user