Compare commits

...
9 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
19 changed files with 1675 additions and 1 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
+1 -1
View File
@@ -1,4 +1,4 @@
__pycache__/ __pycache__/
*.pyc *.pyc
*.pyo *.pyo
.pytest_cache/ .pytest_cache/
+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"]
View File
+61
View File
@@ -0,0 +1,61 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel, EmailStr
SECRET_KEY = "your-secret-key-change-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
email: Optional[str] = None
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserCreate(BaseModel):
email: EmailStr
password: str
full_name: Optional[str] = None
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)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str) -> Optional[str]:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
email: str = payload.get("sub")
if email is None:
return None
return email
except JWTError:
return None
+296
View File
@@ -0,0 +1,296 @@
from fastapi import BackgroundTasks, FastAPI, Depends, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from database.database import SessionLocal, Base, engine
from database.models import Document, ScraperRun, User
from api.auth import (
UserLogin, UserCreate, Token, UserResponse, RoleUpdate,
get_password_hash, verify_password,
create_access_token, decode_token, ACCESS_TOKEN_EXPIRE_MINUTES
)
Base.metadata.create_all(bind=engine)
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
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("/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=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.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, 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=401, detail="User not found")
if not verify_password(credentials.password, user.password):
raise HTTPException(status_code=401, detail="Invalid password")
access_token = create_access_token(
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:
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=401, detail="Invalid token")
db = SessionLocal()
user = db.query(User).filter(User.email == email).first()
db.close()
if not user:
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,
"title": doc.title,
"ingredient": doc.ingredient,
"source": doc.source,
"type": doc.document_type,
"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
+11
View File
@@ -0,0 +1,11 @@
from database.database import SessionLocal
from database.models import Document
db = SessionLocal()
documents = db.query(Document).all()
print(f"Total documents: {len(documents)}")
for doc in documents[:5]:
print(doc.title)
View File
+21
View File
@@ -0,0 +1,21 @@
from sqlalchemy import create_engine
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 = 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=CONNECT_ARGS
)
SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=engine
)
Base = declarative_base()
+30
View File
@@ -0,0 +1,30 @@
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)
source = Column(String)
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
+27
View File
@@ -0,0 +1,27 @@
from database.database import engine, SessionLocal
from database.models import Base, User
from api.auth import get_password_hash
Base.metadata.create_all(bind=engine)
# Créer un utilisateur de test
db = SessionLocal()
# Vérifier si l'utilisateur existe déjà
existing_user = db.query(User).filter(User.email == "admin@test.com").first()
if not existing_user:
test_user = User(
email="admin@test.com",
password=get_password_hash("password123"),
full_name="Admin Test User"
)
db.add(test_user)
db.commit()
print("Test user created: admin@test.com / password123")
else:
print("Test user already exists")
db.close()
print("Database initialized successfully.")
+12
View File
@@ -0,0 +1,12 @@
fastapi
uvicorn
sqlalchemy
requests
beautifulsoup4
pydantic[email]
ollama
python-jose[cryptography]
passlib[argon2]
python-multipart
pdfplumber
psycopg2-binary
View File
+275
View File
@@ -0,0 +1,275 @@
import sys
import os
import requests
import time
import re
import io
import ollama
import json
import pdfplumber
from bs4 import BeautifulSoup
from urllib.parse import urljoin
sys.path.append(
os.path.abspath(
os.path.join(os.path.dirname(__file__), "..")
)
)
from database.database import SessionLocal, engine
from database.models import Document, Base
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
session = requests.Session()
session.headers.update(headers)
BASE_URL = "https://www.cir-safety.org"
MEETING_NUMBERS = range(115, 175)
PDF_LIMIT = 100
url_templates = [
"https://www.cir-safety.org/meeting/{num}th-expert-panel-meeting",
"https://www.cir-safety.org/meeting/{num}st-expert-panel-meeting",
"https://www.cir-safety.org/meeting/{num}nd-expert-panel-meeting",
"https://www.cir-safety.org/meeting/{num}rd-expert-panel-meeting",
"https://www.cir-safety.org/meeting/{num}th-cir-expert-panel-meeting",
"https://www.cir-safety.org/meeting/{num}st-cir-expert-panel-meeting",
]
PDF_RE = re.compile(r"\.pdf", re.I)
SKIP_KEYWORDS = ["Agenda", "Minutes", "Status Report"]
def extract_pages_text(pdf_bytes: bytes, max_pages: int = 3) -> str:
"""Extrait le texte des 3 premières pages du PDF."""
try:
with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
if not pdf.pages:
return ""
texts = []
for i, page in enumerate(pdf.pages[:max_pages]):
text = page.extract_text() or ""
if text.strip():
texts.append(f"--- PAGE {i+1} ---\n{text}")
return "\n\n".join(texts)[:5000]
except Exception as e:
print(f" ⚠ Erreur extraction PDF: {e}")
return ""
def extract_info_with_ollama(text: str, pdf_url: str) -> dict:
"""
Envoie le texte des premières pages à Ollama
et récupère les infos structurées en JSON.
"""
if not text.strip():
filename = pdf_url.split("/")[-1].replace("_", " ").replace(".pdf", "")
return {
"title": filename,
"ingredient": None,
"document_type": "document",
"date": None,
}
prompt = f"""You are a regulatory document parser specialized in cosmetic safety documents.
Analyze the following text extracted from the first 3 pages of a regulatory document and extract the information below.
Return ONLY a valid JSON object with these exact fields:
{{
"title": "the real and complete official title of the document as it appears in the text",
"ingredient": "the cosmetic ingredient(s) name only, or null if this document is not about a specific ingredient (e.g. it's a general study, methodology paper, meeting report, status report, etc.)",
"document_type": "one of: final report, draft report, tentative report, safety assessment, opinion, strategy, study, meeting report, status report, other",
"date": "year only as a string e.g. '2023', or null if not found"
}}
Important rules:
- title: look carefully in pages 1 and 2 for the REAL title, it is usually the largest or most prominent text. Do NOT use a generic description.
- ingredient: extract ONLY the ingredient name(s). Remove phrases like 'Safety Assessment of', 'Final Report on', 'Opinion on', 'Amended Safety Assessment of'. If the document is a general study, methodology, or administrative document with no specific ingredient, return null.
- document_type: detect from keywords. If you see 'Final''final report', 'Draft''draft report', 'Tentative''tentative report', 'Opinion''opinion', 'Strategy''strategy', 'Study''study'.
- date: find the most recent year mentioned in the document header or footer (between 2000-2030).
- Return ONLY the JSON object, no markdown, no explanation.
Document text (first 3 pages):
{text}"""
try:
response = ollama.chat(
model="llama3.2",
messages=[{"role": "user", "content": prompt}],
options={"temperature": 0}
)
raw = response["message"]["content"].strip()
raw = re.sub(r"```json|```", "", raw).strip()
match = re.search(r"\{.*\}", raw, re.DOTALL)
if match:
raw = match.group(0)
data = json.loads(raw)
ingredient = data.get("ingredient")
if ingredient and str(ingredient).strip().lower() in ("null", "none", "n/a", ""):
ingredient = None
return {
"title": str(data.get("title") or "").strip()[:200] or "Unknown",
"ingredient": str(ingredient).strip()[:200] if ingredient else None,
"document_type": str(data.get("document_type") or "document").strip().lower(),
"date": str(data.get("date")).strip() if data.get("date") else None,
}
except Exception as e:
print(f" ⚠ Ollama error: {e}")
filename = pdf_url.split("/")[-1].replace("_", " ").replace(".pdf", "")
return {
"title": filename,
"ingredient": None,
"document_type": "document",
"date": None,
}
def get_working_url(num):
for template in url_templates:
url = template.format(num=num)
try:
r = session.get(url, timeout=20)
if r.status_code == 200:
return url, r
except Exception:
pass
return None, None
def extract_pdf_urls_from_page(r):
soup = BeautifulSoup(r.text, "html.parser")
all_pdf_links = soup.find_all("a", href=PDF_RE)
print(f"{len(all_pdf_links)} PDF links found")
if not all_pdf_links:
print(" ⚠ No PDF links detected.")
return []
urls = []
seen = set()
for link in all_pdf_links:
href = link.get("href", "")
pdf_url = urljoin(BASE_URL, href)
if pdf_url in seen:
continue
seen.add(pdf_url)
context_el = None
for tag in ("tr", "li", "div", "p"):
context_el = link.find_parent(tag)
if context_el:
break
text = context_el.get_text(" ", strip=True) if context_el else ""
if any(kw in text for kw in SKIP_KEYWORDS):
continue
urls.append(pdf_url)
return urls
def save_documents(docs):
db = SessionLocal()
saved = 0
try:
for d in docs:
exists = db.query(Document).filter(Document.pdf_url == d["pdf_url"]).first()
if not exists:
db.add(Document(**d))
saved += 1
db.commit()
print(f"{saved} new document(s) saved")
except Exception as e:
print(f" DB error: {e}")
db.rollback()
finally:
db.close()
def scrape_cir(limit=PDF_LIMIT):
Base.metadata.create_all(bind=engine)
total = 0
print(f"CIR scraper — limit: {limit} PDFs\n")
for meeting_num in MEETING_NUMBERS:
if total >= limit:
print(f"\n✓ Limit of {limit} reached.")
break
print(f"\n{'='*60}")
print(f"Meeting: {meeting_num} ({total}/{limit})")
url, r = get_working_url(meeting_num)
if not url:
print(" ✗ No page found")
continue
print(f"{url}")
try:
pdf_urls = extract_pdf_urls_from_page(r)
docs_to_save = []
for pdf_url in pdf_urls:
if total >= limit:
break
print(f"\n Downloading: {pdf_url.split('/')[-1]}")
try:
pdf_resp = session.get(pdf_url, timeout=30)
if pdf_resp.status_code != 200:
print(f" ✗ HTTP {pdf_resp.status_code}")
continue
text = extract_pages_text(pdf_resp.content, max_pages=3)
print(f"{len(text)} chars extracted")
info = extract_info_with_ollama(text, pdf_url)
print(f" → Title: {info['title'][:70]}")
print(f" → Ingredient: {info['ingredient'] or '(none — general document)'}")
print(f" → Type: {info['document_type']} | Date: {info['date']}")
docs_to_save.append({
"title": info["title"],
"ingredient": info["ingredient"] or "N/A",
"source": "CIR",
"document_type": info["document_type"],
"meeting_date": info["date"],
"pdf_url": pdf_url,
})
total += 1
time.sleep(1)
except Exception as e:
print(f" ✗ Error processing PDF: {e}")
continue
if docs_to_save:
save_documents(docs_to_save)
time.sleep(2)
except Exception as e:
print(f" ✗ Failed: {e}")
print(f"\n{'='*60}")
print(f"CIR scraping complete: {total}/{limit} PDFs")
if __name__ == "__main__":
scrape_cir()
+271
View File
@@ -0,0 +1,271 @@
import sys
import os
import requests
import time
import re
import io
import ollama
import json
import pdfplumber
from bs4 import BeautifulSoup
from urllib.parse import urljoin
sys.path.append(
os.path.abspath(
os.path.join(os.path.dirname(__file__), "..")
)
)
from database.database import SessionLocal, engine
from database.models import Document, Base
BASE_URL = "https://health.ec.europa.eu"
START_URL = (
"https://health.ec.europa.eu/"
"scientific-committees/"
"scientific-committee-consumer-safety-sccs/"
"sccs-opinions_en"
)
MAX_PDFS = 100
headers = {"User-Agent": "Mozilla/5.0"}
session = requests.Session()
session.headers.update(headers)
def extract_pages_text(pdf_bytes: bytes, max_pages: int = 3) -> str:
"""Extrait le texte des 3 premières pages du PDF."""
try:
with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
if not pdf.pages:
return ""
texts = []
for i, page in enumerate(pdf.pages[:max_pages]):
text = page.extract_text() or ""
if text.strip():
texts.append(f"--- PAGE {i+1} ---\n{text}")
return "\n\n".join(texts)[:5000]
except Exception as e:
print(f" ⚠ Erreur extraction PDF: {e}")
return ""
def extract_info_with_ollama(text: str, pdf_url: str, default_type: str = "opinion") -> dict:
"""
Envoie le texte des premières pages à Ollama
et récupère les infos structurées en JSON.
"""
if not text.strip():
filename = pdf_url.split("/")[-1].replace("_", " ").replace(".pdf", "")
return {
"title": filename,
"ingredient": None,
"document_type": default_type,
"date": None,
}
prompt = f"""You are a regulatory document parser specialized in cosmetic safety documents.
Analyze the following text extracted from the first 3 pages of a regulatory document and extract the information below.
Return ONLY a valid JSON object with these exact fields:
{{
"title": "the real and complete official title of the document as it appears in the text",
"ingredient": "the cosmetic ingredient(s) name only, or null if this document is not about a specific ingredient (e.g. it's a general study, methodology paper, meeting report, status report, etc.)",
"document_type": "one of: final report, draft report, tentative report, safety assessment, opinion, strategy, study, meeting report, status report, other",
"date": "year only as a string e.g. '2023', or null if not found"
}}
Important rules:
- title: look carefully in pages 1 and 2 for the REAL title, it is usually the largest or most prominent text. Do NOT use a generic description.
- ingredient: extract ONLY the ingredient name(s). Remove phrases like 'Safety Assessment of', 'Final Report on', 'Opinion on', 'Amended Safety Assessment of'. If the document is a general study, methodology, or administrative document with no specific ingredient, return null.
- document_type: detect from keywords. If you see 'Final''final report', 'Draft''draft report', 'Tentative''tentative report', 'Opinion''opinion', 'Strategy''strategy', 'Study''study'.
- date: find the most recent year mentioned in the document header or footer (between 2000-2030).
- Return ONLY the JSON object, no markdown, no explanation.
Document text (first 3 pages):
{text}"""
try:
response = ollama.chat(
model="llama3.2",
messages=[{"role": "user", "content": prompt}],
options={"temperature": 0}
)
raw = response["message"]["content"].strip()
raw = re.sub(r"```json|```", "", raw).strip()
match = re.search(r"\{.*\}", raw, re.DOTALL)
if match:
raw = match.group(0)
data = json.loads(raw)
ingredient = data.get("ingredient")
if ingredient and str(ingredient).strip().lower() in ("null", "none", "n/a", ""):
ingredient = None
return {
"title": str(data.get("title") or "").strip()[:200] or "Unknown",
"ingredient": str(ingredient).strip()[:200] if ingredient else None,
"document_type": str(data.get("document_type") or default_type).strip().lower(),
"date": str(data.get("date")).strip() if data.get("date") else None,
}
except Exception as e:
print(f" ⚠ Ollama error: {e}")
filename = pdf_url.split("/")[-1].replace("_", " ").replace(".pdf", "")
return {
"title": filename,
"ingredient": None,
"document_type": default_type,
"date": None,
}
def get_soup(url):
try:
r = session.get(url, timeout=25)
if r.status_code == 429:
print("429 → waiting 60s")
time.sleep(60)
r = session.get(url, timeout=25)
r.raise_for_status()
return BeautifulSoup(r.text, "html.parser")
except Exception as e:
print(e)
return None
def save_document(data: dict) -> bool:
db = SessionLocal()
try:
exists = db.query(Document).filter(Document.pdf_url == data["pdf_url"]).first()
if exists:
return False
db.add(Document(**data))
db.commit()
return True
except Exception as e:
print(f" DB error: {e}")
db.rollback()
return False
finally:
db.close()
def process_pdf(pdf_url: str) -> dict | None:
print(f"\n Downloading: {pdf_url.split('/')[-1]}")
try:
r = session.get(pdf_url, timeout=30)
if r.status_code != 200:
print(f" ✗ HTTP {r.status_code}")
return None
text = extract_pages_text(r.content, max_pages=3)
print(f"{len(text)} chars extracted")
info = extract_info_with_ollama(text, pdf_url, default_type="opinion")
print(f" → Title: {info['title'][:70]}")
print(f" → Ingredient: {info['ingredient'] or '(none — general document)'}")
print(f" → Type: {info['document_type']} | Date: {info['date']}")
return {
"title": info["title"],
"ingredient": info["ingredient"] or "N/A",
"source": "SCCS",
"document_type": info["document_type"],
"meeting_date": info["date"],
"pdf_url": pdf_url,
}
except Exception as e:
print(f" ✗ Error: {e}")
return None
def process_detail_page(url: str, saved_count: int, limit: int) -> int:
if saved_count >= limit:
return 0
time.sleep(2)
print(f"\n Page: {url.split('/')[-1]}")
soup = get_soup(url)
if not soup:
return 0
added = 0
excluded = ["draft", "preliminary", "summary", "factsheet", "infographic"]
for a in soup.find_all("a", href=re.compile(r"\.pdf$", re.I)):
if saved_count + added >= limit:
break
link_text = a.get_text(strip=True).lower()
if any(x in link_text for x in excluded):
continue
pdf_url = urljoin(BASE_URL, a["href"])
doc = process_pdf(pdf_url)
if doc:
if save_document(doc):
added += 1
print(f" ✓ [{saved_count + added}/{limit}] saved")
time.sleep(1)
return added
def scrape_sccs(limit=MAX_PDFS):
Base.metadata.create_all(bind=engine)
saved_count = 0
urls_to_visit = [START_URL]
visited = set()
print(f"SCCS scraper — limit: {limit} PDFs\n")
while urls_to_visit:
if saved_count >= limit:
print(f"\n✓ Limit of {limit} reached.")
break
current = urls_to_visit.pop(0)
if current in visited:
continue
visited.add(current)
print(f"\n{'='*60}")
print(f"Exploring: {current.split('/')[-1]} ({saved_count}/{limit})")
soup = get_soup(current)
if not soup:
continue
for a in soup.find_all("a", href=True):
if saved_count >= limit:
break
href = a["href"].lower()
if "opinions" in href or "sccs_o_" in href:
full_url = urljoin(BASE_URL, a["href"])
if "_en" in full_url and not full_url.endswith(".pdf"):
if any(x in full_url for x in ["2016-2021", "2013-2016", "2009-2012"]):
if full_url not in urls_to_visit:
urls_to_visit.append(full_url)
else:
added = process_detail_page(full_url, saved_count, limit)
saved_count += added
print(f"\n{'='*60}")
print(f"SCCS scraping complete: {saved_count}/{limit} PDFs")
if __name__ == "__main__":
scrape_sccs()
View File
+603
View File
@@ -0,0 +1,603 @@
"""
tests/test_regwatch_complet.py
==========================================================
Tests complets RegWatch — conformément au guide Nexa DBI
Mis à jour pour correspondre au vrai code main.py + auth.py
Couvre :
- Tests unitaires (scraper CIR)
- Tests d'intégration (endpoints API)
- Tests de sécurité (injection SQL, JWT, XSS)
- Conformité RGPD (mdp haché, données non exposées)
- Tests endpoints admin
==========================================================
"""
import pytest
import sys
import os
import time
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
# ── Import app FastAPI ────────────────────────────────────────
try:
from fastapi.testclient import TestClient
from api.main import app
client = TestClient(app)
APP_OK = True
except Exception as e:
APP_OK = False
def skip_no_app():
if not APP_OK:
pytest.skip("App / BDD non disponible")
# ══════════════════════════════════════════════════════════════
# PARTIE 1 — TESTS UNITAIRES (scraper CIR)
# ══════════════════════════════════════════════════════════════
class TestUnitairesExtractPagesText:
"""Tests unitaires de extract_pages_text() — cir_scraper.py"""
def test_bytes_vide_retourne_chaine_vide(self):
from scrapers.cir_scraper import extract_pages_text
assert extract_pages_text(b"") == ""
def test_pdf_invalide_retourne_chaine_vide(self):
from scrapers.cir_scraper import extract_pages_text
assert extract_pages_text(b"ceci n est pas un pdf") == ""
def test_retour_toujours_une_str(self):
from scrapers.cir_scraper import extract_pages_text
result = extract_pages_text(b"")
assert isinstance(result, str)
def test_limite_5000_caracteres(self):
long_text = "A" * 10000
assert len(long_text[:5000]) == 5000
class TestUnitairesFallbackOllama:
"""Tests unitaires du fallback Ollama"""
def test_fallback_utilise_nom_fichier(self):
pdf_url = "https://cir-safety.org/sites/files/Retinol_2024.pdf"
filename = pdf_url.split("/")[-1].replace("_", " ").replace(".pdf", "")
assert filename == "Retinol 2024"
def test_fallback_retourne_4_cles(self):
fallback = {
"title": "Mon Document",
"ingredient": None,
"document_type": "document",
"date": None,
}
assert all(k in fallback for k in ["title", "ingredient", "document_type", "date"])
assert fallback["ingredient"] is None
def test_nettoyage_ingredient_null_string(self):
for val in ["null", "none", "n/a", "", "NULL", "None"]:
result = None if str(val).strip().lower() in ("null", "none", "n/a", "") else val
assert result is None
def test_titre_tronque_200_chars(self):
long = "A" * 300
assert len(str(long).strip()[:200]) == 200
class TestUnitairesTemplatesURL:
"""Tests unitaires des templates d'URL CIR"""
def test_tous_les_templates_contiennent_num(self):
from scrapers.cir_scraper import url_templates
for t in url_templates:
assert "{num}" in t
def test_tous_les_templates_sont_https(self):
from scrapers.cir_scraper import url_templates
for t in url_templates:
assert t.startswith("https://")
def test_format_url_remplace_num(self):
from scrapers.cir_scraper import url_templates
url = url_templates[0].format(num=150)
assert "150" in url
assert "{num}" not in url
def test_meeting_numbers_couvre_115_a_174(self):
from scrapers.cir_scraper import MEETING_NUMBERS
nums = list(MEETING_NUMBERS)
assert nums[0] == 115
assert nums[-1] == 174
assert len(nums) == 60
def test_pdf_limit_est_100(self):
from scrapers.cir_scraper import PDF_LIMIT
assert PDF_LIMIT == 100
class TestUnitairesFiltragePDF:
"""Tests unitaires du filtrage des liens PDF"""
def test_skip_keywords_contient_les_3_valeurs(self):
from scrapers.cir_scraper import SKIP_KEYWORDS
assert "Agenda" in SKIP_KEYWORDS
assert "Minutes" in SKIP_KEYWORDS
assert "Status Report" in SKIP_KEYWORDS
def test_regex_pdf_detecte_extension_pdf(self):
from scrapers.cir_scraper import PDF_RE
assert PDF_RE.search("document.pdf")
assert PDF_RE.search("document.PDF")
assert PDF_RE.search("rapport.Pdf")
def test_regex_pdf_ne_detecte_pas_autres(self):
from scrapers.cir_scraper import PDF_RE
assert not PDF_RE.search("document.docx")
assert not PDF_RE.search("image.png")
def test_agenda_est_filtre(self):
from scrapers.cir_scraper import SKIP_KEYWORDS
context = "116th Expert Panel Meeting Agenda"
assert any(kw in context for kw in SKIP_KEYWORDS)
def test_final_report_non_filtre(self):
from scrapers.cir_scraper import SKIP_KEYWORDS
context = "Final Report on the Safety Assessment of Retinol"
assert not any(kw in context for kw in SKIP_KEYWORDS)
class TestUnitairesDeduplication:
"""Tests unitaires de la logique de déduplication"""
def test_nouvelle_url_acceptee(self):
existing = {"https://cir.org/doc1.pdf"}
assert "https://cir.org/doc3.pdf" not in existing
def test_url_existante_rejetee(self):
existing = {"https://cir.org/doc1.pdf"}
assert "https://cir.org/doc1.pdf" in existing
def test_structure_doc_6_champs(self):
doc = {
"title": "Final Report on Retinol",
"ingredient": "Retinol",
"source": "CIR",
"document_type": "final report",
"meeting_date": "2024",
"pdf_url": "https://cir.org/retinol_2024.pdf",
}
for champ in ["title", "ingredient", "source", "document_type", "meeting_date", "pdf_url"]:
assert champ in doc
def test_source_est_cir(self):
assert "CIR" == "CIR"
class TestUnitairesHeadersHTTP:
"""Tests unitaires des headers HTTP anti-blocage"""
def test_user_agent_simule_chrome(self):
from scrapers.cir_scraper import headers
assert "User-Agent" in headers
assert "Mozilla" in headers["User-Agent"]
assert "Chrome" in headers["User-Agent"]
def test_session_utilise_les_headers(self):
from scrapers.cir_scraper import session
assert "User-Agent" in session.headers
assert "Mozilla" in session.headers["User-Agent"]
# ══════════════════════════════════════════════════════════════
# PARTIE 2 — TESTS D'INTÉGRATION (endpoints FastAPI)
# ══════════════════════════════════════════════════════════════
class TestIntegrationRacine:
"""Tests de GET /"""
def test_racine_200(self):
skip_no_app()
assert client.get("/").status_code == 200
def test_racine_retourne_message_running(self):
skip_no_app()
data = client.get("/").json()
assert "message" in data
assert "running" in data["message"].lower() or "RegWatch" in data["message"]
def test_racine_retourne_json(self):
skip_no_app()
r = client.get("/")
assert r.headers["content-type"].startswith("application/json")
class TestIntegrationSignup:
"""Tests de POST /signup"""
def test_email_invalide_422(self):
skip_no_app()
r = client.post("/signup", json={"email": "pasunemail", "password": "test", "full_name": "T"})
assert r.status_code == 422
def test_champs_manquants_422(self):
skip_no_app()
r = client.post("/signup", json={"email": "test@test.com"})
assert r.status_code == 422
def test_email_deja_existant_400(self):
skip_no_app()
email = f"doublon_{int(time.time())}@regwatch.fr"
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
r = client.post("/signup", json={"email": email, "password": "autre", "full_name": "T"})
assert r.status_code == 400
def test_signup_reussi_retourne_id_email_role(self):
"""UserResponse retourne maintenant id, email, full_name ET role"""
skip_no_app()
email = f"ok_{int(time.time())}@regwatch.fr"
r = client.post("/signup", json={"email": email, "password": "test123", "full_name": "OK"})
assert r.status_code == 200
data = r.json()
assert "id" in data
assert data["email"] == email
assert "role" in data
assert data["role"] == "user" # role par défaut
class TestIntegrationLogin:
"""Tests de POST /login"""
def test_email_inexistant_401(self):
skip_no_app()
r = client.post("/login", json={"email": "xyz_inconnu@regwatch.fr", "password": "test"})
assert r.status_code == 401
assert "not found" in r.json()["detail"].lower()
def test_mauvais_mot_de_passe_401(self):
skip_no_app()
email = f"mdp_{int(time.time())}@regwatch.fr"
client.post("/signup", json={"email": email, "password": "bonmdp", "full_name": "T"})
r = client.post("/login", json={"email": email, "password": "mauvaismdp"})
assert r.status_code == 401
def test_login_reussi_retourne_token_bearer(self):
skip_no_app()
email = f"jwt_{int(time.time())}@regwatch.fr"
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
r = client.post("/login", json={"email": email, "password": "test123"})
assert r.status_code == 200
data = r.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
assert len(data["access_token"]) > 10
def test_corps_vide_422(self):
skip_no_app()
assert client.post("/login", json={}).status_code == 422
class TestIntegrationMe:
"""Tests de GET /me?token=..."""
def test_sans_token_422(self):
skip_no_app()
assert client.get("/me").status_code == 422
def test_token_invalide_401(self):
skip_no_app()
r = client.get("/me?token=token_completement_faux")
assert r.status_code == 401
def test_token_valide_retourne_user_avec_role(self):
"""GET /me retourne maintenant le role dans UserResponse"""
skip_no_app()
email = f"me_{int(time.time())}@regwatch.fr"
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
token = client.post("/login", json={"email": email, "password": "test123"}).json()["access_token"]
r = client.get(f"/me?token={token}")
assert r.status_code == 200
data = r.json()
assert data["email"] == email
assert "role" in data
assert data["role"] == "user"
class TestIntegrationDocuments:
"""Tests de GET /documents"""
def test_sans_auth_retourne_200(self):
"""GET /documents ne requiert pas de JWT"""
skip_no_app()
assert client.get("/documents").status_code == 200
def test_retourne_liste_json(self):
skip_no_app()
assert isinstance(client.get("/documents").json(), list)
def test_structure_7_champs_par_document(self):
"""Chaque doc a id, title, ingredient, source, type, date, pdf_url"""
skip_no_app()
docs = client.get("/documents").json()
if docs:
for champ in ["id", "title", "ingredient", "source", "type", "date", "pdf_url"]:
assert champ in docs[0], f"Champ manquant : {champ}"
def test_source_cir_ou_sccs(self):
skip_no_app()
docs = client.get("/documents").json()
for doc in docs:
assert doc["source"] in ["CIR", "SCCS"]
# ══════════════════════════════════════════════════════════════
# PARTIE 3 — TESTS SÉCURITÉ
# ══════════════════════════════════════════════════════════════
class TestSecuriteInjectionSQL:
"""Tests injection SQL — SQLAlchemy ORM protège nativement"""
def test_injection_sql_dans_email_login(self):
skip_no_app()
r = client.post("/login", json={"email": "' OR 1=1 --", "password": "test"})
assert r.status_code in [401, 422]
def test_injection_sql_dans_email_signup(self):
skip_no_app()
r = client.post("/signup", json={
"email": "'; DROP TABLE users; --",
"password": "test123",
"full_name": "Hacker"
})
assert r.status_code in [400, 422]
def test_injection_sql_guillemets_doubles(self):
skip_no_app()
r = client.post("/login", json={"email": "\" OR \"1\"=\"1", "password": "test"})
assert r.status_code != 500
class TestSecuriteJWT:
"""Tests sécurité JWT"""
def test_token_falsifie_retourne_401(self):
skip_no_app()
r = client.get("/me?token=eyJhbGciOiJIUzI1NiJ9.faux.mauvaise_signature")
assert r.status_code == 401
def test_token_vide_retourne_401_ou_422(self):
skip_no_app()
assert client.get("/me?token=").status_code in [401, 422]
def test_token_malformed_retourne_401(self):
skip_no_app()
assert client.get("/me?token=cecinestunepasjwt").status_code == 401
def test_token_valide_sur_bon_endpoint(self):
skip_no_app()
email = f"jwt_ok_{int(time.time())}@regwatch.fr"
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
token = client.post("/login", json={"email": email, "password": "test123"}).json()["access_token"]
assert client.get(f"/me?token={token}").status_code == 200
class TestSecuriteXSS:
"""Tests XSS"""
def test_xss_dans_full_name_stocke_sans_crash(self):
skip_no_app()
email = f"xss_{int(time.time())}@regwatch.fr"
r = client.post("/signup", json={
"email": email,
"password": "test123",
"full_name": "<script>alert('xss')</script>"
})
assert r.status_code in [200, 422]
def test_xss_dans_email_ne_plante_pas(self):
skip_no_app()
r = client.post("/login", json={
"email": "<script>alert(1)</script>@test.com",
"password": "test"
})
assert r.status_code in [401, 422]
# ══════════════════════════════════════════════════════════════
# PARTIE 4 — CONFORMITÉ RGPD
# ══════════════════════════════════════════════════════════════
class TestRGPD:
"""Tests conformité RGPD"""
def test_mot_de_passe_absent_de_la_reponse_signup(self):
skip_no_app()
email = f"rgpd_{int(time.time())}@regwatch.fr"
r = client.post("/signup", json={
"email": email,
"password": "mon_super_secret_123",
"full_name": "RGPD Test"
})
assert r.status_code == 200
assert "mon_super_secret_123" not in r.text
assert "password" not in r.json()
def test_mot_de_passe_absent_de_la_reponse_login(self):
skip_no_app()
email = f"rgpd2_{int(time.time())}@regwatch.fr"
client.post("/signup", json={"email": email, "password": "secret456", "full_name": "T"})
r = client.post("/login", json={"email": email, "password": "secret456"})
assert r.status_code == 200
assert "secret456" not in r.text
assert "password" not in r.json()
def test_mot_de_passe_absent_de_la_reponse_me(self):
skip_no_app()
email = f"rgpd3_{int(time.time())}@regwatch.fr"
client.post("/signup", json={"email": email, "password": "secret789", "full_name": "T"})
token = client.post("/login", json={"email": email, "password": "secret789"}).json()["access_token"]
r = client.get(f"/me?token={token}")
assert r.status_code == 200
assert "secret789" not in r.text
assert "password" not in r.json()
def test_hachage_argon2_dans_requirements(self):
req_path = os.path.join(os.path.dirname(__file__), "..", "requirements.txt")
if os.path.exists(req_path):
with open(req_path) as f:
content = f.read()
assert "argon2" in content.lower()
else:
pytest.skip("requirements.txt non trouvé")
def test_token_jwt_structure_3_parties(self):
skip_no_app()
email = f"expire_{int(time.time())}@regwatch.fr"
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
token = client.post("/login", json={"email": email, "password": "test123"}).json()["access_token"]
parts = token.split(".")
assert len(parts) == 3
def test_cors_autorise_frontend(self):
skip_no_app()
r = client.get("/", headers={"Origin": "http://localhost:5173"})
assert r.status_code == 200
# ══════════════════════════════════════════════════════════════
# PARTIE 5 — TESTS ENDPOINTS ADMIN
# ══════════════════════════════════════════════════════════════
def get_admin_token():
"""Crée un compte admin et retourne son token"""
master_email = "master_admin@regwatch.fr"
master_password = "master123"
# Essai de login master
login_r = client.post("/login", json={"email": master_email, "password": master_password})
if login_r.status_code != 200:
# Crée le master
signup_r = client.post("/signup", json={
"email": master_email,
"password": master_password,
"full_name": "Master Admin"
})
print(f"\n[DEBUG] signup master: {signup_r.status_code} {signup_r.json()}")
token_r = client.post("/login", json={"email": master_email, "password": master_password})
print(f"[DEBUG] login master: {token_r.status_code} {token_r.json()}")
master_token = token_r.json()["access_token"]
# Se promeut lui-même (aucun admin n'existe encore)
promote_r = client.post(f"/admin/make-admin?token={master_token}&target_email={master_email}")
print(f"[DEBUG] promote master: {promote_r.status_code} {promote_r.json()}")
# Re-login
master_token = client.post("/login", json={
"email": master_email, "password": master_password
}).json()["access_token"]
else:
master_token = login_r.json()["access_token"]
print(f"\n[DEBUG] master déjà existant, token ok")
# Vérifie que master est bien admin
stats_r = client.get(f"/admin/stats?token={master_token}")
print(f"[DEBUG] stats avec master token: {stats_r.status_code} {stats_r.json()}")
return master_token, master_email
class TestAdminStats:
"""Tests de GET /admin/stats"""
def test_sans_token_401(self):
skip_no_app()
r = client.get("/admin/stats?token=faux")
assert r.status_code in [401, 403]
def test_avec_token_user_403(self):
skip_no_app()
email = f"user_{int(time.time())}@regwatch.fr"
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
token = client.post("/login", json={"email": email, "password": "test123"}).json()["access_token"]
r = client.get(f"/admin/stats?token={token}")
assert r.status_code == 403
def test_avec_token_admin_retourne_stats(self):
skip_no_app()
token, _ = get_admin_token()
r = client.get(f"/admin/stats?token={token}")
assert r.status_code == 200
data = r.json()
assert "total_documents" in data
assert "cir_documents" in data
assert "sccs_documents" in data
assert "total_users" in data
class TestAdminUsers:
"""Tests de GET /admin/users et DELETE /admin/users/{id}"""
def test_get_users_sans_admin_403(self):
skip_no_app()
email = f"user_{int(time.time())}@regwatch.fr"
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
token = client.post("/login", json={"email": email, "password": "test123"}).json()["access_token"]
r = client.get(f"/admin/users?token={token}")
assert r.status_code == 403
def test_get_users_avec_admin_retourne_liste(self):
skip_no_app()
token, _ = get_admin_token()
r = client.get(f"/admin/users?token={token}")
assert r.status_code == 200
assert isinstance(r.json(), list)
def test_delete_user_inexistant_404(self):
skip_no_app()
token, _ = get_admin_token()
r = client.delete(f"/admin/users/999999?token={token}")
assert r.status_code == 404
def test_patch_role_user_inexistant_404(self):
skip_no_app()
token, _ = get_admin_token()
r = client.patch(f"/admin/users/999999/role?token={token}", json={"role": "admin"})
assert r.status_code == 404
class TestAdminDocuments:
"""Tests de DELETE /admin/documents/{id}"""
def test_delete_document_sans_admin_403(self):
skip_no_app()
email = f"user_{int(time.time())}@regwatch.fr"
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
token = client.post("/login", json={"email": email, "password": "test123"}).json()["access_token"]
r = client.delete(f"/admin/documents/1?token={token}")
assert r.status_code == 403
def test_delete_document_inexistant_404(self):
skip_no_app()
token, _ = get_admin_token()
r = client.delete(f"/admin/documents/999999?token={token}")
assert r.status_code == 404
class TestAdminMakeAdmin:
"""Tests de POST /admin/make-admin"""
def test_make_admin_utilisateur_inexistant_404(self):
skip_no_app()
token, _ = get_admin_token()
r = client.post(f"/admin/make-admin?token={token}&target_email=inexistant@regwatch.fr")
assert r.status_code == 404
def test_make_admin_retourne_message(self):
skip_no_app()
token, _ = get_admin_token()
email = f"futuradmin_{int(time.time())}@regwatch.fr"
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
r = client.post(f"/admin/make-admin?token={token}&target_email={email}")
assert r.status_code == 200
assert "admin" in r.json()["message"].lower()