324 lines
16 KiB
JavaScript
324 lines
16 KiB
JavaScript
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('邮件列表挂载后,表格选中项必须用于全选和批量操作', 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()
|
|
}
|
|
})
|