修复全量测试竞态并更新审查夹具,保存证书覆盖调整前基线
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-20 01:25:34 +08:00
parent 35b4882cfb
commit c82be24178
15 changed files with 146 additions and 158 deletions

View File

@ -0,0 +1,18 @@
// Package passwordcapacity 管理所有密码计算入口共享的并发名额。
package passwordcapacity
import "errors"
var ErrBusy = errors.New("password computation is busy")
var computationSlot = make(chan struct{}, 1)
// Acquire 满载时立即拒绝,不积压等待请求;调用方必须延迟释放名额。
func Acquire() (func(), error) {
select {
case computationSlot <- struct{}{}:
return func() { <-computationSlot }, nil
default:
return nil, ErrBusy
}
}

View File

@ -2,62 +2,23 @@
package passwordtest
import (
"crypto/rand"
"io"
"sync"
"sync/atomic"
"testing"
"time"
"git.pcux.cn/xuans/Smail/utils/password"
"git.pcux.cn/xuans/Smail/internal/passwordcapacity"
)
type heldReader struct {
original io.Reader
entered chan struct{}
resume chan struct{}
enteredOnce atomic.Bool
}
func (r *heldReader) Read(p []byte) (int, error) {
if r.enteredOnce.CompareAndSwap(false, true) {
close(r.entered)
<-r.resume
panic(r)
}
return r.original.Read(p)
}
// Hold 仅供非并行测试使用,释放时同时验证异常路径没有泄漏计算名额。
// Hold 仅供非并行测试使用,直接占用计算名额,不修改 TLS 等服务使用的随机源。
func Hold(t *testing.T) func() {
t.Helper()
r := &heldReader{original: rand.Reader, entered: make(chan struct{}), resume: make(chan struct{})}
rand.Reader = r
done := make(chan any, 1)
go func() {
defer func() { done <- recover() }()
_, _ = password.Hash("HeldPass123")
}()
unlock, err := passwordcapacity.Acquire()
if err != nil {
t.Fatalf("密码计算名额未被测试请求占用:%v", err)
}
var once sync.Once
release := func() {
once.Do(func() {
close(r.resume)
defer func() { rand.Reader = r.original }()
select {
case result := <-done:
if result != r {
t.Errorf("密码测试占位未经过预期的异常释放路径")
}
case <-time.After(5 * time.Second):
t.Error("密码测试占位释放超时")
}
})
once.Do(unlock)
}
t.Cleanup(release)
select {
case <-r.entered:
case <-time.After(5 * time.Second):
t.Fatal("密码计算名额未被测试请求占用")
}
return release
}

View File

@ -1,17 +1,9 @@
package password
import "errors"
import "git.pcux.cn/xuans/Smail/internal/passwordcapacity"
var ErrBusy = errors.New("password computation is busy")
// 所有协议和恢复流程共享一个计算名额;满载立即拒绝,不积压等待请求。
var computationSlot = make(chan struct{}, 1)
var ErrBusy = passwordcapacity.ErrBusy
func acquireComputation() (func(), error) {
select {
case computationSlot <- struct{}{}:
return func() { <-computationSlot }, nil
default:
return nil, ErrBusy
}
return passwordcapacity.Acquire()
}

View File

