mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-03 22:47:09 +08:00
fix(user): harden profile and MFA workflows (#666)
This commit is contained in:
@@ -46,6 +46,9 @@ const qrCodeImage = ref('')
|
||||
// 二维码信息
|
||||
const qrCode = ref('')
|
||||
|
||||
// 每次生成请求独占一个序号,关闭、重开或重试都会使旧请求失效。
|
||||
let otpGeneration = 0
|
||||
|
||||
// 清空当前 OTP 设置流程的临时数据。
|
||||
function resetOtpSetupState() {
|
||||
qrCodeImage.value = ''
|
||||
@@ -64,6 +67,7 @@ function setOtpGenerateError(message?: string) {
|
||||
|
||||
// 为当前用户获取 OTP URI 并生成二维码图片。
|
||||
async function getOtpUri() {
|
||||
const generation = ++otpGeneration
|
||||
resetOtpSetupState()
|
||||
// 如果已经启用OTP,只打开对话框,不生成新的二维码
|
||||
if (props.isOtp) {
|
||||
@@ -80,23 +84,26 @@ async function getOtpUri() {
|
||||
const uri = result.data?.uri?.trim()
|
||||
const otpSecret = result.data?.secret?.trim()
|
||||
|
||||
if (result.success && uri) {
|
||||
otpUri.value = uri
|
||||
secret.value = otpSecret || ''
|
||||
qrCode.value = uri
|
||||
// 生成二维码图片
|
||||
qrCodeImage.value = await QRCode.toDataURL(uri, {
|
||||
if (result.success && uri && otpSecret) {
|
||||
const image = await QRCode.toDataURL(uri, {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
})
|
||||
if (generation !== otpGeneration || !props.modelValue) return
|
||||
otpUri.value = uri
|
||||
secret.value = otpSecret
|
||||
qrCode.value = uri
|
||||
qrCodeImage.value = image
|
||||
} else {
|
||||
if (generation !== otpGeneration || !props.modelValue) return
|
||||
setOtpGenerateError(result.message || 'empty otp uri')
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== otpGeneration || !props.modelValue) return
|
||||
console.error(error)
|
||||
setOtpGenerateError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
otpLoading.value = false
|
||||
if (generation === otpGeneration) otpLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +166,7 @@ watch(
|
||||
otpPassword.value = ''
|
||||
} else {
|
||||
// 弹窗关闭时,清空数据
|
||||
otpGeneration += 1
|
||||
resetOtpSetupState()
|
||||
otpLoading.value = false
|
||||
otpPassword.value = ''
|
||||
@@ -166,6 +174,11 @@ watch(
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 组件卸载代表 OTP 会话结束,迟到的生成结果不得再产生界面副作用。
|
||||
onUnmounted(() => {
|
||||
otpGeneration += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, PassKey } from '@/api/types'
|
||||
import { isAxiosError } from 'axios'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
@@ -35,13 +36,27 @@ const show = computed({
|
||||
// PassKey列表
|
||||
const passkeyList = ref<PassKey[]>([])
|
||||
|
||||
// 列表请求状态用于区分加载失败和合法空列表。
|
||||
const passkeyListLoading = ref(false)
|
||||
const passkeyListFailed = ref(false)
|
||||
|
||||
// PassKey注册loading
|
||||
const passkeyRegistering = ref(false)
|
||||
|
||||
// PassKey名称
|
||||
const passkeyName = ref('')
|
||||
|
||||
const passkeyTransactionToken = ref('')
|
||||
let passkeyListGeneration = 0
|
||||
let passkeyRegistrationGeneration = 0
|
||||
let passkeyRegistrationAbortController: AbortController | null = null
|
||||
|
||||
// 注册尝试只属于当前对话框会话,结束会话或开始新尝试时取消整条 WebAuthn 链路。
|
||||
function invalidatePasskeyRegistration() {
|
||||
passkeyRegistrationGeneration += 1
|
||||
passkeyRegistrationAbortController?.abort()
|
||||
passkeyRegistrationAbortController = null
|
||||
passkeyRegistering.value = false
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string) {
|
||||
@@ -50,14 +65,24 @@ function formatDate(dateStr: string) {
|
||||
|
||||
// 获取PassKey列表
|
||||
async function fetchPassKeyList() {
|
||||
const generation = ++passkeyListGeneration
|
||||
passkeyListLoading.value = true
|
||||
passkeyListFailed.value = false
|
||||
try {
|
||||
const result = (await api.get('mfa/passkey/list')) as ApiResponse<PassKey[]>
|
||||
if (generation !== passkeyListGeneration || !props.modelValue) return
|
||||
if (result.success) {
|
||||
passkeyList.value = result.data || []
|
||||
emit('update:passkeyList', passkeyList.value)
|
||||
} else {
|
||||
passkeyListFailed.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== passkeyListGeneration || !props.modelValue) return
|
||||
passkeyListFailed.value = true
|
||||
console.error(error)
|
||||
} finally {
|
||||
if (generation === passkeyListGeneration) passkeyListLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,12 +103,25 @@ async function registerPassKey() {
|
||||
return
|
||||
}
|
||||
|
||||
invalidatePasskeyRegistration()
|
||||
const generation = passkeyRegistrationGeneration
|
||||
const registrationAbortController = new AbortController()
|
||||
passkeyRegistrationAbortController = registrationAbortController
|
||||
const registrationName = passkeyName.value
|
||||
passkeyRegistering.value = true
|
||||
try {
|
||||
// 1. 开始注册
|
||||
const startResult = (await api.post('mfa/passkey/register/start', {
|
||||
name: passkeyName.value,
|
||||
})) as ApiResponse<{ options: string; transaction_token: string }>
|
||||
const startResult = (await api.post(
|
||||
'mfa/passkey/register/start',
|
||||
{
|
||||
name: registrationName,
|
||||
},
|
||||
{
|
||||
signal: registrationAbortController.signal,
|
||||
},
|
||||
)) as ApiResponse<{ options: string; transaction_token: string }>
|
||||
|
||||
if (generation !== passkeyRegistrationGeneration || !props.modelValue) return
|
||||
|
||||
if (!startResult.success) {
|
||||
$toast.error(startResult.message || t('profile.passkeyRegisterFailed'))
|
||||
@@ -92,7 +130,6 @@ async function registerPassKey() {
|
||||
|
||||
const { options, transaction_token: transactionToken } = startResult.data
|
||||
const publicKeyOptions = JSON.parse(options)
|
||||
passkeyTransactionToken.value = transactionToken
|
||||
|
||||
// 2. 调用WebAuthn API
|
||||
const credential = (await navigator.credentials.create({
|
||||
@@ -108,8 +145,11 @@ async function registerPassKey() {
|
||||
id: base64UrlToUint8Array(cred.id),
|
||||
})),
|
||||
},
|
||||
signal: registrationAbortController.signal,
|
||||
})) as PublicKeyCredential
|
||||
|
||||
if (generation !== passkeyRegistrationGeneration || !props.modelValue) return
|
||||
|
||||
if (!credential) {
|
||||
$toast.error(t('profile.passkeyRegisterCancelled'))
|
||||
return
|
||||
@@ -129,11 +169,19 @@ async function registerPassKey() {
|
||||
}
|
||||
|
||||
// 4. 完成注册
|
||||
const finishResult = (await api.post('mfa/passkey/register/finish', {
|
||||
credential: credentialJSON,
|
||||
transaction_token: passkeyTransactionToken.value,
|
||||
name: passkeyName.value,
|
||||
})) as ApiResponse
|
||||
const finishResult = (await api.post(
|
||||
'mfa/passkey/register/finish',
|
||||
{
|
||||
credential: credentialJSON,
|
||||
transaction_token: transactionToken,
|
||||
name: registrationName,
|
||||
},
|
||||
{
|
||||
signal: registrationAbortController.signal,
|
||||
},
|
||||
)) as ApiResponse
|
||||
|
||||
if (generation !== passkeyRegistrationGeneration || !props.modelValue) return
|
||||
|
||||
if (finishResult.success) {
|
||||
$toast.success(t('profile.passkeyRegisterSuccess'))
|
||||
@@ -142,21 +190,25 @@ async function registerPassKey() {
|
||||
} else {
|
||||
$toast.error(finishResult.message || t('profile.passkeyRegisterFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
if (generation !== passkeyRegistrationGeneration || !props.modelValue) return
|
||||
console.error('PassKey注册失败:', error)
|
||||
if (error.name === 'NotAllowedError') {
|
||||
if (error instanceof Error && error.name === 'NotAllowedError') {
|
||||
$toast.error(t('profile.passkeyRegisterCancelled'))
|
||||
} else if (error.name === 'NotSupportedError') {
|
||||
} else if (error instanceof Error && error.name === 'NotSupportedError') {
|
||||
$toast.error(t('login.passkeyNotSupported'))
|
||||
} else if (error.message?.includes('start failed')) {
|
||||
} else if (error instanceof Error && error.message.includes('start failed')) {
|
||||
$toast.error(t('login.passkeyLoginStartFailed'))
|
||||
} else if (error.response) {
|
||||
} else if (isAxiosError(error) && error.response) {
|
||||
$toast.error(error.response.data?.message || error.response.data?.detail || t('profile.passkeyRegisterFailed'))
|
||||
} else {
|
||||
$toast.error(error.message || t('profile.passkeyRegisterFailed'))
|
||||
$toast.error(error instanceof Error ? error.message : t('profile.passkeyRegisterFailed'))
|
||||
}
|
||||
} finally {
|
||||
passkeyRegistering.value = false
|
||||
if (generation === passkeyRegistrationGeneration) passkeyRegistering.value = false
|
||||
if (passkeyRegistrationAbortController === registrationAbortController) {
|
||||
passkeyRegistrationAbortController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,13 +246,21 @@ watch(
|
||||
passkeyName.value = ''
|
||||
} else {
|
||||
// 弹窗关闭时,清空数据
|
||||
passkeyListGeneration += 1
|
||||
invalidatePasskeyRegistration()
|
||||
passkeyName.value = ''
|
||||
passkeyTransactionToken.value = ''
|
||||
passkeyList.value = []
|
||||
passkeyListLoading.value = false
|
||||
passkeyListFailed.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
passkeyListGeneration += 1
|
||||
invalidatePasskeyRegistration()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -250,7 +310,21 @@ watch(
|
||||
</VCard>
|
||||
|
||||
<!-- 已注册的通行密钥列表 -->
|
||||
<div v-if="passkeyList.length > 0" class="mt-6 px-4">
|
||||
<LoadingBanner v-if="passkeyListLoading" class="mt-6" />
|
||||
<VAlert
|
||||
v-else-if="passkeyListFailed"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
:title="t('common.serverConnectionFailed')"
|
||||
class="mt-6"
|
||||
>
|
||||
<template #append>
|
||||
<VBtn color="error" variant="text" :loading="passkeyListLoading" @click="fetchPassKeyList">
|
||||
{{ t('common.retry') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
</VAlert>
|
||||
<div v-else-if="passkeyList.length > 0" class="mt-6 px-4">
|
||||
<div
|
||||
v-for="passkey in passkeyList"
|
||||
:key="passkey.id"
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import OTPAuthDialog from '@/components/dialog/OTPAuthDialog.vue'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiPost: vi.fn(),
|
||||
qrCode: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({ default: { post: (...args: unknown[]) => mocks.apiPost(...args) } }))
|
||||
vi.mock('qrcode', () => ({ default: { toDataURL: (...args: unknown[]) => mocks.qrCode(...args) } }))
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
const DialogCloseBtn = defineComponent({
|
||||
name: 'VDialogCloseBtn',
|
||||
emits: ['click'],
|
||||
setup(_props, { emit }) {
|
||||
return () => h('button', { 'aria-label': '关闭', onClick: () => emit('click'), type: 'button' })
|
||||
},
|
||||
})
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(resolvePromise => {
|
||||
resolve = resolvePromise
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function renderDialog(isOtp = false) {
|
||||
return renderWithProviders(OTPAuthDialog, {
|
||||
props: { isOtp, modelValue: true },
|
||||
global: { stubs: { VDialogCloseBtn: DialogCloseBtn } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('OTPAuthDialog', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiPost.mockReset()
|
||||
mocks.qrCode.mockReset()
|
||||
mocks.qrCode.mockResolvedValue('data:image/png;base64,otp')
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('generates a fresh URI, secret, and QR code when an inactive session opens', async () => {
|
||||
mocks.apiPost.mockResolvedValue({
|
||||
data: { secret: 'SECRET-ONE', uri: 'otpauth://totp/MoviePilot:alice?secret=SECRET-ONE' },
|
||||
success: true,
|
||||
})
|
||||
await renderDialog()
|
||||
|
||||
expect(await screen.findByText('SECRET-ONE')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: '设置二次验证' })).toHaveAttribute('src', 'data:image/png;base64,otp')
|
||||
expect(mocks.qrCode).toHaveBeenCalledWith('otpauth://totp/MoviePilot:alice?secret=SECRET-ONE', {
|
||||
margin: 1,
|
||||
width: 200,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['URI', { data: { secret: 'SECRET', uri: '' }, success: true }],
|
||||
['secret', { data: { secret: '', uri: 'otpauth://totp/MoviePilot:alice' }, success: true }],
|
||||
])('treats a missing %s as a retryable generation failure', async (_field, response) => {
|
||||
mocks.apiPost.mockResolvedValue(response)
|
||||
await renderDialog()
|
||||
|
||||
expect(await screen.findByRole('button', { name: '重试' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('img', { name: '设置二次验证' })).not.toBeInTheDocument()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('获取身份验证器设置失败'))
|
||||
})
|
||||
|
||||
it('retries after a generation HTTP failure', async () => {
|
||||
mocks.apiPost.mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({
|
||||
data: { secret: 'SECRET-TWO', uri: 'otpauth://totp/MoviePilot:alice?secret=SECRET-TWO' },
|
||||
success: true,
|
||||
})
|
||||
await renderDialog()
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '重试' }))
|
||||
expect(await screen.findByText('SECRET-TWO')).toBeInTheDocument()
|
||||
expect(mocks.apiPost).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('enables OTP only after a successful verification', async () => {
|
||||
mocks.apiPost
|
||||
.mockResolvedValueOnce({
|
||||
data: { secret: 'SECRET', uri: 'otpauth://totp/MoviePilot:alice?secret=SECRET' },
|
||||
success: true,
|
||||
})
|
||||
.mockResolvedValueOnce({ success: false, message: '验证码错误' })
|
||||
.mockResolvedValueOnce({ success: true })
|
||||
const { emitted } = await renderDialog()
|
||||
await screen.findByText('SECRET')
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '确认' }))
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('请填写6位验证码')
|
||||
|
||||
await fireEvent.update(screen.getByLabelText('输入身份验证器生成的 6 位验证码'), '123456')
|
||||
await fireEvent.click(screen.getByRole('button', { name: '确认' }))
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('启用二次验证失败:验证码错误'))
|
||||
expect(emitted()['update:isOtp']).toBeUndefined()
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '确认' }))
|
||||
await waitFor(() => expect(emitted()['update:isOtp']).toEqual([[true]]))
|
||||
expect(mocks.apiPost).toHaveBeenLastCalledWith('mfa/otp/verify', {
|
||||
otpPassword: '123456',
|
||||
uri: 'otpauth://totp/MoviePilot:alice?secret=SECRET',
|
||||
})
|
||||
})
|
||||
|
||||
it('disables OTP through password confirmation only after backend success', async () => {
|
||||
mocks.apiPost
|
||||
.mockResolvedValueOnce({ message: '密码错误', success: false })
|
||||
.mockResolvedValueOnce({ success: true })
|
||||
const { emitted } = await renderDialog(true)
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '关闭二次验证' }))
|
||||
const request = (emitted().verifyPassword as unknown[][] | undefined)?.[0]?.[0] as {
|
||||
callback: (password: string) => Promise<void>
|
||||
}
|
||||
expect(request).toMatchObject({ text: '关闭二次验证前需要验证登录密码。', title: '关闭二次验证' })
|
||||
|
||||
await request.callback('wrong')
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('关闭二次验证失败:密码错误')
|
||||
expect(emitted()['update:isOtp']).toBeUndefined()
|
||||
|
||||
await request.callback('correct')
|
||||
await waitFor(() => expect(emitted()['update:isOtp']).toEqual([[false]]))
|
||||
expect(mocks.apiPost).toHaveBeenLastCalledWith('mfa/otp/disable', { password: 'correct' })
|
||||
})
|
||||
|
||||
it('ignores a stale generation response after close and reopen', async () => {
|
||||
const oldRequest = deferred<{ data: { secret: string; uri: string }; success: boolean }>()
|
||||
const newRequest = deferred<{ data: { secret: string; uri: string }; success: boolean }>()
|
||||
mocks.apiPost.mockReturnValueOnce(oldRequest.promise).mockReturnValueOnce(newRequest.promise)
|
||||
const result = await renderDialog()
|
||||
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledOnce())
|
||||
|
||||
await result.rerender({ isOtp: false, modelValue: false })
|
||||
await result.rerender({ isOtp: false, modelValue: true })
|
||||
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledTimes(2))
|
||||
newRequest.resolve({ data: { secret: 'NEW', uri: 'otpauth://new' }, success: true })
|
||||
expect(await screen.findByText('NEW')).toBeInTheDocument()
|
||||
|
||||
oldRequest.resolve({ data: { secret: 'OLD', uri: 'otpauth://old' }, success: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await waitFor(() => expect(screen.getByText('NEW')).toBeInTheDocument())
|
||||
expect(screen.queryByText('OLD')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ignores a pending generation failure after the shared dialog unmounts', async () => {
|
||||
const request = deferred<{ message: string; success: boolean }>()
|
||||
mocks.apiPost.mockReturnValue(request.promise)
|
||||
const { unmount } = await renderDialog()
|
||||
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledOnce())
|
||||
|
||||
unmount()
|
||||
request.resolve({ message: 'late failure', success: false })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits close when the user closes the dialog', async () => {
|
||||
mocks.apiPost.mockResolvedValue({
|
||||
data: { secret: 'SECRET', uri: 'otpauth://totp/MoviePilot:alice?secret=SECRET' },
|
||||
success: true,
|
||||
})
|
||||
const { emitted } = await renderDialog()
|
||||
await screen.findByText('SECRET')
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '关闭' }))
|
||||
await fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||
expect(emitted()['update:modelValue']).toEqual([[false], [false]])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,274 @@
|
||||
import PasskeyDialog from '@/components/dialog/PasskeyDialog.vue'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { createPassKey } from '@tests/support/factories/user'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
credentialCreate: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||
post: (...args: unknown[]) => mocks.apiPost(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
const DialogCloseBtn = defineComponent({
|
||||
name: 'VDialogCloseBtn',
|
||||
emits: ['click'],
|
||||
setup(_props, { emit }) {
|
||||
return () => h('button', { 'aria-label': '关闭', onClick: () => emit('click'), type: 'button' })
|
||||
},
|
||||
})
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(resolvePromise => {
|
||||
resolve = resolvePromise
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function registrationCredential(id: string): PublicKeyCredential {
|
||||
const bytes = new Uint8Array([1, 2, 3]).buffer
|
||||
return {
|
||||
id,
|
||||
rawId: bytes,
|
||||
response: {
|
||||
attestationObject: bytes,
|
||||
clientDataJSON: bytes,
|
||||
getTransports: () => ['internal'],
|
||||
},
|
||||
type: 'public-key',
|
||||
} as unknown as PublicKeyCredential
|
||||
}
|
||||
|
||||
function startResponse(token: string) {
|
||||
return {
|
||||
data: {
|
||||
options: JSON.stringify({ challenge: 'AQID', user: { id: 'BAUG', name: 'alice' } }),
|
||||
transaction_token: token,
|
||||
},
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
|
||||
async function renderDialog() {
|
||||
return renderWithProviders(PasskeyDialog, {
|
||||
props: { modelValue: true },
|
||||
global: { stubs: { VDialogCloseBtn: DialogCloseBtn } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('PasskeyDialog', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.apiPost.mockReset()
|
||||
mocks.credentialCreate.mockReset()
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
mocks.apiGet.mockResolvedValue({ data: [], success: true })
|
||||
Object.defineProperty(window, 'PublicKeyCredential', { configurable: true, value: class PublicKeyCredential {} })
|
||||
Object.defineProperty(window, 'isSecureContext', { configurable: true, value: true })
|
||||
Object.defineProperty(navigator, 'credentials', {
|
||||
configurable: true,
|
||||
value: { create: (...args: unknown[]) => mocks.credentialCreate(...args) },
|
||||
})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('loads the current list and emits it to the profile', async () => {
|
||||
mocks.apiGet.mockResolvedValue({ data: [createPassKey()], success: true })
|
||||
const { emitted } = await renderDialog()
|
||||
|
||||
expect(await screen.findByText('MacBook Touch ID')).toBeInTheDocument()
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('mfa/passkey/list')
|
||||
expect(emitted()['update:passkeyList']).toEqual([[[createPassKey()]]])
|
||||
})
|
||||
|
||||
it('distinguishes a list HTTP failure from an empty list and retries', async () => {
|
||||
mocks.apiGet.mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({
|
||||
data: [createPassKey({ name: 'Recovered Key' })],
|
||||
success: true,
|
||||
})
|
||||
await renderDialog()
|
||||
|
||||
expect(await screen.findByText('服务器连接失败')).toBeInTheDocument()
|
||||
expect(screen.queryByText('您还没有注册任何通行密钥')).not.toBeInTheDocument()
|
||||
await fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
expect(await screen.findByText('Recovered Key')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('validates name and browser capability before starting registration', async () => {
|
||||
await renderDialog()
|
||||
await screen.findByText('您还没有注册任何通行密钥')
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '注册通行密钥' }))
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('请输入通行密钥名称')
|
||||
|
||||
await fireEvent.update(screen.getByLabelText('通行密钥名称'), 'Laptop')
|
||||
Object.defineProperty(window, 'PublicKeyCredential', { configurable: true, value: undefined })
|
||||
Object.defineProperty(window, 'isSecureContext', { configurable: true, value: false })
|
||||
await fireEvent.click(screen.getByRole('button', { name: '注册通行密钥' }))
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('通行密钥需要 HTTPS 安全连接')
|
||||
expect(mocks.apiPost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('registers a credential with the transaction from the same attempt and refreshes the list', async () => {
|
||||
mocks.apiPost.mockResolvedValueOnce(startResponse('tx-one')).mockResolvedValueOnce({ success: true })
|
||||
mocks.credentialCreate.mockResolvedValue(registrationCredential('credential-one'))
|
||||
mocks.apiGet
|
||||
.mockResolvedValueOnce({ data: [], success: true })
|
||||
.mockResolvedValueOnce({ data: [createPassKey({ name: 'Laptop' })], success: true })
|
||||
await renderDialog()
|
||||
await screen.findByText('您还没有注册任何通行密钥')
|
||||
|
||||
await fireEvent.update(screen.getByLabelText('通行密钥名称'), 'Laptop')
|
||||
await fireEvent.click(screen.getByRole('button', { name: '注册通行密钥' }))
|
||||
|
||||
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledTimes(2))
|
||||
expect(mocks.apiPost).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'mfa/passkey/register/finish',
|
||||
expect.objectContaining({
|
||||
credential: expect.objectContaining({ id: 'credential-one', type: 'public-key' }),
|
||||
name: 'Laptop',
|
||||
transaction_token: 'tx-one',
|
||||
}),
|
||||
expect.objectContaining({ signal: expect.anything() }),
|
||||
)
|
||||
expect(await screen.findByText('Laptop')).toBeInTheDocument()
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('通行密钥注册成功')
|
||||
})
|
||||
|
||||
it('does not finish registration after the credential chooser is cancelled', async () => {
|
||||
mocks.apiPost.mockResolvedValueOnce(startResponse('tx-one'))
|
||||
mocks.credentialCreate.mockResolvedValue(null)
|
||||
await renderDialog()
|
||||
await screen.findByText('您还没有注册任何通行密钥')
|
||||
|
||||
await fireEvent.update(screen.getByLabelText('通行密钥名称'), 'Laptop')
|
||||
await fireEvent.click(screen.getByRole('button', { name: '注册通行密钥' }))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('注册被取消'))
|
||||
expect(mocks.apiPost).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cancels WebAuthn and does not finish registration after the shared dialog unmounts', async () => {
|
||||
const credentialRequest = deferred<PublicKeyCredential>()
|
||||
mocks.apiPost.mockResolvedValueOnce(startResponse('tx-one'))
|
||||
mocks.credentialCreate.mockReturnValue(credentialRequest.promise)
|
||||
const { unmount } = await renderDialog()
|
||||
await screen.findByText('您还没有注册任何通行密钥')
|
||||
|
||||
await fireEvent.update(screen.getByLabelText('通行密钥名称'), 'Laptop')
|
||||
await fireEvent.click(screen.getByRole('button', { name: '注册通行密钥' }))
|
||||
await waitFor(() => expect(mocks.credentialCreate).toHaveBeenCalledOnce())
|
||||
const credentialOptions = mocks.credentialCreate.mock.calls[0][0] as CredentialCreationOptions
|
||||
|
||||
unmount()
|
||||
expect(credentialOptions.signal?.aborted).toBe(true)
|
||||
credentialRequest.resolve(registrationCredential('late-credential'))
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.apiPost).toHaveBeenCalledOnce()
|
||||
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deletes only after password confirmation and refreshes only after success', async () => {
|
||||
mocks.apiGet.mockResolvedValue({ data: [createPassKey()], success: true })
|
||||
mocks.apiPost
|
||||
.mockResolvedValueOnce({ message: '密码错误', success: false })
|
||||
.mockResolvedValueOnce({ success: true })
|
||||
const { emitted } = await renderDialog()
|
||||
await screen.findByText('MacBook Touch ID')
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '' }))
|
||||
const request = (emitted().verifyPassword as unknown[][] | undefined)?.[0]?.[0] as {
|
||||
callback: (password: string) => Promise<void>
|
||||
}
|
||||
await request.callback('wrong')
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('密码错误')
|
||||
expect(mocks.apiGet).toHaveBeenCalledOnce()
|
||||
|
||||
await request.callback('correct')
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledTimes(2))
|
||||
expect(mocks.apiPost).toHaveBeenLastCalledWith('mfa/passkey/delete', { passkey_id: 11, password: 'correct' })
|
||||
})
|
||||
|
||||
it('ignores an old list response after close and reopen', async () => {
|
||||
const oldRequest = deferred<{ data: ReturnType<typeof createPassKey>[]; success: boolean }>()
|
||||
const newRequest = deferred<{ data: ReturnType<typeof createPassKey>[]; success: boolean }>()
|
||||
mocks.apiGet.mockReturnValueOnce(oldRequest.promise).mockReturnValueOnce(newRequest.promise)
|
||||
const result = await renderDialog()
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledOnce())
|
||||
|
||||
await result.rerender({ modelValue: false })
|
||||
await result.rerender({ modelValue: true })
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledTimes(2))
|
||||
newRequest.resolve({ data: [createPassKey({ name: 'New Session Key' })], success: true })
|
||||
expect(await screen.findByText('New Session Key')).toBeInTheDocument()
|
||||
|
||||
oldRequest.resolve({ data: [createPassKey({ name: 'Old Session Key' })], success: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await waitFor(() => expect(screen.getByText('New Session Key')).toBeInTheDocument())
|
||||
expect(screen.queryByText('Old Session Key')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps each fast registration attempt bound to its own transaction', async () => {
|
||||
const oldCredential = deferred<PublicKeyCredential>()
|
||||
mocks.credentialCreate
|
||||
.mockReturnValueOnce(oldCredential.promise)
|
||||
.mockResolvedValueOnce(registrationCredential('new'))
|
||||
mocks.apiPost.mockImplementation((url: string) => {
|
||||
if (url === 'mfa/passkey/register/start') {
|
||||
const starts = mocks.apiPost.mock.calls.filter(call => call[0] === url).length
|
||||
return Promise.resolve(startResponse(starts === 1 ? 'tx-old' : 'tx-new'))
|
||||
}
|
||||
if (url === 'mfa/passkey/register/finish') return Promise.resolve({ success: true })
|
||||
return Promise.reject(new Error(`unexpected POST ${url}`))
|
||||
})
|
||||
const result = await renderDialog()
|
||||
await screen.findByText('您还没有注册任何通行密钥')
|
||||
|
||||
await fireEvent.update(screen.getByLabelText('通行密钥名称'), 'Old')
|
||||
await fireEvent.click(screen.getByRole('button', { name: '注册通行密钥' }))
|
||||
await waitFor(() => expect(mocks.credentialCreate).toHaveBeenCalledOnce())
|
||||
await result.rerender({ modelValue: false })
|
||||
await result.rerender({ modelValue: true })
|
||||
await fireEvent.update(screen.getByLabelText('通行密钥名称'), 'New')
|
||||
await fireEvent.click(screen.getByRole('button', { name: '注册通行密钥' }))
|
||||
await waitFor(() =>
|
||||
expect(mocks.apiPost.mock.calls.filter(call => call[0] === 'mfa/passkey/register/finish')).toHaveLength(1),
|
||||
)
|
||||
|
||||
oldCredential.resolve(registrationCredential('old'))
|
||||
await flushPromises()
|
||||
expect(mocks.apiPost.mock.calls.filter(call => call[0] === 'mfa/passkey/register/finish')).toHaveLength(1)
|
||||
expect(mocks.apiPost.mock.calls.find(call => call[0] === 'mfa/passkey/register/finish')?.[1]).toMatchObject({
|
||||
credential: expect.objectContaining({ id: 'new' }),
|
||||
transaction_token: 'tx-new',
|
||||
})
|
||||
})
|
||||
|
||||
it('emits close when the user closes the dialog', async () => {
|
||||
const { emitted } = await renderDialog()
|
||||
await screen.findByText('您还没有注册任何通行密钥')
|
||||
|
||||
await fireEvent.click(screen.getAllByRole('button', { name: '关闭' })[0])
|
||||
expect(emitted()['update:modelValue']).toEqual([[false]])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user