Smail/test/review/frontend-findings.test.mjs
smail e4486885e4
Some checks are pending
Security Checks / security (push) Waiting to run
修复发件箱部分投递状态显示
2026-09-30 23:25:57 +08:00

338 lines
16 KiB
JavaScript
Raw Permalink 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 assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { compileFunction } from 'node:vm'
import { createRequire } from 'node:module'
import { readFormConfig, updateFormConfig, validateFormConfig, relayConfigured, relaySecurityForMail } from '../../frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js'
import { decodeBlobAPIError } from '../../frontend/src/utils/apiResponse.mjs'
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
const Vue = require('vue')
const { parse, compileScript } = require('@vue/compiler-sfc')
const read = name => readFileSync(new URL(`../../frontend/${name}`, import.meta.url), 'utf8')
test('发件箱必须区分部分成功与全部失败', () => {
const source = read('src/views/List/List.js')
.replace(/^import .*$/gm, '')
.replace('export function usePage', 'function usePage')
.replace(/export \{[^}]+\}\s*$/, '')
const { isPartialDelivery } = compileFunction(source + '\nreturn { isPartialDelivery }')()
assert.equal(isPartialDelivery({
status: 2,
error: '投递未全部完成;已接收 1,失败 1。已接收的收件人不会自动重投'
}), true)
assert.equal(isPartialDelivery({ status: 2, error: '投递失败;已接收 0,失败 2' }), false)
assert.equal(isPartialDelivery({ status: 1, error: '' }), false)
})
test('邮件列表挂载后,表格选中项必须用于全选和批量操作', async () => {
const source = read('src/views/List/List.js').replace(/^import .*$/gm, '')
.replace('export function usePage', 'function usePage').replace(/export \{[^}]+\}\s*$/, '')
const icons = ['CircleCheck', 'Switch', 'Delete', 'View', 'Folder', 'EpArrowDownBold', 'EditPen', 'Warning', 'Check', 'Refresh']
const messages = []
const requests = []
const rows = [{ id: 1 }, { id: 2 }]
const transport = {
post: async url => {
requests.push(url)
return { errorNo: 0, data: { list: rows, total_page: 1 } }
},
get: async () => ({ errorNo: 0, data: [] })
}
const usePage = compileFunction(source + '\nreturn usePage', [
'Vue', 'ref', 'watch', 'useRouter', 'useGroupStore', 'lang', 'http', 'ElMessage', 'ElMessageBox',
'computed', 'onMounted', 'onUnmounted', 'useGlobalStatusStore', ...icons
])(
Vue, Vue.ref, Vue.watch, () => ({ push() {} }), () => Vue.reactive({ tag: '', name: '收件箱' }), {}, transport,
{ warning: value => messages.push(value), success() {}, error() {} }, { confirm: async () => true },
Vue.computed, Vue.onMounted, Vue.onUnmounted, () => Vue.reactive({ isLogin: true }),
...icons.map(() => ({}))
)
let page
let table
let cleared = 0
let toggled = 0
const { descriptor, errors } = parse(read('src/views/List/List.vue'))
assert.deepEqual(errors, [])
const compiled = compileScript(descriptor, { id: 'audit-list', inlineTemplate: true })
const script = compiled.content
.replace(/import \{([^}]+)\} from ["']vue["']/g, (_, fields) => `const {${fields.replace(/\bas\b/g, ':')}} = Vue`)
.replace(/^import .*$/gm, '').replace('export default', 'return')
const component = compileFunction(script, ['Vue', 'usePage', ...icons])(
Vue, () => { page = usePage(); return page }, ...icons.map(() => ({}))
)
const tableComponent = {
props: ['data'],
emits: ['selection-change'],
setup(props, { expose, emit }) {
let selection = []
table = {
getSelectionRows: () => selection,
clearSelection: () => { cleared++; selection = []; emit('selection-change', selection) },
toggleRowSelection: (row, selected) => {
toggled++
selection = selection.filter(item => item.id !== row.id)
if (selected) selection.push(row)
emit('selection-change', selection)
},
selectRow: row => { selection = [row]; emit('selection-change', selection) }
}
expose(table)
return () => Vue.h('audit-table')
}
}
const node = type => ({ type, children: [] })
const renderer = Vue.createRenderer({
createElement: node, createText: text => ({ ...node('text'), text }), createComment: node,
setText: (item, text) => { item.text = text }, setElementText: (item, text) => { item.text = text },
parentNode: item => item.parent, nextSibling: () => null, remove() {},
insert: (item, parent) => { item.parent = parent; parent.children.push(item) },
patchProp: (item, key, previous, value) => { item[key] = value }
})
const app = renderer.createApp(component)
app.component('ElTable', tableComponent)
app.config.warnHandler = () => {}
app.mount(node('root'))
try {
await Vue.nextTick()
assert.ok(table)
table.selectRow(rows[0])
assert.equal(page.selectedRows.value.length, 1)
requests.length = 0
assert.ok(page.taskTableDataRef.value, '真实模板必须连接组合函数返回的表格引用')
page.selectAll()
assert.equal(page.selectedRows.value.length, 2)
page.invertSelection()
assert.equal(page.selectedRows.value.length, 0)
assert.equal(cleared, 1)
assert.equal(toggled, 4)
page.selectAll()
page.markRead()
page.move(0, '归档')
page.del()
await Promise.resolve()
await Promise.resolve()
const actual = { connected: !!page.taskTableDataRef.value, cleared, toggled, requests: [...requests], warnings: [...messages] }
console.log(`list_actual=${JSON.stringify(actual)}`)
assert.equal(cleared, 2)
assert.equal(toggled, 6)
for (const action of ['read', 'move', 'del']) assert.ok(requests.includes(`/api/v1/email/${action}`))
assert.equal(actual.connected, true, '真实模板必须连接组合函数返回的表格引用')
assert.equal(actual.cleared, 2)
assert.equal(actual.requests.filter(url => !url.endsWith('/list')).length, 3)
} finally {
app.unmount()
}
})
// 后端约束由 TestAuditPendingConfigBlocksFurtherUpdates 使用真实 manager 单独验证。
for (const action of ['edit', 'refresh']) {
test(`保存候选配置后 ${action} 不应让页面失去继续保存或应用的入口`, async () => {
const source = read('src/views/components/ConfigSettings/ConfigSettings.js')
.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage')
const initial = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: {
relayHost: '',
relayPort: 0,
security: '',
from: '',
fromDomain: '',
verificationEnabled: true,
loginPolicy: 'new_device'
},
imap: { tlsPort: 993 },
pop3: { port: 110, tlsPort: 995 },
policy: { maxMessageBytes: 10 << 20, spamFilterLevel: 2, externalRecoveryEnabled: true }
}
let pending = null
let applied = false
const errors = []
const transport = {
get: async url => ({ errorNo: 0, data: url.endsWith('/pending') ? pending : url.endsWith('/status') ? { activeGeneration: 1, setupState: 'ready' } : initial }),
put: async (url, body) => {
if (pending) return { errorNo: 409, errorMsg: '配置代次冲突' }
pending = { generation: 2, candidateId: 'audit-candidate', sameSession: true, section: 'mail', payload: body.payload }
return { errorNo: 0, data: pending }
},
post: async (url, body) => {
assert.ok(url.endsWith('/apply'))
assert.equal(body.candidateId, pending.candidateId)
assert.equal(body.generation, pending.generation)
applied = true
pending = null
return { errorNo: 0, data: { generation: 2 } }
}
}
const page = compileFunction(source + '\nreturn usePage("mail")', [
'computed', 'onMounted', 'onBeforeUnmount', 'reactive', 'ref', 'ElMessage', 'ElMessageBox',
'http', 'useGlobalStatusStore', 'isRecentAuthenticationCancelled',
'requestWithRecentAuthentication', 'readFormConfig', 'updateFormConfig', 'validateFormConfig',
'relayConfigured', 'relaySecurityForMail'
])(
Vue.computed, () => {}, () => {}, Vue.reactive, Vue.ref,
{ success() {}, warning() {}, error: message => errors.push(message) }, { confirm: async () => true },
transport, () => ({ hasCapability: () => true }), () => false, request => request(),
readFormConfig, updateFormConfig, validateFormConfig, relayConfigured, relaySecurityForMail
)
await page.loadAll()
page.updateField(['policy', 'maxMessageBytes'], 8 << 20)
await page.saveConfig()
assert.equal(page.savedGeneration.value, 2)
assert.deepEqual(errors, [])
if (action === 'edit') page.updateField(['policy', 'maxMessageBytes'], 16 << 20)
else await page.refreshPage()
await page.continuePending()
console.log(`action=${action} server_pending=${pending} saved_generation=${page.savedGeneration.value} apply_requested=${applied} errors=${JSON.stringify(errors)}`)
assert.equal(applied, true, '页面必须保留恢复或取消候选配置的可用路径,而不是保存冲突且应用不发请求')
})
}
// 使用真实响应拦截器和页面函数;仅隔离网络、消息提示以及下载所需的浏览器对象。
test('外部邮箱验证码输入错误后必须能够再次输入,而不是重新绑定同一地址', async () => {
const source = read('src/views/components/SecuritySettings/ExternalEmailBindingDialog/ExternalEmailBindingDialog.js')
.replace(/^import(?:.|\n)*?from .*$/gm, '')
.replace(/export /g, '')
let binds = 0
let verifies = 0
const verifyRequests = []
const events = []
const dialog = compileFunction(source + '\nreturn useExternalEmailBindingDialog({ modelValue: false }, emit)', [
'computed', 'onBeforeUnmount', 'reactive', 'ref', 'watch', 'http',
'isRecentAuthenticationCancelled', 'requestWithRecentAuthentication', 'emit'
])(
Vue.computed, () => {}, Vue.reactive, Vue.ref, Vue.watch,
{ post: async (url, body) => {
if (url.endsWith('/bind')) {
binds++
return { errorNo: 0, data: { id: 1, challenge_id: 'audit-binding', email: 'b***@example.com', expires_at: new Date(Date.now() + 600000).toISOString() } }
}
assert.ok(url.endsWith('/verify'))
verifies++
verifyRequests.push(body)
return verifies === 1 ? { errorNo: 400, errorMsg: '验证失败' } : { errorNo: 0 }
} },
() => false,
request => request(),
(name, value) => events.push([name, value])
)
dialog.form.email = 'binding@example.com'
assert.equal(await dialog.sendVerificationCode(), true)
dialog.form.code = 'incorrect'
assert.equal(await dialog.verifyAndBind(), false)
assert.equal(verifies, 1)
dialog.form.code = 'correct'
assert.equal(await dialog.verifyAndBind(), true)
console.log(`bind_count=${binds} verify_count=${verifies} verify_requests=${JSON.stringify(verifyRequests)} events=${JSON.stringify(events)}`)
assert.equal(binds, 1, '验证码错误后不能重新创建同一绑定')
assert.equal(verifies, 2, '验证码首次输入错误后应允许重试验证现有绑定')
assert.equal(verifyRequests[0].id, 'audit-binding')
assert.equal(verifyRequests[1].id, 'audit-binding')
})
test('近期认证要求由 Axios 异常返回时必须确认密码并重试原请求', async () => {
const source = read('src/utils/recentAuthentication.js')
.replace(/^import .*$/gm, '')
.replace(/export /g, '')
let requests = 0
const posts = []
const result = await compileFunction(source + '\nreturn requestWithRecentAuthentication(request)', [
'ElMessageBox', 'http', 'request'
])(
{ prompt: async () => ({ value: 'CurrentPass12' }) },
{ post: async (url, body) => { posts.push([url, body]); return { errorNo: 0 } } },
async () => {
requests++
if (requests === 1) {
const error = new Error('需要近期认证')
error.apiErrorCode = 'RECENT_AUTH_REQUIRED'
throw error
}
return { errorNo: 0, data: { challenge_id: 'retry-success' } }
}
)
assert.equal(requests, 2)
assert.deepEqual(posts, [['/api/v1/security/reauth', { password: 'CurrentPass12' }]])
assert.equal(result.data.challenge_id, 'retry-success')
})
// 审计导出页面已经移除,保留共享下载响应处理的独立回归。
test('下载响应拦截器必须保留真实 Blob 内容', async () => {
let interceptor
const transport = { interceptors: { response: { use: success => { interceptor = success } } } }
const axiosSource = read('src/utils/axios.js')
.replace(/^import .*$/gm, '')
.replace('import.meta.env.VITE_APP_URL', 'undefined')
.replace('export {http};', '')
compileFunction(axiosSource, ['axios', 'lang', 'router', 'createLoginRedirectHandler', 'decodeBlobAPIError'])(
{ create: () => transport }, { lang: 'zhCn' }, {}, () => () => {}, decodeBlobAPIError
)
assert.equal(typeof interceptor, 'function')
for (const format of ['csv', 'json']) {
const payload = format === 'csv' ? 'id,operation\n1,update\n' : '[{"id":1,"operation":"update"}]'
const original = new Blob([payload], { type: format === 'csv' ? 'text/csv' : 'application/json' })
const downloaded = await interceptor({ data: original, config: { url: '/api/v1/download' } })
assert.equal(downloaded, original)
assert.equal(await downloaded.text(), payload)
}
})
for (const format of ['csv', 'json']) {
test(`${format} 下载的错误 Blob 必须解码,后续正常响应保持原样`, async () => {
const payload = format === 'csv' ? 'id,operation\n1,update\n' : '[{"id":1}]'
const error = { errorNo: 403, errorMsg: '无权下载' }
const failure = { data: new Blob([JSON.stringify(error)], { type: 'application/json' }) }
await decodeBlobAPIError(failure)
assert.deepEqual(failure.data, error)
const original = new Blob([payload], { type: format === 'csv' ? 'text/csv' : 'application/json' })
const success = { data: original }
await decodeBlobAPIError(success)
assert.equal(success.data, original)
assert.equal(await success.data.text(), payload)
})
}
// 挂载真实 SFC 的编译结果和真实组合函数,使用 Vue 自定义渲染器代替浏览器 DOM。
test('写信页挂载后,附件按钮必须连接到文件输入框', async () => {
const source = read('src/views/Editer/Editer.js').replace(/^import .*$/gm, '')
.replace('export function usePage', 'function usePage').replace(/export \{[^}]+\}\s*$/, '')
const names = ['onBeforeUnmount', 'reactive', 'ref', 'shallowRef', 'ElMessage', 'Close', 'Paperclip',
'Position', 'ArrowDown', 'Document', 'lang', 'Editor', 'Toolbar', 'i18nChangeLanguage', 'useRouter',
'http', 'useGroupStore', 'useGlobalStatusStore', 'MouseEvent', 'Delete']
const values = [Vue.onBeforeUnmount, Vue.reactive, Vue.ref, Vue.shallowRef, {}, {}, {}, {}, {}, {},
{ lang: 'zhCn' }, {}, {}, () => {}, () => ({}), { get: async () => ({ errorNo: 0, data: [] }) }, () => ({}),
() => ({ userInfos: { account: 'audit', name: 'audit', domains: ['example.test'], is_admin: false } }),
class { constructor(type) { this.type = type } }, {}]
const usePage = compileFunction(source + '\nreturn usePage', names)(...values)
let page
const { descriptor, errors } = parse(read('src/views/Editer/Editer.vue'))
assert.deepEqual(errors, [])
const compiled = compileScript(descriptor, { id: 'audit-editor', inlineTemplate: true })
const script = compiled.content
.replace(/import \{([^}]+)\} from ["']vue["']/g, (_, fields) => `const {${fields.replace(/\bas\b/g, ':')}} = Vue`)
.replace(/^import .*$/gm, '').replace('export default', 'return')
const component = compileFunction(script, ['Vue', 'usePage', 'Paperclip', 'Position', 'ArrowDown', 'Toolbar', 'Editor', 'Document', 'Close'])(
Vue, () => { page = usePage(); return page }, {}, {}, {}, {}, {}, {}, {}
)
const node = type => ({ type, style: {}, children: [], dispatchEvent(event) { this.lastEvent = event.type; return true } })
const renderer = Vue.createRenderer({
createElement: node, createText: text => ({ ...node('text'), text }), createComment: node,
setText: (item, text) => { item.text = text }, setElementText: (item, text) => { item.text = text },
parentNode: item => item.parent, nextSibling: () => null, remove() {},
insert: (item, parent) => { item.parent = parent; parent.children.push(item) },
patchProp: (item, key, previous, value) => { item[key] = value }
})
const app = renderer.createApp(component)
app.config.warnHandler = () => {}
app.mount(node('root'))
try {
await Vue.nextTick()
console.log(`template_file_ref_connected=${!!page.fileRef.value}`)
assert.doesNotThrow(() => page.upload(), '挂载后点击添加附件不应抛出异常')
assert.equal(page.fileRef.value?.lastEvent, 'click')
} finally {
app.unmount()
}
})