Compare commits

...
11 Commits
Author SHA1 Message Date
Mouigni 272d14d623 Maj
Build & Deploy / build (push) Successful in 38s
2026-08-19 13:42:31 +02:00
Mouigni 742469b032 Mise à jour API, scraper CIR et tests
Build & Deploy / build (push) Successful in 17s
2026-08-16 17:13:43 +02:00
neckfireandClaude Opus 5 f8c8e16e7e Scrapers : endpoints de lancement à la demande et d'historique
Build & Deploy / build (push) Successful in 16s
La page admin appelait /admin/scrapers/logs et /admin/scrapers/run, absents de
l'API : 404 sur cet onglet.

- Nouvelle table scraper_runs (source, status, started_at, finished_at,
  documents_added, message), créée au boot par le create_all de entrypoint.sh.
- GET /admin/scrapers/logs : les 20 dernières exécutions, réservé aux admins.
- POST /admin/scrapers/run?source=CIR|SCCS : journalise puis lance le scraper en
  tâche de fond, et renvoie la ligne de journal attendue par le front. Refuse en
  409 si une exécution de la même source est déjà en cours.
- Le nombre de documents ajoutés est mesuré avant/après, comme le fait déjà le
  cron hôte.

Les imports de scrapers sont tardifs : ollama/pdfplumber/bs4 sont lourds et ne
servent qu'ici, inutile de les charger au démarrage de l'API.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 16:33:45 +02:00
neckfireandClaude Opus 5 97983ae5ba Rebranche la base sur DATABASE_URL (PostgreSQL partagé) au lieu du SQLite en dur
Build & Deploy / build (push) Successful in 34s
database.py codait la base en dur en SQLite et ignorait la variable
DATABASE_URL fournie par le compose. Le conteneur recréait donc un
/app/regwatch.db vide à chaque démarrage (aucun volume), pendant que les
397 documents réels dormaient dans PostgreSQL : GET /documents renvoyait
[] et le cron de veille comptait dans PG des documents que les scrapers
écrivaient dans le SQLite jetable.

