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.
This commit is contained in:
parent
ee32aec970
commit
0907446958
5 changed files with 279 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
256
veza-backend-api/internal/integration/e2e_test.go
Normal file
256
veza-backend-api/internal/integration/e2e_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue