Mise à jour API, scraper CIR et tests
Build & Deploy / build (push) Successful in 17s

This commit is contained in:
Mouigni
2026-08-16 17:13:43 +02:00
parent f8c8e16e7e
commit 742469b032
3 changed files with 195 additions and 132 deletions
+4 -12
View File
@@ -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):
-1
View File
@@ -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)
+191 -119
View File
@@ -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": "<script>alert(1)</script>@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
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()