veza/veza-backend-api/internal/handlers/common.go
senke 97069a2bf4 [BE-TEST-007] test: Add unit tests for webhook handlers
- Added comprehensive unit tests for all webhook handler methods:
  * RegisterWebhook (success, invalid URL, no events, unauthorized)
  * ListWebhooks (success)
  * DeleteWebhook (success, not found, invalid ID)
  * GetWebhookStats (success)
  * TestWebhook (success, not found)
  * RegenerateAPIKey (success, not found, invalid ID)
- Fixed validation bug in BindAndValidateJSON to properly return errors for binding validation failures
- Fixed compilation errors in profile_handler_test.go and room_handler_test.go
- All tests passing
2025-12-25 01:32:54 +01:00

530 lines
16 KiB
Go

package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"veza-backend-api/internal/dto"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/validators"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// ResponseData représente la structure standardisée des réponses API
type ResponseData struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Timestamp time.Time `json:"timestamp"`
RequestID string `json:"request_id,omitempty"`
}
// PaginationData représente les données de pagination
type PaginationData struct {
Page int `json:"page"`
Limit int `json:"limit"`
Total int64 `json:"total"`
TotalPages int `json:"total_pages"`
HasNext bool `json:"has_next"`
HasPrevious bool `json:"has_previous"`
NextCursor string `json:"next_cursor,omitempty"`
PreviousCursor string `json:"previous_cursor,omitempty"`
}
// PaginatedResponse représente une réponse paginée
type PaginatedResponse struct {
ResponseData
Pagination PaginationData `json:"pagination"`
}
// ValidationError et ValidationErrors sont maintenant dans internal/dto/validation.go
// pour éviter les cycles d'import. Utiliser dto.ValidationError et dto.ValidationErrors
// CommonHandler contient les dépendances communes aux handlers
type CommonHandler struct {
logger *zap.Logger
validator *validators.Validator // GO-013: Validator centralisé
}
// NewCommonHandler crée une nouvelle instance de CommonHandler
// GO-013: Initialise le validator centralisé
func NewCommonHandler(logger *zap.Logger) *CommonHandler {
return &CommonHandler{
logger: logger,
validator: validators.NewValidator(),
}
}
// ValidateRequest valide une requête avec le validator centralisé
// GO-013: Helper pour valider les requêtes et retourner des erreurs formatées
func (h *CommonHandler) ValidateRequest(c *gin.Context, req interface{}) bool {
validationErrors := h.validator.Validate(req)
if len(validationErrors) > 0 {
h.RespondWithValidationError(c, validationErrors)
return false
}
return true
}
// RespondWithSuccess répond avec une réponse de succès
func (h *CommonHandler) RespondWithSuccess(c *gin.Context, data interface{}, message string) {
// Utiliser la structure unifiée APIResponse via RespondSuccess
// Si message est présent, on l'encapsule avec les données
if message != "" {
RespondSuccess(c, http.StatusOK, gin.H{
"message": message,
"data": data,
})
} else {
RespondSuccess(c, http.StatusOK, data)
}
}
// RespondWithError répond avec une erreur
func (h *CommonHandler) RespondWithError(c *gin.Context, statusCode int, message string, err error) {
// Utiliser la structure unifiée APIResponse
// On crée une structure d'erreur ad-hoc pour correspondre à l'interface attendue par APIResponse.Error (qui est interface{})
// Ou mieux, on utilise RespondWithError qui attend un code, message et détails
// Note: RespondWithError est defined in error_response.go et attend (c, code, message, details...)
// Ici on a statusCode HTTP. RespondWithError attend un ErrorCode interne.
// C'est un conflit de signature.
// On va donc construire manuellement la réponse d'erreur unifiée.
errResponse := gin.H{
"code": statusCode,
"message": message,
"details": nil,
}
if err != nil {
h.logger.Error("Handler error",
zap.String("error", err.Error()),
zap.String("request_id", c.GetString("request_id")),
zap.String("endpoint", c.Request.URL.Path),
)
// On pourrait ajouter err.Error() dans details, mais pour sécurité on évite d'exposer l'erreur brute sauf si nécessaire
}
c.JSON(statusCode, APIResponse{
Success: false,
Data: nil,
Error: errResponse,
})
}
// RespondWithValidationError répond avec des erreurs de validation
// GO-013: Utilise dto.ValidationError pour éviter les cycles d'import
func (h *CommonHandler) RespondWithValidationError(c *gin.Context, errors []dto.ValidationError) {
// Adapter pour l'enveloppe unifiée
// Code 400 ou 422
c.JSON(http.StatusBadRequest, APIResponse{
Success: false,
Data: nil,
Error: gin.H{
"code": http.StatusBadRequest,
"message": "Validation failed",
"details": errors,
},
})
}
// RespondWithPaginatedData répond avec des données paginées
func (h *CommonHandler) RespondWithPaginatedData(c *gin.Context, data interface{}, pagination PaginationData, message string) {
// Pour la pagination, on met tout dans Data
responseData := gin.H{
"list": data,
"pagination": pagination,
}
if message != "" {
responseData["message"] = message
}
RespondSuccess(c, http.StatusOK, responseData)
}
// BindJSON lie les données JSON de la requête à une structure
// DEPRECATED: Utiliser BindAndValidateJSON à la place pour une gestion d'erreurs robuste
func (h *CommonHandler) BindJSON(c *gin.Context, obj interface{}) error {
if err := c.ShouldBindJSON(obj); err != nil {
h.logger.Warn("Failed to bind JSON",
zap.Error(err),
zap.String("request_id", c.GetString("request_id")),
)
return err
}
return nil
}
// MaxJSONBodySize définit la taille maximale du body JSON (10MB par défaut)
const MaxJSONBodySize = 10 * 1024 * 1024 // 10MB
// BindAndValidateJSON lie et valide les données JSON de la requête de manière robuste
// P0: JSON Hardening - Garantit qu'aucune erreur de parsing/validation ne passe silencieusement
//
// Comportement:
// - Vérifie la taille du body (max 10MB par défaut)
// - Parse le JSON avec ShouldBindJSON (Gin)
// - Valide avec le validator centralisé
// - Retourne une AppError avec code approprié (400 pour JSON malformé, 422 pour validation)
//
// Usage:
//
// var req MyRequest
// if appErr := h.BindAndValidateJSON(c, &req); appErr != nil {
// RespondWithAppError(c, appErr)
// return
// }
func (h *CommonHandler) BindAndValidateJSON(c *gin.Context, obj interface{}) *apperrors.AppError {
requestID := c.GetString("request_id")
// 1. Vérifier la taille du body
if c.Request.ContentLength > MaxJSONBodySize {
h.logger.Warn("Request body too large",
zap.Int64("content_length", c.Request.ContentLength),
zap.Int64("max_size", MaxJSONBodySize),
zap.String("request_id", requestID),
zap.String("endpoint", c.Request.URL.Path),
)
return apperrors.New(
apperrors.ErrCodeValidation,
fmt.Sprintf("Request body too large: maximum size is %d bytes", MaxJSONBodySize),
)
}
// 2. Limiter la lecture du body pour éviter les attaques par body trop gros
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, MaxJSONBodySize)
// 3. Parser le JSON avec ShouldBindJSON
if err := c.ShouldBindJSON(obj); err != nil {
// Analyser le type d'erreur pour retourner le bon code
var jsonSyntaxError *json.SyntaxError
var jsonUnmarshalTypeError *json.UnmarshalTypeError
var maxBytesError *http.MaxBytesError
switch {
case errors.As(err, &maxBytesError):
// Body trop gros (dépassement de la limite)
h.logger.Warn("Request body exceeds maximum size",
zap.Error(err),
zap.String("request_id", requestID),
zap.String("endpoint", c.Request.URL.Path),
)
return apperrors.New(
apperrors.ErrCodeValidation,
fmt.Sprintf("Request body too large: maximum size is %d bytes", MaxJSONBodySize),
)
case errors.As(err, &jsonSyntaxError):
// JSON syntaxiquement invalide
h.logger.Warn("Invalid JSON syntax",
zap.Error(err),
zap.Int64("offset", jsonSyntaxError.Offset),
zap.String("request_id", requestID),
zap.String("endpoint", c.Request.URL.Path),
)
return apperrors.New(
apperrors.ErrCodeValidation,
fmt.Sprintf("Invalid JSON syntax at offset %d: %s", jsonSyntaxError.Offset, jsonSyntaxError.Error()),
)
case errors.As(err, &jsonUnmarshalTypeError):
// Type incorrect pour un champ
h.logger.Warn("Invalid JSON type",
zap.Error(err),
zap.String("field", jsonUnmarshalTypeError.Field),
zap.String("type", jsonUnmarshalTypeError.Type.String()),
zap.String("request_id", requestID),
zap.String("endpoint", c.Request.URL.Path),
)
return apperrors.New(
apperrors.ErrCodeInvalidFormat,
fmt.Sprintf("Invalid type for field '%s': expected %s", jsonUnmarshalTypeError.Field, jsonUnmarshalTypeError.Type.String()),
)
case errors.Is(err, io.EOF):
// Body vide
h.logger.Warn("Empty request body",
zap.String("request_id", requestID),
zap.String("endpoint", c.Request.URL.Path),
)
return apperrors.New(
apperrors.ErrCodeValidation,
"Request body is empty or invalid JSON",
)
case errors.Is(err, io.ErrUnexpectedEOF):
// JSON incomplet
h.logger.Warn("Incomplete JSON",
zap.Error(err),
zap.String("request_id", requestID),
zap.String("endpoint", c.Request.URL.Path),
)
return apperrors.New(
apperrors.ErrCodeValidation,
"Incomplete or malformed JSON",
)
default:
// Erreur générique de binding (peut inclure des erreurs de validation Gin)
// On va laisser le validator gérer les erreurs de validation
// Si c'est une erreur de binding Gin (ex: unknown field), on la traite ici
errStr := err.Error()
if strings.Contains(errStr, "unknown field") || strings.Contains(errStr, "unknown") {
h.logger.Warn("Unknown fields in JSON",
zap.Error(err),
zap.String("request_id", requestID),
zap.String("endpoint", c.Request.URL.Path),
)
return apperrors.New(
apperrors.ErrCodeValidation,
"Unknown fields in JSON payload",
)
}
// Pour les autres erreurs de binding, on considère que c'est une erreur de validation
// Les erreurs de validation de binding Gin (comme "required", "url", "min") doivent être retournées
h.logger.Debug("JSON binding error (will be handled by validator)",
zap.Error(err),
zap.String("request_id", requestID),
zap.String("endpoint", c.Request.URL.Path),
)
// Retourner l'erreur de validation de binding
return apperrors.New(
apperrors.ErrCodeValidation,
fmt.Sprintf("Validation failed: %s", err.Error()),
)
}
}
// 4. Valider avec le validator centralisé
validationErrors := h.validator.Validate(obj)
if len(validationErrors) > 0 {
// Convertir dto.ValidationError en errors.ErrorDetail
details := make([]apperrors.ErrorDetail, 0, len(validationErrors))
for _, ve := range validationErrors {
details = append(details, apperrors.ErrorDetail{
Field: ve.Field,
Message: ve.Message,
})
}
h.logger.Warn("Validation failed",
zap.Int("error_count", len(validationErrors)),
zap.String("request_id", requestID),
zap.String("endpoint", c.Request.URL.Path),
)
return apperrors.NewValidationError("Validation failed", details...)
}
return nil
}
// GetUserIDFromContext extrait l'ID utilisateur du contexte
func (h *CommonHandler) GetUserIDFromContext(c *gin.Context) (string, error) {
userID, exists := c.Get("user_id")
if !exists {
return "", apperrors.NewUnauthorizedError("User not authenticated")
}
userIDStr, ok := userID.(string)
if !ok {
return "", apperrors.New(apperrors.ErrCodeValidation, "Invalid user ID type")
}
return userIDStr, nil
}
// GetUserIDUUID extrait l'ID utilisateur du contexte comme uuid.UUID (MOD-P1-001)
// Retourne false si user_id est absent ou invalide (répond déjà avec 401)
func GetUserIDUUID(c *gin.Context) (uuid.UUID, bool) {
userIDInterface, exists := c.Get("user_id")
if !exists {
RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized"))
return uuid.Nil, false
}
userID, ok := userIDInterface.(uuid.UUID)
if !ok {
RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized"))
return uuid.Nil, false
}
return userID, true
}
// WithTimeout crée un context avec timeout pour les opérations I/O critiques (MOD-P1-004)
// Utilise le timeout par défaut de 5s pour DB/Redis, ou le timeout fourni
func WithTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
if timeout == 0 {
timeout = 5 * time.Second // Default timeout pour DB/Redis
}
return context.WithTimeout(ctx, timeout)
}
// GetPaginationParams extrait les paramètres de pagination de la requête
func (h *CommonHandler) GetPaginationParams(c *gin.Context) (page, limit int, cursor string) {
page = 1
limit = 20
if pageStr := c.Query("page"); pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
}
if limitStr := c.Query("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
cursor = c.Query("cursor")
return page, limit, cursor
}
// ValidatePagination valide les paramètres de pagination
// GO-013: Utilise dto.ValidationError
func (h *CommonHandler) ValidatePagination(page, limit int) []dto.ValidationError {
var validationErrors []dto.ValidationError
if page < 1 {
validationErrors = append(validationErrors, dto.ValidationError{
Field: "page",
Message: "Page must be greater than 0",
Value: strconv.Itoa(page),
})
}
if limit < 1 || limit > 100 {
validationErrors = append(validationErrors, dto.ValidationError{
Field: "limit",
Message: "Limit must be between 1 and 100",
Value: strconv.Itoa(limit),
})
}
return validationErrors
}
// LogRequest log une requête entrante
func (h *CommonHandler) LogRequest(c *gin.Context, operation string) {
h.logger.Info("Request received",
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.String("operation", operation),
zap.String("user_id", c.GetString("user_id")),
zap.String("request_id", c.GetString("request_id")),
zap.String("ip", c.ClientIP()),
zap.String("user_agent", c.Request.UserAgent()),
)
}
// LogResponse log une réponse sortante
func (h *CommonHandler) LogResponse(c *gin.Context, statusCode int, duration time.Duration) {
h.logger.Info("Response sent",
zap.Int("status_code", statusCode),
zap.Duration("duration", duration),
zap.String("request_id", c.GetString("request_id")),
)
}
// SetRequestID middleware pour ajouter un ID de requête
func (h *CommonHandler) SetRequestID() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = generateRequestID()
}
c.Set("request_id", requestID)
c.Header("X-Request-ID", requestID)
c.Next()
}
}
// generateRequestID génère un ID de requête unique
func generateRequestID() string {
return strconv.FormatInt(time.Now().UnixNano(), 36)
}
// ValidateRequiredFields valide que les champs requis sont présents
// GO-013: Utilise dto.ValidationError
func (h *CommonHandler) ValidateRequiredFields(fields map[string]interface{}) []dto.ValidationError {
var validationErrors []dto.ValidationError
for field, value := range fields {
if value == nil || value == "" {
validationErrors = append(validationErrors, dto.ValidationError{
Field: field,
Message: "This field is required",
})
}
}
return validationErrors
}
// SanitizeString nettoie une chaîne de caractères
func (h *CommonHandler) SanitizeString(input string) string {
// Supprimer les caractères de contrôle et les espaces en début/fin
cleaned := strings.TrimSpace(input)
// Limiter la longueur
if len(cleaned) > 1000 {
cleaned = cleaned[:1000]
}
return cleaned
}
// ParseJSON parse du JSON de manière sécurisée
func (h *CommonHandler) ParseJSON(data []byte, v interface{}) error {
if err := json.Unmarshal(data, v); err != nil {
h.logger.Error("Failed to parse JSON", zap.Error(err))
return err
}
return nil
}
// SafeMarshalJSON sérialise en JSON de manière sécurisée
func (h *CommonHandler) SafeMarshalJSON(v interface{}) ([]byte, error) {
data, err := json.Marshal(v)
if err != nil {
h.logger.Error("Failed to marshal JSON", zap.Error(err))
return nil, err
}
return data, nil
}
// GetClientIP obtient l'IP réelle du client
func (h *CommonHandler) GetClientIP(c *gin.Context) string {
// Vérifier les headers de proxy
if ip := c.GetHeader("X-Forwarded-For"); ip != "" {
return strings.Split(ip, ",")[0]
}
if ip := c.GetHeader("X-Real-IP"); ip != "" {
return ip
}
return c.ClientIP()
}
// RateLimitKey génère une clé pour le rate limiting
func (h *CommonHandler) RateLimitKey(c *gin.Context, prefix string) string {
userID := c.GetString("user_id")
if userID != "" {
return prefix + ":user:" + userID
}
return prefix + ":ip:" + h.GetClientIP(c)
}