同步客户端授权码页面回归测试

This commit is contained in:
smail 2026-09-16 00:21:28 +08:00
parent 755a0f50fd
commit 5cdc7ffb40
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' 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 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') const recentAuthenticationSource = readFileSync(new URL('../../frontend/src/utils/recentAuthentication.js', import.meta.url), 'utf8')
.replace(/^import .*$/gm, '') .replace(/^import .*$/gm, '')
.replaceAll('export async function ', 'async function ') .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 source = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigSettings.js', import.meta.url), 'utf8')
const compilePage = compileFunction( const compilePage = compileFunction(
source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage(pageKey)', 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 createPageWithKey = (...args) => {
const http = args[6] const http = args[6]
@ -29,6 +34,8 @@ const createPageWithKey = (...args) => {
formHelpers.readFormConfig, formHelpers.readFormConfig,
formHelpers.updateFormConfig, formHelpers.updateFormConfig,
formHelpers.validateFormConfig, formHelpers.validateFormConfig,
formHelpers.relayConfigured,
formHelpers.relaySecurityForMail,
recentAuthentication.isRecentAuthenticationCancelled, recentAuthentication.isRecentAuthenticationCancelled,
recentAuthentication.requestWithRecentAuthentication recentAuthentication.requestWithRecentAuthentication
) )

View File

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

View File

@ -35,11 +35,11 @@ test('二级菜单只发送可见且尚未选中的选项,不提前修改父
test('一级菜单保留能力过滤和当前分类标题联动', () => { test('一级菜单保留能力过滤和当前分类标题联动', () => {
const source = read('HomeHeader/HomeHeader.js') const source = read('HomeHeader/HomeHeader.js')
const names = [ const names = [
'EpMenu', 'Lock', 'Connection', 'Setting', 'FolderOpened', 'Filter', 'User', 'Grid', 'Document', 'EpMenu', 'Lock', 'Connection', 'Setting', 'FolderOpened', 'Filter', 'User', 'Grid', 'Document', 'Checked',
'SettingsPageStatus', 'BrandLogo' 'SettingsPageStatus', 'BrandLogo'
] ]
const createPage = compileFunction( 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()', .replace(/export \{[\s\S]*?\}\s*$/, '') + '\nreturn usePage()',
['computed', 'ref', 'onMounted', 'onUnmounted', 'window', 'useGlobalStatusStore', 'lang', 'defineAsyncComponent', ...names] ['computed', 'ref', 'onMounted', 'onUnmounted', 'window', 'useGlobalStatusStore', 'lang', 'defineAsyncComponent', ...names]
) )
@ -50,14 +50,14 @@ test('一级菜单保留能力过滤和当前分类标题联动', () => {
}) })
const page = createPage(computed, ref, () => {}, () => {}, { innerWidth: 1440 }, () => store, const page = createPage(computed, ref, () => {}, () => {}, { innerWidth: 1440 }, () => store,
{ security: '安全', plugin_settings: '插件配置' }, () => ({}), ...names.map(name => ({ name }))) { 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.settingsMenu.value.find(item => item.key === 'third-party').label, '邮件客户端')
assert.equal(page.activeSettings.value.label, '安全') assert.equal(page.activeSettings.value.label, '安全')
capabilities.add('config.read') capabilities.add('config.read')
capabilities.add('plugin.read_global') capabilities.add('plugin.read_global')
store.settingsActiveTab = 'config' store.settingsActiveTab = 'config'
assert.equal(page.activeSettings.value.label, '配置管理') 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.equal(page.settingsMenu.value.find(item => item.key === 'plugins').label, '插件配置')
assert.ok(page.settingsMenu.value.every(item => item.icon && item.hint)) assert.ok(page.settingsMenu.value.every(item => item.icon && item.hint))
capabilities.delete('config.read') capabilities.delete('config.read')
@ -139,7 +139,7 @@ test('配置管理只在真实移动端将内部导航移到内容顶部', () =>
const containerStart = config.indexOf('@container settings-content (max-width: 560px)') const containerStart = config.indexOf('@container settings-content (max-width: 560px)')
const desktopStart = config.indexOf('@media (min-width: 769px)', containerStart) const desktopStart = config.indexOf('@media (min-width: 769px)', containerStart)
const containerRule = config.slice(containerStart, desktopStart) 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, /grid-template-areas: "main submenu"/)
assert.match(config, /@media \(min-width: 769px\)[\s\S]*grid-template-columns: minmax\(0, 1fr\)/) 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"/) 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 assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { compileFunction } from 'node:vm'
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
const require = createRequire(new URL('../../frontend/package.json', import.meta.url)) 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 { parse, compileScript } = require('@vue/compiler-sfc')
const read = extension => readFileSync(new URL(`../../frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.${extension}`, import.meta.url), 'utf8') const read = extension => readFileSync(new URL(`../../frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.${extension}`, import.meta.url), 'utf8')
for (const succeeded of [false, true]) { test('客户端授权码页面的 Vue 模板和脚本可编译', () => {
test(`关闭协议后界面状态必须与服务端一致:请求成功=${succeeded}`, async () => { const { descriptor, errors } = parse(read('vue'))
const source = read('js').replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') assert.deepEqual(errors, [])
let serverEnabled = true assert.doesNotThrow(() => compileScript(descriptor, { id: 'audit-protocol', inlineTemplate: true }))
const requests = [] })
const notices = []
const transport = { test('客户端授权码接口与界面能力保持一致', () => {
get: async () => ({ errorNo: 0, data: [{ protocol: 'imap', enabled: serverEnabled, configured: true }] }), const source = read('js')
put: async (url, data) => { const template = read('vue')
requests.push({ url, ...data }) for (const path of [
if (!succeeded) throw new Error('隔离模拟网络失败') '/api/v1/security/credentials/clients',
serverEnabled = data.enabled '/api/v1/security/credentials/clients/create',
return { errorNo: 0 } '/api/v1/security/credentials/clients/regenerate',
} '/api/v1/security/credentials/clients/revoke',
} '/api/v1/security/credentials/clients/reveal',
const usePage = compileFunction(source + '\nreturn usePage', ['onMounted', 'reactive', 'ref', 'ElMessageBox', 'ElNotification', 'http'])( ]) {
Vue.onMounted, Vue.reactive, Vue.ref, {}, value => notices.push(value), transport assert.match(source, new RegExp(path.replaceAll('/', '\\/')))
) }
const { descriptor, errors } = parse(read('vue')) assert.match(template, /安全验证查看明文/)
assert.deepEqual(errors, []) assert.match(source, /startRevealCountdown\(60\)/)
const compiled = compileScript(descriptor, { id: 'audit-protocol', inlineTemplate: true }) assert.match(template, /自动隐藏/)
const script = compiled.content assert.match(template, /撤销删除/)
.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()
}
})
}