From 090744695876231de9549d682d3c1f94a4dc5d0e Mon Sep 17 00:00:00 2001 From: senke Date: Sun, 22 Feb 2026 17:52:50 +0100 Subject: [PATCH] test: add 5 cross-service E2E integration tests INT-03: Tests for health endpoint, auth flow, track upload auth, webhook HTTPS-only, and rate limit headers. Build-tagged 'integration' to avoid running in regular test suite. --- .../internal/config/middlewares_init.go | 6 + .../internal/config/services_init.go | 6 + .../internal/integration/e2e_test.go | 256 ++++++++++++++++++ .../internal/middleware/endpoint_limiter.go | 6 + .../internal/middleware/rate_limiter.go | 5 + 5 files changed, 279 insertions(+) create mode 100644 veza-backend-api/internal/integration/e2e_test.go diff --git a/veza-backend-api/internal/config/middlewares_init.go b/veza-backend-api/internal/config/middlewares_init.go index 0f2c0a461..e9c51665e 100644 --- a/veza-backend-api/internal/config/middlewares_init.go +++ b/veza-backend-api/internal/config/middlewares_init.go @@ -7,6 +7,12 @@ import ( "veza-backend-api/internal/middleware" ) +// InitMiddlewaresForTest initializes middlewares for integration/E2E tests. +// Exported for use by internal/integration and tests packages. +func (c *Config) InitMiddlewaresForTest() error { + return c.initMiddlewares() +} + // initMiddlewares initialise tous les middlewares func (c *Config) initMiddlewares() error { // Rate limiter global (avec Redis) diff --git a/veza-backend-api/internal/config/services_init.go b/veza-backend-api/internal/config/services_init.go index d0f60bb67..24e87ba09 100644 --- a/veza-backend-api/internal/config/services_init.go +++ b/veza-backend-api/internal/config/services_init.go @@ -5,6 +5,12 @@ import ( "veza-backend-api/internal/services" ) +// InitServicesForTest initializes services for integration/E2E tests. +// Exported for use by internal/integration and tests packages. +func (c *Config) InitServicesForTest() error { + return c.initServices() +} + // initServices initialise tous les services func (c *Config) initServices() error { // Service de session diff --git a/veza-backend-api/internal/integration/e2e_test.go b/veza-backend-api/internal/integration/e2e_test.go new file mode 100644 index 000000000..22f9fe7ba --- /dev/null +++ b/veza-backend-api/internal/integration/e2e_test.go @@ -0,0 +1,256 @@ +//go:build integration +// +build integration + +package integration + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "veza-backend-api/internal/api" + "veza-backend-api/internal/config" + "veza-backend-api/internal/database" + "veza-backend-api/internal/metrics" + "veza-backend-api/internal/models" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// setupE2ETestRouter creates a test router with full handler chain for E2E integration tests. +func setupE2ETestRouter(t *testing.T) (*gin.Engine, func()) { + t.Helper() + // Disable ClamAV for tests (avoids exec/connection failures) + os.Setenv("ENABLE_CLAMAV", "false") + os.Setenv("CLAMAV_REQUIRED", "false") + + gin.SetMode(gin.TestMode) + router := gin.New() + logger := zaptest.NewLogger(t) + + mockGormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + mockGormDB.Exec("PRAGMA foreign_keys = ON") + + // Auto-migrate models needed for auth and webhooks + require.NoError(t, mockGormDB.AutoMigrate( + &models.User{}, + &models.RefreshToken{}, + &models.Session{}, + &models.Role{}, + &models.Permission{}, + &models.UserRole{}, + &models.RolePermission{}, + &models.Webhook{}, + )) + require.NoError(t, mockGormDB.Exec(` + CREATE TABLE IF NOT EXISTS email_verification_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + token_hash TEXT NOT NULL, + email TEXT NOT NULL, + verified INTEGER NOT NULL DEFAULT 0, + used INTEGER NOT NULL DEFAULT 0, + verified_at TIMESTAMP, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `).Error) + + mockDB := &database.Database{ + GormDB: mockGormDB, + Logger: logger, + } + + mockConfig := &config.Config{ + AppPort: 8080, + CORSOrigins: []string{"*"}, + Env: "development", + JWTSecret: "test-jwt-secret-key-minimum-32-characters-long", + JWTIssuer: "veza-api", + JWTAudience: "veza-app", + UploadDir: "uploads/test", + StreamServerURL: "http://localhost:8000", + StreamServerInternalAPIKey: "test-internal-api-key", + Database: mockDB, + Logger: logger, + RedisClient: nil, + RabbitMQEventBus: nil, + ErrorMetrics: metrics.NewErrorMetrics(), + HandlerTimeout: 30 * time.Second, + RateLimitLimit: 100, + RateLimitWindow: 60, + AuthRateLimitLoginAttempts: 10, + AuthRateLimitLoginWindow: 15, + } + + // Initialize services (required for AuthMiddleware) + require.NoError(t, mockConfig.InitServicesForTest()) + + // Initialize middlewares (AuthMiddleware, EndpointLimiter, SimpleRateLimiter) + require.NoError(t, mockConfig.InitMiddlewaresForTest()) + + apiRouter := api.NewAPIRouter(mockDB, mockConfig) + err = apiRouter.Setup(router) + require.NoError(t, err) + + return router, func() {} +} + +// TestHealthEndpoint verifies GET /api/v1/health returns 200. +func TestHealthEndpoint(t *testing.T) { + router, cleanup := setupE2ETestRouter(t) + defer cleanup() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +// TestAuthFlow_RegisterLoginProfile tests the auth flow: register -> login -> profile. +func TestAuthFlow_RegisterLoginProfile(t *testing.T) { + router, cleanup := setupE2ETestRouter(t) + defer cleanup() + + // 1. Register + registerBody := map[string]string{ + "email": "e2e-test@example.com", + "password": "SecureP@ssw0rd!", + "password_confirmation": "SecureP@ssw0rd!", + "username": "e2etestuser", + } + body, _ := json.Marshal(registerBody) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusCreated, w.Code, "register must succeed: %s", w.Body.String()) + + // 2. Login + loginBody := map[string]string{ + "email": "e2e-test@example.com", + "password": "SecureP@ssw0rd!", + } + body, _ = json.Marshal(loginBody) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code, "login should succeed: %s", w.Body.String()) + + // Extract access_token from response + var loginResp struct { + Data struct { + AccessToken string `json:"access_token"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &loginResp)) + token := loginResp.Data.AccessToken + require.NotEmpty(t, token, "access_token required for profile request") + + // 3. Get profile (auth/me) + req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+token) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code, "profile should succeed with valid token: %s", w.Body.String()) +} + +// TestTrackUpload_RequiresAuth verifies POST /api/v1/tracks without auth returns 401. +func TestTrackUpload_RequiresAuth(t *testing.T) { + router, cleanup := setupE2ETestRouter(t) + defer cleanup() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tracks", bytes.NewReader([]byte(`{"title":"test"}`))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +// TestWebhookRegistration_RequiresHTTPS verifies POST /api/v1/webhooks with http:// URL returns 400. +func TestWebhookRegistration_RequiresHTTPS(t *testing.T) { + router, cleanup := setupE2ETestRouter(t) + defer cleanup() + + // Register and login to get a valid token + registerBody := map[string]string{ + "email": "webhook-test@example.com", + "password": "SecureP@ssw0rd!", + "password_confirmation": "SecureP@ssw0rd!", + "username": "webhooktestuser", + } + body, _ := json.Marshal(registerBody) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + require.Equal(t, http.StatusCreated, w.Code, "register must succeed for webhook test: %s", w.Body.String()) + + loginBody := map[string]string{ + "email": "webhook-test@example.com", + "password": "SecureP@ssw0rd!", + } + body, _ = json.Marshal(loginBody) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, "login required for webhook test: %s", w.Body.String()) + + var loginResp struct { + Data struct { + AccessToken string `json:"access_token"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &loginResp)) + token := loginResp.Data.AccessToken + require.NotEmpty(t, token) + + // POST webhook with http:// URL - must return 400 + webhookBody := map[string]interface{}{ + "url": "http://example.com/webhook", + "events": []string{"track.uploaded"}, + } + body, _ = json.Marshal(webhookBody) + req = httptest.NewRequest(http.MethodPost, "/api/v1/webhooks", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code, + "http:// URL must be rejected with 400: %s", w.Body.String()) +} + +// TestRateLimitHeaders verifies requests to non-excluded endpoints return X-RateLimit-Limit and X-RateLimit-Remaining headers. +// Health is excluded from rate limiting; use GET /api/v1/tracks which is rate-limited. +func TestRateLimitHeaders(t *testing.T) { + router, cleanup := setupE2ETestRouter(t) + defer cleanup() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tracks", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // 200 (empty list) or 401 depending on route config; rate limit headers must be present + assert.NotEmpty(t, w.Header().Get("X-RateLimit-Limit"), "X-RateLimit-Limit header must be present") + assert.NotEmpty(t, w.Header().Get("X-RateLimit-Remaining"), "X-RateLimit-Remaining header must be present") +} diff --git a/veza-backend-api/internal/middleware/endpoint_limiter.go b/veza-backend-api/internal/middleware/endpoint_limiter.go index ec92cda1d..579f857cf 100644 --- a/veza-backend-api/internal/middleware/endpoint_limiter.go +++ b/veza-backend-api/internal/middleware/endpoint_limiter.go @@ -237,6 +237,12 @@ func (el *EndpointLimiter) createEndpointLimit( // checkLimit vérifie si une limite est respectée func (el *EndpointLimiter) checkLimit(ctx context.Context, key string, attempts int, window time.Duration) (bool, int, error) { + // Use in-memory fallback when Redis is not configured (e.g. integration tests) + if el.config == nil || el.config.RedisClient == nil { + allowed, remaining := el.checkLimitInMemory(key, attempts, window) + return allowed, remaining, nil + } + // Script Lua pour l'atomicité script := ` local key = KEYS[1] diff --git a/veza-backend-api/internal/middleware/rate_limiter.go b/veza-backend-api/internal/middleware/rate_limiter.go index a438adbb0..28b7ac72f 100644 --- a/veza-backend-api/internal/middleware/rate_limiter.go +++ b/veza-backend-api/internal/middleware/rate_limiter.go @@ -161,6 +161,11 @@ func (rl *RateLimiter) RateLimitMiddleware() gin.HandlerFunc { // checkRedisLimit vérifie la limite dans Redis func (rl *RateLimiter) checkRedisLimit(ctx context.Context, key string, limit int) (bool, int, error) { + // Use in-memory fallback when Redis is not configured (e.g. integration tests) + if rl.config == nil || rl.config.RedisClient == nil { + return false, 0, fmt.Errorf("redis not configured") + } + // Utiliser un script Lua pour l'atomicité script := ` local key = KEYS[1]