airwallex_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. //go:build unit
  2. package provider
  3. import (
  4. "context"
  5. "crypto/hmac"
  6. "crypto/sha256"
  7. "encoding/hex"
  8. "encoding/json"
  9. "io"
  10. "net/http"
  11. "net/http/httptest"
  12. "strconv"
  13. "strings"
  14. "testing"
  15. "time"
  16. "github.com/Wei-Shaw/sub2api/internal/payment"
  17. "github.com/stretchr/testify/require"
  18. )
  19. func TestNewAirwallexValidatesConfig(t *testing.T) {
  20. t.Parallel()
  21. _, err := NewAirwallex("1", map[string]string{
  22. "clientId": "cid",
  23. "apiKey": "key",
  24. "webhookSecret": "secret",
  25. "apiBase": "https://evil.example.com/api/v1",
  26. })
  27. require.ErrorContains(t, err, "apiBase host")
  28. _, err = NewAirwallex("1", map[string]string{
  29. "clientId": "cid",
  30. "apiKey": "key",
  31. "webhookSecret": "secret",
  32. "apiBase": airwallexDemoAPIBase,
  33. "countryCode": "C1",
  34. })
  35. require.ErrorContains(t, err, "countryCode")
  36. prov, err := NewAirwallex("1", map[string]string{
  37. "clientId": "cid",
  38. "apiKey": "key",
  39. "webhookSecret": "secret",
  40. "apiBase": airwallexDemoAPIBase,
  41. })
  42. require.NoError(t, err)
  43. require.Equal(t, payment.TypeAirwallex, prov.ProviderKey())
  44. require.Equal(t, []payment.PaymentType{payment.TypeAirwallex}, prov.SupportedTypes())
  45. require.Equal(t, payment.DefaultPaymentCurrency, prov.config["currency"])
  46. require.Equal(t, airwallexDefaultCountry, prov.config["countryCode"])
  47. }
  48. func TestAirwallexCreatePaymentUsesServerAmountAndStableRequestID(t *testing.T) {
  49. t.Parallel()
  50. var createRequests []airwallexCreatePaymentIntentRequest
  51. server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  52. switch r.URL.Path {
  53. case "/api/v1/authentication/login":
  54. require.Equal(t, "cid", r.Header.Get("x-client-id"))
  55. require.Equal(t, "key", r.Header.Get("x-api-key"))
  56. _, _ = w.Write([]byte(`{"token":"token-1","expires_at":"2099-01-01T00:00:00Z"}`))
  57. case "/api/v1/pa/payment_intents/create":
  58. require.Equal(t, "Bearer token-1", r.Header.Get("Authorization"))
  59. body, err := io.ReadAll(r.Body)
  60. require.NoError(t, err)
  61. require.Contains(t, string(body), `"amount":12.34`)
  62. var payload airwallexCreatePaymentIntentRequest
  63. require.NoError(t, json.Unmarshal(body, &payload))
  64. createRequests = append(createRequests, payload)
  65. _, _ = w.Write([]byte(`{"id":"int_123","client_secret":"secret_123","amount":12.34,"currency":"CNY","merchant_order_id":"sub2_order","status":"REQUIRES_PAYMENT_METHOD"}`))
  66. default:
  67. http.NotFound(w, r)
  68. }
  69. }))
  70. defer server.Close()
  71. prov := mustTestAirwallexProvider(t, server)
  72. resp, err := prov.CreatePayment(context.Background(), payment.CreatePaymentRequest{
  73. OrderID: "sub2_order",
  74. Amount: "12.34",
  75. ReturnURL: "https://merchant.example.com/payment/result",
  76. })
  77. require.NoError(t, err)
  78. require.Equal(t, "int_123", resp.TradeNo)
  79. require.Equal(t, "secret_123", resp.ClientSecret)
  80. require.Equal(t, "int_123", resp.IntentID)
  81. require.Equal(t, "CNY", resp.Currency)
  82. require.Equal(t, "CN", resp.CountryCode)
  83. require.Equal(t, "demo", resp.PaymentEnv)
  84. require.Len(t, createRequests, 1)
  85. require.Equal(t, "12.34", createRequests[0].Amount.StringFixed(2))
  86. require.Equal(t, "CNY", createRequests[0].Currency)
  87. require.Equal(t, "sub2_order", createRequests[0].MerchantOrderID)
  88. require.Equal(t, airwallexDeterministicRequestID("payment-intent", "sub2_order", "12.34", "CNY"), createRequests[0].RequestID)
  89. }
  90. func TestAirwallexCreatePaymentUsesConfiguredCurrency(t *testing.T) {
  91. t.Parallel()
  92. var createRequest airwallexCreatePaymentIntentRequest
  93. server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  94. switch r.URL.Path {
  95. case "/api/v1/authentication/login":
  96. _, _ = w.Write([]byte(`{"token":"token-1","expires_at":"2099-01-01T00:00:00Z"}`))
  97. case "/api/v1/pa/payment_intents/create":
  98. body, err := io.ReadAll(r.Body)
  99. require.NoError(t, err)
  100. require.NoError(t, json.Unmarshal(body, &createRequest))
  101. _, _ = w.Write([]byte(`{"id":"int_123","client_secret":"secret_123","amount":12.34,"currency":"HKD","merchant_order_id":"sub2_order","status":"REQUIRES_PAYMENT_METHOD"}`))
  102. default:
  103. http.NotFound(w, r)
  104. }
  105. }))
  106. defer server.Close()
  107. prov, err := NewAirwallex("1", map[string]string{
  108. "clientId": "cid",
  109. "apiKey": "key",
  110. "webhookSecret": "whsec",
  111. "apiBase": airwallexDemoAPIBase,
  112. "currency": "hkd",
  113. "countryCode": "HK",
  114. })
  115. require.NoError(t, err)
  116. prov.config["apiBase"] = server.URL + "/api/v1"
  117. prov.httpClient = server.Client()
  118. resp, err := prov.CreatePayment(context.Background(), payment.CreatePaymentRequest{
  119. OrderID: "sub2_order",
  120. Amount: "12.34",
  121. ReturnURL: "https://merchant.example.com/payment/result",
  122. })
  123. require.NoError(t, err)
  124. require.Equal(t, "HKD", createRequest.Currency)
  125. require.Equal(t, "HKD", resp.Currency)
  126. require.Equal(t, "HK", resp.CountryCode)
  127. require.Equal(t, "HKD", prov.MerchantIdentityMetadata()["currency"])
  128. }
  129. func TestAirwallexRequestsUseConfiguredAccountID(t *testing.T) {
  130. t.Parallel()
  131. paRequestCount := 0
  132. server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  133. switch r.URL.Path {
  134. case "/api/v1/authentication/login":
  135. require.Equal(t, "acct_123", r.Header.Get("x-login-as"))
  136. _, _ = w.Write([]byte(`{"token":"token-1","expires_at":"2099-01-01T00:00:00Z"}`))
  137. case "/api/v1/pa/payment_intents/create":
  138. paRequestCount++
  139. require.Equal(t, "acct_123", r.Header.Get("x-on-behalf-of"))
  140. _, _ = w.Write([]byte(`{"id":"int_123","client_secret":"secret_123","amount":12.34,"currency":"CNY","merchant_order_id":"sub2_order","status":"REQUIRES_PAYMENT_METHOD"}`))
  141. case "/api/v1/pa/payment_intents/int_123":
  142. paRequestCount++
  143. require.Equal(t, "acct_123", r.Header.Get("x-on-behalf-of"))
  144. _, _ = w.Write([]byte(`{"id":"int_123","amount":12.34,"currency":"CNY","merchant_order_id":"sub2_order","status":"SUCCEEDED"}`))
  145. case "/api/v1/pa/refunds/create":
  146. paRequestCount++
  147. require.Equal(t, "acct_123", r.Header.Get("x-on-behalf-of"))
  148. _, _ = w.Write([]byte(`{"id":"ref_123","payment_intent_id":"int_123","amount":12.34,"currency":"CNY","status":"SETTLED"}`))
  149. case "/api/v1/pa/payment_intents/int_123/cancel":
  150. paRequestCount++
  151. require.Equal(t, "acct_123", r.Header.Get("x-on-behalf-of"))
  152. _, _ = w.Write([]byte(`{"id":"int_123","amount":12.34,"currency":"CNY","merchant_order_id":"sub2_order","status":"CANCELLED"}`))
  153. default:
  154. http.NotFound(w, r)
  155. }
  156. }))
  157. defer server.Close()
  158. prov, err := NewAirwallex("1", map[string]string{
  159. "clientId": "cid",
  160. "apiKey": "key",
  161. "webhookSecret": "whsec",
  162. "apiBase": airwallexDemoAPIBase,
  163. "accountId": "acct_123",
  164. })
  165. require.NoError(t, err)
  166. prov.config["apiBase"] = server.URL + "/api/v1"
  167. prov.httpClient = server.Client()
  168. _, err = prov.CreatePayment(context.Background(), payment.CreatePaymentRequest{
  169. OrderID: "sub2_order",
  170. Amount: "12.34",
  171. })
  172. require.NoError(t, err)
  173. _, err = prov.QueryOrder(context.Background(), "int_123")
  174. require.NoError(t, err)
  175. _, err = prov.Refund(context.Background(), payment.RefundRequest{
  176. TradeNo: "int_123",
  177. Amount: "12.34",
  178. Reason: "test refund",
  179. })
  180. require.NoError(t, err)
  181. require.NoError(t, prov.CancelPayment(context.Background(), "int_123"))
  182. require.Contains(t, prov.tokenCacheKey(), "acct_123")
  183. require.Equal(t, 4, paRequestCount)
  184. }
  185. func TestAirwallexRefundRejectsUnsettledStatus(t *testing.T) {
  186. t.Parallel()
  187. for _, status := range []string{"RECEIVED", "ACCEPTED", "FAILED"} {
  188. t.Run(status, func(t *testing.T) {
  189. t.Parallel()
  190. server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  191. switch r.URL.Path {
  192. case "/api/v1/authentication/login":
  193. _, _ = w.Write([]byte(`{"token":"token-1","expires_at":"2099-01-01T00:00:00Z"}`))
  194. case "/api/v1/pa/refunds/create":
  195. _, _ = w.Write([]byte(`{"id":"ref_123","payment_intent_id":"int_123","amount":12.34,"currency":"CNY","status":"` + status + `"}`))
  196. default:
  197. http.NotFound(w, r)
  198. }
  199. }))
  200. defer server.Close()
  201. prov := mustTestAirwallexProvider(t, server)
  202. resp, err := prov.Refund(context.Background(), payment.RefundRequest{
  203. TradeNo: "int_123",
  204. Amount: "12.34",
  205. Reason: "test refund",
  206. })
  207. require.ErrorContains(t, err, "airwallex refund not settled")
  208. require.NotNil(t, resp)
  209. require.Equal(t, "ref_123", resp.RefundID)
  210. if status == airwallexRefundStatusFailed {
  211. require.Equal(t, payment.ProviderStatusFailed, resp.Status)
  212. } else {
  213. require.Equal(t, payment.ProviderStatusPending, resp.Status)
  214. }
  215. })
  216. }
  217. }
  218. func TestAirwallexAuthErrorIncludesCredentialGuidance(t *testing.T) {
  219. t.Parallel()
  220. server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  221. require.Equal(t, "/api/v1/authentication/login", r.URL.Path)
  222. w.WriteHeader(http.StatusUnauthorized)
  223. _, _ = w.Write([]byte(`{"code":"credentials_invalid","details":["Access Denied"],"message":"UNAUTHORIZED","source":""}`))
  224. }))
  225. defer server.Close()
  226. prov := mustTestAirwallexProvider(t, server)
  227. _, err := prov.CreatePayment(context.Background(), payment.CreatePaymentRequest{
  228. OrderID: "sub2_order",
  229. Amount: "12.34",
  230. })
  231. require.ErrorContains(t, err, "credentials_invalid")
  232. require.ErrorContains(t, err, "API Base environment")
  233. require.ErrorContains(t, err, "https://api-demo.airwallex.com/api/v1")
  234. require.ErrorContains(t, err, "https://api.airwallex.com/api/v1")
  235. require.ErrorContains(t, err, "Account ID")
  236. }
  237. func TestAirwallexVerifyNotificationRequiresValidSignatureAndCurrency(t *testing.T) {
  238. t.Parallel()
  239. prov, err := NewAirwallex("1", map[string]string{
  240. "clientId": "cid",
  241. "apiKey": "key",
  242. "webhookSecret": "whsec",
  243. "apiBase": airwallexDemoAPIBase,
  244. "accountId": "acct_123",
  245. })
  246. require.NoError(t, err)
  247. 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"}}}`
  248. timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
  249. headers := signedAirwallexHeaders(raw, timestamp, "whsec")
  250. n, err := prov.VerifyNotification(context.Background(), raw, headers)
  251. require.NoError(t, err)
  252. require.NotNil(t, n)
  253. require.Equal(t, "int_123", n.TradeNo)
  254. require.Equal(t, "sub2_abc", n.OrderID)
  255. require.Equal(t, payment.NotificationStatusSuccess, n.Status)
  256. require.InDelta(t, 88.66, n.Amount, 0.0001)
  257. require.Equal(t, "CNY", n.Metadata["currency"])
  258. require.Equal(t, "acct_123", n.Metadata["account_id"])
  259. headers["x-signature"] = strings.Repeat("0", 64)
  260. _, err = prov.VerifyNotification(context.Background(), raw, headers)
  261. require.ErrorContains(t, err, "invalid signature")
  262. }
  263. func TestVerifyAirwallexWebhookSignatureRejectsReplay(t *testing.T) {
  264. t.Parallel()
  265. raw := `{"id":"evt_1"}`
  266. timestamp := "1778241600000"
  267. headers := signedAirwallexHeaders(raw, timestamp, "whsec")
  268. err := verifyAirwallexWebhookSignature(raw, headers, "whsec", time.UnixMilli(1778241600000).Add(airwallexWebhookTolerance+time.Millisecond))
  269. require.ErrorContains(t, err, "outside tolerance")
  270. }
  271. func TestAirwallexQueryOrderMapsSucceeded(t *testing.T) {
  272. t.Parallel()
  273. server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  274. switch r.URL.Path {
  275. case "/api/v1/authentication/login":
  276. _, _ = w.Write([]byte(`{"token":"token-1","expires_at":"2099-01-01T00:00:00Z"}`))
  277. case "/api/v1/pa/payment_intents/int_123":
  278. _, _ = w.Write([]byte(`{"id":"int_123","amount":99.01,"currency":"CNY","merchant_order_id":"sub2_order","status":"SUCCEEDED"}`))
  279. default:
  280. http.NotFound(w, r)
  281. }
  282. }))
  283. defer server.Close()
  284. prov := mustTestAirwallexProvider(t, server)
  285. resp, err := prov.QueryOrder(context.Background(), "int_123")
  286. require.NoError(t, err)
  287. require.Equal(t, payment.ProviderStatusPaid, resp.Status)
  288. require.InDelta(t, 99.01, resp.Amount, 0.0001)
  289. require.Equal(t, "CNY", resp.Metadata["currency"])
  290. require.Equal(t, "SUCCEEDED", resp.Metadata["status"])
  291. }
  292. func mustTestAirwallexProvider(t *testing.T, server *httptest.Server) *Airwallex {
  293. t.Helper()
  294. prov, err := NewAirwallex("1", map[string]string{
  295. "clientId": "cid",
  296. "apiKey": "key",
  297. "webhookSecret": "whsec",
  298. "apiBase": airwallexDemoAPIBase,
  299. })
  300. require.NoError(t, err)
  301. prov.config["apiBase"] = server.URL + "/api/v1"
  302. prov.httpClient = server.Client()
  303. return prov
  304. }
  305. func signedAirwallexHeaders(rawBody, timestamp, secret string) map[string]string {
  306. mac := hmac.New(sha256.New, []byte(secret))
  307. _, _ = mac.Write([]byte(timestamp))
  308. _, _ = mac.Write([]byte(rawBody))
  309. return map[string]string{
  310. "x-timestamp": timestamp,
  311. "x-signature": hex.EncodeToString(mac.Sum(nil)),
  312. }
  313. }