stabilizing veza-backend-api: P0

This commit is contained in:
senke 2025-12-16 11:59:56 -05:00
parent 3c534a59a0
commit 54a570bb74
4 changed files with 249 additions and 5 deletions

View file

@ -6,6 +6,7 @@ import (
"os"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
@ -304,7 +305,15 @@ func (r *APIRouter) setupUserRoutes(router *gin.RouterGroup) {
if r.config.AuthMiddleware != nil {
protected := users.Group("")
protected.Use(r.config.AuthMiddleware.RequireAuth())
protected.PUT("/:id", profileHandler.UpdateProfile)
// MOD-P0-003: Apply ownership middleware for PUT /users/:id
// Resolver: For users, the :id param is directly the user_id
userOwnerResolver := func(c *gin.Context) (uuid.UUID, error) {
userIDStr := c.Param("id")
return uuid.Parse(userIDStr)
}
protected.PUT("/:id", r.config.AuthMiddleware.RequireOwnershipOrAdmin("user", userOwnerResolver), profileHandler.UpdateProfile)
protected.GET("/:id/completion", profileHandler.GetProfileCompletion)
}
}
@ -368,8 +377,24 @@ func (r *APIRouter) setupTrackRoutes(router *gin.RouterGroup) {
uploadGroup := protected.Group("")
uploadGroup.Use(r.config.AuthMiddleware.RequireContentCreatorRole())
uploadGroup.POST("", trackHandler.UploadTrack)
protected.PUT("/:id", trackHandler.UpdateTrack)
protected.DELETE("/:id", trackHandler.DeleteTrack)
// MOD-P0-003: Apply ownership middleware for PUT/DELETE /tracks/:id
// Resolver: Load track from DB to get its user_id
trackOwnerResolver := func(c *gin.Context) (uuid.UUID, error) {
trackIDStr := c.Param("id")
trackID, err := uuid.Parse(trackIDStr)
if err != nil {
return uuid.Nil, err
}
// Load track to get owner ID
track, err := trackService.GetTrackByID(c.Request.Context(), trackID)
if err != nil {
return uuid.Nil, err
}
return track.UserID, nil
}
protected.PUT("/:id", r.config.AuthMiddleware.RequireOwnershipOrAdmin("track", trackOwnerResolver), trackHandler.UpdateTrack)
protected.DELETE("/:id", r.config.AuthMiddleware.RequireOwnershipOrAdmin("track", trackOwnerResolver), trackHandler.DeleteTrack)
// Upload
protected.GET("/:id/status", trackHandler.GetUploadStatus)

View file

@ -510,8 +510,8 @@ func TestNewConfig_ProductionCORSRequired(t *testing.T) {
os.Setenv("APP_ENV", "production")
os.Setenv("JWT_SECRET", "test-jwt-secret-key-minimum-32-characters-long")
os.Setenv("DATABASE_URL", "postgresql://test:test@localhost:5432/test_db")
os.Unsetenv("CORS_ALLOWED_ORIGINS") // Manquant intentionnellement
os.Setenv("REDIS_ENABLE", "false") // Désactiver Redis pour éviter erreur de connexion
os.Unsetenv("CORS_ALLOWED_ORIGINS") // Manquant intentionnellement
os.Setenv("REDIS_ENABLE", "false") // Désactiver Redis pour éviter erreur de connexion
os.Setenv("RABBITMQ_ENABLE", "false") // Désactiver RabbitMQ pour éviter erreur de connexion
// MOD-P0-001: NewConfig() doit retourner une erreur car CORS est vide en production

View file

@ -372,6 +372,76 @@ func (am *AuthMiddleware) RequireContentCreatorRole() gin.HandlerFunc {
}
}
// ResourceOwnerResolver est une fonction qui récupère l'ID du propriétaire d'une ressource
// depuis le contexte de la requête (paramètres de route, etc.)
// Retourne l'UUID du propriétaire et une erreur si la ressource n'existe pas
type ResourceOwnerResolver func(c *gin.Context) (uuid.UUID, error)
// RequireOwnershipOrAdmin middleware qui vérifie que l'utilisateur authentifié est le propriétaire
// de la ressource ou qu'il a le rôle admin
// MOD-P0-003: Middleware générique pour vérification ownership centralisée
func (am *AuthMiddleware) RequireOwnershipOrAdmin(resourceType string, resolver ResourceOwnerResolver) gin.HandlerFunc {
return func(c *gin.Context) {
// Authentifier l'utilisateur
userID, ok := am.authenticate(c)
if !ok {
return
}
// Récupérer l'ID du propriétaire de la ressource via le resolver
resourceOwnerID, err := resolver(c)
if err != nil {
am.logger.Warn("Failed to resolve resource owner",
zap.Error(err),
zap.String("resource_type", resourceType),
zap.String("user_id", userID.String()),
)
response.NotFound(c, "Resource not found")
c.Abort()
return
}
// Si l'utilisateur est le propriétaire, autoriser
if userID == resourceOwnerID {
c.Next()
return
}
// Vérifier si l'utilisateur est admin
hasRole, err := am.permissionService.HasRole(c.Request.Context(), userID, "admin")
if err != nil {
am.logger.Error("Failed to check admin role for ownership",
zap.Error(err),
zap.String("resource_type", resourceType),
zap.String("user_id", userID.String()),
)
response.InternalServerError(c, "Internal server error")
c.Abort()
return
}
if hasRole {
am.logger.Info("Admin override for ownership check",
zap.String("resource_type", resourceType),
zap.String("user_id", userID.String()),
zap.String("resource_owner_id", resourceOwnerID.String()),
)
c.Next()
return
}
// L'utilisateur n'est ni le propriétaire ni admin → Forbidden
am.logger.Warn("Ownership check failed",
zap.String("resource_type", resourceType),
zap.String("user_id", userID.String()),
zap.String("resource_owner_id", resourceOwnerID.String()),
zap.String("ip", c.ClientIP()),
)
response.Forbidden(c, "You do not have permission to access this resource")
c.Abort()
}
}
// RefreshToken middleware pour rafraîchir les tokens
// MIGRATION UUID: Simplifié pour UUID
func (am *AuthMiddleware) RefreshToken() gin.HandlerFunc {

View file

@ -629,3 +629,152 @@ func TestAuthMiddleware_P1_StrictClaims(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, w.Code)
})
}
// TestRequireOwnershipOrAdmin_OwnerAccess teste que le propriétaire peut accéder à sa ressource
// MOD-P0-003: Test ownership middleware avec owner
func TestRequireOwnershipOrAdmin_OwnerAccess(t *testing.T) {
jwtService := setupTestJWTService(t)
authMiddleware, mockSessionService, _, _, mockUserRepository := setupTestAuthMiddleware(t, jwtService)
ownerID := uuid.New()
requesterID := ownerID // Même utilisateur
// Mock user
user := &models.User{
ID: requesterID,
Email: "owner@test.com",
TokenVersion: 0,
}
mockUserRepository.On("GetByID", requesterID.String()).Return(user, nil)
// Mock session
session := &services.Session{
ID: uuid.New(),
UserID: requesterID,
}
mockSessionService.On("ValidateSession", mock.Anything, mock.Anything).Return(session, nil)
// Resolver qui retourne l'owner ID depuis les paramètres
resolver := func(c *gin.Context) (uuid.UUID, error) {
idStr := c.Param("id")
return uuid.Parse(idStr)
}
router := gin.New()
router.Use(authMiddleware.RequireOwnershipOrAdmin("test_resource", resolver))
router.PUT("/test/:id", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "success"})
})
token := generateTestToken(t, requesterID, 1*time.Hour)
req := httptest.NewRequest("PUT", "/test/"+ownerID.String(), nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
mockUserRepository.AssertExpectations(t)
mockSessionService.AssertExpectations(t)
}
// TestRequireOwnershipOrAdmin_AdminAccess teste qu'un admin peut accéder à n'importe quelle ressource
// MOD-P0-003: Test ownership middleware avec admin override
func TestRequireOwnershipOrAdmin_AdminAccess(t *testing.T) {
jwtService := setupTestJWTService(t)
authMiddleware, mockSessionService, _, mockPermissionService, mockUserRepository := setupTestAuthMiddleware(t, jwtService)
ownerID := uuid.New()
adminID := uuid.New() // Admin différent du owner
// Mock user (admin)
user := &models.User{
ID: adminID,
Email: "admin@test.com",
TokenVersion: 0,
}
mockUserRepository.On("GetByID", adminID.String()).Return(user, nil)
// Mock session
session := &services.Session{
ID: uuid.New(),
UserID: adminID,
}
mockSessionService.On("ValidateSession", mock.Anything, mock.Anything).Return(session, nil)
// Mock admin role
mockPermissionService.On("HasRole", mock.Anything, adminID, "admin").Return(true, nil)
// Resolver qui retourne l'owner ID depuis les paramètres
resolver := func(c *gin.Context) (uuid.UUID, error) {
idStr := c.Param("id")
return uuid.Parse(idStr)
}
router := gin.New()
router.Use(authMiddleware.RequireOwnershipOrAdmin("test_resource", resolver))
router.PUT("/test/:id", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "success"})
})
token := generateTestToken(t, adminID, 1*time.Hour)
req := httptest.NewRequest("PUT", "/test/"+ownerID.String(), nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
mockUserRepository.AssertExpectations(t)
mockSessionService.AssertExpectations(t)
mockPermissionService.AssertExpectations(t)
}
// TestRequireOwnershipOrAdmin_ForbiddenAccess teste qu'un utilisateur non-owner et non-admin est rejeté
// MOD-P0-003: Test ownership middleware avec accès interdit
func TestRequireOwnershipOrAdmin_ForbiddenAccess(t *testing.T) {
jwtService := setupTestJWTService(t)
authMiddleware, mockSessionService, _, mockPermissionService, mockUserRepository := setupTestAuthMiddleware(t, jwtService)
ownerID := uuid.New()
otherUserID := uuid.New() // Utilisateur différent du owner
// Mock user (non-owner, non-admin)
user := &models.User{
ID: otherUserID,
Email: "other@test.com",
TokenVersion: 0,
}
mockUserRepository.On("GetByID", otherUserID.String()).Return(user, nil)
// Mock session
session := &services.Session{
ID: uuid.New(),
UserID: otherUserID,
}
mockSessionService.On("ValidateSession", mock.Anything, mock.Anything).Return(session, nil)
// Mock non-admin role
mockPermissionService.On("HasRole", mock.Anything, otherUserID, "admin").Return(false, nil)
// Resolver qui retourne l'owner ID depuis les paramètres
resolver := func(c *gin.Context) (uuid.UUID, error) {
idStr := c.Param("id")
return uuid.Parse(idStr)
}
router := gin.New()
router.Use(authMiddleware.RequireOwnershipOrAdmin("test_resource", resolver))
router.PUT("/test/:id", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "success"})
})
token := generateTestToken(t, otherUserID, 1*time.Hour)
req := httptest.NewRequest("PUT", "/test/"+ownerID.String(), nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
mockUserRepository.AssertExpectations(t)
mockSessionService.AssertExpectations(t)
mockPermissionService.AssertExpectations(t)
}