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__/
*.pyc
*.pyo
venv/
.venv/
*.db
.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
email: str
full_name: Optional[str] = None
role: str = "user" # ← nouveau
class RoleUpdate(BaseModel):
role: str
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
@@ -44,8 +48,7 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str) -> Optional[str]:
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 datetime import timedelta
from database.database import SessionLocal
from database.models import Document, User
from sqlalchemy import text
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from database.database import SessionLocal, Base, engine
from database.models import Document, ScraperRun, User
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
)
Base.metadata.create_all(bind=engine)
app = FastAPI()
app.add_middleware(
@@ -18,113 +24,74 @@ app.add_middleware(
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("/")
def root():
return {"message": "RegWatch API running"}
@app.post("/test-login")
def test_login(credentials: UserLogin):
return {"received": credentials.dict()}
@app.post("/signup", response_model=UserResponse)
def signup(user: UserCreate):
db = SessionLocal()
existing_user = db.query(User).filter(User.email == user.email).first()
if existing_user:
db.close()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
raise HTTPException(status_code=400, detail="Email already registered")
hashed_password = get_password_hash(user.password)
db_user = User(
email=user.email,
password=hashed_password,
full_name=user.full_name
)
db_user = User(email=user.email, password=hashed_password, full_name=user.full_name)
db.add(db_user)
db.commit()
db.refresh(db_user)
db.close()
return UserResponse(
id=db_user.id,
email=db_user.email,
full_name=db_user.full_name
)
return UserResponse(id=db_user.id, email=db_user.email, full_name=db_user.full_name, role=db_user.role)
@app.post("/login", response_model=Token)
def login(credentials: UserLogin):
try:
db = SessionLocal()
user = db.query(User).filter(User.email == credentials.email).first()
db.close()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
raise HTTPException(status_code=401, detail="User not found")
if not verify_password(credentials.password, user.password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid password"
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
raise HTTPException(status_code=401, detail="Invalid password")
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")
except HTTPException:
raise
except Exception as e:
print(f"ERROR in login: {str(e)}")
import traceback
traceback.print_exc()
raise HTTPException(
status_code=500,
detail=f"Server error: {str(e)}"
)
raise HTTPException(status_code=500, detail=f"Server error: {str(e)}")
@app.get("/me", response_model=UserResponse)
def get_current_user(token: str):
email = decode_token(token)
if not email:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
raise HTTPException(status_code=401, detail="Invalid token")
db = SessionLocal()
user = db.query(User).filter(User.email == email).first()
db.close()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return UserResponse(
id=user.id,
email=user.email,
full_name=user.full_name
)
raise HTTPException(status_code=404, detail="User not found")
return UserResponse(id=user.id, email=user.email, full_name=user.full_name, role=user.role)
@app.get("/documents")
def get_documents():
db = SessionLocal()
documents = db.query(Document).all()
result = []
for doc in documents:
result.append({
"id": doc.id,
@@ -135,7 +102,195 @@ def get_documents():
"date": doc.meeting_date,
"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()
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
@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
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(
DATABASE_URL,
connect_args={"check_same_thread": False}
connect_args=CONNECT_ARGS
)
SessionLocal = sessionmaker(
+12 -3
View File
@@ -1,17 +1,16 @@
from sqlalchemy import Column, Integer, String
from sqlalchemy import Column, DateTime, Integer, String
from database.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True)
password = Column(String)
full_name = Column(String, nullable=True)
role = Column(String, default="user") # ← nouveau
class Document(Base):
__tablename__ = "documents"
id = Column(Integer, primary_key=True, index=True)
title = Column(String)
ingredient = Column(String)
@@ -19,3 +18,13 @@ class Document(Base):
document_type = Column(String)
meeting_date = Column(String, nullable=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]
python-multipart
pdfplumber
psycopg2-binary
-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)
+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 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
# ══════════════════════════════════════════════════════════════
# 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()