mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-13 17:44:52 +08:00
fix(user): harden profile and MFA workflows (#666)
This commit is contained in:
@@ -230,11 +230,6 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/components/dialog/PasskeyDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/RcloneConfigDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -1010,14 +1005,6 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/views/user/UserProfileView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/workflow/WorkflowShareView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
|
||||
@@ -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"
|
||||
|
||||
186
src/components/dialog/__tests__/OTPAuthDialog.spec.ts
Normal file
186
src/components/dialog/__tests__/OTPAuthDialog.spec.ts
Normal file
@@ -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]])
|
||||
})
|
||||
})
|
||||
274
src/components/dialog/__tests__/PasskeyDialog.spec.ts
Normal file
274
src/components/dialog/__tests__/PasskeyDialog.spec.ts
Normal file
@@ -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]])
|
||||
})
|
||||
})
|
||||
@@ -8,13 +8,14 @@ import { useDisplay } from 'vuetify'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import type { ApiResponse } from '@/api/types'
|
||||
|
||||
const OTPAuthDialog = defineAsyncComponent(() => import('@/components/dialog/OTPAuthDialog.vue'))
|
||||
const PasskeyDialog = defineAsyncComponent(() => import('@/components/dialog/PasskeyDialog.vue'))
|
||||
const VerifyPasswordDialog = defineAsyncComponent(() => import('@/components/dialog/VerifyPasswordDialog.vue'))
|
||||
|
||||
// 国际化
|
||||
const { t, locale } = useI18n()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -35,6 +36,10 @@ const refInputEl = ref<HTMLElement>()
|
||||
// 正在保存
|
||||
const isSaving = ref(false)
|
||||
|
||||
// 个人资料首次加载状态,失败时不展示未初始化的默认表单。
|
||||
const isProfileLoading = ref(true)
|
||||
const profileLoadFailed = ref(false)
|
||||
|
||||
// 当前头像缓存
|
||||
const currentAvatar = ref(avatar1)
|
||||
|
||||
@@ -189,6 +194,8 @@ function restoreCurrentAvatar() {
|
||||
|
||||
// 加载当前用户信息
|
||||
async function fetchUserInfo() {
|
||||
isProfileLoading.value = true
|
||||
profileLoadFailed.value = false
|
||||
try {
|
||||
const result: User = await api.get(`user/${userStore.userName}`)
|
||||
if (result) {
|
||||
@@ -197,11 +204,14 @@ async function fetchUserInfo() {
|
||||
accountInfo.value.nickname = accountInfo.value.settings?.nickname ?? ''
|
||||
currentUserName.value = accountInfo.value.name
|
||||
currentAvatar.value = accountInfo.value.avatar
|
||||
// 同时加载PassKey列表
|
||||
await fetchPassKeyList()
|
||||
// Passkey 数量是辅助信息,不阻塞已成功取得的个人资料。
|
||||
void fetchPassKeyList()
|
||||
}
|
||||
} catch (error) {
|
||||
profileLoadFailed.value = true
|
||||
console.log(error)
|
||||
} finally {
|
||||
isProfileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +230,6 @@ async function saveAccountInfo() {
|
||||
$toast.error(t('profile.passwordMismatch'))
|
||||
return
|
||||
}
|
||||
accountInfo.value.password = newPassword.value
|
||||
}
|
||||
|
||||
// 将nickname保存到settings中,后端可以直接处理JSON对象
|
||||
@@ -231,16 +240,21 @@ async function saveAccountInfo() {
|
||||
|
||||
const oldUserName = accountInfo.value.name
|
||||
const oldAvatar = accountInfo.value.avatar
|
||||
accountInfo.value.avatar = currentAvatar.value
|
||||
accountInfo.value.name = currentUserName.value
|
||||
isSaving.value = true
|
||||
try {
|
||||
// 创建一个临时对象来保存用户数据,确保所有字段都会发送
|
||||
const userData = { ...accountInfo.value }
|
||||
// 请求数据独立于已确认快照,失败后保留当前输入供用户重试。
|
||||
const userData: User = {
|
||||
...accountInfo.value,
|
||||
avatar: currentAvatar.value,
|
||||
name: currentUserName.value,
|
||||
...(newPassword.value ? { password: newPassword.value } : {}),
|
||||
}
|
||||
|
||||
const result: { [key: string]: any } = await api.put('user/', userData)
|
||||
const result = (await api.put('user/', userData)) as ApiResponse
|
||||
|
||||
if (result.success) {
|
||||
accountInfo.value.name = currentUserName.value
|
||||
accountInfo.value.avatar = currentAvatar.value
|
||||
if (oldUserName !== currentUserName.value) {
|
||||
$toast.success(t('profile.usernameChangeSuccess', { oldName: oldUserName, newName: currentUserName.value }))
|
||||
// 更新本地用户名显示
|
||||
@@ -264,16 +278,13 @@ async function saveAccountInfo() {
|
||||
} else {
|
||||
$toast.error(t('profile.saveFailed', { message: result.message }))
|
||||
}
|
||||
// 失败缓存值还原
|
||||
currentUserName.value = accountInfo.value.name
|
||||
accountInfo.value.name = oldUserName
|
||||
currentAvatar.value = accountInfo.value.avatar
|
||||
accountInfo.value.avatar = oldAvatar
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('保存失败:', error)
|
||||
$toast.error(t('profile.saveFailed', { message: t('common.serverConnectionFailed') }))
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
// 验证密码载荷接口
|
||||
@@ -313,7 +324,7 @@ async function confirmVerifyPassword(password = verifyPassword.value) {
|
||||
// 获取PassKey列表
|
||||
async function fetchPassKeyList() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('mfa/passkey/list')
|
||||
const result = (await api.get('mfa/passkey/list')) as ApiResponse<PassKey[]>
|
||||
if (result.success) {
|
||||
passkeyList.value = result.data || []
|
||||
}
|
||||
@@ -338,7 +349,15 @@ watch(
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VRow>
|
||||
<LoadingBanner v-if="isProfileLoading" class="mt-12" />
|
||||
<VAlert v-else-if="profileLoadFailed" type="error" variant="tonal" :title="t('common.serverConnectionFailed')">
|
||||
<template #append>
|
||||
<VBtn color="error" variant="text" :loading="isProfileLoading" @click="fetchUserInfo">
|
||||
{{ t('common.retry') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
</VAlert>
|
||||
<VRow v-else>
|
||||
<VCol cols="12">
|
||||
<VCard :title="t('profile.personalInfo')">
|
||||
<VCardText class="flex">
|
||||
|
||||
309
src/views/user/__tests__/UserProfileView.spec.ts
Normal file
309
src/views/user/__tests__/UserProfileView.spec.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import UserProfileView from '@/views/user/UserProfileView.vue'
|
||||
import type { PassKey } from '@/api/types'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { createPassKey, createUser } from '@tests/support/factories/user'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
apiPut: vi.fn(),
|
||||
openSharedDialog: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||
put: (...args: unknown[]) => mocks.apiPut(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
function currentUser() {
|
||||
return createUser({
|
||||
avatar: 'saved-avatar.png',
|
||||
email: 'alice@example.com',
|
||||
is_otp: false,
|
||||
settings: { nickname: 'Alice' },
|
||||
})
|
||||
}
|
||||
|
||||
function mockSuccessfulLoad() {
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
if (url === 'user/alice') return Promise.resolve(currentUser())
|
||||
if (url === 'mfa/passkey/list') return Promise.resolve({ data: [createPassKey()], success: true })
|
||||
return Promise.reject(new Error(`unexpected GET ${url}`))
|
||||
})
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(resolvePromise => {
|
||||
resolve = resolvePromise
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function renderProfile() {
|
||||
return renderWithProviders(UserProfileView, {
|
||||
initialState: {
|
||||
user: {
|
||||
avatar: 'store-avatar.png',
|
||||
userId: 7,
|
||||
userName: 'alice',
|
||||
},
|
||||
},
|
||||
stubActions: false,
|
||||
})
|
||||
}
|
||||
|
||||
async function uploadAvatar(input: HTMLInputElement, file: File) {
|
||||
Object.defineProperty(input, 'files', { configurable: true, value: [file] })
|
||||
await fireEvent(input, new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
describe('UserProfileView', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.apiPut.mockReset()
|
||||
mocks.openSharedDialog.mockReset()
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('loads the current profile and passkey count from their real response shapes', async () => {
|
||||
mockSuccessfulLoad()
|
||||
await renderProfile()
|
||||
|
||||
expect(await screen.findByDisplayValue('alice@example.com')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Alice')).toBeInTheDocument()
|
||||
expect(mocks.apiGet).toHaveBeenNthCalledWith(1, 'user/alice')
|
||||
expect(mocks.apiGet).toHaveBeenNthCalledWith(2, 'mfa/passkey/list')
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '账号安全' }))
|
||||
expect(await screen.findByText('1 个密钥')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a retryable error instead of a blank profile after loading fails', async () => {
|
||||
mocks.apiGet.mockRejectedValueOnce(new Error('network')).mockImplementation((url: string) => {
|
||||
if (url === 'user/alice') return Promise.resolve(currentUser())
|
||||
return Promise.resolve({ data: [], success: true })
|
||||
})
|
||||
await renderProfile()
|
||||
|
||||
expect(await screen.findByText('服务器连接失败')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('邮箱')).not.toBeInTheDocument()
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
expect(await screen.findByDisplayValue('alice@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the loaded profile while the passkey badge request is still pending', async () => {
|
||||
const passkeyRequest = deferred<{ data: PassKey[]; success: boolean }>()
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
if (url === 'user/alice') return Promise.resolve(currentUser())
|
||||
if (url === 'mfa/passkey/list') return passkeyRequest.promise
|
||||
return Promise.reject(new Error(`unexpected GET ${url}`))
|
||||
})
|
||||
await renderProfile()
|
||||
|
||||
expect(await screen.findByDisplayValue('alice@example.com')).toBeInTheDocument()
|
||||
passkeyRequest.resolve({ data: [], success: true })
|
||||
})
|
||||
|
||||
it('validates password confirmation without sending an update', async () => {
|
||||
mockSuccessfulLoad()
|
||||
await renderProfile()
|
||||
await screen.findByDisplayValue('alice@example.com')
|
||||
|
||||
await fireEvent.update(screen.getByLabelText('密码'), 'new-password')
|
||||
await fireEvent.update(screen.getByLabelText('确认密码'), 'different')
|
||||
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('两次输入的密码不一致')
|
||||
expect(mocks.apiPut).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('validates avatar files and supports restoring the saved or default avatar', async () => {
|
||||
mockSuccessfulLoad()
|
||||
const { container } = await renderProfile()
|
||||
await screen.findByDisplayValue('alice@example.com')
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
|
||||
await uploadAvatar(fileInput, new File(['text'], 'avatar.txt', { type: 'text/plain' }))
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('上传的文件不符合要求,请重新选择头像')
|
||||
|
||||
await uploadAvatar(fileInput, new File([new Uint8Array(800 * 1024 + 1)], 'large.png', { type: 'image/png' }))
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('文件大小不得大于800KB')
|
||||
|
||||
await uploadAvatar(fileInput, new File([new Uint8Array([1, 2, 3])], 'avatar.png', { type: 'image/png' }))
|
||||
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('新头像上传成功,待保存后生效!'))
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '重置' }))
|
||||
await fireEvent.click(screen.getByRole('button', { name: '默认' }))
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('已还原当前使用头像!')
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('已重置为默认头像,待保存后生效!')
|
||||
})
|
||||
|
||||
it('keeps notification identity fields editable in the profile payload', async () => {
|
||||
mockSuccessfulLoad()
|
||||
mocks.apiPut.mockResolvedValue({ success: true })
|
||||
await renderProfile()
|
||||
await screen.findByDisplayValue('alice@example.com')
|
||||
|
||||
const identities: Array<[string, string]> = [
|
||||
['企业微信用户', 'wechat-user'],
|
||||
['微信 ClawBot 用户', 'clawbot-user'],
|
||||
['飞书用户', 'feishu-user'],
|
||||
['Telegram用户', 'telegram-user'],
|
||||
['Slack用户', 'slack-user'],
|
||||
['Discord用户', 'discord-user'],
|
||||
['VoceChat用户', 'vocechat-user'],
|
||||
['SynologyChat用户', 'synology-user'],
|
||||
['豆瓣用户', 'douban-user'],
|
||||
]
|
||||
for (const [label, value] of identities) await fireEvent.update(screen.getByLabelText(label), value)
|
||||
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledOnce())
|
||||
expect(mocks.apiPut.mock.calls[0][1]).toMatchObject({
|
||||
settings: {
|
||||
discord_userid: 'discord-user',
|
||||
douban_userid: 'douban-user',
|
||||
feishu_openid: 'feishu-user',
|
||||
slack_userid: 'slack-user',
|
||||
synologychat_userid: 'synology-user',
|
||||
telegram_userid: 'telegram-user',
|
||||
vocechat_userid: 'vocechat-user',
|
||||
wechat_userid: 'wechat-user',
|
||||
wechatclawbot_userid: 'clawbot-user',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('saves edited profile data and updates the current-user store only after success', async () => {
|
||||
mockSuccessfulLoad()
|
||||
mocks.apiPut.mockResolvedValue({ success: true })
|
||||
await renderProfile()
|
||||
await screen.findByDisplayValue('alice@example.com')
|
||||
|
||||
await fireEvent.update(screen.getByLabelText('邮箱'), 'updated@example.com')
|
||||
await fireEvent.update(screen.getByLabelText('昵称'), 'Alice Updated')
|
||||
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledOnce())
|
||||
expect(mocks.apiPut).toHaveBeenCalledWith(
|
||||
'user/',
|
||||
expect.objectContaining({
|
||||
email: 'updated@example.com',
|
||||
name: 'alice',
|
||||
settings: expect.objectContaining({ nickname: 'Alice Updated' }),
|
||||
}),
|
||||
)
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('用户信息保存成功!')
|
||||
expect(useUserStore().userName).toBe('alice')
|
||||
})
|
||||
|
||||
it('keeps edited input retryable after a business failure', async () => {
|
||||
mockSuccessfulLoad()
|
||||
mocks.apiPut.mockResolvedValue({ message: '没有权限', success: false })
|
||||
await renderProfile()
|
||||
await screen.findByDisplayValue('alice@example.com')
|
||||
|
||||
await fireEvent.update(screen.getByLabelText('邮箱'), 'retry@example.com')
|
||||
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('用户信息保存失败:没有权限!'))
|
||||
expect(screen.getByDisplayValue('retry@example.com')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '保存' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('shows an HTTP save failure and allows the same input to be retried', async () => {
|
||||
mockSuccessfulLoad()
|
||||
mocks.apiPut.mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ success: true })
|
||||
await renderProfile()
|
||||
await screen.findByDisplayValue('alice@example.com')
|
||||
|
||||
await fireEvent.update(screen.getByLabelText('邮箱'), 'retry@example.com')
|
||||
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('用户信息保存失败:服务器连接失败!'))
|
||||
expect(screen.getByDisplayValue('retry@example.com')).toBeInTheDocument()
|
||||
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('prevents duplicate save submissions while a request is pending', async () => {
|
||||
mockSuccessfulLoad()
|
||||
let resolveUpdate!: (value: { success: boolean }) => void
|
||||
mocks.apiPut.mockReturnValue(
|
||||
new Promise(resolve => {
|
||||
resolveUpdate = resolve
|
||||
}),
|
||||
)
|
||||
await renderProfile()
|
||||
await screen.findByDisplayValue('alice@example.com')
|
||||
|
||||
const save = screen.getByRole('button', { name: '保存' })
|
||||
await fireEvent.click(save)
|
||||
await fireEvent.click(save)
|
||||
expect(mocks.apiPut).toHaveBeenCalledOnce()
|
||||
|
||||
resolveUpdate({ success: true })
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '保存' })).toBeEnabled())
|
||||
})
|
||||
|
||||
it('projects OTP and passkey dialog updates back into the profile badges', async () => {
|
||||
mockSuccessfulLoad()
|
||||
await renderProfile()
|
||||
await screen.findByDisplayValue('alice@example.com')
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '账号安全' }))
|
||||
await fireEvent.click(screen.getByText('二次验证'))
|
||||
const otpEvents = mocks.openSharedDialog.mock.calls[0][2] as Record<string, (value: unknown) => void>
|
||||
otpEvents['update:isOtp'](true)
|
||||
otpEvents['update:modelValue'](false)
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '账号安全' }))
|
||||
expect(await screen.findByText('已启用')).toBeInTheDocument()
|
||||
await fireEvent.click(screen.getByText('通行密钥管理'))
|
||||
const passkeyEvents = mocks.openSharedDialog.mock.calls[1][2] as Record<string, (value: unknown) => void>
|
||||
passkeyEvents['update:passkeyList']([createPassKey(), createPassKey({ id: 12, name: 'Security Key' })])
|
||||
passkeyEvents['update:modelValue'](false)
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '账号安全' }))
|
||||
expect(await screen.findByText('2 个密钥')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('forwards MFA password verification through the shared verification dialog', async () => {
|
||||
mockSuccessfulLoad()
|
||||
const controllers = Array.from({ length: 2 }, () => ({ close: vi.fn(), id: 1, updateProps: vi.fn() }))
|
||||
mocks.openSharedDialog.mockImplementation(() => controllers.shift())
|
||||
await renderProfile()
|
||||
await screen.findByDisplayValue('alice@example.com')
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '账号安全' }))
|
||||
await fireEvent.click(screen.getByText('二次验证'))
|
||||
const otpEvents = mocks.openSharedDialog.mock.calls[0][2] as Record<string, (value: unknown) => void>
|
||||
const verified = vi.fn()
|
||||
otpEvents.verifyPassword({ callback: verified, text: '请输入密码', title: '验证密码' })
|
||||
|
||||
const verifyProps = mocks.openSharedDialog.mock.calls[1][1]
|
||||
const verifyEvents = mocks.openSharedDialog.mock.calls[1][2] as Record<string, (value?: unknown) => void>
|
||||
expect(verifyProps).toEqual({ text: '请输入密码', title: '验证密码' })
|
||||
await verifyEvents.confirm('secret')
|
||||
expect(verified).toHaveBeenCalledWith('secret')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { User } from '@/api/types'
|
||||
import type { PassKey, User } from '@/api/types'
|
||||
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
|
||||
|
||||
/** 构造与用户查询接口一致的稳定测试数据。 */
|
||||
@@ -16,3 +16,14 @@ export function createUser(overrides: Partial<User> = {}): User {
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** 构造与通行密钥列表接口一致的稳定测试数据。 */
|
||||
export function createPassKey(overrides: Partial<PassKey> = {}): PassKey {
|
||||
return {
|
||||
id: 11,
|
||||
name: 'MacBook Touch ID',
|
||||
created_at: '2026-08-01T08:00:00Z',
|
||||
last_used_at: '2026-08-10T08:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +341,8 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'src/utils/torrentDownloadCache.ts',
|
||||
'src/components/dialog/SiteAddEditDialog.vue',
|
||||
'src/components/dialog/UserAddEditDialog.vue',
|
||||
'src/components/dialog/OTPAuthDialog.vue',
|
||||
'src/components/dialog/PasskeyDialog.vue',
|
||||
'src/components/dialog/SiteCookieUpdateDialog.vue',
|
||||
'src/components/dialog/SiteImportDialog.vue',
|
||||
'src/components/cards/SubscribeShareCard.vue',
|
||||
@@ -387,6 +389,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'src/utils/searchStream.ts',
|
||||
'src/views/site/SiteCardListView.vue',
|
||||
'src/views/user/UserListView.vue',
|
||||
'src/views/user/UserProfileView.vue',
|
||||
'src/views/reorganize/DownloadingListView.vue',
|
||||
'src/views/plugin/PluginCardListView.vue',
|
||||
'src/utils/siteIconCache.ts',
|
||||
@@ -417,12 +420,30 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/components/dialog/OTPAuthDialog.vue': {
|
||||
branches: 80,
|
||||
functions: 85,
|
||||
lines: 85,
|
||||
statements: 85,
|
||||
},
|
||||
'src/components/dialog/PasskeyDialog.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/views/user/UserListView.vue': {
|
||||
branches: 80,
|
||||
functions: 85,
|
||||
lines: 85,
|
||||
statements: 85,
|
||||
},
|
||||
'src/views/user/UserProfileView.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/components/cards/TorrentCard.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
|
||||
Reference in New Issue
Block a user