Refactoring

This commit is contained in:
dahoud
2026-02-05 18:09:30 +00:00
parent 2a794523b6
commit 806efeb074
24 changed files with 2261 additions and 123 deletions

View File

@@ -0,0 +1,110 @@
package com.lions.dev.exception;
import jakarta.persistence.EntityNotFoundException;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import org.jboss.logging.Logger;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Mapper d'exceptions global pour standardiser les réponses d'erreur de l'API.
*
* Format de réponse d'erreur standard:
* {
* "timestamp": "2026-02-05T12:00:00Z",
* "status": 400,
* "error": "Bad Request",
* "message": "Description de l'erreur",
* "path": "/api/endpoint"
* }
*
* @since 2.0 - Production-ready
*/
@Provider
public class GlobalExceptionMapper implements ExceptionMapper<Throwable> {
private static final Logger LOG = Logger.getLogger(GlobalExceptionMapper.class);
@Override
public Response toResponse(Throwable exception) {
// Déterminer le statut et le message en fonction du type d'exception
int status;
String error;
String message;
if (exception instanceof ConstraintViolationException cve) {
status = Response.Status.BAD_REQUEST.getStatusCode();
error = "Validation Error";
message = cve.getConstraintViolations().stream()
.map(cv -> cv.getPropertyPath() + ": " + cv.getMessage())
.collect(Collectors.joining("; "));
LOG.warnf("Validation error: %s", message);
} else if (exception instanceof IllegalArgumentException) {
status = Response.Status.BAD_REQUEST.getStatusCode();
error = "Bad Request";
message = exception.getMessage();
LOG.warnf("Bad request: %s", message);
} else if (exception instanceof EntityNotFoundException || exception instanceof NotFoundException) {
status = Response.Status.NOT_FOUND.getStatusCode();
error = "Not Found";
message = exception.getMessage() != null ? exception.getMessage() : "Resource not found";
LOG.warnf("Not found: %s", message);
} else if (exception instanceof UserNotFoundException) {
status = Response.Status.NOT_FOUND.getStatusCode();
error = "User Not Found";
message = exception.getMessage();
LOG.warnf("User not found: %s", message);
} else if (exception instanceof NotAuthorizedException) {
status = Response.Status.UNAUTHORIZED.getStatusCode();
error = "Unauthorized";
message = "Authentication required";
LOG.warnf("Unauthorized access attempt");
} else if (exception instanceof ForbiddenException || exception instanceof UnauthorizedException) {
status = Response.Status.FORBIDDEN.getStatusCode();
error = "Forbidden";
message = exception.getMessage() != null ? exception.getMessage() : "Access denied";
LOG.warnf("Forbidden: %s", message);
} else if (exception instanceof SecurityException) {
status = Response.Status.FORBIDDEN.getStatusCode();
error = "Security Error";
message = exception.getMessage();
LOG.warnf("Security error: %s", message);
} else if (exception instanceof WebApplicationException wae) {
Response response = wae.getResponse();
status = response.getStatus();
error = Response.Status.fromStatusCode(status).getReasonPhrase();
message = exception.getMessage();
LOG.warnf("Web application exception (%d): %s", status, message);
} else {
// Erreur interne non gérée
status = Response.Status.INTERNAL_SERVER_ERROR.getStatusCode();
error = "Internal Server Error";
message = "An unexpected error occurred. Please try again later.";
LOG.errorf(exception, "Unhandled exception: %s", exception.getMessage());
}
// Construire la réponse d'erreur standardisée
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("timestamp", Instant.now().toString());
errorResponse.put("status", status);
errorResponse.put("error", error);
errorResponse.put("message", message);
return Response.status(status)
.type(MediaType.APPLICATION_JSON)
.entity(errorResponse)
.build();
}
}

View File

@@ -0,0 +1,159 @@
package com.lions.dev.filter;
import jakarta.annotation.Priority;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.Priorities;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import java.io.IOException;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Filtre de limitation de débit (rate limiting) pour protéger les endpoints sensibles
* contre les attaques par force brute.
*
* Endpoints protégés:
* - /authenticate (login)
* - /forgot-password (reset password)
* - /register (création de compte)
*
* Configuration:
* - afterwork.ratelimit.max-requests: Nombre max de requêtes par fenêtre (défaut: 10)
* - afterwork.ratelimit.window-seconds: Durée de la fenêtre en secondes (défaut: 60)
*
* @since 2.0 - Production-ready security
*/
@Provider
@Priority(Priorities.AUTHENTICATION - 1)
@ApplicationScoped
public class RateLimitFilter implements ContainerRequestFilter {
private static final Logger LOG = Logger.getLogger(RateLimitFilter.class);
@ConfigProperty(name = "afterwork.ratelimit.max-requests", defaultValue = "10")
int maxRequests;
@ConfigProperty(name = "afterwork.ratelimit.window-seconds", defaultValue = "60")
int windowSeconds;
// Stockage des compteurs par IP et endpoint
private final ConcurrentHashMap<String, RateLimitEntry> rateLimitCache = new ConcurrentHashMap<>();
// Endpoints à protéger
private static final String[] PROTECTED_ENDPOINTS = {
"/authenticate",
"/forgot-password",
"/register",
"/users/authenticate",
"/users/register",
"/users/forgot-password"
};
@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
String path = requestContext.getUriInfo().getPath();
// Vérifier si l'endpoint est protégé
boolean isProtected = false;
for (String endpoint : PROTECTED_ENDPOINTS) {
if (path.contains(endpoint)) {
isProtected = true;
break;
}
}
if (!isProtected) {
return; // Pas de rate limiting pour cet endpoint
}
String clientIp = getClientIp(requestContext);
String key = clientIp + ":" + path;
RateLimitEntry entry = rateLimitCache.compute(key, (k, existing) -> {
long now = Instant.now().getEpochSecond();
if (existing == null || (now - existing.windowStart) >= windowSeconds) {
// Nouvelle fenêtre
return new RateLimitEntry(now, 1);
} else {
// Même fenêtre, incrémenter
existing.incrementCount();
return existing;
}
});
if (entry.getCount() > maxRequests) {
LOG.warnf("Rate limit exceeded for IP: %s on endpoint: %s (count: %d)",
clientIp, path, entry.getCount());
requestContext.abortWith(
Response.status(429)
.entity("{\"message\": \"Trop de requêtes. Veuillez réessayer dans " + windowSeconds + " secondes.\"}")
.header("Retry-After", String.valueOf(windowSeconds))
.header("X-RateLimit-Limit", String.valueOf(maxRequests))
.header("X-RateLimit-Remaining", "0")
.header("X-RateLimit-Reset", String.valueOf(entry.windowStart + windowSeconds))
.build()
);
}
}
/**
* Extrait l'IP du client en tenant compte des proxies.
*/
private String getClientIp(ContainerRequestContext context) {
// Vérifier X-Forwarded-For pour les proxies/load balancers
String xff = context.getHeaderString("X-Forwarded-For");
if (xff != null && !xff.isBlank()) {
// Prendre la première IP (client original)
return xff.split(",")[0].trim();
}
// Vérifier X-Real-IP (nginx)
String realIp = context.getHeaderString("X-Real-IP");
if (realIp != null && !realIp.isBlank()) {
return realIp.trim();
}
// Fallback: adresse inconnue (ne devrait pas arriver en production)
return "unknown";
}
/**
* Nettoie les entrées expirées du cache (à appeler périodiquement).
*/
public void cleanupExpiredEntries() {
long now = Instant.now().getEpochSecond();
rateLimitCache.entrySet().removeIf(entry ->
(now - entry.getValue().windowStart) >= windowSeconds * 2);
}
/**
* Classe interne pour stocker les informations de rate limiting.
*/
private static class RateLimitEntry {
final long windowStart;
private final AtomicInteger count;
RateLimitEntry(long windowStart, int initialCount) {
this.windowStart = windowStart;
this.count = new AtomicInteger(initialCount);
}
void incrementCount() {
count.incrementAndGet();
}
int getCount() {
return count.get();
}
}
}

View File

