- 前端免费排盘默认不保存,勾选同意后才调用保存接口 - 服务端要求 consentVersion,记录 consentAt,校验并限流公开保存接口 - 付费结算单独披露报告快照与订单信息将由服务端存储 - 管理端展示同意版本与时间,历史无同意证据记录保留并标注 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
243 lines
11 KiB
Go
243 lines
11 KiB
Go
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())
|
|
}
|
|
}
|
|
|
|
func TestSaveChartRecordRequiresConsent(t *testing.T) {
|
|
router := setupChartRecordAPI(t)
|
|
response := postChartRecord(router, "198.51.100.41", false)
|
|
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), `"code":400`) {
|
|
t.Fatalf("expected consent to be required, got %d: %s", response.Code, response.Body.String())
|
|
}
|
|
var recordCount int64
|
|
if err := config.GetDB().Model(&model.BaziChartRecord{}).Count(&recordCount).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if recordCount != 0 {
|
|
t.Fatalf("expected no chart record without consent, got %d", recordCount)
|
|
}
|
|
}
|
|
|
|
func TestSaveChartRecordPersistsConsentViaHTTP(t *testing.T) {
|
|
router := setupChartRecordAPI(t)
|
|
response := postChartRecord(router, "198.51.100.42", true)
|
|
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"code":0`) {
|
|
t.Fatalf("expected chart record to be saved after consent, got %d: %s", response.Code, response.Body.String())
|
|
}
|
|
var record model.BaziChartRecord
|
|
if err := config.GetDB().Where("report_id = ?", "ZM-CHART123").First(&record).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if record.ConsentVersion != "chart-record-v1" || record.ConsentAt == nil {
|
|
t.Fatalf("expected persisted consent version and timestamp")
|
|
}
|
|
}
|
|
|
|
func TestSaveChartRecordRateLimitedViaHTTP(t *testing.T) {
|
|
router := setupChartRecordAPI(t)
|
|
for requestNumber := 0; requestNumber < 11; requestNumber++ {
|
|
response := postChartRecord(router, "198.51.100.43", true)
|
|
if requestNumber < 10 && response.Code != http.StatusOK {
|
|
t.Fatalf("request %d expected HTTP 200, got %d: %s", requestNumber+1, response.Code, response.Body.String())
|
|
}
|
|
if requestNumber == 10 && response.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("expected HTTP 429 after rate limit, got %d: %s", response.Code, response.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSaveChartRecordRejectsOversizedBodyViaHTTP(t *testing.T) {
|
|
router := setupChartRecordAPI(t)
|
|
body := fmt.Sprintf(`{"reportId":"ZM-CHART123","birthDate":"1990-08-08","birthTime":"10:30","gender":"female","city":"上海","consentVersion":"chart-record-v1","snapshot":{"data":"%s"}}`, strings.Repeat("x", 600*1024))
|
|
request := httptest.NewRequest(http.MethodPost, "/api/commerce/charts", strings.NewReader(body))
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.RemoteAddr = "198.51.100.44:1234"
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, request)
|
|
if response.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected oversized body to be rejected, got %d", response.Code)
|
|
}
|
|
}
|
|
|
|
func setupChartRecordAPI(t *testing.T) *gin.Engine {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("SERVER_ENV", "test")
|
|
t.Setenv("DB_TYPE", "sqlite")
|
|
t.Setenv("DB_NAME", fmt.Sprintf("file:chart-consent-http-%d?mode=memory&cache=shared", time.Now().UnixNano()))
|
|
t.Setenv("JWT_SECRET", "chart-consent-test-secret")
|
|
if err := config.InitDB(config.Load()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
router := gin.New()
|
|
router.POST("/api/commerce/charts", middleware.RateLimit(), SaveChartRecord)
|
|
return router
|
|
}
|
|
|
|
func postChartRecord(router *gin.Engine, clientIP string, consent bool) *httptest.ResponseRecorder {
|
|
body := `{"reportId":"ZM-CHART123","birthDate":"1990-08-08","birthTime":"10:30","gender":"female","city":"上海","snapshot":{"id":"ZM-CHART123"}`
|
|
if consent {
|
|
body += `,"consentVersion":"chart-record-v1"`
|
|
}
|
|
body += `}`
|
|
request := httptest.NewRequest(http.MethodPost, "/api/commerce/charts", strings.NewReader(body))
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.RemoteAddr = clientIP + ":1234"
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, request)
|
|
return response
|
|
}
|