290 lines
11 KiB
Go
290 lines
11 KiB
Go
package paymentplugins
|
|
|
|
import (
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestWeChatNativeCreateOrderSignsRequestAndReturnsQRCode(t *testing.T) {
|
|
merchantPrivate, merchantPublic := testRSAKeyPair(t)
|
|
platformPrivate, platformPublic := testRSAKeyPair(t)
|
|
fixedNow := time.Date(2026, 9, 2, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60))
|
|
config := testWeChatConfig(merchantPrivate, platformPublic)
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
if request.Method != http.MethodPost || request.URL.Path != "/v3/pay/transactions/native" {
|
|
t.Fatalf("request = %s %s", request.Method, request.URL.Path)
|
|
}
|
|
body, err := io.ReadAll(request.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(request.Header.Get("Authorization"), `mchid="1900000001"`) {
|
|
t.Fatalf("authorization = %q", request.Header.Get("Authorization"))
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if payload["out_trade_no"] != "0123456789abcdef0123456789abcdef" {
|
|
t.Fatalf("out_trade_no = %v", payload["out_trade_no"])
|
|
}
|
|
responseBody := []byte(`{"code_url":"weixin://wxpay/bizpayurl?pr=test"}`)
|
|
timestamp, nonce := fixedNow.Unix(), "response-nonce"
|
|
signature, err := rsaSHA256Sign(platformPrivate, []byte(testWeChatMessage(timestamp, nonce, responseBody)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writer.Header().Set("Wechatpay-Timestamp", "1788321600")
|
|
writer.Header().Set("Wechatpay-Nonce", nonce)
|
|
writer.Header().Set("Wechatpay-Serial", "PUB_KEY_ID_3000000001")
|
|
writer.Header().Set("Wechatpay-Signature", signature)
|
|
_, _ = writer.Write(responseBody)
|
|
}))
|
|
defer server.Close()
|
|
|
|
provider := NewWeChatProvider(server.Client(), server.URL)
|
|
provider.now = func() time.Time { return fixedNow }
|
|
checkout, err := provider.CreateOrder(context.Background(), config, CreateRequest{
|
|
MerchantOrderNo: "0123456789abcdef0123456789abcdef", Description: "100 积分",
|
|
AmountFen: 100, Currency: "CNY", ExpiresAt: fixedNow.Add(30 * time.Minute), NotifyURL: "https://merchant.example/api/notify",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if checkout.Mode != "qr_code" || !strings.HasPrefix(checkout.Value, "weixin://") || !checkout.ExpiresAt.Equal(fixedNow.Add(30*time.Minute)) {
|
|
t.Fatalf("checkout = %#v", checkout)
|
|
}
|
|
_ = merchantPublic
|
|
}
|
|
|
|
func TestWeChatNotificationVerifiesAndDecrypts(t *testing.T) {
|
|
merchantPrivate, _ := testRSAKeyPair(t)
|
|
platformPrivate, platformPublic := testRSAKeyPair(t)
|
|
fixedNow := time.Date(2026, 9, 2, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60))
|
|
config := testWeChatConfig(merchantPrivate, platformPublic)
|
|
transaction := []byte(`{"appid":"wx-app","mchid":"1900000001","out_trade_no":"0123456789abcdef0123456789abcdef","transaction_id":"420000001","trade_type":"NATIVE","trade_state":"SUCCESS","success_time":"2026-09-02T11:59:00+08:00","amount":{"total":100,"currency":"CNY"}}`)
|
|
ciphertext := encryptWeChatTestResource(t, config["apiV3Key"], "0123456789ab", "transaction", transaction)
|
|
envelope, err := json.Marshal(map[string]any{
|
|
"id": "EV-1", "event_type": "TRANSACTION.SUCCESS", "resource_type": "encrypt-resource",
|
|
"resource": map[string]any{"algorithm": "AEAD_AES_256_GCM", "ciphertext": ciphertext, "associated_data": "transaction", "nonce": "0123456789ab"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
nonce := "notification-nonce"
|
|
signature, err := rsaSHA256Sign(platformPrivate, []byte(testWeChatMessage(fixedNow.Unix(), nonce, envelope)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
headers := http.Header{
|
|
"Wechatpay-Timestamp": {"1788321600"}, "Wechatpay-Nonce": {nonce},
|
|
"Wechatpay-Serial": {"PUB_KEY_ID_3000000001"}, "Wechatpay-Signature": {signature},
|
|
}
|
|
provider := NewWeChatProvider(http.DefaultClient, "https://example.invalid")
|
|
provider.now = func() time.Time { return fixedNow }
|
|
notification, err := provider.VerifyNotification(context.Background(), config, headers, envelope)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if notification.EventID != "EV-1" || !notification.Paid || notification.AmountFen != 100 || notification.ProviderTradeNo != "420000001" {
|
|
t.Fatalf("notification = %#v", notification)
|
|
}
|
|
}
|
|
|
|
func TestWeChatMissingTradeBillUsesBillSentinel(t *testing.T) {
|
|
merchantPrivate, _ := testRSAKeyPair(t)
|
|
platformPrivate, platformPublic := testRSAKeyPair(t)
|
|
fixedNow := time.Date(2026, 9, 2, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60))
|
|
config := testWeChatConfig(merchantPrivate, platformPublic)
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
body := []byte(`{"code":"NO_STATEMENT_EXIST","message":"not ready"}`)
|
|
nonce := "bill-response-nonce"
|
|
signature, err := rsaSHA256Sign(platformPrivate, []byte(testWeChatMessage(fixedNow.Unix(), nonce, body)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writer.Header().Set("Wechatpay-Timestamp", strconv.FormatInt(fixedNow.Unix(), 10))
|
|
writer.Header().Set("Wechatpay-Nonce", nonce)
|
|
writer.Header().Set("Wechatpay-Serial", "PUB_KEY_ID_3000000001")
|
|
writer.Header().Set("Wechatpay-Signature", signature)
|
|
writer.WriteHeader(http.StatusNotFound)
|
|
_, _ = writer.Write(body)
|
|
}))
|
|
defer server.Close()
|
|
|
|
provider := NewWeChatProvider(server.Client(), server.URL)
|
|
provider.now = func() time.Time { return fixedNow }
|
|
_, err := provider.DownloadTradeBill(context.Background(), config, fixedNow.AddDate(0, 0, -1))
|
|
if !errors.Is(err, ErrTradeBillNotFound) {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAlipayPagePayBuildsSignedDesktopCheckout(t *testing.T) {
|
|
merchantPrivate, merchantPublic := testRSAKeyPair(t)
|
|
_, alipayPublic := testRSAKeyPair(t)
|
|
config := testAlipayConfig(merchantPrivate, alipayPublic)
|
|
provider := NewAlipayProvider(http.DefaultClient)
|
|
fixedNow := time.Date(2026, 9, 2, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60))
|
|
provider.now = func() time.Time { return fixedNow.Add(time.Second) }
|
|
checkout, err := provider.CreateOrder(context.Background(), config, CreateRequest{
|
|
MerchantOrderNo: "0123456789abcdef0123456789abcdef", Description: "100 积分",
|
|
AmountFen: 100, Currency: "CNY", ExpiresAt: fixedNow.Add(30 * time.Minute),
|
|
NotifyURL: "https://merchant.example/api/notify", ReturnURL: "https://merchant.example/api/return",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
parsed, err := url.Parse(checkout.Value)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
params := parsed.Query()
|
|
signature := params.Get("sign")
|
|
params.Del("sign")
|
|
if err := rsaSHA256Verify(merchantPublic, []byte(canonicalAlipayParams(params)), signature); err != nil {
|
|
t.Fatalf("checkout signature: %v", err)
|
|
}
|
|
if params.Get("method") != "alipay.trade.page.pay" || !strings.Contains(params.Get("biz_content"), `"product_code":"FAST_INSTANT_TRADE_PAY"`) || !strings.Contains(params.Get("biz_content"), `"timeout_express":"30m"`) {
|
|
t.Fatalf("params = %v", params)
|
|
}
|
|
}
|
|
|
|
func TestAlipayRejectsNonOfficialGateway(t *testing.T) {
|
|
merchantPrivate, _ := testRSAKeyPair(t)
|
|
_, alipayPublic := testRSAKeyPair(t)
|
|
config := testAlipayConfig(merchantPrivate, alipayPublic)
|
|
config["gateway"] = "https://evil.example/gateway.do"
|
|
if err := NewAlipayProvider(http.DefaultClient).ValidateConfig(config); err == nil {
|
|
t.Fatal("expected non-official gateway error")
|
|
}
|
|
}
|
|
|
|
func TestAlipayNotificationVerifiesMerchantAndAmount(t *testing.T) {
|
|
merchantPrivate, _ := testRSAKeyPair(t)
|
|
alipayPrivate, alipayPublic := testRSAKeyPair(t)
|
|
config := testAlipayConfig(merchantPrivate, alipayPublic)
|
|
values := url.Values{
|
|
"notify_id": {"notify-1"}, "app_id": {"2026000000000001"}, "seller_id": {"2088000000000001"},
|
|
"out_trade_no": {"0123456789abcdef0123456789abcdef"}, "trade_no": {"2026090200001"},
|
|
"trade_status": {"TRADE_SUCCESS"}, "total_amount": {"1.00"}, "gmt_payment": {"2026-09-02 12:00:00"},
|
|
"sign_type": {"RSA2"},
|
|
}
|
|
unsigned := cloneURLValues(values)
|
|
unsigned.Del("sign_type")
|
|
signature, err := rsaSHA256Sign(alipayPrivate, []byte(canonicalAlipayParams(unsigned)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
values.Set("sign", signature)
|
|
provider := NewAlipayProvider(http.DefaultClient)
|
|
notification, err := provider.VerifyNotification(context.Background(), config, nil, []byte(values.Encode()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if notification.EventID != "notify-1" || !notification.Paid || notification.AmountFen != 100 || notification.Currency != "CNY" {
|
|
t.Fatalf("notification = %#v", notification)
|
|
}
|
|
}
|
|
|
|
func TestParseYuanToFenUsesExactDecimalArithmetic(t *testing.T) {
|
|
for value, expected := range map[string]int64{"0.01": 1, "1": 100, "1.2": 120, "1000000.99": 100000099} {
|
|
actual, err := parseYuanToFen(value)
|
|
if err != nil || actual != expected {
|
|
t.Fatalf("parseYuanToFen(%q) = %d, %v; want %d", value, actual, err, expected)
|
|
}
|
|
}
|
|
if _, err := parseYuanToFen("1.001"); err == nil {
|
|
t.Fatal("expected precision error")
|
|
}
|
|
if _, err := parseYuanToFen("92233720368547758.08"); err == nil {
|
|
t.Fatal("expected overflow error")
|
|
}
|
|
}
|
|
|
|
func TestRSAKeyParsersAcceptRawBase64(t *testing.T) {
|
|
privateKey, publicKey := testRSAKeyPair(t)
|
|
privateDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
publicDER, err := x509.MarshalPKIXPublicKey(publicKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := parseRSAPrivateKey(base64.StdEncoding.EncodeToString(privateDER)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := parseRSAPublicKey(base64.StdEncoding.EncodeToString(publicDER)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func testRSAKeyPair(t *testing.T) (*rsa.PrivateKey, *rsa.PublicKey) {
|
|
t.Helper()
|
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return privateKey, &privateKey.PublicKey
|
|
}
|
|
|
|
func testPrivatePEM(key *rsa.PrivateKey) string {
|
|
encoded, _ := x509.MarshalPKCS8PrivateKey(key)
|
|
return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded}))
|
|
}
|
|
|
|
func testPublicPEM(key *rsa.PublicKey) string {
|
|
encoded, _ := x509.MarshalPKIXPublicKey(key)
|
|
return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: encoded}))
|
|
}
|
|
|
|
func testWeChatConfig(merchantPrivate *rsa.PrivateKey, platformPublic *rsa.PublicKey) Config {
|
|
return Config{
|
|
"appId": "wx-app", "mchId": "1900000001", "merchantSerialNo": "MERCHANT-SERIAL",
|
|
"merchantPrivateKey": testPrivatePEM(merchantPrivate), "apiV3Key": "0123456789abcdef0123456789abcdef",
|
|
"wechatPayPublicKeyId": "PUB_KEY_ID_3000000001", "wechatPayPublicKey": testPublicPEM(platformPublic),
|
|
}
|
|
}
|
|
|
|
func testAlipayConfig(merchantPrivate *rsa.PrivateKey, alipayPublic *rsa.PublicKey) Config {
|
|
return Config{
|
|
"appId": "2026000000000001", "sellerId": "2088000000000001", "merchantPrivateKey": testPrivatePEM(merchantPrivate),
|
|
"alipayPublicKey": testPublicPEM(alipayPublic), "gateway": "https://openapi.alipay.com/gateway.do",
|
|
}
|
|
}
|
|
|
|
func testWeChatMessage(timestamp int64, nonce string, body []byte) string {
|
|
return strconv.FormatInt(timestamp, 10) + "\n" + nonce + "\n" + string(body) + "\n"
|
|
}
|
|
|
|
func encryptWeChatTestResource(t *testing.T, key, nonce, associatedData string, plaintext []byte) string {
|
|
t.Helper()
|
|
block, err := aes.NewCipher([]byte(key))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return base64.StdEncoding.EncodeToString(gcm.Seal(nil, []byte(nonce), plaintext, []byte(associatedData)))
|
|
}
|