@@ -0,0 +1,54 @@
package com.lions.dev.health;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.health.HealthCheck;
import org.eclipse.microprofile.health.HealthCheckResponse;
import org.eclipse.microprofile.health.HealthCheckResponseBuilder;
import org.eclipse.microprofile.health.Readiness;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.Optional;
/**
* Health check pour la connexion Kafka.
* Vérifie que le broker Kafka est accessible.
*
* @since 2.0 - Production-ready
*/
@Readiness
@ApplicationScoped
public class KafkaHealthCheck implements HealthCheck {
@ConfigProperty(name = "kafka.bootstrap.servers", defaultValue = "localhost:9092")
String bootstrapServers;
@Override
public HealthCheckResponse call() {
HealthCheckResponseBuilder responseBuilder = HealthCheckResponse.named("Kafka Connection");
try {
// Parse the first bootstrap server
String[] servers = bootstrapServers.split(",");
String[] hostPort = servers[0].trim().split(":");
String host = hostPort[0];
int port = hostPort.length > 1 ? Integer.parseInt(hostPort[1]) : 9092;
// Try to connect
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(host, port), 2000);
responseBuilder.up()
.withData("bootstrapServers", bootstrapServers)
.withData("status", "Kafka broker is reachable");
}
} catch (Exception e) {
responseBuilder.down()
.withData("bootstrapServers", bootstrapServers)
.withData("error", e.getMessage())
.withData("status", "Kafka broker is NOT reachable");
}
return responseBuilder.build();
}
}

View File

@@ -0,0 +1,28 @@
package com.lions.dev.health;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.health.HealthCheck;
import org.eclipse.microprofile.health.HealthCheckResponse;
import org.eclipse.microprofile.health.Liveness;
/**
* Health check de vivacité (liveness) pour Kubernetes.
* Vérifie que l'application est vivante et répond.
*
* Endpoint: GET /q/health/live
*
* @since 2.0 - Production-ready
*/
@Liveness
@ApplicationScoped
public class LivenessCheck implements HealthCheck {
@Override
public HealthCheckResponse call() {
return HealthCheckResponse.named("AfterWork API Liveness")
.up()
.withData("version", "2.0")
.withData("status", "Application is running")
.build();
}
}

View File

@@ -0,0 +1,46 @@
package com.lions.dev.health;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import org.eclipse.microprofile.health.HealthCheck;
import org.eclipse.microprofile.health.HealthCheckResponse;
import org.eclipse.microprofile.health.HealthCheckResponseBuilder;
import org.eclipse.microprofile.health.Readiness;
/**
* Health check de disponibilité (readiness) pour Kubernetes.
* Vérifie que l'application est prête à recevoir du trafic.
* Contrôle notamment la connexion à la base de données.
*
* Endpoint: GET /q/health/ready
*
* @since 2.0 - Production-ready
*/
@Readiness
@ApplicationScoped
public class ReadinessCheck implements HealthCheck {
@Inject
EntityManager entityManager;
@Override
public HealthCheckResponse call() {
HealthCheckResponseBuilder responseBuilder = HealthCheckResponse.named("AfterWork API Readiness");
try {
// Test de la connexion à la base de données
entityManager.createNativeQuery("SELECT 1").getSingleResult();
responseBuilder.up()
.withData("database", "connected")
.withData("status", "Application is ready to accept traffic");
} catch (Exception e) {
responseBuilder.down()
.withData("database", "disconnected")
.withData("error", e.getMessage())
.withData("status", "Application is NOT ready - database connection failed");
}
return responseBuilder.build();
}
}

View File

