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("
验证码: 654321
")}, 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(`普通消息
`), } 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) } }