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) } } } // TestBuildIOSRefundQueryResponse 校验 iOS 退款问询应答结构: // 必须带 ErrCode/ErrMsg 并包一层 IosRefundQueryResponse,字段名与官方一致。 // 回归防护:早期实现返回平铺的 {result_code, evidence},Apple 侧无法解析。 func TestBuildIOSRefundQueryResponse(t *testing.T) { body := BuildIOSRefundQueryResponse(1, "拒绝退款", "订单 OD1 发放的权益已被使用") var got struct { ErrCode int `json:"ErrCode"` ErrMsg string `json:"ErrMsg"` Resp struct { ResultCode int `json:"result_code"` ResultInfo string `json:"result_info"` Evidence string `json:"evidence"` } `json:"IosRefundQueryResponse"` } if err := json.Unmarshal(body, &got); err != nil { t.Fatalf("unmarshal ios refund query response: %v, body=%s", err, body) } if got.ErrCode != 0 || got.ErrMsg != "success" { t.Fatalf("ErrCode/ErrMsg = %d/%q, want 0/success", got.ErrCode, got.ErrMsg) } if got.Resp.ResultCode != 1 || got.Resp.ResultInfo != "拒绝退款" || got.Resp.Evidence == "" { t.Fatalf("IosRefundQueryResponse mismatch: %+v", got.Resp) } } // 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) } } // TestXPayRefundNotifyOfficialPayload 官方退款推送报文:订单号取 MchOrderId、退款单号取 MchRefundId、 // 结果由 RetCode=0 判定为成功(回归防护:早期误用 OutTradeNo/RefundStatus 导致取不到订单号而静默丢单)。 func TestXPayRefundNotifyOfficialPayload(t *testing.T) { raw := []byte(`{ "ToUserName":"gh_test", "FromUserName":"official_openid", "CreateTime":1789500611, "MsgType":"event", "Event":"xpay_refund_notify", "OpenId":"o_user", "WxRefundId":"wx_refund_1", "MchRefundId":"RF20260916033011abcdef", "WxOrderId":"wx_order_1", "MchOrderId":"OD20260916030000fc1cfee91b3748a2", "RefundFee":990, "RetCode":0, "RetMsg":"", "RefundStartTimestamp":1789500600, "RefundSuccTimestamp":1789500611, "WxpayRefundTransactionId":"wxpay_refund_1", "RetryTimes":0 }`) var refund XPayRefundNotify if err := json.Unmarshal(raw, &refund); err != nil { t.Fatalf("unmarshal refund notify: %v", err) } if got := refund.OrderNo(); got != "OD20260916030000fc1cfee91b3748a2" { t.Fatalf("OrderNo() = %q", got) } if got := refund.RefundNo(); got != "RF20260916033011abcdef" { t.Fatalf("RefundNo() = %q", got) } if refund.RefundFee != 990 { t.Fatalf("RefundFee = %d, want 990", refund.RefundFee) } succ, determinable := refund.Succeeded() if !determinable || !succ { t.Fatalf("Succeeded() = (%v, %v), want (true, true)", succ, determinable) } } // TestXPayRefundNotifyFailurePayload 官方失败报文:RetCode 非 0 即退款失败,原因在 RetMsg。 func TestXPayRefundNotifyFailurePayload(t *testing.T) { raw := []byte(`{ "Event":"xpay_refund_notify", "OpenId":"o_user", "MchOrderId":"OD1", "MchRefundId":"RF1", "RefundFee":990, "RetCode":268490005, "RetMsg":"已退款不可再退" }`) var refund XPayRefundNotify if err := json.Unmarshal(raw, &refund); err != nil { t.Fatalf("unmarshal refund notify: %v", err) } succ, determinable := refund.Succeeded() if !determinable || succ { t.Fatalf("Succeeded() = (%v, %v), want (false, true)", succ, determinable) } if refund.RetMsg == "" { t.Fatal("RetMsg should be preserved for fail_reason") } } // TestXPayRefundNotifyLegacyPayload 旧版式报文(OutTradeNo + RefundStatus)仍可解析并判定(大小写不敏感)。 func TestXPayRefundNotifyLegacyPayload(t *testing.T) { raw := []byte(`{ "Event":"xpay_refund_notify", "OutTradeNo":"OD-LEGACY", "RefundOrderId":"RF-LEGACY", "RefundStatus":"success", "RefundFee":100 }`) var refund XPayRefundNotify if err := json.Unmarshal(raw, &refund); err != nil { t.Fatalf("unmarshal refund notify: %v", err) } if got := refund.OrderNo(); got != "OD-LEGACY" { t.Fatalf("OrderNo() = %q, want OD-LEGACY", got) } if got := refund.RefundNo(); got != "RF-LEGACY" { t.Fatalf("RefundNo() = %q, want RF-LEGACY", got) } succ, determinable := refund.Succeeded() if !determinable || !succ { t.Fatalf("Succeeded() = (%v, %v), want (true, true)", succ, determinable) } } // TestXPayRefundNotifyUndeterminable 报文未携带 RetCode 与 RefundStatus 时必须报「不可判定」, // 调用方据此应答失败触发微信重试(而非静默按成功处理)。 func TestXPayRefundNotifyUndeterminable(t *testing.T) { raw := []byte(`{"Event":"xpay_refund_notify","MchOrderId":"OD1","RefundFee":100}`) var refund XPayRefundNotify if err := json.Unmarshal(raw, &refund); err != nil { t.Fatalf("unmarshal refund notify: %v", err) } succ, determinable := refund.Succeeded() if determinable || succ { t.Fatalf("Succeeded() = (%v, %v), want (false, false)", succ, determinable) } if got := refund.RefundNo(); got != "" { t.Fatalf("RefundNo() = %q, want empty", got) } } // TestXPayIOSRefundQueryOrderNoFallback iOS 退款问询推送订单号:OutTradeNo 优先,为空回退 MchOrderId。 func TestXPayIOSRefundQueryOrderNoFallback(t *testing.T) { withOutTradeNo := XPayIOSRefundQueryNotify{XPayNotifyBase: XPayNotifyBase{OutTradeNo: "OD-A"}, MchOrderID: "OD-B"} if got := withOutTradeNo.OrderNo(); got != "OD-A" { t.Fatalf("OrderNo() = %q, want OD-A (OutTradeNo 优先)", got) } onlyMch := XPayIOSRefundQueryNotify{MchOrderID: "OD-B"} if got := onlyMch.OrderNo(); got != "OD-B" { t.Fatalf("OrderNo() = %q, want OD-B (回退 MchOrderId)", got) } } // 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") } }