fix(user): harden profile and MFA workflows (#666)

This commit is contained in:
InfinityPacer
2026-08-11 23:17:58 +08:00
committed by GitHub
parent 6c384bf1ee
commit df4d697845
9 changed files with 951 additions and 57 deletions
+20 -7
View File
@@ -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>
+93 -19
View File
@@ -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]])
})
})