@@ -5,12 +5,16 @@ import com.lions.dev.dto.response.establishment.EstablishmentMediaResponseDTO;
import com.lions.dev.entity.establishment.EstablishmentMedia;
import com.lions.dev.entity.establishment.MediaType;
import com.lions.dev.service.EstablishmentMediaService;
import com.lions.dev.util.UserRoles;
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
@@ -20,7 +24,10 @@ import java.util.stream.Collectors;
/**
* Ressource REST pour la gestion des médias d'établissements.
* Cette classe expose des endpoints pour uploader, récupérer et supprimer des médias.
*
* SÉCURITÉ : Les lectures sont publiques, les écritures requièrent une authentification.
*
* @since 2.0 - Sécurité JWT + RBAC production-ready
*/
@Path("/establishments/{establishmentId}/media")
@Produces(jakarta.ws.rs.core.MediaType.APPLICATION_JSON)
@@ -35,8 +42,10 @@ public class EstablishmentMediaResource {
/**
* Récupère tous les médias d'un établissement.
* Endpoint public.
*/
@GET
@PermitAll
@Operation(summary = "Récupérer tous les médias d'un établissement",
description = "Retourne la liste de tous les médias (photos et vidéos) d'un établissement")
public Response getEstablishmentMedia(@PathParam("establishmentId") String establishmentId) {
@@ -64,12 +73,14 @@ public class EstablishmentMediaResource {
/**
* Upload un nouveau média pour un établissement.
* Accepte un body JSON avec les informations du média.
* Requiert une authentification (propriétaire ou manager).
*/
@POST
@Transactional
@RolesAllowed({UserRoles.OWNER, UserRoles.MANAGER, UserRoles.ADMIN, UserRoles.SUPER_ADMIN})
@Operation(summary = "Uploader un média pour un établissement",
description = "Upload un nouveau média (photo ou vidéo) pour un établissement")
@SecurityRequirement(name = "bearerAuth")
public Response uploadMedia(
@PathParam("establishmentId") String establishmentId,
@Valid EstablishmentMediaRequestDTO requestDTO,
@@ -135,12 +146,15 @@ public class EstablishmentMediaResource {
/**
* Supprime un média d'un établissement.
* Requiert une authentification (propriétaire ou manager).
*/
@DELETE
@Path("/{mediaId}")
@Transactional
@RolesAllowed({UserRoles.OWNER, UserRoles.MANAGER, UserRoles.ADMIN, UserRoles.SUPER_ADMIN})
@Operation(summary = "Supprimer un média d'un établissement",
description = "Supprime un média spécifique d'un établissement")
@SecurityRequirement(name = "bearerAuth")
public Response deleteMedia(
@PathParam("establishmentId") String establishmentId,
@PathParam("mediaId") String mediaId) {

View File

@@ -1,18 +1,20 @@
package com.lions.dev.resource;
import com.lions.dev.core.security.JwtAuthFilter;
import com.lions.dev.core.security.RequiresAuth;
import com.lions.dev.dto.request.establishment.EstablishmentRatingRequestDTO;
import com.lions.dev.dto.response.establishment.EstablishmentRatingResponseDTO;
import com.lions.dev.dto.response.establishment.EstablishmentRatingStatsResponseDTO;
import com.lions.dev.entity.establishment.EstablishmentRating;
import com.lions.dev.security.Permission;
import com.lions.dev.security.RequiresPermission;
import com.lions.dev.service.EstablishmentRatingService;
import com.lions.dev.service.SecurityService;
import com.lions.dev.util.UserRoles;
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
@@ -26,7 +28,10 @@ import java.util.UUID;
/**
* Ressource REST pour la gestion des notations d'établissements.
* Cette classe expose des endpoints pour soumettre, modifier et récupérer les notes.
*
* SÉCURITÉ : Les lectures sont publiques, les écritures requièrent une authentification.
*
* @since 2.0 - Sécurité JWT + RBAC production-ready
*/
@Path("/establishments/{establishmentId}/ratings")
@Produces(MediaType.APPLICATION_JSON)
@@ -37,14 +42,10 @@ public class EstablishmentRatingResource {
@Inject
EstablishmentRatingService ratingService;
private static final Logger LOG = Logger.getLogger(EstablishmentRatingResource.class);
@Inject
SecurityService securityService;
/**
* Extrait l'ID de l'utilisateur authentifié du contexte de la requête.
*/
private UUID getAuthenticatedUserId(ContainerRequestContext requestContext) {
return (UUID) requestContext.getProperty(JwtAuthFilter.AUTHENTICATED_USER_ID);
}
private static final Logger LOG = Logger.getLogger(EstablishmentRatingResource.class);
/**
* Soumet une nouvelle note pour un établissement.
@@ -52,7 +53,8 @@ public class EstablishmentRatingResource {
*/
@POST
@Transactional
@RequiresAuth
@RolesAllowed({UserRoles.USER, UserRoles.OWNER, UserRoles.MANAGER, UserRoles.ADMIN, UserRoles.SUPER_ADMIN})
@RequiresPermission(Permission.REVIEWS_CREATE)
@Operation(summary = "Soumettre une note pour un établissement",
description = "Soumet une nouvelle note (1 à 5 étoiles) pour un établissement. Requiert une authentification JWT.")
@SecurityRequirement(name = "bearerAuth")
@@ -60,10 +62,9 @@ public class EstablishmentRatingResource {
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "400", description = "Données invalides")
public Response submitRating(
@Context ContainerRequestContext requestContext,
@PathParam("establishmentId") String establishmentId,
@Valid EstablishmentRatingRequestDTO requestDTO) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
UUID authenticatedUserId = securityService.getCurrentUserId();
LOG.info("Soumission d'une note pour l'établissement " + establishmentId + " par l'utilisateur " + authenticatedUserId);
try {
@@ -96,7 +97,8 @@ public class EstablishmentRatingResource {
*/
@PUT
@Transactional
@RequiresAuth
@RolesAllowed({UserRoles.USER, UserRoles.OWNER, UserRoles.MANAGER, UserRoles.ADMIN, UserRoles.SUPER_ADMIN})
@RequiresPermission(Permission.REVIEWS_UPDATE_OWN)
@Operation(summary = "Modifier une note existante",
description = "Met à jour une note existante pour un établissement. Requiert une authentification JWT.")
@SecurityRequirement(name = "bearerAuth")
@@ -104,10 +106,9 @@ public class EstablishmentRatingResource {
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "404", description = "Note non trouvée")
public Response updateRating(
@Context ContainerRequestContext requestContext,
@PathParam("establishmentId") String establishmentId,
@Valid EstablishmentRatingRequestDTO requestDTO) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
UUID authenticatedUserId = securityService.getCurrentUserId();
LOG.info("Mise à jour de la note pour l'établissement " + establishmentId + " par l'utilisateur " + authenticatedUserId);
try {
@@ -136,10 +137,11 @@ public class EstablishmentRatingResource {
/**
* Récupère les statistiques de notation d'un établissement.
* Doit être déclaré avant les endpoints génériques GET pour la résolution correcte par JAX-RS.
* Endpoint public.
*/
@GET
@Path("/stats")
@PermitAll
@Operation(summary = "Récupérer les statistiques de notation",
description = "Récupère les statistiques de notation d'un établissement (moyenne, total, distribution)")
public Response getRatingStats(@PathParam("establishmentId") String establishmentId) {
@@ -170,10 +172,11 @@ public class EstablishmentRatingResource {
/**
* Récupère la note d'un utilisateur pour un établissement (via path parameter).
* Endpoint alternatif pour compatibilité.
* Endpoint public.
*/
@GET
@Path("/users/{userId}")
@PermitAll
@Operation(summary = "Récupérer la note d'un utilisateur (path parameter)",
description = "Récupère la note donnée par un utilisateur spécifique pour un établissement (via path parameter)")
public Response getUserRatingByPath(
@@ -209,10 +212,10 @@ public class EstablishmentRatingResource {
/**
* Récupère la note d'un utilisateur pour un établissement (via query parameter).
* Endpoint utilisé par le frontend Flutter.
* Doit être déclaré en dernier car c'est l'endpoint le plus générique.
* Endpoint public.
*/
@GET
@PermitAll
@Operation(summary = "Récupérer la note d'un utilisateur",
description = "Récupère la note donnée par un utilisateur spécifique pour un établissement (via query parameter userId)")
public Response getUserRatingByQuery(

View File

@@ -3,12 +3,16 @@ package com.lions.dev.resource;
import com.lions.dev.dto.request.establishment.InitiateSubscriptionRequestDTO;
import com.lions.dev.dto.response.establishment.InitiateSubscriptionResponseDTO;
import com.lions.dev.service.WavePaymentService;
import com.lions.dev.util.UserRoles;
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
@@ -17,6 +21,10 @@ import java.util.UUID;
/**
* Ressource pour les abonnements / droits d'accès des établissements (paiement Wave).
*
* SÉCURITÉ : L'initiation du paiement requiert une authentification, le statut est public.
*
* @since 2.0 - Sécurité JWT + RBAC production-ready
*/
@Path("/establishments/{establishmentId}/subscriptions")
@Produces(MediaType.APPLICATION_JSON)
@@ -32,11 +40,14 @@ public class EstablishmentSubscriptionResource {
/**
* Initie un paiement Wave pour les droits d'accès d'un établissement.
* Retourne l'URL de redirection vers la page de paiement Wave.
* Requiert une authentification (propriétaire ou manager de l'établissement).
*/
@POST
@Path("/initiate")
@RolesAllowed({UserRoles.OWNER, UserRoles.MANAGER, UserRoles.ADMIN, UserRoles.SUPER_ADMIN})
@Operation(summary = "Initier un paiement Wave (droits d'accès)",
description = "Crée une session Wave et retourne l'URL de paiement. Le client redirige l'utilisateur vers payment_url.")
@SecurityRequirement(name = "bearerAuth")
public Response initiatePayment(
@PathParam("establishmentId") UUID establishmentId,
@Valid InitiateSubscriptionRequestDTO request) {
@@ -55,9 +66,11 @@ public class EstablishmentSubscriptionResource {
/**
* Vérifie si l'établissement a un abonnement actif.
* Endpoint public.
*/
@GET
@Path("/status")
@PermitAll
@Operation(summary = "Statut d'abonnement", description = "Indique si l'établissement a des droits d'accès actifs.")
public Response getSubscriptionStatus(@PathParam("establishmentId") UUID establishmentId) {
boolean active = wavePaymentService.hasActiveSubscription(establishmentId);

View File

@@ -1,6 +1,9 @@
package com.lions.dev.resource;
import com.lions.dev.service.FileService;
import com.lions.dev.util.UserRoles;
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
@@ -11,6 +14,9 @@ import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;
@@ -22,7 +28,15 @@ import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/**
* Ressource REST pour la gestion des uploads de fichiers (médias).
*
* SÉCURITÉ : L'upload requiert une authentification, la lecture est publique.
*
* @since 2.0 - Sécurité JWT + RBAC production-ready
*/
@Path("/media")
@Tag(name = "Media Upload", description = "Gestion des uploads de fichiers médias")
public class FileUploadResource {
private static final Logger LOG = Logger.getLogger(FileUploadResource.class);
@@ -37,6 +51,9 @@ public class FileUploadResource {
@Path("/upload")
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Produces(MediaType.APPLICATION_JSON)
@RolesAllowed({UserRoles.USER, UserRoles.OWNER, UserRoles.MANAGER, UserRoles.ADMIN, UserRoles.SUPER_ADMIN})
@Operation(summary = "Uploader un fichier média", description = "Requiert une authentification JWT")
@SecurityRequirement(name = "bearerAuth")
public Response uploadFile(
@RestForm("file") FileUpload file,
@RestForm("type") String type,
@@ -194,6 +211,8 @@ public class FileUploadResource {
@GET
@Path("/files/{fileName}")
@Produces(MediaType.APPLICATION_OCTET_STREAM)
@PermitAll
@Operation(summary = "Récupérer un fichier média", description = "Endpoint public pour servir les fichiers uploadés")
public Response getFile(@PathParam("fileName") String fileName) {
try {
java.nio.file.Path filePath = java.nio.file.Paths.get("/tmp/uploads/", fileName);

View File

@@ -1,18 +1,20 @@
package com.lions.dev.resource;
import com.lions.dev.core.security.JwtAuthFilter;
import com.lions.dev.core.security.RequiresAuth;
import com.lions.dev.dto.request.promotion.PromotionCreateRequestDTO;
import com.lions.dev.dto.request.promotion.PromotionUpdateRequestDTO;
import com.lions.dev.dto.response.promotion.PromotionResponseDTO;
import com.lions.dev.entity.promotion.Promotion;
import com.lions.dev.security.Permission;
import com.lions.dev.security.RequiresPermission;
import com.lions.dev.service.PromotionService;
import com.lions.dev.service.SecurityService;
import com.lions.dev.util.UserRoles;
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
@@ -29,8 +31,10 @@ import java.util.stream.Collectors;
/**
* Ressource REST pour la gestion des promotions dans le système AfterWork.
*
* Cette classe expose des endpoints pour créer, récupérer, mettre à jour
* et supprimer des promotions d'établissements.
* SÉCURITÉ : Les lectures sont publiques, les écritures requièrent une authentification.
* Seul le responsable de l'établissement peut créer/modifier/supprimer des promotions.
*
* @since 2.0 - Sécurité JWT + RBAC production-ready
*/
@Path("/promotions")
@Produces(MediaType.APPLICATION_JSON)
@@ -41,14 +45,10 @@ public class PromotionResource {
@Inject
PromotionService promotionService;
private static final Logger LOG = Logger.getLogger(PromotionResource.class);
@Inject
SecurityService securityService;
/**
* Extrait l'ID de l'utilisateur authentifié du contexte de la requête.
*/
private UUID getAuthenticatedUserId(ContainerRequestContext requestContext) {
return (UUID) requestContext.getProperty(JwtAuthFilter.AUTHENTICATED_USER_ID);
}
private static final Logger LOG = Logger.getLogger(PromotionResource.class);
// =====================================================================
// ENDPOINTS PUBLICS (LECTURE)
@@ -62,6 +62,7 @@ public class PromotionResource {
* @return Liste paginée des promotions actives
*/
@GET
@PermitAll
@Operation(
summary = "Récupérer toutes les promotions actives",
description = "Retourne une liste paginée de toutes les promotions actives et valides")
@@ -94,6 +95,7 @@ public class PromotionResource {
*/
@GET
@Path("/{id}")
@PermitAll
@Operation(
summary = "Récupérer une promotion par ID",
description = "Retourne les détails d'une promotion spécifique")
@@ -127,6 +129,7 @@ public class PromotionResource {
*/
@GET
@Path("/code/{code}")
@PermitAll
@Operation(
summary = "Rechercher une promotion par code promo",
description = "Retourne la promotion correspondant au code promo")
@@ -164,6 +167,7 @@ public class PromotionResource {
*/
@GET
@Path("/establishment/{establishmentId}")
@PermitAll
@Operation(
summary = "Récupérer les promotions d'un établissement",
description = "Retourne les promotions d'un établissement spécifique")
@@ -211,7 +215,8 @@ public class PromotionResource {
*/
@POST
@Transactional
@RequiresAuth
@RolesAllowed({UserRoles.OWNER, UserRoles.MANAGER, UserRoles.ADMIN, UserRoles.SUPER_ADMIN})
@RequiresPermission(Permission.PROMOTIONS_CREATE)
@Operation(
summary = "Créer une promotion",
description = "Crée une nouvelle promotion pour un établissement. Seul le responsable peut créer.")
@@ -220,10 +225,8 @@ public class PromotionResource {
@APIResponse(responseCode = "400", description = "Données invalides")
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à créer des promotions pour cet établissement")
public Response createPromotion(
@Context ContainerRequestContext requestContext,
@Valid PromotionCreateRequestDTO requestDTO) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
public Response createPromotion(@Valid PromotionCreateRequestDTO requestDTO) {
UUID authenticatedUserId = securityService.getCurrentUserId();
LOG.info("[LOG] Création d'une promotion pour l'établissement : " + requestDTO.getEstablishmentId() +
" par l'utilisateur : " + authenticatedUserId);
@@ -267,7 +270,8 @@ public class PromotionResource {
@PUT
@Path("/{id}")
@Transactional
@RequiresAuth
@RolesAllowed({UserRoles.OWNER, UserRoles.MANAGER, UserRoles.ADMIN, UserRoles.SUPER_ADMIN})
@RequiresPermission(Permission.PROMOTIONS_UPDATE_OWN)
@Operation(
summary = "Mettre à jour une promotion",
description = "Met à jour une promotion existante. Seul le responsable peut modifier.")
@@ -277,10 +281,9 @@ public class PromotionResource {
@APIResponse(responseCode = "403", description = "Non autorisé à modifier cette promotion")
@APIResponse(responseCode = "404", description = "Promotion non trouvée")
public Response updatePromotion(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID promotionId,
@Valid PromotionUpdateRequestDTO requestDTO) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
UUID authenticatedUserId = securityService.getCurrentUserId();
LOG.info("[LOG] Mise à jour de la promotion : " + promotionId + " par l'utilisateur : " + authenticatedUserId);
try {
@@ -321,7 +324,8 @@ public class PromotionResource {
@DELETE
@Path("/{id}")
@Transactional
@RequiresAuth
@RolesAllowed({UserRoles.OWNER, UserRoles.MANAGER, UserRoles.ADMIN, UserRoles.SUPER_ADMIN})
@RequiresPermission(Permission.PROMOTIONS_DELETE_OWN)
@Operation(
summary = "Supprimer une promotion",
description = "Supprime une promotion. Seul le responsable peut supprimer.")
@@ -330,10 +334,8 @@ public class PromotionResource {
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à supprimer cette promotion")
@APIResponse(responseCode = "404", description = "Promotion non trouvée")
public Response deletePromotion(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID promotionId) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
public Response deletePromotion(@PathParam("id") UUID promotionId) {
UUID authenticatedUserId = securityService.getCurrentUserId();
LOG.info("[LOG] Suppression de la promotion : " + promotionId + " par l'utilisateur : " + authenticatedUserId);
try {
@@ -379,7 +381,8 @@ public class PromotionResource {
@PATCH
@Path("/{id}/active")
@Transactional
@RequiresAuth
@RolesAllowed({UserRoles.OWNER, UserRoles.MANAGER, UserRoles.ADMIN, UserRoles.SUPER_ADMIN})
@RequiresPermission(Permission.PROMOTIONS_UPDATE_OWN)
@Operation(
summary = "Activer/Désactiver une promotion",
description = "Change l'état actif d'une promotion. Seul le responsable peut modifier.")
@@ -389,10 +392,9 @@ public class PromotionResource {
@APIResponse(responseCode = "403", description = "Non autorisé")
@APIResponse(responseCode = "404", description = "Promotion non trouvée")
public Response setPromotionActive(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID promotionId,
@QueryParam("active") @DefaultValue("true") boolean isActive) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
UUID authenticatedUserId = securityService.getCurrentUserId();
LOG.info("[LOG] Changement d'état de la promotion " + promotionId + " à " + isActive);
try {

View File

@@ -1,18 +1,20 @@
package com.lions.dev.resource;
import com.lions.dev.core.security.JwtAuthFilter;
import com.lions.dev.core.security.RequiresAuth;
import com.lions.dev.dto.request.review.ReviewCreateRequestDTO;
import com.lions.dev.dto.request.review.ReviewUpdateRequestDTO;
import com.lions.dev.dto.response.review.ReviewResponseDTO;
import com.lions.dev.entity.establishment.Review;
import com.lions.dev.security.Permission;
import com.lions.dev.security.RequiresPermission;
import com.lions.dev.service.ReviewService;
import com.lions.dev.service.SecurityService;
import com.lions.dev.util.UserRoles;
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
@@ -30,8 +32,10 @@ import java.util.stream.Collectors;
/**
* Ressource REST pour la gestion des avis d'établissements.
*
* Cette classe expose des endpoints pour créer, récupérer, mettre à jour
* et supprimer des avis sur les établissements.
* SÉCURITÉ : Les lectures sont publiques, les écritures requièrent une authentification.
* L'utilisateur ne peut modifier/supprimer que SES PROPRES avis.
*
* @since 2.0 - Sécurité JWT + RBAC production-ready
*/
@Path("/reviews")
@Produces(MediaType.APPLICATION_JSON)
@@ -42,14 +46,10 @@ public class ReviewResource {
@Inject
ReviewService reviewService;
private static final Logger LOG = Logger.getLogger(ReviewResource.class);
@Inject
SecurityService securityService;
/**
* Extrait l'ID de l'utilisateur authentifié du contexte de la requête.
*/
private UUID getAuthenticatedUserId(ContainerRequestContext requestContext) {
return (UUID) requestContext.getProperty(JwtAuthFilter.AUTHENTICATED_USER_ID);
}
private static final Logger LOG = Logger.getLogger(ReviewResource.class);
// =====================================================================
// ENDPOINTS PUBLICS (LECTURE)
@@ -57,9 +57,11 @@ public class ReviewResource {
/**
* Récupère un avis par son ID.
* Endpoint public.
*/
@GET
@Path("/{id}")
@PermitAll
@Operation(
summary = "Récupérer un avis par ID",
description = "Retourne les détails d'un avis spécifique")
@@ -87,9 +89,11 @@ public class ReviewResource {
/**
* Récupère les avis d'un établissement.
* Endpoint public.
*/
@GET
@Path("/establishment/{establishmentId}")
@PermitAll
@Operation(
summary = "Récupérer les avis d'un établissement",
description = "Retourne la liste paginée des avis pour un établissement")
@@ -124,9 +128,11 @@ public class ReviewResource {
/**
* Récupère les statistiques des avis pour un établissement.
* Endpoint public.
*/
@GET
@Path("/establishment/{establishmentId}/stats")
@PermitAll
@Operation(
summary = "Récupérer les statistiques des avis",
description = "Retourne les statistiques (moyenne, distribution, etc.) des avis pour un établissement")
@@ -147,9 +153,11 @@ public class ReviewResource {
/**
* Récupère les avis d'un utilisateur.
* Endpoint public.
*/
@GET
@Path("/user/{userId}")
@PermitAll
@Operation(
summary = "Récupérer les avis d'un utilisateur",
description = "Retourne la liste paginée des avis écrits par un utilisateur")
@@ -177,9 +185,11 @@ public class ReviewResource {
/**
* Vérifie si l'utilisateur a déjà écrit un avis pour un établissement.
* Endpoint public.
*/
@GET
@Path("/establishment/{establishmentId}/user/{userId}")
@PermitAll
@Operation(
summary = "Récupérer l'avis d'un utilisateur pour un établissement",
description = "Retourne l'avis si l'utilisateur en a écrit un, 404 sinon")
@@ -218,7 +228,8 @@ public class ReviewResource {
*/
@POST
@Transactional
@RequiresAuth
@RolesAllowed({UserRoles.USER, UserRoles.MANAGER, UserRoles.ADMIN, UserRoles.SUPER_ADMIN})
@RequiresPermission(Permission.REVIEWS_CREATE)
@Operation(
summary = "Créer un avis",
description = "Crée un nouvel avis pour un établissement. Un seul avis par utilisateur et établissement.")
@@ -226,10 +237,8 @@ public class ReviewResource {
@APIResponse(responseCode = "201", description = "Avis créé avec succès")
@APIResponse(responseCode = "400", description = "Données invalides ou avis déjà existant")
@APIResponse(responseCode = "401", description = "Non authentifié")
public Response createReview(
@Context ContainerRequestContext requestContext,
@Valid ReviewCreateRequestDTO requestDTO) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
public Response createReview(@Valid ReviewCreateRequestDTO requestDTO) {
UUID authenticatedUserId = securityService.getCurrentUserId();
LOG.info("[LOG] Création d'un avis pour l'établissement : " + requestDTO.getEstablishmentId() +
" par l'utilisateur : " + authenticatedUserId);
@@ -258,7 +267,8 @@ public class ReviewResource {
@PUT
@Path("/{id}")
@Transactional
@RequiresAuth
@RolesAllowed({UserRoles.USER, UserRoles.MANAGER, UserRoles.ADMIN, UserRoles.SUPER_ADMIN})
@RequiresPermission(Permission.REVIEWS_UPDATE_OWN)
@Operation(
summary = "Mettre à jour un avis",
description = "Met à jour un avis existant. Seul l'auteur peut modifier.")
@@ -268,10 +278,9 @@ public class ReviewResource {
@APIResponse(responseCode = "403", description = "Non autorisé à modifier cet avis")
@APIResponse(responseCode = "404", description = "Avis non trouvé")
public Response updateReview(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID reviewId,
@Valid ReviewUpdateRequestDTO requestDTO) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
UUID authenticatedUserId = securityService.getCurrentUserId();
LOG.info("[LOG] Mise à jour de l'avis : " + reviewId + " par l'utilisateur : " + authenticatedUserId);
try {
@@ -304,7 +313,8 @@ public class ReviewResource {
@DELETE
@Path("/{id}")
@Transactional
@RequiresAuth
@RolesAllowed({UserRoles.USER, UserRoles.MANAGER, UserRoles.ADMIN, UserRoles.SUPER_ADMIN})
@RequiresPermission(Permission.REVIEWS_DELETE_OWN)
@Operation(
summary = "Supprimer un avis",
description = "Supprime un avis. Seul l'auteur peut supprimer.")
@@ -313,10 +323,8 @@ public class ReviewResource {
@APIResponse(responseCode = "401", description = "Non authentifié")
@APIResponse(responseCode = "403", description = "Non autorisé à supprimer cet avis")
@APIResponse(responseCode = "404", description = "Avis non trouvé")
public Response deleteReview(
@Context ContainerRequestContext requestContext,
@PathParam("id") UUID reviewId) {
UUID authenticatedUserId = getAuthenticatedUserId(requestContext);
public Response deleteReview(@PathParam("id") UUID reviewId) {
UUID authenticatedUserId = securityService.getCurrentUserId();
LOG.info("[LOG] Suppression de l'avis : " + reviewId + " par l'utilisateur : " + authenticatedUserId);
try {

View File

@@ -1,24 +1,24 @@
# ====================================================================
# AfterWork Server - Configuration PRODUCTION (profil prod)
# ====================================================================
# Charg? avec QUARKUS_PROFILE=prod (Kubernetes ConfigMap).
# Ce fichier remplace application-production.properties pour coh?rence
# avec le d?ploiement (QUARKUS_PROFILE=prod).
# Chargé avec QUARKUS_PROFILE=prod (Kubernetes ConfigMap).
#
# INFRASTRUCTURE LIONS:
# - Prometheus: https://prometheus.lions.dev (scraping automatique via annotations)
# - Grafana: https://grafana.lions.dev (dashboard AfterWork disponible)
# - Vault: https://vault.lions.dev (gestion secrets si déverrouillé)
# - Kafka: kafka-service.kafka.svc.cluster.local:9092
# - PostgreSQL: postgresql-service.postgresql.svc.cluster.local:5432
# - Keycloak: https://security.lions.dev (SSO optionnel)
# ====================================================================
# HTTP - Chemin de base de l'API
# ====================================================================
# Permet d'acc?der ? l'API via https://api.lions.dev/afterwork
# Accessible via https://api.lions.dev/afterwork
#
# IMPORTANT - Configuration Ingress requise:
# Cette application utilise quarkus.http.root-path pour ?tre "context-aware",
# ce qui permet ? Swagger UI de g?n?rer les bonnes URLs.
# L'Ingress Kubernetes DOIT pr?server le chemin complet (PAS de rewrite-target).
#
# Configuration Ingress correcte:
# - path: /afterwork
# - pathType: Prefix
# - PAS d'annotation rewrite-target
# IMPORTANT - Configuration Ingress:
# L'application est "context-aware" (quarkus.http.root-path=/afterwork).
# L'Ingress Kubernetes doit préserver le chemin complet (PAS de rewrite-target).
#
quarkus.http.root-path=/afterwork

View File

@@ -0,0 +1,163 @@
package com.lions.dev.resource;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
/**
* Tests d'intégration pour BookingResource.
*
* @since 2.0 - Tests production-ready
*/
@QuarkusTest
class BookingResourceTest {
// =========================================================================
// Tests d'accès sans authentification
// =========================================================================
@Test
@DisplayName("GET /bookings - Sans authentification devrait retourner 401")
void testGetAllBookingsWithoutAuth() {
given()
.when()
.get("/afterwork/bookings")
.then()
.statusCode(401);
}
@Test
@DisplayName("POST /bookings - Sans authentification devrait retourner 401")
void testCreateBookingWithoutAuth() {
given()
.contentType(ContentType.JSON)
.body("""
{
"eventId": "00000000-0000-0000-0000-000000000001",
"userId": "00000000-0000-0000-0000-000000000002",
"seats": 2
}
""")
.when()
.post("/afterwork/bookings")
.then()
.statusCode(401);
}
// =========================================================================
// Tests avec authentification USER
// =========================================================================
@Test
@TestSecurity(user = "user@test.com", roles = {"USER"})
@DisplayName("GET /bookings/user/{userId} - Accès aux propres réservations")
void testGetOwnBookings() {
UUID userId = UUID.randomUUID();
given()
.when()
.get("/afterwork/bookings/user/" + userId)
.then()
.statusCode(anyOf(is(200), is(403))); // 403 si userId ne correspond pas
}
@Test
@TestSecurity(user = "user@test.com", roles = {"USER"})
@DisplayName("GET /bookings/{id} - Réservation inexistante devrait retourner 404")
void testGetNonExistentBooking() {
UUID randomId = UUID.randomUUID();
given()
.when()
.get("/afterwork/bookings/" + randomId)
.then()
.statusCode(404);
}
@Test
@TestSecurity(user = "user@test.com", roles = {"USER"})
@DisplayName("DELETE /bookings/{id} - Réservation inexistante devrait retourner 404")
void testCancelNonExistentBooking() {
UUID randomId = UUID.randomUUID();
given()
.when()
.delete("/afterwork/bookings/" + randomId)
.then()
.statusCode(404);
}
// =========================================================================
// Tests avec authentification ADMIN
// =========================================================================
@Test
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
@DisplayName("GET /bookings - ADMIN peut voir toutes les réservations")
void testAdminCanViewAllBookings() {
given()
.when()
.get("/afterwork/bookings")
.then()
.statusCode(200)
.body("$", instanceOf(java.util.List.class));
}
@Test
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
@DisplayName("GET /bookings/event/{eventId} - ADMIN peut voir les réservations d'un événement")
void testAdminCanViewEventBookings() {
UUID eventId = UUID.randomUUID();
given()
.when()
.get("/afterwork/bookings/event/" + eventId)
.then()
.statusCode(anyOf(is(200), is(404)));
}
// =========================================================================
// Tests avec authentification OWNER/MANAGER
// =========================================================================
@Test
@TestSecurity(user = "owner@test.com", roles = {"OWNER"})
@DisplayName("GET /bookings/establishment/{id} - OWNER peut voir les réservations de son établissement")
void testOwnerCanViewEstablishmentBookings() {
UUID establishmentId = UUID.randomUUID();
given()
.when()
.get("/afterwork/bookings/establishment/" + establishmentId)
.then()
.statusCode(anyOf(is(200), is(404), is(403)));
}
// =========================================================================
// Tests de validation
// =========================================================================
@Test
@TestSecurity(user = "user@test.com", roles = {"USER"})
@DisplayName("POST /bookings - Données invalides devrait retourner 400")
void testCreateBookingWithInvalidData() {
given()
.contentType(ContentType.JSON)
.body("""
{
"seats": -1
}
""")
.when()
.post("/afterwork/bookings")
.then()
.statusCode(400);
}
}

View File

@@ -0,0 +1,223 @@
package com.lions.dev.resource;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
/**
* Tests d'intégration pour EstablishmentResource.
*
* @since 2.0 - Tests production-ready
*/
@QuarkusTest
class EstablishmentResourceTest {
// =========================================================================
// Tests endpoints publics (lecture)
// =========================================================================
@Test
@DisplayName("GET /establishments - Liste des établissements (public)")
void testGetAllEstablishments() {
given()
.when()
.get("/afterwork/establishments")
.then()
.statusCode(200)
.body("$", instanceOf(java.util.List.class));
}
@Test
@DisplayName("GET /establishments/{id} - Établissement inexistant devrait retourner 404")
void testGetNonExistentEstablishment() {
UUID randomId = UUID.randomUUID();
given()
.when()
.get("/afterwork/establishments/" + randomId)
.then()
.statusCode(404);
}
@Test
@DisplayName("GET /establishments/search - Recherche d'établissements")
void testSearchEstablishments() {
given()
.queryParam("q", "bar")
.when()
.get("/afterwork/establishments/search")
.then()
.statusCode(200)
.body("$", instanceOf(java.util.List.class));
}
@Test
@DisplayName("GET /establishments/nearby - Recherche par proximité")
void testGetNearbyEstablishments() {
given()
.queryParam("lat", 5.3600)
.queryParam("lng", -3.9400)
.queryParam("radius", 10)
.when()
.get("/afterwork/establishments/nearby")
.then()
.statusCode(200)
.body("$", instanceOf(java.util.List.class));
}
// =========================================================================
// Tests endpoints protégés (création, modification)
// =========================================================================
@Test
@DisplayName("POST /establishments - Sans authentification devrait retourner 401")
void testCreateEstablishmentWithoutAuth() {
given()
.contentType(ContentType.JSON)
.body("""
{
"name": "Test Bar",
"description": "Un bar de test",
"address": "123 Rue Test",
"city": "Abidjan"
}
""")
.when()
.post("/afterwork/establishments")
.then()
.statusCode(401);
}
@Test
@TestSecurity(user = "user@test.com", roles = {"USER"})
@DisplayName("POST /establishments - USER ne peut pas créer d'établissement")
void testCreateEstablishmentWithUserRole() {
given()
.contentType(ContentType.JSON)
.body("""
{
"name": "Test Bar",
"description": "Un bar de test",
"address": "123 Rue Test",
"city": "Abidjan"
}
""")
.when()
.post("/afterwork/establishments")
.then()
.statusCode(403);
}
@Test
@TestSecurity(user = "owner@test.com", roles = {"OWNER"})
@DisplayName("POST /establishments - OWNER peut créer un établissement")
void testCreateEstablishmentWithOwnerRole() {
given()
.contentType(ContentType.JSON)
.body("""
{
"name": "Test Bar",
"description": "Un bar de test",
"address": "123 Rue Test",
"city": "Abidjan",
"phone": "+22501234567",
"email": "testbar@test.com",
"latitude": 5.3600,
"longitude": -3.9400
}
""")
.when()
.post("/afterwork/establishments")
.then()
.statusCode(anyOf(is(201), is(200), is(400))); // 400 si validation échoue
}
@Test
@TestSecurity(user = "owner@test.com", roles = {"OWNER"})
@DisplayName("PUT /establishments/{id} - OWNER peut modifier un établissement")
void testUpdateEstablishmentWithOwnerRole() {
UUID randomId = UUID.randomUUID();
given()
.contentType(ContentType.JSON)
.body("""
{
"name": "Updated Bar Name",
"description": "Description mise à jour"
}
""")
.when()
.put("/afterwork/establishments/" + randomId)
.then()
.statusCode(anyOf(is(200), is(404), is(403))); // 404 si n'existe pas, 403 si pas propriétaire
}
@Test
@TestSecurity(user = "owner@test.com", roles = {"OWNER"})
@DisplayName("DELETE /establishments/{id} - Établissement inexistant devrait retourner 404")
void testDeleteNonExistentEstablishment() {
UUID randomId = UUID.randomUUID();
given()
.when()
.delete("/afterwork/establishments/" + randomId)
.then()
.statusCode(404);
}
// =========================================================================
// Tests endpoints admin
// =========================================================================
@Test
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
@DisplayName("GET /establishments/pending - ADMIN peut voir les établissements en attente")
void testGetPendingEstablishments() {
given()
.when()
.get("/afterwork/establishments/pending")
.then()
.statusCode(anyOf(is(200), is(404)));
}
@Test
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
@DisplayName("PATCH /establishments/{id}/approve - ADMIN peut approuver un établissement")
void testApproveEstablishment() {
UUID randomId = UUID.randomUUID();
given()
.when()
.patch("/afterwork/establishments/" + randomId + "/approve")
.then()
.statusCode(anyOf(is(200), is(404)));
}
// =========================================================================
// Tests de validation
// =========================================================================
@Test
@TestSecurity(user = "owner@test.com", roles = {"OWNER"})
@DisplayName("POST /establishments - Données invalides devrait retourner 400")
void testCreateEstablishmentWithInvalidData() {
given()
.contentType(ContentType.JSON)
.body("""
{
"name": ""
}
""")
.when()
.post("/afterwork/establishments")
.then()
.statusCode(400);
}
}

View File

@@ -0,0 +1,215 @@
package com.lions.dev.resource;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
/**
* Tests d'intégration pour UsersResource.
*
* @since 2.0 - Tests production-ready
*/
@QuarkusTest
class UsersResourceTest {
// =========================================================================
// Tests endpoints publics (authentification, inscription)
// =========================================================================
@Test
@DisplayName("POST /users/authenticate - Identifiants invalides devrait retourner 401")
void testAuthenticateWithInvalidCredentials() {
given()
.contentType(ContentType.JSON)
.body("""
{
"email": "invalid@test.com",
"password": "wrongpassword"
}
""")
.when()
.post("/afterwork/users/authenticate")
.then()
.statusCode(anyOf(is(401), is(404)));
}
@Test
@DisplayName("POST /users/authenticate - Email manquant devrait retourner 400")
void testAuthenticateWithMissingEmail() {
given()
.contentType(ContentType.JSON)
.body("""
{
"password": "somepassword"
}
""")
.when()
.post("/afterwork/users/authenticate")
.then()
.statusCode(400);
}
@Test
@DisplayName("POST /users/register - Email invalide devrait retourner 400")
void testRegisterWithInvalidEmail() {
given()
.contentType(ContentType.JSON)
.body("""
{
"email": "invalidemail",
"password": "ValidPassword123!",
"firstName": "Test",
"lastName": "User"
}
""")
.when()
.post("/afterwork/users/register")
.then()
.statusCode(400);
}
@Test
@DisplayName("POST /users/forgot-password - Email inexistant devrait retourner 404 ou 200 (sécurité)")
void testForgotPasswordWithNonExistentEmail() {
// Pour des raisons de sécurité, certaines implémentations retournent 200
// même si l'email n'existe pas (pour ne pas divulguer d'informations)
given()
.contentType(ContentType.JSON)
.body("""
{
"email": "nonexistent@test.com"
}
""")
.when()
.post("/afterwork/users/forgot-password")
.then()
.statusCode(anyOf(is(200), is(404)));
}
// =========================================================================
// Tests endpoints protégés (profil utilisateur)
// =========================================================================
@Test
@DisplayName("GET /users/me - Sans authentification devrait retourner 401")
void testGetProfileWithoutAuth() {
given()
.when()
.get("/afterwork/users/me")
.then()
.statusCode(401);
}
@Test
@TestSecurity(user = "testuser@test.com", roles = {"USER"})
@DisplayName("GET /users/me - Avec authentification devrait retourner le profil")
void testGetProfileWithAuth() {
given()
.when()
.get("/afterwork/users/me")
.then()
.statusCode(anyOf(is(200), is(404))); // 404 si l'utilisateur de test n'existe pas en BDD
}
@Test
@DisplayName("PUT /users/{id} - Sans authentification devrait retourner 401")
void testUpdateProfileWithoutAuth() {
UUID randomId = UUID.randomUUID();
given()
.contentType(ContentType.JSON)
.body("""
{
"firstName": "Updated",
"lastName": "User"
}
""")
.when()
.put("/afterwork/users/" + randomId)
.then()
.statusCode(401);
}
// =========================================================================
// Tests endpoints admin
// =========================================================================
@Test
@DisplayName("GET /users - Sans authentification devrait retourner 401")
void testGetAllUsersWithoutAuth() {
given()
.when()
.get("/afterwork/users")
.then()
.statusCode(401);
}
@Test
@TestSecurity(user = "user@test.com", roles = {"USER"})
@DisplayName("GET /users - Avec rôle USER devrait retourner 403")
void testGetAllUsersWithUserRole() {
given()
.when()
.get("/afterwork/users")
.then()
.statusCode(403);
}
@Test
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
@DisplayName("GET /users - Avec rôle ADMIN devrait retourner 200")
void testGetAllUsersWithAdminRole() {
given()
.when()
.get("/afterwork/users")
.then()
.statusCode(200)
.body("$", instanceOf(java.util.List.class));
}
@Test
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
@DisplayName("DELETE /users/{id} - Suppression d'un utilisateur inexistant devrait retourner 404")
void testDeleteNonExistentUser() {
UUID randomId = UUID.randomUUID();
given()
.when()
.delete("/afterwork/users/" + randomId)
.then()
.statusCode(404);
}
// =========================================================================
// Tests de validation
// =========================================================================
@Test
@DisplayName("POST /users/register - Données complètes valides")
void testRegisterValidation() {
String uniqueEmail = "test_" + System.currentTimeMillis() + "@test.com";
given()
.contentType(ContentType.JSON)
.body(String.format("""
{
"email": "%s",
"password": "ValidPassword123!",
"firstName": "Test",
"lastName": "User",
"phone": "+33612345678"
}
""", uniqueEmail))
.when()
.post("/afterwork/users/register")
.then()
.statusCode(anyOf(is(201), is(200), is(409))); // 409 si l'email existe déjà
}
}

View File

@@ -0,0 +1,113 @@
package com.lions.dev.service;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
import jakarta.inject.Inject;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tests unitaires pour MessageService.
*
* @since 2.0 - Tests production-ready
*/
@QuarkusTest
class MessageServiceTest {
@Inject
MessageService messageService;
// =========================================================================
// Tests de base
// =========================================================================
@Test
@DisplayName("Service devrait être injecté")
void testServiceInjection() {
assertNotNull(messageService, "MessageService devrait être injecté");
}
@Test
@DisplayName("getUserConversations devrait retourner une liste vide pour un utilisateur sans conversations")
void testGetUserConversationsEmpty() {
UUID randomUserId = UUID.randomUUID();
var conversations = messageService.getUserConversations(randomUserId);
assertNotNull(conversations, "La liste ne devrait pas être null");
assertTrue(conversations.isEmpty(), "La liste devrait être vide pour un nouvel utilisateur");
}
@Test
@DisplayName("getConversation devrait retourner null pour une conversation inexistante")
void testGetNonExistentConversation() {
UUID randomConvId = UUID.randomUUID();
var conversation = messageService.getConversation(randomConvId);
assertNull(conversation, "Devrait retourner null pour une conversation inexistante");
}
@Test
@DisplayName("getConversationMessages devrait retourner une liste vide pour une conversation inexistante")
void testGetMessagesForNonExistentConversation() {
UUID randomConvId = UUID.randomUUID();
var messages = messageService.getConversationMessages(randomConvId, 0, 50);
assertNotNull(messages, "La liste ne devrait pas être null");
assertTrue(messages.isEmpty(), "La liste devrait être vide");
}
@Test
@DisplayName("getTotalUnreadCount devrait retourner 0 pour un utilisateur sans messages")
void testGetUnreadCountForNewUser() {
UUID randomUserId = UUID.randomUUID();
long count = messageService.getTotalUnreadCount(randomUserId);
assertEquals(0, count, "Le compte de messages non lus devrait être 0");
}
@Test
@DisplayName("deleteMessage devrait retourner false pour un message inexistant")
void testDeleteNonExistentMessage() {
UUID randomMessageId = UUID.randomUUID();
boolean deleted = messageService.deleteMessage(randomMessageId);
assertFalse(deleted, "Devrait retourner false pour un message inexistant");
}
@Test
@DisplayName("deleteConversation devrait retourner false pour une conversation inexistante")
void testDeleteNonExistentConversation() {
UUID randomConvId = UUID.randomUUID();
boolean deleted = messageService.deleteConversation(randomConvId);
assertFalse(deleted, "Devrait retourner false pour une conversation inexistante");
}
// =========================================================================
// Tests de pagination
// =========================================================================
@Test
@DisplayName("getConversationMessages devrait respecter les paramètres de pagination")
void testMessagesPagination() {
UUID randomConvId = UUID.randomUUID();
// Page 0, size 10
var page1 = messageService.getConversationMessages(randomConvId, 0, 10);
assertNotNull(page1);
// Page 1, size 10
var page2 = messageService.getConversationMessages(randomConvId, 1, 10);
assertNotNull(page2);
}
}

View File

@@ -0,0 +1,164 @@
package com.lions.dev.service;
import com.lions.dev.security.Permission;
import com.lions.dev.security.Role;
import com.lions.dev.security.RolePermissionConfig;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
import jakarta.inject.Inject;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Set;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tests unitaires pour le SecurityService.
* Utilise @TestSecurity pour simuler différents contextes de sécurité.
*
* @since 2.0 - Tests production-ready
*/
@QuarkusTest
class SecurityServiceTest {
@Inject
SecurityService securityService;
// =========================================================================
// Tests des permissions par rôle
// =========================================================================
@Test
@DisplayName("Role USER devrait avoir les permissions de base")
void testUserRoleHasBasicPermissions() {
Set<Permission> userPermissions = RolePermissionConfig.getPermissions(Role.USER);
// Vérifier les permissions de profil
assertTrue(userPermissions.contains(Permission.PROFILE_READ), "USER devrait pouvoir lire son profil");
assertTrue(userPermissions.contains(Permission.PROFILE_UPDATE), "USER devrait pouvoir modifier son profil");
// Vérifier les permissions de lecture
assertTrue(userPermissions.contains(Permission.EVENTS_READ), "USER devrait pouvoir lire les événements");
assertTrue(userPermissions.contains(Permission.ESTABLISHMENTS_READ), "USER devrait pouvoir lire les établissements");
// Vérifier les permissions de participation
assertTrue(userPermissions.contains(Permission.EVENTS_PARTICIPATE), "USER devrait pouvoir participer aux événements");
assertTrue(userPermissions.contains(Permission.RESERVATIONS_CREATE), "USER devrait pouvoir créer des réservations");
// Vérifier l'absence de permissions admin
assertFalse(userPermissions.contains(Permission.SUPER_ADMIN_ACCESS), "USER ne devrait pas avoir accès super admin");
assertFalse(userPermissions.contains(Permission.USERS_DELETE_ANY), "USER ne devrait pas pouvoir supprimer d'autres utilisateurs");
}
@Test
@DisplayName("Role OWNER devrait avoir les permissions d'établissement")
void testOwnerRoleHasEstablishmentPermissions() {
Set<Permission> ownerPermissions = RolePermissionConfig.getPermissions(Role.OWNER);
// Permissions de gestion d'établissement
assertTrue(ownerPermissions.contains(Permission.ESTABLISHMENTS_CREATE), "OWNER devrait pouvoir créer un établissement");
assertTrue(ownerPermissions.contains(Permission.ESTABLISHMENTS_UPDATE_OWN), "OWNER devrait pouvoir modifier son établissement");
assertTrue(ownerPermissions.contains(Permission.ESTABLISHMENTS_DELETE_OWN), "OWNER devrait pouvoir supprimer son établissement");
assertTrue(ownerPermissions.contains(Permission.ESTABLISHMENTS_MANAGE_STAFF), "OWNER devrait pouvoir gérer le personnel");
assertTrue(ownerPermissions.contains(Permission.ESTABLISHMENTS_VIEW_ANALYTICS), "OWNER devrait voir les analytics");
// Permissions de promotions
assertTrue(ownerPermissions.contains(Permission.PROMOTIONS_CREATE), "OWNER devrait pouvoir créer des promotions");
assertTrue(ownerPermissions.contains(Permission.PROMOTIONS_UPDATE_OWN), "OWNER devrait pouvoir modifier ses promotions");
assertTrue(ownerPermissions.contains(Permission.PROMOTIONS_DELETE_OWN), "OWNER devrait pouvoir supprimer ses promotions");
}
@Test
@DisplayName("Role SUPER_ADMIN devrait avoir toutes les permissions")
void testSuperAdminHasAllPermissions() {
Set<Permission> superAdminPermissions = RolePermissionConfig.getPermissions(Role.SUPER_ADMIN);
// SUPER_ADMIN devrait avoir accès à tout
assertTrue(superAdminPermissions.contains(Permission.SUPER_ADMIN_ACCESS), "SUPER_ADMIN devrait avoir accès super admin");
assertTrue(superAdminPermissions.contains(Permission.USERS_DELETE_ANY), "SUPER_ADMIN devrait pouvoir supprimer tout utilisateur");
assertTrue(superAdminPermissions.contains(Permission.EVENTS_DELETE_ANY), "SUPER_ADMIN devrait pouvoir supprimer tout événement");
assertTrue(superAdminPermissions.contains(Permission.ESTABLISHMENTS_DELETE_ANY), "SUPER_ADMIN devrait pouvoir supprimer tout établissement");
assertTrue(superAdminPermissions.contains(Permission.ADMIN_SETTINGS), "SUPER_ADMIN devrait pouvoir modifier les paramètres système");
}
@Test
@DisplayName("Role MODERATOR devrait avoir les permissions de modération")
void testModeratorRoleHasModerationPermissions() {
Set<Permission> modPermissions = RolePermissionConfig.getPermissions(Role.MODERATOR);
assertTrue(modPermissions.contains(Permission.MODERATION_VIEW_REPORTS), "MODERATOR devrait voir les signalements");
assertTrue(modPermissions.contains(Permission.MODERATION_HANDLE_REPORTS), "MODERATOR devrait traiter les signalements");
assertTrue(modPermissions.contains(Permission.MODERATION_HIDE_CONTENT), "MODERATOR devrait pouvoir masquer du contenu");
assertTrue(modPermissions.contains(Permission.POSTS_MODERATE), "MODERATOR devrait pouvoir modérer les posts");
assertTrue(modPermissions.contains(Permission.REVIEWS_MODERATE), "MODERATOR devrait pouvoir modérer les avis");
}
// =========================================================================
// Tests du contexte de sécurité avec @TestSecurity
// =========================================================================
@Test
@TestSecurity(user = "testuser@test.com", roles = {"USER"})
@DisplayName("Utilisateur authentifié avec rôle USER")
void testAuthenticatedUserRole() {
assertTrue(securityService.hasAnyRole("USER"), "Devrait avoir le rôle USER");
assertFalse(securityService.hasAnyRole("ADMIN"), "Ne devrait pas avoir le rôle ADMIN");
assertFalse(securityService.hasAnyRole("SUPER_ADMIN"), "Ne devrait pas avoir le rôle SUPER_ADMIN");
}
@Test
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
@DisplayName("Utilisateur authentifié avec rôle ADMIN")
void testAuthenticatedAdminRole() {
assertTrue(securityService.hasAnyRole("ADMIN"), "Devrait avoir le rôle ADMIN");
assertTrue(securityService.hasAnyRole("USER", "ADMIN"), "Devrait avoir USER ou ADMIN");
}
@Test
@TestSecurity(user = "superadmin@test.com", roles = {"SUPER_ADMIN"})
@DisplayName("Utilisateur authentifié avec rôle SUPER_ADMIN")
void testAuthenticatedSuperAdminRole() {
assertTrue(securityService.hasAnyRole("SUPER_ADMIN"), "Devrait avoir le rôle SUPER_ADMIN");
}
// =========================================================================
// Tests des utilitaires de rôle
// =========================================================================
@Test
@DisplayName("Test conversion String vers Role")
void testRoleFromString() {
assertEquals(Role.USER, Role.valueOf("USER"));
assertEquals(Role.OWNER, Role.valueOf("OWNER"));
assertEquals(Role.MANAGER, Role.valueOf("MANAGER"));
assertEquals(Role.ADMIN, Role.valueOf("ADMIN"));
assertEquals(Role.SUPER_ADMIN, Role.valueOf("SUPER_ADMIN"));
assertEquals(Role.MODERATOR, Role.valueOf("MODERATOR"));
assertEquals(Role.SUPPORT, Role.valueOf("SUPPORT"));
assertEquals(Role.FINANCE, Role.valueOf("FINANCE"));
}
@Test
@DisplayName("Test que chaque rôle a au moins une permission")
void testAllRolesHavePermissions() {
for (Role role : Role.values()) {
Set<Permission> permissions = RolePermissionConfig.getPermissions(role);
assertFalse(permissions.isEmpty(), "Le rôle " + role + " devrait avoir au moins une permission");
}
}
// =========================================================================
// Tests des permissions
// =========================================================================
@Test
@DisplayName("Test que toutes les permissions ont une description")
void testAllPermissionsHaveDescription() {
for (Permission permission : Permission.values()) {
assertNotNull(permission.getDescription(), "La permission " + permission + " devrait avoir une description");
assertFalse(permission.getDescription().isBlank(), "La description de " + permission + " ne devrait pas être vide");
}
}
}