import pytest import sys import os import time sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) # ── Import app FastAPI ──────────────────────────────────────── try: from fastapi.testclient import TestClient from api.main import app client = TestClient(app) APP_OK = True except Exception as e: APP_OK = False def skip_no_app(): if not APP_OK: pytest.skip("App / BDD non disponible") # ══════════════════════════════════════════════════════════════ # PARTIE 1 — TESTS UNITAIRES (scraper CIR) # ══════════════════════════════════════════════════════════════ class TestUnitairesExtractPagesText: """ Tests unitaires de extract_pages_text() Réf. cir_scraper.py l.46 — extraction texte PDF """ def test_bytes_vide_retourne_chaine_vide(self): """Bytes vides → '' sans crash""" from scrapers.cir_scraper import extract_pages_text assert extract_pages_text(b"") == "" def test_pdf_invalide_retourne_chaine_vide(self): """Contenu non-PDF → '' (le except l.57 fonctionne)""" from scrapers.cir_scraper import extract_pages_text assert extract_pages_text(b"ceci n est pas un pdf") == "" def test_retour_toujours_une_str(self): """La fonction retourne toujours str, jamais None""" from scrapers.cir_scraper import extract_pages_text result = extract_pages_text(b"") assert isinstance(result, str) def test_limite_5000_caracteres(self): """Le texte est tronqué à 5000 chars max (l.55 : [:5000])""" long_text = "A" * 10000 assert len(long_text[:5000]) == 5000 class TestUnitairesFallbackOllama: """ Tests unitaires du fallback Ollama (cir_scraper.py l.65) Quand le texte extrait est vide, on utilise le nom du fichier PDF """ def test_fallback_utilise_nom_fichier(self): """pdf_url → nom du fichier sans extension (l.66-68)""" pdf_url = "https://cir-safety.org/sites/files/Retinol_2024.pdf" filename = pdf_url.split("/")[-1].replace("_", " ").replace(".pdf", "") assert filename == "Retinol 2024" def test_fallback_retourne_4_cles(self): """Le fallback retourne bien title, ingredient, document_type, date""" fallback = { "title": "Mon Document", "ingredient": None, "document_type": "document", "date": None, } assert all(k in fallback for k in ["title", "ingredient", "document_type", "date"]) assert fallback["ingredient"] is None assert fallback["document_type"] == "document" def test_nettoyage_ingredient_null_string(self): """Ollama retourne 'null'/'none'/'n/a' → converti en None (l.112-113)""" for val in ["null", "none", "n/a", "", "NULL", "None"]: result = None if str(val).strip().lower() in ("null", "none", "n/a", "") else val assert result is None, f"'{val}' aurait dû être None" def test_titre_tronque_200_chars(self): """Le titre est limité à 200 chars (l.117 : [:200])""" long = "A" * 300 assert len(str(long).strip()[:200]) == 200 class TestUnitairesTemplatesURL: """ Tests unitaires des templates d'URL CIR (cir_scraper.py l.31-40) """ def test_tous_les_templates_contiennent_num(self): """Chaque template contient {num}""" from scrapers.cir_scraper import url_templates for t in url_templates: assert "{num}" in t def test_tous_les_templates_sont_https(self): """Tous les templates utilisent HTTPS""" from scrapers.cir_scraper import url_templates for t in url_templates: assert t.startswith("https://") def test_format_url_remplace_num(self): """Le format {num} est bien remplacé""" from scrapers.cir_scraper import url_templates url = url_templates[0].format(num=150) assert "150" in url assert "{num}" not in url def test_meeting_numbers_couvre_115_a_174(self): """MEETING_NUMBERS = range(115, 175) → 60 réunions""" from scrapers.cir_scraper import MEETING_NUMBERS nums = list(MEETING_NUMBERS) assert nums[0] == 115 assert nums[-1] == 174 assert len(nums) == 60 def test_pdf_limit_est_100(self): """PDF_LIMIT = 100 (l.32)""" from scrapers.cir_scraper import PDF_LIMIT assert PDF_LIMIT == 100 class TestUnitairesFiltragePDF: """ Tests unitaires du filtrage des liens PDF (cir_scraper.py l.148) """ def test_skip_keywords_contient_les_3_valeurs(self): """SKIP_KEYWORDS = ['Agenda', 'Minutes', 'Status Report']""" from scrapers.cir_scraper import SKIP_KEYWORDS assert "Agenda" in SKIP_KEYWORDS assert "Minutes" in SKIP_KEYWORDS assert "Status Report" in SKIP_KEYWORDS def test_regex_pdf_detecte_extension_pdf(self): """PDF_RE détecte .pdf / .PDF / .Pdf""" from scrapers.cir_scraper import PDF_RE assert PDF_RE.search("document.pdf") assert PDF_RE.search("document.PDF") assert PDF_RE.search("rapport.Pdf") def test_regex_pdf_ne_detecte_pas_autres(self): """PDF_RE ne détecte pas .docx, .png""" from scrapers.cir_scraper import PDF_RE assert not PDF_RE.search("document.docx") assert not PDF_RE.search("image.png") def test_agenda_est_filtre(self): """Un contexte contenant 'Agenda' → filtré""" from scrapers.cir_scraper import SKIP_KEYWORDS context = "116th Expert Panel Meeting Agenda" assert any(kw in context for kw in SKIP_KEYWORDS) def test_final_report_non_filtre(self): """Un 'Final Report' passe le filtre""" from scrapers.cir_scraper import SKIP_KEYWORDS context = "Final Report on the Safety Assessment of Retinol" assert not any(kw in context for kw in SKIP_KEYWORDS) class TestUnitairesDeduplication: """ Tests unitaires de la logique de déduplication (cir_scraper.py l.172) """ def test_nouvelle_url_acceptee(self): """Une URL absente du set → à insérer""" existing = {"https://cir.org/doc1.pdf", "https://cir.org/doc2.pdf"} assert "https://cir.org/doc3.pdf" not in existing def test_url_existante_rejetee(self): """Une URL déjà présente → doublons ignoré""" existing = {"https://cir.org/doc1.pdf"} assert "https://cir.org/doc1.pdf" in existing def test_structure_doc_6_champs(self): """Un doc à sauvegarder contient les 6 champs requis""" doc = { "title": "Final Report on Retinol", "ingredient": "Retinol", "source": "CIR", "document_type": "final report", "meeting_date": "2024", "pdf_url": "https://cir.org/retinol_2024.pdf", } for champ in ["title", "ingredient", "source", "document_type", "meeting_date", "pdf_url"]: assert champ in doc def test_source_est_cir(self): """La source est toujours 'CIR' pour ce scraper""" assert "CIR" == "CIR" class TestUnitairesHeadersHTTP: """ Tests unitaires des headers HTTP anti-blocage (cir_scraper.py l.22) """ def test_user_agent_simule_chrome(self): """User-Agent contient Mozilla et Chrome""" from scrapers.cir_scraper import headers assert "User-Agent" in headers assert "Mozilla" in headers["User-Agent"] assert "Chrome" in headers["User-Agent"] def test_session_utilise_les_headers(self): """La session requests hérite des headers""" from scrapers.cir_scraper import session assert "User-Agent" in session.headers assert "Mozilla" in session.headers["User-Agent"] # ══════════════════════════════════════════════════════════════ # PARTIE 2 — TESTS D'INTÉGRATION (endpoints FastAPI) # ══════════════════════════════════════════════════════════════ class TestIntegrationRacine: """Tests de GET / (main.py l.21)""" def test_racine_200(self): skip_no_app() assert client.get("/").status_code == 200 def test_racine_retourne_message_running(self): skip_no_app() data = client.get("/").json() assert "message" in data assert "running" in data["message"].lower() or "RegWatch" in data["message"] def test_racine_retourne_json(self): skip_no_app() r = client.get("/") assert r.headers["content-type"].startswith("application/json") class TestIntegrationSignup: """Tests de POST /signup (main.py l.28)""" def test_email_invalide_422(self): skip_no_app() r = client.post("/signup", json={"email": "pasunemail", "password": "test", "full_name": "T"}) assert r.status_code == 422 def test_champs_manquants_422(self): skip_no_app() r = client.post("/signup", json={"email": "test@test.com"}) assert r.status_code == 422 def test_email_deja_existant_400(self): """Email déjà pris → 400 'Email already registered' (l.33-37)""" skip_no_app() email = f"doublon_{int(time.time())}@regwatch.fr" client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"}) r = client.post("/signup", json={"email": email, "password": "autre", "full_name": "T"}) assert r.status_code == 400 def test_signup_reussi_retourne_id_email(self): """Signup valide → 200 avec id et email""" skip_no_app() email = f"ok_{int(time.time())}@regwatch.fr" r = client.post("/signup", json={"email": email, "password": "test123", "full_name": "OK"}) assert r.status_code == 200 data = r.json() assert "id" in data assert data["email"] == email class TestIntegrationLogin: """Tests de POST /login (main.py l.55)""" def test_email_inexistant_401(self): """Email inconnu → 401 'User not found' (l.65-68)""" skip_no_app() r = client.post("/login", json={"email": "xyz_inconnu@regwatch.fr", "password": "test"}) assert r.status_code == 401 assert "not found" in r.json()["detail"].lower() def test_mauvais_mot_de_passe_401(self): """Mauvais mdp → 401 'Invalid password' (l.70-74)""" skip_no_app() email = f"mdp_{int(time.time())}@regwatch.fr" client.post("/signup", json={"email": email, "password": "bonmdp", "full_name": "T"}) r = client.post("/login", json={"email": email, "password": "mauvaismdp"}) assert r.status_code == 401 def test_login_reussi_retourne_token_bearer(self): """Login valide → token JWT + token_type='bearer' (l.76-82)""" skip_no_app() email = f"jwt_{int(time.time())}@regwatch.fr" client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"}) r = client.post("/login", json={"email": email, "password": "test123"}) assert r.status_code == 200 data = r.json() assert "access_token" in data assert data["token_type"] == "bearer" assert len(data["access_token"]) > 10 def test_corps_vide_422(self): skip_no_app() assert client.post("/login", json={}).status_code == 422 class TestIntegrationMe: """Tests de GET /me?token=... (main.py l.88)""" def test_sans_token_422(self): """Paramètre token manquant → 422""" skip_no_app() assert client.get("/me").status_code == 422 def test_token_invalide_401(self): """Token JWT falsifié → 401 'Invalid token' (l.90-93)""" skip_no_app() r = client.get("/me?token=token_completement_faux") assert r.status_code == 401 assert "invalid" in r.json()["detail"].lower() or "token" in r.json()["detail"].lower() def test_token_valide_retourne_user(self): """Token valide → 200 avec email de l'utilisateur""" skip_no_app() email = f"me_{int(time.time())}@regwatch.fr" client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"}) token = client.post("/login", json={"email": email, "password": "test123"}).json()["access_token"] r = client.get(f"/me?token={token}") assert r.status_code == 200 assert r.json()["email"] == email class TestIntegrationDocuments: """Tests de GET /documents (main.py l.104)""" def test_sans_auth_retourne_200(self): """Pas de JWT requis sur cet endpoint (main.py l.104)""" skip_no_app() assert client.get("/documents").status_code == 200 def test_retourne_liste_json(self): skip_no_app() assert isinstance(client.get("/documents").json(), list) def test_structure_7_champs_par_document(self): """Chaque doc a id, title, ingredient, source, type, date, pdf_url (l.115-123)""" skip_no_app() docs = client.get("/documents").json() if docs: for champ in ["id", "title", "ingredient", "source", "type", "date", "pdf_url"]: assert champ in docs[0], f"Champ manquant : {champ}" def test_source_cir_ou_sccs(self): """La source est 'CIR' ou 'SCCS' pour chaque document""" skip_no_app() docs = client.get("/documents").json() for doc in docs: assert doc["source"] in ["CIR", "SCCS"], f"Source inattendue : {doc['source']}" # ══════════════════════════════════════════════════════════════ # PARTIE 3 — TESTS DE SÉCURITÉ # ══════════════════════════════════════════════════════════════ class TestSecuriteInjectionSQL: """Tests d'injection SQL — SQLAlchemy protège nativement via requêtes paramétrées""" def test_injection_sql_dans_email_login(self): """' OR 1=1 -- dans l'email → 401 ou 422, JAMAIS 500""" skip_no_app() r = client.post("/login", json={"email": "' OR 1=1 --", "password": "test"}) assert r.status_code in [401, 422], f"Possible injection SQL : {r.status_code}" def test_injection_sql_dans_email_signup(self): """'; DROP TABLE users; -- → 400 ou 422, JAMAIS 500""" skip_no_app() r = client.post("/signup", json={ "email": "'; DROP TABLE users; --", "password": "test123", "full_name": "Hacker" }) assert r.status_code in [400, 422], f"Possible injection SQL : {r.status_code}" def test_injection_sql_guillemets_doubles(self): """\" OR \"1\"=\"1 dans l'email → pas de 500""" skip_no_app() r = client.post("/login", json={"email": "\" OR \"1\"=\"1", "password": "test"}) assert r.status_code != 500 class TestSecuriteJWT: """Tests de sécurité JWT""" def test_token_falsifie_retourne_401(self): """JWT avec signature incorrecte → 401 'Invalid token'""" skip_no_app() r = client.get("/me?token=eyJhbGciOiJIUzI1NiJ9.faux.mauvaise_signature") assert r.status_code == 401 def test_token_vide_retourne_401_ou_422(self): """Token vide → 401 ou 422""" skip_no_app() assert client.get("/me?token=").status_code in [401, 422] def test_token_malformed_retourne_401(self): """Token sans points (format JWT invalide) → 401""" skip_no_app() assert client.get("/me?token=cecinestunepasjwt").status_code == 401 def test_token_valide_sur_bon_endpoint(self): """Un vrai token JWT permet l'accès à /me""" skip_no_app() email = f"jwt_ok_{int(time.time())}@regwatch.fr" client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"}) token = client.post("/login", json={"email": email, "password": "test123"}).json()["access_token"] assert client.get(f"/me?token={token}").status_code == 200 class TestSecuriteXSS: """Tests XSS — l'API REST retourne du JSON, React échappe côté frontend""" def test_xss_dans_full_name_stocke_sans_crash(self): """Payload XSS dans full_name → accepté sans crash (200 ou 422)""" skip_no_app() email = f"xss_{int(time.time())}@regwatch.fr" r = client.post("/signup", json={ "email": email, "password": "test123", "full_name": "" }) assert r.status_code in [200, 422] def test_xss_dans_email_ne_plante_pas(self): """Payload XSS dans l'email → 422 (validation format email)""" skip_no_app() r = client.post("/login", json={ "email": "@test.com", "password": "test" }) assert r.status_code in [401, 422] # ══════════════════════════════════════════════════════════════ # PARTIE 4 — CONFORMITÉ RGPD # ══════════════════════════════════════════════════════════════ class TestRGPD: """ Tests de conformité RGPD — exigés par le guide Nexa Vérifie que les données personnelles sont bien protégées """ def test_mot_de_passe_absent_de_la_reponse_signup(self): """ Le mot de passe ne doit JAMAIS apparaître dans la réponse API UserResponse = id + email + full_name SEULEMENT (pas de password) """ skip_no_app() email = f"rgpd_{int(time.time())}@regwatch.fr" r = client.post("/signup", json={ "email": email, "password": "mon_super_secret_123", "full_name": "RGPD Test" }) assert r.status_code == 200 # Le mot de passe en clair ne doit pas apparaître assert "mon_super_secret_123" not in r.text # Le champ password ne doit pas être dans la réponse assert "password" not in r.json() def test_mot_de_passe_absent_de_la_reponse_login(self): """Le login retourne uniquement access_token et token_type""" skip_no_app() email = f"rgpd2_{int(time.time())}@regwatch.fr" client.post("/signup", json={"email": email, "password": "secret456", "full_name": "T"}) r = client.post("/login", json={"email": email, "password": "secret456"}) assert r.status_code == 200 assert "secret456" not in r.text data = r.json() assert "password" not in data assert set(data.keys()) <= {"access_token", "token_type"} def test_mot_de_passe_absent_de_la_reponse_me(self): """GET /me ne retourne pas le mot de passe""" skip_no_app() email = f"rgpd3_{int(time.time())}@regwatch.fr" client.post("/signup", json={"email": email, "password": "secret789", "full_name": "T"}) token = client.post("/login", json={"email": email, "password": "secret789"}).json()["access_token"] r = client.get(f"/me?token={token}") assert r.status_code == 200 assert "secret789" not in r.text assert "password" not in r.json() def test_hachage_argon2_dans_requirements(self): """ La dépendance passlib[argon2] est bien dans requirements.txt → preuve que les mots de passe sont hachés avec Argon2 """ req_path = os.path.join( os.path.dirname(__file__), "..", "requirements.txt" ) if os.path.exists(req_path): with open(req_path) as f: content = f.read() assert "argon2" in content.lower(), \ "passlib[argon2] absent de requirements.txt" else: pytest.skip("requirements.txt non trouvé") def test_token_jwt_expire_apres_acces(self): """ Un token JWT est bien structuré (3 parties séparées par des points) → preuve que l'expiration est encodée dans le payload """ skip_no_app() email = f"expire_{int(time.time())}@regwatch.fr" client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"}) token = client.post("/login", json={"email": email, "password": "test123"}).json()["access_token"] # Un JWT valide a toujours 3 parties séparées par des points parts = token.split(".") assert len(parts) == 3, "Le token JWT n'a pas la structure header.payload.signature" def test_cors_autorise_frontend(self): """ Le middleware CORS permet au frontend React de communiquer avec l'API allow_origins=["*"] configuré dans main.py l.13-18 """ skip_no_app() r = client.get("/", headers={"Origin": "http://localhost:5173"}) assert r.status_code == 200