Files
one-pipe-system/scripts/tests/changePassword.test.mjs
luo abd1467482
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 13m9s
fix: 实名时间+8和修改密码新增
2026-10-09 10:52:39 +08:00

302 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { effectScope, nextTick, reactive } from 'vue'
import { compileScript, parse } from '@vue/compiler-sfc'
import ts from 'typescript'
const componentPath = 'src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue'
const componentSource = await readFile(componentPath, 'utf8')
const messages = []
const requests = []
let rememberedClears = 0
let localStateClears = 0
let response = { status: 200, data: { code: 0, msg: 'ok', data: null } }
let pendingRequest
function moduleUrl(source, imports = {}) {
const { outputText } = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ESNext }
})
const resolved = outputText.replace(/from ['"]([^'"]+)['"]/g, (_match, name) => {
return `from '${imports[name] || import.meta.resolve(name)}'`
})
return `data:text/javascript;base64,${Buffer.from(resolved).toString('base64')}`
}
// 真实请求层使用模拟 Axios adapter:不会访问网络或修改真实账号密码。
const axios = (await import('axios')).default
const previousAdapter = axios.defaults.adapter
globalThis.__passwordTest = {
message: (type, value) => messages.push({ type, value }),
clearRemembered: () => rememberedClears++,
store: {
accessToken: 'mock-access-token',
refreshToken: '',
clearLocalState: () => localStateClears++
}
}
const uiUrl = moduleUrl(`
export const ElButton = {}, ElDialog = {}, ElForm = {}, ElFormItem = {}, ElInput = {}
export const ElMessage = {
success: (value) => globalThis.__passwordTest.message('success', value),
error: (value) => globalThis.__passwordTest.message('error', value)
}
`)
const storeUrl = moduleUrl('export const useUserStore = () => globalThis.__passwordTest.store')
const statusUrl = moduleUrl(await readFile('src/utils/http/status.ts', 'utf8'))
const errorUrl = moduleUrl(await readFile('src/utils/business/apiError.ts', 'utf8'))
const idUrl = moduleUrl(await readFile('src/utils/business/id.ts', 'utf8'))
const httpSource = (await readFile('src/utils/http/index.ts', 'utf8'))
.replace('import.meta.env.DEV', 'true')
.replace('import.meta.env.VITE_API_URL', "''")
const httpUrl = moduleUrl(httpSource, {
'element-plus': uiUrl,
'@/store/modules/user': storeUrl,
'./status': statusUrl,
'@/utils/business/apiError': errorUrl,
'@/utils/business/id': idUrl
})
// 导入时才创建 Axios 实例,因此临时保留模拟 adapter 到模块初始化结束。
const mockAdapter = async (config) => {
requests.push(config)
if (pendingRequest) await pendingRequest
const result = { ...response, headers: { 'content-type': 'application/json' }, config }
if (response.status >= 400) {
throw new axios.AxiosError('Mock HTTP error', 'ERR_BAD_RESPONSE', config, undefined, result)
}
return result
}
axios.defaults.adapter = mockAdapter
await import(httpUrl)
axios.defaults.adapter = previousAdapter
const baseUrl = moduleUrl(await readFile('src/api/BaseService.ts', 'utf8'), {
'@/utils/http': httpUrl
})
const authUrl = moduleUrl(await readFile('src/api/modules/auth.ts', 'utf8'), {
'../BaseService': baseUrl
})
const { AuthService } = await import(authUrl)
const { descriptor } = parse(componentSource, { filename: componentPath })
const script = compileScript(descriptor, { id: 'password-test' })
const { default: Dialog } = await import(
moduleUrl(script.content, {
'element-plus': uiUrl,
'vue-i18n': moduleUrl('export const useI18n = () => ({ t: (key) => key })'),
'@/api/modules/auth': authUrl,
'@/utils/http/status': statusUrl,
'@/utils/auth/rememberPassword': moduleUrl(
'export const clearRememberedCredentials = () => globalThis.__passwordTest.clearRemembered()'
)
})
)
function createDialog() {
response = { status: 200, data: { code: 0, msg: 'ok', data: null } }
pendingRequest = undefined
requests.length = 0
messages.length = 0
rememberedClears = 0
const props = reactive({ modelValue: true })
const scope = effectScope()
const state = scope.run(() =>
Dialog.setup(props, {
expose: () => {},
emit: (_event, value) => {
props.modelValue = value
}
})
)
let clearedValidation = 0
async function validateField(field) {
const value = state.formData[field]
for (const rule of state.rules.value[field]) {
if (rule.required && !value) throw new Error(rule.message)
if (rule.min !== undefined && value.length < rule.min) throw new Error(rule.message)
if (rule.max !== undefined && value.length > rule.max) throw new Error(rule.message)
if (rule.validator) {
await new Promise((resolve, reject) => {
rule.validator(rule, value, (error) => (error ? reject(error) : resolve()))
})
}
}
}
state.formRef.value = {
validate: async () => {
for (const field of Object.keys(state.rules.value)) await validateField(field)
return true
},
validateField,
clearValidate: () => clearedValidation++
}
Object.assign(state.formData, {
oldPassword: 'old',
newPassword: 'new-password',
confirmPassword: 'new-password'
})
return { state, props, scope, validationClears: () => clearedValidation }
}
test('密码长度 7/33 位阻止提交,8/32 位允许提交,旧密码不受新长度限制', async () => {
for (const length of [7, 8, 32, 33]) {
const { state, props, scope } = createDialog()
state.formData.newPassword = 'a'.repeat(length)
state.formData.confirmPassword = 'a'.repeat(length)
await state.handleSubmit()
assert.equal(requests.length, length === 8 || length === 32 ? 1 : 0)
assert.equal(props.modelValue, length !== 8 && length !== 32)
assert.equal(state.submitting.value, false)
scope.stop()
}
})
test('密码必填、一致性及确认密码长度校验失败不发送请求', async () => {
for (const invalid of [
{ oldPassword: '' },
{ newPassword: '' },
{ confirmPassword: '' },
{ confirmPassword: 'different-password' },
{ confirmPassword: 'a'.repeat(7) },
{ confirmPassword: 'a'.repeat(33) }
]) {
const { state, scope } = createDialog()
Object.assign(state.formData, invalid)
await state.handleSubmit()
assert.equal(requests.length, 0)
assert.equal(state.submitting.value, false)
scope.stop()
}
})
test('修改成功发送下划线参数及 Bearer 认证、保留密码空格、清理表单但不退出登录', async () => {
const { state, props, scope } = createDialog()
state.formData.oldPassword = ' old '
state.formData.newPassword = state.formData.confirmPassword = ' new-password '
await state.handleSubmit()
assert.equal(requests[0].method, 'put')
assert.equal(requests[0].url, '/api/auth/password')
assert.equal(requests[0].headers.get('Authorization'), 'Bearer mock-access-token')
assert.deepEqual(JSON.parse(requests[0].data), {
old_password: ' old ',
new_password: ' new-password '
})
assert.equal(rememberedClears, 1)
assert.equal(localStateClears, 0)
assert.equal(props.modelValue, false)
assert.deepEqual({ ...state.formData }, { oldPassword: '', newPassword: '', confirmPassword: '' })
assert.deepEqual(messages, [{ type: 'success', value: 'changePassword.success' }])
scope.stop()
})
test('服务层丢弃确认密码和其他额外字段', async () => {
createDialog().scope.stop()
await AuthService.changePassword({
old_password: 'old',
new_password: 'new-password',
confirmPassword: 'unused'
})
assert.deepEqual(JSON.parse(requests[0].data), {
old_password: 'old',
new_password: 'new-password'
})
})
test('校验或请求未完成时重复提交只产生一次请求,并阻止关闭', async () => {
const { state, props, scope } = createDialog()
let finishRequest
pendingRequest = new Promise((resolve) => {
finishRequest = resolve
})
const first = state.handleSubmit()
await state.handleSubmit()
await nextTick()
assert.equal(state.submitting.value, true)
let closed = false
state.handleBeforeClose(() => {
closed = true
})
assert.equal(closed, false)
assert.equal(props.modelValue, true)
finishRequest()
await first
assert.equal(requests.length, 1)
scope.stop()
})
test('HTTP 400/500 错误保留弹窗和输入,统一请求层提示一次且允许重试', async () => {
for (const status of [400, 500]) {
const { state, props, scope } = createDialog()
response = { status, data: { code: status, msg: '旧密码错误', data: {} } }
await state.handleSubmit()
assert.equal(props.modelValue, true)
assert.equal(state.formData.oldPassword, 'old')
assert.equal(state.submitting.value, false)
assert.equal(rememberedClears, 0)
assert.equal(messages.length, 1)
assert.equal(messages[0].type, 'error')
response = { status: 200, data: { code: 0, msg: 'ok', data: null } }
await state.handleSubmit()
assert.equal(props.modelValue, false)
assert.equal(requests.length, 2)
scope.stop()
}
})
test('HTTP 200 业务错误不会误报成功或清除保存的凭证', async () => {
const { state, props, scope } = createDialog()
response = { status: 200, data: { code: 1001, msg: '旧密码错误', data: {} } }
await state.handleSubmit()
assert.equal(props.modelValue, true)
assert.equal(rememberedClears, 0)
assert.deepEqual(messages, [{ type: 'error', value: '旧密码错误' }])
scope.stop()
})
test('关闭后重新打开清空密码及校验状态', async () => {
const { state, props, scope, validationClears } = createDialog()
state.visible.value = false
state.resetForm()
props.modelValue = true
await nextTick()
assert.deepEqual({ ...state.formData }, { oldPassword: '', newPassword: '', confirmPassword: '' })
assert.ok(validationClears() > 0)
assert.equal(state.submitting.value, false)
scope.stop()
})
test('菜单顺序、显隐、标签、键盘提交、窄屏宽度及中英文文案完整', async () => {
const header = await readFile('src/components/core/layouts/art-header-bar/index.vue', 'utf8')
assert.ok(header.indexOf('topBar.user.userCenter') < header.indexOf('topBar.user.changePassword'))
assert.ok(header.indexOf('topBar.user.changePassword') < header.indexOf('topBar.user.lockScreen'))
assert.match(header, /userMenuPopover\.value\?\.hide\(\)[\s\S]*showChangePassword\.value = true/)
assert.equal(componentSource.match(/show-password/g)?.length, 3)
assert.match(componentSource, /width="min\(460px, calc\(100vw - 32px\)\)"/)
assert.match(componentSource, /label-position="top"/)
assert.match(componentSource, /@submit\.prevent="handleSubmit"/)
assert.match(header, /:focus-visible/)
for (const language of ['zh', 'en']) {
const dictionary = JSON.parse(await readFile(`src/locales/langs/${language}.json`, 'utf8'))
for (const [, key] of componentSource.matchAll(/t\('changePassword\.([^']+)'\)/g)) {
assert.ok(dictionary.changePassword[key], `${language}: ${key}`)
}
assert.ok(dictionary.topBar.user.changePassword)
}
})
test('HTTP 401 沿用统一认证过期处理,仅提示一次并清理本地登录状态', async () => {
const { state, scope } = createDialog()
response = { status: 401, data: { code: 401, msg: 'unauthorized', data: {} } }
await state.handleSubmit()
assert.equal(state.submitting.value, false)
assert.equal(rememberedClears, 0)
assert.deepEqual(messages, [{ type: 'error', value: '登录已过期,请重新登录' }])
await new Promise((resolve) => setTimeout(resolve, 1050))
assert.equal(localStateClears, 1)
scope.stop()
})
test.after(() => {
delete globalThis.__passwordTest
})