feat(v0.912): Cashflow - payment E2E integration tests
- Add MarketplaceServiceOverride and AuthMiddlewareOverride to config for tests - Wire overrides in routes_webhooks and routes_marketplace (authForMarketplaceInterface) - payment_flow_test: cart -> checkout -> webhook -> order completed, license, transfer - webhook_idempotency_test: 3 identical webhooks -> 1 order, 1 license - webhook_security_test: empty secret 500, invalid sig 401, valid sig 200 - refund_flow_test: completed order -> refund -> order refunded, license revoked - Shared computeWebhookSignature helper in webhook_test_helpers.go - SetMaxOpenConns(1) for sqlite :memory: in idempotency test to avoid flakiness Ref: docs/ROADMAP_V09XX_TO_V1.md v0.912 Cashflow
This commit is contained in:
parent
4720bb20b2
commit
7cb4ef56e1
9 changed files with 809 additions and 27 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.911
|
||||
0.912
|
||||
|
|
|
|||
|
|
@ -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,6 +34,12 @@ func (r *APIRouter) setupMarketplaceRoutes(router *gin.RouterGroup) {
|
|||
uploadDir = "uploads/tracks"
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
|
|
@ -33,12 +54,20 @@ func (r *APIRouter) setupMarketplaceRoutes(router *gin.RouterGroup) {
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
270
veza-backend-api/tests/integration/payment_flow_test.go
Normal file
270
veza-backend-api/tests/integration/payment_flow_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
174
veza-backend-api/tests/integration/refund_flow_test.go
Normal file
174
veza-backend-api/tests/integration/refund_flow_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
157
veza-backend-api/tests/integration/webhook_idempotency_test.go
Normal file
157
veza-backend-api/tests/integration/webhook_idempotency_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
126
veza-backend-api/tests/integration/webhook_security_test.go
Normal file
126
veza-backend-api/tests/integration/webhook_security_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
17
veza-backend-api/tests/integration/webhook_test_helpers.go
Normal file
17
veza-backend-api/tests/integration/webhook_test_helpers.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
Loading…
Reference in a new issue