Smail/test/frontend/config-settings.test.mjs
smail a7ad2bc32e
Some checks are pending
Security Checks / security (push) Waiting to run
进一步精简配置保存流程
2026-09-07 17:50:06 +08:00

936 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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 recentAuthenticationSource = readFileSync(new URL('../../frontend/src/utils/recentAuthentication.js', import.meta.url), 'utf8')
.replace(/^import .*$/gm, '')
.replaceAll('export async function ', 'async function ')
.replaceAll('export function ', 'function ')
const compileRecentAuthentication = compileFunction(
recentAuthenticationSource + '\nreturn { isRecentAuthenticationCancelled, requestWithRecentAuthentication }',
['ElMessageBox', 'http']
)
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']
)
const createPageWithKey = (...args) => {
const http = args[6]
const messageBox = args[5]
const recentAuthentication = compileRecentAuthentication(messageBox, http)
return compilePage(
...args,
formHelpers.readFormConfig,
formHelpers.updateFormConfig,
formHelpers.validateFormConfig,
recentAuthentication.isRecentAuthenticationCancelled,
recentAuthentication.requestWithRecentAuthentication
)
}
const createPage = (...args) => createPageWithKey(...args, 'base', () => {})
function fixture(get, pageKey = 'base', options = {}) {
const messages = []
let dispose = () => {}
const page = createPageWithKey(computed, () => {}, reactive, ref,
{ error: message => messages.push(message), warning: message => messages.push(message), success: () => {} },
{
confirm: options.confirm || (async () => {}),
prompt: options.prompt || (async () => { throw 'cancel' })
}, {
get: path => path.endsWith('/pending') && !options.pendingGet ? Promise.resolve({ errorNo: 0, data: null }) : get(path),
...options.http
},
() => ({ hasCapability: options.hasCapability || (() => true) }), pageKey, callback => { dispose = callback })
return { page, messages, dispose: () => dispose() }
}
function deferred() {
let resolve
let reject
const promise = new Promise((success, failure) => { resolve = success; reject = failure })
return { promise, resolve, reject }
}
test('证书页从状态接口读取生效反代模式,不额外加载基础配置', async () => {
const requests = []
const { page } = fixture(async path => {
requests.push(path)
return { errorNo: 0, data: path.endsWith('/status') ? { available: true, tlsMode: 'proxy', activeGeneration: 2 } : { tls: { mode: 'managed_upload' } } }
}, 'ssl')
await page.loadAll()
assert.equal(page.status.tlsMode, 'proxy')
assert.deepEqual(requests, ['/api/v1/config/status', '/api/v1/config/ssl'])
const template = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue', import.meta.url), 'utf8')
assert.doesNotMatch(template, /selectedSection === 'ssl' && status\.tlsMode === 'proxy'|Smail 不自动申请或续期 SSL 证书/)
})
for (const failedRequest of ['/status', '/pending']) {
test(`候选保存成功后 ${failedRequest} 读取失败不能重新保存,刷新后可恢复或取消`, async () => {
let pending = null
let fail = false
let saves = 0
const active = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: {
relayHost: 'old.example.com', relayPort: 587, security: 'starttls',
from: 'sender@example.com', fromDomain: 'example.com'
},
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
}
const { page } = fixture(() => {}, 'mail', { http: {
get: async path => {
if (fail && path.endsWith(failedRequest)) throw new Error('模拟读取失败')
return { errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: 1 } : active }
},
put: async (path, body) => {
saves++
pending = { generation: 2, candidateId: 'pending-recovery', section: 'mail', sameSession: true, payload: body.payload }
fail = true
return { errorNo: 0, data: pending }
},
post: async (path, body) => {
assert.equal(body.candidateId, 'pending-recovery')
if (body.action === 'resume') pending.sameSession = true
if (body.action === 'cancel') pending = null
return { errorNo: 0 }
}
} })
await page.loadAll()
page.updateField(['relay', 'relayHost'], 'new.example.com')
await page.saveConfig()
assert.equal(saves, 1)
page.updateField(['relay', 'relayHost'], 'blocked.example.com')
await page.saveConfig()
assert.equal(saves, 1)
assert.equal(page.formConfig.value.relay.relayHost, 'new.example.com')
fail = false
pending.sameSession = false
await page.refreshPage()
assert.equal(page.savedGeneration.value, 0)
assert.equal(page.pendingCandidate.value.candidateId, 'pending-recovery')
await page.managePending('resume')
assert.equal(page.savedGeneration.value, 2)
await page.managePending('cancel')
assert.equal(page.pendingCandidate.value, null)
page.updateField(['relay', 'relayHost'], 'editable.example.com')
assert.equal(page.formConfig.value.relay.relayHost, 'editable.example.com')
})
}
test('旧版配置仅请求状态,不并发请求分区、备份和审计', async () => {
const requests = []
const { page, messages } = fixture(async path => {
requests.push(path)
return { errorNo: 0, data: { available: false, unavailableReason: '在线配置管理尚未启用' } }
})
await page.loadAll()
assert.deepEqual(requests, ['/api/v1/config/status'])
assert.equal(page.availabilityError.value, '在线配置管理尚未启用')
assert.deepEqual(messages, [])
assert.equal(page.loading.value, false)
})
test('配置页不加载备份或审计,维护页失败只显示本页错误', async () => {
const requests = []
const get = async path => {
requests.push(path)
if (path.endsWith('/status')) return { errorNo: 0, data: { available: true } }
if (path.endsWith('/base')) return { errorNo: 0, data: {} }
throw new Error('配置服务暂时不可用')
}
const { page, messages } = fixture(get)
await page.loadAll()
assert.equal(page.availabilityError.value, '')
assert.equal(page.editorText.value, '{}')
assert.deepEqual(requests, ['/api/v1/config/status', '/api/v1/config/base'])
const backup = fixture(get, 'backups').page
const audit = fixture(get, 'audit').page
await Promise.all([backup.loadAll(), audit.loadAll()])
assert.equal(backup.backupsError.value, '配置服务暂时不可用')
assert.equal(audit.auditError.value, '配置服务暂时不可用')
assert.equal(backup.editorText.value, '')
assert.equal(audit.editorText.value, '')
assert.deepEqual(messages, [])
await page.loadBackups()
assert.deepEqual(messages, [])
assert.equal(page.backupsError.value, '配置服务暂时不可用')
})
test('附属模块失败清除旧数据和回滚选择,重试成功清除错误', async () => {
let fail = true
const { page } = fixture(async () => {
if (fail) throw new Error('网络不可用')
return { errorNo: 0, data: [{ generation: 2 }] }
})
page.backups.value = [{ generation: 1 }]
page.auditItems.value = [{ id: 'old' }]
page.rollbackGeneration.value = 1
page.rollbackPreview.value = { targetGeneration: 1 }
page.rollbackConfirmed.value = true
await Promise.all([page.loadBackups(), page.loadAudit()])
assert.deepEqual(page.backups.value, [])
assert.deepEqual(page.auditItems.value, [])
assert.equal(page.rollbackGeneration.value, 0)
assert.equal(page.rollbackPreview.value, null)
assert.equal(page.rollbackConfirmed.value, false)
fail = false
await Promise.all([page.loadBackups(), page.loadAudit()])
assert.equal(page.backupsError.value, '')
assert.equal(page.auditError.value, '')
assert.equal(page.backups.value.length, 1)
assert.equal(page.auditItems.value.length, 1)
assert.equal(page.backupsLoading.value, false)
assert.equal(page.auditLoading.value, false)
})
test('状态请求失败后可重试恢复,不保留不可用提示', async () => {
let available = false
const { page } = fixture(async path => {
if (!available) throw new Error('网络不可用')
return { errorNo: 0, data: path.endsWith('/status') ? { available: true } : {} }
})
await page.loadAll()
assert.equal(page.availabilityError.value, '网络不可用')
available = true
await page.loadAll()
assert.equal(page.availabilityError.value, '')
})
test('独立页互不覆盖,页面卸载时清除密码并丢弃较慢响应', async () => {
const base = deferred()
const email = deferred()
const { page, dispose } = fixture(() => base.promise)
page.editorText.value = '{"old":true}'
page.confirmed.value = true
page.secretValue.value = 'test-only-value'
const baseRequest = page.loadSection()
assert.equal(page.editorText.value, '')
assert.equal(page.confirmed.value, false)
assert.equal(page.secretValue.value, '')
assert.equal(page.sectionLoading.value, true)
page.secretValue.value = 'test-only-value'
dispose()
assert.equal(page.secretValue.value, '')
const emailPage = fixture(() => email.promise, 'mail').page
const emailRequest = emailPage.loadSection()
email.resolve({ errorNo: 0, data: { policy: { smtpPort: 25 } } })
await emailRequest
assert.equal(emailPage.currentSection.value.label, '邮件服务与策略')
assert.equal(emailPage.sectionLoading.value, false)
base.resolve({ errorNo: 0, data: { database: { type: 'sqlite' } } })
await baseRequest
assert.deepEqual(JSON.parse(emailPage.editorText.value), { policy: { smtpPort: 25 } })
assert.equal(page.editorText.value, '')
assert.equal(page.databaseConfig.type, '')
})
test('同一页重复加载时,过期失败不覆盖最新请求', async () => {
const base = deferred()
const ssl = deferred()
let count = 0
const { page } = fixture(() => ++count === 1 ? base.promise : ssl.promise, 'ssl')
const baseRequest = page.loadSection()
const sslRequest = page.loadSection()
base.reject(new Error('旧分区读取失败'))
await baseRequest
assert.equal(page.sectionLoading.value, true)
assert.equal(page.sectionError.value, '')
ssl.resolve({ errorNo: 0, data: { enabled: true } })
await sslRequest
assert.equal(page.sectionLoading.value, false)
assert.equal(page.sectionError.value, '')
assert.deepEqual(JSON.parse(page.editorText.value), { enabled: true })
})
test('配置页按需加载,邮件页和安全策略页共用 mail 分区', async () => {
const requests = []
for (const key of ['base', 'mail', 'security_policy', 'ssl', 'plugins']) {
const { page } = fixture(async path => { requests.push(path); return { errorNo: 0, data: {} } }, key)
await page.loadAll()
assert.equal(page.selectedSection.value, key)
assert.match(page.currentSection.value.label, /[\u4e00-\u9fff]/)
assert.equal(page.secretValue.value, '')
assert.equal(page.confirmed.value, false)
assert.equal(page.savedGeneration.value, 0)
}
assert.deepEqual(requests, ['base', 'mail', 'security_policy', 'ssl', 'plugins']
.flatMap(key => ['/api/v1/config/status', `/api/v1/config/${key === 'security_policy' ? 'mail' : key}`]))
})
for (const key of ['mail', 'security_policy']) {
test(`${key} 页面验证、保存、候选恢复及应用均使用 mail 分区且保留未展示字段`, async () => {
const requests = []
const payloads = []
let pending = null
let active = {
meta: { schema: 1, generation: 1 },
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: { verificationEnabled: true, loginPolicy: 'new_device' },
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 },
policy: { maxMessageBytes: 1234, spamFilterLevel: 1, externalRecoveryEnabled: true }
}
const { page } = fixture(() => {}, key, { http: {
get: async path => ({ errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: active.meta.generation } : structuredClone(active) }),
put: async (path, body) => {
requests.push(path)
payloads.push(structuredClone(body))
pending = { generation: 2, candidateId: 'mail-candidate', section: 'mail', sameSession: true, payload: body.payload }
return { errorNo: 0, data: pending }
},
post: async (path, body) => {
requests.push(path)
if (path.endsWith('/apply')) {
assert.equal(body.candidateId, 'mail-candidate')
active = { ...pending.payload, meta: { schema: 1, generation: 2 } }
pending = null
}
return { errorNo: 0, data: { valid: true } }
}
} })
await page.loadAll()
const path = key === 'mail' ? ['imap', 'tlsPort'] : ['relay', 'loginPolicy']
page.updateField(path, key === 'mail' ? 1993 : 'always')
await page.validateConfig()
await page.saveConfig()
assert.equal(page.savedGeneration.value, 2)
assert.equal(payloads[0].expectedGeneration, 1)
assert.deepEqual(payloads[0].payload.listener, active.listener)
assert.deepEqual(payloads[0].payload.pop3, active.pop3)
if (key === 'security_policy') assert.equal(payloads[0].secretChanges['relay.password'], undefined)
if (key === 'security_policy') {
await page.refreshPage()
assert.equal(page.savedGeneration.value, 2)
}
page.updateField(path, key === 'mail' ? 8888 : 'off')
assert.notEqual(key === 'mail' ? page.formConfig.value.imap.tlsPort : page.formConfig.value.relay.loginPolicy, key === 'mail' ? 8888 : 'off')
await page.applyConfig()
assert.equal(page.status.activeGeneration, 2)
assert.equal(page.savedGeneration.value, 0)
assert.deepEqual(requests, ['/api/v1/config/mail/validate', '/api/v1/config/mail', '/api/v1/config/mail/apply'])
})
}
test('安全策略页只编辑登录验证和账号恢复字段,完整保留邮件监听、中继与邮件策略', async () => {
const config = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: { relayHost: 'smtp.example.com', relayPort: 587, security: 'starttls', verificationEnabled: true, loginPolicy: 'new_device' },
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 },
policy: { maxMessageBytes: 10485760, spamFilterLevel: 2, externalRecoveryEnabled: true }
}
let request
const { page } = fixture(async () => ({ errorNo: 0, data: config }), 'security_policy', {
http: { put: async (path, body) => { request = { path, body }; return { errorNo: 0, data: { generation: 2 } } } }
})
await page.loadSection()
page.updateField(['relay', 'loginPolicy'], 'always')
page.updateField(['listener', 'port'], 2525)
await page.saveConfig()
assert.equal(request.path, '/api/v1/config/mail')
assert.equal(request.body.payload.listener.port, 25)
assert.equal(request.body.payload.relay.loginPolicy, 'always')
assert.deepEqual(request.body.payload.imap, config.imap)
assert.deepEqual(request.body.payload.pop3, config.pop3)
assert.equal(request.body.payload.policy.maxMessageBytes, config.policy.maxMessageBytes)
assert.deepEqual(request.body.secretChanges, {})
})
test('迁移预览票据返回模板,迁移执行取消时不发送写入请求', async () => {
const requests = []
const page = createPage(computed, () => {}, reactive, ref,
{ success: () => {}, error: () => assert.fail('不应显示错误') },
{ confirm: async () => { throw 'cancel' } },
{ post: async (path, payload) => {
requests.push({ path, payload })
return { errorNo: 0, data: { migration: { id: 'test-migration', status: 'previewed' }, token: 'test-only-ticket' } }
} }, () => ({ hasCapability: () => true }))
page.status.activeGeneration = 3
page.migrationTargetName.value = ' test-next.db '
assert.equal(page.migrationToken.value, '')
await page.previewDatabaseMigration()
assert.equal(page.migrationToken.value, 'test-only-ticket')
assert.equal(page.migrationPreview.value.id, 'test-migration')
await page.executeDatabaseMigration()
assert.deepEqual(requests, [{
path: '/api/v1/database/migrations/preview',
payload: { target: { type: 'sqlite', localRef: 'test-next.db' }, expectedGeneration: 3 }
}])
})
test('三种数据库使用独立目标字段,类型切换清除密码及旧预览', async () => {
const requests = []
const { page, dispose } = fixture(async () => ({ errorNo: 0, data: {} }), 'migration', {
http: { post: async (path, payload) => {
requests.push({ path, payload })
return { errorNo: 0, data: { migration: { id: 'next', target: payload.target }, token: 'ticket' } }
} }
})
for (const type of ['mysql', 'postgresql']) {
page.changeMigrationType(type)
assert.equal(page.migrationTarget.port, type === 'mysql' ? 3306 : 5432)
assert.equal(page.migrationReady.value, false)
page.migrationTargetName.value = 'smail_next'
Object.assign(page.migrationTarget, { host: ' db.local ', username: ' smail ', password: ' test-secret ' })
assert.equal(page.migrationReady.value, true)
await page.previewDatabaseMigration()
assert.equal(requests.at(-1).payload.target.type, type)
assert.equal(requests.at(-1).payload.target.host, 'db.local')
assert.equal(requests.at(-1).payload.target.password.plaintext, ' test-secret ')
page.migrationTarget.host = 'other.local'
await page.executeDatabaseMigration()
assert.equal(page.migrationToken.value, '')
assert.match(page.migrationError.value, /重新生成/)
}
assert.equal(requests.length, 2)
page.changeMigrationType('sqlite')
assert.equal(page.migrationTarget.password, '')
assert.equal(page.migrationPreview.value, null)
page.migrationTargetName.value = '../bad.db'
assert.equal(page.migrationReady.value, false)
page.migrationTargetName.value = 'next.db'
page.status.pendingRestart = ['database']
assert.equal(page.migrationReady.value, false)
page.migrationTarget.password = 'temporary'
dispose()
assert.equal(page.migrationTarget.password, '')
})
test('独立页模板可编译,编辑器不再连接维护面板且权限绑定保留', () => {
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc')
const root = new URL('../../frontend/src/views/components/ConfigSettings/', import.meta.url)
const template = readFileSync(new URL('ConfigEditor/ConfigEditor.vue', root), 'utf8')
const maintenance = readFileSync(new URL('ConfigMaintenance/ConfigMaintenance.vue', root), 'utf8')
const css = readFileSync(new URL('ConfigSettings.css', root), 'utf8')
const { descriptor, errors } = parse(template)
assert.deepEqual(errors, [])
assert.doesNotThrow(() => compileScript(descriptor, { id: 'config-layout', inlineTemplate: true }))
assert.deepEqual(compileStyle({ source: css, id: 'data-v-config-layout', scoped: true }).errors, [])
assert.match(template, /v-if="canWrite" class="editor-footer"/)
assert.doesNotMatch(template, /v-model="confirmed"/)
assert.doesNotMatch(template, /maintenance-panel|migration-panel|rollback-panel|audit-panel/)
for (const [key, capability] of [['migration', 'canMigrate'], ['restart', 'canRestart'], ['backups', 'canRollback'], ['audit', 'canAudit']]) {
assert.ok(maintenance.includes(`pageKey === '${key}' && ${capability}`))
}
assert.match(maintenance, /v-if="canExportAudit"/)
for (const name of ['BaseConfig', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance']) {
const page = readFileSync(new URL(`${name}/${name}.vue`, root), 'utf8')
const { descriptor, errors } = parse(page)
assert.deepEqual(errors, [])
assert.doesNotThrow(() => compileScript(descriptor, { id: name, inlineTemplate: true }))
}
assert.match(css, /@container config-main \(max-width: 620px\)/)
assert.match(css, /\.maintenance-summary:focus-visible/)
assert.doesNotMatch(template, /&#x[0-9a-f]+;/i)
})
test('配置编辑页将验证、候选保存和应用精简为单一保存操作', () => {
const root = new URL('../../frontend/src/views/components/ConfigSettings/', import.meta.url)
const template = readFileSync(new URL('ConfigEditor/ConfigEditor.vue', root), 'utf8')
const sharedCss = readFileSync(new URL('ConfigSettings.css', root), 'utf8')
const editorCss = readFileSync(new URL('ConfigEditor/ConfigEditor.css', root), 'utf8')
assert.doesNotMatch(template, /change-workflow|step-number|配置变更流程/)
assert.doesNotMatch(sharedCss + editorCss, /change-workflow|step-number/)
assert.match(template, /@click="saveAndApply"/)
assert.match(template, /保存配置/)
assert.match(template, /@click="continuePending"/)
assert.match(template, /继续保存/)
assert.match(template, /v-if="canWrite" class="editor-footer"/)
assert.match(template, /@click="managePending\('cancel'\)"/)
assert.match(template, /放弃更改/)
assert.doesNotMatch(template, /只验证|保存候选配置|我已确认这是受控变更|应用代次/)
})
test('保存并应用一次点击按候选协议连续完成保存和应用', async () => {
const requests = []
let pending = null
const active = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: { relayHost: '', relayPort: 0, security: '', from: '', fromDomain: '' },
imap: { tlsPort: 993 },
pop3: { port: 110, tlsPort: 995 },
policy: { maxMessageBytes: 10 << 20, spamFilterLevel: 2 }
}
const { page } = fixture(async path => ({
errorNo: 0,
data: path.endsWith('/status') ? { activeGeneration: 4 } : active
}), 'mail', {
http: {
get: async path => ({
errorNo: 0,
data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: 4 } : active
}),
put: async (path, body) => {
requests.push(path)
pending = { generation: 5, candidateId: 'one-click-5', section: 'mail', sameSession: true, payload: body.payload }
return { errorNo: 0, data: pending }
},
post: async (path, body) => {
requests.push(path)
assert.equal(body.generation, 5)
assert.equal(body.candidateId, 'one-click-5')
pending = null
return { errorNo: 0, data: { generation: 5 } }
}
}
})
await page.loadAll()
page.updateField(['policy', 'maxMessageBytes'], 12 << 20)
await page.saveAndApply()
assert.deepEqual(requests, ['/api/v1/config/mail', '/api/v1/config/mail/apply'])
assert.equal(page.pendingCandidate.value, null)
assert.equal(page.savedGeneration.value, 0)
})
test('最近认证过期时在当前页确认密码并自动重试保存和应用', async () => {
const requests = []
let pending = null
let saveAttempts = 0
let prompts = 0
const active = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: { relayHost: '', relayPort: 0, security: '', from: '', fromDomain: '' },
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
}
const recentAuthError = Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' })
const { page } = fixture(async () => ({ errorNo: 0, data: active }), 'mail', {
prompt: async () => { prompts++; return { value: 'CurrentPass12' } },
http: {
get: async path => ({
errorNo: 0,
data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: 1 } : active
}),
put: async (path, body) => {
requests.push(path)
saveAttempts++
if (saveAttempts === 1) throw recentAuthError
pending = { generation: 2, candidateId: 'reauth-candidate', section: 'mail', sameSession: true, payload: body.payload }
return { errorNo: 0, data: pending }
},
post: async (path, body) => {
requests.push(path)
if (path.endsWith('/reauth')) {
assert.deepEqual(body, { password: 'CurrentPass12' })
return { errorNo: 0, data: { reauthenticated: true } }
}
pending = null
return { errorNo: 0, data: { generation: 2 } }
}
}
})
await page.loadAll()
page.updateField(['listener', 'port'], 2525)
await page.saveAndApply()
assert.equal(prompts, 1)
assert.deepEqual(requests, [
'/api/v1/config/mail',
'/api/v1/security/reauth',
'/api/v1/config/mail',
'/api/v1/config/mail/apply'
])
})
test('取消最近身份确认时保留配置输入且不重试请求', async () => {
let saves = 0
const active = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: {}, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
}
const { page, messages } = fixture(async path => ({
errorNo: 0,
data: path.endsWith('/status') ? { activeGeneration: 1 } : active
}), 'mail', {
prompt: async () => { throw 'cancel' },
http: {
put: async () => {
saves++
throw Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' })
}
}
})
await page.loadAll()
page.updateField(['listener', 'port'], 2525)
await page.saveAndApply()
assert.equal(saves, 1)
assert.equal(page.formConfig.value.listener.port, 2525)
assert.deepEqual(messages, [])
})
test('配置编辑区移除填写标题、说明与重复标签,保留主标题和必要提示', () => {
const root = new URL('../../frontend/src/views/components/ConfigSettings/', import.meta.url)
const template = readFileSync(new URL('ConfigEditor/ConfigEditor.vue', root), 'utf8')
const css = readFileSync(new URL('ConfigSettings.css', root), 'utf8')
assert.doesNotMatch(template, /'填写配置'|'查看配置'|按分组填写内容|当前页面的脱敏配置内容|panel-heading|selected-section-label/)
assert.doesNotMatch(css, /selected-section-label/)
assert.match(template, /<h3 id="config-page-title">{{ currentSection.label }}/)
assert.doesNotMatch(template, /selectedSection === 'ssl' && status\.tlsMode === 'proxy'|role="note"/)
assert.match(template, /v-if="sectionError"[\s\S]*?:title="sectionError"/)
assert.doesNotMatch(template, /config-sensitive-hint|sensitiveHint/)
assert.match(template, /v-if="secretField" class="secret-editor"/)
assert.match(template, /v-if="validationResult && !validationResult\.valid"[^>]*role="status"/)
})
test('离开、刷新和正在执行的变更受保护,取消时保留编辑内容', async () => {
let cancel = true
let prompts = 0
const { page } = fixture(async () => ({ errorNo: 0, data: {} }), 'mail', {
confirm: async () => { prompts++; if (cancel) throw 'cancel' }
})
await page.loadAll()
assert.equal(await page.beforeLeave(), true)
assert.equal(prompts, 0)
page.editorText.value = '{"test":true}'
assert.equal(await page.beforeLeave(), false)
await page.refreshPage()
assert.equal(page.editorText.value, '{"test":true}')
cancel = false
assert.equal(await page.beforeLeave(), true)
page.working.value = 'save'
assert.equal(await page.beforeLeave(), false)
page.working.value = ''
await page.refreshPage()
assert.equal(page.hasChanges.value, false)
page.savedGeneration.value = 3
cancel = true
assert.equal(await page.beforeLeave(), false)
})
test('标题合并配置状态,保留刷新保护和降级提示,不再渲染快照卡片', () => {
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc')
const root = new URL('../../frontend/src/views/components/ConfigSettings/ConfigEditor/', import.meta.url)
const template = readFileSync(new URL('ConfigEditor.vue', root), 'utf8')
const css = readFileSync(new URL('ConfigEditor.css', root), 'utf8')
const { descriptor, errors } = parse(template)
assert.deepEqual(errors, [])
assert.doesNotThrow(() => compileScript(descriptor, { id: 'config-overview', inlineTemplate: true }))
assert.deepEqual(compileStyle({ source: css, id: 'data-v-config-overview', scoped: true }).errors, [])
const header = template.match(/<header[\s\S]*?<\/header>/)?.[0] || ''
assert.match(header, /aria-labelledby="config-page-title"/)
assert.match(header, /<h3 id="config-page-title">{{ currentSection.label }}/)
assert.doesNotMatch(header, /stateLabel|运行状态|state-tag/)
assert.match(header, /v-for="issue in status.degradedReasons \|\| \[\]"/)
assert.match(header, /v-if="!loading && !availabilityError"/)
assert.match(header, /:disabled="!!working \|\| sectionLoading \|\| loading"/)
assert.match(header, /@click="refreshPage"/)
assert.doesNotMatch(header, /<dl class="overview-meta"/)
assert.doesNotMatch(header, /access-tag/)
assert.doesNotMatch(template, /配置快照|status-panel|status-grid/)
assert.match(css, /@container config-main \(max-width: 620px\)/)
assert.match(css, /overflow-wrap: anywhere/)
})
test('卸载发生在状态请求期间,不再发送后续分区请求', async () => {
const pending = deferred()
const requests = []
const { page, dispose } = fixture(path => { requests.push(path); return pending.promise })
const loading = page.loadAll()
dispose()
pending.resolve({ errorNo: 0, data: { available: true } })
await loading
assert.deepEqual(requests, ['/api/v1/config/status'])
})
test('只读账号不得发送配置写入请求或读取无权限维护数据', async () => {
const requests = []
const { page } = fixture(async path => { requests.push(path); return { errorNo: 0, data: {} } }, 'audit', {
hasCapability: capability => capability === 'config.read'
})
await page.loadAll()
await page.validateConfig()
await page.saveConfig()
page.savedGeneration.value = 3
await page.applyConfig()
assert.deepEqual(requests, ['/api/v1/config/status'])
})
test('邮件页保持验证、保存、应用端点和代次协议,中继密码只作为 secretChanges 发送', async () => {
const requests = []
let pending = null
const active = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: {
relayHost: 'smtp.example.com', relayPort: 587, security: 'starttls',
from: 'sender@example.com', fromDomain: 'example.com'
},
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
}
const { page } = fixture(async path => ({ errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: 8 } : active }), 'mail', {
http: {
get: async path => ({ errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: 8 } : active }),
post: async (path, body) => { requests.push({ path, body }); if (path.endsWith('/apply')) pending = null; return { errorNo: 0, data: { valid: true } } },
put: async (path, body) => {
requests.push({ path, body })
pending = { generation: 9, candidateId: 'candidate-9', section: 'mail', sameSession: true, payload: body.payload }
return { errorNo: 0, data: pending }
}
}
})
await page.loadAll()
page.updateField(['relay', 'password'], 'test-only-value')
page.confirmed.value = true
await page.validateConfig()
await page.saveConfig()
assert.equal(page.savedGeneration.value, 9)
await page.applyConfig()
assert.deepEqual(requests.map(item => item.path), ['/api/v1/config/mail/validate', '/api/v1/config/mail', '/api/v1/config/mail/apply'])
assert.equal(requests[0].body.expectedGeneration, 8)
assert.equal(requests[0].body.payload.relay.password, undefined)
assert.deepEqual(requests[0].body.payload.listener, active.listener)
assert.deepEqual(requests[0].body.secretChanges, { 'relay.password': { operation: 'replace', value: { plaintext: 'test-only-value' } } })
assert.deepEqual(requests[0].body.confirmation, { confirmed: true, method: 'recent_auth' })
assert.deepEqual(requests[2].body, { generation: 9, candidateId: 'candidate-9' })
assert.equal(page.secretValue.value, '')
assert.equal(page.confirmed.value, false)
assert.equal(page.savedGeneration.value, 0)
})
test('可视化编辑按权限和工作状态保护,编辑会清除过期确认及候选代次', async () => {
const config = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: { relayHost: 'smtp.example.com' }, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
}
const { page, dispose } = fixture(async () => ({ errorNo: 0, data: config }), 'mail')
await page.loadAll()
const original = page.editorText.value
for (const state of ['loading', 'sectionLoading', 'working', 'sectionError']) {
page[state].value = true
page.updateField(['relay', 'relayHost'], 'blocked.example.com')
page.updateSecret('blocked-value')
assert.equal(page.editorText.value, original)
assert.equal(page.secretValue.value, '')
page[state].value = false
}
const readonly = fixture(async () => ({ errorNo: 0, data: {} }), 'mail', { hasCapability: () => false }).page
readonly.updateField(['relay', 'relayHost'], 'blocked.example.com')
assert.equal(readonly.editorText.value, '')
page.savedGeneration.value = 8
page.updateField(['relay', 'relayHost'], 'blocked.example.com')
assert.equal(page.editorText.value, original)
page.savedGeneration.value = 0
page.confirmed.value = true
page.validationResult.value = { valid: true }
page.updateField(['relay', 'relayHost'], 'new.example.com')
assert.equal(page.formConfig.value.relay.relayHost, 'new.example.com')
assert.equal(page.savedGeneration.value, 0)
assert.equal(page.confirmed.value, false)
assert.equal(page.validationResult.value, null)
dispose()
page.updateField(['relay', 'relayHost'], 'blocked.example.com')
assert.equal(page.formConfig.value.relay.relayHost, 'new.example.com')
})
test('前端无效字段阻止网络请求,修正字段后同步清除错误', async () => {
const requests = []
const config = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: {
relayHost: 'smtp.example.com', relayPort: 587, security: 'starttls',
from: 'sender@example.com', fromDomain: 'example.com'
},
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
}
const { page } = fixture(async () => ({ errorNo: 0, data: config }), 'mail', {
http: { post: async path => { requests.push(path); return { errorNo: 0, data: { valid: true } } } }
})
await page.loadAll()
page.updateField(['imap', 'tlsPort'], null)
await page.validateConfig()
await page.saveConfig()
assert.deepEqual(requests, [])
assert.ok(page.formErrors.value['imap.tlsPort'])
page.updateField(['imap', 'tlsPort'], 1993)
assert.deepEqual(page.formErrors.value, {})
await page.validateConfig()
assert.deepEqual(requests, ['/api/v1/config/mail/validate'])
})
test('插件读取状态字段不传回严格解析接口,未展示字段和权限仍保留', async () => {
let request
const plugin = { enabled: true, executable: 'pmail_telegram_push.exe', permissions: { capabilities: ['network', 'userSettings', 'settingsHtml'], network: [] }, settings: { timeout: 30, future: 'preserved' }, secretStatus: { configured: true }, settingsConfigured: true }
const { page } = fixture(async () => ({ errorNo: 0, data: { meta: { generation: 7 }, plugins: { telegram_push: plugin } } }), 'plugins', {
http: { put: async (path, body) => { request = body; return { errorNo: 0, data: { generation: 8 } } } }
})
await page.loadSection()
page.updateField(['plugins', 'telegram_push', 'settings', 'timeout'], 40)
await page.saveConfig()
const actual = request.payload.plugins.telegram_push
assert.equal(actual.secretStatus, undefined)
assert.equal(actual.settingsConfigured, undefined)
assert.deepEqual(actual.permissions, plugin.permissions)
assert.deepEqual(actual.settings, { timeout: 40, future: 'preserved' })
assert.equal(request.payload.meta.generation, 7)
assert.deepEqual(request.secretChanges, {})
assert.equal(page.formConfig.value.plugins.telegram_push.secretStatus.configured, true)
})
test('SQLite 不显示或发送密码,网络数据库和中继密码留空保留且不裁剪空格', async () => {
for (const type of ['sqlite', 'mysql', 'postgresql']) {
let request
const { page } = fixture(async () => ({ errorNo: 0, data: {
database: { type, host: 'localhost', port: 3306, username: 'test' },
domains: { primary: 'example.com', web: 'mail.example.com', accepted: ['example.com'] }
} }), 'base', {
http: { post: async (path, body) => { request = body; return { errorNo: 0, data: { valid: true } } } }
})
await page.loadSection()
assert.equal(!!page.secretField.value, type !== 'sqlite')
await page.validateConfig()
assert.deepEqual(request.secretChanges, {})
page.secretValue.value = ' test-only-value '
await page.validateConfig()
assert.deepEqual(request.secretChanges, type === 'sqlite' ? {} : { 'database.password': { operation: 'replace', value: { plaintext: ' test-only-value ' } } })
}
let request
const mail = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: {
relayHost: 'smtp.example.com', relayPort: 587, security: 'starttls',
from: 'sender@example.com', fromDomain: 'example.com'
},
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
}
const { page } = fixture(async () => ({ errorNo: 0, data: mail }), 'mail', {
http: { post: async (path, body) => { request = body; return { errorNo: 0, data: { valid: true } } } }
})
await page.loadSection()
page.updateField(['relay', 'password'], ' smtp-test-only ')
await page.validateConfig()
assert.equal(request.secretChanges['relay.password'].value.plaintext, ' smtp-test-only ')
})
test('基础配置保存完整保留隐藏的额外访问主机、数据库和代理信任配置', async () => {
const config = {
database: { type: 'sqlite', localRef: 'preserved.db' },
domains: { primary: 'example.com', web: 'mail.example.com', accepted: ['example.com'], accessHosts: ['alternate.example.com', '192.0.2.10'] },
http: { tlsMode: 'proxy', port: 8080, httpsEnabled: false, trustedProxyCidrs: ['127.0.0.1/32', '192.0.2.10/32'] },
security: { externalEmailEnabled: false, webAuthnEnabled: false }
}
let request
const { page } = fixture(async () => ({ errorNo: 0, data: config }), 'base', {
http: { put: async (path, body) => { request = { path, body }; return { errorNo: 0, data: { generation: 2 } } } }
})
await page.loadSection()
page.updateField(['http', 'port'], 9080)
await page.saveConfig()
assert.equal(request.path, '/api/v1/config/base')
assert.deepEqual(request.body.payload, { ...config, http: { ...config.http, port: 9080 } })
assert.deepEqual(request.body.secretChanges, {})
})
test('保存后更改密码或填写内容不能应用旧候选', async () => {
const requests = []
const { page } = fixture(async () => ({ errorNo: 0, data: {} }), 'mail', {
http: {
put: async () => ({ errorNo: 0, data: { generation: 8 } }),
post: async path => { requests.push(path); return { errorNo: 0 } }
}
})
await page.loadSection()
await page.saveConfig()
page.updateSecret('test-only-value')
assert.equal(page.savedGeneration.value, 0)
await page.applyConfig()
await page.saveConfig()
page.editorText.value = '{"relay":{"relayHost":"new.example.com"}}'
await page.applyConfig()
assert.equal(page.savedGeneration.value, 0)
assert.deepEqual(requests, [])
})
test('邮件页与安全策略页共享候选并明确提示改动范围,忽略代次与字段顺序', async () => {
const active = {
meta: { generation: 1 }, listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: { verificationEnabled: true, loginPolicy: 'new_device' },
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 },
policy: { maxMessageBytes: 1234, externalRecoveryEnabled: true }
}
for (const key of ['mail', 'security_policy']) {
const { page } = fixture(async () => ({ errorNo: 0, data: active }), key)
await page.loadSection()
page.pendingCandidate.value = { section: 'mail', payload: { ...active, meta: { generation: 2 }, pop3: { tlsPort: 995, port: 110 } } }
assert.match(page.pendingScopeHint.value, /邮件服务与安全策略未变化/)
page.pendingCandidate.value.payload = { ...active, pop3: { port: 1110, tlsPort: 1995 }, policy: { maxMessageBytes: 5678 } }
assert.match(page.pendingScopeHint.value, /本次候选涉及:POP3 监听、邮件与账号策略。/)
assert.match(page.pendingScopeHint.value, /邮件服务页和安全策略页共用一组运行配置/)
page.pendingCandidate.value = { section: 'smtp' }
assert.equal(page.pendingScopeHint.value, '')
page.pendingCandidate.value = { section: 'mail' }
assert.match(page.pendingScopeHint.value, /候选内容尚未完整读取/)
}
const template = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue', import.meta.url), 'utf8')
assert.match(template, /:title="pendingScopeHint"/)
})
for (const key of ['spam_block', 'telegram_push', 'wechat_push']) {
test(`${key} 独立页面保留整个插件分区,验证、候选恢复和应用使用统一接口`, async () => {
let active = { meta: { generation: 1 }, plugins: {
spam_block: { enabled: false, executable: 'spam_block.exe', permissions: { network: [] }, settings: { threshold: 20 } },
telegram_push: { enabled: false, executable: 'pmail_telegram_push.exe', settings: { timeout: 30 }, secretStatus: { configured: true }, settingsConfigured: true },
wechat_push: { enabled: false, executable: 'wechat_push.exe', settings: { appId: 'test-only', future: 'preserved' } }
} }
const original = structuredClone(active)
let pending = null
const requests = []
const http = {
get: async path => {
requests.push(path)
return { errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: active.meta.generation }
: path.endsWith('/pending') ? pending : active }
},
put: async (path, body) => {
assert.equal(path, '/api/v1/config/plugins')
assert.equal(body.expectedGeneration, 1)
assert.deepEqual(body.secretChanges, {})
assert.equal(body.payload.plugins.telegram_push.secretStatus, undefined)
assert.equal(body.payload.plugins.telegram_push.settingsConfigured, undefined)
for (const id of Object.keys(original.plugins)) {
assert.deepEqual(body.payload.plugins[id].settings, original.plugins[id].settings)
assert.equal(body.payload.plugins[id].enabled, id === key)
}
pending = { generation: 2, section: 'plugins', sameSession: true, candidateId: 'plugin-test', payload: body.payload }
return { errorNo: 0, data: pending }
},
post: async (path, body) => {
requests.push(path)
if (path.endsWith('/apply')) {
assert.equal(body.generation, 2)
assert.equal(body.candidateId, 'plugin-test')
active = pending.payload
active.meta.generation = 2
pending = null
}
return { errorNo: 0, data: { valid: true } }
}
}
const { page, messages, dispose } = fixture(() => {}, key, { http })
await page.loadAll()
assert.deepEqual(requests, ['/api/v1/config/status', '/api/v1/config/plugins', '/api/v1/config/pending'])
assert.equal(page.selectedSection.value, key)
page.updateField(['plugins', key, 'enabled'], true)
await page.validateConfig()
await page.saveConfig()
assert.ok(requests.includes('/api/v1/config/plugins/validate'))
assert.match(page.pendingScopeHint.value, new RegExp(`本次候选涉及:${page.currentSection.value.label}。`))
const other = Object.keys(original.plugins).find(id => id !== key)
page.updateField(['plugins', other, 'enabled'], true)
assert.equal(page.formConfig.value.plugins[other].enabled, false)
dispose()
const restored = fixture(() => {}, other, { http }).page
await restored.loadAll()
assert.equal(restored.savedGeneration.value, 2)
assert.match(restored.pendingScopeHint.value, /三个插件共用一组运行配置/)
assert.ok(restored.pendingScopeHint.value.includes(page.currentSection.value.label))
await restored.applyConfig()
assert.ok(requests.includes('/api/v1/config/plugins/apply'))
assert.equal(restored.savedGeneration.value, 0)
assert.deepEqual(messages, [])
})
}