fix(frontend): 修复前端审计问题并补充回归测试

This commit is contained in:
yangjianbo
2026-02-14 11:56:08 +08:00
parent d04b47b3ca
commit f6bff97d26
27 changed files with 772 additions and 219 deletions

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)
})
})