补齐邮件协议隔离回归并优化客户端设置
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-06 22:28:20 +08:00
parent 110a5f86cc
commit 20a6eea3b3
15 changed files with 360 additions and 21 deletions

View File

@ -369,13 +369,13 @@ func ListProtocolCredentials(ctx *context.Context, w http.ResponseWriter, req *h
items, err := service.ListProtocolCredentials(ctx.UserID)
if err != nil {
log.WithError(err).Error("load protocol credentials failed")
writeSecurityError(w, response.ParamsError, "读取第三方协议设置失败")
writeSecurityError(w, response.ParamsError, "读取邮件客户端协议设置失败")
return
}
response.NewSuccessResponse(items).FPrint(w)
}
// SetProtocolCredentialEnabled 设置当前用户的第三方协议开关。
// SetProtocolCredentialEnabled 设置当前用户的邮件客户端协议开关,不改变服务监听状态。
func SetProtocolCredentialEnabled(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
service, ok := requireProtocolCredentialContext(ctx, w)
if !ok {

View File

@ -2,7 +2,7 @@ package models
import "time"
// ProtocolCredential 保存单个用户的第三方邮件协议开关和授权码哈希。
// ProtocolCredential 保存单个用户的邮件客户端协议开关和授权码哈希,不控制全局监听服务。
type ProtocolCredential struct {
ID int `xorm:"id bigint unsigned not null pk autoincr" json:"-"`
UserID int `xorm:"user_id bigint not null" json:"-"`

View File

@ -204,9 +204,22 @@ func (s *Service) VerifyProtocolCode(userID int, protocol string, plain string)
return false
}
now := s.now()
_, _ = s.engine.ID(item.ID).Cols("last_used_at", "updated_at").Update(&models.ProtocolCredential{LastUsedAt: &now})
// 哈希计算期间可能发生关闭、撤销或换码,返回成功前必须再次原子确认凭据状态。
affected, err := s.engine.ID(item.ID).
Where("enabled = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", true, item.CodeVersion, item.CodeHash).
Cols("last_used_at", "updated_at").Update(&models.ProtocolCredential{LastUsedAt: &now})
if err != nil {
return false
}
if affected == 1 {
return true
}
// MySQL 在时间字段未变化时可能报告零影响行,必须再次查询有效状态而不是误拒绝或直接放行。
valid, err := s.engine.ID(item.ID).
Where("enabled = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", true, item.CodeVersion, item.CodeHash).
Exist(&models.ProtocolCredential{})
return err == nil && valid
}
func newProtocolCode() (string, error) {
buf := make([]byte, 15)

View File

@ -116,12 +116,12 @@ const baseGroups = [
]
const smtpGroups = [
group('listener', 'SMTP 服务监听', '', [
group('listener', 'SMTP 服务监听', '本地邮件服务默认启动,不受中继配置或用户协议开关控制。', [
port('listener.port', 'SMTP 收信端口', { min: 1, hint: '默认 25,用于接收其他邮件服务器投递,支持 STARTTLS;不是发信中继端口。' }),
port('listener.starttlsPort', 'SMTP 提交端口(STARTTLS)', { min: 1, hint: '默认 587,客户端升级 TLS 后进行认证。' }),
port('listener.tlsPort', 'SMTP 隐式 TLS 端口', { min: 1, hint: '默认 465,客户端建立连接时直接使用 TLS。' })
]),
group('relay', 'SMTP 中继连接', '用于发送验证及通知邮件。未使用中继时可保留空值。', [
group('relay', 'SMTP 中继连接(可选)', '仅用于验证及通知邮件外发,不负责启动本地邮件服务;未使用时可保留空值。', [
field('relay.relayHost', 'SMTP 服务器', 'text', { placeholder: 'smtp.example.com' }),
field('relay.security', '连接加密', 'select', {
options: options([

View File

@ -13,7 +13,7 @@ const sections = [
{ value: 'ssl', label: 'SSL 配置', description: '管理证书与加密连接,应用后请留意待重启提示。' },
{ value: 'plugins', label: '插件配置', description: '管理插件运行配置,敏感设置仅显示配置状态。' },
{ value: 'spam_block', label: '垃圾邮件拦截', description: '配置垃圾邮件检测服务、超时和拦截阈值,仅编辑当前插件。' },
{ value: 'telegram_push', label: 'Telegram 推送', description: '配置 Telegram 推送的运行参数与网络权限,个人绑定在第三方授权中管理。' },
{ value: 'telegram_push', label: 'Telegram 推送', description: '配置 Telegram 推送的运行参数与网络权限,个人绑定在扩展功能中管理。' },
{ value: 'wechat_push', label: '微信推送', description: '配置微信推送的应用、模板与接收用户,敏感凭据不回填。' }
]

View File

@ -44,7 +44,7 @@ export function usePage() {
const settingsMenu = computed(() => [
{ key: 'security', label: lang.security, hint: '账号与登录保护', icon: Lock, component: SecuritySettings, capability: 'password.change_own' },
{ key: 'third-party', label: '第三方授权', hint: '邮件客户端接入', icon: Connection, component: ThirdPartyAuthorization, capability: 'plugin.write_own' },
{ key: 'third-party', label: '邮件客户端', hint: '当前账号协议授权', icon: Connection, component: ThirdPartyAuthorization, capability: 'user.read' },
{ key: 'config', label: '配置管理', hint: '服务与运行配置', icon: Setting, component: ConfigSettings, capability: 'config.read' },
{ key: 'group', label: lang.group_settings, hint: '邮箱分组管理', icon: FolderOpened, component: GroupSettings, capability: 'user.read' },
{ key: 'rule', label: lang.rule_setting, hint: '邮件处理规则', icon: Filter, component: RuleSettings, capability: 'user.read' },

View File

@ -29,11 +29,11 @@ export function usePage() {
try {
const response = await http.get('/api/v1/security/credentials/protocols')
if (response.errorNo !== 0) {
throw new Error(response.errorMsg || '读取第三方授权设置失败')
throw new Error(response.errorMsg || '读取邮件客户端协议设置失败')
}
protocolCredentials.value = Array.isArray(response.data) ? response.data : []
} catch (error) {
notify(error?.message || '读取第三方授权设置失败')
notify(error?.message || '读取邮件客户端协议设置失败')
} finally {
protocolLoading.value = false
}
@ -52,7 +52,11 @@ export function usePage() {
if (response.errorNo !== 0) {
throw new Error(response.errorMsg || '协议设置失败')
}
notify(`${protocolLabel(item.protocol)} 协议已${enabled ? '开启' : '关闭'}`, 'success')
if (!enabled && generatedProtocol.value === item.protocol) {
generatedProtocol.value = ''
generatedProtocolCode.value = ''
}
notify(`${protocolLabel(item.protocol)} 当前账号客户端访问已${enabled ? '开启' : '关闭'}`, 'success')
} catch (error) {
item.enabled = previous
notify(error?.message || '协议设置失败')

View File

@ -2,14 +2,14 @@
<div class="third-party-authorization" v-loading="protocolLoading">
<section class="authorization-section">
<div class="authorization-header">
<h3>第三方邮件客户端登录</h3>
<h3>邮件客户端协议</h3>
<p class="authorization-desc">
开启协议并生成授权码后,才能使用邮件客户端登录。登录名必须填写完整邮箱地址,密码必须填写授权码,不能填写网页登录密码。
本地邮件服务默认启动,不依赖 SMTP 中继。以下开关只控制当前账号的客户端访问权限,不影响其他用户或服务监听。
</p>
</div>
<el-alert
title="授权码只在生成成功后显示一次,请立即保存;撤销授权码或关闭协议后,客户端会立即无法登录。"
title="登录名填写完整邮箱地址,密码填写本协议授权码,不能使用网页登录密码。授权码仅显示一次;关闭协议会暂停新登录,重新开启可继续使用未撤销的授权码。"
type="warning"
:closable="false"
show-icon
@ -36,6 +36,7 @@
:loading="protocolLoading"
active-text="开启"
inactive-text="关闭"
:aria-label="`${protocolLabel(item.protocol)} 当前账号客户端访问`"
@change="value => setProtocolEnabled(item, value)"
/>
<el-button

View File

@ -3,6 +3,7 @@
## 目录用途
- `frontend/`:前端自动测试及其构建路径约束测试,直接复用 `frontend/node_modules`。
- `backend/`:集中保存邮件监听、账号协议隔离和凭据竞态回归,通过默认入口的 Go overlay 注入相应业务包;不复制测试文件到源码目录。
- `tools/password-check/`:离线密码哈希校验工具,只通过环境变量接收输入,不输出密码和哈希。
- `manual/smtp/`:人工 SMTP 发信脚本,可能向外部发送真实邮件,不包含在自动测试中。运行前需自行检查收发件人和服务器配置。
- `plugins/spam_block/`:垃圾邮件模型测试,需要额外安装 TensorFlow 并准备相应模型;不包含在默认测试中。

View File

@ -1,4 +1,4 @@
package integration_test
package res_init_test
import (
"bufio"
@ -33,6 +33,8 @@ func TestConfiguredMailListenerPortsAcceptProtocolConnections(t *testing.T) {
smtp := config.SMTPSectionConfig{Meta: meta, Listener: config.SMTPListenerConfig{
Port: ports[0], STARTTLSPort: ports[1], TLSPort: ports[2],
}}
// 中继与 IMAP 共用数值端口也不冲突;不配置中继主机仍可启动全部本地监听。
smtp.Relay.RelayPort = ports[3]
email := config.EmailSectionConfig{Meta: meta, IMAP: config.IMAPListenerConfig{TLSPort: ports[3]}, POP3: config.POP3ListenerConfig{Port: ports[4], TLSPort: ports[5]}}
ssl := config.SSLSectionConfig{Meta: meta, TLS: config.SSLConfig{Mode: "default", DefaultDirectory: "config/ssl"}}
rootConfig := config.RootConfig{

View File

@ -0,0 +1,145 @@
package controllers
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/auth"
"git.pcux.cn/xuans/Smail/services/credentials"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
_ "modernc.org/sqlite"
"xorm.io/xorm"
)
func TestProtocolClientHandlersIsolateEveryRole(t *testing.T) {
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "protocol-access.db"))
if err != nil {
t.Fatal(err)
}
previousDB, previousRuntime := db.Instance, currentSecurityRuntime()
t.Cleanup(func() {
ConfigureSecurityRuntime(previousRuntime.verification, previousRuntime.external, previousRuntime.recovery, previousRuntime.credentials)
db.Instance = previousDB
_ = engine.Close()
})
if err := engine.Sync2(&models.User{}, &models.UserEmail{}, &models.ProtocolCredential{}); err != nil {
t.Fatal(err)
}
db.Instance = engine
service, err := credentials.NewService(engine)
if err != nil {
t.Fatal(err)
}
ConfigureSecurityRuntime(nil, nil, nil, service)
newUser := func(account string, role access.Role) models.User {
user := models.User{Account: account, Name: account}
user.ApplyRole(role)
if err := auth.SetPassword(&user, "WebOnly42"); err != nil {
t.Fatal(err)
}
if _, err := engine.Insert(&user); err != nil {
t.Fatal(err)
}
now, address := time.Now(), account+"@example.com"
if _, err := engine.Insert(&models.UserEmail{UserID: user.ID, Address: address, AddressCanonical: &address, VerifiedAt: &now}); err != nil {
t.Fatal(err)
}
return user
}
victim := newUser("other", access.RoleUser)
victimCodes := map[string]string{}
for _, protocol := range credentials.SupportedProtocols() {
if err := service.SetProtocolEnabled(victim.ID, protocol, true); err != nil {
t.Fatal(err)
}
victimCodes[protocol], err = service.GenerateProtocolCode(victim.ID, protocol)
if err != nil {
t.Fatal(err)
}
}
for index, role := range []access.Role{access.RoleUser, access.RoleAdmin, access.RoleSuperAdmin} {
t.Run(string(role), func(t *testing.T) {
user := newUser(fmt.Sprintf("actor%d", index), role)
ctx := &appcontext.Context{UserID: user.ID, UserAccount: user.Account, Role: role, IsAdmin: user.IsAdmin == 1}
call := func(handler func(*appcontext.Context, http.ResponseWriter, *http.Request), method, body string) response.Response {
t.Helper()
// 即使查询参数伪造目标账号,所有操作也只能作用于会话所属用户。
request := httptest.NewRequest(method, fmt.Sprintf("/api/security/credentials/protocols?user_id=%d", victim.ID), strings.NewReader(body))
recorder := httptest.NewRecorder()
handler(ctx, recorder, request)
if strings.Contains(recorder.Body.String(), "code_hash") {
t.Fatal("接口泄露授权码哈希")
}
var result response.Response
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
return result
}
mustSucceed := func(result response.Response) {
t.Helper()
if result.ErrorNo != 0 {
t.Fatalf("当前角色操作自己的协议设置失败:%d", result.ErrorNo)
}
}
for _, protocol := range credentials.SupportedProtocols() {
body := fmt.Sprintf(`{"protocol":%q}`, protocol)
malicious := fmt.Sprintf(`{"protocol":%q,"enabled":false,"user_id":%d}`, protocol, victim.ID)
if result := call(SetProtocolCredentialEnabled, http.MethodPut, malicious); result.ErrorNo == 0 {
t.Fatal("接口接受了未知目标用户字段")
}
mustSucceed(call(SetProtocolCredentialEnabled, http.MethodPut, fmt.Sprintf(`{"protocol":%q,"enabled":true}`, protocol)))
generated := call(GenerateProtocolCredential, http.MethodPost, body)
mustSucceed(generated)
data, ok := generated.Data.(map[string]any)
if !ok || data["shown_once"] != true {
t.Fatal("授权码未按一次性响应返回")
}
code, ok := data["code"].(string)
if !ok || code == "" {
t.Fatal("生成的授权码为空")
}
login := func(plain string) bool {
got, err := auth.AuthenticateProtocol(protocol, "192.0.2.231:12345", user.Account+"@example.com", plain)
return err == nil && got != nil && got.ID == user.ID
}
if !login(code) || login("WebOnly42") {
t.Fatal("协议认证未正确区分授权码与网页登录密码")
}
listed := call(ListProtocolCredentials, http.MethodGet, "")
mustSucceed(listed)
encoded, err := json.Marshal(listed.Data)
if err != nil || strings.Contains(string(encoded), code) {
t.Fatal("状态查询重新返回了授权码明文")
}
mustSucceed(call(SetProtocolCredentialEnabled, http.MethodPut, fmt.Sprintf(`{"protocol":%q,"enabled":false}`, protocol)))
if login(code) {
t.Fatal("关闭协议后实际认证入口仍放行")
}
mustSucceed(call(SetProtocolCredentialEnabled, http.MethodPut, fmt.Sprintf(`{"protocol":%q,"enabled":true}`, protocol)))
if !login(code) {
t.Fatal("重新开启协议后有效授权码未恢复")
}
mustSucceed(call(RevokeProtocolCredential, http.MethodPost, body))
if login(code) {
t.Fatal("撤销授权码后实际认证入口仍放行")
}
for _, otherProtocol := range credentials.SupportedProtocols() {
if !service.VerifyProtocolCode(victim.ID, otherProtocol, victimCodes[otherProtocol]) {
t.Fatal("当前角色的操作影响了其他用户")
}
}
}
})
}
}

View File

@ -0,0 +1,117 @@
package credentials
import (
"errors"
"testing"
"time"
"git.pcux.cn/xuans/Smail/models"
)
func TestProtocolClientSwitchesIsolateUsersAndProtocols(t *testing.T) {
service := newFixture(t)
other := models.User{Account: "bob", Name: "Bob", Password: "unused", Role: "user"}
if _, err := service.engine.Insert(&other); err != nil {
t.Fatal(err)
}
codes := map[int]map[string]string{}
for _, userID := range []int{1, other.ID} {
statuses, err := service.ListProtocolCredentials(userID)
if err != nil || len(statuses) != 3 {
t.Fatalf("初始协议状态不完整:%v", err)
}
codes[userID] = map[string]string{}
for _, status := range statuses {
if status.Enabled || status.Configured {
t.Fatal("新账号不应自动获得邮件客户端访问权限")
}
if err := service.SetProtocolEnabled(userID, status.Protocol, true); err != nil {
t.Fatal(err)
}
code, err := service.GenerateProtocolCode(userID, status.Protocol)
if err != nil {
t.Fatal(err)
}
codes[userID][status.Protocol] = code
}
}
for _, protocol := range SupportedProtocols() {
t.Run(protocol, func(t *testing.T) {
code := codes[1][protocol]
if !service.VerifyProtocolCode(1, protocol, code) {
t.Fatal("开启状态下有效授权码被拒绝")
}
if err := service.SetProtocolEnabled(1, protocol, false); err != nil {
t.Fatal(err)
}
if service.VerifyProtocolCode(1, protocol, code) {
t.Fatal("关闭协议后仍能通过授权码认证")
}
if _, err := service.GenerateProtocolCode(1, protocol); !errors.Is(err, ErrProtocolDisabled) {
t.Fatalf("关闭协议后仍能生成授权码:%v", err)
}
for _, otherProtocol := range SupportedProtocols() {
if otherProtocol != protocol && !service.VerifyProtocolCode(1, otherProtocol, codes[1][otherProtocol]) {
t.Fatal("关闭一个协议影响了当前用户的其他协议")
}
if !service.VerifyProtocolCode(other.ID, otherProtocol, codes[other.ID][otherProtocol]) {
t.Fatal("关闭协议影响了其他用户")
}
}
if err := service.SetProtocolEnabled(1, protocol, true); err != nil {
t.Fatal(err)
}
if !service.VerifyProtocolCode(1, protocol, code) {
t.Fatal("重新开启后未撤销的授权码应恢复认证")
}
if service.VerifyProtocolCode(other.ID, protocol, code) {
t.Fatal("授权码可被其他账号使用")
}
})
}
}
func TestProtocolClientRejectsChangesDuringHashVerification(t *testing.T) {
for _, change := range []string{"disable", "revoke", "rotate", "database_error"} {
t.Run(change, func(t *testing.T) {
service := newFixture(t)
if err := service.SetProtocolEnabled(1, ProtocolSMTP, true); err != nil {
t.Fatal(err)
}
code, err := service.GenerateProtocolCode(1, ProtocolSMTP)
if err != nil {
t.Fatal(err)
}
writer, err := NewService(service.engine)
if err != nil {
t.Fatal(err)
}
// 在哈希匹配后、认证提交前模拟另一个请求写入,稳定覆盖竞态而不依赖休眠。
changed := false
service.now = func() time.Time {
changed = true
var err error
switch change {
case "disable":
err = writer.SetProtocolEnabled(1, ProtocolSMTP, false)
case "revoke":
err = writer.RevokeProtocolCode(1, ProtocolSMTP)
case "rotate":
_, err = writer.GenerateProtocolCode(1, ProtocolSMTP)
case "database_error":
err = service.engine.Close()
}
if err != nil {
t.Fatal(err)
}
return time.Now()
}
if service.VerifyProtocolCode(1, ProtocolSMTP, code) {
t.Fatal("凭据状态变化或数据库写入失败后,过期认证仍返回成功")
}
if !changed {
t.Fatal("未进入哈希计算后的竞态验证位置")
}
})
}
}

View File

@ -0,0 +1,42 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { compileFunction } from 'node:vm'
import { ref } from './vue.mjs'
// 将已有真实 Vue 开关事件回归纳入默认前端测试,避免只覆盖调用函数的模拟。
import '../review/protocol-frontend.test.mjs'
const root = new URL('../../frontend/src/views/components/ThirdPartyAuthorization/', import.meta.url)
const read = extension => readFileSync(new URL(`ThirdPartyAuthorization.${extension}`, root), 'utf8')
for (const succeeded of [false, true]) {
test(`关闭协议时只在保存成功后清除本次明文:成功=${succeeded}`, async () => {
const usePage = compileFunction(
read('js').replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage',
['onMounted', 'ref', 'ElMessageBox', 'ElNotification', 'http'],
)(() => {}, ref, {}, () => {}, {
put: async () => ({ errorNo: succeeded ? 0 : 1 }),
})
const page = usePage()
const item = { protocol: 'imap', enabled: true, configured: true }
page.generatedProtocol.value = 'imap'
page.generatedProtocolCode.value = 'temporary-test-only'
await page.setProtocolEnabled(item, false)
assert.equal(item.enabled, !succeeded)
assert.equal(page.generatedProtocolCode.value, succeeded ? '' : 'temporary-test-only')
assert.equal(page.generatedProtocol.value, succeeded ? '' : 'imap')
assert.equal(item.configured, true)
assert.equal(page.protocolLoading.value, false)
})
}
test('邮件客户端文案明确区分本地服务、账号权限和可恢复的关闭状态', () => {
const template = read('vue')
assert.match(template, /邮件客户端协议/)
assert.match(template, /不依赖 SMTP 中继/)
assert.match(template, /只控制当前账号/)
assert.match(template, /重新开启可继续使用未撤销的授权码/)
assert.match(template, /:aria-label=/)
assert.doesNotMatch(template, /第三方邮件|&#x[0-9a-f]+;/i)
})

View File

@ -44,19 +44,20 @@ test('一级菜单保留能力过滤和当前分类标题联动', () => {
.replace(/export \{[\s\S]*?\}\s*$/, '') + '\nreturn usePage()',
['computed', 'ref', 'onMounted', 'onUnmounted', 'window', 'useGlobalStatusStore', 'lang', ...names]
)
const capabilities = reactive(new Set(['password.change_own', 'plugin.write_own']))
const capabilities = reactive(new Set(['password.change_own', 'user.read']))
const store = reactive({
settingsActiveTab: 'security',
hasCapability: value => capabilities.has(value)
})
const page = createPage(computed, ref, () => {}, () => {}, { innerWidth: 1440 }, () => store,
{ security: '安全' }, ...names.map(name => ({ name })))
assert.deepEqual(page.settingsMenu.value.map(item => item.key), ['security', 'third-party'])
assert.deepEqual(page.settingsMenu.value.map(item => item.key), ['security', 'third-party', 'group', 'rule'])
assert.equal(page.settingsMenu.value.find(item => item.key === 'third-party').label, '邮件客户端')
assert.equal(page.activeSettings.value.label, '安全')
capabilities.add('config.read')
store.settingsActiveTab = 'config'
assert.equal(page.activeSettings.value.label, '配置管理')
assert.equal(page.settingsMenu.value.length, 3)
assert.equal(page.settingsMenu.value.length, 5)
assert.ok(page.settingsMenu.value.every(item => item.icon && item.hint))
capabilities.delete('config.read')
assert.equal(page.activeSettings.value, undefined)

View File

@ -1,6 +1,6 @@
import { spawn } from 'node:child_process'
import { createWriteStream } from 'node:fs'
import { mkdir, readdir } from 'node:fs/promises'
import { mkdir, readdir, writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
@ -40,7 +40,20 @@ if (suite !== 'backend') {
}
if (suite !== 'frontend' && result === 0) {
const args = extra[0] === '--' ? extra.slice(1) : extra
result = await run('backend', 'go', ['test', ...(args.length ? args : ['./...'])], path.join(root, 'backend'))
// 独立回归源码集中放在 test/backend,通过 overlay 保留同包测试语义。
const replacements = {
'backend/services/credentials/client_regression_test.go': 'test/backend/protocol_credentials_test.go',
'backend/controllers/client_regression_test.go': 'test/backend/protocol_access_test.go',
'backend/res_init/client_listeners_test.go': 'test/backend/mail_listeners_test.go',
}
const overlay = path.join(directory, 'overlay.json')
const Replace = Object.fromEntries(Object.entries(replacements).map(([target, source]) => [
path.join(root, target), path.join(root, source),
]))
await writeFile(overlay, JSON.stringify({ Replace }, null, 2) + '\n')
// 显式传入的审查 overlay 保持原有行为,默认入口自动包含本轮回归。
const overlayArgs = args.some(arg => arg === '-overlay' || arg.startsWith('-overlay=')) ? [] : [`-overlay=${overlay}`]
result = await run('backend', 'go', ['test', ...overlayArgs, ...(args.length ? args : ['./...'])], path.join(root, 'backend'))
}
console.log(`测试记录:${path.relative(root, directory)}`)
process.exitCode = result