- DATABASE_URL est lu depuis l'environnement, le SQLite ne reste qu'en repli.
- check_same_thread n'est passé que pour SQLite (psycopg2 le rejette).
- psycopg2-binary ajouté : aucun driver PostgreSQL n'était installé.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:01:17 +02:00
neckfireandClaude Opus 5 b780dce8c3 Relance CI (build #5 en échec : 502 Docker Hub sur python:3.12-slim)
Build & Deploy / build (push) Successful in 33s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 14:27:10 +02:00
neckfireandClaude Opus 5 1e811c0b43 Retire le BOM UTF-8 en tête de entrypoint.sh et .gitignore
Build & Deploy / build (push) Failing after 6s
Le BOM (EF BB BF) précédait le shebang de entrypoint.sh : le noyau ne
voyait plus de shebang valide, d'où « exec ./entrypoint.sh: exec format
error » et le conteneur regwatch-backend en boucle de redémarrage.

Même BOM sur .gitignore, où il neutralisait le premier motif
(__pycache__/).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 14:18:15 +02:00
Mouigni ad84286259 Ajout admin
Build & Deploy / build (push) Successful in 41s
2026-08-13 23:46:41 +02:00
Mouigni c4aeedd032 Restauration : Docker + CI/CD + entrypoint
Build & Deploy / build (push) Successful in 28s
2026-08-12 00:09:18 +02:00
Mouigni 1f7d06c26d Restauration : fichiers Python depuis commit 4080a308ea 2026-08-11 23:33:24 +02:00
Mouignihazi 8df04dd679 Fix : gitignore + suppression __pycache__ et regwatch.db 2026-08-11 23:18:50 +02:00
Mouignihazi 36e211a524 Revert "Sécurité : validation EmailStr + tests complets 57/57 + RGPD"
This reverts commit 4080a308ea.
2026-08-09 21:30:14 +02:00
12 changed files with 521 additions and 213 deletions
+33
View File
@@ -0,0 +1,33 @@
name: Build & Deploy
on:
push:
branches: [main]
workflow_dispatch: {}
env:
IMAGE: git.nfteam.ovh/mouigni/regwatch-backend
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build & push image
run: |
set -e
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.nfteam.ovh -u "${{ secrets.REGISTRY_USER }}" --password-stdin
SHA="${GITHUB_SHA::12}"
docker build -t "${IMAGE}:latest" -t "${IMAGE}:${SHA}" -f Dockerfile .
docker push --all-tags "${IMAGE}"
- name: Notify ntfy
if: always()
run: |
if [ "${{ job.status }}" = "success" ]; then EMOJI="white_check_mark"; PRIO="default"; else EMOJI="rotating_light"; PRIO="high"; fi
curl -s -H "Authorization: Bearer ${{ secrets.NTFY_TOKEN }}" \
-H "Title: ${GITHUB_REPOSITORY} — ${{ job.status }}" \
-H "Priority: ${PRIO}" -H "Tags: ${EMOJI}" \
-H "Click: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions" \
-d "${GITHUB_WORKFLOW} (${GITHUB_REF_NAME} #${GITHUB_RUN_NUMBER}) : ${{ job.status }}" \
"${{ secrets.NTFY_URL }}/${{ secrets.NTFY_TOPIC }}" || true
+7 -7
View File
@@ -1,11 +1,11 @@
@"
.env
__pycache__/ __pycache__/
*.pyc *.pyc
*.pyo *.pyo
venv/
.venv/
*.db
.pytest_cache/ .pytest_cache/
htmlcov/ *.db
"@ venv/
.env
.venv/
*.egg-info/
dist/
build/
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chmod +x entrypoint.sh
EXPOSE 8000
CMD ["./entrypoint.sh"]
+5 -2
View File
@@ -30,6 +30,10 @@ class UserResponse(BaseModel):
id: int id: int
email: str email: str
full_name: Optional[str] = None full_name: Optional[str] = None
role: str = "user" # ← nouveau
class RoleUpdate(BaseModel):
role: str
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password) return pwd_context.verify(plain_password, hashed_password)
@@ -44,8 +48,7 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
else: else:
expire = datetime.now(timezone.utc) + timedelta(minutes=15) expire = datetime.now(timezone.utc) + timedelta(minutes=15)
to_encode.update({"exp": expire}) to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def decode_token(token: str) -> Optional[str]: def decode_token(token: str) -> Optional[str]:
try: try:
+222 -67
View File
@@ -1,13 +1,19 @@
from fastapi import FastAPI, Depends, HTTPException, status from fastapi import BackgroundTasks, FastAPI, Depends, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from datetime import timedelta from sqlalchemy import text
from database.database import SessionLocal from datetime import datetime, timedelta, timezone
from database.models import Document, User from zoneinfo import ZoneInfo
from database.database import SessionLocal, Base, engine
from database.models import Document, ScraperRun, User
from api.auth import ( from api.auth import (
UserLogin, UserCreate, Token, UserResponse, get_password_hash, verify_password, UserLogin, UserCreate, Token, UserResponse, RoleUpdate,
get_password_hash, verify_password,
create_access_token, decode_token, ACCESS_TOKEN_EXPIRE_MINUTES create_access_token, decode_token, ACCESS_TOKEN_EXPIRE_MINUTES
) )
Base.metadata.create_all(bind=engine)
app = FastAPI() app = FastAPI()
app.add_middleware( app.add_middleware(
@@ -18,113 +24,74 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
def require_admin(token: str):
email = decode_token(token)
if not email:
raise HTTPException(status_code=401, detail="Invalid token")
db = SessionLocal()
user = db.query(User).filter(User.email == email).first()
db.close()
if not user or user.role != "admin":
raise HTTPException(status_code=403, detail="Accès refusé")
return user
@app.get("/") @app.get("/")
def root(): def root():
return {"message": "RegWatch API running"} return {"message": "RegWatch API running"}
@app.post("/test-login")
def test_login(credentials: UserLogin):
return {"received": credentials.dict()}
@app.post("/signup", response_model=UserResponse) @app.post("/signup", response_model=UserResponse)
def signup(user: UserCreate): def signup(user: UserCreate):
db = SessionLocal() db = SessionLocal()
existing_user = db.query(User).filter(User.email == user.email).first() existing_user = db.query(User).filter(User.email == user.email).first()
if existing_user: if existing_user:
db.close() db.close()
raise HTTPException( raise HTTPException(status_code=400, detail="Email already registered")
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
hashed_password = get_password_hash(user.password) hashed_password = get_password_hash(user.password)
db_user = User( db_user = User(email=user.email, password=hashed_password, full_name=user.full_name)
email=user.email,
password=hashed_password,
full_name=user.full_name
)
db.add(db_user) db.add(db_user)
db.commit() db.commit()
db.refresh(db_user) db.refresh(db_user)
db.close() db.close()
return UserResponse(id=db_user.id, email=db_user.email, full_name=db_user.full_name, role=db_user.role)
return UserResponse(
id=db_user.id,
email=db_user.email,
full_name=db_user.full_name
)
@app.post("/login", response_model=Token) @app.post("/login", response_model=Token)
def login(credentials: UserLogin): def login(credentials: UserLogin):
try: try:
db = SessionLocal() db = SessionLocal()
user = db.query(User).filter(User.email == credentials.email).first() user = db.query(User).filter(User.email == credentials.email).first()
db.close() db.close()
if not user: if not user:
raise HTTPException( raise HTTPException(status_code=401, detail="User not found")
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
if not verify_password(credentials.password, user.password): if not verify_password(credentials.password, user.password):
raise HTTPException( raise HTTPException(status_code=401, detail="Invalid password")
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid password"
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token( access_token = create_access_token(
data={"sub": user.email}, expires_delta=access_token_expires data={"sub": user.email},
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
) )
return Token(access_token=access_token, token_type="bearer") return Token(access_token=access_token, token_type="bearer")
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
print(f"ERROR in login: {str(e)}") raise HTTPException(status_code=500, detail=f"Server error: {str(e)}")
import traceback
traceback.print_exc()
raise HTTPException(
status_code=500,
detail=f"Server error: {str(e)}"
)
@app.get("/me", response_model=UserResponse) @app.get("/me", response_model=UserResponse)
def get_current_user(token: str): def get_current_user(token: str):
email = decode_token(token) email = decode_token(token)
if not email: if not email:
raise HTTPException( raise HTTPException(status_code=401, detail="Invalid token")
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
db = SessionLocal() db = SessionLocal()
user = db.query(User).filter(User.email == email).first() user = db.query(User).filter(User.email == email).first()
db.close() db.close()
if not user: if not user:
raise HTTPException( raise HTTPException(status_code=404, detail="User not found")
status_code=status.HTTP_404_NOT_FOUND, return UserResponse(id=user.id, email=user.email, full_name=user.full_name, role=user.role)
detail="User not found"
)
return UserResponse(
id=user.id,
email=user.email,
full_name=user.full_name
)
@app.get("/documents") @app.get("/documents")
def get_documents(): def get_documents():
db = SessionLocal() db = SessionLocal()
documents = db.query(Document).all() documents = db.query(Document).all()
result = [] result = []
for doc in documents: for doc in documents:
result.append({ result.append({
"id": doc.id, "id": doc.id,
@@ -135,7 +102,195 @@ def get_documents():
"date": doc.meeting_date, "date": doc.meeting_date,
"pdf_url": doc.pdf_url "pdf_url": doc.pdf_url
}) })
db.close()
return result
@app.post("/admin/migrate-add-role")
def migrate_add_role():
db = SessionLocal()
try:
db.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR DEFAULT 'user'"))
db.commit()
return {"message": "Colonne role ajoutée avec succès"}
except Exception as e:
return {"message": str(e)}
finally:
db.close()
@app.post("/admin/make-admin")
def make_admin(token: str, target_email: str):
db = SessionLocal()
existing_admins = db.query(User).filter(User.role == "admin").count()
if existing_admins > 0:
# Il y a déjà un admin — vérifier que l'appelant est admin
email = decode_token(token)
caller = db.query(User).filter(User.email == email).first()
if not caller or caller.role != "admin":
db.close()
raise HTTPException(status_code=403, detail="Accès refusé")
target = db.query(User).filter(User.email == target_email).first()
if not target:
db.close()
raise HTTPException(status_code=404, detail="Utilisateur introuvable")
target.role = "admin"
db.commit()
db.close()
return {"message": f"{target_email} est maintenant admin"}
@app.get("/admin/stats")
def admin_stats(token: str):
require_admin(token)
db = SessionLocal()
total_docs = db.query(Document).count()
cir_docs = db.query(Document).filter(Document.source == "CIR").count()
sccs_docs = db.query(Document).filter(Document.source == "SCCS").count()
total_users = db.query(User).count()
db.close()
return {
"total_documents": total_docs,
"cir_documents": cir_docs,
"sccs_documents": sccs_docs,
"total_users": total_users
}
@app.get("/admin/users")
def admin_get_users(token: str):
require_admin(token)
db = SessionLocal()
users = db.query(User).all()
result = [{"id": u.id, "email": u.email, "full_name": u.full_name, "role": u.role} for u in users]
db.close()
return result
@app.delete("/admin/users/{user_id}")
def admin_delete_user(user_id: int, token: str):
require_admin(token)
db = SessionLocal()
user = db.query(User).filter(User.id == user_id).first()
if not user:
db.close()
raise HTTPException(status_code=404, detail="Utilisateur introuvable")
db.delete(user)
db.commit()
db.close()
return {"message": "Utilisateur supprimé"}
@app.patch("/admin/users/{user_id}/role")
def admin_update_role(user_id: int, role_update: RoleUpdate, token: str):
require_admin(token)
db = SessionLocal()
user = db.query(User).filter(User.id == user_id).first()
if not user:
db.close()
raise HTTPException(status_code=404, detail="Utilisateur introuvable")
user.role = role_update.role
db.commit()
db.close()
return {"message": f"Rôle mis à jour : {role_update.role}"}
@app.delete("/admin/documents/{doc_id}")
def admin_delete_document(doc_id: int, token: str):
require_admin(token)
db = SessionLocal()
doc = db.query(Document).filter(Document.id == doc_id).first()
if not doc:
db.close()
raise HTTPException(status_code=404, detail="Document introuvable")
db.delete(doc)
db.commit()
db.close()
return {"message": "Document supprimé"}
SCRAPER_SOURCES = ("CIR", "SCCS")
PARIS = ZoneInfo("Europe/Paris")
def _serialize_run(run: ScraperRun) -> dict:
started = run.started_at.astimezone(PARIS) if run.started_at else None
return {
"id": str(run.id),
"when": started.strftime("%d/%m/%Y %H:%M") if started else "",
"source": run.source,
"status": run.status,
"documents_added": run.documents_added or 0,
"message": run.message,
}
def _execute_scraper(run_id: int, source: str) -> None:
from scrapers.cir_scraper import scrape_cir
from scrapers.sccs_scraper import scrape_sccs
db = SessionLocal()
run = db.query(ScraperRun).filter(ScraperRun.id == run_id).first()
run.status = "running"
db.commit()
before = db.query(Document).filter(Document.source == source).count()
db.close() db.close()
status, message = "success", None
try:
if source == "CIR":
scrape_cir()
else:
scrape_sccs()
except Exception as exc:
status, message = "error", str(exc)[:500]
db = SessionLocal()
run = db.query(ScraperRun).filter(ScraperRun.id == run_id).first()
run.status = status
run.message = message
run.documents_added = max(db.query(Document).filter(Document.source == source).count() - before, 0)
run.finished_at = datetime.now(timezone.utc)
db.commit()
db.close()
@app.get("/admin/scrapers/logs")
def admin_scraper_logs(token: str, limit: int = 20):
require_admin(token)
db = SessionLocal()
runs = db.query(ScraperRun).order_by(ScraperRun.id.desc()).limit(limit).all()
result = [_serialize_run(r) for r in runs]
db.close()
return result return result
@app.post("/admin/scrapers/run")
def admin_scraper_run(token: str, source: str, background_tasks: BackgroundTasks):
require_admin(token)
source = source.upper()
if source not in SCRAPER_SOURCES:
raise HTTPException(status_code=400, detail=f"Source inconnue : {source}")
db = SessionLocal()
already = (
db.query(ScraperRun)
.filter(ScraperRun.source == source, ScraperRun.status.in_(("pending", "running")))
.first()
)
if already:
payload = _serialize_run(already)
db.close()
raise HTTPException(status_code=409, detail=f"Un scraper {source} est déjà en cours (#{payload['id']})")
run = ScraperRun(
source=source,
status="pending",
started_at=datetime.now(timezone.utc),
documents_added=0,
)
db.add(run)
db.commit()
db.refresh(run)
payload = _serialize_run(run)
run_id = run.id
db.close()
background_tasks.add_task(_execute_scraper, run_id, source)
return payload
+4 -2
View File
@@ -3,11 +3,13 @@ from sqlalchemy.orm import sessionmaker, declarative_base
import os import os
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "regwatch.db") DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "regwatch.db")
DATABASE_URL = f"sqlite:///{DB_PATH}" DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DB_PATH}")
CONNECT_ARGS = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
engine = create_engine( engine = create_engine(
DATABASE_URL, DATABASE_URL,
connect_args={"check_same_thread": False} connect_args=CONNECT_ARGS
) )
SessionLocal = sessionmaker( SessionLocal = sessionmaker(
+21 -12
View File
@@ -1,21 +1,30 @@
from sqlalchemy import Column, Integer, String from sqlalchemy import Column, DateTime, Integer, String
from database.database import Base from database.database import Base
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
id = Column(Integer, primary_key=True, index=True) email = Column(String, unique=True, index=True)
email = Column(String, unique=True, index=True) password = Column(String)
password = Column(String)
full_name = Column(String, nullable=True) full_name = Column(String, nullable=True)
role = Column(String, default="user") # ← nouveau
class Document(Base): class Document(Base):
__tablename__ = "documents" __tablename__ = "documents"
id = Column(Integer, primary_key=True, index=True)
id = Column(Integer, primary_key=True, index=True) title = Column(String)
title = Column(String) ingredient = Column(String)
ingredient = Column(String) source = Column(String)
source = Column(String)
document_type = Column(String) document_type = Column(String)
meeting_date = Column(String, nullable=True) meeting_date = Column(String, nullable=True)
pdf_url = Column(String, unique=True) pdf_url = Column(String, unique=True)
class ScraperRun(Base):
__tablename__ = "scraper_runs"
id = Column(Integer, primary_key=True, index=True)
source = Column(String, index=True)
status = Column(String, default="pending")
started_at = Column(DateTime(timezone=True))
finished_at = Column(DateTime(timezone=True), nullable=True)
documents_added = Column(Integer, default=0)
message = Column(String, nullable=True)
+18
View File
@@ -0,0 +1,18 @@
services:
regwatch-backend:
image: git.nfteam.ovh/mouigni/regwatch-backend:latest
container_name: regwatch-backend
restart: unless-stopped
pull_policy: always
env_file: .env
environment:
- OLLAMA_HOST=http://192.168.1.136:11434
ports:
- "8114:8000"
labels:
- "com.centurylinklabs.watchtower.enable=true"
- "homepage.group=RegWatch"
- "homepage.name=RegWatch API"
- "homepage.icon=fastapi.png"
- "homepage.href=https://regwatch-api.nfteam.ovh"
- "homepage.description=API veille réglementaire (FastAPI, scrapers CIR/SCCS)"
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -e
python -c "from database.models import Base; from database.database import engine; Base.metadata.create_all(bind=engine); print('regwatch: schema OK')"
exec uvicorn api.main:app --host 0.0.0.0 --port 8000
+1
View File
@@ -9,3 +9,4 @@ python-jose[cryptography]
passlib[argon2] passlib[argon2]
python-multipart python-multipart
pdfplumber pdfplumber
psycopg2-binary
-1
View File
@@ -107,7 +107,6 @@ Document text (first 3 pages):
raw = response["message"]["content"].strip() raw = response["message"]["content"].strip()
raw = re.sub(r"```json|```", "", raw).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) match = re.search(r"\{.*\}", raw, re.DOTALL)
if match: if match:
raw = match.group(0) raw = match.group(0)
+190 -118
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 pytest
import sys import sys
import os import os
@@ -24,47 +38,35 @@ def skip_no_app():
# ══════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════
class TestUnitairesExtractPagesText: class TestUnitairesExtractPagesText:
""" """Tests unitaires de extract_pages_text() — cir_scraper.py"""
Tests unitaires de extract_pages_text()
Réf. cir_scraper.py l.46 — extraction texte PDF
"""
def test_bytes_vide_retourne_chaine_vide(self): def test_bytes_vide_retourne_chaine_vide(self):
"""Bytes vides → '' sans crash"""
from scrapers.cir_scraper import extract_pages_text from scrapers.cir_scraper import extract_pages_text
assert extract_pages_text(b"") == "" assert extract_pages_text(b"") == ""
def test_pdf_invalide_retourne_chaine_vide(self): def test_pdf_invalide_retourne_chaine_vide(self):
"""Contenu non-PDF → '' (le except l.57 fonctionne)"""
from scrapers.cir_scraper import extract_pages_text from scrapers.cir_scraper import extract_pages_text
assert extract_pages_text(b"ceci n est pas un pdf") == "" assert extract_pages_text(b"ceci n est pas un pdf") == ""
def test_retour_toujours_une_str(self): def test_retour_toujours_une_str(self):
"""La fonction retourne toujours str, jamais None"""
from scrapers.cir_scraper import extract_pages_text from scrapers.cir_scraper import extract_pages_text
result = extract_pages_text(b"") result = extract_pages_text(b"")
assert isinstance(result, str) assert isinstance(result, str)
def test_limite_5000_caracteres(self): def test_limite_5000_caracteres(self):
"""Le texte est tronqué à 5000 chars max (l.55 : [:5000])"""
long_text = "A" * 10000 long_text = "A" * 10000
assert len(long_text[:5000]) == 5000 assert len(long_text[:5000]) == 5000
class TestUnitairesFallbackOllama: class TestUnitairesFallbackOllama:
""" """Tests unitaires du fallback Ollama"""
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): 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" pdf_url = "https://cir-safety.org/sites/files/Retinol_2024.pdf"
filename = pdf_url.split("/")[-1].replace("_", " ").replace(".pdf", "") filename = pdf_url.split("/")[-1].replace("_", " ").replace(".pdf", "")
assert filename == "Retinol 2024" assert filename == "Retinol 2024"
def test_fallback_retourne_4_cles(self): def test_fallback_retourne_4_cles(self):
"""Le fallback retourne bien title, ingredient, document_type, date"""
fallback = { fallback = {
"title": "Mon Document", "title": "Mon Document",
"ingredient": None, "ingredient": None,
@@ -73,46 +75,37 @@ class TestUnitairesFallbackOllama:
} }
assert all(k in fallback for k in ["title", "ingredient", "document_type", "date"]) assert all(k in fallback for k in ["title", "ingredient", "document_type", "date"])
assert fallback["ingredient"] is None assert fallback["ingredient"] is None
assert fallback["document_type"] == "document"
def test_nettoyage_ingredient_null_string(self): 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"]: for val in ["null", "none", "n/a", "", "NULL", "None"]:
result = None if str(val).strip().lower() in ("null", "none", "n/a", "") else val 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): def test_titre_tronque_200_chars(self):
"""Le titre est limité à 200 chars (l.117 : [:200])"""
long = "A" * 300 long = "A" * 300
assert len(str(long).strip()[:200]) == 200 assert len(str(long).strip()[:200]) == 200
class TestUnitairesTemplatesURL: class TestUnitairesTemplatesURL:
""" """Tests unitaires des templates d'URL CIR"""
Tests unitaires des templates d'URL CIR (cir_scraper.py l.31-40)
"""
def test_tous_les_templates_contiennent_num(self): def test_tous_les_templates_contiennent_num(self):
"""Chaque template contient {num}"""
from scrapers.cir_scraper import url_templates from scrapers.cir_scraper import url_templates
for t in url_templates: for t in url_templates:
assert "{num}" in t assert "{num}" in t
def test_tous_les_templates_sont_https(self): def test_tous_les_templates_sont_https(self):
"""Tous les templates utilisent HTTPS"""
from scrapers.cir_scraper import url_templates from scrapers.cir_scraper import url_templates
for t in url_templates: for t in url_templates:
assert t.startswith("https://") assert t.startswith("https://")
def test_format_url_remplace_num(self): def test_format_url_remplace_num(self):
"""Le format {num} est bien remplacé"""
from scrapers.cir_scraper import url_templates from scrapers.cir_scraper import url_templates
url = url_templates[0].format(num=150) url = url_templates[0].format(num=150)
assert "150" in url assert "150" in url
assert "{num}" not in url assert "{num}" not in url
def test_meeting_numbers_couvre_115_a_174(self): def test_meeting_numbers_couvre_115_a_174(self):
"""MEETING_NUMBERS = range(115, 175) → 60 réunions"""
from scrapers.cir_scraper import MEETING_NUMBERS from scrapers.cir_scraper import MEETING_NUMBERS
nums = list(MEETING_NUMBERS) nums = list(MEETING_NUMBERS)
assert nums[0] == 115 assert nums[0] == 115
@@ -120,66 +113,53 @@ class TestUnitairesTemplatesURL:
assert len(nums) == 60 assert len(nums) == 60
def test_pdf_limit_est_100(self): def test_pdf_limit_est_100(self):
"""PDF_LIMIT = 100 (l.32)"""
from scrapers.cir_scraper import PDF_LIMIT from scrapers.cir_scraper import PDF_LIMIT
assert PDF_LIMIT == 100 assert PDF_LIMIT == 100
class TestUnitairesFiltragePDF: class TestUnitairesFiltragePDF:
""" """Tests unitaires du filtrage des liens PDF"""
Tests unitaires du filtrage des liens PDF (cir_scraper.py l.148)
"""
def test_skip_keywords_contient_les_3_valeurs(self): def test_skip_keywords_contient_les_3_valeurs(self):
"""SKIP_KEYWORDS = ['Agenda', 'Minutes', 'Status Report']"""
from scrapers.cir_scraper import SKIP_KEYWORDS from scrapers.cir_scraper import SKIP_KEYWORDS
assert "Agenda" in SKIP_KEYWORDS assert "Agenda" in SKIP_KEYWORDS
assert "Minutes" in SKIP_KEYWORDS assert "Minutes" in SKIP_KEYWORDS
assert "Status Report" in SKIP_KEYWORDS assert "Status Report" in SKIP_KEYWORDS
def test_regex_pdf_detecte_extension_pdf(self): def test_regex_pdf_detecte_extension_pdf(self):
"""PDF_RE détecte .pdf / .PDF / .Pdf"""
from scrapers.cir_scraper import PDF_RE from scrapers.cir_scraper import PDF_RE
assert PDF_RE.search("document.pdf") assert PDF_RE.search("document.pdf")
assert PDF_RE.search("document.PDF") assert PDF_RE.search("document.PDF")
assert PDF_RE.search("rapport.Pdf") assert PDF_RE.search("rapport.Pdf")
def test_regex_pdf_ne_detecte_pas_autres(self): def test_regex_pdf_ne_detecte_pas_autres(self):
"""PDF_RE ne détecte pas .docx, .png"""
from scrapers.cir_scraper import PDF_RE from scrapers.cir_scraper import PDF_RE
assert not PDF_RE.search("document.docx") assert not PDF_RE.search("document.docx")
assert not PDF_RE.search("image.png") assert not PDF_RE.search("image.png")
def test_agenda_est_filtre(self): def test_agenda_est_filtre(self):
"""Un contexte contenant 'Agenda' → filtré"""
from scrapers.cir_scraper import SKIP_KEYWORDS from scrapers.cir_scraper import SKIP_KEYWORDS
context = "116th Expert Panel Meeting Agenda" context = "116th Expert Panel Meeting Agenda"
assert any(kw in context for kw in SKIP_KEYWORDS) assert any(kw in context for kw in SKIP_KEYWORDS)
def test_final_report_non_filtre(self): def test_final_report_non_filtre(self):
"""Un 'Final Report' passe le filtre"""
from scrapers.cir_scraper import SKIP_KEYWORDS from scrapers.cir_scraper import SKIP_KEYWORDS
context = "Final Report on the Safety Assessment of Retinol" context = "Final Report on the Safety Assessment of Retinol"
assert not any(kw in context for kw in SKIP_KEYWORDS) assert not any(kw in context for kw in SKIP_KEYWORDS)
class TestUnitairesDeduplication: class TestUnitairesDeduplication:
""" """Tests unitaires de la logique de déduplication"""
Tests unitaires de la logique de déduplication (cir_scraper.py l.172)
"""
def test_nouvelle_url_acceptee(self): def test_nouvelle_url_acceptee(self):
"""Une URL absente du set → à insérer""" existing = {"https://cir.org/doc1.pdf"}
existing = {"https://cir.org/doc1.pdf", "https://cir.org/doc2.pdf"}
assert "https://cir.org/doc3.pdf" not in existing assert "https://cir.org/doc3.pdf" not in existing
def test_url_existante_rejetee(self): def test_url_existante_rejetee(self):
"""Une URL déjà présente → doublons ignoré"""
existing = {"https://cir.org/doc1.pdf"} existing = {"https://cir.org/doc1.pdf"}
assert "https://cir.org/doc1.pdf" in existing assert "https://cir.org/doc1.pdf" in existing
def test_structure_doc_6_champs(self): def test_structure_doc_6_champs(self):
"""Un doc à sauvegarder contient les 6 champs requis"""
doc = { doc = {
"title": "Final Report on Retinol", "title": "Final Report on Retinol",
"ingredient": "Retinol", "ingredient": "Retinol",
@@ -192,24 +172,19 @@ class TestUnitairesDeduplication:
assert champ in doc assert champ in doc
def test_source_est_cir(self): def test_source_est_cir(self):
"""La source est toujours 'CIR' pour ce scraper"""
assert "CIR" == "CIR" assert "CIR" == "CIR"
class TestUnitairesHeadersHTTP: class TestUnitairesHeadersHTTP:
""" """Tests unitaires des headers HTTP anti-blocage"""
Tests unitaires des headers HTTP anti-blocage (cir_scraper.py l.22)
"""
def test_user_agent_simule_chrome(self): def test_user_agent_simule_chrome(self):
"""User-Agent contient Mozilla et Chrome"""
from scrapers.cir_scraper import headers from scrapers.cir_scraper import headers
assert "User-Agent" in headers assert "User-Agent" in headers
assert "Mozilla" in headers["User-Agent"] assert "Mozilla" in headers["User-Agent"]
assert "Chrome" in headers["User-Agent"] assert "Chrome" in headers["User-Agent"]
def test_session_utilise_les_headers(self): def test_session_utilise_les_headers(self):
"""La session requests hérite des headers"""
from scrapers.cir_scraper import session from scrapers.cir_scraper import session
assert "User-Agent" in session.headers assert "User-Agent" in session.headers
assert "Mozilla" in session.headers["User-Agent"] assert "Mozilla" in session.headers["User-Agent"]
@@ -220,7 +195,7 @@ class TestUnitairesHeadersHTTP:
# ══════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════
class TestIntegrationRacine: class TestIntegrationRacine:
"""Tests de GET / (main.py l.21)""" """Tests de GET /"""
def test_racine_200(self): def test_racine_200(self):
skip_no_app() skip_no_app()
@@ -239,7 +214,7 @@ class TestIntegrationRacine:
class TestIntegrationSignup: class TestIntegrationSignup:
"""Tests de POST /signup (main.py l.28)""" """Tests de POST /signup"""
def test_email_invalide_422(self): def test_email_invalide_422(self):
skip_no_app() skip_no_app()
@@ -252,15 +227,14 @@ class TestIntegrationSignup:
assert r.status_code == 422 assert r.status_code == 422
def test_email_deja_existant_400(self): def test_email_deja_existant_400(self):
"""Email déjà pris → 400 'Email already registered' (l.33-37)"""
skip_no_app() skip_no_app()
email = f"doublon_{int(time.time())}@regwatch.fr" email = f"doublon_{int(time.time())}@regwatch.fr"
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"}) client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
r = client.post("/signup", json={"email": email, "password": "autre", "full_name": "T"}) r = client.post("/signup", json={"email": email, "password": "autre", "full_name": "T"})
assert r.status_code == 400 assert r.status_code == 400
def test_signup_reussi_retourne_id_email(self): def test_signup_reussi_retourne_id_email_role(self):
"""Signup valide → 200 avec id et email""" """UserResponse retourne maintenant id, email, full_name ET role"""
skip_no_app() skip_no_app()
email = f"ok_{int(time.time())}@regwatch.fr" email = f"ok_{int(time.time())}@regwatch.fr"
r = client.post("/signup", json={"email": email, "password": "test123", "full_name": "OK"}) r = client.post("/signup", json={"email": email, "password": "test123", "full_name": "OK"})
@@ -268,20 +242,20 @@ class TestIntegrationSignup:
data = r.json() data = r.json()
assert "id" in data assert "id" in data
assert data["email"] == email assert data["email"] == email
assert "role" in data
assert data["role"] == "user" # role par défaut
class TestIntegrationLogin: class TestIntegrationLogin:
"""Tests de POST /login (main.py l.55)""" """Tests de POST /login"""
def test_email_inexistant_401(self): def test_email_inexistant_401(self):
"""Email inconnu → 401 'User not found' (l.65-68)"""
skip_no_app() skip_no_app()
r = client.post("/login", json={"email": "xyz_inconnu@regwatch.fr", "password": "test"}) r = client.post("/login", json={"email": "xyz_inconnu@regwatch.fr", "password": "test"})
assert r.status_code == 401 assert r.status_code == 401
assert "not found" in r.json()["detail"].lower() assert "not found" in r.json()["detail"].lower()
def test_mauvais_mot_de_passe_401(self): def test_mauvais_mot_de_passe_401(self):
"""Mauvais mdp → 401 'Invalid password' (l.70-74)"""
skip_no_app() skip_no_app()
email = f"mdp_{int(time.time())}@regwatch.fr" email = f"mdp_{int(time.time())}@regwatch.fr"
client.post("/signup", json={"email": email, "password": "bonmdp", "full_name": "T"}) client.post("/signup", json={"email": email, "password": "bonmdp", "full_name": "T"})
@@ -289,7 +263,6 @@ class TestIntegrationLogin:
assert r.status_code == 401 assert r.status_code == 401
def test_login_reussi_retourne_token_bearer(self): def test_login_reussi_retourne_token_bearer(self):
"""Login valide → token JWT + token_type='bearer' (l.76-82)"""
skip_no_app() skip_no_app()
email = f"jwt_{int(time.time())}@regwatch.fr" email = f"jwt_{int(time.time())}@regwatch.fr"
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"}) client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
@@ -306,36 +279,36 @@ class TestIntegrationLogin:
class TestIntegrationMe: class TestIntegrationMe:
"""Tests de GET /me?token=... (main.py l.88)""" """Tests de GET /me?token=..."""
def test_sans_token_422(self): def test_sans_token_422(self):
"""Paramètre token manquant → 422"""
skip_no_app() skip_no_app()
assert client.get("/me").status_code == 422 assert client.get("/me").status_code == 422
def test_token_invalide_401(self): def test_token_invalide_401(self):
"""Token JWT falsifié → 401 'Invalid token' (l.90-93)"""
skip_no_app() skip_no_app()
r = client.get("/me?token=token_completement_faux") r = client.get("/me?token=token_completement_faux")
assert r.status_code == 401 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): def test_token_valide_retourne_user_avec_role(self):
"""Token valide → 200 avec email de l'utilisateur""" """GET /me retourne maintenant le role dans UserResponse"""
skip_no_app() skip_no_app()
email = f"me_{int(time.time())}@regwatch.fr" email = f"me_{int(time.time())}@regwatch.fr"
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"}) client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
token = client.post("/login", json={"email": email, "password": "test123"}).json()["access_token"] token = client.post("/login", json={"email": email, "password": "test123"}).json()["access_token"]
r = client.get(f"/me?token={token}") r = client.get(f"/me?token={token}")
assert r.status_code == 200 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: class TestIntegrationDocuments:
"""Tests de GET /documents (main.py l.104)""" """Tests de GET /documents"""
def test_sans_auth_retourne_200(self): 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() skip_no_app()
assert client.get("/documents").status_code == 200 assert client.get("/documents").status_code == 200
@@ -344,7 +317,7 @@ class TestIntegrationDocuments:
assert isinstance(client.get("/documents").json(), list) assert isinstance(client.get("/documents").json(), list)
def test_structure_7_champs_par_document(self): 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() skip_no_app()
docs = client.get("/documents").json() docs = client.get("/documents").json()
if docs: if docs:
@@ -352,64 +325,56 @@ class TestIntegrationDocuments:
assert champ in docs[0], f"Champ manquant : {champ}" assert champ in docs[0], f"Champ manquant : {champ}"
def test_source_cir_ou_sccs(self): def test_source_cir_ou_sccs(self):
"""La source est 'CIR' ou 'SCCS' pour chaque document"""
skip_no_app() skip_no_app()
docs = client.get("/documents").json() docs = client.get("/documents").json()
for doc in docs: 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: 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): def test_injection_sql_dans_email_login(self):
"""' OR 1=1 -- dans l'email → 401 ou 422, JAMAIS 500"""
skip_no_app() skip_no_app()
r = client.post("/login", json={"email": "' OR 1=1 --", "password": "test"}) 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): def test_injection_sql_dans_email_signup(self):
"""'; DROP TABLE users; -- → 400 ou 422, JAMAIS 500"""
skip_no_app() skip_no_app()
r = client.post("/signup", json={ r = client.post("/signup", json={
"email": "'; DROP TABLE users; --", "email": "'; DROP TABLE users; --",
"password": "test123", "password": "test123",
"full_name": "Hacker" "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): def test_injection_sql_guillemets_doubles(self):
"""\" OR \"1\"=\"1 dans l'email → pas de 500"""
skip_no_app() skip_no_app()
r = client.post("/login", json={"email": "\" OR \"1\"=\"1", "password": "test"}) r = client.post("/login", json={"email": "\" OR \"1\"=\"1", "password": "test"})
assert r.status_code != 500 assert r.status_code != 500
class TestSecuriteJWT: class TestSecuriteJWT:
"""Tests de sécurité JWT""" """Tests sécurité JWT"""
def test_token_falsifie_retourne_401(self): def test_token_falsifie_retourne_401(self):
"""JWT avec signature incorrecte → 401 'Invalid token'"""
skip_no_app() skip_no_app()
r = client.get("/me?token=eyJhbGciOiJIUzI1NiJ9.faux.mauvaise_signature") r = client.get("/me?token=eyJhbGciOiJIUzI1NiJ9.faux.mauvaise_signature")
assert r.status_code == 401 assert r.status_code == 401
def test_token_vide_retourne_401_ou_422(self): def test_token_vide_retourne_401_ou_422(self):
"""Token vide → 401 ou 422"""
skip_no_app() skip_no_app()
assert client.get("/me?token=").status_code in [401, 422] assert client.get("/me?token=").status_code in [401, 422]
def test_token_malformed_retourne_401(self): def test_token_malformed_retourne_401(self):
"""Token sans points (format JWT invalide) → 401"""
skip_no_app() skip_no_app()
assert client.get("/me?token=cecinestunepasjwt").status_code == 401 assert client.get("/me?token=cecinestunepasjwt").status_code == 401
def test_token_valide_sur_bon_endpoint(self): def test_token_valide_sur_bon_endpoint(self):
"""Un vrai token JWT permet l'accès à /me"""
skip_no_app() skip_no_app()
email = f"jwt_ok_{int(time.time())}@regwatch.fr" email = f"jwt_ok_{int(time.time())}@regwatch.fr"
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"}) client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
@@ -418,10 +383,9 @@ class TestSecuriteJWT:
class TestSecuriteXSS: 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): def test_xss_dans_full_name_stocke_sans_crash(self):
"""Payload XSS dans full_name → accepté sans crash (200 ou 422)"""
skip_no_app() skip_no_app()
email = f"xss_{int(time.time())}@regwatch.fr" email = f"xss_{int(time.time())}@regwatch.fr"
r = client.post("/signup", json={ r = client.post("/signup", json={
@@ -432,7 +396,6 @@ class TestSecuriteXSS:
assert r.status_code in [200, 422] assert r.status_code in [200, 422]
def test_xss_dans_email_ne_plante_pas(self): def test_xss_dans_email_ne_plante_pas(self):
"""Payload XSS dans l'email → 422 (validation format email)"""
skip_no_app() skip_no_app()
r = client.post("/login", json={ r = client.post("/login", json={
"email": "<script>alert(1)</script>@test.com", "email": "<script>alert(1)</script>@test.com",
@@ -446,16 +409,9 @@ class TestSecuriteXSS:
# ══════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════
class TestRGPD: class TestRGPD:
""" """Tests conformité RGPD"""
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): 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() skip_no_app()
email = f"rgpd_{int(time.time())}@regwatch.fr" email = f"rgpd_{int(time.time())}@regwatch.fr"
r = client.post("/signup", json={ r = client.post("/signup", json={
@@ -464,25 +420,19 @@ class TestRGPD:
"full_name": "RGPD Test" "full_name": "RGPD Test"
}) })
assert r.status_code == 200 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 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() assert "password" not in r.json()
def test_mot_de_passe_absent_de_la_reponse_login(self): def test_mot_de_passe_absent_de_la_reponse_login(self):
"""Le login retourne uniquement access_token et token_type"""
skip_no_app() skip_no_app()
email = f"rgpd2_{int(time.time())}@regwatch.fr" email = f"rgpd2_{int(time.time())}@regwatch.fr"
client.post("/signup", json={"email": email, "password": "secret456", "full_name": "T"}) client.post("/signup", json={"email": email, "password": "secret456", "full_name": "T"})
r = client.post("/login", json={"email": email, "password": "secret456"}) r = client.post("/login", json={"email": email, "password": "secret456"})
assert r.status_code == 200 assert r.status_code == 200
assert "secret456" not in r.text assert "secret456" not in r.text
data = r.json() assert "password" not in 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): def test_mot_de_passe_absent_de_la_reponse_me(self):
"""GET /me ne retourne pas le mot de passe"""
skip_no_app() skip_no_app()
email = f"rgpd3_{int(time.time())}@regwatch.fr" email = f"rgpd3_{int(time.time())}@regwatch.fr"
client.post("/signup", json={"email": email, "password": "secret789", "full_name": "T"}) client.post("/signup", json={"email": email, "password": "secret789", "full_name": "T"})
@@ -493,39 +443,161 @@ class TestRGPD:
assert "password" not in r.json() assert "password" not in r.json()
def test_hachage_argon2_dans_requirements(self): def test_hachage_argon2_dans_requirements(self):
""" req_path = os.path.join(os.path.dirname(__file__), "..", "requirements.txt")
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): if os.path.exists(req_path):
with open(req_path) as f: with open(req_path) as f:
content = f.read() content = f.read()
assert "argon2" in content.lower(), \ assert "argon2" in content.lower()
"passlib[argon2] absent de requirements.txt"
else: else:
pytest.skip("requirements.txt non trouvé") pytest.skip("requirements.txt non trouvé")
def test_token_jwt_expire_apres_acces(self): def test_token_jwt_structure_3_parties(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() skip_no_app()
email = f"expire_{int(time.time())}@regwatch.fr" email = f"expire_{int(time.time())}@regwatch.fr"
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"}) client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
token = client.post("/login", json={"email": email, "password": "test123"}).json()["access_token"] 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(".") 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): 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() skip_no_app()
r = client.get("/", headers={"Origin": "http://localhost:5173"}) 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()