""" Tests unitaires pour le module Auth. Vérifie : - L'accès public aux endpoints de login/CSRF/subscribe - La protection JWT des endpoints protégés (profils, rôles, session) - La génération et validation des tokens JWT """ import json from django.test import TestCase, override_settings from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient from rest_framework_simplejwt.tokens import RefreshToken from Auth.models import Profile, ProfileRole from Establishment.models import Establishment # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def create_establishment(): """Crée un établissement minimal utilisé dans les tests.""" return Establishment.objects.create( name="Ecole Test", address="1 rue de l'Ecole", total_capacity=100, establishment_type=[1], ) def create_user(email="test@example.com", password="testpassword123"): """Crée un utilisateur (Profile) de test.""" user = Profile.objects.create_user( username=email, email=email, password=password, ) return user def create_active_user_with_role(email="active@example.com", password="testpassword123"): """Crée un utilisateur avec un rôle actif.""" user = create_user(email=email, password=password) establishment = create_establishment() ProfileRole.objects.create( profile=user, role_type=ProfileRole.RoleType.PROFIL_ADMIN, establishment=establishment, is_active=True, ) return user def get_jwt_token(user): """Retourne un token d'accès JWT pour l'utilisateur donné.""" refresh = RefreshToken.for_user(user) return str(refresh.access_token) # --------------------------------------------------------------------------- # Tests endpoints publics # --------------------------------------------------------------------------- @override_settings( CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, SESSION_ENGINE='django.contrib.sessions.backends.db', ) class CsrfEndpointTest(TestCase): """Test de l'endpoint CSRF – doit être accessible sans authentification.""" def setUp(self): self.client = APIClient() def test_csrf_endpoint_accessible_sans_auth(self): """GET /Auth/csrf doit retourner 200 sans token.""" response = self.client.get(reverse("Auth:csrf")) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIn("csrfToken", response.json()) @override_settings( CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, SESSION_ENGINE='django.contrib.sessions.backends.db', ) class LoginEndpointTest(TestCase): """Tests de l'endpoint de connexion.""" def setUp(self): self.client = APIClient() self.url = reverse("Auth:login") self.user = create_active_user_with_role( email="logintest@example.com", password="secureP@ss1" ) def test_login_avec_identifiants_valides(self): """POST /Auth/login avec identifiants valides retourne 200 et un token.""" payload = {"email": "logintest@example.com", "password": "secureP@ss1"} response = self.client.post( self.url, data=json.dumps(payload), content_type="application/json" ) self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() self.assertIn("token", data) self.assertIn("refresh", data) def test_login_avec_mauvais_mot_de_passe(self): """POST /Auth/login avec mauvais mot de passe retourne 400 ou 401.""" payload = {"email": "logintest@example.com", "password": "wrongpassword"} response = self.client.post( self.url, data=json.dumps(payload), content_type="application/json" ) self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED]) def test_login_avec_email_inexistant(self): """POST /Auth/login avec email inconnu retourne 400 ou 401.""" payload = {"email": "unknown@example.com", "password": "anypassword"} response = self.client.post( self.url, data=json.dumps(payload), content_type="application/json" ) self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED]) def test_login_accessible_sans_authentification(self): """L'endpoint de login doit être accessible sans token JWT.""" # On vérifie juste que l'on n'obtient pas 401/403 pour raison d'auth manquante payload = {"email": "logintest@example.com", "password": "secureP@ss1"} response = self.client.post( self.url, data=json.dumps(payload), content_type="application/json" ) self.assertNotEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN) @override_settings( CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, SESSION_ENGINE='django.contrib.sessions.backends.db', ) class RefreshJWTEndpointTest(TestCase): """Tests de l'endpoint de rafraîchissement du token JWT.""" def setUp(self): self.client = APIClient() self.url = reverse("Auth:refresh_jwt") self.user = create_active_user_with_role(email="refresh@example.com") def test_refresh_avec_token_valide(self): """POST /Auth/refreshJWT avec refresh token valide retourne un nouvel access token.""" import jwt, uuid from datetime import datetime, timedelta from django.conf import settings as django_settings # RefreshJWTView attend le format custom (type='refresh'), pas le format SimpleJWT refresh_payload = { 'user_id': self.user.id, 'type': 'refresh', 'jti': str(uuid.uuid4()), 'exp': datetime.utcnow() + timedelta(days=1), 'iat': datetime.utcnow(), } custom_refresh = jwt.encode( refresh_payload, django_settings.SIMPLE_JWT['SIGNING_KEY'], algorithm=django_settings.SIMPLE_JWT['ALGORITHM'], ) payload = {"refresh": custom_refresh} response = self.client.post( self.url, data=json.dumps(payload), content_type="application/json" ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIn("token", response.json()) def test_refresh_avec_token_invalide(self): """POST /Auth/refreshJWT avec token invalide retourne 401.""" payload = {"refresh": "invalid.token.here"} response = self.client.post( self.url, data=json.dumps(payload), content_type="application/json" ) self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED]) def test_refresh_accessible_sans_authentification(self): """L'endpoint de refresh doit être accessible sans token d'accès.""" refresh = RefreshToken.for_user(self.user) payload = {"refresh": str(refresh)} response = self.client.post( self.url, data=json.dumps(payload), content_type="application/json" ) self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN) # --------------------------------------------------------------------------- # Tests endpoints protégés – Session # --------------------------------------------------------------------------- @override_settings( CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, SESSION_ENGINE='django.contrib.sessions.backends.db', ) class SessionEndpointTest(TestCase): """Tests de l'endpoint d'information de session.""" def setUp(self): self.client = APIClient() self.url = reverse("Auth:infoSession") self.user = create_active_user_with_role(email="session@example.com") def test_info_session_sans_token_retourne_401(self): """GET /Auth/infoSession sans token doit retourner 401.""" response = self.client.get(self.url) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) def test_info_session_avec_token_valide_retourne_200(self): """GET /Auth/infoSession avec token valide doit retourner 200 et les données utilisateur.""" token = get_jwt_token(self.user) self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") response = self.client.get(self.url) self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() self.assertIn("user", data) self.assertEqual(data["user"]["email"], self.user.email) def test_info_session_avec_token_invalide_retourne_401(self): """GET /Auth/infoSession avec token invalide doit retourner 401.""" self.client.credentials(HTTP_AUTHORIZATION="Bearer token.invalide.xyz") response = self.client.get(self.url) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) def test_info_session_avec_token_expire_retourne_401(self): """GET /Auth/infoSession avec un token expiré doit retourner 401.""" import jwt from datetime import datetime, timedelta from django.conf import settings as django_settings expired_payload = { 'user_id': self.user.id, 'exp': datetime.utcnow() - timedelta(hours=1), } expired_token = jwt.encode( expired_payload, django_settings.SECRET_KEY, algorithm='HS256' ) self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {expired_token}") response = self.client.get(self.url) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) # --------------------------------------------------------------------------- # Tests endpoints protégés – Profils # --------------------------------------------------------------------------- @override_settings( CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, SESSION_ENGINE='django.contrib.sessions.backends.db', REST_FRAMEWORK={ 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework_simplejwt.authentication.JWTAuthentication', ), 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.IsAuthenticated', ), }, ) class ProfileEndpointAuthTest(TestCase): """Tests d'authentification sur les endpoints de profils.""" def setUp(self): self.client = APIClient() self.profiles_url = reverse("Auth:profile") self.user = create_active_user_with_role(email="profile_auth@example.com") def test_get_profiles_sans_auth_retourne_401(self): """GET /Auth/profiles sans token doit retourner 401.""" response = self.client.get(self.profiles_url) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) def test_get_profiles_avec_auth_retourne_200(self): """GET /Auth/profiles avec token valide doit retourner 200.""" token = get_jwt_token(self.user) self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") response = self.client.get(self.profiles_url) self.assertEqual(response.status_code, status.HTTP_200_OK) def test_post_profile_sans_auth_retourne_401(self): """POST /Auth/profiles sans token doit retourner 401.""" payload = {"email": "new@example.com", "password": "pass123"} response = self.client.post( self.profiles_url, data=json.dumps(payload), content_type="application/json", ) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) def test_get_profile_par_id_sans_auth_retourne_401(self): """GET /Auth/profiles/{id} sans token doit retourner 401.""" url = reverse("Auth:profile", kwargs={"id": self.user.id}) response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) def test_put_profile_sans_auth_retourne_401(self): """PUT /Auth/profiles/{id} sans token doit retourner 401.""" url = reverse("Auth:profile", kwargs={"id": self.user.id}) payload = {"email": self.user.email} response = self.client.put( url, data=json.dumps(payload), content_type="application/json" ) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) def test_delete_profile_sans_auth_retourne_401(self): """DELETE /Auth/profiles/{id} sans token doit retourner 401.""" url = reverse("Auth:profile", kwargs={"id": self.user.id}) response = self.client.delete(url) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) # --------------------------------------------------------------------------- # Tests endpoints protégés – ProfileRole # --------------------------------------------------------------------------- @override_settings( CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, SESSION_ENGINE='django.contrib.sessions.backends.db', REST_FRAMEWORK={ 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework_simplejwt.authentication.JWTAuthentication', ), 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.IsAuthenticated', ), }, ) class ProfileRoleEndpointAuthTest(TestCase): """Tests d'authentification sur les endpoints de rôles.""" def setUp(self): self.client = APIClient() self.profile_roles_url = reverse("Auth:profileRoles") self.user = create_active_user_with_role(email="roles_auth@example.com") def test_get_profile_roles_sans_auth_retourne_401(self): """GET /Auth/profileRoles sans token doit retourner 401.""" response = self.client.get(self.profile_roles_url) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) def test_get_profile_roles_avec_auth_retourne_200(self): """GET /Auth/profileRoles avec token valide doit retourner 200.""" token = get_jwt_token(self.user) self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") response = self.client.get(self.profile_roles_url) self.assertNotIn( response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN], msg="Un token valide ne doit pas être rejeté par la couche d'authentification", ) def test_post_profile_role_sans_auth_retourne_401(self): """POST /Auth/profileRoles sans token doit retourner 401.""" payload = {"profile": self.user.id, "role_type": 1} response = self.client.post( self.profile_roles_url, data=json.dumps(payload), content_type="application/json", ) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) # --------------------------------------------------------------------------- # Tests de génération de token JWT # --------------------------------------------------------------------------- @override_settings( CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, SESSION_ENGINE='django.contrib.sessions.backends.db', ) class JWTTokenGenerationTest(TestCase): """Tests de génération et validation des tokens JWT.""" def setUp(self): self.user = create_user(email="jwt@example.com", password="jwttest123") def test_generation_token_valide(self): """Un token généré pour un utilisateur est valide et contient user_id.""" import jwt from django.conf import settings as django_settings token = get_jwt_token(self.user) self.assertIsNotNone(token) self.assertIsInstance(token, str) decoded = jwt.decode(token, django_settings.SECRET_KEY, algorithms=["HS256"]) self.assertEqual(decoded["user_id"], self.user.id) def test_refresh_token_permet_obtenir_nouvel_access_token(self): """Le refresh token permet d'obtenir un nouvel access token via SimpleJWT.""" refresh = RefreshToken.for_user(self.user) access = refresh.access_token self.assertIsNotNone(str(access)) self.assertIsNotNone(str(refresh)) def test_token_different_par_utilisateur(self): """Deux utilisateurs différents ont des tokens différents.""" user2 = create_user(email="jwt2@example.com", password="jwttest123") token1 = get_jwt_token(self.user) token2 = get_jwt_token(user2) self.assertNotEqual(token1, token2) # --------------------------------------------------------------------------- # Tests de sécurité — Correction des vulnérabilités identifiées # --------------------------------------------------------------------------- @override_settings( CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, SESSION_ENGINE='django.contrib.sessions.backends.db', ) class SessionViewTokenTypeTest(TestCase): """ SessionView doit rejeter les refresh tokens. Avant la correction, jwt.decode() était appelé sans vérification du claim 'type', ce qui permettait d'utiliser un refresh token là où seul un access token est attendu. """ def setUp(self): self.client = APIClient() self.url = reverse("Auth:infoSession") self.user = create_active_user_with_role(email="session_type@example.com") def test_refresh_token_rejete_par_session_view(self): """ Utiliser un refresh token SimpleJWT sur /infoSession doit retourner 401. """ import jwt from datetime import datetime, timedelta from django.conf import settings as django_settings # Fabriquer manuellement un token de type 'refresh' signé avec la clé correcte refresh_payload = { 'user_id': self.user.id, 'type': 'refresh', # ← type incorrect pour cet endpoint 'jti': 'test-refresh-jti', 'exp': datetime.utcnow() + timedelta(days=1), 'iat': datetime.utcnow(), } refresh_token = jwt.encode( refresh_payload, django_settings.SECRET_KEY, algorithm='HS256' ) self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {refresh_token}") response = self.client.get(self.url) self.assertEqual( response.status_code, status.HTTP_401_UNAUTHORIZED, "Un refresh token ne doit pas être accepté sur /infoSession (OWASP A07 - Auth Failures)" ) def test_access_token_accepte_par_session_view(self): """Un access token de type 'access' est accepté.""" import jwt from datetime import datetime, timedelta from django.conf import settings as django_settings access_payload = { 'user_id': self.user.id, 'type': 'access', 'jti': 'test-access-jti', 'exp': datetime.utcnow() + timedelta(minutes=15), 'iat': datetime.utcnow(), } access_token = jwt.encode( access_payload, django_settings.SECRET_KEY, algorithm='HS256' ) self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") response = self.client.get(self.url) self.assertEqual(response.status_code, status.HTTP_200_OK) @override_settings( CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, SESSION_ENGINE='django.contrib.sessions.backends.db', ) class RefreshJWTErrorLeakTest(TestCase): """ RefreshJWTView ne doit pas retourner les messages d'exception internes. Avant la correction, str(e) était renvoyé directement au client. """ def setUp(self): self.client = APIClient() self.url = reverse("Auth:refresh_jwt") def test_token_invalide_ne_revele_pas_details_internes(self): """ Un token invalide doit retourner un message générique, pas les détails de l'exception. """ payload = {"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.forged.signature"} response = self.client.post( self.url, data=json.dumps(payload), content_type="application/json" ) self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED]) body = response.content.decode() # Le message ne doit pas contenir de traceback ou de détails internes de bibliothèque self.assertNotIn("Traceback", body) self.assertNotIn("jwt.exceptions", body) self.assertNotIn("simplejwt", body.lower()) def test_erreur_reponse_est_generique(self): """ Le message d'erreur doit être 'Token invalide' (générique), pas le str(e). """ payload = {"refresh": "bad.token.data"} response = self.client.post( self.url, data=json.dumps(payload), content_type="application/json" ) data = response.json() self.assertIn('errorMessage', data) # Le message doit être le message générique, pas la chaîne brute de l'exception self.assertIn(data['errorMessage'], ['Token invalide', 'Format de token invalide', 'Refresh token expiré', 'Erreur interne du serveur']) @override_settings( CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, SESSION_ENGINE='django.contrib.sessions.backends.db', ) class SecurityHeadersTest(TestCase): """ Les en-têtes de sécurité HTTP doivent être présents dans toutes les réponses. """ def setUp(self): self.client = APIClient() def test_x_content_type_options_present(self): """X-Content-Type-Options: nosniff doit être présent.""" response = self.client.get(reverse("Auth:csrf")) self.assertEqual( response.get('X-Content-Type-Options'), 'nosniff', "X-Content-Type-Options: nosniff doit être défini (prévient le MIME sniffing)" ) def test_referrer_policy_present(self): """Referrer-Policy doit être présent.""" response = self.client.get(reverse("Auth:csrf")) self.assertIsNotNone( response.get('Referrer-Policy'), "Referrer-Policy doit être défini" ) def test_csp_frame_ancestors_present(self): """Content-Security-Policy doit contenir frame-ancestors.""" response = self.client.get(reverse("Auth:csrf")) csp = response.get('Content-Security-Policy', '') self.assertIn('frame-ancestors', csp, "CSP doit définir frame-ancestors (protection clickjacking)") self.assertIn("object-src 'none'", csp, "CSP doit définir object-src 'none' (prévient les plugins malveillants)")