Smail/backend/hooks/wechat_push/wechat_push_test.go
smail afc177132a
Some checks are pending
Security Checks / security (push) Waiting to run
微信个人绑定状态复核前检查点
保存微信个人绑定后端、按实际收件账户推送和回归源码;前端未接入,回归用例未执行,尚未部署验收。
2026-09-28 21:27:30 +08:00

361 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"encoding/json"
"io"
"net/http"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"unicode/utf8"
"git.pcux.cn/xuans/Smail/dto/parsemail"
"git.pcux.cn/xuans/Smail/hooks/framework"
"git.pcux.cn/xuans/Smail/models"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
)
type weChatRoundTripFunc func(*http.Request) (*http.Response, error)
func (f weChatRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return f(request)
}
func validWeChatRuntimePayload() []byte {
payload := weChatRuntimeConfig{
PluginID: "wechat_push",
Generation: 1,
Settings: json.RawMessage(`{"appId":"app-id","templateId":"template-id","userId":"user-id","webDomain":"mail.example.com"}`),
Secrets: map[string]string{"appSecret": "app-secret"},
Capabilities: []string{"network"},
Network: []framework.PluginNetworkTarget{{Scheme: "https", Host: "api.weixin.qq.com", Port: 443}},
}
data, err := json.Marshal(payload)
if err != nil {
panic(err)
}
return data
}
func TestWeChatRuntimeConfigAcceptsAuthorizedPayload(t *testing.T) {
hook := NewWechatPushHook()
if err := hook.ValidateConfig(validWeChatRuntimePayload()); err != nil {
t.Fatal(err)
}
if err := hook.Configure(validWeChatRuntimePayload()); err != nil {
t.Fatal(err)
}
if err := hook.Health(); err != nil {
t.Fatal(err)
}
if hook.appId != "app-id" || hook.secret != "app-secret" || hook.webDomain != "mail.example.com" {
t.Fatalf("unexpected runtime configuration: %+v", hook)
}
}
func TestWeChatRoutesOnlyToBoundActualRecipients(t *testing.T) {
hook := NewWechatPushHook()
if err := hook.Configure(validWeChatRuntimePayload()); err != nil {
t.Fatal(err)
}
hook.token, hook.tokenExpires = "test-access-token", time.Now().Add(time.Hour).Unix()
var recipients []string
hook.httpClient = &http.Client{Transport: weChatRoundTripFunc(func(request *http.Request) (*http.Response, error) {
var payload sendMsgRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
recipients = append(recipients, payload.Touser)
if payload.Data.Content.Value != "您收到一封新邮件,请登录邮箱查看。" {
t.Fatal("默认通知不应泄露正文或验证码")
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"errcode":0}`))}, nil
})}
ctx := &appcontext.Context{}
email := &parsemail.Email{MessageId: 12, Subject: "private subject"}
hook.ReceiveSaveAfter(ctx, email, []*models.UserEmail{{UserID: 1}})
if len(recipients) != 0 {
t.Fatal("未绑定的用户一不应触发旧版全局收件人通知")
}
for id, recipient := range map[int]string{17: "openid-first", 29: "openid-second"} {
payload, err := json.Marshal(weChatUserSettings{AppID: "app-id", OpenID: recipient, Enabled: true})
if err != nil || hook.ConfigureUserSettings(id, payload) != nil {
t.Fatal("个人绑定配置失败")
}
}
hook.ReceiveSaveAfter(ctx, email, []*models.UserEmail{{UserID: 17}, {UserID: 17}, {UserID: 29, IsRead: 1}, {UserID: 1}})
if len(recipients) != 1 || recipients[0] != "openid-first" {
t.Fatalf("实际通知收件人错误:%v", recipients)
}
payload, _ := json.Marshal(weChatUserSettings{AppID: "app-id", OpenID: "openid-first", Enabled: false})
if err := hook.ConfigureUserSettings(17, payload); err != nil {
t.Fatal(err)
}
hook.ReceiveSaveAfter(ctx, email, []*models.UserEmail{{UserID: 17}, {UserID: 29}})
if len(recipients) != 2 || recipients[1] != "openid-second" {
t.Fatal("停用本人推送不应影响其他收件账户")
}
}
func TestWeChatRuntimeConfigRejectsZeroGeneration(t *testing.T) {
var payload weChatRuntimeConfig
if err := json.Unmarshal(validWeChatRuntimePayload(), &payload); err != nil {
t.Fatal(err)
}
payload.Generation = 0
data, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
if err := NewWechatPushHook().ValidateConfig(data); err == nil {
t.Fatal("zero plugin configuration generation was accepted")
}
}
func TestWeChatRuntimeConfigRejectsSecurityBoundaryViolations(t *testing.T) {
tests := []struct {
name string
mutate func(*weChatRuntimeConfig)
}{
{name: "plugin id", mutate: func(payload *weChatRuntimeConfig) { payload.PluginID = "other" }},
{name: "missing secret", mutate: func(payload *weChatRuntimeConfig) { payload.Secrets = nil }},
{name: "missing capability", mutate: func(payload *weChatRuntimeConfig) { payload.Capabilities = nil }},
{name: "extra capability", mutate: func(payload *weChatRuntimeConfig) { payload.Capabilities = []string{"network", "filesystem"} }},
{name: "wrong network", mutate: func(payload *weChatRuntimeConfig) { payload.Network = nil }},
{name: "extra network", mutate: func(payload *weChatRuntimeConfig) {
payload.Network = append(payload.Network, framework.PluginNetworkTarget{Scheme: "https", Host: "example.com", Port: 443})
}},
{name: "unknown secret", mutate: func(payload *weChatRuntimeConfig) { payload.Secrets["other"] = "value" }},
{name: "invalid domain", mutate: func(payload *weChatRuntimeConfig) {
payload.Settings = json.RawMessage(`{"appId":"app-id","templateId":"template-id","userId":"user-id","webDomain":"https://example.com/path"}`)
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var payload weChatRuntimeConfig
if err := json.Unmarshal(validWeChatRuntimePayload(), &payload); err != nil {
t.Fatal(err)
}
test.mutate(&payload)
data, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
if err := NewWechatPushHook().ValidateConfig(data); err == nil {
t.Fatal("invalid runtime configuration was accepted")
} else if strings.Contains(err.Error(), "app-secret") {
t.Fatal("secret material appeared in the error")
}
})
}
}
func TestWeChatRuntimeConfigRejectsTrailingJSON(t *testing.T) {
data := append(validWeChatRuntimePayload(), []byte(` {}`)...)
if err := NewWechatPushHook().ValidateConfig(data); err == nil {
t.Fatal("trailing JSON was accepted")
}
}
func TestFindWeChatVerificationCode(t *testing.T) {
tests := []struct {
name string
content string
want string
}{
{name: "chinese", content: "您的验证码为:123456,请勿泄露", want: "123456"},
{name: "before keyword", content: "847291 是您的验证码", want: "847291"},
{name: "english", content: "Your verification code is A7B9C2", want: "A7B9C2"},
{name: "leading zero", content: "验证码:001234", want: "001234"},
{name: "four characters", content: "校验码:a1B2", want: "a1B2"},
{name: "eight characters", content: "动态码:a1B2c3D4", want: "a1B2c3D4"},
{name: "one time password", content: "一次性密码:123456", want: "123456"},
{name: "english security", content: "SECURITY CODE: a1B2c3", want: "a1B2c3"},
{name: "english before", content: "123456 is your verification code", want: "123456"},
{name: "otp", content: "OTP: 123456", want: "123456"},
{name: "nonbreaking space", content: "verification code:\u00a0123456", want: "123456"},
{name: "three characters", content: "验证码:123"},
{name: "nine characters", content: "验证码:123456789"},
{name: "letters only", content: "verification code: secure"},
{name: "no keyword", content: "订单号 123456"},
{name: "year", content: "2026 年验证码安全规则更新"},
{name: "identifier suffix", content: "验证码:123456_value"},
{name: "identifier prefix", content: "验证码:value_123456"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := findWeChatVerificationCode(tt.content)
if got != tt.want || ok != (tt.want != "") {
t.Fatalf("验证码 = %q,匹配 = %v,期望 %q", got, ok, tt.want)
}
})
}
}
func TestBuildWeChatMessageVerificationContent(t *testing.T) {
tests := []struct {
name string
email *parsemail.Email
want string
}{
{
name: "html entities",
email: &parsemail.Email{HTML: []byte("<p>验证码:&nbsp;<b>654321</b></p>")},
want: "验证码:654321",
},
{
name: "subject priority",
email: &parsemail.Email{Subject: "验证码:123456", Text: []byte("验证码:654321")},
want: "验证码:123456",
},
{
name: "text priority",
email: &parsemail.Email{Text: []byte("验证码:123456"), HTML: []byte("验证码:654321")},
want: "验证码:123456",
},
{
name: "ordinary email unchanged",
email: &parsemail.Email{Subject: "项目进度 2026", Text: []byte("今天已完成开发")},
want: "<<项目进度 2026>> 今天已完成开发",
},
{
name: "do not combine parts",
email: &parsemail.Email{Subject: "验证码", Text: []byte("123456")},
want: "<<验证码>> 123456",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := buildWeChatMessage(tt.email); got != tt.want {
t.Fatalf("通知内容 = %q,期望 %q", got, tt.want)
}
})
}
}
func TestExtractWeChatVerificationCodeBoundsInput(t *testing.T) {
inputs := map[string]string{
"past limit": strings.Repeat(" ", maxWeChatVerificationScanBytes) + "验证码:123456",
"partial code": strings.Repeat(" ", maxWeChatVerificationScanBytes-len("验证码:123456")) +
"验证码:123456789",
}
for name, content := range inputs {
t.Run(name, func(t *testing.T) {
parts := map[string]*parsemail.Email{
"subject": {Subject: content},
"text": {Text: []byte(content)},
"html": {HTML: []byte(content)},
}
for part, email := range parts {
if code, ok := extractWeChatVerificationCode(email); ok || code != "" {
t.Fatalf("%s 不应提取扫描范围外或被截断的验证码:%q", part, code)
}
}
})
}
}
func TestWeChatHTMLNotificationTextIgnoresAttributes(t *testing.T) {
email := &parsemail.Email{
Subject: "通知",
Text: []byte("查看邮件"),
HTML: []byte(`<img alt="验证码:123456"><p>普通消息</p>`),
}
if got := buildWeChatMessage(email); got != "<<通知>> 查看邮件" {
t.Fatalf("HTML 属性不应作为验证码来源:%q", got)
}
}
func TestBuildWeChatMessageLimitsContent(t *testing.T) {
message := buildWeChatMessage(&parsemail.Email{
Subject: strings.Repeat("界", maxWeChatMessageBytes),
Text: []byte(strings.Repeat("body", maxWeChatMessageBytes)),
})
if len(message) > maxWeChatMessageBytes {
t.Fatalf("message length = %d, want at most %d", len(message), maxWeChatMessageBytes)
}
if !utf8.ValidString(message) {
t.Fatal("message is not valid UTF-8")
}
if message := buildWeChatMessage(nil); message != "" {
t.Fatalf("nil email message = %q", message)
}
}
func TestSendUserMsgLimitsOutboundContent(t *testing.T) {
originalClient := weChatHTTPClient
t.Cleanup(func() { weChatHTTPClient = originalClient })
var captured sendMsgRequest
weChatHTTPClient = &http.Client{Transport: weChatRoundTripFunc(func(request *http.Request) (*http.Response, error) {
body, err := io.ReadAll(request.Body)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(body, &captured); err != nil {
t.Fatal(err)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{}`)),
}, nil
})}
hook := &WeChatPushHook{
token: "test-token",
tokenExpires: time.Now().Add(time.Minute).Unix(),
templateId: "template",
webDomain: "mail.example.com",
}
hook.sendUserMsg(nil, "user", strings.Repeat("x", maxWeChatMessageBytes+100))
if len(captured.Data.Content.Value) != maxWeChatMessageBytes {
t.Fatalf("outbound content length = %d, want %d", len(captured.Data.Content.Value), maxWeChatMessageBytes)
}
}
func TestGetWxAccessTokenConcurrentRefresh(t *testing.T) {
originalClient := weChatHTTPClient
t.Cleanup(func() { weChatHTTPClient = originalClient })
var requestCount atomic.Int32
weChatHTTPClient = &http.Client{Transport: weChatRoundTripFunc(func(request *http.Request) (*http.Response, error) {
requestCount.Add(1)
time.Sleep(20 * time.Millisecond)
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"access_token":"shared-token","expires_in":7200}`)),
}, nil
})}
hook := &WeChatPushHook{appId: "test-app-id", secret: "test-secret"}
const workerCount = 32
start := make(chan struct{})
results := make(chan string, workerCount)
var workers sync.WaitGroup
workers.Add(workerCount)
for range workerCount {
go func() {
defer workers.Done()
<-start
results <- hook.getWxAccessToken()
}()
}
close(start)
workers.Wait()
close(results)
for token := range results {
if token != "shared-token" {
t.Fatalf("token = %q, want shared-token", token)
}
}
if got := requestCount.Load(); got != 1 {
t.Fatalf("token request count = %d, want 1", got)
}
}