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 } from '../../frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js' 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('邮件列表挂载后,表格选中项必须用于全选和批量操作', 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'] 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', ...icons ])( Vue, Vue.ref, Vue.watch, () => ({ push() {} }), () => Vue.reactive({ tag: '', name: '收件箱' }), {}, transport, { warning: value => messages.push(value), success() {}, error() {} }, { confirm: async () => 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' ])( 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 ) 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'])( { create: () => transport }, { lang: 'zhCn' }, {}, () => () => {} ) assert.equal(typeof interceptor, 'function') const source = read('src/views/components/ConfigSettings/ConfigSettings.js') .replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') const results = [] for (const format of ['csv', 'json']) { const payload = format === 'csv' ? 'id,operation\n1,update\n' : '[{"id":1,"operation":"update"}]' let downloaded let clicked = false const errors = [] const page = compileFunction(source + '\nreturn usePage("audit")', [ 'computed', 'onMounted', 'onBeforeUnmount', 'reactive', 'ref', 'ElMessage', 'ElMessageBox', 'http', 'useGlobalStatusStore', 'readFormConfig', 'updateFormConfig', 'validateFormConfig', 'URL', 'document' ])( Vue.computed, () => {}, () => {}, Vue.reactive, Vue.ref, { success() {}, error: message => errors.push(message) }, {}, { post: async (url, body, config) => { assert.equal(url, '/api/v1/audits/config') assert.equal(config.responseType, 'blob') assert.equal(body.format, format) return interceptor({ data: new Blob([payload]), config: { url } }) } }, () => ({ hasCapability: () => true }), () => ({}), () => '', () => ({}), { createObjectURL: blob => { downloaded = blob; return 'blob:audit' }, revokeObjectURL() {} }, { createElement: () => ({ click: () => { clicked = true } }) } ) await page.exportAudit(format) assert.deepEqual(errors, []) assert.equal(clicked, true) assert.ok(downloaded instanceof Blob) const actual = await downloaded.text() console.log(`format=${format} downloaded=${JSON.stringify(actual)}`) results.push({ format, actual, payload }) } for (const { actual, payload } of results) assert.equal(actual, payload, '下载文件必须保留真实审计记录,而不是 undefined') }) for (const format of ['csv', 'json']) { test(`审计 ${format} 导出的错误 Blob 不应下载,失败后允许重试`, async () => { const source = read('src/views/components/ConfigSettings/ConfigSettings.js') .replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') const errors = [] const downloads = [] let clicked = 0 let requests = 0 const payload = format === 'csv' ? 'id,operation\n1,update\n' : '[{"id":1}]' const page = compileFunction(source + '\nreturn usePage("audit")', [ 'computed', 'onMounted', 'onBeforeUnmount', 'reactive', 'ref', 'ElMessage', 'ElMessageBox', 'http', 'useGlobalStatusStore', 'readFormConfig', 'updateFormConfig', 'validateFormConfig', 'URL', 'document' ])( Vue.computed, () => {}, () => {}, Vue.reactive, Vue.ref, { success() {}, error: message => errors.push(message) }, {}, { post: async () => ++requests === 1 ? new Blob([JSON.stringify({ errorNo: 403, errorMsg: '无权导出审计记录' })], { type: 'application/json' }) : new Blob([payload], { type: format === 'csv' ? 'text/csv' : 'application/json' }) }, () => ({ hasCapability: () => true }), () => ({}), () => '', () => ({}), { createObjectURL: blob => { downloads.push(blob); return 'blob:audit' }, revokeObjectURL() {} }, { createElement: () => ({ click: () => { clicked++ } }) } ) await page.exportAudit(format) assert.deepEqual(errors, ['无权导出审计记录']) assert.equal(downloads.length, 0) assert.equal(clicked, 0) assert.equal(page.working.value, '') await page.exportAudit(format) assert.equal(requests, 2) assert.equal(downloads.length, 1) assert.equal(clicked, 1) assert.equal(await downloads[0].text(), payload) assert.equal(page.working.value, '') }) } // 挂载真实 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'] const values = [Vue.onBeforeUnmount, Vue.reactive, Vue.ref, Vue.shallowRef, {}, {}, {}, {}, {}, {}, { lang: 'zhCn' }, {}, {}, () => {}, () => ({}), {}, () => ({}), () => ({ 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() } })