easypay_url_test.go 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. package provider
  2. import (
  3. "context"
  4. "net/http"
  5. "net/http/httptest"
  6. "testing"
  7. "github.com/Wei-Shaw/sub2api/internal/payment"
  8. )
  9. // issue #6292: mapi.php 上游若回相对形式的 payurl/payurl2/qrcode,网关原样
  10. // 落库,前端把 qr_code 直接喂给 QRCode.toCanvas,扫出来是一段裸路径;pay_url 则在
  11. // 网关自己的域名下 404。这里锁定归一化规则与全部「不得改写」的边界。
  12. func TestResolveEasyPayReturnedRef(t *testing.T) {
  13. t.Parallel()
  14. const apiBase = "https://pay.example.com/xpay/epay"
  15. tests := []struct {
  16. name string
  17. apiBase string
  18. ref string
  19. want string
  20. }{
  21. {
  22. // issue 复现主线:站点根相对路径按 apiBase 的 origin 补全,
  23. // apiBase 自身的 /xpay/epay 前缀不参与(根相对语义)。
  24. name: "root relative path resolves against origin",
  25. apiBase: apiBase,
  26. ref: "/api/pay/toapp/ORDER_ID",
  27. want: "https://pay.example.com/api/pay/toapp/ORDER_ID",
  28. },
  29. {
  30. name: "root relative path keeps query string",
  31. apiBase: apiBase,
  32. ref: "/api/pay/toapp?oid=ORDER_ID&t=1",
  33. want: "https://pay.example.com/api/pay/toapp?oid=ORDER_ID&t=1",
  34. },
  35. {
  36. name: "protocol relative reference inherits base scheme",
  37. apiBase: apiBase,
  38. ref: "//cashier.example.com/pay/ORDER_ID",
  39. want: "https://cashier.example.com/pay/ORDER_ID",
  40. },
  41. {
  42. name: "surrounding whitespace does not defeat resolution",
  43. apiBase: apiBase,
  44. ref: " /api/pay/toapp/ORDER_ID ",
  45. want: "https://pay.example.com/api/pay/toapp/ORDER_ID",
  46. },
  47. {
  48. name: "http base keeps its scheme",
  49. apiBase: "http://pay.example.com",
  50. ref: "/api/pay/toapp/ORDER_ID",
  51. want: "http://pay.example.com/api/pay/toapp/ORDER_ID",
  52. },
  53. // —— 以下全部必须原样返回 ——
  54. {
  55. name: "empty stays empty",
  56. apiBase: apiBase,
  57. ref: "",
  58. want: "",
  59. },
  60. {
  61. name: "absolute https url is untouched",
  62. apiBase: apiBase,
  63. ref: "https://cashier.other.com/pay/ORDER_ID",
  64. want: "https://cashier.other.com/pay/ORDER_ID",
  65. },
  66. {
  67. name: "wechat deep link is untouched",
  68. apiBase: apiBase,
  69. ref: "weixin://wxpay/bizpayurl?pr=ABCdef",
  70. want: "weixin://wxpay/bizpayurl?pr=ABCdef",
  71. },
  72. {
  73. name: "alipay deep link is untouched",
  74. apiBase: apiBase,
  75. ref: "alipays://platformapi/startapp?saId=10000007",
  76. want: "alipays://platformapi/startapp?saId=10000007",
  77. },
  78. {
  79. name: "face-to-face wxp payload is untouched",
  80. apiBase: apiBase,
  81. ref: "wxp://f2f0Abc_dEfGhIjKlMnOpQrStU",
  82. want: "wxp://f2f0Abc_dEfGhIjKlMnOpQrStU",
  83. },
  84. {
  85. // 无前导斜杠的裸 token:把它改写成 <apiBase>/token 会毁掉一个本来
  86. // 可用的二维码载荷,比 bug 本身更糟,所以不碰。
  87. name: "opaque token without leading slash is untouched",
  88. apiBase: apiBase,
  89. ref: "OrderToken123",
  90. want: "OrderToken123",
  91. },
  92. {
  93. name: "path-like reference without leading slash is untouched",
  94. apiBase: apiBase,
  95. ref: "api/pay/toapp/ORDER_ID",
  96. want: "api/pay/toapp/ORDER_ID",
  97. },
  98. {
  99. name: "empty api base leaves the reference alone",
  100. apiBase: "",
  101. ref: "/api/pay/toapp/ORDER_ID",
  102. want: "/api/pay/toapp/ORDER_ID",
  103. },
  104. {
  105. name: "api base without scheme leaves the reference alone",
  106. apiBase: "pay.example.com",
  107. ref: "/api/pay/toapp/ORDER_ID",
  108. want: "/api/pay/toapp/ORDER_ID",
  109. },
  110. }
  111. for _, tt := range tests {
  112. tt := tt
  113. t.Run(tt.name, func(t *testing.T) {
  114. t.Parallel()
  115. if got := resolveEasyPayReturnedRef(tt.apiBase, tt.ref); got != tt.want {
  116. t.Fatalf("resolveEasyPayReturnedRef(%q, %q) = %q, want %q", tt.apiBase, tt.ref, got, tt.want)
  117. }
  118. })
  119. }
  120. }
  121. // newEasyPayMapiStub serves mapi.php with a canned body and reports the api base
  122. // the provider should be configured with. basePath lets a test prove that a
  123. // root-relative reference drops the api base's own path prefix.
  124. func newEasyPayMapiStub(t *testing.T, basePath, body string) (*EasyPay, string) {
  125. t.Helper()
  126. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  127. w.Header().Set("Content-Type", "application/json")
  128. _, _ = w.Write([]byte(body))
  129. }))
  130. t.Cleanup(server.Close)
  131. apiBase := server.URL + basePath
  132. provider, err := NewEasyPay("test-instance", map[string]string{
  133. "pid": "pid-1",
  134. "pkey": "pkey-1",
  135. "apiBase": apiBase,
  136. "notifyUrl": "https://example.com/notify",
  137. "returnUrl": "https://example.com/return",
  138. })
  139. if err != nil {
  140. t.Fatalf("NewEasyPay: %v", err)
  141. }
  142. return provider, server.URL
  143. }
  144. func TestEasyPayCreatePaymentResolvesRelativeQRCode(t *testing.T) {
  145. t.Parallel()
  146. provider, origin := newEasyPayMapiStub(t, "/xpay/epay",
  147. `{"code":1,"trade_no":"TRADE_NO","payurl":"","qrcode":"/api/pay/toapp/ORDER_ID"}`)
  148. resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
  149. OrderID: "sub2-relative-qr",
  150. Amount: "1.00",
  151. PaymentType: payment.TypeWxpay,
  152. Subject: "Relative QR",
  153. })
  154. if err != nil {
  155. t.Fatalf("CreatePayment: %v", err)
  156. }
  157. if want := origin + "/api/pay/toapp/ORDER_ID"; resp.QRCode != want {
  158. t.Fatalf("QRCode = %q, want %q (a bare path would render as text in WeChat)", resp.QRCode, want)
  159. }
  160. if resp.PayURL != "" {
  161. t.Fatalf("PayURL = %q, want empty (upstream sent none)", resp.PayURL)
  162. }
  163. if resp.TradeNo != "TRADE_NO" {
  164. t.Fatalf("TradeNo = %q, want TRADE_NO", resp.TradeNo)
  165. }
  166. }
  167. func TestEasyPayCreatePaymentResolvesRelativeMobilePayURL2(t *testing.T) {
  168. t.Parallel()
  169. provider, origin := newEasyPayMapiStub(t, "",
  170. `{"code":1,"trade_no":"TRADE_NO","payurl":"/pc/pay/ORDER_ID","payurl2":"/h5/pay/ORDER_ID"}`)
  171. resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
  172. OrderID: "sub2-relative-h5",
  173. Amount: "1.00",
  174. PaymentType: payment.TypeWxpay,
  175. Subject: "Relative H5",
  176. IsMobile: true,
  177. })
  178. if err != nil {
  179. t.Fatalf("CreatePayment: %v", err)
  180. }
  181. // payurl2 优先于 payurl 的选择语义不变,只是补全了地址。
  182. if want := origin + "/h5/pay/ORDER_ID"; resp.PayURL != want {
  183. t.Fatalf("PayURL = %q, want %q", resp.PayURL, want)
  184. }
  185. }
  186. func TestEasyPayCreatePaymentKeepsUpstreamAbsoluteAndOpaqueValues(t *testing.T) {
  187. t.Parallel()
  188. const (
  189. absoluteURL = "https://cashier.example.com/pay/ORDER_ID"
  190. deepLink = "weixin://wxpay/bizpayurl?pr=ABCdef"
  191. )
  192. provider, _ := newEasyPayMapiStub(t, "/xpay/epay",
  193. `{"code":1,"trade_no":"TRADE_NO","payurl":"`+absoluteURL+`","qrcode":"`+deepLink+`"}`)
  194. resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
  195. OrderID: "sub2-absolute",
  196. Amount: "1.00",
  197. PaymentType: payment.TypeWxpay,
  198. Subject: "Absolute",
  199. })
  200. if err != nil {
  201. t.Fatalf("CreatePayment: %v", err)
  202. }
  203. if resp.PayURL != absoluteURL {
  204. t.Fatalf("PayURL = %q, want %q unchanged", resp.PayURL, absoluteURL)
  205. }
  206. if resp.QRCode != deepLink {
  207. t.Fatalf("QRCode = %q, want %q unchanged", resp.QRCode, deepLink)
  208. }
  209. }