Smail/backend/hooks/wechat_push/wechat_push_test.go
smail 66d2c957d9
Some checks are pending
Security Checks / security (push) Waiting to run
补全微信验证码提取边界处理和回归用例
2026-09-28 00:00:02 +08:00

315 lines
10 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"
)
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 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)
}
}