mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-02 00:30:32 +00:00
createAPIPayment 把上游 mapi.php 返回的 payurl/payurl2/qrcode 原样落库。 EasyPay 兼容上游对该字段并不统一:部分实现回站点根相对路径(如 `/api/pay/toapp/<order>`)而非完整地址。而链路上没有任何一层修复它—— sanitizeCreatePaymentResponseDetails 只剥 NUL 字节就写进 pay_url/qr_code, 前端 PaymentQRCodeView.renderQR 又把 qr_code 直接喂给 QRCode.toCanvas。 结果二维码的载荷是一段裸路径:微信扫出来是文本,pay_url 则落到网关自己的 域名下 404,用户无法进入上游收银台。 同文件的 createRedirectPayment 本就自行拼绝对地址(apiBase + "/submit.php?"), 只有 mapi.php 这一支透传;Alipay provider 也早已写明「Setting it as QRCode would let the frontend render an unscannable image」并拒绝产出不可扫的值。 本提交让 EasyPay 与这两处对齐:按实例配置的 apiBase 解析上游回传的引用。 仅补全以 "/" 开头的引用。带 scheme 的一律原样返回,覆盖绝对 https:// 收银台 地址与 weixin:// / wxp:// / alipays:// 等 app 深链;无前导斜杠的引用同样不动, 因为二维码载荷常是不透明 token,把 "OrderToken123" 改写成 "<apiBase>/OrderToken123" 会毁掉一个当前可用的载荷,比本 bug 更糟。 payurl2 优先于 payurl 的选择语义保持不变。 Fixes #6292
229 lines
6.9 KiB
Go
229 lines
6.9 KiB
Go
package provider
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/payment"
|
|
)
|
|
|
|
// issue #6292: mapi.php 上游若回相对形式的 payurl/payurl2/qrcode,网关原样
|
|
// 落库,前端把 qr_code 直接喂给 QRCode.toCanvas,扫出来是一段裸路径;pay_url 则在
|
|
// 网关自己的域名下 404。这里锁定归一化规则与全部「不得改写」的边界。
|
|
|
|
func TestResolveEasyPayReturnedRef(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const apiBase = "https://pay.example.com/xpay/epay"
|
|
|
|
tests := []struct {
|
|
name string
|
|
apiBase string
|
|
ref string
|
|
want string
|
|
}{
|
|
{
|
|
// issue 复现主线:站点根相对路径按 apiBase 的 origin 补全,
|
|
// apiBase 自身的 /xpay/epay 前缀不参与(根相对语义)。
|
|
name: "root relative path resolves against origin",
|
|
apiBase: apiBase,
|
|
ref: "/api/pay/toapp/ORDER_ID",
|
|
want: "https://pay.example.com/api/pay/toapp/ORDER_ID",
|
|
},
|
|
{
|
|
name: "root relative path keeps query string",
|
|
apiBase: apiBase,
|
|
ref: "/api/pay/toapp?oid=ORDER_ID&t=1",
|
|
want: "https://pay.example.com/api/pay/toapp?oid=ORDER_ID&t=1",
|
|
},
|
|
{
|
|
name: "protocol relative reference inherits base scheme",
|
|
apiBase: apiBase,
|
|
ref: "//cashier.example.com/pay/ORDER_ID",
|
|
want: "https://cashier.example.com/pay/ORDER_ID",
|
|
},
|
|
{
|
|
name: "surrounding whitespace does not defeat resolution",
|
|
apiBase: apiBase,
|
|
ref: " /api/pay/toapp/ORDER_ID ",
|
|
want: "https://pay.example.com/api/pay/toapp/ORDER_ID",
|
|
},
|
|
{
|
|
name: "http base keeps its scheme",
|
|
apiBase: "http://pay.example.com",
|
|
ref: "/api/pay/toapp/ORDER_ID",
|
|
want: "http://pay.example.com/api/pay/toapp/ORDER_ID",
|
|
},
|
|
// —— 以下全部必须原样返回 ——
|
|
{
|
|
name: "empty stays empty",
|
|
apiBase: apiBase,
|
|
ref: "",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "absolute https url is untouched",
|
|
apiBase: apiBase,
|
|
ref: "https://cashier.other.com/pay/ORDER_ID",
|
|
want: "https://cashier.other.com/pay/ORDER_ID",
|
|
},
|
|
{
|
|
name: "wechat deep link is untouched",
|
|
apiBase: apiBase,
|
|
ref: "weixin://wxpay/bizpayurl?pr=ABCdef",
|
|
want: "weixin://wxpay/bizpayurl?pr=ABCdef",
|
|
},
|
|
{
|
|
name: "alipay deep link is untouched",
|
|
apiBase: apiBase,
|
|
ref: "alipays://platformapi/startapp?saId=10000007",
|
|
want: "alipays://platformapi/startapp?saId=10000007",
|
|
},
|
|
{
|
|
name: "face-to-face wxp payload is untouched",
|
|
apiBase: apiBase,
|
|
ref: "wxp://f2f0Abc_dEfGhIjKlMnOpQrStU",
|
|
want: "wxp://f2f0Abc_dEfGhIjKlMnOpQrStU",
|
|
},
|
|
{
|
|
// 无前导斜杠的裸 token:把它改写成 <apiBase>/token 会毁掉一个本来
|
|
// 可用的二维码载荷,比 bug 本身更糟,所以不碰。
|
|
name: "opaque token without leading slash is untouched",
|
|
apiBase: apiBase,
|
|
ref: "OrderToken123",
|
|
want: "OrderToken123",
|
|
},
|
|
{
|
|
name: "path-like reference without leading slash is untouched",
|
|
apiBase: apiBase,
|
|
ref: "api/pay/toapp/ORDER_ID",
|
|
want: "api/pay/toapp/ORDER_ID",
|
|
},
|
|
{
|
|
name: "empty api base leaves the reference alone",
|
|
apiBase: "",
|
|
ref: "/api/pay/toapp/ORDER_ID",
|
|
want: "/api/pay/toapp/ORDER_ID",
|
|
},
|
|
{
|
|
name: "api base without scheme leaves the reference alone",
|
|
apiBase: "pay.example.com",
|
|
ref: "/api/pay/toapp/ORDER_ID",
|
|
want: "/api/pay/toapp/ORDER_ID",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := resolveEasyPayReturnedRef(tt.apiBase, tt.ref); got != tt.want {
|
|
t.Fatalf("resolveEasyPayReturnedRef(%q, %q) = %q, want %q", tt.apiBase, tt.ref, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// newEasyPayMapiStub serves mapi.php with a canned body and reports the api base
|
|
// the provider should be configured with. basePath lets a test prove that a
|
|
// root-relative reference drops the api base's own path prefix.
|
|
func newEasyPayMapiStub(t *testing.T, basePath, body string) (*EasyPay, string) {
|
|
t.Helper()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(body))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
|
|
apiBase := server.URL + basePath
|
|
provider, err := NewEasyPay("test-instance", map[string]string{
|
|
"pid": "pid-1",
|
|
"pkey": "pkey-1",
|
|
"apiBase": apiBase,
|
|
"notifyUrl": "https://example.com/notify",
|
|
"returnUrl": "https://example.com/return",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewEasyPay: %v", err)
|
|
}
|
|
return provider, server.URL
|
|
}
|
|
|
|
func TestEasyPayCreatePaymentResolvesRelativeQRCode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
provider, origin := newEasyPayMapiStub(t, "/xpay/epay",
|
|
`{"code":1,"trade_no":"TRADE_NO","payurl":"","qrcode":"/api/pay/toapp/ORDER_ID"}`)
|
|
|
|
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
|
OrderID: "sub2-relative-qr",
|
|
Amount: "1.00",
|
|
PaymentType: payment.TypeWxpay,
|
|
Subject: "Relative QR",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreatePayment: %v", err)
|
|
}
|
|
if want := origin + "/api/pay/toapp/ORDER_ID"; resp.QRCode != want {
|
|
t.Fatalf("QRCode = %q, want %q (a bare path would render as text in WeChat)", resp.QRCode, want)
|
|
}
|
|
if resp.PayURL != "" {
|
|
t.Fatalf("PayURL = %q, want empty (upstream sent none)", resp.PayURL)
|
|
}
|
|
if resp.TradeNo != "TRADE_NO" {
|
|
t.Fatalf("TradeNo = %q, want TRADE_NO", resp.TradeNo)
|
|
}
|
|
}
|
|
|
|
func TestEasyPayCreatePaymentResolvesRelativeMobilePayURL2(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
provider, origin := newEasyPayMapiStub(t, "",
|
|
`{"code":1,"trade_no":"TRADE_NO","payurl":"/pc/pay/ORDER_ID","payurl2":"/h5/pay/ORDER_ID"}`)
|
|
|
|
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
|
OrderID: "sub2-relative-h5",
|
|
Amount: "1.00",
|
|
PaymentType: payment.TypeWxpay,
|
|
Subject: "Relative H5",
|
|
IsMobile: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreatePayment: %v", err)
|
|
}
|
|
// payurl2 优先于 payurl 的选择语义不变,只是补全了地址。
|
|
if want := origin + "/h5/pay/ORDER_ID"; resp.PayURL != want {
|
|
t.Fatalf("PayURL = %q, want %q", resp.PayURL, want)
|
|
}
|
|
}
|
|
|
|
func TestEasyPayCreatePaymentKeepsUpstreamAbsoluteAndOpaqueValues(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
absoluteURL = "https://cashier.example.com/pay/ORDER_ID"
|
|
deepLink = "weixin://wxpay/bizpayurl?pr=ABCdef"
|
|
)
|
|
provider, _ := newEasyPayMapiStub(t, "/xpay/epay",
|
|
`{"code":1,"trade_no":"TRADE_NO","payurl":"`+absoluteURL+`","qrcode":"`+deepLink+`"}`)
|
|
|
|
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
|
OrderID: "sub2-absolute",
|
|
Amount: "1.00",
|
|
PaymentType: payment.TypeWxpay,
|
|
Subject: "Absolute",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreatePayment: %v", err)
|
|
}
|
|
if resp.PayURL != absoluteURL {
|
|
t.Fatalf("PayURL = %q, want %q unchanged", resp.PayURL, absoluteURL)
|
|
}
|
|
if resp.QRCode != deepLink {
|
|
t.Fatalf("QRCode = %q, want %q unchanged", resp.QRCode, deepLink)
|
|
}
|
|
}
|