diff --git a/VERSION b/VERSION index a7403c6bf..17c22b883 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.911 +0.912 diff --git a/veza-backend-api/internal/api/routes_marketplace.go b/veza-backend-api/internal/api/routes_marketplace.go index 2ce8fa905..b77fd7192 100644 --- a/veza-backend-api/internal/api/routes_marketplace.go +++ b/veza-backend-api/internal/api/routes_marketplace.go @@ -8,10 +8,25 @@ import ( "veza-backend-api/internal/config" "veza-backend-api/internal/core/marketplace" "veza-backend-api/internal/handlers" + "veza-backend-api/internal/middleware" "veza-backend-api/internal/services" "veza-backend-api/internal/services/hyperswitch" ) +// authForMarketplaceInterface has the auth methods needed by marketplace routes (allows test overrides) +type authForMarketplaceInterface interface { + RequireAuth() gin.HandlerFunc + RequireContentCreatorRole() gin.HandlerFunc + RequireOwnershipOrAdmin(string, middleware.ResourceOwnerResolver) gin.HandlerFunc +} + +func (r *APIRouter) authForMarketplace() authForMarketplaceInterface { + if r.config.AuthMiddlewareOverride != nil { + return r.config.AuthMiddlewareOverride.(authForMarketplaceInterface) + } + return r.config.AuthMiddleware +} + // setupMarketplaceRoutes configure les routes de la marketplace func (r *APIRouter) setupMarketplaceRoutes(router *gin.RouterGroup) { uploadDir := r.config.UploadDir @@ -19,26 +34,40 @@ func (r *APIRouter) setupMarketplaceRoutes(router *gin.RouterGroup) { uploadDir = "uploads/tracks" } - storageService := services.NewTrackStorageService(uploadDir, false, r.logger) - opts := []marketplace.ServiceOption{} - if r.config.HyperswitchEnabled && r.config.HyperswitchAPIKey != "" && r.config.HyperswitchURL != "" { - if r.config.SentryEnvironment == config.EnvProduction && !r.config.HyperswitchLiveMode { - r.logger.Warn("Hyperswitch is enabled in production but HYPERSWITCH_LIVE_MODE=false; using test keys", - zap.String("hint", "Set HYPERSWITCH_LIVE_MODE=true and use live API keys for production payments")) + var marketService marketplace.MarketplaceService + var marketServicePtr *marketplace.Service + if r.config.MarketplaceServiceOverride != nil { + marketService = r.config.MarketplaceServiceOverride.(marketplace.MarketplaceService) + marketServicePtr = r.config.MarketplaceServiceOverride.(*marketplace.Service) + } else { + storageService := services.NewTrackStorageService(uploadDir, false, r.logger) + opts := []marketplace.ServiceOption{} + if r.config.HyperswitchEnabled && r.config.HyperswitchAPIKey != "" && r.config.HyperswitchURL != "" { + if r.config.SentryEnvironment == config.EnvProduction && !r.config.HyperswitchLiveMode { + r.logger.Warn("Hyperswitch is enabled in production but HYPERSWITCH_LIVE_MODE=false; using test keys", + zap.String("hint", "Set HYPERSWITCH_LIVE_MODE=true and use live API keys for production payments")) + } + hsClient := hyperswitch.NewClient(r.config.HyperswitchURL, r.config.HyperswitchAPIKey) + hsProvider := hyperswitch.NewProvider(hsClient) + opts = append(opts, + marketplace.WithPaymentProvider(hsProvider), + marketplace.WithHyperswitchConfig(true, r.config.CheckoutSuccessURL), + ) } - hsClient := hyperswitch.NewClient(r.config.HyperswitchURL, r.config.HyperswitchAPIKey) - hsProvider := hyperswitch.NewProvider(hsClient) - opts = append(opts, - marketplace.WithPaymentProvider(hsProvider), - marketplace.WithHyperswitchConfig(true, r.config.CheckoutSuccessURL), - ) + if r.config.StripeConnectEnabled && r.config.StripeConnectSecretKey != "" { + scs := services.NewStripeConnectService(r.db.GormDB, r.config.StripeConnectSecretKey, r.logger) + opts = append(opts, marketplace.WithTransferService(scs, r.config.PlatformFeeRate)) + } + svc := marketplace.NewService(r.db.GormDB, r.logger, storageService, opts...) + marketService = svc + marketServicePtr = svc } + var stripeConnectSvc *services.StripeConnectService if r.config.StripeConnectEnabled && r.config.StripeConnectSecretKey != "" { stripeConnectSvc = services.NewStripeConnectService(r.db.GormDB, r.config.StripeConnectSecretKey, r.logger) - opts = append(opts, marketplace.WithTransferService(stripeConnectSvc, r.config.PlatformFeeRate)) } - marketService := marketplace.NewService(r.db.GormDB, r.logger, storageService, opts...) + productPreviewDir := uploadDir if productPreviewDir == "" { productPreviewDir = "uploads" @@ -51,13 +80,13 @@ func (r *APIRouter) setupMarketplaceRoutes(router *gin.RouterGroup) { group.GET("/products/:id/preview", marketHandler.StreamProductPreview) group.GET("/products/:id/reviews", marketHandler.ListReviews) - if r.config.AuthMiddleware != nil { + if auth := r.authForMarketplace(); auth != nil { protected := group.Group("") - protected.Use(r.config.AuthMiddleware.RequireAuth()) + protected.Use(auth.RequireAuth()) r.applyCSRFProtection(protected) createGroup := protected.Group("") - createGroup.Use(r.config.AuthMiddleware.RequireContentCreatorRole()) + createGroup.Use(auth.RequireContentCreatorRole()) createGroup.POST("/products", marketHandler.CreateProduct) createGroup.POST("/products/:id/preview", marketHandler.UploadProductPreview) @@ -73,8 +102,8 @@ func (r *APIRouter) setupMarketplaceRoutes(router *gin.RouterGroup) { } return product.SellerID, nil } - protected.PUT("/products/:id", r.config.AuthMiddleware.RequireOwnershipOrAdmin("product", productOwnerResolver), marketHandler.UpdateProduct) - protected.PUT("/products/:id/images", r.config.AuthMiddleware.RequireOwnershipOrAdmin("product", productOwnerResolver), marketHandler.UpdateProductImages) + protected.PUT("/products/:id", auth.RequireOwnershipOrAdmin("product", productOwnerResolver), marketHandler.UpdateProduct) + protected.PUT("/products/:id/images", auth.RequireOwnershipOrAdmin("product", productOwnerResolver), marketHandler.UpdateProductImages) protected.GET("/orders", marketHandler.ListOrders) protected.GET("/orders/:id", marketHandler.GetOrder) @@ -85,17 +114,17 @@ func (r *APIRouter) setupMarketplaceRoutes(router *gin.RouterGroup) { protected.GET("/licenses/mine", marketHandler.GetMyLicenses) protected.POST("/products/:id/reviews", marketHandler.CreateReview) - marketplaceExtHandler := handlers.NewMarketplaceExtHandler(marketService, r.logger) + marketplaceExtHandler := handlers.NewMarketplaceExtHandler(marketServicePtr, r.logger) protected.GET("/wishlist", marketplaceExtHandler.GetWishlist) protected.POST("/wishlist", marketplaceExtHandler.AddToWishlist) protected.DELETE("/wishlist/:productId", marketplaceExtHandler.RemoveFromWishlist) } sell := router.Group("/sell") - if r.config.AuthMiddleware != nil { + if auth := r.authForMarketplace(); auth != nil { sellProtected := sell.Group("") - sellProtected.Use(r.config.AuthMiddleware.RequireAuth()) - sellProtected.Use(r.config.AuthMiddleware.RequireContentCreatorRole()) + sellProtected.Use(auth.RequireAuth()) + sellProtected.Use(auth.RequireContentCreatorRole()) r.applyCSRFProtection(sellProtected) sellProtected.GET("/stats", marketHandler.GetSellStats) sellProtected.GET("/stats/evolution", marketHandler.GetSellStatsEvolution) @@ -110,12 +139,12 @@ func (r *APIRouter) setupMarketplaceRoutes(router *gin.RouterGroup) { } commerce := router.Group("/commerce") - if r.config.AuthMiddleware != nil { + if auth := r.authForMarketplace(); auth != nil { cartProtected := commerce.Group("") - cartProtected.Use(r.config.AuthMiddleware.RequireAuth()) + cartProtected.Use(auth.RequireAuth()) r.applyCSRFProtection(cartProtected) - marketplaceExtHandler := handlers.NewMarketplaceExtHandler(marketService, r.logger) + marketplaceExtHandler := handlers.NewMarketplaceExtHandler(marketServicePtr, r.logger) cartProtected.GET("/cart", marketplaceExtHandler.GetCart) cartProtected.GET("/promo/:code", marketplaceExtHandler.ValidatePromo) cartProtected.POST("/cart/items", marketplaceExtHandler.AddToCart) diff --git a/veza-backend-api/internal/api/routes_webhooks.go b/veza-backend-api/internal/api/routes_webhooks.go index 66227df66..c6ca1a71d 100644 --- a/veza-backend-api/internal/api/routes_webhooks.go +++ b/veza-backend-api/internal/api/routes_webhooks.go @@ -85,6 +85,9 @@ func (r *APIRouter) hyperswitchWebhookHandler() gin.HandlerFunc { // getMarketplaceService returns the marketplace service with Hyperswitch wiring. // Used by webhook handler; mirrors setupMarketplaceRoutes service creation. func (r *APIRouter) getMarketplaceService() marketplace.MarketplaceService { + if r.config.MarketplaceServiceOverride != nil { + return r.config.MarketplaceServiceOverride.(marketplace.MarketplaceService) + } uploadDir := r.config.UploadDir if uploadDir == "" { uploadDir = "uploads/tracks" diff --git a/veza-backend-api/internal/config/config.go b/veza-backend-api/internal/config/config.go index 8c66b3848..cde20b6bf 100644 --- a/veza-backend-api/internal/config/config.go +++ b/veza-backend-api/internal/config/config.go @@ -146,6 +146,12 @@ type Config struct { HyperswitchWebhookSecret string // Webhook signature verification secret CheckoutSuccessURL string // URL to redirect after successful payment (e.g. /checkout/success) + // Test-only: when set, used instead of creating marketplace from config (integration tests) + MarketplaceServiceOverride interface{} + + // Test-only: when set, used instead of AuthMiddleware (integration tests, e.g. X-User-ID header) + AuthMiddlewareOverride interface{} + // Stripe Connect (Seller Payout v0.602) StripeConnectEnabled bool // STRIPE_CONNECT_ENABLED StripeConnectSecretKey string // STRIPE_SECRET_KEY (for server-side Stripe API calls) diff --git a/veza-backend-api/tests/integration/payment_flow_test.go b/veza-backend-api/tests/integration/payment_flow_test.go new file mode 100644 index 000000000..ed6cc4542 --- /dev/null +++ b/veza-backend-api/tests/integration/payment_flow_test.go @@ -0,0 +1,270 @@ +//go:build integration +// +build integration + +package integration + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "veza-backend-api/internal/api" + "veza-backend-api/internal/config" + "veza-backend-api/internal/core/marketplace" + "veza-backend-api/internal/database" + "veza-backend-api/internal/metrics" + "veza-backend-api/internal/middleware" + "veza-backend-api/internal/models" + "veza-backend-api/internal/services" +) + +// mockPaymentProvider returns fixed payment_id for CreatePayment (no real Hyperswitch call) +type mockPaymentProvider struct { + paymentID string + clientSecret string +} + +func (m *mockPaymentProvider) CreatePayment(_ context.Context, _ int64, _ string, _ string, _ string, _ map[string]string) (string, string, error) { + return m.paymentID, m.clientSecret, nil +} + +func (m *mockPaymentProvider) GetPayment(_ context.Context, _ string) (string, error) { + return "succeeded", nil +} + +// mockTransferService records CreateTransfer calls +type mockTransferService struct { + calls []struct { + SellerID uuid.UUID + Amount int64 + Currency string + OrderID string + } +} + +func (m *mockTransferService) CreateTransfer(_ context.Context, sellerUserID uuid.UUID, amount int64, currency, orderID string) error { + m.calls = append(m.calls, struct { + SellerID uuid.UUID + Amount int64 + Currency string + OrderID string + }{sellerUserID, amount, currency, orderID}) + return nil +} + +// testAuthMiddleware reads X-User-ID header and sets user_id in context (for integration tests) +type testAuthMiddleware struct{} + +func (t *testAuthMiddleware) RequireAuth() gin.HandlerFunc { + return func(c *gin.Context) { + if userIDStr := c.GetHeader("X-User-ID"); userIDStr != "" { + if userID, err := uuid.Parse(userIDStr); err == nil { + c.Set("user_id", userID) + c.Next() + return + } + } + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + } +} + +func (t *testAuthMiddleware) RequireContentCreatorRole() gin.HandlerFunc { + return t.RequireAuth() +} + +func (t *testAuthMiddleware) RequireOwnershipOrAdmin(_ string, _ middleware.ResourceOwnerResolver) gin.HandlerFunc { + return t.RequireAuth() +} + +func (t *testAuthMiddleware) RequirePermission(_ string) gin.HandlerFunc { + return t.RequireAuth() +} + +func (t *testAuthMiddleware) RequireAdmin() gin.HandlerFunc { + return t.RequireAuth() +} + +func (t *testAuthMiddleware) OptionalAuth() gin.HandlerFunc { + return func(c *gin.Context) { c.Next() } +} + +func (t *testAuthMiddleware) RefreshToken() gin.HandlerFunc { + return func(c *gin.Context) { c.AbortWithStatus(http.StatusNotImplemented) } +} + +func setupPaymentFlowRouter(t *testing.T, db *gorm.DB, marketService *marketplace.Service) *gin.Engine { + t.Helper() + os.Setenv("ENABLE_CLAMAV", "false") + os.Setenv("CLAMAV_REQUIRED", "false") + + gin.SetMode(gin.TestMode) + router := gin.New() + + sqlDB, err := db.DB() + require.NoError(t, err) + + vezaDB := &database.Database{ + DB: sqlDB, + GormDB: db, + Logger: zap.NewNop(), + } + + cfg := &config.Config{ + HyperswitchWebhookSecret: "test-secret-at-least-32-chars-long", + JWTSecret: "test-jwt-secret-key-minimum-32-characters-long", + JWTIssuer: "veza-api", + JWTAudience: "veza-app", + Logger: zap.NewNop(), + RedisClient: nil, + ErrorMetrics: metrics.NewErrorMetrics(), + UploadDir: "uploads/test", + Env: "development", + Database: vezaDB, + CORSOrigins: []string{"*"}, + HandlerTimeout: 30 * time.Second, + RateLimitLimit: 100, + RateLimitWindow: 60, + AuthRateLimitLoginAttempts: 10, + AuthRateLimitLoginWindow: 15, + MarketplaceServiceOverride: marketService, + } + require.NoError(t, cfg.InitServicesForTest()) + require.NoError(t, cfg.InitMiddlewaresForTest()) + cfg.AuthMiddlewareOverride = &testAuthMiddleware{} + + apiRouter := api.NewAPIRouter(vezaDB, cfg) + require.NoError(t, apiRouter.Setup(router)) + return router +} + +// TestPaymentFlow_E2E_CartCheckoutWebhook verifies the full payment flow: +// add to cart -> checkout -> webhook payment_succeeded -> order completed, license created, transfer initiated +func TestPaymentFlow_E2E_CartCheckoutWebhook(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &models.User{}, + &models.Track{}, + &marketplace.Product{}, + &marketplace.Order{}, + &marketplace.OrderItem{}, + &marketplace.License{}, + &marketplace.SellerTransfer{}, + )) + // CartItem uses gen_random_uuid() (PostgreSQL) - create manually for SQLite + require.NoError(t, db.Exec(` + CREATE TABLE IF NOT EXISTS cart_items ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + product_id TEXT NOT NULL, + quantity INTEGER DEFAULT 1, + created_at DATETIME, + updated_at DATETIME + ) + `).Error) + db.Callback().Create().Before("gorm:create").Register("generate_uuid_cart_item", func(d *gorm.DB) { + if item, ok := d.Statement.Dest.(*marketplace.CartItem); ok && item.ID == uuid.Nil { + item.ID = uuid.New() + } + }) + + buyerID := uuid.New() + sellerID := uuid.New() + trackID := uuid.New() + + require.NoError(t, db.Create(&models.User{ID: buyerID}).Error) + require.NoError(t, db.Create(&models.User{ID: sellerID}).Error) + require.NoError(t, db.Create(&models.Track{ID: trackID, UserID: sellerID, FilePath: "/test.mp3"}).Error) + + product := &marketplace.Product{ + ID: uuid.New(), + SellerID: sellerID, + Title: "Test Product", + Price: 9.99, + ProductType: "track", + TrackID: &trackID, + Status: marketplace.ProductStatusActive, + } + require.NoError(t, db.Create(product).Error) + + mockPay := &mockPaymentProvider{paymentID: "pay_mock_123", clientSecret: "pi_secret_xxx"} + mockTransfer := &mockTransferService{} + storageService := services.NewTrackStorageService("uploads/test", false, zap.NewNop()) + marketService := marketplace.NewService(db, zap.NewNop(), storageService, + marketplace.WithPaymentProvider(mockPay), + marketplace.WithHyperswitchConfig(true, "/purchases"), + marketplace.WithTransferService(mockTransfer, 0.10), + ) + + router := setupPaymentFlowRouter(t, db, marketService) + + // Step 1: Add to cart + addCartBody, _ := json.Marshal(map[string]interface{}{ + "product_id": product.ID.String(), + "quantity": 1, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/commerce/cart/items", bytes.NewReader(addCartBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-ID", buyerID.String()) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + require.Equal(t, http.StatusCreated, w.Code, "add to cart must succeed: %s", w.Body.String()) + + // Step 2: Checkout + req = httptest.NewRequest(http.MethodPost, "/api/v1/commerce/cart/checkout", bytes.NewReader([]byte("{}"))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-ID", buyerID.String()) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + require.Equal(t, http.StatusCreated, w.Code, "checkout must succeed: %s", w.Body.String()) + + var checkoutResp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &checkoutResp)) + data, ok := checkoutResp["data"].(map[string]interface{}) + require.True(t, ok) + orderData, ok := data["order"].(map[string]interface{}) + require.True(t, ok) + orderIDStr, ok := orderData["id"].(string) + require.True(t, ok) + assert.NotEmpty(t, data["client_secret"], "client_secret must be present") + assert.Equal(t, "pay_mock_123", data["payment_id"], "payment_id must match mock") + + // Step 3: Simulate webhook payment_succeeded + payload, _ := json.Marshal(map[string]string{ + "payment_id": "pay_mock_123", + "status": "succeeded", + }) + signature := computeWebhookSignature(payload, "test-secret-at-least-32-chars-long") + req = httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/hyperswitch", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-webhook-signature-512", signature) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, "webhook must succeed: %s", w.Body.String()) + + // Assertions: order completed, license created, transfer initiated + orderID, _ := uuid.Parse(orderIDStr) + var order marketplace.Order + require.NoError(t, db.First(&order, orderID).Error) + assert.Equal(t, "completed", order.Status) + + var licenses []marketplace.License + require.NoError(t, db.Where("order_id = ?", orderID).Find(&licenses).Error) + assert.Len(t, licenses, 1, "must have 1 license") + + assert.Len(t, mockTransfer.calls, 1, "transfer must be initiated") + assert.Equal(t, sellerID, mockTransfer.calls[0].SellerID) +} diff --git a/veza-backend-api/tests/integration/refund_flow_test.go b/veza-backend-api/tests/integration/refund_flow_test.go new file mode 100644 index 000000000..a400a7c06 --- /dev/null +++ b/veza-backend-api/tests/integration/refund_flow_test.go @@ -0,0 +1,174 @@ +//go:build integration +// +build integration + +package integration + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "veza-backend-api/internal/api" + "veza-backend-api/internal/config" + "veza-backend-api/internal/core/marketplace" + "veza-backend-api/internal/database" + "veza-backend-api/internal/metrics" + "veza-backend-api/internal/models" + "veza-backend-api/internal/services" +) + +// mockRefundPaymentProvider implements PaymentProvider and Refund for refund flow test +type mockRefundPaymentProvider struct { + refundErr error +} + +func (m *mockRefundPaymentProvider) CreatePayment(_ context.Context, _ int64, _ string, _ string, _ string, _ map[string]string) (string, string, error) { + return "pay_refund_mock", "secret", nil +} + +func (m *mockRefundPaymentProvider) GetPayment(_ context.Context, _ string) (string, error) { + return "succeeded", nil +} + +func (m *mockRefundPaymentProvider) Refund(_ context.Context, _ string, _ *int64, _ string) error { + return m.refundErr +} + +func setupRefundFlowRouter(t *testing.T, db *gorm.DB, marketService *marketplace.Service) *gin.Engine { + t.Helper() + os.Setenv("ENABLE_CLAMAV", "false") + os.Setenv("CLAMAV_REQUIRED", "false") + + gin.SetMode(gin.TestMode) + router := gin.New() + + sqlDB, err := db.DB() + require.NoError(t, err) + + vezaDB := &database.Database{ + DB: sqlDB, + GormDB: db, + Logger: zap.NewNop(), + } + + cfg := &config.Config{ + HyperswitchWebhookSecret: "test-secret", + JWTSecret: "test-jwt-secret-key-minimum-32-characters-long", + JWTIssuer: "veza-api", + JWTAudience: "veza-app", + Logger: zap.NewNop(), + RedisClient: nil, + ErrorMetrics: metrics.NewErrorMetrics(), + UploadDir: "uploads/test", + Env: "development", + Database: vezaDB, + CORSOrigins: []string{"*"}, + HandlerTimeout: 30 * time.Second, + RateLimitLimit: 100, + RateLimitWindow: 60, + AuthRateLimitLoginAttempts: 10, + AuthRateLimitLoginWindow: 15, + MarketplaceServiceOverride: marketService, + AuthMiddlewareOverride: &testAuthMiddleware{}, + } + require.NoError(t, cfg.InitServicesForTest()) + require.NoError(t, cfg.InitMiddlewaresForTest()) + + apiRouter := api.NewAPIRouter(vezaDB, cfg) + require.NoError(t, apiRouter.Setup(router)) + return router +} + +// TestRefundFlow_CompletedOrder_Refund_RevokesLicense verifies: order completed -> refund request -> +// order status refunded, licence revoked. +func TestRefundFlow_CompletedOrder_Refund_RevokesLicense(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &models.User{}, + &models.Track{}, + &marketplace.Product{}, + &marketplace.Order{}, + &marketplace.OrderItem{}, + &marketplace.License{}, + &marketplace.SellerTransfer{}, + )) + + buyerID := uuid.New() + sellerID := uuid.New() + trackID := uuid.New() + productID := uuid.New() + orderID := uuid.New() + + require.NoError(t, db.Create(&models.User{ID: buyerID}).Error) + require.NoError(t, db.Create(&models.User{ID: sellerID}).Error) + require.NoError(t, db.Create(&models.Track{ID: trackID, UserID: sellerID, FilePath: "/t.mp3"}).Error) + require.NoError(t, db.Create(&marketplace.Product{ + ID: productID, + SellerID: sellerID, + Title: "Test Product", + Price: 9.99, + ProductType: "track", + TrackID: &trackID, + Status: marketplace.ProductStatusActive, + }).Error) + require.NoError(t, db.Create(&marketplace.Order{ + ID: orderID, + BuyerID: buyerID, + TotalAmount: 9.99, + Currency: "EUR", + Status: "completed", + HyperswitchPaymentID: "pay_refund_mock", + }).Error) + require.NoError(t, db.Create(&marketplace.OrderItem{ + ID: uuid.New(), + OrderID: orderID, + ProductID: productID, + Price: 9.99, + }).Error) + require.NoError(t, db.Create(&marketplace.License{ + ID: uuid.New(), + BuyerID: buyerID, + TrackID: trackID, + ProductID: productID, + OrderID: orderID, + Type: marketplace.LicenseBasic, + }).Error) + + mockPay := &mockRefundPaymentProvider{} + storageService := services.NewTrackStorageService("uploads/test", false, zap.NewNop()) + marketService := marketplace.NewService(db, zap.NewNop(), storageService, marketplace.WithPaymentProvider(mockPay)) + + router := setupRefundFlowRouter(t, db, marketService) + + body, _ := json.Marshal(map[string]string{"reason": "Customer request"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/marketplace/orders/"+orderID.String()+"/refund", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-ID", buyerID.String()) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code, "refund must succeed: %s", w.Body.String()) + + var order marketplace.Order + require.NoError(t, db.First(&order, orderID).Error) + assert.Equal(t, "refunded", order.Status) + + var licenses []marketplace.License + require.NoError(t, db.Where("order_id = ?", orderID).Find(&licenses).Error) + require.Len(t, licenses, 1) + assert.NotNil(t, licenses[0].RevokedAt, "license must be revoked") +} diff --git a/veza-backend-api/tests/integration/webhook_idempotency_test.go b/veza-backend-api/tests/integration/webhook_idempotency_test.go new file mode 100644 index 000000000..d9816d05e --- /dev/null +++ b/veza-backend-api/tests/integration/webhook_idempotency_test.go @@ -0,0 +1,157 @@ +//go:build integration +// +build integration + +package integration + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "veza-backend-api/internal/api" + "veza-backend-api/internal/config" + "veza-backend-api/internal/core/marketplace" + "veza-backend-api/internal/database" + "veza-backend-api/internal/metrics" + "veza-backend-api/internal/models" + "veza-backend-api/internal/services" +) + +func setupWebhookIdempotencyRouter(t *testing.T, db *gorm.DB, marketService *marketplace.Service) *gin.Engine { + t.Helper() + os.Setenv("ENABLE_CLAMAV", "false") + os.Setenv("CLAMAV_REQUIRED", "false") + + gin.SetMode(gin.TestMode) + router := gin.New() + + sqlDB, err := db.DB() + require.NoError(t, err) + + vezaDB := &database.Database{ + DB: sqlDB, + GormDB: db, + Logger: zap.NewNop(), + } + + cfg := &config.Config{ + HyperswitchWebhookSecret: "test-secret-at-least-32-chars-long", + JWTSecret: "test-jwt-secret-key-minimum-32-characters-long", + JWTIssuer: "veza-api", + JWTAudience: "veza-app", + Logger: zap.NewNop(), + RedisClient: nil, + ErrorMetrics: metrics.NewErrorMetrics(), + UploadDir: "uploads/test", + Env: "development", + Database: vezaDB, + CORSOrigins: []string{"*"}, + HandlerTimeout: 30 * time.Second, + RateLimitLimit: 100, + RateLimitWindow: 60, + AuthRateLimitLoginAttempts: 10, + AuthRateLimitLoginWindow: 15, + MarketplaceServiceOverride: marketService, + } + require.NoError(t, cfg.InitServicesForTest()) + require.NoError(t, cfg.InitMiddlewaresForTest()) + + apiRouter := api.NewAPIRouter(vezaDB, cfg) + require.NoError(t, apiRouter.Setup(router)) + return router +} + +// TestWebhookIdempotency_ThreeIdenticalWebhooks_CreatesOneOrder verifies that sending +// the same payment_succeeded webhook 3 times results in 1 completed order and 1 license. +func TestWebhookIdempotency_ThreeIdenticalWebhooks_CreatesOneOrder(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + // Ensure single connection for sqlite :memory: (each new connection gets empty DB) + sqlDB, _ := db.DB() + sqlDB.SetMaxOpenConns(1) + require.NoError(t, db.AutoMigrate( + &models.User{}, + &models.Track{}, + &marketplace.Product{}, + &marketplace.Order{}, + &marketplace.OrderItem{}, + &marketplace.License{}, + &marketplace.SellerTransfer{}, + )) + + buyerID := uuid.New() + sellerID := uuid.New() + trackID := uuid.New() + + require.NoError(t, db.Create(&models.User{ID: buyerID}).Error) + require.NoError(t, db.Create(&models.User{ID: sellerID}).Error) + require.NoError(t, db.Create(&models.Track{ID: trackID, UserID: sellerID, FilePath: "/test.mp3"}).Error) + + product := &marketplace.Product{ + ID: uuid.New(), + SellerID: sellerID, + Title: "Test Product", + Price: 9.99, + ProductType: "track", + TrackID: &trackID, + Status: marketplace.ProductStatusActive, + } + require.NoError(t, db.Create(product).Error) + + order := &marketplace.Order{ + ID: uuid.New(), + BuyerID: buyerID, + TotalAmount: 9.99, + Currency: "EUR", + Status: "pending", + HyperswitchPaymentID: "pay_idem_123", + } + require.NoError(t, db.Create(order).Error) + require.NoError(t, db.Create(&marketplace.OrderItem{ + ID: uuid.New(), + OrderID: order.ID, + ProductID: product.ID, + Price: 9.99, + }).Error) + + storageService := services.NewTrackStorageService("uploads/test", false, zap.NewNop()) + marketService := marketplace.NewService(db, zap.NewNop(), storageService) + + router := setupWebhookIdempotencyRouter(t, db, marketService) + + payload, _ := json.Marshal(map[string]string{ + "payment_id": "pay_idem_123", + "status": "succeeded", + }) + signature := computeWebhookSignature(payload, "test-secret-at-least-32-chars-long") + + for i := 0; i < 3; i++ { + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/hyperswitch", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-webhook-signature-512", signature) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, "webhook call %d must return 200", i+1) + } + + var orders []marketplace.Order + require.NoError(t, db.Where("hyperswitch_payment_id = ?", "pay_idem_123").Find(&orders).Error) + assert.Len(t, orders, 1, "must have exactly 1 order") + assert.Equal(t, "completed", orders[0].Status, "order must be completed") + + var licenses []marketplace.License + require.NoError(t, db.Where("order_id = ?", order.ID).Find(&licenses).Error) + assert.Len(t, licenses, 1, "must have exactly 1 license") +} diff --git a/veza-backend-api/tests/integration/webhook_security_test.go b/veza-backend-api/tests/integration/webhook_security_test.go new file mode 100644 index 000000000..fb96be2e2 --- /dev/null +++ b/veza-backend-api/tests/integration/webhook_security_test.go @@ -0,0 +1,126 @@ +//go:build integration +// +build integration + +package integration + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "veza-backend-api/internal/api" + "veza-backend-api/internal/config" + "veza-backend-api/internal/core/marketplace" + "veza-backend-api/internal/database" + "veza-backend-api/internal/metrics" + "veza-backend-api/internal/models" +) + +func setupWebhookSecurityRouter(t *testing.T, webhookSecret string) *gin.Engine { + t.Helper() + os.Setenv("ENABLE_CLAMAV", "false") + os.Setenv("CLAMAV_REQUIRED", "false") + + gin.SetMode(gin.TestMode) + router := gin.New() + + gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, gormDB.AutoMigrate( + &models.User{}, + &models.Track{}, + &marketplace.Product{}, + &marketplace.Order{}, + &marketplace.OrderItem{}, + &marketplace.License{}, + &marketplace.SellerTransfer{}, + )) + sqlDB, err := gormDB.DB() + require.NoError(t, err) + + vezaDB := &database.Database{ + DB: sqlDB, + GormDB: gormDB, + Logger: zap.NewNop(), + } + + cfg := &config.Config{ + HyperswitchWebhookSecret: webhookSecret, + JWTSecret: "test-jwt-secret-key-minimum-32-characters-long", + JWTIssuer: "veza-api", + JWTAudience: "veza-app", + Logger: zap.NewNop(), + RedisClient: nil, + ErrorMetrics: metrics.NewErrorMetrics(), + UploadDir: "uploads/test", + Env: "development", + Database: vezaDB, + CORSOrigins: []string{"*"}, + HandlerTimeout: 30 * time.Second, + RateLimitLimit: 100, + RateLimitWindow: 60, + AuthRateLimitLoginAttempts: 10, + AuthRateLimitLoginWindow: 15, + } + require.NoError(t, cfg.InitServicesForTest()) + require.NoError(t, cfg.InitMiddlewaresForTest()) + + apiRouter := api.NewAPIRouter(vezaDB, cfg) + require.NoError(t, apiRouter.Setup(router)) + return router +} + +// TestWebhookSecurity_EmptySecret_Returns500 verifies that when HyperswitchWebhookSecret +// is empty, the webhook handler returns 500 (VEZA-SEC-005). +func TestWebhookSecurity_EmptySecret_Returns500(t *testing.T) { + router := setupWebhookSecurityRouter(t, "") + + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/hyperswitch", bytes.NewReader([]byte(`{"test": true}`))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code, "empty webhook secret must return 500") +} + +// TestWebhookSecurity_InvalidSignature_Returns401 verifies that with a configured secret, +// invalid or missing signature returns 401. +func TestWebhookSecurity_InvalidSignature_Returns401(t *testing.T) { + router := setupWebhookSecurityRouter(t, "test-secret-at-least-32-chars-long") + + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/hyperswitch", bytes.NewReader([]byte(`{"payment_id":"pay_123","status":"succeeded"}`))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-webhook-signature-512", "invalid_signature") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code, "invalid signature must return 401") +} + +// TestWebhookSecurity_ValidSignature_Returns200 verifies that with a configured secret +// and valid HMAC-SHA512 signature, the webhook returns 200. +func TestWebhookSecurity_ValidSignature_Returns200(t *testing.T) { + secret := "test-secret-at-least-32-chars-long" + payload := []byte(`{"payment_id":"pay_nonexistent","status":"succeeded"}`) + signature := computeWebhookSignature(payload, secret) + + router := setupWebhookSecurityRouter(t, secret) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/hyperswitch", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-webhook-signature-512", signature) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code, "valid signature must return 200") +} diff --git a/veza-backend-api/tests/integration/webhook_test_helpers.go b/veza-backend-api/tests/integration/webhook_test_helpers.go new file mode 100644 index 000000000..3480b09dc --- /dev/null +++ b/veza-backend-api/tests/integration/webhook_test_helpers.go @@ -0,0 +1,17 @@ +//go:build integration +// +build integration + +package integration + +import ( + "crypto/hmac" + "crypto/sha512" + "encoding/hex" +) + +// computeWebhookSignature computes HMAC-SHA512 signature for Hyperswitch webhook payload. +func computeWebhookSignature(payload []byte, secret string) string { + mac := hmac.New(sha512.New, []byte(secret)) + mac.Write(payload) + return hex.EncodeToString(mac.Sum(nil)) +}