Files
sub2api-ht/backend/internal/payment/provider/airwallex_test.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)),
}
}