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)
}