diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..71f3c2c --- /dev/null +++ b/.env.example @@ -0,0 +1,128 @@ +# ============================================ +# AfterWork API - Configuration +# ============================================ +# Copiez ce fichier vers .env et remplissez les valeurs appropriées +# NE JAMAIS COMMITTER le fichier .env ! +# +# ==== INFRASTRUCTURE LIONS (Production) ==== +# - API Gateway: https://api.lions.dev/afterwork +# - PostgreSQL: postgresql-service.postgresql.svc.cluster.local:5432 +# - Kafka: kafka-service.kafka.svc.cluster.local:9092 +# - Prometheus: https://prometheus.lions.dev +# - Grafana: https://grafana.lions.dev +# - Vault: https://vault.lions.dev +# - Keycloak: https://security.lions.dev + +# ============================================ +# BASE DE DONNÉES +# ============================================ +# === Développement local === +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=afterwork_dev +DB_USERNAME=skyfile +DB_PASSWORD=skyfile + +# === Production Lions (via Kubernetes Secrets) === +# DB_HOST=postgresql-service.postgresql.svc.cluster.local +# DB_PORT=5432 +# DB_NAME=mic-after-work-server-impl-quarkus-main +# DB_USERNAME=lionsuser +# DB_PASSWORD= + +# ============================================ +# JWT / SÉCURITÉ +# ============================================ +# Secret pour signer les tokens JWT (minimum 32 caractères) +# Générez avec: openssl rand -base64 32 +JWT_SECRET=afterwork-jwt-secret-min-32-bytes-for-hs256! +JWT_LIFESPAN=86400 +JWT_ISSUER=afterwork-api + +# ============================================ +# SUPER ADMIN +# ============================================ +SUPER_ADMIN_EMAIL=superadmin@afterwork.lions.dev +SUPER_ADMIN_PASSWORD=SuperAdmin2025! +SUPER_ADMIN_API_KEY=dev-super-admin-key + +# ============================================ +# EMAIL (SMTP) +# ============================================ +# Mode mock pour le développement (pas d'envoi réel) +MAILER_MOCK=true +MAILER_HOST=smtp.gmail.com +MAILER_PORT=587 +MAILER_USERNAME=noreply@afterwork.ci +MAILER_PASSWORD=CHANGEZ_MOI_SMTP_PASSWORD +MAILER_FROM=AfterWork + +# ============================================ +# KAFKA +# ============================================ +# === Développement local === +KAFKA_BOOTSTRAP_SERVERS=localhost:9092 + +# === Production Lions === +# KAFKA_BOOTSTRAP_SERVERS=kafka-service.kafka.svc.cluster.local:9092 + +# === Confluent Cloud (optionnel) === +# KAFKA_BOOTSTRAP_SERVERS=pkc-xxxxx.region.provider.confluent.cloud:9092 +# KAFKA_SECURITY_PROTOCOL=SASL_SSL +# KAFKA_SASL_MECHANISM=PLAIN +# KAFKA_SASL_USERNAME=YOUR_API_KEY +# KAFKA_SASL_PASSWORD=YOUR_API_SECRET + +# ============================================ +# WAVE PAYMENT +# ============================================ +WAVE_BASE_URL=https://api.wave.com +WAVE_API_KEY=VOTRE_CLE_API_WAVE +WAVE_SECRET=VOTRE_SECRET_WAVE +WAVE_CURRENCY=XOF +WAVE_CALLBACK_URL=https://api.lions.dev/afterwork/webhooks/wave + +# ============================================ +# RATE LIMITING +# ============================================ +AFTERWORK_RATELIMIT_MAX_REQUESTS=10 +AFTERWORK_RATELIMIT_WINDOW_SECONDS=60 + +# ============================================ +# QUARKUS +# ============================================ +QUARKUS_PROFILE=dev +QUARKUS_PACKAGE_TYPE=fast-jar +QUARKUS_LOG_LEVEL=INFO +QUARKUS_LOG_CONSOLE_JSON=false + +# CORS (développement) +QUARKUS_HTTP_CORS=true +QUARKUS_HTTP_CORS_ORIGINS=http://localhost:3000,http://localhost:4200 + +# ============================================ +# OBSERVABILITÉ +# ============================================ +# Métriques Prometheus (auto-découverte via annotations K8s) +QUARKUS_MICROMETER_EXPORT_PROMETHEUS_ENABLED=true + +# Health checks +QUARKUS_SMALLRYE_HEALTH_UI_ENABLE=true + +# ============================================ +# DÉPLOIEMENT LIONS (lionsctl) +# ============================================ +# Pour déployer avec lionsctl: +# lionsctl pipeline \ +# -u https://git.lions.dev/lionsdev/mic-after-work-server-impl-quarkus-main \ +# -b develop \ +# -j 17 \ +# -e production \ +# -c k2 \ +# -m dadyo@lions.dev + +# Variables d'environnement requises pour lionsctl: +# LIONS_REGISTRY_USERNAME=lionsregistry +# LIONS_REGISTRY_PASSWORD= +# LIONS_GITEA_USERNAME=lionsctl-bot +# LIONS_GITEA_PASSWORD=lionsctl-bot@2025 diff --git a/kubernetes/afterwork-configmap.yaml b/kubernetes/afterwork-configmap.yaml index b9e3e9d..76e61c9 100644 --- a/kubernetes/afterwork-configmap.yaml +++ b/kubernetes/afterwork-configmap.yaml @@ -1,12 +1,2 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: afterwork-config - namespace: applications -data: - DB_HOST: "postgresql" - DB_PORT: "5432" - DB_NAME: "afterwork_db" - DB_USERNAME: "afterwork" - QUARKUS_PROFILE: "prod" - TZ: "Africa/Douala" +# ConfigMap déplacé dans afterwork-secrets.yaml pour cohérence +# Voir afterwork-secrets.yaml pour la configuration complète diff --git a/kubernetes/afterwork-deployment.yaml b/kubernetes/afterwork-deployment.yaml index 7f628fe..291c82a 100644 --- a/kubernetes/afterwork-deployment.yaml +++ b/kubernetes/afterwork-deployment.yaml @@ -1,14 +1,20 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: afterwork-api + name: mic-after-work-server-impl-quarkus-main namespace: applications labels: - app: afterwork-api + app: mic-after-work-server-impl-quarkus-main version: "1.0.0" environment: production + component: application + project: lions-infrastructure-2025 + annotations: + description: "AfterWork API - Application sociale déployée via lionsctl" + lionsctl.lions.dev/deployed-by: "lionsctl" spec: - replicas: 2 + replicas: 1 + revisionHistoryLimit: 3 strategy: type: RollingUpdate rollingUpdate: @@ -16,37 +22,86 @@ spec: maxUnavailable: 0 selector: matchLabels: - app: afterwork-api + app: mic-after-work-server-impl-quarkus-main template: metadata: labels: - app: afterwork-api + app: mic-after-work-server-impl-quarkus-main version: "1.0.0" + component: application + project: lions-infrastructure-2025 annotations: + # Prometheus scraping - Lions Prometheus auto-découvre via ces annotations prometheus.io/scrape: "true" prometheus.io/port: "8080" prometheus.io/path: "/afterwork/q/metrics" spec: + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 + seccompProfile: + type: RuntimeDefault + terminationGracePeriodSeconds: 30 containers: - - name: afterwork-api - image: registry.lions.dev/lionsdev/mic-after-work-server-impl-quarkus-main:d659416 + - name: mic-after-work-server-impl-quarkus-main + image: registry.lions.dev/lionsdev/mic-after-work-server-impl-quarkus-main:latest imagePullPolicy: Always ports: - containerPort: 8080 name: http protocol: TCP + # Variables d'environnement depuis ConfigMap et Secrets envFrom: - configMapRef: name: afterwork-config - secretRef: name: afterwork-secrets + env: + # Override explicites pour Quarkus + - name: QUARKUS_DATASOURCE_DB_KIND + value: "postgresql" + - name: QUARKUS_DATASOURCE_USERNAME + valueFrom: + configMapKeyRef: + name: afterwork-config + key: DB_USERNAME + - name: QUARKUS_DATASOURCE_PASSWORD + valueFrom: + secretKeyRef: + name: afterwork-secrets + key: DB_PASSWORD + - name: QUARKUS_DATASOURCE_JDBC_URL + value: "jdbc:postgresql://$(DB_HOST):$(DB_PORT)/$(DB_NAME)" + # Kafka - Lions Kafka cluster + - name: KAFKA_BOOTSTRAP_SERVERS + valueFrom: + configMapKeyRef: + name: afterwork-config + key: KAFKA_BOOTSTRAP_SERVERS + # JWT + - name: SMALLRYE_JWT_SIGN_KEY + valueFrom: + secretKeyRef: + name: afterwork-secrets + key: JWT_SECRET + - name: MP_JWT_VERIFY_ISSUER + valueFrom: + configMapKeyRef: + name: afterwork-config + key: JWT_ISSUER + # Java options + - name: JAVA_OPTS + value: "-Xms256m -Xmx512m -Djava.util.logging.manager=org.jboss.logmanager.LogManager" resources: requests: memory: "512Mi" - cpu: "250m" + cpu: "200m" limits: memory: "1Gi" cpu: "1000m" + # Health checks HTTP (utilisent les endpoints SmallRye Health) livenessProbe: httpGet: path: /afterwork/q/health/live @@ -67,13 +122,35 @@ spec: timeoutSeconds: 5 successThreshold: 1 failureThreshold: 3 + # Startup probe pour éviter les kills pendant le démarrage + startupProbe: + httpGet: + path: /afterwork/q/health/started + port: 8080 + scheme: HTTP + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 30 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + capabilities: + drop: + - ALL volumeMounts: - - name: temp-uploads - mountPath: /tmp/uploads + - name: tmp-volume + mountPath: /tmp + - name: logs-volume + mountPath: /app/logs volumes: - - name: temp-uploads - emptyDir: - sizeLimit: 1Gi + - name: tmp-volume + emptyDir: {} + - name: logs-volume + emptyDir: {} imagePullSecrets: - name: lionsregistry-secret restartPolicy: Always diff --git a/kubernetes/afterwork-monitoring.yaml b/kubernetes/afterwork-monitoring.yaml new file mode 100644 index 0000000..c1f00a4 --- /dev/null +++ b/kubernetes/afterwork-monitoring.yaml @@ -0,0 +1,408 @@ +# ============================================================================== +# AfterWork API - Configuration Monitoring pour Lions Infrastructure +# ============================================================================== +# Cette configuration intègre l'application avec: +# - Prometheus (https://prometheus.lions.dev) - scraping auto via annotations +# - Grafana (https://grafana.lions.dev) - dashboard dédié +# ============================================================================== + +--- +# ============================================================================== +# ServiceMonitor pour Prometheus Operator (si installé) +# ============================================================================== +# Note: L'infrastructure Lions utilise le scraping via annotations pod, mais +# ce ServiceMonitor peut être utilisé si Prometheus Operator est déployé. +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: afterwork-api-monitor + namespace: monitoring + labels: + app: mic-after-work-server-impl-quarkus-main + release: prometheus + project: lions-infrastructure-2025 +spec: + selector: + matchLabels: + app: mic-after-work-server-impl-quarkus-main + namespaceSelector: + matchNames: + - applications + endpoints: + - port: http-direct + path: /afterwork/q/metrics + interval: 30s + scrapeTimeout: 10s + scheme: http + +--- +# ============================================================================== +# PrometheusRule - Alertes pour AfterWork API +# ============================================================================== +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: afterwork-api-alerts + namespace: monitoring + labels: + app: mic-after-work-server-impl-quarkus-main + release: prometheus + project: lions-infrastructure-2025 +spec: + groups: + - name: afterwork-api.rules + rules: + # Alerte si l'application est down + - alert: AfterWorkAPIDown + expr: up{job=~".*afterwork.*"} == 0 + for: 2m + labels: + severity: critical + application: afterwork-api + annotations: + summary: "AfterWork API is down" + description: "L'API AfterWork n'est pas accessible depuis plus de 2 minutes" + + # Alerte si le taux d'erreur HTTP 5xx est élevé + - alert: AfterWorkHighErrorRate + expr: | + sum(rate(http_server_requests_seconds_count{ + kubernetes_namespace="applications", + app="mic-after-work-server-impl-quarkus-main", + status=~"5.." + }[5m])) / + sum(rate(http_server_requests_seconds_count{ + kubernetes_namespace="applications", + app="mic-after-work-server-impl-quarkus-main" + }[5m])) > 0.05 + for: 5m + labels: + severity: warning + application: afterwork-api + annotations: + summary: "High error rate on AfterWork API" + description: "Le taux d'erreur 5xx est supérieur à 5% depuis 5 minutes" + + # Alerte si la latence p95 est élevée + - alert: AfterWorkHighLatency + expr: | + histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{ + kubernetes_namespace="applications", + app="mic-after-work-server-impl-quarkus-main" + }[5m])) by (le)) > 2 + for: 5m + labels: + severity: warning + application: afterwork-api + annotations: + summary: "High latency on AfterWork API" + description: "La latence p95 dépasse 2 secondes depuis 5 minutes" + + # Alerte si la mémoire est proche de la limite + - alert: AfterWorkHighMemoryUsage + expr: | + sum(container_memory_working_set_bytes{ + namespace="applications", + pod=~"mic-after-work-server-impl-quarkus-main.*" + }) / + sum(container_spec_memory_limit_bytes{ + namespace="applications", + pod=~"mic-after-work-server-impl-quarkus-main.*" + }) > 0.85 + for: 5m + labels: + severity: warning + application: afterwork-api + annotations: + summary: "High memory usage on AfterWork API" + description: "L'utilisation mémoire dépasse 85% de la limite" + + # Alerte si le pod redémarre fréquemment + - alert: AfterWorkPodRestarts + expr: | + increase(kube_pod_container_status_restarts_total{ + namespace="applications", + pod=~"mic-after-work-server-impl-quarkus-main.*" + }[1h]) > 3 + for: 5m + labels: + severity: warning + application: afterwork-api + annotations: + summary: "AfterWork API pod restarting frequently" + description: "Le pod a redémarré plus de 3 fois dans la dernière heure" + +--- +# ============================================================================== +# Grafana Dashboard ConfigMap (pour import automatique) +# ============================================================================== +apiVersion: v1 +kind: ConfigMap +metadata: + name: afterwork-grafana-dashboard + namespace: monitoring + labels: + grafana_dashboard: "1" + app: mic-after-work-server-impl-quarkus-main + project: lions-infrastructure-2025 +data: + afterwork-api-dashboard.json: | + { + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "green", "value": null}, + {"color": "yellow", "value": 100}, + {"color": "red", "value": 500} + ] + }, + "unit": "reqps" + } + }, + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 0}, + "id": 1, + "options": {}, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{kubernetes_namespace=\"applications\",app=\"mic-after-work-server-impl-quarkus-main\"}[5m]))", + "legendFormat": "Requests/s", + "refId": "A" + } + ], + "title": "Request Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "ms" + } + }, + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 0}, + "id": 2, + "options": {}, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{kubernetes_namespace=\"applications\",app=\"mic-after-work-server-impl-quarkus-main\"}[5m])) by (le)) * 1000", + "legendFormat": "p95 Latency", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.50, sum(rate(http_server_requests_seconds_bucket{kubernetes_namespace=\"applications\",app=\"mic-after-work-server-impl-quarkus-main\"}[5m])) by (le)) * 1000", + "legendFormat": "p50 Latency", + "refId": "B" + } + ], + "title": "Response Time", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "percent" + } + }, + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 8}, + "id": 3, + "options": {}, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{kubernetes_namespace=\"applications\",app=\"mic-after-work-server-impl-quarkus-main\",status=~\"5..\"}[5m])) / sum(rate(http_server_requests_seconds_count{kubernetes_namespace=\"applications\",app=\"mic-after-work-server-impl-quarkus-main\"}[5m])) * 100", + "legendFormat": "Error Rate %", + "refId": "A" + } + ], + "title": "Error Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "bytes" + } + }, + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 8}, + "id": 4, + "options": {}, + "targets": [ + { + "expr": "sum(container_memory_working_set_bytes{namespace=\"applications\",pod=~\"mic-after-work-server-impl-quarkus-main.*\"})", + "legendFormat": "Memory Used", + "refId": "A" + }, + { + "expr": "sum(container_spec_memory_limit_bytes{namespace=\"applications\",pod=~\"mic-after-work-server-impl-quarkus-main.*\"})", + "legendFormat": "Memory Limit", + "refId": "B" + } + ], + "title": "Memory Usage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "short" + } + }, + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 16}, + "id": 5, + "options": {}, + "targets": [ + { + "expr": "sum(rate(container_cpu_usage_seconds_total{namespace=\"applications\",pod=~\"mic-after-work-server-impl-quarkus-main.*\"}[5m])) * 1000", + "legendFormat": "CPU Usage (millicores)", + "refId": "A" + } + ], + "title": "CPU Usage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "red", "value": null}, + {"color": "green", "value": 1} + ] + } + } + }, + "gridPos": {"h": 4, "w": 6, "x": 12, "y": 16}, + "id": 6, + "options": { + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "targets": [ + { + "expr": "up{job=~\".*afterwork.*\"}", + "legendFormat": "Status", + "refId": "A" + } + ], + "title": "API Status", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "green", "value": null}, + {"color": "yellow", "value": 1}, + {"color": "red", "value": 3} + ] + } + } + }, + "gridPos": {"h": 4, "w": 6, "x": 18, "y": 16}, + "id": 7, + "options": { + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + } + }, + "targets": [ + { + "expr": "increase(kube_pod_container_status_restarts_total{namespace=\"applications\",pod=~\"mic-after-work-server-impl-quarkus-main.*\"}[1h])", + "legendFormat": "Restarts (1h)", + "refId": "A" + } + ], + "title": "Pod Restarts (1h)", + "type": "stat" + } + ], + "refresh": "30s", + "schemaVersion": 38, + "style": "dark", + "tags": ["lions", "afterwork", "quarkus", "api"], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "AfterWork API Dashboard", + "uid": "afterwork-api", + "version": 1, + "weekStart": "" + } diff --git a/kubernetes/afterwork-secrets.yaml b/kubernetes/afterwork-secrets.yaml index 9571351..53ac6e7 100644 --- a/kubernetes/afterwork-secrets.yaml +++ b/kubernetes/afterwork-secrets.yaml @@ -6,8 +6,175 @@ metadata: labels: app: afterwork-api component: secrets + environment: production + project: lions-infrastructure-2025 type: Opaque stringData: - # Base de données PostgreSQL - # Pattern cohérent avec unionflow et btpxpress + # ============================================================================== + # BASE DE DONNÉES PostgreSQL + # ============================================================================== + # Utilise le PostgreSQL de l'infrastructure Lions + # postgresql-service.postgresql.svc.cluster.local:5432 DB_PASSWORD: "AfterWork2025!" + + # ============================================================================== + # JWT / SÉCURITÉ + # ============================================================================== + # Clé secrète JWT (minimum 32 caractères, aléatoire) + # Générer avec: openssl rand -base64 32 + JWT_SECRET: "AfterWorkJWTSecret2025LionsInfrastructureKey" + + # ============================================================================== + # COMPTE ADMINISTRATEUR INITIAL + # ============================================================================== + ADMIN_EMAIL: "admin@afterwork.ci" + ADMIN_PASSWORD: "AdminAfterWork2025!" + + # ============================================================================== + # SERVICE EMAIL (SMTP) + # ============================================================================== + # Configuration Gmail ou autre SMTP + MAILER_USERNAME: "noreply@afterwork.ci" + MAILER_PASSWORD: "CHANGEZ_MOI_SMTP_PASSWORD" + + # ============================================================================== + # WAVE PAYMENT (Intégration paiement) + # ============================================================================== + WAVE_API_KEY: "CHANGEZ_MOI_WAVE_API_KEY" + WAVE_SECRET: "CHANGEZ_MOI_WAVE_SECRET" + +--- +# ============================================================================== +# CONFIGMAP POUR CONFIGURATION NON-SENSIBLE +# ============================================================================== +apiVersion: v1 +kind: ConfigMap +metadata: + name: afterwork-config + namespace: applications + labels: + app: afterwork-api + component: configuration + environment: production + project: lions-infrastructure-2025 +data: + # ============================================================================== + # BASE DE DONNÉES - Lions PostgreSQL + # ============================================================================== + DB_HOST: "postgresql-service.postgresql.svc.cluster.local" + DB_PORT: "5432" + DB_NAME: "mic-after-work-server-impl-quarkus-main" + DB_USERNAME: "lionsuser" + + # ============================================================================== + # QUARKUS + # ============================================================================== + QUARKUS_PROFILE: "prod" + QUARKUS_LOG_LEVEL: "INFO" + QUARKUS_LOG_CONSOLE_JSON: "true" + + # ============================================================================== + # JWT + # ============================================================================== + JWT_LIFESPAN: "86400" + JWT_ISSUER: "afterwork-api" + + # ============================================================================== + # KAFKA - Lions Infrastructure + # ============================================================================== + # Utilise le Kafka déployé dans le namespace kafka + KAFKA_BOOTSTRAP_SERVERS: "kafka-service.kafka.svc.cluster.local:9092" + + # ============================================================================== + # EMAIL (SMTP) + # ============================================================================== + MAILER_HOST: "smtp.gmail.com" + MAILER_PORT: "587" + MAILER_FROM: "AfterWork " + MAILER_START_TLS: "REQUIRED" + # En production, mettre false. true = mock (pas d'envoi réel) + MAILER_MOCK: "true" + + # ============================================================================== + # RATE LIMITING + # ============================================================================== + AFTERWORK_RATELIMIT_MAX_REQUESTS: "10" + AFTERWORK_RATELIMIT_WINDOW_SECONDS: "60" + + # ============================================================================== + # WAVE PAYMENT + # ============================================================================== + WAVE_BASE_URL: "https://api.wave.com" + WAVE_CURRENCY: "XOF" + WAVE_CALLBACK_URL: "https://api.lions.dev/afterwork/webhooks/wave" + + # ============================================================================== + # OBSERVABILITY - Lions Prometheus/Grafana + # ============================================================================== + # Prometheus scrape via annotations sur le pod + # Grafana disponible sur https://grafana.lions.dev + + # ============================================================================== + # KEYCLOAK / SSO (optionnel) + # ============================================================================== + # OIDC_AUTH_SERVER_URL: "https://security.lions.dev/realms/lions" + # OIDC_CLIENT_ID: "afterwork-api" + +--- +# ============================================================================== +# EXTERNAL SECRET - Intégration Vault (ACTIF) +# ============================================================================== +# Vault est déverrouillé sur https://vault.lions.dev +# Les secrets sont synchronisés depuis Vault vers Kubernetes automatiquement +# +# PRÉREQUIS: Créer les secrets dans Vault avec: +# vault kv put lions/afterwork \ +# db_password="AfterWork2025!" \ +# jwt_secret="AfterWorkJWTSecret2025LionsInfrastructureKey" \ +# admin_password="AdminAfterWork2025!" \ +# mailer_password="SMTP_PASSWORD" \ +# wave_api_key="WAVE_KEY" \ +# wave_secret="WAVE_SECRET" +# +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: afterwork-vault-secrets + namespace: applications + labels: + app: afterwork-api + component: external-secrets + project: lions-infrastructure-2025 +spec: + refreshInterval: "1h" + secretStoreRef: + name: vault-backend + kind: ClusterSecretStore + target: + name: afterwork-secrets-vault + creationPolicy: Owner + data: + - secretKey: DB_PASSWORD + remoteRef: + key: lions/data/afterwork + property: db_password + - secretKey: JWT_SECRET + remoteRef: + key: lions/data/afterwork + property: jwt_secret + - secretKey: ADMIN_PASSWORD + remoteRef: + key: lions/data/afterwork + property: admin_password + - secretKey: MAILER_PASSWORD + remoteRef: + key: lions/data/afterwork + property: mailer_password + - secretKey: WAVE_API_KEY + remoteRef: + key: lions/data/afterwork + property: wave_api_key + - secretKey: WAVE_SECRET + remoteRef: + key: lions/data/afterwork + property: wave_secret diff --git a/kubernetes/afterwork-service.yaml b/kubernetes/afterwork-service.yaml index 9d90f48..82bc758 100644 --- a/kubernetes/afterwork-service.yaml +++ b/kubernetes/afterwork-service.yaml @@ -1,10 +1,14 @@ apiVersion: v1 kind: Service metadata: - name: afterwork-api + name: mic-after-work-server-impl-quarkus-main-service namespace: applications labels: - app: afterwork-api + app: mic-after-work-server-impl-quarkus-main + component: application + project: lions-infrastructure-2025 + annotations: + description: "Service for AfterWork API" spec: type: ClusterIP sessionAffinity: ClientIP @@ -12,9 +16,15 @@ spec: clientIP: timeoutSeconds: 10800 ports: - - port: 8080 + # Port 80 exposé, route vers 8080 du container + - port: 80 targetPort: 8080 protocol: TCP name: http + # Port 8080 pour compatibilité directe + - port: 8080 + targetPort: 8080 + protocol: TCP + name: http-direct selector: - app: afterwork-api + app: mic-after-work-server-impl-quarkus-main diff --git a/pom.xml b/pom.xml index d66a51e..3408ca8 100644 --- a/pom.xml +++ b/pom.xml @@ -126,6 +126,25 @@ io.quarkus quarkus-mailer + + + + + io.quarkus + quarkus-smallrye-health + + + io.quarkus + quarkus-micrometer + + + io.quarkus + quarkus-micrometer-registry-prometheus + + + + + io.quarkus quarkus-junit5 @@ -136,6 +155,11 @@ quarkus-junit5-mockito test + + io.quarkus + quarkus-test-security + test + io.rest-assured rest-assured diff --git a/src/main/java/com/lions/dev/exception/GlobalExceptionMapper.java b/src/main/java/com/lions/dev/exception/GlobalExceptionMapper.java new file mode 100644 index 0000000..8fc0ba2 --- /dev/null +++ b/src/main/java/com/lions/dev/exception/GlobalExceptionMapper.java @@ -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 { + + 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 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(); + } +} diff --git a/src/main/java/com/lions/dev/filter/RateLimitFilter.java b/src/main/java/com/lions/dev/filter/RateLimitFilter.java new file mode 100644 index 0000000..c352534 --- /dev/null +++ b/src/main/java/com/lions/dev/filter/RateLimitFilter.java @@ -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 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(); + } + } +} diff --git a/src/main/java/com/lions/dev/health/KafkaHealthCheck.java b/src/main/java/com/lions/dev/health/KafkaHealthCheck.java new file mode 100644 index 0000000..246a1e3 --- /dev/null +++ b/src/main/java/com/lions/dev/health/KafkaHealthCheck.java @@ -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(); + } +} diff --git a/src/main/java/com/lions/dev/health/LivenessCheck.java b/src/main/java/com/lions/dev/health/LivenessCheck.java new file mode 100644 index 0000000..576c9f1 --- /dev/null +++ b/src/main/java/com/lions/dev/health/LivenessCheck.java @@ -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(); + } +} diff --git a/src/main/java/com/lions/dev/health/ReadinessCheck.java b/src/main/java/com/lions/dev/health/ReadinessCheck.java new file mode 100644 index 0000000..bc4b7a1 --- /dev/null +++ b/src/main/java/com/lions/dev/health/ReadinessCheck.java @@ -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(); + } +} diff --git a/src/main/java/com/lions/dev/resource/EstablishmentMediaResource.java b/src/main/java/com/lions/dev/resource/EstablishmentMediaResource.java index 6edc7ea..122634c 100644 --- a/src/main/java/com/lions/dev/resource/EstablishmentMediaResource.java +++ b/src/main/java/com/lions/dev/resource/EstablishmentMediaResource.java @@ -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) { diff --git a/src/main/java/com/lions/dev/resource/EstablishmentRatingResource.java b/src/main/java/com/lions/dev/resource/EstablishmentRatingResource.java index b6f08e8..9a2415c 100644 --- a/src/main/java/com/lions/dev/resource/EstablishmentRatingResource.java +++ b/src/main/java/com/lions/dev/resource/EstablishmentRatingResource.java @@ -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( diff --git a/src/main/java/com/lions/dev/resource/EstablishmentSubscriptionResource.java b/src/main/java/com/lions/dev/resource/EstablishmentSubscriptionResource.java index 87be2d9..9a75287 100644 --- a/src/main/java/com/lions/dev/resource/EstablishmentSubscriptionResource.java +++ b/src/main/java/com/lions/dev/resource/EstablishmentSubscriptionResource.java @@ -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); diff --git a/src/main/java/com/lions/dev/resource/FileUploadResource.java b/src/main/java/com/lions/dev/resource/FileUploadResource.java index a242242..ba9e731 100644 --- a/src/main/java/com/lions/dev/resource/FileUploadResource.java +++ b/src/main/java/com/lions/dev/resource/FileUploadResource.java @@ -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); diff --git a/src/main/java/com/lions/dev/resource/PromotionResource.java b/src/main/java/com/lions/dev/resource/PromotionResource.java index 085bc75..6286460 100644 --- a/src/main/java/com/lions/dev/resource/PromotionResource.java +++ b/src/main/java/com/lions/dev/resource/PromotionResource.java @@ -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 { diff --git a/src/main/java/com/lions/dev/resource/ReviewResource.java b/src/main/java/com/lions/dev/resource/ReviewResource.java index 0dceac7..a28b82e 100644 --- a/src/main/java/com/lions/dev/resource/ReviewResource.java +++ b/src/main/java/com/lions/dev/resource/ReviewResource.java @@ -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 { diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 132e195..a2bb7ad 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -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 diff --git a/src/test/java/com/lions/dev/resource/BookingResourceTest.java b/src/test/java/com/lions/dev/resource/BookingResourceTest.java new file mode 100644 index 0000000..ca42d92 --- /dev/null +++ b/src/test/java/com/lions/dev/resource/BookingResourceTest.java @@ -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); + } +} diff --git a/src/test/java/com/lions/dev/resource/EstablishmentResourceTest.java b/src/test/java/com/lions/dev/resource/EstablishmentResourceTest.java new file mode 100644 index 0000000..92405a2 --- /dev/null +++ b/src/test/java/com/lions/dev/resource/EstablishmentResourceTest.java @@ -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); + } +} diff --git a/src/test/java/com/lions/dev/resource/UsersResourceTest.java b/src/test/java/com/lions/dev/resource/UsersResourceTest.java new file mode 100644 index 0000000..7a59d09 --- /dev/null +++ b/src/test/java/com/lions/dev/resource/UsersResourceTest.java @@ -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à + } +} diff --git a/src/test/java/com/lions/dev/service/MessageServiceTest.java b/src/test/java/com/lions/dev/service/MessageServiceTest.java new file mode 100644 index 0000000..576dade --- /dev/null +++ b/src/test/java/com/lions/dev/service/MessageServiceTest.java @@ -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); + } +} diff --git a/src/test/java/com/lions/dev/service/SecurityServiceTest.java b/src/test/java/com/lions/dev/service/SecurityServiceTest.java new file mode 100644 index 0000000..f5ca553 --- /dev/null +++ b/src/test/java/com/lions/dev/service/SecurityServiceTest.java @@ -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 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 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 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 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 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"); + } + } +}