This commit is contained in:
parent
8f15361557
commit
b3132edfb7
@ -181,12 +181,14 @@ const sslGroups = [
|
||||
field('tls.certificateRef', '证书文件', 'text', {
|
||||
wide: true,
|
||||
show: externalManagedCertificate,
|
||||
placeholder: 'config/ssl/example.com/fullchain.pem'
|
||||
placeholder: 'config/ssl/example.com/fullchain.pem',
|
||||
hint: '填写后端服务器上的 PEM 证书路径。自定义模式支持绝对路径或相对运行根目录的路径,保存后显示导入后的受管路径;Web 服务器管理模式填写已有受管证书的相对路径。'
|
||||
}),
|
||||
field('tls.privateKeyRef', '私钥文件', 'text', {
|
||||
wide: true,
|
||||
show: externalManagedCertificate,
|
||||
placeholder: 'config/ssl/example.com/privkey.pem'
|
||||
placeholder: 'config/ssl/example.com/privkey.pem',
|
||||
hint: '填写与证书匹配的 PEM 私钥文件路径,不是私钥内容。文件必须可被后端运行用户读取;自定义模式保存后显示受管副本的路径。'
|
||||
})
|
||||
])
|
||||
]
|
||||
|
||||
@ -275,7 +275,7 @@ export function usePage(pageKey = 'base') {
|
||||
}
|
||||
}
|
||||
|
||||
const loadSection = async () => {
|
||||
const loadSection = async ({ minimumGeneration = 0 } = {}) => {
|
||||
const requestId = ++sectionRequestId
|
||||
const section = apiSection()
|
||||
sectionLoading.value = true
|
||||
@ -290,19 +290,25 @@ export function usePage(pageKey = 'base') {
|
||||
savedInput = null
|
||||
try {
|
||||
const response = await http.get(`/api/v1/config/${section}`)
|
||||
if (requestId !== sectionRequestId) return
|
||||
if (requestId !== sectionRequestId || disposed) return false
|
||||
notifyResponseError(response, '配置分区读取失败')
|
||||
const publicConfig = response.data || {}
|
||||
const generation = Number(section === 'base' ? publicConfig.generation : publicConfig.meta?.generation)
|
||||
if (minimumGeneration && (!Number.isSafeInteger(generation) || generation < minimumGeneration)) {
|
||||
throw new Error('读取到的配置版本落后于本次保存,请刷新状态核对')
|
||||
}
|
||||
editorText.value = JSON.stringify(publicConfig, null, 2)
|
||||
loadedText.value = editorText.value
|
||||
if (section === 'base') {
|
||||
const database = publicConfig.database || {}
|
||||
Object.assign(databaseConfig, { type: database.type || '', name: database.name || '', localRef: database.localRef || '' })
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
if (requestId !== sectionRequestId) return
|
||||
if (requestId !== sectionRequestId || disposed) return false
|
||||
sectionError.value = error.message || '配置分区读取失败'
|
||||
editorText.value = ''
|
||||
return false
|
||||
} finally {
|
||||
if (requestId === sectionRequestId) sectionLoading.value = false
|
||||
}
|
||||
@ -597,6 +603,8 @@ export function usePage(pageKey = 'base') {
|
||||
return false
|
||||
}
|
||||
working.value = 'apply'
|
||||
const generation = savedGeneration.value
|
||||
let applied = false
|
||||
try {
|
||||
const response = await requestWithRecentAuthentication(() => http.post(
|
||||
`/api/v1/config/${apiSection()}/apply`,
|
||||
@ -606,13 +614,30 @@ export function usePage(pageKey = 'base') {
|
||||
}
|
||||
))
|
||||
notifyResponseError(response, '配置保存失败')
|
||||
savedGeneration.value = 0
|
||||
await Promise.all([loadStatus(), loadSection()])
|
||||
applied = true
|
||||
// 服务端已提交的候选不能再次应用;回读失败时只允许刷新,不误报保存失败或展示旧配置。
|
||||
pendingCandidate.value = null
|
||||
invalidateCandidate()
|
||||
await loadStatus()
|
||||
if (disposed) return false
|
||||
if (!Number.isSafeInteger(Number(status.activeGeneration)) || Number(status.activeGeneration) < generation) {
|
||||
throw new Error('配置状态尚未更新')
|
||||
}
|
||||
if (!await loadSection({ minimumGeneration: generation })) throw new Error('最新配置读取失败')
|
||||
await loadPending()
|
||||
if (disposed) return false
|
||||
ElMessage.success(successMessage)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (disposed) return false
|
||||
if (isRecentAuthenticationCancelled(error)) return false
|
||||
if (applied) {
|
||||
sectionError.value = '配置已应用,但最新配置读取失败,请刷新状态核对'
|
||||
editorText.value = ''
|
||||
loadedText.value = ''
|
||||
ElMessage.warning(sectionError.value)
|
||||
return false
|
||||
}
|
||||
ElMessage.error(error.message || '配置保存失败')
|
||||
return false
|
||||
} finally {
|
||||
|
||||
@ -37,9 +37,10 @@ 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: () => {} },
|
||||
{ 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' })
|
||||
@ -48,7 +49,7 @@ function fixture(get, pageKey = 'base', options = {}) {
|
||||
...options.http
|
||||
},
|
||||
() => ({ hasCapability: options.hasCapability || (() => true) }), pageKey, callback => { dispose = callback })
|
||||
return { page, messages, dispose: () => dispose() }
|
||||
return { page, messages, successes, dispose: () => dispose() }
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
@ -454,21 +455,22 @@ test('配置编辑页将验证、候选保存和应用精简为单一保存操
|
||||
test('保存并应用一次点击按候选协议连续完成保存和应用', async () => {
|
||||
const requests = []
|
||||
let pending = null
|
||||
const active = {
|
||||
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 } = fixture(async path => ({
|
||||
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: 4 } : active
|
||||
data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: active.meta.generation } : active
|
||||
}),
|
||||
put: async (path, body) => {
|
||||
requests.push(path)
|
||||
@ -479,17 +481,21 @@ test('保存并应用一次点击按候选协议连续完成保存和应用', as
|
||||
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(['policy', 'maxMessageBytes'], 12 << 20)
|
||||
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('最近认证过期时在当前页确认密码并自动重试保存和应用', async () => {
|
||||
@ -497,18 +503,19 @@ test('最近认证过期时在当前页确认密码并自动重试保存和应
|
||||
let pending = null
|
||||
let saveAttempts = 0
|
||||
let prompts = 0
|
||||
const active = {
|
||||
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 } = fixture(async () => ({ errorNo: 0, data: active }), 'mail', {
|
||||
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: 1 } : active
|
||||
data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: active.meta.generation } : active
|
||||
}),
|
||||
put: async (path, body) => {
|
||||
requests.push(path)
|
||||
@ -523,6 +530,7 @@ test('最近认证过期时在当前页确认密码并自动重试保存和应
|
||||
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 } }
|
||||
}
|
||||
@ -532,6 +540,8 @@ test('最近认证过期时在当前页确认密码并自动重试保存和应
|
||||
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',
|
||||
@ -657,7 +667,8 @@ test('只读账号不得发送配置写入请求或读取无权限维护数据',
|
||||
test('邮件页保持验证、保存、应用端点和代次协议,中继密码只作为 secretChanges 发送', async () => {
|
||||
const requests = []
|
||||
let pending = null
|
||||
const active = {
|
||||
let active = {
|
||||
meta: { generation: 8 },
|
||||
listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' },
|
||||
relay: {
|
||||
relayHost: 'smtp.example.com', relayPort: 587, security: 'starttls',
|
||||
@ -667,8 +678,15 @@ test('邮件页保持验证、保存、应用端点和代次协议,中继密
|
||||
}
|
||||
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 } } },
|
||||
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 }
|
||||
@ -682,7 +700,7 @@ test('邮件页保持验证、保存、应用端点和代次协议,中继密
|
||||
await page.validateConfig()
|
||||
await page.saveConfig()
|
||||
assert.equal(page.savedGeneration.value, 9)
|
||||
await page.applyConfig()
|
||||
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)
|
||||
@ -698,6 +716,7 @@ test('邮件页保持验证、保存、应用端点和代次协议,中继密
|
||||
test('自定义证书导入后使用规范化的候选引用继续应用,不误判表单已变化', async () => {
|
||||
const requests = []
|
||||
let pending = null
|
||||
let generation = 8
|
||||
let active = {
|
||||
tls: {
|
||||
management: 'custom', mode: 'managed_upload', defaultDirectory: 'config/ssl', customDirectory: 'config/ssl',
|
||||
@ -705,15 +724,16 @@ test('自定义证书导入后使用规范化的候选引用继续应用,不
|
||||
}
|
||||
}
|
||||
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 } = fixture(async () => ({ errorNo: 0, data: active }), 'ssl', {
|
||||
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: 8 } : active }),
|
||||
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 }
|
||||
@ -722,6 +742,7 @@ test('自定义证书导入后使用规范化的候选引用继续应用,不
|
||||
post: async (path, body) => {
|
||||
requests.push({ path, body })
|
||||
active = normalized
|
||||
generation = 9
|
||||
pending = null
|
||||
return { errorNo: 0, data: { generation: 9 } }
|
||||
}
|
||||
@ -733,8 +754,77 @@ test('自定义证书导入后使用规范化的候选引用继续应用,不
|
||||
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 },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user