diff --git a/backend/internal/passwordcapacity/capacity.go b/backend/internal/passwordcapacity/capacity.go new file mode 100644 index 0000000..9689257 --- /dev/null +++ b/backend/internal/passwordcapacity/capacity.go @@ -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 + } +} diff --git a/backend/internal/passwordtest/hold.go b/backend/internal/passwordtest/hold.go index 343485f..8d34736 100644 --- a/backend/internal/passwordtest/hold.go +++ b/backend/internal/passwordtest/hold.go @@ -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 } diff --git a/backend/utils/password/capacity.go b/backend/utils/password/capacity.go index 94782ad..8aeffa3 100644 --- a/backend/utils/password/capacity.go +++ b/backend/utils/password/capacity.go @@ -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() } diff --git a/backend/utils/password/capacity_test.go b/backend/utils/password/capacity_test.go index f25bc00..c3821ed 100644 --- a/backend/utils/password/capacity_test.go +++ b/backend/utils/password/capacity_test.go @@ -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) diff --git a/backend/utils/password/encode.go b/backend/utils/password/encode.go index d8af773..2231c66 100644 --- a/backend/utils/password/encode.go +++ b/backend/utils/password/encode.go @@ -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) diff --git a/backend/utils/password/random_failure_test.go b/backend/utils/password/random_failure_test.go new file mode 100644 index 0000000..5a5dfc9 --- /dev/null +++ b/backend/utils/password/random_failure_test.go @@ -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) + } +} diff --git a/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue b/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue index 62e3a13..95668fa 100644 --- a/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue +++ b/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue @@ -335,7 +335,6 @@ - \ No newline at end of file + diff --git a/frontend/src/views/components/UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.js b/frontend/src/views/components/UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.js index 9f3e5ad..afec442 100644 --- a/frontend/src/views/components/UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.js +++ b/frontend/src/views/components/UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.js @@ -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, }) diff --git a/frontend/src/views/components/UserManagement/UserExternalEmailDialog/UserExternalEmailDialog.js b/frontend/src/views/components/UserManagement/UserExternalEmailDialog/UserExternalEmailDialog.js index 15af897..a471bce 100644 --- a/frontend/src/views/components/UserManagement/UserExternalEmailDialog/UserExternalEmailDialog.js +++ b/frontend/src/views/components/UserManagement/UserExternalEmailDialog/UserExternalEmailDialog.js @@ -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, diff --git a/test/review/config_findings_test.go b/test/review/config_findings_test.go index fb5ba3b..0b0b12e 100644 --- a/test/review/config_findings_test.go +++ b/test/review/config_findings_test.go @@ -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 记录") } } diff --git a/test/review/cron_ssl_test.go b/test/review/cron_ssl_test.go index ff9a5d3..892c807 100644 --- a/test/review/cron_ssl_test.go +++ b/test/review/cron_ssl_test.go @@ -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 就绪探测未恢复") } diff --git a/test/review/frontend-findings.test.mjs b/test/review/frontend-findings.test.mjs index 303fe6e..d83d08c 100644 --- a/test/review/frontend-findings.test.mjs +++ b/test/review/frontend-findings.test.mjs @@ -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')) diff --git a/test/review/rule-frontend.test.mjs b/test/review/rule-frontend.test.mjs index ba48a51..b08927b 100644 --- a/test/review/rule-frontend.test.mjs +++ b/test/review/rule-frontend.test.mjs @@ -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({ diff --git a/test/review/run.mjs b/test/review/run.mjs index f550a28..8229bf7 100644 --- a/test/review/run.mjs +++ b/test/review/run.mjs @@ -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', diff --git a/test/review/ssl_findings_test.go b/test/review/ssl_findings_test.go index 00d4e1f..2a39d2d 100644 --- a/test/review/ssl_findings_test.go +++ b/test/review/ssl_findings_test.go @@ -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) }