""" tests/test_regwatch_complet.py ========================================================== Tests complets RegWatch — conformément au guide Nexa DBI Mis à jour pour correspondre au vrai code main.py + auth.py Couvre : - Tests unitaires (scraper CIR) - Tests d'intégration (endpoints API) - Tests de sécurité (injection SQL, JWT, XSS) - Conformité RGPD (mdp haché, données non exposées) - Tests endpoints admin ========================================================== """ 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() — cir_scraper.py""" def test_bytes_vide_retourne_chaine_vide(self): from scrapers.cir_scraper import extract_pages_text assert extract_pages_text(b"") == "" def test_pdf_invalide_retourne_chaine_vide(self): 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): from scrapers.cir_scraper import extract_pages_text result = extract_pages_text(b"") assert isinstance(result, str) def test_limite_5000_caracteres(self): long_text = "A" * 10000 assert len(long_text[:5000]) == 5000 class TestUnitairesFallbackOllama: """Tests unitaires du fallback Ollama""" def test_fallback_utilise_nom_fichier(self): 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): 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 def test_nettoyage_ingredient_null_string(self): 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 def test_titre_tronque_200_chars(self): long = "A" * 300 assert len(str(long).strip()[:200]) == 200 class TestUnitairesTemplatesURL: """Tests unitaires des templates d'URL CIR""" def test_tous_les_templates_contiennent_num(self): from scrapers.cir_scraper import url_templates for t in url_templates: assert "{num}" in t def test_tous_les_templates_sont_https(self): from scrapers.cir_scraper import url_templates for t in url_templates: assert t.startswith("https://") def test_format_url_remplace_num(self): 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): 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): from scrapers.cir_scraper import PDF_LIMIT assert PDF_LIMIT == 100 class TestUnitairesFiltragePDF: """Tests unitaires du filtrage des liens PDF""" def test_skip_keywords_contient_les_3_valeurs(self): 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): 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): 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): 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): 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""" def test_nouvelle_url_acceptee(self): existing = {"https://cir.org/doc1.pdf"} assert "https://cir.org/doc3.pdf" not in existing def test_url_existante_rejetee(self): existing = {"https://cir.org/doc1.pdf"} assert "https://cir.org/doc1.pdf" in existing def test_structure_doc_6_champs(self): 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): assert "CIR" == "CIR" class TestUnitairesHeadersHTTP: """Tests unitaires des headers HTTP anti-blocage""" def test_user_agent_simule_chrome(self): 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): 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 /""" 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""" 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): 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_role(self): """UserResponse retourne maintenant id, email, full_name ET role""" 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 assert "role" in data assert data["role"] == "user" # role par défaut class TestIntegrationLogin: """Tests de POST /login""" def test_email_inexistant_401(self): 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): 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): 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=...""" def test_sans_token_422(self): skip_no_app() assert client.get("/me").status_code == 422 def test_token_invalide_401(self): skip_no_app() r = client.get("/me?token=token_completement_faux") assert r.status_code == 401 def test_token_valide_retourne_user_avec_role(self): """GET /me retourne maintenant le role dans UserResponse""" 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 data = r.json() assert data["email"] == email assert "role" in data assert data["role"] == "user" class TestIntegrationDocuments: """Tests de GET /documents""" def test_sans_auth_retourne_200(self): """GET /documents ne requiert pas de JWT""" 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""" 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): skip_no_app() docs = client.get("/documents").json() for doc in docs: assert doc["source"] in ["CIR", "SCCS"] # ══════════════════════════════════════════════════════════════ # PARTIE 3 — TESTS SÉCURITÉ # ══════════════════════════════════════════════════════════════ class TestSecuriteInjectionSQL: """Tests injection SQL — SQLAlchemy ORM protège nativement""" def test_injection_sql_dans_email_login(self): skip_no_app() r = client.post("/login", json={"email": "' OR 1=1 --", "password": "test"}) assert r.status_code in [401, 422] def test_injection_sql_dans_email_signup(self): skip_no_app() r = client.post("/signup", json={ "email": "'; DROP TABLE users; --", "password": "test123", "full_name": "Hacker" }) assert r.status_code in [400, 422] def test_injection_sql_guillemets_doubles(self): skip_no_app() r = client.post("/login", json={"email": "\" OR \"1\"=\"1", "password": "test"}) assert r.status_code != 500 class TestSecuriteJWT: """Tests sécurité JWT""" def test_token_falsifie_retourne_401(self): 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): skip_no_app() assert client.get("/me?token=").status_code in [401, 422] def test_token_malformed_retourne_401(self): skip_no_app() assert client.get("/me?token=cecinestunepasjwt").status_code == 401 def test_token_valide_sur_bon_endpoint(self): 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""" def test_xss_dans_full_name_stocke_sans_crash(self): 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): 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 conformité RGPD""" def test_mot_de_passe_absent_de_la_reponse_signup(self): 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 assert "mon_super_secret_123" not in r.text assert "password" not in r.json() def test_mot_de_passe_absent_de_la_reponse_login(self): 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 assert "password" not in r.json() def test_mot_de_passe_absent_de_la_reponse_me(self): 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): 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() else: pytest.skip("requirements.txt non trouvé") def test_token_jwt_structure_3_parties(self): 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"] parts = token.split(".") assert len(parts) == 3 def test_cors_autorise_frontend(self): skip_no_app() r = client.get("/", headers={"Origin": "http://localhost:5173"}) assert r.status_code == 200 # ══════════════════════════════════════════════════════════════ # PARTIE 5 — TESTS ENDPOINTS ADMIN # ══════════════════════════════════════════════════════════════ def get_admin_token(): """Crée un compte admin et retourne son token""" master_email = "master_admin@regwatch.fr" master_password = "master123" # Essai de login master login_r = client.post("/login", json={"email": master_email, "password": master_password}) if login_r.status_code != 200: # Crée le master signup_r = client.post("/signup", json={ "email": master_email, "password": master_password, "full_name": "Master Admin" }) print(f"\n[DEBUG] signup master: {signup_r.status_code} {signup_r.json()}") token_r = client.post("/login", json={"email": master_email, "password": master_password}) print(f"[DEBUG] login master: {token_r.status_code} {token_r.json()}") master_token = token_r.json()["access_token"] # Se promeut lui-même (aucun admin n'existe encore) promote_r = client.post(f"/admin/make-admin?token={master_token}&target_email={master_email}") print(f"[DEBUG] promote master: {promote_r.status_code} {promote_r.json()}") # Re-login master_token = client.post("/login", json={ "email": master_email, "password": master_password }).json()["access_token"] else: master_token = login_r.json()["access_token"] print(f"\n[DEBUG] master déjà existant, token ok") # Vérifie que master est bien admin stats_r = client.get(f"/admin/stats?token={master_token}") print(f"[DEBUG] stats avec master token: {stats_r.status_code} {stats_r.json()}") return master_token, master_email class TestAdminStats: """Tests de GET /admin/stats""" def test_sans_token_401(self): skip_no_app() r = client.get("/admin/stats?token=faux") assert r.status_code in [401, 403] def test_avec_token_user_403(self): skip_no_app() email = f"user_{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"/admin/stats?token={token}") assert r.status_code == 403 def test_avec_token_admin_retourne_stats(self): skip_no_app() token, _ = get_admin_token() r = client.get(f"/admin/stats?token={token}") assert r.status_code == 200 data = r.json() assert "total_documents" in data assert "cir_documents" in data assert "sccs_documents" in data assert "total_users" in data class TestAdminUsers: """Tests de GET /admin/users et DELETE /admin/users/{id}""" def test_get_users_sans_admin_403(self): skip_no_app() email = f"user_{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"/admin/users?token={token}") assert r.status_code == 403 def test_get_users_avec_admin_retourne_liste(self): skip_no_app() token, _ = get_admin_token() r = client.get(f"/admin/users?token={token}") assert r.status_code == 200 assert isinstance(r.json(), list) def test_delete_user_inexistant_404(self): skip_no_app() token, _ = get_admin_token() r = client.delete(f"/admin/users/999999?token={token}") assert r.status_code == 404 def test_patch_role_user_inexistant_404(self): skip_no_app() token, _ = get_admin_token() r = client.patch(f"/admin/users/999999/role?token={token}", json={"role": "admin"}) assert r.status_code == 404 class TestAdminDocuments: """Tests de DELETE /admin/documents/{id}""" def test_delete_document_sans_admin_403(self): skip_no_app() email = f"user_{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.delete(f"/admin/documents/1?token={token}") assert r.status_code == 403 def test_delete_document_inexistant_404(self): skip_no_app() token, _ = get_admin_token() r = client.delete(f"/admin/documents/999999?token={token}") assert r.status_code == 404 class TestAdminMakeAdmin: """Tests de POST /admin/make-admin""" def test_make_admin_utilisateur_inexistant_404(self): skip_no_app() token, _ = get_admin_token() r = client.post(f"/admin/make-admin?token={token}&target_email=inexistant@regwatch.fr") assert r.status_code == 404 def test_make_admin_retourne_message(self): skip_no_app() token, _ = get_admin_token() email = f"futuradmin_{int(time.time())}@regwatch.fr" client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"}) r = client.post(f"/admin/make-admin?token={token}&target_email={email}") assert r.status_code == 200 assert "admin" in r.json()["message"].lower()