353 lines
12 KiB
Go
353 lines
12 KiB
Go
//go:build unit
|
|
|
|
package provider
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/payment"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestNewAirwallexValidatesConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := NewAirwallex("1", map[string]string{
|
|
"clientId": "cid",
|
|
"apiKey": "key",
|
|
"webhookSecret": "secret",
|
|
"apiBase": "https://evil.example.com/api/v1",
|
|
})
|
|
require.ErrorContains(t, err, "apiBase host")
|
|
|
|
_, err = NewAirwallex("1", map[string]string{
|
|
"clientId": "cid",
|
|
"apiKey": "key",
|
|
"webhookSecret": "secret",
|
|
"apiBase": airwallexDemoAPIBase,
|
|
"countryCode": "C1",
|
|
})
|
|
require.ErrorContains(t, err, "countryCode")
|
|
|
|
prov, err := NewAirwallex("1", map[string]string{
|
|
"clientId": "cid",
|
|
"apiKey": "key",
|
|
"webhookSecret": "secret",
|
|
"apiBase": airwallexDemoAPIBase,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, payment.TypeAirwallex, prov.ProviderKey())
|
|
require.Equal(t, []payment.PaymentType{payment.TypeAirwallex}, prov.SupportedTypes())
|
|
require.Equal(t, payment.DefaultPaymentCurrency, prov.config["currency"])
|
|
require.Equal(t, airwallexDefaultCountry, prov.config["countryCode"])
|
|
}
|
|
|
|
func TestAirwallexCreatePaymentUsesServerAmountAndStableRequestID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var createRequests []airwallexCreatePaymentIntentRequest
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/v1/authentication/login":
|
|
require.Equal(t, "cid", r.Header.Get("x-client-id"))
|
|
require.Equal(t, "key", r.Header.Get("x-api-key"))
|
|
_, _ = w.Write([]byte(`{"token":"token-1","expires_at":"2099-01-01T00:00:00Z"}`))
|
|
case "/api/v1/pa/payment_intents/create":
|
|
require.Equal(t, "Bearer token-1", r.Header.Get("Authorization"))
|
|
body, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(body), `"amount":12.34`)
|
|
var payload airwallexCreatePaymentIntentRequest
|
|
require.NoError(t, json.Unmarshal(body, &payload))
|
|
createRequests = append(createRequests, payload)
|
|
_, _ = w.Write([]byte(`{"id":"int_123","client_secret":"secret_123","amount":12.34,"currency":"CNY","merchant_order_id":"sub2_order","status":"REQUIRES_PAYMENT_METHOD"}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
prov := mustTestAirwallexProvider(t, server)
|
|
resp, err := prov.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
|
OrderID: "sub2_order",
|
|
Amount: "12.34",
|
|
ReturnURL: "https://merchant.example.com/payment/result",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "int_123", resp.TradeNo)
|
|
require.Equal(t, "secret_123", resp.ClientSecret)
|
|
require.Equal(t, "int_123", resp.IntentID)
|
|
require.Equal(t, "CNY", resp.Currency)
|
|
require.Equal(t, "CN", resp.CountryCode)
|
|
require.Equal(t, "demo", resp.PaymentEnv)
|
|
require.Len(t, createRequests, 1)
|
|
require.Equal(t, "12.34", createRequests[0].Amount.StringFixed(2))
|
|
require.Equal(t, "CNY", createRequests[0].Currency)
|
|
require.Equal(t, "sub2_order", createRequests[0].MerchantOrderID)
|
|
require.Equal(t, airwallexDeterministicRequestID("payment-intent", "sub2_order", "12.34", "CNY"), createRequests[0].RequestID)
|
|
}
|
|
|
|
func TestAirwallexCreatePaymentUsesConfiguredCurrency(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var createRequest airwallexCreatePaymentIntentRequest
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/v1/authentication/login":
|
|
_, _ = w.Write([]byte(`{"token":"token-1","expires_at":"2099-01-01T00:00:00Z"}`))
|
|
case "/api/v1/pa/payment_intents/create":
|
|
body, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
require.NoError(t, json.Unmarshal(body, &createRequest))
|
|
_, _ = w.Write([]byte(`{"id":"int_123","client_secret":"secret_123","amount":12.34,"currency":"HKD","merchant_order_id":"sub2_order","status":"REQUIRES_PAYMENT_METHOD"}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
prov, err := NewAirwallex("1", map[string]string{
|
|
"clientId": "cid",
|
|
"apiKey": "key",
|
|
"webhookSecret": "whsec",
|
|
"apiBase": airwallexDemoAPIBase,
|
|
"currency": "hkd",
|
|
"countryCode": "HK",
|
|
})
|
|
require.NoError(t, err)
|
|
prov.config["apiBase"] = server.URL + "/api/v1"
|
|
prov.httpClient = server.Client()
|
|
|
|
resp, err := prov.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
|
OrderID: "sub2_order",
|
|
Amount: "12.34",
|
|
ReturnURL: "https://merchant.example.com/payment/result",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "HKD", createRequest.Currency)
|
|
require.Equal(t, "HKD", resp.Currency)
|
|
require.Equal(t, "HK", resp.CountryCode)
|
|
require.Equal(t, "HKD", prov.MerchantIdentityMetadata()["currency"])
|
|
}
|
|
|
|
func TestAirwallexRequestsUseConfiguredAccountID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
paRequestCount := 0
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/v1/authentication/login":
|
|
require.Equal(t, "acct_123", r.Header.Get("x-login-as"))
|
|
_, _ = w.Write([]byte(`{"token":"token-1","expires_at":"2099-01-01T00:00:00Z"}`))
|
|
case "/api/v1/pa/payment_intents/create":
|
|
paRequestCount++
|
|
require.Equal(t, "acct_123", r.Header.Get("x-on-behalf-of"))
|
|
_, _ = w.Write([]byte(`{"id":"int_123","client_secret":"secret_123","amount":12.34,"currency":"CNY","merchant_order_id":"sub2_order","status":"REQUIRES_PAYMENT_METHOD"}`))
|
|
case "/api/v1/pa/payment_intents/int_123":
|
|
paRequestCount++
|
|
require.Equal(t, "acct_123", r.Header.Get("x-on-behalf-of"))
|
|
_, _ = w.Write([]byte(`{"id":"int_123","amount":12.34,"currency":"CNY","merchant_order_id":"sub2_order","status":"SUCCEEDED"}`))
|
|
case "/api/v1/pa/refunds/create":
|
|
paRequestCount++
|
|
require.Equal(t, "acct_123", r.Header.Get("x-on-behalf-of"))
|
|
_, _ = w.Write([]byte(`{"id":"ref_123","payment_intent_id":"int_123","amount":12.34,"currency":"CNY","status":"SETTLED"}`))
|
|
case "/api/v1/pa/payment_intents/int_123/cancel":
|
|
paRequestCount++
|
|
require.Equal(t, "acct_123", r.Header.Get("x-on-behalf-of"))
|
|
_, _ = w.Write([]byte(`{"id":"int_123","amount":12.34,"currency":"CNY","merchant_order_id":"sub2_order","status":"CANCELLED"}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
prov, err := NewAirwallex("1", map[string]string{
|
|
"clientId": "cid",
|
|
"apiKey": "key",
|
|
"webhookSecret": "whsec",
|
|
"apiBase": airwallexDemoAPIBase,
|
|
"accountId": "acct_123",
|
|
})
|
|
require.NoError(t, err)
|
|
prov.config["apiBase"] = server.URL + "/api/v1"
|
|
prov.httpClient = server.Client()
|
|
|
|
_, err = prov.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
|
OrderID: "sub2_order",
|
|
Amount: "12.34",
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = prov.QueryOrder(context.Background(), "int_123")
|
|
require.NoError(t, err)
|
|
_, err = prov.Refund(context.Background(), payment.RefundRequest{
|
|
TradeNo: "int_123",
|
|
Amount: "12.34",
|
|
Reason: "test refund",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, prov.CancelPayment(context.Background(), "int_123"))
|
|
require.Contains(t, prov.tokenCacheKey(), "acct_123")
|
|
require.Equal(t, 4, paRequestCount)
|
|
}
|
|
|
|
func TestAirwallexRefundRejectsUnsettledStatus(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, status := range []string{"RECEIVED", "ACCEPTED", "FAILED"} {
|
|
t.Run(status, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/v1/authentication/login":
|
|
_, _ = w.Write([]byte(`{"token":"token-1","expires_at":"2099-01-01T00:00:00Z"}`))
|
|
case "/api/v1/pa/refunds/create":
|
|
_, _ = w.Write([]byte(`{"id":"ref_123","payment_intent_id":"int_123","amount":12.34,"currency":"CNY","status":"` + status + `"}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
prov := mustTestAirwallexProvider(t, server)
|
|
resp, err := prov.Refund(context.Background(), payment.RefundRequest{
|
|
TradeNo: "int_123",
|
|
Amount: "12.34",
|
|
Reason: "test refund",
|
|
})
|
|
|
|
require.ErrorContains(t, err, "airwallex refund not settled")
|
|
require.NotNil(t, resp)
|
|
require.Equal(t, "ref_123", resp.RefundID)
|
|
if status == airwallexRefundStatusFailed {
|
|
require.Equal(t, payment.ProviderStatusFailed, resp.Status)
|
|
} else {
|
|
require.Equal(t, payment.ProviderStatusPending, resp.Status)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAirwallexAuthErrorIncludesCredentialGuidance(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
require.Equal(t, "/api/v1/authentication/login", r.URL.Path)
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"code":"credentials_invalid","details":["Access Denied"],"message":"UNAUTHORIZED","source":""}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
prov := mustTestAirwallexProvider(t, server)
|
|
_, err := prov.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
|
OrderID: "sub2_order",
|
|
Amount: "12.34",
|
|
})
|
|
|
|
require.ErrorContains(t, err, "credentials_invalid")
|
|
require.ErrorContains(t, err, "API Base environment")
|
|
require.ErrorContains(t, err, "https://api-demo.airwallex.com/api/v1")
|
|
require.ErrorContains(t, err, "https://api.airwallex.com/api/v1")
|
|
require.ErrorContains(t, err, "Account ID")
|
|
}
|
|
|
|
func TestAirwallexVerifyNotificationRequiresValidSignatureAndCurrency(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
prov, err := NewAirwallex("1", map[string]string{
|
|
"clientId": "cid",
|
|
"apiKey": "key",
|
|
"webhookSecret": "whsec",
|
|
"apiBase": airwallexDemoAPIBase,
|
|
"accountId": "acct_123",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
raw := `{"id":"evt_1","name":"payment_intent.succeeded","accountId":"acct_123","data":{"object":{"id":"int_123","merchant_order_id":"sub2_abc","amount":88.66,"currency":"CNY","status":"SUCCEEDED"}}}`
|
|
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
|
headers := signedAirwallexHeaders(raw, timestamp, "whsec")
|
|
|
|
n, err := prov.VerifyNotification(context.Background(), raw, headers)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, n)
|
|
require.Equal(t, "int_123", n.TradeNo)
|
|
require.Equal(t, "sub2_abc", n.OrderID)
|
|
require.Equal(t, payment.NotificationStatusSuccess, n.Status)
|
|
require.InDelta(t, 88.66, n.Amount, 0.0001)
|
|
require.Equal(t, "CNY", n.Metadata["currency"])
|
|
require.Equal(t, "acct_123", n.Metadata["account_id"])
|
|
|
|
headers["x-signature"] = strings.Repeat("0", 64)
|
|
_, err = prov.VerifyNotification(context.Background(), raw, headers)
|
|
require.ErrorContains(t, err, "invalid signature")
|
|
}
|
|
|
|
func TestVerifyAirwallexWebhookSignatureRejectsReplay(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
raw := `{"id":"evt_1"}`
|
|
timestamp := "1778241600000"
|
|
headers := signedAirwallexHeaders(raw, timestamp, "whsec")
|
|
err := verifyAirwallexWebhookSignature(raw, headers, "whsec", time.UnixMilli(1778241600000).Add(airwallexWebhookTolerance+time.Millisecond))
|
|
require.ErrorContains(t, err, "outside tolerance")
|
|
}
|
|
|
|
func TestAirwallexQueryOrderMapsSucceeded(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/v1/authentication/login":
|
|
_, _ = w.Write([]byte(`{"token":"token-1","expires_at":"2099-01-01T00:00:00Z"}`))
|
|
case "/api/v1/pa/payment_intents/int_123":
|
|
_, _ = w.Write([]byte(`{"id":"int_123","amount":99.01,"currency":"CNY","merchant_order_id":"sub2_order","status":"SUCCEEDED"}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
prov := mustTestAirwallexProvider(t, server)
|
|
resp, err := prov.QueryOrder(context.Background(), "int_123")
|
|
require.NoError(t, err)
|
|
require.Equal(t, payment.ProviderStatusPaid, resp.Status)
|
|
require.InDelta(t, 99.01, resp.Amount, 0.0001)
|
|
require.Equal(t, "CNY", resp.Metadata["currency"])
|
|
require.Equal(t, "SUCCEEDED", resp.Metadata["status"])
|
|
}
|
|
|
|
func mustTestAirwallexProvider(t *testing.T, server *httptest.Server) *Airwallex {
|
|
t.Helper()
|
|
prov, err := NewAirwallex("1", map[string]string{
|
|
"clientId": "cid",
|
|
"apiKey": "key",
|
|
"webhookSecret": "whsec",
|
|
"apiBase": airwallexDemoAPIBase,
|
|
})
|
|
require.NoError(t, err)
|
|
prov.config["apiBase"] = server.URL + "/api/v1"
|
|
prov.httpClient = server.Client()
|
|
return prov
|
|
}
|
|
|
|
func signedAirwallexHeaders(rawBody, timestamp, secret string) map[string]string {
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
_, _ = mac.Write([]byte(timestamp))
|
|
_, _ = mac.Write([]byte(rawBody))
|
|
return map[string]string{
|
|
"x-timestamp": timestamp,
|
|
"x-signature": hex.EncodeToString(mac.Sum(nil)),
|
|
}
|
|
}
|