315 lines
10 KiB
Go
315 lines
10 KiB
Go
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>验证码: <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)
|
||
}
|
||
}
|