Files
zt-closet-backend/pkg/wechat/virtualpay_test.go
T
2026-09-14 01:52:32 +08:00

252 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package wechat
import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"sort"
"strings"
"testing"
)
// TestHMACSHA256Hex 用 RFC 4231 测试向量校验 HMAC-SHA256 实现。
func TestHMACSHA256Hex(t *testing.T) {
key := strings.Repeat("\x0b", 20) // 0x0b * 20
data := "Hi There"
want := "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
if got := hmacSHA256Hex(key, data); got != want {
t.Fatalf("hmacSHA256Hex mismatch: got %s, want %s", got, want)
}
}
// TestBuildPaymentSign 校验 signData 字典序、paySig 与 signature 的内部一致性。
func TestBuildPaymentSign(t *testing.T) {
const (
appKey = "test-app-key"
sessionKey = "test-session-key"
offerID = "wx1234567890"
productID = "monthly_vip"
outTradeNo = "OD20260908120000abcdef"
attach = "uid=42"
)
virtualPayMu.Lock()
defaultVirtualPayClient = &virtualPayClient{appKey: appKey, env: XPayEnvProd}
virtualPayMu.Unlock()
defer func() {
virtualPayMu.Lock()
defaultVirtualPayClient = nil
virtualPayMu.Unlock()
}()
sign, err := BuildPaymentSign(offerID, productID, outTradeNo, attach, sessionKey, 1900)
if err != nil {
t.Fatalf("BuildPaymentSign error: %v", err)
}
// signData 字段必须按字典序排列(微信侧按字典序重组验签)。
wantOrder := []string{"attach", "buyQuantity", "currencyType", "env", "goodsPrice", "offerId", "outTradeNo", "productId"}
last := -1
for _, key := range wantOrder {
idx := strings.Index(sign.SignData, `"`+key+`"`)
if idx < 0 {
t.Fatalf("signData missing key %q: %s", key, sign.SignData)
}
if idx < last {
t.Fatalf("signData key order wrong at %q: %s", key, sign.SignData)
}
last = idx
}
// signData 必须是合法 JSON,且 offerId 为字符串。
var m map[string]any
if err := json.Unmarshal([]byte(sign.SignData), &m); err != nil {
t.Fatalf("signData is not valid JSON: %v", err)
}
if _, ok := m["offerId"].(string); !ok {
t.Fatalf("offerId must be string, got %T", m["offerId"])
}
if v, ok := m["goodsPrice"].(float64); !ok || v != 1900 {
t.Fatalf("goodsPrice mismatch: %v", m["goodsPrice"])
}
// paySig = hmac(appKey, "requestVirtualPayment&"+signData);signature = hmac(sessionKey, signData)。
wantPaySig := hmacSHA256Hex(appKey, requestVirtualPaymentURI+"&"+sign.SignData)
wantSignature := hmacSHA256Hex(sessionKey, sign.SignData)
if sign.PaySig != wantPaySig {
t.Fatalf("paySig mismatch: got %s, want %s", sign.PaySig, wantPaySig)
}
if sign.Signature != wantSignature {
t.Fatalf("signature mismatch: got %s, want %s", sign.Signature, wantSignature)
}
}
// TestCheckURLSignature 校验消息推送 URL 验签(SHA1 字典序拼接)。
func TestCheckURLSignature(t *testing.T) {
token := "my-token"
timestamp := "1756000000"
nonce := "abc123"
// 独立计算期望值:["1756000000","abc123","my-token"] 字典序拼接后做 SHA1。
arr := []string{token, timestamp, nonce}
sort.Strings(arr)
sum := sha1.Sum([]byte(arr[0] + arr[1] + arr[2]))
wantSig := hex.EncodeToString(sum[:])
if !CheckURLSignature(token, timestamp, nonce, wantSig) {
t.Fatal("CheckURLSignature should pass with correct signature")
}
if CheckURLSignature(token, timestamp, nonce, "bad") {
t.Fatal("CheckURLSignature should fail with wrong signature")
}
if CheckURLSignature("", timestamp, nonce, wantSig) {
t.Fatal("CheckURLSignature should fail with empty token")
}
}
// TestParseNotifyAndResponse 校验发货推送解析与应答构造。
func TestParseNotifyAndResponse(t *testing.T) {
raw := []byte(`{
"ToUserName":"gh_test",
"FromUserName":"official_openid",
"CreateTime":1756000000,
"MsgType":"event",
"Event":"xpay_goods_deliver_notify",
"OpenId":"o_user",
"OutTradeNo":"OD20260908120000abcdef",
"Env":0,
"WeChatPayInfo":{"MchOrderNo":"mch_123","TransactionId":"wx_123","PaidTime":1756000000},
"GoodsInfo":{"ProductId":"monthly_vip","Quantity":1,"OrigPrice":1900,"ActualPrice":1900,"Attach":"uid=42"}
}`)
base, err := ParseNotify(raw)
if err != nil {
t.Fatalf("ParseNotify error: %v", err)
}
if base.Event != EventGoodsDeliverNotify {
t.Fatalf("unexpected event: %s", base.Event)
}
if base.OutTradeNo != "OD20260908120000abcdef" {
t.Fatalf("unexpected out_trade_no: %s", base.OutTradeNo)
}
var deliver XPayGoodsDeliverNotify
if err := json.Unmarshal(raw, &deliver); err != nil {
t.Fatalf("unmarshal deliver notify: %v", err)
}
if deliver.GoodsInfo.ProductID != "monthly_vip" || deliver.GoodsInfo.ActualPrice != 1900 {
t.Fatalf("deliver goods info mismatch: %+v", deliver.GoodsInfo)
}
okBody := BuildNotifyResponse(true)
if !strings.Contains(string(okBody), `"ErrCode":0`) {
t.Fatalf("success response missing ErrCode 0: %s", okBody)
}
failBody := BuildNotifyResponse(false)
if !strings.Contains(string(failBody), `"ErrCode":1`) {
t.Fatalf("fail response missing ErrCode 1: %s", failBody)
}
}
// TestIsOrderPaidStatus 校验订单支付状态白名单判断:仅「已支付及之后」为已支付,
// 未支付/已退款/已关闭等一律按未支付(fail-closed)。
func TestIsOrderPaidStatus(t *testing.T) {
cases := []struct {
status int
want bool
}{
{XPayOrderStatusPaid, true},
{XPayOrderStatusProviding, true},
{XPayOrderStatusProvided, true},
{XPayOrderStatusInit, false},
{XPayOrderStatusCreated, false},
{XPayOrderStatusRefunded, false},
{XPayOrderStatusClosed, false},
{XPayOrderStatusRefundFailed, false},
}
for _, c := range cases {
if got := IsOrderPaidStatus(c.status); got != c.want {
t.Fatalf("IsOrderPaidStatus(%d) = %v, want %v", c.status, got, c.want)
}
}
}
// TestIsOrderRefundedStatus 校验退款状态白名单判断:仅已退款/用户退款完成为已退款。
func TestIsOrderRefundedStatus(t *testing.T) {
cases := []struct {
status int
want bool
}{
{XPayOrderStatusRefunded, true},
{XPayOrderStatusUserRefunded, true},
{XPayOrderStatusPaid, false},
{XPayOrderStatusRefundFailed, false},
{XPayOrderStatusCreated, false},
}
for _, c := range cases {
if got := IsOrderRefundedStatus(c.status); got != c.want {
t.Fatalf("IsOrderRefundedStatus(%d) = %v, want %v", c.status, got, c.want)
}
}
}
// TestXPayOrderInfoUnmarshal 校验 query_order 响应解析:订单字段包在 order 子对象内,status 为数字枚举。
// 回归防护:早期实现把字段平铺在顶层且 status 定义为 string,导致反查恒失败、订单永远停在待支付。
func TestXPayOrderInfoUnmarshal(t *testing.T) {
raw := []byte(`{
"errcode": 0,
"errmsg": "",
"order": {
"order_id": "OD20260914014559fc1cfee91b3748a2",
"wx_order_id": "wx_order_1",
"status": 2,
"order_fee": 990,
"paid_fee": 990,
"order_type": 0,
"refund_fee": 0,
"left_fee": 990,
"env_type": 2,
"biz_meta": "monthly_vip"
}
}`)
var info XPayOrderInfo
if err := json.Unmarshal(raw, &info); err != nil {
t.Fatalf("unmarshal query_order: %v", err)
}
if info.Order.Status != XPayOrderStatusPaid {
t.Fatalf("status = %d, want %d", info.Order.Status, XPayOrderStatusPaid)
}
if info.Order.PaidFee != 990 {
t.Fatalf("paid_fee = %d, want 990", info.Order.PaidFee)
}
if info.Order.OrderID != "OD20260914014559fc1cfee91b3748a2" {
t.Fatalf("order_id = %q", info.Order.OrderID)
}
if !IsOrderPaidStatus(info.Order.Status) {
t.Fatalf("IsOrderPaidStatus(%d) = false, want true", info.Order.Status)
}
}
// TestResolveEnvKey 校验环境自动选择规则:只填现网/只填沙箱/两把都填/两把都空。
func TestResolveEnvKey(t *testing.T) {
// 只填现网 -> 现网
key, env, err := resolveEnvKey("prod-key", "")
if err != nil || key != "prod-key" || env != XPayEnvProd {
t.Fatalf("only env key: key=%q env=%d err=%v", key, env, err)
}
// 只填沙箱 -> 沙箱
key, env, err = resolveEnvKey("", "sandbox-key")
if err != nil || key != "sandbox-key" || env != XPayEnvSandbox {
t.Fatalf("only sandbox key: key=%q env=%d err=%v", key, env, err)
}
// 两把都填 -> 现网优先
key, env, err = resolveEnvKey("prod-key", "sandbox-key")
if err != nil || key != "prod-key" || env != XPayEnvProd {
t.Fatalf("both keys: key=%q env=%d err=%v", key, env, err)
}
// 两把都空 -> 报错
if _, _, err := resolveEnvKey("", ""); err == nil {
t.Fatal("empty keys should return error")
}
}