Smail/server/hooks/wechat_push/wechat_push_test.go

206 lines
6.5 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 TestBuildWeChatMessageLimitsContent(t *testing.T) {
message := buildWeChatMessage(&parsemail.Email{
Subject: strings.Repeat("\u754c", 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)
}
}