267 lines
9.6 KiB
Go
267 lines
9.6 KiB
Go
package shuncheng
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"testing"
|
|
)
|
|
|
|
func TestSignUsesDocumentedBase64LowerMD5(t *testing.T) {
|
|
params := map[string]string{
|
|
"service": "get_tdcode",
|
|
"merchant_id": "1234567890",
|
|
"third_order_id": "NO1",
|
|
"amount": "100",
|
|
"nonce_str": "abc",
|
|
"req_serial_no": "req001",
|
|
"empty": "",
|
|
"sign": "ignored",
|
|
}
|
|
|
|
got := SignStrings(params, "secret")
|
|
want := "ZTM5YTQ1NDk4YmRhODJlYTYyOWQzMGI2N2M5YzFjYjQ="
|
|
if got != want {
|
|
t.Fatalf("SignStrings() = %s, want %s", got, want)
|
|
}
|
|
|
|
baseString := SignBaseString(stringMapToAny(params))
|
|
wantBaseString := "amount=100&merchant_id=1234567890&nonce_str=abc&req_serial_no=req001&service=get_tdcode&third_order_id=NO1"
|
|
if baseString != wantBaseString {
|
|
t.Fatalf("SignBaseString() = %s, want %s", baseString, wantBaseString)
|
|
}
|
|
}
|
|
|
|
func TestSignSortsNestedObjectLikeDocs(t *testing.T) {
|
|
params := map[string]any{
|
|
"reqSerialNo": "20260318110810747",
|
|
"data": map[string]any{
|
|
"merchantId": "M1",
|
|
"applyAmount": 10000,
|
|
"reqId": "R1",
|
|
"ignored": nil,
|
|
},
|
|
}
|
|
|
|
baseString := SignBaseString(params)
|
|
wantBaseString := `data={"applyAmount":10000,"merchantId":"M1","reqId":"R1"}&reqSerialNo=20260318110810747`
|
|
if baseString != wantBaseString {
|
|
t.Fatalf("SignBaseString() = %s, want %s", baseString, wantBaseString)
|
|
}
|
|
got := Sign(params, "secret")
|
|
want := "N2VlOWY3OWJlZmU4YThiNjEwN2Y1NWYxNzI0ZjY0NTY="
|
|
if got != want {
|
|
t.Fatalf("Sign() = %s, want %s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestParsePayloadNormalizesXMLAliases(t *testing.T) {
|
|
params, err := ParsePayload([]byte(`<schc>
|
|
<resp_code><![CDATA[200]]></resp_code>
|
|
<result_code><![CDATA[0]]></result_code>
|
|
<third_order_id><![CDATA[PAY1]]></third_order_id>
|
|
<schc_order_id><![CDATA[SC1]]></schc_order_id>
|
|
<schc_refund_id><![CDATA[RF1]]></schc_refund_id>
|
|
<refund_amount><![CDATA[100]]></refund_amount>
|
|
<status><![CDATA[11]]></status>
|
|
</schc>`))
|
|
if err != nil {
|
|
t.Fatalf("ParsePayload() error = %v", err)
|
|
}
|
|
if params["provider_order_id"] != "SC1" {
|
|
t.Fatalf("provider_order_id = %q, want SC1", params["provider_order_id"])
|
|
}
|
|
if params["provider_refund_id"] != "RF1" {
|
|
t.Fatalf("provider_refund_id = %q, want RF1", params["provider_refund_id"])
|
|
}
|
|
}
|
|
|
|
func TestParsePayloadExtractsEscapedXMLFromHTML(t *testing.T) {
|
|
params, err := ParsePayload([]byte(`<html><body><schc><resp_code>200</resp_code><result_code>0</result_code><third_order_id>PAY1</third_order_id><schc_order_id>SC1</schc_order_id><jspay_url>https://pay.example/sc1</jspay_url></schc></body></html>`))
|
|
if err != nil {
|
|
t.Fatalf("ParsePayload() error = %v", err)
|
|
}
|
|
if params["provider_order_id"] != "SC1" || params["jspay_url"] != "https://pay.example/sc1" {
|
|
t.Fatalf("ParsePayload() = %#v, want extracted shuncheng xml", params)
|
|
}
|
|
}
|
|
|
|
func TestParsePayloadAcceptsEscapedURLQueryInHTMLXML(t *testing.T) {
|
|
params, err := ParsePayload([]byte(`<html><body><schc><resp_code>200</resp_code><result_code>0</result_code><third_order_id>PAY1</third_order_id><schc_order_id>SC1</schc_order_id><jspay_url>https://pay.example/sc1?mid=1&pu=2</jspay_url></schc></body></html>`))
|
|
if err != nil {
|
|
t.Fatalf("ParsePayload() error = %v", err)
|
|
}
|
|
if params["jspay_url"] != "https://pay.example/sc1?mid=1&pu=2" {
|
|
t.Fatalf("jspay_url = %q, want query decoded", params["jspay_url"])
|
|
}
|
|
}
|
|
|
|
func TestParsePayloadAcceptsJSONStringXML(t *testing.T) {
|
|
xmlText := `<?xml version="1.0" encoding="utf-16"?><schc xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"><resp_code>200</resp_code><merchant_id>5169118450</merchant_id><sign>00EFB11258D694634F727A8FCCF92290</sign><cost_time>4</cost_time><sign_type>MD5</sign_type><third_order_id>PAY1</third_order_id><jspay_url>https://pay.example/sc1?mid=1&pu=2</jspay_url><result_code>0</result_code><schc_order_id>SC1</schc_order_id></schc>`
|
|
body, err := json.Marshal(xmlText)
|
|
if err != nil {
|
|
t.Fatalf("json marshal xml text: %v", err)
|
|
}
|
|
params, err := ParsePayload(body)
|
|
if err != nil {
|
|
t.Fatalf("ParsePayload() error = %v", err)
|
|
}
|
|
if params["provider_order_id"] != "SC1" || params["jspay_url"] != "https://pay.example/sc1?mid=1&pu=2" {
|
|
t.Fatalf("ParsePayload() = %#v, want xml string decoded", params)
|
|
}
|
|
}
|
|
|
|
func TestParsePayloadTrimsBOMBeforeXML(t *testing.T) {
|
|
params, err := ParsePayload([]byte("\xef\xbb\xbf<schc><resp_code>200</resp_code><result_code>0</result_code><third_order_id>PAY1</third_order_id><schc_order_id>SC1</schc_order_id></schc>"))
|
|
if err != nil {
|
|
t.Fatalf("ParsePayload() error = %v", err)
|
|
}
|
|
if params["provider_order_id"] != "SC1" {
|
|
t.Fatalf("provider_order_id = %q, want SC1", params["provider_order_id"])
|
|
}
|
|
}
|
|
|
|
func TestParsePayloadRejectsPlainHTML(t *testing.T) {
|
|
if _, err := ParsePayload([]byte(`<html><body>invalid semicolon; separator</body></html>`)); err == nil {
|
|
t.Fatal("ParsePayload() error = nil, want unsupported html error")
|
|
}
|
|
}
|
|
|
|
func TestVerifyNotifyAcceptsDocumentedAndLegacySignatures(t *testing.T) {
|
|
client := NewClient(Config{NotifyKey: "notify-secret"})
|
|
params := map[string]string{
|
|
"merchant_id": "1234567890",
|
|
"third_order_id": "NO1",
|
|
"schc_order_id": "SC1",
|
|
"amount": "100",
|
|
"status": "2",
|
|
"sign_type": "MD5",
|
|
}
|
|
params["sign"] = SignStrings(params, "notify-secret")
|
|
if result := client.VerifyNotifyDetail(params); !result.OK {
|
|
t.Fatalf("VerifyNotifyDetail().OK = false, got=%s expected=%v", result.Got, result.Expected)
|
|
}
|
|
|
|
params["sign"] = SignStringsUpperMD5(params, "notify-secret")
|
|
if result := client.VerifyNotifyDetail(params); !result.OK {
|
|
t.Fatalf("VerifyNotifyDetail() legacy upper md5 OK = false, got=%s expected=%v", result.Got, result.Expected)
|
|
}
|
|
|
|
params["amount"] = "101"
|
|
if client.VerifyNotify(params) {
|
|
t.Fatal("VerifyNotify() = true after amount changed, want false")
|
|
}
|
|
}
|
|
|
|
func TestCreatePaymentFetchesTokenOnceAndPostsSignedJSON(t *testing.T) {
|
|
resetTokenCacheForTest()
|
|
var tokenCount int
|
|
var createCount int
|
|
var captured map[string]string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case endpointToken:
|
|
tokenCount++
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"respCode":"200","data":"token-1"}`))
|
|
case endpointSimplePay:
|
|
createCount++
|
|
if r.Header.Get("Authorization") != "Bearer token-1" {
|
|
t.Fatalf("Authorization = %q, want Bearer token-1", r.Header.Get("Authorization"))
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
|
|
t.Fatalf("decode create payment request: %v", err)
|
|
}
|
|
if captured["sign"] != SignStrings(captured, "secret-key") {
|
|
t.Fatalf("sign = %q, want recalculated %q", captured["sign"], SignStrings(captured, "secret-key"))
|
|
}
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
_, _ = w.Write([]byte(`<schc><resp_code>200</resp_code><result_code>0</result_code><third_order_id>PAY1</third_order_id><schc_order_id>SC1</schc_order_id><jspay_url>https://pay.example/sc1</jspay_url></schc>`))
|
|
default:
|
|
t.Fatalf("unexpected path %s", r.URL.Path)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(Config{
|
|
GatewayURL: server.URL,
|
|
MerchantID: "merchant-1",
|
|
SecretID: "secret-id",
|
|
SecretKey: "secret-key",
|
|
NotifyURL: "https://example.com/notify",
|
|
})
|
|
for i := 0; i < 2; i++ {
|
|
resp, err := client.CreatePayment(context.Background(), CreatePaymentRequest{
|
|
ThirdOrderID: "PAY1",
|
|
AmountCent: 100,
|
|
Body: "租号订单",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreatePayment() error = %v", err)
|
|
}
|
|
if !resp.OK || resp.ProviderOrderID != "SC1" || resp.JSPayURL == "" {
|
|
t.Fatalf("CreatePayment() response = %+v", resp)
|
|
}
|
|
}
|
|
if tokenCount != 1 {
|
|
t.Fatalf("tokenCount = %d, want 1", tokenCount)
|
|
}
|
|
if createCount != 2 {
|
|
t.Fatalf("createCount = %d, want 2", createCount)
|
|
}
|
|
if captured["notify_url"] != "https://example.com/notify" {
|
|
t.Fatalf("notify_url = %q, want configured notify url", captured["notify_url"])
|
|
}
|
|
}
|
|
|
|
func TestUnauthorizedRefreshesTokenOnce(t *testing.T) {
|
|
resetTokenCacheForTest()
|
|
var tokenCount int
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case endpointToken:
|
|
tokenCount++
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"respCode":"200","data":"token-` + strconv.Itoa(tokenCount) + `"}`))
|
|
case endpointPaymentQuery:
|
|
if r.Header.Get("Authorization") == "Bearer token-1" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if r.Header.Get("Authorization") != "Bearer token-2" {
|
|
t.Fatalf("Authorization = %q, want Bearer token-2", r.Header.Get("Authorization"))
|
|
}
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
_, _ = w.Write([]byte(`<schc><resp_code>200</resp_code><result_code>0</result_code><third_order_id>PAY1</third_order_id><schc_order_id>SC1</schc_order_id><status>2</status></schc>`))
|
|
default:
|
|
t.Fatalf("unexpected path %s", r.URL.Path)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(Config{
|
|
GatewayURL: server.URL,
|
|
MerchantID: "merchant-1",
|
|
SecretID: "secret-id",
|
|
SecretKey: "secret-key",
|
|
})
|
|
resp, err := client.QueryPayment(context.Background(), "PAY1", "")
|
|
if err != nil {
|
|
t.Fatalf("QueryPayment() error = %v", err)
|
|
}
|
|
if !resp.OK || resp.Status != "paid" || resp.ProviderOrderID != "SC1" {
|
|
t.Fatalf("QueryPayment() response = %+v", resp)
|
|
}
|
|
if tokenCount != 2 {
|
|
t.Fatalf("tokenCount = %d, want 2", tokenCount)
|
|
}
|
|
}
|
|
|
|
func resetTokenCacheForTest() {
|
|
tokenCache.Lock()
|
|
tokenCache.items = map[string]tokenEntry{}
|
|
tokenCache.Unlock()
|
|
}
|