wechat_test.go 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. // wechat 客户端单测:code 换 openid(httptest 打桩微信服务端)+ 扫码授权 URL 构造。
  2. //
  3. // 不连真微信:BaseURL 注入 httptest.Server,覆盖成功 / errcode!=0 / 网络故障 / 响应非法 JSON,
  4. // 以及 BuildQRConnectURL 的参数完整性(appid/redirect_uri/scope= snsapi_login/state + #wechat_redirect)。
  5. package wechat
  6. import (
  7. "context"
  8. "net/http"
  9. "net/http/httptest"
  10. "regexp"
  11. "strings"
  12. "testing"
  13. "time"
  14. )
  15. // newStub 起一个 httptest 微信服务替身,按 handler 返回预置响应。
  16. func newStub(t *testing.T, handler http.HandlerFunc) *HTTPClient {
  17. t.Helper()
  18. srv := httptest.NewServer(handler)
  19. t.Cleanup(srv.Close)
  20. return &HTTPClient{HTTP: &http.Client{Timeout: 2 * time.Second}, BaseURL: srv.URL}
  21. }
  22. // TestExchangeCode_Success 微信返回 openid:ExchangeCode 应取回原值。
  23. func TestExchangeCode_Success(t *testing.T) {
  24. var gotAppID, gotSecret, gotCode, gotRedirect string
  25. c := newStub(t, func(w http.ResponseWriter, r *http.Request) {
  26. q := r.URL.Query()
  27. gotAppID = q.Get("appid")
  28. gotSecret = q.Get("secret")
  29. gotCode = q.Get("code")
  30. gotRedirect = q.Get("redirect_uri")
  31. _, _ = w.Write([]byte(`{"access_token":"at-1","openid":"openid-x","errcode":0,"errmsg":"ok"}`))
  32. })
  33. oid, err := c.ExchangeCode(context.Background(), "appid-a", "secret-b", "code-1", "https://cb.test/auth/wechat/callback")
  34. if err != nil {
  35. t.Fatalf("ExchangeCode: %v", err)
  36. }
  37. if oid != "openid-x" {
  38. t.Fatalf("openid=%q(期望 openid-x)", oid)
  39. }
  40. if gotAppID != "appid-a" || gotSecret != "secret-b" || gotCode != "code-1" {
  41. t.Fatalf("请求参数不符:appid=%q secret=%q code=%q", gotAppID, gotSecret, gotCode)
  42. }
  43. if gotRedirect != "https://cb.test/auth/wechat/callback" {
  44. t.Fatalf("redirect_uri=%q(期望回调地址原样透传)", gotRedirect)
  45. }
  46. }
  47. // TestExchangeCode_WechatReject errcode!=0:返回 *errToken,IsTokenError 可判。
  48. func TestExchangeCode_WechatReject(t *testing.T) {
  49. c := newStub(t, func(w http.ResponseWriter, r *http.Request) {
  50. _, _ = w.Write([]byte(`{"errcode":40029,"errmsg":"invalid code"}`))
  51. })
  52. _, err := c.ExchangeCode(context.Background(), "a", "s", "bad", "")
  53. if err == nil {
  54. t.Fatal("errcode!=0 应返回 error")
  55. }
  56. if !IsTokenError(err) {
  57. t.Fatalf("应为 *errToken,实际 %T: %v", err, err)
  58. }
  59. }
  60. // TestExchangeCode_EmptyOpenID errcode=0 但 openid 缺失:按失败处理(防半成功态)。
  61. func TestExchangeCode_EmptyOpenID(t *testing.T) {
  62. c := newStub(t, func(w http.ResponseWriter, r *http.Request) {
  63. _, _ = w.Write([]byte(`{"access_token":"at","errcode":0,"errmsg":"ok"}`))
  64. })
  65. _, err := c.ExchangeCode(context.Background(), "a", "s", "code", "")
  66. if err == nil {
  67. t.Fatal("errcode=0 但 openid 空应返回 error(防半成功态)")
  68. }
  69. }
  70. // TestExchangeCode_NonJSON 响应非 JSON:返回 error(fail-closed,不误判成功)。
  71. func TestExchangeCode_NonJSON(t *testing.T) {
  72. c := newStub(t, func(w http.ResponseWriter, r *http.Request) {
  73. _, _ = w.Write([]byte("<html>bad gateway</html>"))
  74. })
  75. _, err := c.ExchangeCode(context.Background(), "a", "s", "code", "")
  76. if err == nil {
  77. t.Fatal("非 JSON 响应应返回 error")
  78. }
  79. }
  80. // TestExchangeCode_NetworkFail 网络故障(关 server):返回 *errToken。
  81. func TestExchangeCode_NetworkFail(t *testing.T) {
  82. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
  83. c := &HTTPClient{HTTP: &http.Client{Timeout: 2 * time.Second}, BaseURL: srv.URL}
  84. srv.Close() // 关服务 → 连接失败
  85. _, err := c.ExchangeCode(context.Background(), "a", "s", "code", "")
  86. if err == nil {
  87. t.Fatal("网络故障应返回 error")
  88. }
  89. if !IsTokenError(err) {
  90. t.Fatalf("应为 *errToken,实际 %T", err)
  91. }
  92. }
  93. // TestBuildQRConnectURL 扫码授权 URL 参数完整 + 尾段 #wechat_redirect(微信约定必须保留)。
  94. func TestBuildQRConnectURL(t *testing.T) {
  95. u := BuildQRConnectURL("appid-a", "https://cb.test/auth/wechat/callback", "state-123")
  96. // 必含关键参数(redirect_uri 已 URL-encode,故用解码后断言)
  97. if !strings.Contains(u, "appid=appid-a") {
  98. t.Fatalf("缺 appid:%q", u)
  99. }
  100. if !strings.Contains(u, "response_type=code") {
  101. t.Fatalf("缺 response_type=code:%q", u)
  102. }
  103. if !strings.Contains(u, "scope=snsapi_login") {
  104. t.Fatalf("缺 scope=snsapi_login(网页扫码登录固定):%q", u)
  105. }
  106. if !strings.Contains(u, "state=state-123") {
  107. t.Fatalf("缺 state(防 CSRF):%q", u)
  108. }
  109. if !strings.HasSuffix(u, "#wechat_redirect") {
  110. t.Fatalf("缺 #wechat_redirect 尾段(微信约定必须保留):%q", u)
  111. }
  112. // redirect_uri 应出现(URL-encoded 形式)
  113. re := regexp.MustCompile(`redirect_uri=https%3A%2F%2Fcb\.test%2Fauth%2Fwechat%2Fcallback`)
  114. if !re.MatchString(u) {
  115. t.Fatalf("redirect_uri 未正确 URL-encode:%q", u)
  116. }
  117. }
  118. // TestBuildAuthorizeURL_Userinfo 移动 webview 授权 URL:oauth2/authorize + snsapi_userinfo + 无 #wechat_redirect。
  119. func TestBuildAuthorizeURL_Userinfo(t *testing.T) {
  120. u := BuildAuthorizeURL("appid-a", "https://cb.test/auth/wechat/callback", "state-456", ScopeUserinfo)
  121. if !strings.Contains(u, "https://open.weixin.qq.com/connect/oauth2/authorize?") {
  122. t.Fatalf("endpoint 应为 oauth2/authorize:%q", u)
  123. }
  124. if !strings.Contains(u, "appid=appid-a") {
  125. t.Fatalf("缺 appid:%q", u)
  126. }
  127. if !strings.Contains(u, "scope=snsapi_userinfo") {
  128. t.Fatalf("scope 应为 snsapi_userinfo:%q", u)
  129. }
  130. if !strings.Contains(u, "state=state-456") {
  131. t.Fatalf("缺 state(防 CSRF):%q", u)
  132. }
  133. if !strings.Contains(u, "response_type=code") {
  134. t.Fatalf("缺 response_type=code:%q", u)
  135. }
  136. // webview 场景无 #wechat_redirect 尾段
  137. if strings.Contains(u, "#wechat_redirect") {
  138. t.Fatalf("webview 场景不应含 #wechat_redirect:%q", u)
  139. }
  140. // redirect_uri 应出现(URL-encoded 形式)
  141. re := regexp.MustCompile(`redirect_uri=https%3A%2F%2Fcb\.test%2Fauth%2Fwechat%2Fcallback`)
  142. if !re.MatchString(u) {
  143. t.Fatalf("redirect_uri 未正确 URL-encode:%q", u)
  144. }
  145. }
  146. // TestBuildAuthorizeURL_Login PC 扫码走 BuildAuthorizeURL(ScopeLogin) 应与 BuildQRConnectURL 等价。
  147. func TestBuildAuthorizeURL_Login(t *testing.T) {
  148. u1 := BuildAuthorizeURL("appid-a", "https://cb.test/x", "s1", ScopeLogin)
  149. u2 := BuildQRConnectURL("appid-a", "https://cb.test/x", "s1")
  150. if u1 != u2 {
  151. t.Fatalf("BuildAuthorizeURL(ScopeLogin) 应等于 BuildQRConnectURL:\n A=%q\n B=%q", u1, u2)
  152. }
  153. }