This commit is contained in:
parent
110a5f86cc
commit
20a6eea3b3
@ -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 {
|
||||
|
||||
@ -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:"-"`
|
||||
|
||||
@ -204,8 +204,21 @@ 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})
|
||||
return true
|
||||
// 哈希计算期间可能发生关闭、撤销或换码,返回成功前必须再次原子确认凭据状态。
|
||||
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) {
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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: '配置微信推送的应用、模板与接收用户,敏感凭据不回填。' }
|
||||
]
|
||||
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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 || '协议设置失败')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
## 目录用途
|
||||
|
||||
- `frontend/`:前端自动测试及其构建路径约束测试,直接复用 `frontend/node_modules`。
|
||||
- `backend/`:集中保存邮件监听、账号协议隔离和凭据竞态回归,通过默认入口的 Go overlay 注入相应业务包;不复制测试文件到源码目录。
|
||||
- `tools/password-check/`:离线密码哈希校验工具,只通过环境变量接收输入,不输出密码和哈希。
|
||||
- `manual/smtp/`:人工 SMTP 发信脚本,可能向外部发送真实邮件,不包含在自动测试中。运行前需自行检查收发件人和服务器配置。
|
||||
- `plugins/spam_block/`:垃圾邮件模型测试,需要额外安装 TensorFlow 并准备相应模型;不包含在默认测试中。
|
||||
|
||||
@ -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{
|
||||
|
||||
145
test/backend/protocol_access_test.go
Normal file
145
test/backend/protocol_access_test.go
Normal 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("当前角色的操作影响了其他用户")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
117
test/backend/protocol_credentials_test.go
Normal file
117
test/backend/protocol_credentials_test.go
Normal 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("未进入哈希计算后的竞态验证位置")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
42
test/frontend/protocol-client.test.mjs
Normal file
42
test/frontend/protocol-client.test.mjs
Normal 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)
|
||||
})
|
||||
@ -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)
|
||||
|
||||
17
test/run.mjs
17
test/run.mjs
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user