修复全量测试竞态并更新审查夹具,保存证书覆盖调整前基线
Some checks are pending
Security Checks / security (push) Waiting to run
Some checks are pending
Security Checks / security (push) Waiting to run
This commit is contained in:
parent
35b4882cfb
commit
c82be24178
18
backend/internal/passwordcapacity/capacity.go
Normal file
18
backend/internal/passwordcapacity/capacity.go
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
36
backend/utils/password/random_failure_test.go
Normal file
36
backend/utils/password/random_failure_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 记录")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 就绪探测未恢复")
|
||||
}
|
||||
|
||||
@ -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'))
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user