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

180 lines
6.8 KiB
Go

package alipay_test
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
sw "github.com/smartwalle/alipay/v3"
"github.com/wangjia/pay/internal/money"
"github.com/wangjia/pay/internal/provider"
ali "github.com/wangjia/pay/internal/provider/alipay"
)
// fakeAlipayRefund 返回一份用"支付宝侧"私钥签名的 alipay.trade.refund 响应。
func fakeAlipayRefund(t *testing.T, aliPriv *rsa.PrivateKey, fundChange string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
node := map[string]any{
"code": "10000", "msg": "Success",
"trade_no": "2021AAA", "out_trade_no": "PAY-1",
"refund_fee": "199.00", "fund_change": fundChange,
}
nodeJSON, _ := json.Marshal(node)
// 支付宝对 response node 的原文做 RSA2 签名(sign_type=RSA2)。
h := sha256.Sum256(nodeJSON)
sig, _ := rsa.SignPKCS1v15(rand.Reader, aliPriv, crypto.SHA256, h[:])
resp := map[string]any{
"alipay_trade_refund_response": json.RawMessage(nodeJSON),
"sign": base64.StdEncoding.EncodeToString(sig),
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
}
// buildRefundClient 与 alipay_test.go 的 genKeys/buildClient 同一套约定:
//
// ⚠️ 与 brief 草稿的差异(执行时发现,已按 SDK 实际要求修正,和 alipay_test.go 注释一致):
// 1. LoadAliPayPublicKey 内部走 PKIX(SubjectPublicKeyInfo)解码,不是 PKCS1 ——
// 用 x509.MarshalPKIXPublicKey 而非 MarshalPKCS1PublicKey。
// 2. smartwalle v3.2.29 没有 sw.WithGateway 这个 option;设置网关的是
// WithSandboxGateway(gateway)/WithProductionGateway(gateway)。client 用
// production=false 建(沙箱),对应用 WithSandboxGateway 把 host 指到 httptest。
func buildRefundClient(t *testing.T, gateway string, aliPub *rsa.PublicKey) *sw.Client {
t.Helper()
app, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("gen app key: %v", err)
}
c, err := sw.New(
"2021000000000000",
base64.StdEncoding.EncodeToString(x509.MarshalPKCS1PrivateKey(app)),
false,
sw.WithSandboxGateway(gateway),
)
if err != nil {
t.Fatalf("new client: %v", err)
}
aliPubDER, err := x509.MarshalPKIXPublicKey(aliPub)
if err != nil {
t.Fatalf("marshal ali pub: %v", err)
}
if err := c.LoadAliPayPublicKey(base64.StdEncoding.EncodeToString(aliPubDER)); err != nil {
t.Fatalf("load pub: %v", err)
}
return c
}
func TestAlipayRefundSyncSuccess(t *testing.T) {
aliKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("gen ali key: %v", err)
}
ts := fakeAlipayRefund(t, aliKey, "Y")
defer ts.Close()
p := ali.New(buildRefundClient(t, ts.URL, &aliKey.PublicKey))
if !p.Capabilities().SupportsRefund {
t.Fatal("alipay Capabilities.SupportsRefund 应为 true")
}
ref, status, err := p.Refund(context.Background(), "PAY-1", "rf-123", 19900, "用户申请")
if err != nil {
t.Fatalf("refund: %v", err)
}
if status != provider.PaidSucceeded || ref != "rf-123" {
t.Fatalf("refund result: ref=%s status=%s", ref, status)
}
}
// FundChange=N 表示重复退款(同 out_request_no 幂等命中,支付宝侧未再发生资金变化)——
// 仍是"受理成功",不能当失败处理,否则重试路径会把已完成的退款误判为失败。
func TestAlipayRefundIdempotentNoFundChange(t *testing.T) {
aliKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("gen ali key: %v", err)
}
ts := fakeAlipayRefund(t, aliKey, "N")
defer ts.Close()
p := ali.New(buildRefundClient(t, ts.URL, &aliKey.PublicKey))
ref, status, err := p.Refund(context.Background(), "PAY-1", "rf-123", 19900, "用户申请")
if err != nil {
t.Fatalf("refund: %v", err)
}
if status != provider.PaidSucceeded || ref != "rf-123" {
t.Fatalf("refund result: ref=%s status=%s", ref, status)
}
}
// 渠道拒绝(如金额超出可退余额)返回 code!=10000 的失败响应 —— TradeRefund 应把
// rsp.Error 包出去,refundRef 留空,状态归 PaidFailed。
// TestAlipayRefundChannelRejected 的响应必须像 fakeAlipayRefund 一样用"支付宝侧"私钥
// 签名——否则 DecodeNotification/TradeRefund 在验签阶段就报错,走的是传输错误分支
// (rsp 根本拿不到),永远到不了下面要验证的 rsp.IsFailure() 分支,原始 fixture 未签名
// 这个测试其实是假绿的(见 P4 item 2)。
func TestAlipayRefundChannelRejected(t *testing.T) {
aliKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("gen ali key: %v", err)
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
node := map[string]any{
"code": "40004", "msg": "Business Failed",
"sub_code": "ACQ.TRADE_HAS_FINISHED", "sub_msg": "交易已完结",
}
nodeJSON, _ := json.Marshal(node)
h := sha256.Sum256(nodeJSON)
sig, _ := rsa.SignPKCS1v15(rand.Reader, aliKey, crypto.SHA256, h[:])
resp := map[string]any{
"alipay_trade_refund_response": json.RawMessage(nodeJSON),
"sign": base64.StdEncoding.EncodeToString(sig),
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
defer ts.Close()
p := ali.New(buildRefundClient(t, ts.URL, &aliKey.PublicKey))
ref, status, err := p.Refund(context.Background(), "PAY-1", "rf-123", 19900, "用户申请")
if err == nil {
t.Fatal("want 渠道拒绝返回 error")
}
// 真正走到 rsp.IsFailure() 分支后,error 须满足 errors.Is(..., ErrRefundRejected)——
// 与 item 1 配套:gateway.Refund 靠这个哨兵区分"渠道确定性拒绝"(可标 failed)
// vs"结果不确定"(须留 processing)。
if !errors.Is(err, provider.ErrRefundRejected) {
t.Fatalf("err = %v want wrap ErrRefundRejected", err)
}
if status != provider.PaidFailed || ref != "" {
t.Fatalf("refund result: ref=%q status=%s", ref, status)
}
}
// TestMoneyFormatStripsTrailingZerosForAlipay 记录一个既定行为(防静默回归):
// money.Format 对整元金额去尾零,19900 分(199.00 元)格式化成 "199" 而非 "199.00"。
// 上面所有 alipay refund 用例传的 19900 分走的正是这条路径(见 p.Refund 内
// money.Format(amountMinor,"CNY") 再作为 RefundAmount 传给 alipay.trade.refund)。
// 支付宝接受这种去尾零形态的金额字符串,且与建单路径(Create → money.Format)同源、
// 已被 TestParseFormatRoundTrip 覆盖一致性,这里单独钉一行防止 alipay refund 路径
// 依赖的具体字符串形态被静默改掉。
func TestMoneyFormatStripsTrailingZerosForAlipay(t *testing.T) {
got, err := money.Format(19900, "CNY")
if err != nil {
t.Fatalf("money.Format(19900,CNY): %v", err)
}
if got != "199" {
t.Fatalf("money.Format(19900,CNY) = %q want %q(既定去尾零行为)", got, "199")
}
}