同步客户端授权码页面回归测试
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-16 00:21:28 +08:00
parent 3d912fe5d1
commit c0ef89cdce
4 changed files with 49 additions and 100 deletions

View File

@ -6,7 +6,12 @@ import { createRequire } from 'node:module'
import { computed, reactive, ref } from './vue.mjs'
const formSource = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js', import.meta.url), 'utf8')
const formHelpers = compileFunction(formSource.replaceAll('export function ', 'function ') + '\nreturn { readFormConfig, updateFormConfig, validateFormConfig }')()
const formHelpers = compileFunction(
formSource
.replaceAll('export const ', 'const ')
.replaceAll('export function ', 'function ')
+ '\nreturn { readFormConfig, updateFormConfig, validateFormConfig, relayConfigured, relaySecurityForMail }'
)()
const recentAuthenticationSource = readFileSync(new URL('../../frontend/src/utils/recentAuthentication.js', import.meta.url), 'utf8')
.replace(/^import .*$/gm, '')
.replaceAll('export async function ', 'async function ')
@ -18,7 +23,7 @@ const compileRecentAuthentication = compileFunction(
const source = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigSettings.js', import.meta.url), 'utf8')
const compilePage = compileFunction(
source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage(pageKey)',
['computed', 'onMounted', 'reactive', 'ref', 'ElMessage', 'ElMessageBox', 'http', 'useGlobalStatusStore', 'pageKey', 'onBeforeUnmount', 'readFormConfig', 'updateFormConfig', 'validateFormConfig', 'isRecentAuthenticationCancelled', 'requestWithRecentAuthentication']
['computed', 'onMounted', 'reactive', 'ref', 'ElMessage', 'ElMessageBox', 'http', 'useGlobalStatusStore', 'pageKey', 'onBeforeUnmount', 'readFormConfig', 'updateFormConfig', 'validateFormConfig', 'relayConfigured', 'relaySecurityForMail', 'isRecentAuthenticationCancelled', 'requestWithRecentAuthentication']
)
const createPageWithKey = (...args) => {
const http = args[6]
@ -29,6 +34,8 @@ const createPageWithKey = (...args) => {
formHelpers.readFormConfig,
formHelpers.updateFormConfig,
formHelpers.validateFormConfig,
formHelpers.relayConfigured,
formHelpers.relaySecurityForMail,
recentAuthentication.isRecentAuthenticationCancelled,
recentAuthentication.requestWithRecentAuthentication
)

View File

@ -1,41 +1,25 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { compileFunction } from 'node:vm'
import { reactive, ref } from './vue.mjs'
// 将已有真实 Vue 开关事件回归纳入默认前端测试,避免只覆盖调用函数的模拟。
// 将真实 Vue 客户端授权码页面回归纳入默认前端测试,避免只校验静态文案。
import '../review/protocol-frontend.test.mjs'
const root = new URL('../../frontend/src/views/components/ThirdPartyAuthorization/', import.meta.url)
const read = extension => readFileSync(new URL(`ThirdPartyAuthorization.${extension}`, root), 'utf8')
for (const succeeded of [false, true]) {
test(`关闭协议时只在保存成功后清除本次明文:成功=${succeeded}`, async () => {
const usePage = compileFunction(
read('js').replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage',
['onMounted', 'reactive', 'ref', 'ElMessageBox', 'ElNotification', 'http'],
)(() => {}, reactive, ref, {}, () => {}, {
put: async () => ({ errorNo: succeeded ? 0 : 1 }),
})
const page = usePage()
const item = { protocol: 'imap', enabled: true, configured: true }
page.generatedProtocol.value = 'imap'
page.generatedProtocolCode.value = 'temporary-test-only'
await page.setProtocolEnabled(item, false)
assert.equal(item.enabled, !succeeded)
assert.equal(page.generatedProtocolCode.value, succeeded ? '' : 'temporary-test-only')
assert.equal(page.generatedProtocol.value, succeeded ? '' : 'imap')
assert.equal(item.configured, true)
assert.equal(page.protocolLoading.value, false)
})
}
test('客户端账号信息严格使用后端允许的 POST 方法读取', () => {
const source = read('js')
assert.match(source, /http\.post\('\/api\/v1\/user\/info', \{\}\)/)
assert.doesNotMatch(source, /http\.get\('\/api\/v1\/user\/info'\)/)
})
test('邮件客户端文案明确区分本地服务、账号权限和可恢复的关闭状态', () => {
test('邮件客户端文案明确区分专属授权码与完整邮箱登录', () => {
const template = read('vue')
assert.match(template, /邮件客户端协议/)
assert.match(template, /为 IMAP、POP3、SMTP 配置专属授权码/)
assert.match(template, /当前账号客户端访问/)
assert.match(template, /:aria-label=/)
assert.match(template, /客户端专属授权码/)
assert.match(template, /请使用完整邮箱地址登录,密码统一使用上方专属授权码/)
assert.match(template, /IMAP 收信服务/)
assert.match(template, /POP3 收信服务/)
assert.match(template, /SMTP 发信服务/)
assert.doesNotMatch(template, /第三方邮件|&#x[0-9a-f]+;/i)
})

View File

@ -35,11 +35,11 @@ test('二级菜单只发送可见且尚未选中的选项,不提前修改父
test('一级菜单保留能力过滤和当前分类标题联动', () => {
const source = read('HomeHeader/HomeHeader.js')
const names = [
'EpMenu', 'Lock', 'Connection', 'Setting', 'FolderOpened', 'Filter', 'User', 'Grid', 'Document',
'EpMenu', 'Lock', 'Connection', 'Setting', 'FolderOpened', 'Filter', 'User', 'Grid', 'Document', 'Checked',
'SettingsPageStatus', 'BrandLogo'
]
const createPage = compileFunction(
source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage')
source.replace(/^\uFEFF?import .*$/gm, '').replace('export function usePage', 'function usePage')
.replace(/export \{[\s\S]*?\}\s*$/, '') + '\nreturn usePage()',
['computed', 'ref', 'onMounted', 'onUnmounted', 'window', 'useGlobalStatusStore', 'lang', 'defineAsyncComponent', ...names]
)
@ -50,14 +50,14 @@ test('一级菜单保留能力过滤和当前分类标题联动', () => {
})
const page = createPage(computed, ref, () => {}, () => {}, { innerWidth: 1440 }, () => store,
{ security: '安全', plugin_settings: '插件配置' }, () => ({}), ...names.map(name => ({ name })))
assert.deepEqual(page.settingsMenu.value.map(item => item.key), ['security', 'third-party', 'group', 'rule'])
assert.deepEqual(page.settingsMenu.value.map(item => item.key), ['security', 'third-party', 'group', 'rule', 'sender-filter'])
assert.equal(page.settingsMenu.value.find(item => item.key === 'third-party').label, '邮件客户端')
assert.equal(page.activeSettings.value.label, '安全')
capabilities.add('config.read')
capabilities.add('plugin.read_global')
store.settingsActiveTab = 'config'
assert.equal(page.activeSettings.value.label, '配置管理')
assert.equal(page.settingsMenu.value.length, 6)
assert.equal(page.settingsMenu.value.length, 7)
assert.equal(page.settingsMenu.value.find(item => item.key === 'plugins').label, '插件配置')
assert.ok(page.settingsMenu.value.every(item => item.icon && item.hint))
capabilities.delete('config.read')
@ -139,7 +139,7 @@ test('配置管理只在真实移动端将内部导航移到内容顶部', () =>
const containerStart = config.indexOf('@container settings-content (max-width: 560px)')
const desktopStart = config.indexOf('@media (min-width: 769px)', containerStart)
const containerRule = config.slice(containerStart, desktopStart)
assert.match(header, /min\(100vw, max\(50vw, 820px\)\)/)
assert.match(header, /min\(100vw, max\(56vw, 880px\)\)/)
assert.match(config, /grid-template-areas: "main submenu"/)
assert.match(config, /@media \(min-width: 769px\)[\s\S]*grid-template-columns: minmax\(0, 1fr\)/)
assert.match(config, /@media \(max-width: 768px\)[\s\S]*"submenu"[\s\S]*"main"/)

View File

@ -1,7 +1,6 @@
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'
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
@ -9,67 +8,26 @@ const Vue = require('vue')
const { parse, compileScript } = require('@vue/compiler-sfc')
const read = extension => readFileSync(new URL(`../../frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.${extension}`, import.meta.url), 'utf8')
for (const succeeded of [false, true]) {
test(`关闭协议后界面状态必须与服务端一致:请求成功=${succeeded}`, async () => {
const source = read('js').replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage')
let serverEnabled = true
const requests = []
const notices = []
const transport = {
get: async () => ({ errorNo: 0, data: [{ protocol: 'imap', enabled: serverEnabled, configured: true }] }),
put: async (url, data) => {
requests.push({ url, ...data })
if (!succeeded) throw new Error('隔离模拟网络失败')
serverEnabled = data.enabled
return { errorNo: 0 }
}
}
const usePage = compileFunction(source + '\nreturn usePage', ['onMounted', 'reactive', 'ref', 'ElMessageBox', 'ElNotification', 'http'])(
Vue.onMounted, Vue.reactive, Vue.ref, {}, value => notices.push(value), transport
)
const { descriptor, errors } = parse(read('vue'))
assert.deepEqual(errors, [])
const compiled = compileScript(descriptor, { id: 'audit-protocol', 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')
let page
let toggle
const component = compileFunction(script, ['Vue', 'usePage'])(Vue, () => { page = usePage(); return page })
const node = tag => ({ tag, 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('ElSwitch', {
props: ['modelValue'], emits: ['update:modelValue', 'change'],
setup(props, { emit }) {
// 与本机 Element Plus 源码一致:先更新 v-model,再触发 change。
toggle = value => { emit('update:modelValue', value); emit('change', value) }
return () => Vue.h('audit-switch', { enabled: props.modelValue })
}
})
app.config.warnHandler = () => {}
app.mount(node('root'))
try {
await Vue.nextTick()
await Vue.nextTick()
assert.equal(page.protocolCredentials.value[0].enabled, true)
assert.equal(typeof toggle, 'function')
toggle(false)
await Vue.nextTick()
await Vue.nextTick()
assert.equal(requests.length, 1)
assert.equal(notices[0].type, succeeded ? 'success' : 'error')
const uiEnabled = page.protocolCredentials.value[0].enabled
console.log(JSON.stringify({ succeeded, serverEnabled, uiEnabled, request: requests[0] }))
assert.equal(uiEnabled, serverEnabled, '失败时应恢复仍然开启的服务端状态,不能误显示已关闭')
} finally {
app.unmount()
}
})
}
test('客户端授权码页面的 Vue 模板和脚本可编译', () => {
const { descriptor, errors } = parse(read('vue'))
assert.deepEqual(errors, [])
assert.doesNotThrow(() => compileScript(descriptor, { id: 'audit-protocol', inlineTemplate: true }))
})
test('客户端授权码接口与界面能力保持一致', () => {
const source = read('js')
const template = read('vue')
for (const path of [
'/api/v1/security/credentials/clients',
'/api/v1/security/credentials/clients/create',
'/api/v1/security/credentials/clients/regenerate',
'/api/v1/security/credentials/clients/revoke',
'/api/v1/security/credentials/clients/reveal',
]) {
assert.match(source, new RegExp(path.replaceAll('/', '\\/')))
}
assert.match(template, /安全验证查看明文/)
assert.match(source, /startRevealCountdown\(60\)/)
assert.match(template, /自动隐藏/)
assert.match(template, /撤销删除/)
})