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 }