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 }, () => ({ role: options.role || 'super_admin', hasCapability: options.hasCapability || (() => true) }), pageKey, callback => { dispose = callback }) return { page, messages, successes, dispose: () => dispose() } } for (const pageKey of ['telegram_push', 'wechat_push']) { for (const role of ['super_admin', 'admin', 'user']) { test(`${role} 的 ${pageKey} 系统配置写权限只对超级管理员开放`, () => { const { page } = fixture(async () => ({ errorNo: 0, data: {} }), pageKey, { role }) assert.equal(page.canWrite.value, role === 'super_admin') }) } } function deferred() { let resolve let reject const promise = new Promise((success, failure) => { resolve = success; reject = failure }) return { promise, resolve, reject } } test('DNS 凭据只通过专用密钥变更提交,留空保持原值', async () => { const sent = [] const current = { tls: { management: 'project', mode: 'managed_default', defaultDirectory: 'ssl', challenge: 'dns', dnsProvider: 'alidns', dnsAccessKey: {}, dnsSecretKey: {} }, dnsAccessKeyStatus: { configured: true }, dnsSecretKeyStatus: { configured: true } } const { page } = fixture(async path => ({ errorNo: 0, data: path.endsWith('/status') ? { available: true, activeGeneration: 1 } : current }), 'ssl', { http: { post: async (path, body) => { sent.push(body); return { errorNo: 0, data: { valid: true } } } } }) await page.loadAll() page.updateField(['tls', 'dnsSecretKeyInput'], 'new-test-secret') await page.validateConfig() assert.equal(sent.length, 1) assert.deepEqual(sent[0].secretChanges, { 'tls.dnsSecretKey': { operation: 'replace', value: { plaintext: 'new-test-secret' } } }) assert.equal(sent[0].payload.dnsAccessKeyStatus, undefined) assert.equal(sent[0].payload.dnsSecretKeyStatus, undefined) assert.equal(sent[0].payload.tls.dnsSecretKeyInput, undefined) assert.equal(sent[0].payload.tls.dnsSecretKey, undefined) page.updateField(['tls', 'dnsSecretKeyInput'], '') await page.validateConfig() assert.deepEqual(sent[1].secretChanges, {}) }) 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, /[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, /