diff --git a/api/main.py b/api/main.py index bee08f6..8915725 100644 --- a/api/main.py +++ b/api/main.py @@ -3,14 +3,17 @@ from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import text from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo -from database.database import SessionLocal +from database.database import SessionLocal, Base, engine from database.models import Document, ScraperRun, User + from api.auth import ( UserLogin, UserCreate, Token, UserResponse, RoleUpdate, get_password_hash, verify_password, create_access_token, decode_token, ACCESS_TOKEN_EXPIRE_MINUTES ) +Base.metadata.create_all(bind=engine) + app = FastAPI() app.add_middleware( @@ -21,7 +24,6 @@ app.add_middleware( allow_headers=["*"], ) -# ── Fonction vérification admin ────────────────────────────── def require_admin(token: str): email = decode_token(token) if not email: @@ -33,7 +35,6 @@ def require_admin(token: str): raise HTTPException(status_code=403, detail="Accès refusé") return user -# ── Endpoints existants ────────────────────────────────────── @app.get("/") def root(): @@ -104,11 +105,9 @@ def get_documents(): db.close() return result -# ── Migration colonne role ─────────────────────────────────── @app.post("/admin/migrate-add-role") def migrate_add_role(): - """Endpoint temporaire — ajoute la colonne role si absente""" db = SessionLocal() try: db.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR DEFAULT 'user'")) @@ -119,15 +118,9 @@ def migrate_add_role(): finally: db.close() -# ── Premier admin sans neckfire ────────────────────────────── @app.post("/admin/make-admin") def make_admin(token: str, target_email: str): - """ - Passe un user en admin. - Fonctionne sans auth si aucun admin n'existe encore. - Se désactive automatiquement si un admin existe déjà. - """ db = SessionLocal() existing_admins = db.query(User).filter(User.role == "admin").count() @@ -149,7 +142,6 @@ def make_admin(token: str, target_email: str): db.close() return {"message": f"{target_email} est maintenant admin"} -# ── Endpoints admin ────────────────────────────────────────── @app.get("/admin/stats") def admin_stats(token: str): diff --git a/scrapers/cir_scraper.py b/scrapers/cir_scraper.py index b6fd36e..56b4b31 100644 --- a/scrapers/cir_scraper.py +++ b/scrapers/cir_scraper.py @@ -107,7 +107,6 @@ Document text (first 3 pages): raw = response["message"]["content"].strip() raw = re.sub(r"```json|```", "", raw).strip() - # Extrait le JSON même s'il y a du texte autour match = re.search(r"\{.*\}", raw, re.DOTALL) if match: raw = match.group(0) diff --git a/tests/test_regwatch_complet.py b/tests/test_regwatch_complet.py index 729c2d2..22de6dd 100644 --- a/tests/test_regwatch_complet.py +++ b/tests/test_regwatch_complet.py @@ -1,3 +1,17 @@ +""" +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 @@ -24,47 +38,35 @@ def skip_no_app(): # ══════════════════════════════════════════════════════════════ class TestUnitairesExtractPagesText: - """ - Tests unitaires de extract_pages_text() - Réf. cir_scraper.py l.46 — extraction texte PDF - """ + """Tests unitaires de extract_pages_text() — cir_scraper.py""" 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 - """ + """Tests unitaires du fallback Ollama""" 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, @@ -73,46 +75,37 @@ class TestUnitairesFallbackOllama: } 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" + assert result is 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) - """ + """Tests unitaires des templates d'URL CIR""" 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 @@ -120,66 +113,53 @@ class TestUnitairesTemplatesURL: 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) - """ + """Tests unitaires du filtrage des liens PDF""" 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) - """ + """Tests unitaires de la logique de déduplication""" def test_nouvelle_url_acceptee(self): - """Une URL absente du set → à insérer""" - existing = {"https://cir.org/doc1.pdf", "https://cir.org/doc2.pdf"} + existing = {"https://cir.org/doc1.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", @@ -192,24 +172,19 @@ class TestUnitairesDeduplication: 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) - """ + """Tests unitaires des headers HTTP anti-blocage""" 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"] @@ -220,7 +195,7 @@ class TestUnitairesHeadersHTTP: # ══════════════════════════════════════════════════════════════ class TestIntegrationRacine: - """Tests de GET / (main.py l.21)""" + """Tests de GET /""" def test_racine_200(self): skip_no_app() @@ -239,7 +214,7 @@ class TestIntegrationRacine: class TestIntegrationSignup: - """Tests de POST /signup (main.py l.28)""" + """Tests de POST /signup""" def test_email_invalide_422(self): skip_no_app() @@ -252,15 +227,14 @@ class TestIntegrationSignup: 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""" + 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"}) @@ -268,20 +242,20 @@ class TestIntegrationSignup: 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 (main.py l.55)""" + """Tests de POST /login""" 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"}) @@ -289,7 +263,6 @@ class TestIntegrationLogin: 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"}) @@ -306,36 +279,36 @@ class TestIntegrationLogin: class TestIntegrationMe: - """Tests de GET /me?token=... (main.py l.88)""" + """Tests de GET /me?token=...""" 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""" + 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 - assert r.json()["email"] == email + data = r.json() + assert data["email"] == email + assert "role" in data + assert data["role"] == "user" class TestIntegrationDocuments: - """Tests de GET /documents (main.py l.104)""" + """Tests de GET /documents""" def test_sans_auth_retourne_200(self): - """Pas de JWT requis sur cet endpoint (main.py l.104)""" + """GET /documents ne requiert pas de JWT""" skip_no_app() assert client.get("/documents").status_code == 200 @@ -344,7 +317,7 @@ class TestIntegrationDocuments: 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)""" + """Chaque doc a id, title, ingredient, source, type, date, pdf_url""" skip_no_app() docs = client.get("/documents").json() if docs: @@ -352,64 +325,56 @@ class TestIntegrationDocuments: 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']}" + assert doc["source"] in ["CIR", "SCCS"] # ══════════════════════════════════════════════════════════════ -# PARTIE 3 — TESTS DE SÉCURITÉ +# PARTIE 3 — TESTS SÉCURITÉ # ══════════════════════════════════════════════════════════════ class TestSecuriteInjectionSQL: - """Tests d'injection SQL — SQLAlchemy protège nativement via requêtes paramétrées""" + """Tests injection SQL — SQLAlchemy ORM protège nativement""" 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}" + assert r.status_code in [401, 422] 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}" + assert r.status_code in [400, 422] 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""" + """Tests 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"}) @@ -418,10 +383,9 @@ class TestSecuriteJWT: class TestSecuriteXSS: - """Tests XSS — l'API REST retourne du JSON, React échappe côté frontend""" + """Tests XSS""" 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={ @@ -432,7 +396,6 @@ class TestSecuriteXSS: 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", @@ -446,16 +409,9 @@ class TestSecuriteXSS: # ══════════════════════════════════════════════════════════════ class TestRGPD: - """ - Tests de conformité RGPD — exigés par le guide Nexa - Vérifie que les données personnelles sont bien protégées - """ + """Tests conformité RGPD""" 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={ @@ -464,25 +420,19 @@ class TestRGPD: "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"} + assert "password" not in r.json() 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"}) @@ -493,39 +443,161 @@ class TestRGPD: 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" - ) + 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" + assert "argon2" in content.lower() 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 - """ + 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"] - # 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" + assert len(parts) == 3 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 \ No newline at end of file + 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() \ No newline at end of file