Smail/test/frontend/config-settings.test.mjs
smail c0ef89cdce
Some checks are pending
Security Checks / security (push) Waiting to run
同步客户端授权码页面回归测试
2026-09-16 00:21:28 +08:00

1290 lines
60 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 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 ')
.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', 'relayConfigured', 'relaySecurityForMail', '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,
formHelpers.relayConfigured,
formHelpers.relaySecurityForMail,
recentAuthentication.isRecentAuthenticationCancelled,
recentAuthentication.requestWithRecentAuthentication
)
}
const createPage = (...args) => createPageWithKey(...args, 'base', () => {})
function fixture(get, pageKey = 'base', options = {}) {
const messages = []
const successes = []
let dispose = () => {}
const page = createPageWithKey(computed, () => {}, reactive, ref,
{ error: message => messages.push(message), warning: message => messages.push(message), success: message => successes.push(message) },
{
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, successes, dispose: () => dispose() }
}
function deferred() {
let resolve
let reject
const promise = new Promise((success, failure) => { resolve = success; reject = failure })
return { promise, resolve, reject }
}
for (const cancelled of [false, true]) {
test(`共享身份确认保留单次重试,移除审计不影响其他操作:取消=${cancelled}`, async () => {
const requests = []
let prompts = 0
const helpers = compileRecentAuthentication({
prompt: async () => {
prompts++
if (cancelled) throw 'cancel'
return { value: 'test-password' }
}
}, {
post: async (url, body) => {
requests.push(url)
assert.equal(body.password, 'test-password')
return { errorNo: 0 }
}
})
let attempts = 0
const action = helpers.requestWithRecentAuthentication(async () => {
if (++attempts === 1) throw Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' })
return { errorNo: 0 }
})
if (cancelled) await assert.rejects(action, error => helpers.isRecentAuthenticationCancelled(error))
else assert.equal((await action).errorNo, 0)
assert.equal(prompts, 1)
assert.equal(attempts, cancelled ? 1 : 2)
assert.deepEqual(requests, cancelled ? [] : ['/api/v1/security/reauth'])
})
}
test('移除配置审计查询、导出和维护面板,不保留隐藏请求', async () => {
const requests = []
const { page } = fixture(async path => {
requests.push(path)
return { errorNo: 0, data: { available: true } }
}, 'audit')
await page.loadAll()
assert.deepEqual(requests, ['/api/v1/config/status'])
assert.equal(page.loadAudit, undefined)
assert.equal(page.exportAudit, undefined)
assert.doesNotMatch(source, /audits\/config|auditItems|canExportAudit/)
})
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: 0, security: 'starttls' },
relay: {
relayHost: 'old.example.com', relayPort: 587, security: 'starttls',
from: 'sender@example.com', fromDomain: 'example.com'
},
imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, 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
await backup.loadAll()
assert.equal(backup.backupsError.value, '配置服务暂时不可用')
assert.equal(backup.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.rollbackGeneration.value = 1
page.rollbackPreview.value = { targetGeneration: 1 }
page.rollbackConfirmed.value = true
await page.loadBackups()
assert.deepEqual(page.backups.value, [])
assert.equal(page.rollbackGeneration.value, 0)
assert.equal(page.rollbackPreview.value, null)
assert.equal(page.rollbackConfirmed.value, false)
fail = false
await page.loadBackups()
assert.equal(page.backupsError.value, '')
assert.equal(page.backups.value.length, 1)
assert.equal(page.backupsLoading.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', 'mail_policy', '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', 'mail_policy', 'security_policy', 'ssl', 'plugins']
.flatMap(key => ['/api/v1/config/status', `/api/v1/config/${['mail_policy', 'security_policy'].includes(key) ? 'mail' : key}`]))
})
for (const key of ['mail', 'mail_policy', '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: 0, security: 'starttls' },
relay: { verificationEnabled: true, loginPolicy: 'new_device' },
imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 },
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 edit = {
mail: { path: ['imap', 'tlsPort'], value: 1993, rejected: 8888 },
mail_policy: { path: ['policy', 'maxMessageBytes'], value: 5678, rejected: 8765 },
security_policy: { path: ['relay', 'loginPolicy'], value: 'always', rejected: 'off' }
}[key]
page.updateField(edit.path, edit.value)
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 !== 'mail') assert.equal(payloads[0].secretChanges['relay.password'], undefined)
if (key === 'security_policy') {
await page.refreshPage()
assert.equal(page.savedGeneration.value, 2)
}
page.updateField(edit.path, edit.rejected)
assert.notEqual(edit.path.reduce((value, part) => value?.[part], page.formConfig.value), edit.rejected)
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']]) {
assert.ok(maintenance.includes(`pageKey === '${key}' && ${capability}`))
}
assert.doesNotMatch(maintenance, /audit-panel|canExportAudit|loadAudit/)
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, /continuingSave \? '继续保存' : '保存配置'/)
assert.match(template, /v-if="showPendingRecovery"/)
assert.match(template, /v-if="showPendingRecovery && pendingScopeHint"/)
assert.match(template, /v-if="saveError"[\s\S]*?:title="saveError"[\s\S]*?type="error"/)
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
let active = {
meta: { generation: 4 },
listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' },
relay: { relayHost: '', relayPort: 0, security: '', from: '', fromDomain: '' },
imap: { tlsPort: 143 },
pop3: { port: 110, tlsPort: 0 },
policy: { maxMessageBytes: 10 << 20, spamFilterLevel: 2 }
}
const { page, successes, messages } = 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: active.meta.generation } : 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')
active = { ...pending.payload, meta: { generation: 5 } }
pending = null
return { errorNo: 0, data: { generation: 5 } }
}
}
})
await page.loadAll()
page.updateField(['listener', 'port'], 2525)
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)
assert.deepEqual(successes, ['配置已保存'])
assert.deepEqual(messages, [])
assert.equal(page.formConfig.value.listener.port, 2525)
})
test('一键保存的所有异步阶段保持忙碌,不闪现恢复提示或允许重复操作', { timeout: 5000 }, async t => {
const stages = Array.from({ length: 7 }, () => ({ entered: deferred(), release: deferred() }))
t.after(() => stages.forEach(stage => stage.release.resolve()))
let requestIndex = 0
let testing = false
let pending = null
let active = {
meta: { generation: 1 },
tls: { management: 'custom', mode: 'managed_upload', defaultDirectory: 'config/ssl', customDirectory: 'config/ssl', certificateRef: 'config/ssl/public.crt', privateKeyRef: 'config/ssl/private.key' }
}
const pause = async () => {
if (!testing) return
const stage = stages[requestIndex++]
assert.ok(stage, '不应产生重复请求')
stage.entered.resolve()
await stage.release.promise
}
const { page, successes } = fixture(() => {}, 'ssl', { http: {
get: async path => {
await pause()
return { errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: active.meta.generation } : active }
},
put: async (path, body) => {
await pause()
pending = { generation: 2, candidateId: 'continuous-save', section: 'ssl', sameSession: true, payload: body.payload }
return { errorNo: 0, data: pending }
},
post: async () => {
await pause()
active = { ...pending.payload, meta: { generation: 2 } }
pending = null
return { errorNo: 0, data: { generation: 2 } }
}
} })
await page.loadAll()
page.updateField(['tls', 'certificateRef'], 'D:/certs/public.crt')
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
const { watch } = require('vue')
const transitions = []
const stop = watch(() => [page.working.value, page.saveFlow.value, page.pendingCandidate.value], () => {
transitions.push({ working: page.working.value, flow: page.saveFlow.value, pending: !!page.pendingCandidate.value, busy: page.busy.value, recovery: page.showPendingRecovery.value })
}, { flush: 'sync' })
t.after(stop)
testing = true
const saving = page.saveAndApply()
for (const stage of stages) {
await Promise.race([stage.entered.promise, saving.then(() => { throw new Error('保存流程提前结束') })])
assert.equal(page.busy.value, true)
assert.equal(page.saveFlow.value, 'save')
assert.equal(page.showPendingRecovery.value, false)
assert.equal(page.continuingSave.value, false)
const input = page.editorText.value
page.updateField(['tls', 'certificateRef'], 'D:/blocked.crt')
assert.equal(page.editorText.value, input)
assert.equal(await page.saveAndApply(), false)
assert.equal(await page.continuePending(), false)
assert.equal(await page.beforeLeave(), false)
stage.release.resolve()
}
assert.equal(await saving, true)
assert.equal(requestIndex, 7)
assert.equal(page.busy.value, false)
assert.equal(page.saveFlow.value, '')
assert.equal(page.showPendingRecovery.value, false)
assert.deepEqual(successes, ['配置已保存'])
const gaps = transitions.filter(state => state.pending && !state.working && state.flow)
assert.ok(gaps.length > 0, '必须检查暂存与应用之间的同步状态切换')
assert.ok(gaps.every(state => state.busy && !state.recovery))
})
for (const failure of ['save', 'apply', 'apply-auth-cancel', 'readback']) {
test(`一键保存 ${failure} 后解除忙碌,真实失败仍提示且未应用的候选可以恢复`, async () => {
let fail = true
let applied = false
let pending = null
let writes = 0
const config = {
meta: { generation: 1 },
tls: { management: 'custom', mode: 'managed_upload', defaultDirectory: 'config/ssl', customDirectory: 'config/ssl', certificateRef: 'config/ssl/public.crt', privateKeyRef: 'config/ssl/private.key' }
}
const { page, messages, successes } = fixture(() => {}, 'ssl', { http: {
get: async path => {
if (fail && applied && failure === 'readback') throw new Error('模拟回读失败')
return { errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: applied ? 2 : 1 } : { ...config, meta: { generation: applied ? 2 : 1 } } }
},
put: async (path, body) => {
writes++
if (fail && failure === 'save') throw new Error('模拟保存失败')
pending = { generation: 2, candidateId: 'failed-save', section: 'ssl', sameSession: true, payload: body.payload }
return { errorNo: 0, data: pending }
},
post: async () => {
writes++
if (fail && failure === 'apply') throw new Error('模拟应用失败')
if (fail && failure === 'apply-auth-cancel') throw Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' })
applied = true
pending = null
return { errorNo: 0, data: { generation: 2 } }
}
} })
await page.loadAll()
page.updateField(['tls', 'certificateRef'], 'D:/certs/public.crt')
assert.equal(await page.saveAndApply(), false)
assert.equal(page.busy.value, false)
assert.equal(page.saveFlow.value, '')
assert.deepEqual(successes, [])
const recoverable = ['apply', 'apply-auth-cancel'].includes(failure)
assert.equal(page.showPendingRecovery.value, recoverable)
assert.equal(page.continuingSave.value, recoverable)
if (failure === 'apply-auth-cancel') {
assert.deepEqual(messages, [])
assert.equal(page.saveError.value, '')
} else {
assert.ok(messages.length > 0, '真实失败不能被隐藏')
assert.equal(page.saveError.value || page.sectionError.value, messages.at(-1), '错误必须保留在页面中')
}
if (failure === 'save') {
page.updateField(['tls', 'certificateRef'], 'D:/certs/fixed/public.crt')
assert.equal(page.saveError.value, '')
}
if (recoverable) {
fail = false
const writesBefore = writes
const continuing = page.continuePending()
assert.equal(page.saveFlow.value, 'resume')
assert.equal(page.continuingSave.value, true)
assert.equal(page.showPendingRecovery.value, false)
assert.equal(page.saveError.value, '')
assert.equal(await continuing, true)
assert.equal(writes, writesBefore + 1, '只应用已有候选,不重复保存')
assert.equal(page.showPendingRecovery.value, false)
assert.deepEqual(successes, ['配置已保存'])
}
})
}
for (const rejected of [false, true]) {
test(`恢复其他会话候选期间保持按钮稳定,恢复失败保留入口:失败=${rejected}`, async () => {
let active = {
meta: { generation: 1 },
tls: { management: 'custom', mode: 'managed_upload', defaultDirectory: 'config/ssl', customDirectory: 'config/ssl', certificateRef: 'config/ssl/public.crt', privateKeyRef: 'config/ssl/private.key' }
}
let pending = { generation: 2, section: 'ssl', sameSession: false, candidateId: 'another-session', payload: active }
const requests = []
const { page, messages } = fixture(() => {}, 'ssl', { http: {
get: async path => ({ errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: active.meta.generation } : active }),
post: async (path, body) => {
assert.equal(page.saveFlow.value, 'resume')
assert.equal(page.busy.value, true)
assert.equal(page.continuingSave.value, true)
assert.equal(page.showPendingRecovery.value, false)
requests.push(path)
if (path.endsWith('/pending')) {
assert.equal(body.action, 'resume')
if (rejected) throw new Error('模拟候选恢复失败')
pending = { ...pending, sameSession: true }
} else {
assert.equal(body.candidateId, 'another-session')
active = { ...active, meta: { generation: 2 } }
pending = null
}
return { errorNo: 0, data: { generation: 2 } }
}
} })
await page.loadAll()
assert.equal(page.showPendingRecovery.value, true)
assert.equal(await page.continuePending(), !rejected)
assert.equal(page.busy.value, false)
assert.equal(page.showPendingRecovery.value, rejected)
assert.equal(page.saveError.value, rejected ? '模拟候选恢复失败' : '')
assert.deepEqual(requests, rejected ? ['/api/v1/config/pending'] : ['/api/v1/config/pending', '/api/v1/config/ssl/apply'])
assert.deepEqual(messages, rejected ? ['模拟候选恢复失败'] : [])
})
}
test('最近认证过期时在当前页确认密码并自动重试保存和应用', async () => {
const requests = []
let pending = null
let saveAttempts = 0
let prompts = 0
let active = {
meta: { generation: 1 },
listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' },
relay: { relayHost: '', relayPort: 0, security: '', from: '', fromDomain: '' },
imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, policy: {}
}
const recentAuthError = Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' })
const { page, successes, messages } = 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: active.meta.generation } : 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 } }
}
active = { ...pending.payload, meta: { generation: 2 } }
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(successes, ['配置已保存'])
assert.deepEqual(messages, [])
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: 0, security: 'starttls' },
relay: {}, imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, 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="busy \|\| 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
let active = {
meta: { generation: 8 },
listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' },
relay: {
relayHost: 'smtp.example.com', relayPort: 587, security: 'starttls',
from: 'sender@example.com', fromDomain: 'example.com'
},
imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, 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: active.meta.generation } : active }),
post: async (path, body) => {
requests.push({ path, body })
if (path.endsWith('/apply')) {
active = { ...pending.payload, meta: { generation: 9 } }
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)
assert.equal(await page.applyConfig(), true)
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 requests = []
let pending = null
let generation = 8
let active = {
tls: {
management: 'custom', mode: 'managed_upload', defaultDirectory: 'config/ssl', customDirectory: 'config/ssl',
certificateRef: 'D:/certs/public.crt', privateKeyRef: 'D:/certs/private.key'
}
}
const normalized = {
meta: { generation: 9 },
tls: {
...active.tls,
certificateRef: 'config/ssl/managed/upload/version-0123456789abcdef0123456789abcdef/public.crt',
privateKeyRef: 'config/ssl/managed/upload/version-0123456789abcdef0123456789abcdef/private.key'
}
}
const { page, successes, messages } = fixture(async () => ({ errorNo: 0, data: active }), 'ssl', {
http: {
get: async path => ({ errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: generation } : active }),
put: async (path, body) => {
requests.push({ path, body })
pending = { generation: 9, candidateId: 'ssl-candidate', section: 'ssl', sameSession: true, payload: normalized }
return { errorNo: 0, data: pending }
},
post: async (path, body) => {
requests.push({ path, body })
active = normalized
generation = 9
pending = null
return { errorNo: 0, data: { generation: 9 } }
}
}
})
await page.loadAll()
await page.saveAndApply()
assert.deepEqual(requests.map(item => item.path), ['/api/v1/config/ssl', '/api/v1/config/ssl/apply'])
assert.deepEqual(requests[1].body, { generation: 9, candidateId: 'ssl-candidate' })
assert.equal(JSON.parse(page.editorText.value).tls.certificateRef, normalized.tls.certificateRef)
assert.equal(page.savedGeneration.value, 0)
assert.deepEqual(successes, ['配置已保存'])
assert.deepEqual(messages, [])
})
for (const failure of ['section-network', 'section-response', 'status-network', 'pending-network', 'section-stale', 'status-stale']) {
test(`证书应用后的 ${failure} 不得误报完成,刷新后恢复真实配置`, async () => {
const original = {
meta: { generation: 8 },
tls: {
management: 'custom', mode: 'managed_upload', defaultDirectory: 'config/ssl', customDirectory: 'config/ssl',
certificateRef: 'config/ssl/old/public.crt', privateKeyRef: 'config/ssl/old/private.key'
}
}
const normalized = {
meta: { generation: 9 },
tls: {
...original.tls,
certificateRef: 'config/ssl/managed/upload/version-0123456789abcdef0123456789abcdef/public.crt',
privateKeyRef: 'config/ssl/managed/upload/version-0123456789abcdef0123456789abcdef/private.key'
}
}
let applied = false
let fail = true
let pending = null
let writes = 0
const { page, messages, successes } = fixture(() => {}, 'ssl', { http: {
get: async path => {
if (applied && fail) {
if (failure === 'section-network' && path.endsWith('/ssl') ||
failure === 'status-network' && path.endsWith('/status') ||
failure === 'pending-network' && path.endsWith('/pending')) throw new Error('模拟回读失败')
if (failure === 'section-response' && path.endsWith('/ssl')) return { errorNo: 500, errorMsg: '配置分区读取失败' }
}
const statusGeneration = applied && !(fail && failure === 'status-stale') ? 9 : 8
const active = applied && !(fail && failure === 'section-stale') ? normalized : original
return { errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: statusGeneration } : path.endsWith('/pending') ? pending : active }
},
put: async () => {
writes++
pending = { generation: 9, candidateId: 'ssl-readback', section: 'ssl', sameSession: true, payload: normalized }
return { errorNo: 0, data: pending }
},
post: async () => {
writes++
applied = true
pending = null
return { errorNo: 0, data: { generation: 9 } }
}
} })
await page.loadAll()
page.updateField(['tls', 'certificateRef'], 'D:/certs/new/public.crt')
page.updateField(['tls', 'privateKeyRef'], 'D:/certs/new/private.key')
await page.saveAndApply()
assert.equal(applied, true)
assert.deepEqual(successes, [])
assert.match(messages.at(-1), /配置已应用.*刷新/)
assert.ok(page.sectionError.value)
assert.equal(page.pendingCandidate.value, null)
assert.equal(page.savedGeneration.value, 0)
assert.equal(page.editorText.value, '')
await page.saveAndApply()
assert.equal(writes, 2)
fail = false
await page.refreshPage()
assert.equal(page.sectionError.value, '')
assert.equal(page.formConfig.value.tls.certificateRef, normalized.tls.certificateRef)
assert.equal(page.status.activeGeneration, 9)
assert.equal(page.hasChanges.value, false)
})
}
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: 0, security: 'starttls' },
relay: {
relayHost: 'smtp.example.com', relayPort: 587, security: 'starttls',
from: 'sender@example.com', fromDomain: 'example.com'
},
imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, 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: 0, security: 'starttls' },
relay: {
relayHost: 'smtp.example.com', relayPort: 587, security: 'starttls',
from: 'sender@example.com', fromDomain: 'example.com'
},
imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, 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', 'mail_policy', '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, [])
})
}