83269e067a
用户关注服务号(扫登录码后关注 或 直接搜索关注)时,在回调 HTTP 响应里 回一条文本消息(微信「被动回复」)。被动回复不需要 access_token、不受 IP 白名单 限制,永远能发。欢迎语在管理端「登录设置」可配,留空用默认。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
155 lines
5.0 KiB
Go
155 lines
5.0 KiB
Go
package wechat
|
||
|
||
import (
|
||
"crypto/sha1"
|
||
"encoding/hex"
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func TestConfig_Enabled(t *testing.T) {
|
||
// 三者缺一不可:appid+secret 建二维码,token 验签
|
||
cases := []struct {
|
||
c Config
|
||
want bool
|
||
}{
|
||
{Config{AppID: "a", AppSecret: "s", Token: "t"}, true},
|
||
{Config{AppID: "a", AppSecret: "s"}, false},
|
||
{Config{AppID: "a", Token: "t"}, false},
|
||
{Config{}, false},
|
||
}
|
||
for _, tc := range cases {
|
||
if got := tc.c.Enabled(); got != tc.want {
|
||
t.Fatalf("%+v Enabled=%v want %v", tc.c, got, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 验签必须与微信算法一致:sha1(sort(token,ts,nonce))。
|
||
func TestCheckSignature(t *testing.T) {
|
||
c := Config{Token: "mytoken"}
|
||
ts, nonce := "1700000000", "abc123"
|
||
arr := []string{c.Token, ts, nonce}
|
||
sort.Strings(arr)
|
||
sum := sha1.Sum([]byte(strings.Join(arr, "")))
|
||
good := hex.EncodeToString(sum[:])
|
||
|
||
if !c.CheckSignature(good, ts, nonce) {
|
||
t.Fatal("正确签名应通过")
|
||
}
|
||
if c.CheckSignature("deadbeef", ts, nonce) {
|
||
t.Fatal("错误签名不该通过")
|
||
}
|
||
if (Config{}).CheckSignature(good, ts, nonce) {
|
||
t.Fatal("无 token 一律不通过(防未配置时被绕过)")
|
||
}
|
||
}
|
||
|
||
// 事件解析 + scene 提取:subscribe 带 qrscene_ 前缀,SCAN 不带;两者都要能登录。
|
||
func TestParseEvent_SubscribeAndScan(t *testing.T) {
|
||
subscribe := `<xml><ToUserName><![CDATA[gh_x]]></ToUserName>
|
||
<FromUserName><![CDATA[openid_new]]></FromUserName>
|
||
<MsgType><![CDATA[event]]></MsgType>
|
||
<Event><![CDATA[subscribe]]></Event>
|
||
<EventKey><![CDATA[qrscene_tkt-abc]]></EventKey></xml>`
|
||
ev, err := ParseEvent([]byte(subscribe))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !ev.IsLoginScan() {
|
||
t.Fatal("subscribe 带 qrscene 应识别为登录扫码")
|
||
}
|
||
if ev.Scene() != "tkt-abc" {
|
||
t.Fatalf("subscribe 应剥掉 qrscene_ 前缀,得 %q", ev.Scene())
|
||
}
|
||
if ev.FromUserName != "openid_new" {
|
||
t.Fatalf("openid 取错:%q", ev.FromUserName)
|
||
}
|
||
|
||
scan := `<xml><FromUserName><![CDATA[openid_old]]></FromUserName>
|
||
<MsgType><![CDATA[event]]></MsgType>
|
||
<Event><![CDATA[SCAN]]></Event>
|
||
<EventKey><![CDATA[tkt-def]]></EventKey></xml>`
|
||
ev2, _ := ParseEvent([]byte(scan))
|
||
if !ev2.IsLoginScan() || ev2.Scene() != "tkt-def" {
|
||
t.Fatalf("SCAN 事件应识别,scene=%q", ev2.Scene())
|
||
}
|
||
}
|
||
|
||
// 非登录事件(取关、普通消息)不能被当成登录。
|
||
func TestParseEvent_IgnoresNonLogin(t *testing.T) {
|
||
unsub := `<xml><FromUserName><![CDATA[o]]></FromUserName><MsgType><![CDATA[event]]></MsgType><Event><![CDATA[unsubscribe]]></Event></xml>`
|
||
ev, _ := ParseEvent([]byte(unsub))
|
||
if ev.IsLoginScan() {
|
||
t.Fatal("取关事件不该被当成登录")
|
||
}
|
||
// 无 scene 的 subscribe(用户直接搜号关注,不是扫登录码)也不登录
|
||
plainSub := `<xml><FromUserName><![CDATA[o]]></FromUserName><MsgType><![CDATA[event]]></MsgType><Event><![CDATA[subscribe]]></Event><EventKey><![CDATA[]]></EventKey></xml>`
|
||
ev2, _ := ParseEvent([]byte(plainSub))
|
||
if ev2.IsLoginScan() {
|
||
t.Fatal("无 scene 的关注不该触发登录")
|
||
}
|
||
text := `<xml><FromUserName><![CDATA[o]]></FromUserName><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[hi]]></Content></xml>`
|
||
ev3, _ := ParseEvent([]byte(text))
|
||
if ev3.IsLoginScan() {
|
||
t.Fatal("普通文本消息不该触发登录")
|
||
}
|
||
}
|
||
|
||
// 关注事件要能识别(自动回复欢迎语),并解析出回复所需的 openid + 公众号原始 ID。
|
||
func TestParseEvent_SubscribeReplyFields(t *testing.T) {
|
||
sub := `<xml><ToUserName><![CDATA[gh_808ba17576cf]]></ToUserName>
|
||
<FromUserName><![CDATA[openid_x]]></FromUserName>
|
||
<CreateTime>1700000000</CreateTime>
|
||
<MsgType><![CDATA[event]]></MsgType>
|
||
<Event><![CDATA[subscribe]]></Event>
|
||
<EventKey><![CDATA[]]></EventKey></xml>`
|
||
ev, err := ParseEvent([]byte(sub))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !ev.IsSubscribe() {
|
||
t.Fatal("subscribe 应被识别为新关注")
|
||
}
|
||
if ev.ToUserName != "gh_808ba17576cf" {
|
||
t.Fatalf("公众号原始 ID 取错:%q", ev.ToUserName)
|
||
}
|
||
// 无 scene 的直接关注:不是登录扫码,但仍是 subscribe(要回欢迎语)
|
||
if ev.IsLoginScan() {
|
||
t.Fatal("无 scene 关注不该触发登录")
|
||
}
|
||
}
|
||
|
||
// 被动回复 XML:ToUser=用户、FromUser=公众号、含文本内容,且防 CDATA 提前闭合。
|
||
func TestBuildTextReply(t *testing.T) {
|
||
xml := BuildTextReply("openid_user", "gh_pub", "你好]]>危险", 1700000000)
|
||
for _, want := range []string{
|
||
"<ToUserName><![CDATA[openid_user]]>",
|
||
"<FromUserName><![CDATA[gh_pub]]>",
|
||
"<MsgType><![CDATA[text]]>",
|
||
"<CreateTime>1700000000</CreateTime>",
|
||
} {
|
||
if !strings.Contains(xml, want) {
|
||
t.Fatalf("回复 XML 缺 %q:\n%s", want, xml)
|
||
}
|
||
}
|
||
if strings.Contains(xml, "你好]]>危险") {
|
||
t.Fatal("内容里的 ]]> 应被转义,避免 CDATA 提前闭合")
|
||
}
|
||
}
|
||
|
||
func TestConfig_SecretRoundTrip(t *testing.T) {
|
||
c := Config{AppID: "x", AppSecret: "plain-secret", Token: "t"}
|
||
stored, err := c.EncryptedForStore()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if stored.AppSecret == "plain-secret" {
|
||
t.Fatal("落库不该是明文")
|
||
}
|
||
if back := stored.DecryptFromStore(); back.AppSecret != "plain-secret" {
|
||
t.Fatalf("还原失败:%q", back.AppSecret)
|
||
}
|
||
}
|