Files
pangolin/server/internal/pay/webhook_sqlite_test.go
T

220 lines
7.7 KiB
Go

package pay
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/wangjia/pangolin/server/internal/codes"
)
func newWebhookRig(t *testing.T) (*WebhookHandler, *sql.DB, *Store) {
t.Helper()
db := openMigratedSQLite(t)
seedUser(t, db, 1, "uuid-1")
st := NewStore(db)
codesSvc := codes.NewService(codes.NewStore(db), nil, 5, time.Hour)
h := NewWebhookHandler(st, codesSvc, db, nil, "pangolin", testSecret, 5*time.Minute, 15*time.Minute)
return h, db, st
}
// deliver 按 pay notifier.go 出站语义构造签名请求(每次新 nonce,模拟重投)。
func deliver(t *testing.T, h *WebhookHandler, payload map[string]any) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(payload)
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := newNonce()
r := httptest.NewRequest(http.MethodPost, "/v1/webhook/pay", bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Pay-System", "pangolin")
r.Header.Set("X-Pay-Event", "payment.succeeded")
r.Header.Set("X-Pay-Timestamp", ts)
r.Header.Set("X-Pay-Nonce", nonce)
r.Header.Set("X-Pay-Sign", hmacSign(testSecret, "pangolin", ts, nonce, string(body)))
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
func succeededPayload(orderNo, sku string) map[string]any {
return map[string]any{
"event_type": "payment.succeeded",
"out_trade_no": orderNo,
"biz_system": "pangolin",
"biz_ref": "uuid-1",
"product_biz_code": sku,
"amount_minor": int64(4201234),
"currency": "USDT",
"channel": "crypto",
"paid_at": time.Now().UTC().Format(time.RFC3339),
}
}
func TestWebhook_HappyPath(t *testing.T) {
h, db, st := newWebhookRig(t)
ctx := context.Background()
if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto"); err != nil {
t.Fatal(err)
}
w := deliver(t, h, succeededPayload("pay001", "pro_month"))
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "SUCCESS") {
t.Fatalf("ACK 不符: %d %q(pay 要求 200+body 含 SUCCESS)", w.Code, w.Body.String())
}
row, err := st.GetForUser(ctx, 1, "pay001")
if err != nil {
t.Fatal(err)
}
if row.Status != "paid" || !row.SubID.Valid || row.AmountMinor != 4201234 || row.Channel != "crypto" {
t.Fatalf("台账未正确消费: %+v", row)
}
var source string
var expires time.Time
if err := db.QueryRow(`SELECT source, expires_at FROM subscriptions WHERE id = ?`, row.SubID.Int64).
Scan(&source, &expires); err != nil {
t.Fatalf("订阅未开通: %v", err)
}
if source != "pay" {
t.Errorf("source = %q, want pay", source)
}
want := time.Now().UTC().AddDate(0, 0, 31)
if d := expires.Sub(want); d > time.Minute || d < -time.Minute {
t.Errorf("expires = %v, want ≈ %v(pro_month=31 天)", expires, want)
}
}
func TestWebhook_RedeliveryIdempotent(t *testing.T) {
h, db, st := newWebhookRig(t)
ctx := context.Background()
_ = st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto")
deliver(t, h, succeededPayload("pay001", "pro_month"))
var exp1 time.Time
_ = db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&exp1)
// 重投(新 nonce)必须:200+SUCCESS、订阅行数不变、到期不变。
w := deliver(t, h, succeededPayload("pay001", "pro_month"))
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "SUCCESS") {
t.Fatalf("重投未确认: %d %q", w.Code, w.Body.String())
}
var n int
_ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1`).Scan(&n)
if n != 1 {
t.Fatalf("重投多开了订阅: rows = %d", n)
}
var exp2 time.Time
_ = db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&exp2)
if !exp1.Equal(exp2) {
t.Errorf("重投改了到期: %v → %v", exp1, exp2)
}
}
func TestWebhook_TwoOrdersStack(t *testing.T) {
h, db, st := newWebhookRig(t)
ctx := context.Background()
_ = st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto")
_ = st.Insert(ctx, 1, "uuid-1", "pro_quarter", "pay002", "crypto")
deliver(t, h, succeededPayload("pay001", "pro_month"))
deliver(t, h, succeededPayload("pay002", "pro_quarter"))
var n int
_ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1`).Scan(&n)
if n != 1 {
t.Fatalf("同 plan 应原地叠加: rows = %d", n)
}
var expires time.Time
_ = db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&expires)
want := time.Now().UTC().AddDate(0, 0, 31+92)
if d := expires.Sub(want); d > time.Minute || d < -time.Minute {
t.Errorf("expires = %v, want ≈ %v(31+92 天叠加)", expires, want)
}
}
// 台账缺行(下单后本地写失败):按 biz_ref 兜底定位用户、补台账、照常开通。
func TestWebhook_MissingLedgerFallsBackToBizRef(t *testing.T) {
h, db, st := newWebhookRig(t)
w := deliver(t, h, succeededPayload("pay-orphan", "pro_year"))
if w.Code != http.StatusOK {
t.Fatalf("code = %d body = %s", w.Code, w.Body.String())
}
row, err := st.GetForUser(context.Background(), 1, "pay-orphan")
if err != nil {
t.Fatalf("兜底台账未建: %v", err)
}
if row.Status != "paid" || row.SKU != "pro_year" {
t.Fatalf("兜底行不符: %+v", row)
}
var n int
_ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1 AND source = 'pay'`).Scan(&n)
if n != 1 {
t.Errorf("订阅未开通: %d", n)
}
}
func TestWebhook_RejectsBadSignatureAndStaleTimestamp(t *testing.T) {
h, db, _ := newWebhookRig(t)
body, _ := json.Marshal(succeededPayload("pay001", "pro_month"))
mk := func(mutate func(r *http.Request)) int {
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := newNonce()
r := httptest.NewRequest(http.MethodPost, "/v1/webhook/pay", bytes.NewReader(body))
r.Header.Set("X-Pay-System", "pangolin")
r.Header.Set("X-Pay-Timestamp", ts)
r.Header.Set("X-Pay-Nonce", nonce)
r.Header.Set("X-Pay-Sign", hmacSign(testSecret, "pangolin", ts, nonce, string(body)))
mutate(r)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Code
}
if c := mk(func(r *http.Request) { r.Header.Set("X-Pay-Sign", "AAAA") }); c != http.StatusUnauthorized {
t.Errorf("坏签名 code = %d, want 401", c)
}
if c := mk(func(r *http.Request) { r.Header.Set("X-Pay-System", "jiu") }); c != http.StatusUnauthorized {
t.Errorf("错 system code = %d, want 401", c)
}
if c := mk(func(r *http.Request) {
stale := strconv.FormatInt(time.Now().Add(-10*time.Minute).Unix(), 10)
r.Header.Set("X-Pay-Timestamp", stale)
// 注意:重签,否则先挂在签名而非时间窗上
nonce := r.Header.Get("X-Pay-Nonce")
r.Header.Set("X-Pay-Sign", hmacSign(testSecret, "pangolin", stale, nonce, string(body)))
}); c != http.StatusUnauthorized {
t.Errorf("过期 ts code = %d, want 401", c)
}
var n int
_ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions`).Scan(&n)
if n != 0 {
t.Errorf("拒绝路径不得开通: %d", n)
}
}
// 未知 sku(catalog 漂移)→ 500,pay 会重投,期间可修 catalog 后自愈。
func TestWebhook_UnknownSKU500(t *testing.T) {
h, _, _ := newWebhookRig(t)
w := deliver(t, h, succeededPayload("pay001", "pro_lifetime"))
if w.Code != http.StatusInternalServerError {
t.Fatalf("code = %d, want 500", w.Code)
}
}
// 白名单外事件(如未来误配 refund.succeeded):确认不处理,避免无谓重投 12 次。
func TestWebhook_IgnoredEventAcked(t *testing.T) {
h, db, _ := newWebhookRig(t)
p := succeededPayload("pay001", "pro_month")
p["event_type"] = "refund.succeeded"
w := deliver(t, h, p)
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "SUCCESS") {
t.Fatalf("白名单外事件应直接确认: %d", w.Code)
}
var n int
_ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions`).Scan(&n)
if n != 0 {
t.Errorf("不得开通: %d", n)
}
}