338 lines
16 KiB
JavaScript
338 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('发件箱必须区分部分成功与全部失败', () => {
|
||
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()
|
||
}
|
||
})
|