在 #544、#545、#546 的原作者 merge commit 之后独立修复:保留上传 Promise、处理重复上传与复制失败、格式对齐,以及易支付网关、订单证据、签名、通知幂等与安全关单校验。同步重建六平台插件产物和包。 验证:前端 1832 tests、build/typecheck、lint、相关格式检查通过;财务 SQLite、支付、插件 RPC 与产物专项通过。后端全量出现一项未改动的素材删除 SQLite 表锁失败,单独复跑 3 次通过,保留全量未通过记录。浏览器与真实商户验收待完成。#543 因迁移历史与能力限制回退暂缓。
158 lines
7.2 KiB
Go
158 lines
7.2 KiB
Go
package paymentplugins
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestEpayRequiresExplicitGateway(t *testing.T) {
|
|
if err := NewEpayProvider(nil).ValidateConfig(Config{"pid": "merchant", "key": "test-key"}); err == nil {
|
|
t.Fatal("missing gateway must not send merchant credentials to a default third party")
|
|
}
|
|
}
|
|
|
|
func TestEpayCheckoutURLAllowlist(t *testing.T) {
|
|
for _, value := range []string{"file://host/etc/passwd", "smb://host/share", "vscode://file/tmp/a", "custom://pay", "javascript://alert(1)", "data://host/value", "https://user:pass@pay.example", "weixin:", "//pay.example"} {
|
|
if isEpayURL(value) {
|
|
t.Errorf("accepted unsafe checkout: %s", value)
|
|
}
|
|
}
|
|
for _, value := range []string{"https://pay.example/order", "weixin://wxpay/bizpayurl?pr=test", "alipays://platformapi/startapp?appId=1", "mqqapi://wallet/pay"} {
|
|
if !isEpayURL(value) {
|
|
t.Errorf("rejected payment URL: %s", value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEpayQueryRejectsIncompleteEvidence(t *testing.T) {
|
|
for _, test := range []struct{ name, response string }{
|
|
{"missing order", `{"code":1,"status":1,"money":"1.00","trade_no":"trade"}`},
|
|
{"different order", `{"code":1,"out_trade_no":"other","status":1,"money":"1.00","trade_no":"trade"}`},
|
|
{"different merchant", `{"code":1,"pid":"other","out_trade_no":"order","status":1,"money":"1.00","trade_no":"trade"}`},
|
|
{"invalid amount", `{"code":1,"out_trade_no":"order","status":1,"money":"bad","trade_no":"trade"}`},
|
|
{"missing amount", `{"code":1,"out_trade_no":"order","status":1,"trade_no":"trade"}`},
|
|
{"zero amount", `{"code":1,"out_trade_no":"order","status":1,"money":"0","trade_no":"trade"}`},
|
|
{"missing transaction", `{"code":1,"out_trade_no":"order","status":1,"money":"1.00"}`},
|
|
{"missing status", `{"code":1,"out_trade_no":"order","money":"1.00","trade_no":"trade"}`},
|
|
{"trailing JSON", `{"code":1,"out_trade_no":"order","status":1,"money":"1.00","trade_no":"trade"}{}`},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, test.response) }))
|
|
defer server.Close()
|
|
provider := NewEpayProvider(server.Client())
|
|
config := Config{"pid": "merchant", "key": "test-key", "gateway": server.URL}
|
|
if result, err := provider.QueryOrder(context.Background(), config, QueryRequest{MerchantOrderNo: "order"}); err == nil {
|
|
t.Fatalf("invalid payment evidence accepted: %+v", result)
|
|
}
|
|
if result, err := provider.CloseOrder(context.Background(), config, CloseRequest{MerchantOrderNo: "order"}); err == nil {
|
|
t.Fatalf("invalid evidence must not close an order: %+v", result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEpayQueryPreservesNumericTradeID(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, `{"code":1,"out_trade_no":"order","status":1,"money":"1.00","trade_no":1234567890123456789}`)
|
|
}))
|
|
defer server.Close()
|
|
result, err := NewEpayProvider(server.Client()).QueryOrder(context.Background(), Config{"pid": "merchant", "key": "test-key", "gateway": server.URL}, QueryRequest{MerchantOrderNo: "order"})
|
|
if err != nil || !result.Paid || result.ProviderTradeNo != "1234567890123456789" {
|
|
t.Fatalf("trade ID lost precision: %+v, %v", result, err)
|
|
}
|
|
}
|
|
|
|
func TestEpayQRCodePreferenceFallsBackToRedirectForPayURL(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, `{"code":1,"payurl":"https://pay.example/checkout"}`)
|
|
}))
|
|
defer server.Close()
|
|
checkout, err := NewEpayProvider(server.Client()).CreateOrder(context.Background(), Config{"pid": "merchant", "key": "test-key", "gateway": server.URL, "checkoutMode": "qr_code"}, CreateRequest{
|
|
MerchantOrderNo: "order", AmountFen: 100, Currency: "CNY", NotifyURL: "https://merchant.example/notify",
|
|
})
|
|
if err != nil || checkout.Mode != "redirect" {
|
|
t.Fatalf("checkout must match returned payload: %+v, %v", checkout, err)
|
|
}
|
|
}
|
|
|
|
func TestEpayNotificationRejectsDuplicateAndInvalidEvidence(t *testing.T) {
|
|
config := Config{"pid": "merchant", "key": "test-key", "gateway": "https://pay.example"}
|
|
for _, kind := range []string{"duplicate", "invalid amount", "zero amount", "missing transaction", "tampered", "pending", "closed", "missing status"} {
|
|
t.Run(kind, func(t *testing.T) {
|
|
values := map[string]string{"pid": "merchant", "out_trade_no": "order", "trade_no": "trade", "money": "1.00", "trade_status": "TRADE_SUCCESS"}
|
|
switch kind {
|
|
case "invalid amount":
|
|
values["money"] = "invalid"
|
|
case "zero amount":
|
|
values["money"] = "0"
|
|
case "missing transaction":
|
|
delete(values, "trade_no")
|
|
case "pending":
|
|
values["trade_status"] = "WAIT_BUYER_PAY"
|
|
case "closed":
|
|
values["trade_status"] = "TRADE_CLOSED"
|
|
case "missing status":
|
|
delete(values, "trade_status")
|
|
}
|
|
values["sign"] = epayMD5Sign(values, config["key"])
|
|
if kind == "tampered" {
|
|
values["money"] = "9.00"
|
|
}
|
|
form := url.Values{}
|
|
for key, value := range values {
|
|
form.Set(key, value)
|
|
}
|
|
if kind == "duplicate" {
|
|
form.Add("money", "9.00")
|
|
}
|
|
if _, err := NewEpayProvider(nil).VerifyNotification(context.Background(), config, nil, []byte(form.Encode())); err == nil {
|
|
t.Fatal("invalid notification accepted")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEpayV2NotificationAndUnsupportedClose(t *testing.T) {
|
|
privateKey, _ := testRSAKeyPair(t)
|
|
config := Config{"pid": "merchant", "version": "1", "gateway": "https://pay.example", "privateKey": testPrivatePEM(privateKey), "platformPublicKey": testPublicPEM(&privateKey.PublicKey)}
|
|
values := map[string]string{"pid": "merchant", "out_trade_no": "order", "trade_no": "trade", "money": "1.00", "trade_status": "TRADE_SUCCESS", "sign_type": "RSA"}
|
|
signature, err := epayRSASign(values, config["privateKey"])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
values["sign"] = signature
|
|
form := url.Values{}
|
|
for key, value := range values {
|
|
form.Set(key, value)
|
|
}
|
|
provider := NewEpayProvider(nil)
|
|
notification, err := provider.VerifyNotification(context.Background(), config, nil, []byte(form.Encode()))
|
|
if err != nil || !notification.Paid || notification.AmountFen != 100 {
|
|
t.Fatalf("valid RSA notification rejected: %+v, %v", notification, err)
|
|
}
|
|
form.Set("money", "9.00")
|
|
if _, err := provider.VerifyNotification(context.Background(), config, nil, []byte(form.Encode())); err == nil {
|
|
t.Fatal("tampered RSA notification accepted")
|
|
}
|
|
if result, err := provider.CloseOrder(context.Background(), config, CloseRequest{MerchantOrderNo: "order"}); err == nil || result.Closed {
|
|
t.Fatalf("unsupported V2 close must not report success: %+v, %v", result, err)
|
|
}
|
|
if _, err := epayRSASign(values, "invalid-key"); err == nil {
|
|
t.Fatal("signing error must not be swallowed")
|
|
}
|
|
}
|
|
|
|
func TestEpayTransportErrorsDoNotExposeMerchantKey(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
server.Close()
|
|
_, err := NewEpayProvider(server.Client()).QueryOrder(context.Background(), Config{"pid": "merchant", "key": "secret-marker", "gateway": server.URL}, QueryRequest{MerchantOrderNo: "order"})
|
|
if err == nil || strings.Contains(err.Error(), "secret-marker") {
|
|
t.Fatalf("unsafe transport error: %v", err)
|
|
}
|
|
}
|