@ -11,7 +11,7 @@ import (
"git.pcux.cn/xuans/Smail/utils/password"
)
func TestCapacityRejectsBurstAndReleasesAfterPanic(t *testing.T) {
func TestCapacityRejectsBurstAndAllowsRetry(t *testing.T) {
encoded, err := password.Hash("StrongPass12")
if err != nil {
t.Fatal(err)
@ -39,7 +39,7 @@ func TestCapacityRejectsBurstAndReleasesAfterPanic(t *testing.T) {
wg.Wait()
release()
if matched, err := password.Check(encoded, "StrongPass12"); !matched || err != nil {
t.Fatalf("异常释放后不能继续认证:%v %v", matched, err)
t.Fatalf("名额释放后不能继续认证:%v %v", matched, err)
}
if matched, err := password.Check(encoded, "WrongPass12"); matched || err != nil {
t.Fatalf("密码错误与服务繁忙未区分:%v %v", matched, err)

View File

@ -39,6 +39,10 @@ func ValidateUserPassword(plain string) error {
// Hash 生成 Argon2id 密码哈希。
func Hash(plain string) (string, error) {
return hashWithRandom(plain, rand.Read)
}
func hashWithRandom(plain string, readRandom func([]byte) (int, error)) (string, error) {
plainSize := len([]byte(plain))
if plainSize < minSecretSize {
return "", ErrInvalidLength
@ -49,7 +53,7 @@ func Hash(plain string) (string, error) {
}
defer release()
salt := make([]byte, saltLength)
if _, err := rand.Read(salt); err != nil {
if _, err := readRandom(salt); err != nil {
return "", fmt.Errorf("generate password salt: %w", err)
}
hash := argon2.IDKey([]byte(plain), salt, iterations, memory, parallelism, keyLength)

View File

@ -0,0 +1,36 @@
package password
import (
"errors"
"testing"
)
func TestHashRandomFailureReleasesCapacity(t *testing.T) {
expected := errors.New("random source unavailable")
_, err := hashWithRandom("StrongPass12", func([]byte) (int, error) {
return 0, expected
})
if !errors.Is(err, expected) {
t.Fatalf("随机源异常未返回:%v", err)
}
if _, err := Hash("StrongPass12"); err != nil {
t.Fatalf("随机源异常后计算名额未释放:%v", err)
}
}
func TestHashRandomPanicReleasesCapacity(t *testing.T) {
expected := errors.New("random source panic")
func() {
defer func() {
if recovered := recover(); recovered != expected {
t.Errorf("未保留预期的随机源异常:%v", recovered)
}
}()
_, _ = hashWithRandom("StrongPass12", func([]byte) (int, error) {
panic(expected)
})
}()
if _, err := Hash("StrongPass12"); err != nil {
t.Fatalf("随机源异常退出后计算名额未释放:%v", err)
}
}

View File

@ -335,7 +335,6 @@
</template>
<script setup>
import { computed } from 'vue'
import { usePage } from './ThirdPartyAuthorization.js'
const {
@ -367,9 +366,6 @@ const {
submitVerifyAndReveal,
} = usePage()
const hasClients = computed(() =>
Array.isArray(clientList.value) && clientList.value.length > 0
)
</script>
<style scoped src="./ThirdPartyAuthorization.css"></style>
<style scoped src="./ThirdPartyAuthorization.css"></style>

View File

@ -96,7 +96,7 @@ export function useRecoveryCodeDialog(props, emit) {
revoking.value = true
errorMessage.value = ''
try {
const response = await requestWithRecentAuthentication(async () => {
await requestWithRecentAuthentication(async () => {
const result = await http.post('/api/v1/user/security/recovery-codes/revoke', {
user_id: props.user.ID,
})

View File

@ -190,7 +190,7 @@ export function useUserExternalEmailDialog(props, emit) {
verifying.value = true
clearMessages()
try {
const response = await requestWithRecentAuthentication(async () => {
await requestWithRecentAuthentication(async () => {
const payload = {
binding_id: challenge.bindingId,
email: form.email,

View File

@ -10,6 +10,7 @@ import (
"testing"
"time"
"git.pcux.cn/xuans/Smail/utils/runtimepath"
_ "modernc.org/sqlite"
)
@ -319,12 +320,12 @@ func TestAuditPendingConfigBlocksFurtherUpdates(t *testing.T) {
}
}
func TestAuditLegacyMigrationMustPreserveSQLiteLocation(t *testing.T) {
func TestAuditLegacyMigrationMustPreserveSQLiteData(t *testing.T) {
root := t.TempDir()
oldRoot := ROOT_PATH
ROOT_PATH = root
t.Cleanup(func() { ROOT_PATH = oldRoot })
sourcePath := filepath.Join(root, "config", "archive", "review.db")
sourcePath := filepath.Join(root, "config", "review.db")
if err := os.MkdirAll(filepath.Dir(sourcePath), 0700); err != nil {
t.Fatal(err)
}
@ -336,9 +337,12 @@ func TestAuditLegacyMigrationMustPreserveSQLiteLocation(t *testing.T) {
if _, err := database.Exec("CREATE TABLE audit_marker (value TEXT); INSERT INTO audit_marker VALUES ('original')"); err != nil {
t.Fatal(err)
}
if err := database.Close(); err != nil {
t.Fatal(err)
}
writeLegacyMigrationFixture(t, root, map[string]any{
"logLevel": "info", "domain": "mail.example.test", "webDomain": "mail.example.test",
"dbType": "sqlite", "dbDSN": "./config/archive/review.db", "isInit": true,
"dbType": "sqlite", "dbDSN": "./config/review.db", "isInit": true,
"sslType": "1", "SSLPublicKeyPath": "./config/ssl/public.crt", "SSLPrivateKeyPath": "./config/ssl/private.key",
"httpPort": 80, "httpsPort": 443, "httpsEnabled": 1,
})
@ -346,6 +350,12 @@ func TestAuditLegacyMigrationMustPreserveSQLiteLocation(t *testing.T) {
if err != nil || before.DbDSN != sourcePath {
t.Fatalf("原配置读取路径前提不成立: %v", err)
}
if err := runtimepath.CheckReady(root); !errors.Is(err, runtimepath.ErrMigrationRequired) {
t.Fatalf("未迁移旧账号库时必须拒绝启动: %v", err)
}
if _, err := runtimepath.Migrate(root); err != nil {
t.Fatal(err)
}
if _, err := MigrateLegacyConfig(root, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); err != nil {
t.Fatal(err)
}
@ -364,7 +374,7 @@ func TestAuditLegacyMigrationMustPreserveSQLiteLocation(t *testing.T) {
queryErr := target.QueryRow("SELECT value FROM audit_marker").Scan(&marker)
t.Logf("source_exists=%t target_missing_before_open=%t same_path=%t marker=%q query_error=%v",
sourceErr == nil, os.IsNotExist(targetErr), before.DbDSN == after.DbDSN, marker, queryErr)
if before.DbDSN != after.DbDSN || queryErr != nil || marker != "original" {
t.Fatal("仅迁移配置不应把已有 SQLite 指向一个新建的空库")
if after.DbDSN != filepath.Join(root, "data", "review.db") || queryErr != nil || marker != "original" {
t.Fatal("运行数据与配置迁移必须保留已有 SQLite 记录")
}
}

View File

@ -5,7 +5,6 @@ import (
"net/http"
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/signal"
@ -23,16 +22,15 @@ func (transport *auditHTTPTransport) RoundTrip(request *http.Request) (*http.Res
// 代理检查和就绪探测只使用隔离配置,传输层替身阻止访问真实服务。
func TestAuditProxyMaintenanceSkipsNetwork(t *testing.T) {
previousRoot, previousSnapshot := config.ROOT_PATH, config.CurrentConfig()
previousTransport, previousExpiry, previousRestart := http.DefaultTransport, expiredTime, signal.RestartChan
previousTransport, previousRestart := http.DefaultTransport, signal.RestartChan
config.ROOT_PATH = t.TempDir()
transport := &auditHTTPTransport{}
http.DefaultTransport = transport
signal.RestartChan = make(chan bool, 1)
expiredTime = time.Time{}
t.Cleanup(func() {
config.ROOT_PATH = previousRoot
config.PublishConfigSnapshot(previousSnapshot)
http.DefaultTransport, expiredTime, signal.RestartChan = previousTransport, previousExpiry, previousRestart
http.DefaultTransport, signal.RestartChan = previousTransport, previousRestart
})
for _, sslType := range []string{config.SSLTypeAutoHTTP, config.SSLTypeAutoDNS, config.SSLTypeUser} {
config.PublishConfigSnapshot(&config.Config{TLSMode: config.HTTPSTLSModeProxy, SSLType: sslType, SetupState: config.SetupStateReady})
@ -45,7 +43,7 @@ func TestAuditProxyMaintenanceSkipsNetwork(t *testing.T) {
t.Fatal("反代模式触发了网络请求或无证书重启")
}
// 切回直接 HTTPS 后,每轮读取最新策略,而非沿用启动时的分支。
config.PublishConfigSnapshot(&config.Config{TLSMode: config.HTTPSTLSModeDirect, SSLType: config.SSLTypeAutoHTTP, SetupState: config.SetupStateReady})
config.PublishConfigSnapshot(&config.Config{TLSMode: config.HTTPSTLSModeDirect, SSLType: config.SSLTypeAutoHTTP, SetupState: config.SetupStateReady, HttpPort: 8001})
if !waitHTTPReady() || transport.calls != 1 {
t.Fatal("直接 HTTPS 的 HTTP 就绪探测未恢复")
}

View File

@ -3,7 +3,8 @@ import test from 'node:test'
import { readFileSync } from 'node:fs'
import { compileFunction } from 'node:vm'
import { createRequire } from 'node:module'
import { readFormConfig, updateFormConfig, validateFormConfig } from '../../frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js'
import { readFormConfig, updateFormConfig, validateFormConfig, relayConfigured, relaySecurityForMail } from '../../frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js'
import { decodeBlobAPIError } from '../../frontend/src/utils/apiResponse.mjs'
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
const Vue = require('vue')
@ -13,7 +14,7 @@ const read = name => readFileSync(new URL(`../../frontend/${name}`, import.meta.
test('邮件列表挂载后,表格选中项必须用于全选和批量操作', async () => {
const source = read('src/views/List/List.js').replace(/^import .*$/gm, '')
.replace('export function usePage', 'function usePage').replace(/export \{[^}]+\}\s*$/, '')
const icons = ['CircleCheck', 'Switch', 'Delete', 'View', 'Folder', 'EpArrowDownBold', 'EditPen', 'Warning']
const icons = ['CircleCheck', 'Switch', 'Delete', 'View', 'Folder', 'EpArrowDownBold', 'EditPen', 'Warning', 'Check', 'Refresh']
const messages = []
const requests = []
const rows = [{ id: 1 }, { id: 2 }]
@ -25,10 +26,12 @@ test('邮件列表挂载后,表格选中项必须用于全选和批量操作',
get: async () => ({ errorNo: 0, data: [] })
}
const usePage = compileFunction(source + '\nreturn usePage', [
'Vue', 'ref', 'watch', 'useRouter', 'useGroupStore', 'lang', 'http', 'ElMessage', 'ElMessageBox', ...icons
'Vue', 'ref', 'watch', 'useRouter', 'useGroupStore', 'lang', 'http', 'ElMessage', 'ElMessageBox',
'computed', 'onMounted', 'onUnmounted', 'useGlobalStatusStore', ...icons
])(
Vue, Vue.ref, Vue.watch, () => ({ push() {} }), () => Vue.reactive({ tag: '', name: '收件箱' }), {}, transport,
{ warning: value => messages.push(value), success() {}, error() {} }, { confirm: async () => true },
Vue.computed, Vue.onMounted, Vue.onUnmounted, () => Vue.reactive({ isLogin: true }),
...icons.map(() => ({}))
)
let page
@ -150,12 +153,13 @@ for (const action of ['edit', 'refresh']) {
const page = compileFunction(source + '\nreturn usePage("mail")', [
'computed', 'onMounted', 'onBeforeUnmount', 'reactive', 'ref', 'ElMessage', 'ElMessageBox',
'http', 'useGlobalStatusStore', 'isRecentAuthenticationCancelled',
'requestWithRecentAuthentication', 'readFormConfig', 'updateFormConfig', 'validateFormConfig'
'requestWithRecentAuthentication', 'readFormConfig', 'updateFormConfig', 'validateFormConfig',
'relayConfigured', 'relaySecurityForMail'
])(
Vue.computed, () => {}, () => {}, Vue.reactive, Vue.ref,
{ success() {}, warning() {}, error: message => errors.push(message) }, { confirm: async () => true },
transport, () => ({ hasCapability: () => true }), () => false, request => request(),
readFormConfig, updateFormConfig, validateFormConfig
readFormConfig, updateFormConfig, validateFormConfig, relayConfigured, relaySecurityForMail
)
await page.loadAll()
page.updateField(['policy', 'maxMessageBytes'], 8 << 20)
@ -239,86 +243,39 @@ test('近期认证要求由 Axios 异常返回时必须确认密码并重试原
assert.equal(result.data.challenge_id, 'retry-success')
})
// 使用真实响应拦截器和页面函数;仅隔离网络、消息提示以及下载所需的浏览器对象。
test('审计导出必须保存服务端返回的 Blob 内容', async () => {
// 审计导出页面已经移除,保留共享下载响应处理的独立回归。
test('下载响应拦截器必须保留真实 Blob 内容', async () => {
let interceptor
const transport = { interceptors: { response: { use: success => { interceptor = success } } } }
const axiosSource = read('src/utils/axios.js')
.replace(/^import .*$/gm, '')
.replace('import.meta.env.VITE_APP_URL', 'undefined')
.replace('export {http};', '')
compileFunction(axiosSource, ['axios', 'lang', 'router', 'createLoginRedirectHandler'])(
{ create: () => transport }, { lang: 'zhCn' }, {}, () => () => {}
compileFunction(axiosSource, ['axios', 'lang', 'router', 'createLoginRedirectHandler', 'decodeBlobAPIError'])(
{ create: () => transport }, { lang: 'zhCn' }, {}, () => () => {}, decodeBlobAPIError
)
assert.equal(typeof interceptor, 'function')
const source = read('src/views/components/ConfigSettings/ConfigSettings.js')
.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage')
const results = []
for (const format of ['csv', 'json']) {
const payload = format === 'csv' ? 'id,operation\n1,update\n' : '[{"id":1,"operation":"update"}]'
let downloaded
let clicked = false
const errors = []
const page = compileFunction(source + '\nreturn usePage("audit")', [
'computed', 'onMounted', 'onBeforeUnmount', 'reactive', 'ref', 'ElMessage', 'ElMessageBox',
'http', 'useGlobalStatusStore', 'readFormConfig', 'updateFormConfig', 'validateFormConfig', 'URL', 'document'
])(
Vue.computed, () => {}, () => {}, Vue.reactive, Vue.ref,
{ success() {}, error: message => errors.push(message) }, {},
{ post: async (url, body, config) => {
assert.equal(url, '/api/v1/audits/config')
assert.equal(config.responseType, 'blob')
assert.equal(body.format, format)
return interceptor({ data: new Blob([payload]), config: { url } })
} },
() => ({ hasCapability: () => true }), () => ({}), () => '', () => ({}),
{ createObjectURL: blob => { downloaded = blob; return 'blob:audit' }, revokeObjectURL() {} },
{ createElement: () => ({ click: () => { clicked = true } }) }
)
await page.exportAudit(format)
assert.deepEqual(errors, [])
assert.equal(clicked, true)
assert.ok(downloaded instanceof Blob)
const actual = await downloaded.text()
console.log(`format=${format} downloaded=${JSON.stringify(actual)}`)
results.push({ format, actual, payload })
const original = new Blob([payload], { type: format === 'csv' ? 'text/csv' : 'application/json' })
const downloaded = await interceptor({ data: original, config: { url: '/api/v1/download' } })
assert.equal(downloaded, original)
assert.equal(await downloaded.text(), payload)
}
for (const { actual, payload } of results) assert.equal(actual, payload, '下载文件必须保留真实审计记录,而不是 undefined')
})
for (const format of ['csv', 'json']) {
test(`审计 ${format} 导出的错误 Blob 不应下载,失败后允许重试`, async () => {
const source = read('src/views/components/ConfigSettings/ConfigSettings.js')
.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage')
const errors = []
const downloads = []
let clicked = 0
let requests = 0
test(`${format} 下载的错误 Blob 必须解码,后续正常响应保持原样`, async () => {
const payload = format === 'csv' ? 'id,operation\n1,update\n' : '[{"id":1}]'
const page = compileFunction(source + '\nreturn usePage("audit")', [
'computed', 'onMounted', 'onBeforeUnmount', 'reactive', 'ref', 'ElMessage', 'ElMessageBox',
'http', 'useGlobalStatusStore', 'readFormConfig', 'updateFormConfig', 'validateFormConfig', 'URL', 'document'
])(
Vue.computed, () => {}, () => {}, Vue.reactive, Vue.ref,
{ success() {}, error: message => errors.push(message) }, {},
{ post: async () => ++requests === 1
? new Blob([JSON.stringify({ errorNo: 403, errorMsg: '无权导出审计记录' })], { type: 'application/json' })
: new Blob([payload], { type: format === 'csv' ? 'text/csv' : 'application/json' }) },
() => ({ hasCapability: () => true }), () => ({}), () => '', () => ({}),
{ createObjectURL: blob => { downloads.push(blob); return 'blob:audit' }, revokeObjectURL() {} },
{ createElement: () => ({ click: () => { clicked++ } }) }
)
await page.exportAudit(format)
assert.deepEqual(errors, ['无权导出审计记录'])
assert.equal(downloads.length, 0)
assert.equal(clicked, 0)
assert.equal(page.working.value, '')
await page.exportAudit(format)
assert.equal(requests, 2)
assert.equal(downloads.length, 1)
assert.equal(clicked, 1)
assert.equal(await downloads[0].text(), payload)
assert.equal(page.working.value, '')
const error = { errorNo: 403, errorMsg: '无权下载' }
const failure = { data: new Blob([JSON.stringify(error)], { type: 'application/json' }) }
await decodeBlobAPIError(failure)
assert.deepEqual(failure.data, error)
const original = new Blob([payload], { type: format === 'csv' ? 'text/csv' : 'application/json' })
const success = { data: original }
await decodeBlobAPIError(success)
assert.equal(success.data, original)
assert.equal(await success.data.text(), payload)
})
}
@ -328,11 +285,11 @@ test('写信页挂载后,附件按钮必须连接到文件输入框', async ()
.replace('export function usePage', 'function usePage').replace(/export \{[^}]+\}\s*$/, '')
const names = ['onBeforeUnmount', 'reactive', 'ref', 'shallowRef', 'ElMessage', 'Close', 'Paperclip',
'Position', 'ArrowDown', 'Document', 'lang', 'Editor', 'Toolbar', 'i18nChangeLanguage', 'useRouter',
'http', 'useGroupStore', 'useGlobalStatusStore', 'MouseEvent']
'http', 'useGroupStore', 'useGlobalStatusStore', 'MouseEvent', 'Delete']
const values = [Vue.onBeforeUnmount, Vue.reactive, Vue.ref, Vue.shallowRef, {}, {}, {}, {}, {}, {},
{ lang: 'zhCn' }, {}, {}, () => {}, () => ({}), {}, () => ({}),
{ lang: 'zhCn' }, {}, {}, () => {}, () => ({}), { get: async () => ({ errorNo: 0, data: [] }) }, () => ({}),
() => ({ userInfos: { account: 'audit', name: 'audit', domains: ['example.test'], is_admin: false } }),
class { constructor(type) { this.type = type } }]
class { constructor(type) { this.type = type } }, {}]
const usePage = compileFunction(source + '\nreturn usePage', names)(...values)
let page
const { descriptor, errors } = parse(read('src/views/Editer/Editer.vue'))

View File

@ -27,8 +27,10 @@ test('规则编辑取消后,新建按钮必须创建独立规则且不能污
}
const lang = { cancel: '取消', submit: '提交', new_rule: '新建规则' }
const usePage = compileFunction(source + '\nreturn usePage', [
'reactive', 'ref', 'lang', 'http', 'ElNotification', 'Delete', 'Edit', 'InfoFilled', 'Plus'
])(Vue.reactive, Vue.ref, lang, transport, () => {}, {}, {}, {}, {})
'reactive', 'ref', 'lang', 'http', 'ElNotification', 'Delete', 'Edit', 'InfoFilled', 'Plus',
'computed', 'useGlobalStatusStore'
])(Vue.reactive, Vue.ref, lang, transport, () => {}, {}, {}, {}, {},
Vue.computed, () => ({ hasCapability: () => true }))
const { descriptor, errors } = parse(read('vue'))
assert.deepEqual(errors, [])
const compiled = compileScript(descriptor, { id: 'audit-rules', inlineTemplate: true })
@ -36,8 +38,11 @@ test('规则编辑取消后,新建按钮必须创建独立规则且不能污
.replace(/import \{([^}]+)\} from ["']vue["']/g, (_, fields) => `const {${fields.replace(/\bas\b/g, ':')}} = Vue`)
.replace(/^import .*$/gm, '').replace('export default', 'return')
let page
const component = compileFunction(script, ['Vue', 'usePage', 'Plus'])(
Vue, () => { page = usePage(); return page }, {}
const paginationSource = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigPagination/ConfigPagination.js', import.meta.url), 'utf8')
const usePagination = compileFunction(paginationSource.replace(/^import .*$/gm, '').replace('export function', 'function')
+ '\nreturn usePagination', ['computed', 'ref', 'watch'])(Vue.computed, Vue.ref, Vue.watch)
const component = compileFunction(script, ['Vue', 'usePage', 'Plus', 'usePagination', 'ConfigPagination', 'MailPolicySettings'])(
Vue, () => { page = usePage(); return page }, {}, usePagination, { render: () => null }, { render: () => null }
)
const node = tag => ({ tag, children: [] })
const renderer = Vue.createRenderer({

View File

@ -58,6 +58,7 @@ const packages = frameworkOnly ? ['./hooks/framework'] : asyncOnly ? ['./utils/a
'./dto/parsemail', './listen/smtp_server'
] : [
'./res_init',
'./utils/async', './utils/address',
'./listen/cron_server',
'./hooks/wechat_push',
'./services/dbmigration', './listen/http_server', './services/list', './services/recovery',

View File

@ -131,8 +131,15 @@ func auditMigratedSSL(t *testing.T, mode string) (*config.Config, []byte) {
t.Cleanup(func() { config.SetRuntimeManager(oldManager) })
auditSSLRoot(t)
resource := auditCertificateChain(t, "imap.example.test", "smtp.imap.example.test", "pop.imap.example.test", "imap.imap.example.test")
reference, err := config.ImportManagedSSL(config.ManagedSSLModeDefault, resource.PrivateKey, resource.Certificate, nil, nil)
if err != nil {
// 旧配置的证书位于 data/ssl;新导入接口已改用平铺文件,不能混合作为旧版夹具。
certificateDirectory := filepath.Join(config.ROOT_PATH, "data", "ssl", "managed", "default", "current")
if err := os.MkdirAll(certificateDirectory, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(certificateDirectory, "public.crt"), resource.Certificate, 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(certificateDirectory, "private.key"), resource.PrivateKey, 0600); err != nil {
t.Fatal(err)
}
legacy := map[string]any{
@ -143,8 +150,8 @@ func auditMigratedSSL(t *testing.T, mode string) (*config.Config, []byte) {
"dbDSN": "./config/review.db",
"isInit": true,
"sslType": mode,
"SSLPublicKeyPath": reference.CertificateRef,
"SSLPrivateKeyPath": reference.PrivateKeyRef,
"SSLPublicKeyPath": "data/ssl/managed/default/current/public.crt",
"SSLPrivateKeyPath": "data/ssl/managed/default/current/private.key",
"httpPort": 80,
"httpsPort": 443,
"httpsEnabled": 1,
@ -153,6 +160,9 @@ func auditMigratedSSL(t *testing.T, mode string) (*config.Config, []byte) {
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(config.ROOT_PATH, "config"), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(config.ROOT_PATH, "config", "config.json"), data, 0600); err != nil {
t.Fatal(err)
}