- 新增商业化模块:Stripe/Binance Pay 支付、邮箱登录、订单管理 - 新增数据模型:ReportProduct, ReportEntitlement, ReportSnapshot, PaymentEvent, EmailLoginCode - 新增 API:/api/commerce/checkout, /api/commerce/webhooks/*, /api/commerce/orders/* - 新增管理后台:订单列表、统计图表、统一 AdminHeader 组件 - 新增中间件测试:RateLimit, Auth, CORS 共 7 个测试用例 - 新增 AGENTS.md 项目代理指令文件 - 新增 REMINDER.md 商业化功能开发规范 - 修复 JWT 解析:添加 WithValidMethods 限制 HS256 - 修复 GetPayStatus:增加用户权限校验 - 更新 .gitignore 和 README.md
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gouki/lunar-server/internal/config"
|
||||
"github.com/gouki/lunar-server/internal/middleware"
|
||||
"github.com/gouki/lunar-server/internal/model"
|
||||
"github.com/gouki/lunar-server/internal/service"
|
||||
)
|
||||
|
||||
func TestCommerceCheckoutRequiresAuthentication(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.POST("/api/commerce/checkout", middleware.Auth(), CreateReportCheckout)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/commerce/checkout", strings.NewReader(`{"provider":"stripe","reportId":"ZM-ABCDEFG","snapshot":{"version":1,"result":"test"}}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), `"code":401`) {
|
||||
t.Fatalf("unexpected response body: %s", response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripeWebhookRejectsUnsignedRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.POST("/api/commerce/webhooks/stripe", StripeWebhook)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/commerce/webhooks/stripe", strings.NewReader(`{"id":"evt_invalid"}`))
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailLoginRejectsMalformedBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.POST("/api/auth/email/request", RequestEmailLoginCode)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/auth/email/request", strings.NewReader(`{}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), `"code":400`) {
|
||||
t.Fatalf("unexpected response body: %s", response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripeCheckoutWebhookAndEntitlementHTTPFlow(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
stripeAPI := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(response, `{"id":"cs_test_flow","url":"https://checkout.stripe.test/flow"}`)
|
||||
}))
|
||||
defer stripeAPI.Close()
|
||||
|
||||
t.Setenv("DB_TYPE", "sqlite")
|
||||
t.Setenv("DB_NAME", "file:commerce-http-test?mode=memory&cache=shared")
|
||||
t.Setenv("JWT_SECRET", "http-test-secret")
|
||||
t.Setenv("STRIPE_SECRET_KEY", "sk_test")
|
||||
t.Setenv("STRIPE_WEBHOOK_SECRET", "whsec_http_test")
|
||||
t.Setenv("STRIPE_API_BASE_URL", stripeAPI.URL)
|
||||
t.Setenv("WEB_APP_URL", "https://example.test")
|
||||
if err := config.InitDB(config.Load()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := model.User{OpenID: "email:http-test", Email: "buyer@example.com", Nickname: "buyer", Status: 1}
|
||||
if err := config.GetDB().Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := service.NewUserService().GenerateToken(user.ID, "http-test-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/commerce/checkout", middleware.Auth(), CreateReportCheckout)
|
||||
router.POST("/api/commerce/webhooks/stripe", StripeWebhook)
|
||||
router.GET("/api/commerce/orders/:orderNo", middleware.Auth(), GetCommerceOrder)
|
||||
router.GET("/api/commerce/reports/:reportId/access", middleware.Auth(), GetReportAccess)
|
||||
router.GET("/api/commerce/reports/recover/:accessCode", middleware.Auth(), GetReportByAccessCode)
|
||||
|
||||
checkoutRequest := httptest.NewRequest(http.MethodPost, "/api/commerce/checkout", strings.NewReader(`{"provider":"stripe","reportId":"ZM-ABCDEFG","snapshot":{"version":1,"id":"ZM-ABCDEFG","result":{"eightChar":{"dayMaster":"甲","dayMasterElement":"木"},"decadeFortunes":[{"ganzhi":"丙寅","startAge":8}]},"elements":{"dominant":"木","weakest":"金"},"shensha":[],"sections":[]}}`))
|
||||
checkoutRequest.Header.Set("Content-Type", "application/json")
|
||||
checkoutRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
checkoutResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(checkoutResponse, checkoutRequest)
|
||||
if checkoutResponse.Code != http.StatusOK {
|
||||
t.Fatalf("expected checkout 200, got %d: %s", checkoutResponse.Code, checkoutResponse.Body.String())
|
||||
}
|
||||
var checkoutPayload struct {
|
||||
Data struct {
|
||||
OrderNo string `json:"orderNo"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(checkoutResponse.Body.Bytes(), &checkoutPayload); err != nil || checkoutPayload.Data.OrderNo == "" {
|
||||
t.Fatalf("invalid checkout response: %s", checkoutResponse.Body.String())
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
webhookBody := []byte(fmt.Sprintf(`{"id":"evt_http_flow","type":"checkout.session.completed","data":{"object":{"id":"cs_test_flow","payment_intent":"pi_http_flow","payment_status":"paid","amount_total":499,"currency":"usd","metadata":{"order_no":"%s"}}}}`, checkoutPayload.Data.OrderNo))
|
||||
mac := hmac.New(sha256.New, []byte("whsec_http_test"))
|
||||
mac.Write([]byte(fmt.Sprintf("%d.%s", now.Unix(), webhookBody)))
|
||||
webhookRequest := httptest.NewRequest(http.MethodPost, "/api/commerce/webhooks/stripe", strings.NewReader(string(webhookBody)))
|
||||
webhookRequest.Header.Set("Stripe-Signature", fmt.Sprintf("t=%d,v1=%s", now.Unix(), hex.EncodeToString(mac.Sum(nil))))
|
||||
webhookResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(webhookResponse, webhookRequest)
|
||||
if webhookResponse.Code != http.StatusOK {
|
||||
t.Fatalf("expected webhook 200, got %d: %s", webhookResponse.Code, webhookResponse.Body.String())
|
||||
}
|
||||
|
||||
accessRequest := httptest.NewRequest(http.MethodGet, "/api/commerce/reports/ZM-ABCDEFG/access", nil)
|
||||
accessRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
accessResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(accessResponse, accessRequest)
|
||||
if accessResponse.Code != http.StatusOK || !strings.Contains(accessResponse.Body.String(), `"unlocked":true`) {
|
||||
t.Fatalf("expected active entitlement, got %d: %s", accessResponse.Code, accessResponse.Body.String())
|
||||
}
|
||||
|
||||
orderRequest := httptest.NewRequest(http.MethodGet, "/api/commerce/orders/"+checkoutPayload.Data.OrderNo, nil)
|
||||
orderRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
orderResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(orderResponse, orderRequest)
|
||||
var orderPayload struct {
|
||||
Data struct {
|
||||
AccessCode string `json:"accessCode"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(orderResponse.Body.Bytes(), &orderPayload); err != nil || orderPayload.Data.AccessCode == "" {
|
||||
t.Fatalf("expected server recovery code, got %s", orderResponse.Body.String())
|
||||
}
|
||||
|
||||
recoverRequest := httptest.NewRequest(http.MethodGet, "/api/commerce/reports/recover/"+orderPayload.Data.AccessCode, nil)
|
||||
recoverRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
recoverResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(recoverResponse, recoverRequest)
|
||||
if recoverResponse.Code != http.StatusOK || !strings.Contains(recoverResponse.Body.String(), `"title":"性格底色"`) {
|
||||
t.Fatalf("expected server-generated premium snapshot, got %d: %s", recoverResponse.Code, recoverResponse.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user