Files

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)))
}