Files
NianAIGC/backend/internal/payments/wechat_refunds_test.go
T
2026-10-12 13:57:23 +08:00

139 lines
5.5 KiB
Go

package payments
import (
"bytes"
"context"
"crypto"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
"github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic"
)
func TestRefundFromSDKRequiresVerifiedFieldsAndSuccessTime(t *testing.T) {
status := refunddomestic.Status("SUCCESS")
refundID, outRefundNo, transactionID, outTradeNo := "wx-refund", "local-refund", "wx-payment", "local-payment"
amount, total, currency := int64(500), int64(1000), "CNY"
response := &refunddomestic.Refund{RefundId: &refundID, OutRefundNo: &outRefundNo,
TransactionId: &transactionID, OutTradeNo: &outTradeNo, Status: &status,
Amount: &refunddomestic.Amount{Refund: &amount, Total: &total, Currency: &currency}}
if _, err := refundFromSDK(response, "mch"); !errors.Is(err, ErrVerification) {
t.Fatal("accepted SUCCESS without success_time", err)
}
now := time.Now()
response.SuccessTime = &now
txn, err := refundFromSDK(response, "mch")
if err != nil || txn.Status != RefundSuccess || txn.MchID != "mch" || txn.AmountFen != 500 {
t.Fatalf("mapping failed: %+v %v", txn, err)
}
currency = "USD"
if _, err := refundFromSDK(response, "mch"); !errors.Is(err, ErrVerification) {
t.Fatal("accepted non-CNY refund", err)
}
}
func TestRefundNotifyURLUsesDedicatedPath(t *testing.T) {
got, err := refundNotifyURL("https://nianxxaigc.nianxx.cn/api/payments/wechat/notify")
if err != nil || got != "https://nianxxaigc.nianxx.cn/api/payments/wechat/refund-notify" {
t.Fatalf("unexpected URL: %q %v", got, err)
}
if _, err := refundNotifyURL("http://example.com/notify"); !errors.Is(err, ErrVerification) {
t.Fatal("accepted HTTP notify", err)
}
}
func TestOnlyDocumentedDefinitiveRefundErrorsAreRejected(t *testing.T) {
cases := []struct {
status int
code string
rejected bool
}{
{400, "PARAM_ERROR", true}, {400, "INVALID_REQUEST", true},
{401, "SIGN_ERROR", true}, {403, "NOT_ENOUGH", true},
{403, "USER_ACCOUNT_ABNORMAL", true}, {404, "MCH_NOT_EXISTS", true},
{404, "RESOURCE_NOT_EXISTS", true}, {429, "FREQUENCY_LIMITED", false},
{500, "SYSTEM_ERROR", false}, {403, "UNKNOWN", false},
}
for _, tc := range cases {
got := classifyRefundRejection(&core.APIError{StatusCode: tc.status, Code: tc.code, Message: "sensitive raw message"})
if (got != nil) != tc.rejected {
t.Fatalf("%d %s rejected=%v", tc.status, tc.code, got != nil)
}
if got != nil && got.Reason == "sensitive raw message" {
t.Fatal("raw provider message exposed")
}
}
if got := classifyRefundRejection(errors.New("network timeout")); got != nil {
t.Fatal("network error rejected")
}
}
func TestRefundNotificationVerifiesSignatureAndDecrypts(t *testing.T) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
const apiKey = "12345678901234567890123456789012"
const keyID = "PUB_KEY_ID_test"
provider := &wechatProvider{notifier: notify.NewNotifyHandler(apiKey,
verifiers.NewSHA256WithRSAPubkeyVerifier(keyID, privateKey.PublicKey))}
block, err := aes.NewCipher([]byte(apiKey))
if err != nil {
t.Fatal(err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
t.Fatal(err)
}
nonce := []byte("123456789012")
plaintext := []byte(`{"mchid":"mch","out_trade_no":"order","transaction_id":"wx-pay","out_refund_no":"refund","refund_id":"wx-refund","refund_status":"SUCCESS","success_time":"2026-10-12T10:00:00+08:00","amount":{"total":1000,"refund":500}}`)
resource := map[string]any{"algorithm": "AEAD_AES_256_GCM", "original_type": "refund", "ciphertext": base64.StdEncoding.EncodeToString(aead.Seal(nil, nonce, plaintext, nil)), "associated_data": "", "nonce": string(nonce)}
makeRequest := func(event string) *http.Request {
body, err := json.Marshal(map[string]any{"id": "notice", "event_type": event, "resource_type": "encrypt-resource", "resource": resource})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
const signNonce = "nonce123"
message := fmt.Sprintf("%s\n%s\n%s\n", timestamp, signNonce, body)
digest := sha256.Sum256([]byte(message))
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, digest[:])
if err != nil {
t.Fatal(err)
}
req.Header.Set("Wechatpay-Serial", keyID)
req.Header.Set("Wechatpay-Timestamp", timestamp)
req.Header.Set("Wechatpay-Nonce", signNonce)
req.Header.Set("Wechatpay-Signature", base64.StdEncoding.EncodeToString(signature))
return req
}
txn, err := provider.ParseRefundNotification(context.Background(), makeRequest("REFUND.SUCCESS"))
if err != nil || txn.Status != RefundSuccess || txn.Currency != "CNY" || txn.AmountFen != 500 || txn.MchID != "mch" {
t.Fatalf("signed refund notification rejected: %+v %v", txn, err)
}
badSignature := makeRequest("REFUND.SUCCESS")
badSignature.Header.Set("Wechatpay-Signature", "invalid")
if _, err := provider.ParseRefundNotification(context.Background(), badSignature); !errors.Is(err, ErrVerification) {
t.Fatal("accepted unsigned notification", err)
}
if _, err := provider.ParseRefundNotification(context.Background(), makeRequest("REFUND.CLOSED")); !errors.Is(err, ErrVerification) {
t.Fatal("accepted mismatched event and refund_status", err)
}
}