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", 4990000, "USDT"); 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", 4990000, "USDT") 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", 4990000, "USDT") _ = st.Insert(ctx, 1, "uuid-1", "pro_quarter", "pay002", "crypto", 12990000, "USDT") 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) } } // 【必修·钱安全回归】台账缺行 + 目标用户已被停用(banned,如下单后被封): // 钱已实收,兜底路径仍须补行开通并回 SUCCESS,不能因 status 过滤而 500→死信丢钱。 func TestWebhook_MissingLedgerFallsBackEvenWhenUserSuspended(t *testing.T) { h, db, st := newWebhookRig(t) // newWebhookRig 已 seedUser(id=1, uuid-1, active);这里另建一个非 active(banned)用户。 seedUserWithStatus(t, db, 2, "uuid-2", "banned") p := succeededPayload("pay-suspended", "pro_year") p["biz_ref"] = "uuid-2" w := deliver(t, h, p) if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "SUCCESS") { t.Fatalf("停用用户兜底补行应仍成功: code=%d body=%q(未修复前会 500 并被 pay 判死信,静默丢钱)", w.Code, w.Body.String()) } row, err := st.GetForUser(context.Background(), 2, "pay-suspended") if err != nil { t.Fatalf("兜底台账未建: %v", err) } if row.Status != "paid" || row.SKU != "pro_year" { t.Fatalf("兜底行不符: %+v", row) } if row.Method != "" { t.Errorf("method = %q, want 空(payload 无 method,不该拿 channel 冒充)", row.Method) } if row.Channel != "crypto" { t.Errorf("channel = %q, want crypto", row.Channel) } var n int _ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 2 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) } }