fix(frontend): 修复前端审计问题并补充回归测试
This commit is contained in:
@@ -209,7 +209,7 @@
|
||||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in modelMappings"
|
||||
:key="index"
|
||||
:key="getModelMappingKey(mapping)"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
@@ -654,6 +654,7 @@ import Select from '@/components/common/Select.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
@@ -695,6 +696,7 @@ const baseUrl = ref('')
|
||||
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||||
const allowedModels = ref<string[]>([])
|
||||
const modelMappings = ref<ModelMapping[]>([])
|
||||
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('bulk-model-mapping')
|
||||
const selectedErrorCodes = ref<number[]>([])
|
||||
const customErrorCodeInput = ref<number | null>(null)
|
||||
const interceptWarmupRequests = ref(false)
|
||||
|
||||
@@ -714,7 +714,7 @@
|
||||
<div v-if="antigravityModelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in antigravityModelMappings"
|
||||
:key="index"
|
||||
:key="getAntigravityModelMappingKey(mapping)"
|
||||
class="space-y-1"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -966,7 +966,7 @@
|
||||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in modelMappings"
|
||||
:key="index"
|
||||
:key="getModelMappingKey(mapping)"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
@@ -1225,7 +1225,7 @@
|
||||
<div v-if="tempUnschedRules.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="(rule, index) in tempUnschedRules"
|
||||
:key="index"
|
||||
:key="getTempUnschedRuleKey(rule)"
|
||||
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
@@ -2097,6 +2097,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
||||
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
|
||||
|
||||
// Type for exposed OAuthAuthorizationFlow component
|
||||
@@ -2227,6 +2228,9 @@ const antigravityModelMappings = ref<ModelMapping[]>([])
|
||||
const antigravityPresetMappings = computed(() => getPresetMappingsByPlatform('antigravity'))
|
||||
const tempUnschedEnabled = ref(false)
|
||||
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-model-mapping')
|
||||
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-antigravity-model-mapping')
|
||||
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('create-temp-unsched-rule')
|
||||
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one')
|
||||
const geminiAIStudioOAuthEnabled = ref(false)
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in modelMappings"
|
||||
:key="index"
|
||||
:key="getModelMappingKey(mapping)"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
@@ -417,7 +417,7 @@
|
||||
<div v-if="antigravityModelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in antigravityModelMappings"
|
||||
:key="index"
|
||||
:key="getAntigravityModelMappingKey(mapping)"
|
||||
class="space-y-1"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -542,7 +542,7 @@
|
||||
<div v-if="tempUnschedRules.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="(rule, index) in tempUnschedRules"
|
||||
:key="index"
|
||||
:key="getTempUnschedRuleKey(rule)"
|
||||
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
@@ -1093,6 +1093,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
||||
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
import {
|
||||
getPresetMappingsByPlatform,
|
||||
commonErrorCodes,
|
||||
@@ -1158,6 +1159,9 @@ const antigravityWhitelistModels = ref<string[]>([])
|
||||
const antigravityModelMappings = ref<ModelMapping[]>([])
|
||||
const tempUnschedEnabled = ref(false)
|
||||
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-model-mapping')
|
||||
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-antigravity-model-mapping')
|
||||
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('edit-temp-unsched-rule')
|
||||
|
||||
// Mixed channel warning dialog state
|
||||
const showMixedChannelWarning = ref(false)
|
||||
|
||||
@@ -143,6 +143,24 @@ const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const readFileAsText = async (sourceFile: File): Promise<string> => {
|
||||
if (typeof sourceFile.text === 'function') {
|
||||
return sourceFile.text()
|
||||
}
|
||||
|
||||
if (typeof sourceFile.arrayBuffer === 'function') {
|
||||
const buffer = await sourceFile.arrayBuffer()
|
||||
return new TextDecoder().decode(buffer)
|
||||
}
|
||||
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result ?? ''))
|
||||
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
|
||||
reader.readAsText(sourceFile)
|
||||
})
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file.value) {
|
||||
appStore.showError(t('admin.accounts.dataImportSelectFile'))
|
||||
@@ -151,7 +169,7 @@ const handleImport = async () => {
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const text = await file.value.text()
|
||||
const text = await readFileAsText(file.value)
|
||||
const dataPayload = JSON.parse(text)
|
||||
|
||||
const res = await adminAPI.accounts.importData({
|
||||
|
||||
@@ -143,6 +143,24 @@ const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const readFileAsText = async (sourceFile: File): Promise<string> => {
|
||||
if (typeof sourceFile.text === 'function') {
|
||||
return sourceFile.text()
|
||||
}
|
||||
|
||||
if (typeof sourceFile.arrayBuffer === 'function') {
|
||||
const buffer = await sourceFile.arrayBuffer()
|
||||
return new TextDecoder().decode(buffer)
|
||||
}
|
||||
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result ?? ''))
|
||||
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
|
||||
reader.readAsText(sourceFile)
|
||||
})
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file.value) {
|
||||
appStore.showError(t('admin.proxies.dataImportSelectFile'))
|
||||
@@ -151,7 +169,7 @@ const handleImport = async () => {
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const text = await file.value.text()
|
||||
const text = await readFileAsText(file.value)
|
||||
const dataPayload = JSON.parse(text)
|
||||
|
||||
const res = await adminAPI.proxies.importData({ data: dataPayload })
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<template v-if="loading">
|
||||
<div v-for="i in 5" :key="i" class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900">
|
||||
<div class="space-y-3">
|
||||
<div v-for="column in columns.filter(c => c.key !== 'actions')" :key="column.key" class="flex justify-between">
|
||||
<div v-for="column in dataColumns" :key="column.key" class="flex justify-between">
|
||||
<div class="h-4 w-20 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
@@ -39,7 +39,7 @@
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="column in columns.filter(c => c.key !== 'actions')"
|
||||
v-for="column in dataColumns"
|
||||
:key="column.key"
|
||||
class="flex items-start justify-between gap-4"
|
||||
>
|
||||
@@ -439,10 +439,15 @@ const resolveRowKey = (row: any, index: number) => {
|
||||
return key ?? index
|
||||
}
|
||||
|
||||
const dataColumns = computed(() => props.columns.filter((column) => column.key !== 'actions'))
|
||||
const columnsSignature = computed(() =>
|
||||
props.columns.map((column) => `${column.key}:${column.sortable ? '1' : '0'}`).join('|')
|
||||
)
|
||||
|
||||
// 数据/列变化时重新检查滚动状态
|
||||
// 注意:不能监听 actionsExpanded,因为 checkActionsColumnWidth 会临时修改它,会导致无限循环
|
||||
watch(
|
||||
[() => props.data.length, () => props.columns],
|
||||
[() => props.data.length, columnsSignature],
|
||||
async () => {
|
||||
await nextTick()
|
||||
checkScrollable()
|
||||
@@ -555,7 +560,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.columns,
|
||||
columnsSignature,
|
||||
() => {
|
||||
// If current sort key is no longer sortable/visible, fall back to default/persisted.
|
||||
const normalized = normalizeSortKey(sortKey.value)
|
||||
@@ -575,7 +580,7 @@ watch(
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
watch(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="relative" ref="dropdownRef">
|
||||
<button
|
||||
@click="toggleDropdown"
|
||||
:disabled="switching"
|
||||
class="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
:title="currentLocale?.name"
|
||||
>
|
||||
@@ -23,6 +24,7 @@
|
||||
<button
|
||||
v-for="locale in availableLocales"
|
||||
:key="locale.code"
|
||||
:disabled="switching"
|
||||
@click="selectLocale(locale.code)"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-dark-700"
|
||||
:class="{
|
||||
@@ -49,6 +51,7 @@ const { locale } = useI18n()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const switching = ref(false)
|
||||
|
||||
const currentLocaleCode = computed(() => locale.value)
|
||||
const currentLocale = computed(() => availableLocales.find((l) => l.code === locale.value))
|
||||
@@ -57,9 +60,18 @@ function toggleDropdown() {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
function selectLocale(code: string) {
|
||||
setLocale(code)
|
||||
isOpen.value = false
|
||||
async function selectLocale(code: string) {
|
||||
if (switching.value || code === currentLocaleCode.value) {
|
||||
isOpen.value = false
|
||||
return
|
||||
}
|
||||
switching.value = true
|
||||
try {
|
||||
await setLocale(code)
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
switching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
|
||||
@@ -84,8 +84,8 @@
|
||||
|
||||
<!-- Page numbers -->
|
||||
<button
|
||||
v-for="pageNum in visiblePages"
|
||||
:key="pageNum"
|
||||
v-for="(pageNum, index) in visiblePages"
|
||||
:key="`${pageNum}-${index}`"
|
||||
@click="typeof pageNum === 'number' && goToPage(pageNum)"
|
||||
:disabled="typeof pageNum !== 'number'"
|
||||
:class="[
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
<!-- Progress bar -->
|
||||
<div v-if="toast.duration" class="h-1 bg-gray-100 dark:bg-dark-700">
|
||||
<div
|
||||
:class="['h-full transition-all', getProgressBarColor(toast.type)]"
|
||||
:style="{ width: `${getProgress(toast)}%` }"
|
||||
:class="['h-full toast-progress', getProgressBarColor(toast.type)]"
|
||||
:style="{ animationDuration: `${toast.duration}ms` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,7 +77,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
@@ -129,36 +129,25 @@ const getProgressBarColor = (type: string): string => {
|
||||
return colors[type] || colors.info
|
||||
}
|
||||
|
||||
const getProgress = (toast: any): number => {
|
||||
if (!toast.duration || !toast.startTime) return 100
|
||||
const elapsed = Date.now() - toast.startTime
|
||||
const progress = Math.max(0, 100 - (elapsed / toast.duration) * 100)
|
||||
return progress
|
||||
}
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
appStore.hideToast(id)
|
||||
}
|
||||
|
||||
let intervalId: number | undefined
|
||||
|
||||
onMounted(() => {
|
||||
// Check for expired toasts every 100ms
|
||||
intervalId = window.setInterval(() => {
|
||||
const now = Date.now()
|
||||
toasts.value.forEach((toast) => {
|
||||
if (toast.duration && toast.startTime) {
|
||||
if (now - toast.startTime >= toast.duration) {
|
||||
removeToast(toast.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, 100)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId !== undefined) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-progress {
|
||||
width: 100%;
|
||||
animation-name: toast-progress-shrink;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
@keyframes toast-progress-shrink {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
to {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
<!-- Options (for select/multi_select) -->
|
||||
<div v-if="form.type === 'select' || form.type === 'multi_select'" class="space-y-2">
|
||||
<label class="input-label">{{ t('admin.users.attributes.options') }}</label>
|
||||
<div v-for="(option, index) in form.options" :key="index" class="flex items-center gap-2">
|
||||
<div v-for="(option, index) in form.options" :key="getOptionKey(option)" class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="option.value"
|
||||
type="text"
|
||||
@@ -246,6 +246,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -270,6 +271,7 @@ const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const editingAttribute = ref<UserAttributeDefinition | null>(null)
|
||||
const deletingAttribute = ref<UserAttributeDefinition | null>(null)
|
||||
const getOptionKey = createStableObjectKeyResolver<UserAttributeOption>('user-attr-option')
|
||||
|
||||
const form = reactive({
|
||||
key: '',
|
||||
@@ -315,7 +317,7 @@ const openEditModal = (attr: UserAttributeDefinition) => {
|
||||
form.placeholder = attr.placeholder || ''
|
||||
form.required = attr.required
|
||||
form.enabled = attr.enabled
|
||||
form.options = attr.options ? [...attr.options] : []
|
||||
form.options = attr.options ? attr.options.map((opt) => ({ ...opt })) : []
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { totpAPI } from '@/api'
|
||||
@@ -107,6 +107,7 @@ const loading = ref(false)
|
||||
const error = ref('')
|
||||
const sendingCode = ref(false)
|
||||
const codeCooldown = ref(0)
|
||||
const cooldownTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
const form = ref({
|
||||
emailCode: '',
|
||||
password: ''
|
||||
@@ -139,10 +140,17 @@ const handleSendCode = async () => {
|
||||
appStore.showSuccess(t('profile.totp.codeSent'))
|
||||
// Start cooldown
|
||||
codeCooldown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
cooldownTimer.value = setInterval(() => {
|
||||
codeCooldown.value--
|
||||
if (codeCooldown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err: any) {
|
||||
@@ -176,4 +184,11 @@ const handleDisable = async () => {
|
||||
onMounted(() => {
|
||||
loadVerificationMethod()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, nextTick, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { totpAPI } from '@/api'
|
||||
@@ -198,6 +198,7 @@ const verifyForm = ref({ emailCode: '', password: '' })
|
||||
const verifyError = ref('')
|
||||
const sendingCode = ref(false)
|
||||
const codeCooldown = ref(0)
|
||||
const cooldownTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const setupLoading = ref(false)
|
||||
const setupData = ref<TotpSetupResponse | null>(null)
|
||||
@@ -338,10 +339,17 @@ const handleSendCode = async () => {
|
||||
appStore.showSuccess(t('profile.totp.codeSent'))
|
||||
// Start cooldown
|
||||
codeCooldown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
cooldownTimer.value = setInterval(() => {
|
||||
codeCooldown.value--
|
||||
if (codeCooldown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err: any) {
|
||||
@@ -397,4 +405,11 @@ const handleVerify = async () => {
|
||||
onMounted(() => {
|
||||
loadVerificationMethod()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import TotpSetupModal from '@/components/user/profile/TotpSetupModal.vue'
|
||||
import TotpDisableDialog from '@/components/user/profile/TotpDisableDialog.vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
showSuccess: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
getVerificationMethod: vi.fn(),
|
||||
sendVerifyCode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showSuccess: mocks.showSuccess,
|
||||
showError: mocks.showError
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
totpAPI: {
|
||||
getVerificationMethod: mocks.getVerificationMethod,
|
||||
sendVerifyCode: mocks.sendVerifyCode,
|
||||
initiateSetup: vi.fn(),
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const flushPromises = async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('TOTP 弹窗定时器清理', () => {
|
||||
let intervalSeed = 1000
|
||||
let setIntervalSpy: ReturnType<typeof vi.spyOn>
|
||||
let clearIntervalSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
intervalSeed = 1000
|
||||
mocks.showSuccess.mockReset()
|
||||
mocks.showError.mockReset()
|
||||
mocks.getVerificationMethod.mockReset()
|
||||
mocks.sendVerifyCode.mockReset()
|
||||
|
||||
mocks.getVerificationMethod.mockResolvedValue({ method: 'email' })
|
||||
mocks.sendVerifyCode.mockResolvedValue({ success: true })
|
||||
|
||||
setIntervalSpy = vi.spyOn(window, 'setInterval').mockImplementation(((handler: TimerHandler) => {
|
||||
void handler
|
||||
intervalSeed += 1
|
||||
return intervalSeed as unknown as number
|
||||
}) as typeof window.setInterval)
|
||||
clearIntervalSpy = vi.spyOn(window, 'clearInterval')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setIntervalSpy.mockRestore()
|
||||
clearIntervalSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('TotpSetupModal 卸载时清理倒计时定时器', async () => {
|
||||
const wrapper = mount(TotpSetupModal)
|
||||
await flushPromises()
|
||||
|
||||
const sendButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('profile.totp.sendCode'))
|
||||
|
||||
expect(sendButton).toBeTruthy()
|
||||
await sendButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
|
||||
const timerId = setIntervalSpy.mock.results[0]?.value
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalledWith(timerId)
|
||||
})
|
||||
|
||||
it('TotpDisableDialog 卸载时清理倒计时定时器', async () => {
|
||||
const wrapper = mount(TotpDisableDialog)
|
||||
await flushPromises()
|
||||
|
||||
const sendButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('profile.totp.sendCode'))
|
||||
|
||||
expect(sendButton).toBeTruthy()
|
||||
await sendButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
|
||||
const timerId = setIntervalSpy.mock.results[0]?.value
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalledWith(timerId)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user