Smail/test/review/telegram_findings_test.go
smail 6f79441585 修复审查发现的认证配置邮件协议及前端问题
补齐安全运行时清理、配置候选恢复、泛域名证书事务提交、邮件副本与协议边界、插件隐私和安装回滚回归,并更新修复状态及待验收清单。
2026-09-06 13:02:02 +08:00

193 lines
6.2 KiB
Go

package hook
import (
"bytes"
"encoding/json"
"fmt"
"html"
"io"
"mime"
"mime/multipart"
"net/http"
"path"
"strings"
"testing"
"time"
"unicode/utf8"
xhtml "golang.org/x/net/html"
"git.pcux.cn/xuans/Smail/dto/parsemail"
"git.pcux.cn/xuans/Smail/models"
"github.com/go-telegram/bot"
"github.com/ydzydzydz/pmail_telegram_push/model"
)
type auditTelegramRequest struct {
method string
fields map[string]string
files int
}
type auditTelegramClient struct {
requests []auditTelegramRequest
}
func (c *auditTelegramClient) Do(request *http.Request) (*http.Response, error) {
// 完整消费 SDK 的管道请求体,不建立任何网络连接。
data, err := io.ReadAll(request.Body)
if err != nil {
return nil, err
}
defer request.Body.Close()
_, params, err := mime.ParseMediaType(request.Header.Get("Content-Type"))
if err != nil {
return nil, err
}
reader := multipart.NewReader(bytes.NewReader(data), params["boundary"])
captured := auditTelegramRequest{method: path.Base(request.URL.Path), fields: make(map[string]string)}
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
value, err := io.ReadAll(part)
if err != nil {
return nil, err
}
captured.fields[part.FormName()] = string(value)
if part.FileName() != "" {
captured.files++
}
}
c.requests = append(c.requests, captured)
result := `{"ok":true,"result":{"message_id":1}}`
if captured.method == "sendMediaGroup" {
result = `{"ok":true,"result":[]}`
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(result)), Header: make(http.Header)}, nil
}
func auditTelegramHook(t *testing.T) (*PmailTelegramPushHook, *auditTelegramClient) {
t.Helper()
client := &auditTelegramClient{}
instance, err := bot.New("1:audit-placeholder-not-a-real-token", bot.WithSkipGetMe(), bot.WithHTTPClient(time.Second, client))
if err != nil {
t.Fatal(err)
}
hook := NewPmailTelegramPushHook()
hook.bot = instance
hook.pluginConfig = pluginSettings{Timeout: 5}
return hook, client
}
func TestAuditDisabledAttachmentsMustNotBeSent(t *testing.T) {
for _, enabled := range []bool{false, true} {
t.Run(fmt.Sprintf("enabled=%v", enabled), func(t *testing.T) {
hook, client := auditTelegramHook(t)
settings := json.RawMessage(fmt.Sprintf(`{"chatId":"123","sendAttachments":%v}`, enabled))
if err := hook.ConfigureUserSettings(1, settings); err != nil {
t.Fatal(err)
}
email := &parsemail.Email{MessageId: 1, From: &parsemail.User{EmailAddress: "sender@example.invalid"},
Attachments: []*parsemail.Attachment{
{Filename: "first.txt", Content: []byte("audit-first")},
{Filename: "second.txt", Content: []byte("audit-second")},
}}
hook.ReceiveSaveAfter(nil, email, []*models.UserEmail{{UserID: 1}})
messages, files := 0, 0
for _, request := range client.requests {
if request.method == "sendMessage" {
messages++
}
files += request.files
}
t.Logf("enabled=%v messages=%d uploaded_files=%d", enabled, messages, files)
if messages != 1 {
t.Fatalf("通知调用链未成功执行:%d", messages)
}
if !enabled && files != 0 {
t.Errorf("已关闭附件发送,仍上传 %d 个附件", files)
}
if enabled && files != 2 {
t.Errorf("启用附件发送的对照未上传完整附件")
}
})
}
}
func TestAuditTelegramPlainTextMustBeEscaped(t *testing.T) {
hook, client := auditTelegramHook(t)
subject := "<audit-subject> & status"
content := "<audit-body> & details"
email := &parsemail.Email{From: &parsemail.User{}, Subject: subject, Text: []byte(content)}
_, err := hook.sendText(email, &model.TelegramPushSetting{ChatID: "123", ShowContent: true})
if err != nil {
t.Fatal(err)
}
request := client.requests[0]
t.Logf("parse_mode=%q raw_subject=%v raw_body=%v", request.fields["parse_mode"],
strings.Contains(request.fields["text"], subject), strings.Contains(request.fields["text"], content))
if request.fields["parse_mode"] != "HTML" {
t.Fatal("测试前提不满足:未启用 HTML 解析")
}
if !strings.Contains(request.fields["text"], html.EscapeString(subject)) || !strings.Contains(request.fields["text"], html.EscapeString(content)) {
t.Error("主题和纯文本正文没有作为普通文字转义")
}
}
func TestAuditTelegramTruncationMustKeepUTF8(t *testing.T) {
hook, _ := auditTelegramHook(t)
for prefix := 0; prefix < 3; prefix++ {
t.Run(fmt.Sprintf("prefix=%d", prefix), func(t *testing.T) {
email := &parsemail.Email{From: &parsemail.User{}, Text: []byte(strings.Repeat("x", prefix) + strings.Repeat("中", 2000))}
text := hook.buildSendText(email, &model.TelegramPushSetting{ShowContent: true})
t.Logf("source_valid=%v output_bytes=%d output_valid=%v", utf8.Valid(email.Text), len(text), utf8.ValidString(text))
if !utf8.ValidString(text) {
t.Error("正常中文被截断为无效 UTF-8")
}
})
}
}
func TestAuditTelegramTruncationKeepsClosedMarkup(t *testing.T) {
hook, _ := auditTelegramHook(t)
for _, spoiler := range []bool{false, true} {
text := hook.buildSendText(&parsemail.Email{
Subject: strings.Repeat("<主题>&\"", 300),
To: []*parsemail.User{nil, {EmailAddress: "<script>@example.com"}},
Text: []byte(strings.Repeat("中文 & <正文> 😀", 1000)),
}, &model.TelegramPushSetting{ShowContent: true, SpoilerContent: spoiler})
if !utf8.ValidString(text) || len(text) > TELEGRAM_TEXT_MAX_SIZE {
t.Fatalf("通知长度或编码错误:%d", len(text))
}
parser := xhtml.NewTokenizer(strings.NewReader(text))
stack := []string{}
for {
switch parser.Next() {
case xhtml.ErrorToken:
if parser.Err() != io.EOF || len(stack) != 0 {
t.Fatalf("通知标签未完整闭合:%v %v", stack, parser.Err())
}
goto done
case xhtml.StartTagToken:
name, _ := parser.TagName()
if string(name) != "b" && string(name) != "tg-spoiler" {
t.Fatalf("外部内容被解释成标签:%s", name)
}
stack = append(stack, string(name))
case xhtml.EndTagToken:
name, _ := parser.TagName()
if len(stack) == 0 || stack[len(stack)-1] != string(name) {
t.Fatalf("标签不匹配:%s", name)
}
stack = stack[:len(stack)-1]
}
}
done:
}
}