Files
pay/internal/provider/alipay/alipay_test.go
T

210 lines
7.2 KiB
Go

package alipay_test
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"net/url"
"sort"
"strings"
"testing"
"time"
sw "github.com/smartwalle/alipay/v3"
"github.com/wangjia/pay/internal/provider"
ali "github.com/wangjia/pay/internal/provider/alipay"
)
// 生成一对 RSA 密钥:appPriv 供 client 下单签名;aliPriv/aliPub 冒充"支付宝侧"给异步通知签名/验签。
//
// ⚠️ 与 brief 草稿的差异(执行时发现,已按 SDK 实际要求修正):
// alipay 公钥 client.LoadAliPayPublicKey 内部走 ncrypto.DecodePublicKey(...).PKIX(),
// 要求 PKIX(SubjectPublicKeyInfo)编码,不是 PKCS1 —— 用 MarshalPKCS1PublicKey 会在
// LoadAliPayPublicKey 报 "x509: failed to parse public key (use ParsePKCS1PublicKey
// instead for this key format)"。改用 x509.MarshalPKIXPublicKey。app 私钥走
// ncrypto.DecodePrivateKey(...).PKCS1(),失败再退 PKCS8,MarshalPKCS1PrivateKey 不受影响。
func genKeys(t *testing.T) (appPrivPKCS1B64, aliPrivPKCS1B64, aliPubPKIXB64 string) {
t.Helper()
app, _ := rsa.GenerateKey(rand.Reader, 2048)
aliK, _ := rsa.GenerateKey(rand.Reader, 2048)
b64 := func(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
appPrivPKCS1B64 = b64(x509.MarshalPKCS1PrivateKey(app))
aliPrivPKCS1B64 = b64(x509.MarshalPKCS1PrivateKey(aliK))
aliPubDER, err := x509.MarshalPKIXPublicKey(&aliK.PublicKey)
if err != nil {
t.Fatalf("marshal ali pub: %v", err)
}
aliPubPKIXB64 = b64(aliPubDER)
return
}
func buildClient(t *testing.T, appPriv, aliPub string) *sw.Client {
c, err := sw.New("2021000000000000", appPriv, false) // 沙箱
if err != nil {
t.Fatalf("new client: %v", err)
}
if err := c.LoadAliPayPublicKey(aliPub); err != nil {
t.Fatalf("load pub: %v", err)
}
return c
}
func TestCreateRedirect(t *testing.T) {
appPriv, _, aliPub := genKeys(t)
p := ali.New(buildClient(t, appPriv, aliPub))
sess, err := p.Create(context.Background(), provider.CreateRequest{
OutTradeNo: "PAY-1", Subject: "Pro 年付", AmountMinor: 19900, Currency: "CNY",
ReturnURL: "https://x/return",
})
if err != nil {
t.Fatalf("create: %v", err)
}
if sess.RenderType != provider.RenderRedirect || sess.ProviderRef != "PAY-1" {
t.Fatalf("session = %+v", sess)
}
u, _ := sess.Payload["url"].(string)
if !strings.Contains(u, "alipay") {
t.Fatalf("url = %q 不像收银台跳转", u)
}
}
// 用"支付宝侧"私钥给一份通知表单签名,adapter 用装的公钥验签 → 归一化 PaidEvent。
func TestVerifyCallbackRSA(t *testing.T) {
appPriv, aliPriv, aliPub := genKeys(t)
p := ali.New(buildClient(t, appPriv, aliPub))
form := url.Values{}
form.Set("out_trade_no", "PAY-1")
form.Set("trade_no", "2021AAA")
form.Set("trade_status", "TRADE_SUCCESS")
form.Set("total_amount", "199.00")
form.Set("sign_type", "RSA2")
form.Set("sign", signRSA2(t, aliPriv, form))
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Raw: []byte(form.Encode())})
if err != nil {
t.Fatalf("verify: %v", err)
}
if ev.ProviderRef != "PAY-1" || ev.Status != provider.PaidSucceeded ||
ev.PaidAmountMinor != 19900 || ev.PaidCurrency != "CNY" {
t.Fatalf("event = %+v", ev)
}
// brief 草稿的回调表单未带 gmt_payment;PaidAt 应留 nil,交 settle 按 D4-A3 兜底用收到时间。
if ev.PaidAt != nil {
t.Fatalf("未带 gmt_payment 时 PaidAt 应为 nil, got %v", ev.PaidAt)
}
}
// D4-A3:VerifyCallback 应从支付宝回调的 gmt_payment(北京时间,无时区位)取真实付款时间,
// 而不是让 settle 一律回退到"收到时间"——对账时才能跟渠道流水的付款时点对得上。
func TestVerifyCallbackRSA_PaidAtFromGmtPayment(t *testing.T) {
appPriv, aliPriv, aliPub := genKeys(t)
p := ali.New(buildClient(t, appPriv, aliPub))
form := url.Values{}
form.Set("out_trade_no", "PAY-2")
form.Set("trade_no", "2021BBB")
form.Set("trade_status", "TRADE_SUCCESS")
form.Set("total_amount", "1.00")
form.Set("gmt_payment", "2026-07-10 15:04:05")
form.Set("sign_type", "RSA2")
form.Set("sign", signRSA2(t, aliPriv, form))
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Raw: []byte(form.Encode())})
if err != nil {
t.Fatalf("verify: %v", err)
}
if ev.PaidAt == nil {
t.Fatal("带 gmt_payment 时 PaidAt 不应为 nil")
}
want := time.Date(2026, 7, 10, 15, 4, 5, 0, time.FixedZone("CST", 8*3600))
if !ev.PaidAt.Equal(want) {
t.Fatalf("PaidAt = %v, want %v", ev.PaidAt, want)
}
}
// 攻击者拿一把与装配时加载的支付宝公钥不匹配的私钥伪造回调(冒充支付宝) → 验签
// 必须失败,且绝不能返回 PaidEvent(哪怕金额/状态字段看起来"正常"也不能被当真入账)。
func TestVerifyCallbackRSA_WrongKeyFails(t *testing.T) {
appPriv, _, aliPub := genKeys(t)
p := ali.New(buildClient(t, appPriv, aliPub))
_, wrongAliPriv, _ := genKeys(t) // 另一把毫不相干的"支付宝私钥"
form := url.Values{}
form.Set("out_trade_no", "PAY-EVIL")
form.Set("trade_no", "2021EVIL")
form.Set("trade_status", "TRADE_SUCCESS")
form.Set("total_amount", "999999.00")
form.Set("sign_type", "RSA2")
form.Set("sign", signRSA2(t, wrongAliPriv, form))
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Raw: []byte(form.Encode())})
if err == nil {
t.Fatalf("want 验签失败(签名私钥与装配公钥不匹配), got event = %+v", ev)
}
if ev != nil {
t.Fatalf("验签失败时不应返回 PaidEvent, got %+v", ev)
}
}
// TRADE_CLOSED(超时未付关闭/全额退款后关闭)须归一化为 failed 终态,不能停在 pending
// 让 SyncPendingAttempts 空转到订单永远"待处理"。
func TestVerifyCallbackRSA_ClosedIsFailed(t *testing.T) {
appPriv, aliPriv, aliPub := genKeys(t)
p := ali.New(buildClient(t, appPriv, aliPub))
form := url.Values{}
form.Set("out_trade_no", "PAY-3")
form.Set("trade_no", "2021CCC")
form.Set("trade_status", "TRADE_CLOSED")
form.Set("total_amount", "1.00")
form.Set("sign_type", "RSA2")
form.Set("sign", signRSA2(t, aliPriv, form))
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Raw: []byte(form.Encode())})
if err != nil {
t.Fatalf("verify: %v", err)
}
if ev.Status != provider.PaidFailed {
t.Fatalf("TRADE_CLOSED 应归一化为 PaidFailed, got %v", ev.Status)
}
}
// signRSA2 复刻支付宝签名:排序非空参数(排除 sign/sign_type),k=v&拼接,RSA-SHA256,base64。
func signRSA2(t *testing.T, aliPrivB64 string, form url.Values) string {
t.Helper()
der, _ := base64.StdEncoding.DecodeString(aliPrivB64)
priv, err := x509.ParsePKCS1PrivateKey(der)
if err != nil {
t.Fatalf("parse ali priv: %v", err)
}
keys := make([]string, 0, len(form))
for k := range form {
if k == "sign" || k == "sign_type" || form.Get(k) == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var parts []string
for _, k := range keys {
parts = append(parts, k+"="+form.Get(k))
}
h := sha256.Sum256([]byte(strings.Join(parts, "&")))
sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, h[:])
if err != nil {
t.Fatalf("sign: %v", err)
}
return base64.StdEncoding.EncodeToString(sig)
}
var _ = pem.Encode // 避免 import 未用(若不需要 pem 可删该 import 与本行)