Compare commits
11
Commits
4080a308ea
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
272d14d623 | ||
|
|
742469b032 | ||
|
|
f8c8e16e7e | ||
|
|
97983ae5ba | ||
|
|
b780dce8c3 | ||
|
|
1e811c0b43 | ||
|
|
ad84286259 | ||
|
|
c4aeedd032 | ||
|
|
1f7d06c26d | ||
|
|
8df04dd679 | ||
|
|
36e211a524 |
@@ -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
@@ -1,11 +1,11 @@
|
|||||||
@"
|
|
||||||
.env
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
venv/
|
|
||||||
.venv/
|
|
||||||
*.db
|
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
htmlcov/
|
*.db
|
||||||
"@
|
venv/
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|||||||
+12
@@ -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
@@ -30,6 +30,10 @@ class UserResponse(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
email: str
|
email: str
|
||||||
full_name: Optional[str] = None
|
full_name: Optional[str] = None
|
||||||
|
role: str = "user" # ← nouveau
|
||||||
|
|
||||||
|
class RoleUpdate(BaseModel):
|
||||||
|
role: str
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
@@ -44,8 +48,7 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
|||||||
else:
|
else:
|
||||||
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||||
to_encode.update({"exp": expire})
|
to_encode.update({"exp": expire})
|
||||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
return encoded_jwt
|
|
||||||
|
|
||||||
def decode_token(token: str) -> Optional[str]:
|
def decode_token(token: str) -> Optional[str]:
|
||||||
try:
|
try:
|
||||||
|
|||||||
+222
-67
@@ -1,13 +1,19 @@
|
|||||||
from fastapi import FastAPI, Depends, HTTPException, status
|
from fastapi import BackgroundTasks, FastAPI, Depends, HTTPException, status
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from datetime import timedelta
|
from sqlalchemy import text
|
||||||
from database.database import SessionLocal
|
from datetime import datetime, timedelta, timezone
|
||||||
from database.models import Document, User
|
from zoneinfo import ZoneInfo
|
||||||
|
from database.database import SessionLocal, Base, engine
|
||||||
|
from database.models import Document, ScraperRun, User
|
||||||
|
|
||||||
from api.auth import (
|
from api.auth import (
|
||||||
UserLogin, UserCreate, Token, UserResponse, get_password_hash, verify_password,
|
UserLogin, UserCreate, Token, UserResponse, RoleUpdate,
|
||||||
|
get_password_hash, verify_password,
|
||||||
create_access_token, decode_token, ACCESS_TOKEN_EXPIRE_MINUTES
|
create_access_token, decode_token, ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -18,113 +24,74 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def require_admin(token: str):
|
||||||
|
email = decode_token(token)
|
||||||
|
if not email:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
db = SessionLocal()
|
||||||
|
user = db.query(User).filter(User.email == email).first()
|
||||||
|
db.close()
|
||||||
|
if not user or user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Accès refusé")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def root():
|
def root():
|
||||||
return {"message": "RegWatch API running"}
|
return {"message": "RegWatch API running"}
|
||||||
|
|
||||||
@app.post("/test-login")
|
|
||||||
def test_login(credentials: UserLogin):
|
|
||||||
return {"received": credentials.dict()}
|
|
||||||
|
|
||||||
@app.post("/signup", response_model=UserResponse)
|
@app.post("/signup", response_model=UserResponse)
|
||||||
def signup(user: UserCreate):
|
def signup(user: UserCreate):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|
||||||
existing_user = db.query(User).filter(User.email == user.email).first()
|
existing_user = db.query(User).filter(User.email == user.email).first()
|
||||||
if existing_user:
|
if existing_user:
|
||||||
db.close()
|
db.close()
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail="Email already registered")
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Email already registered"
|
|
||||||
)
|
|
||||||
|
|
||||||
hashed_password = get_password_hash(user.password)
|
hashed_password = get_password_hash(user.password)
|
||||||
db_user = User(
|
db_user = User(email=user.email, password=hashed_password, full_name=user.full_name)
|
||||||
email=user.email,
|
|
||||||
password=hashed_password,
|
|
||||||
full_name=user.full_name
|
|
||||||
)
|
|
||||||
db.add(db_user)
|
db.add(db_user)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_user)
|
db.refresh(db_user)
|
||||||
db.close()
|
db.close()
|
||||||
|
return UserResponse(id=db_user.id, email=db_user.email, full_name=db_user.full_name, role=db_user.role)
|
||||||
return UserResponse(
|
|
||||||
id=db_user.id,
|
|
||||||
email=db_user.email,
|
|
||||||
full_name=db_user.full_name
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.post("/login", response_model=Token)
|
@app.post("/login", response_model=Token)
|
||||||
def login(credentials: UserLogin):
|
def login(credentials: UserLogin):
|
||||||
try:
|
try:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|
||||||
user = db.query(User).filter(User.email == credentials.email).first()
|
user = db.query(User).filter(User.email == credentials.email).first()
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=401, detail="User not found")
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="User not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not verify_password(credentials.password, user.password):
|
if not verify_password(credentials.password, user.password):
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=401, detail="Invalid password")
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid password"
|
|
||||||
)
|
|
||||||
|
|
||||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
||||||
access_token = create_access_token(
|
access_token = create_access_token(
|
||||||
data={"sub": user.email}, expires_delta=access_token_expires
|
data={"sub": user.email},
|
||||||
|
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
)
|
)
|
||||||
|
|
||||||
return Token(access_token=access_token, token_type="bearer")
|
return Token(access_token=access_token, token_type="bearer")
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"ERROR in login: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Server error: {str(e)}")
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Server error: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/me", response_model=UserResponse)
|
@app.get("/me", response_model=UserResponse)
|
||||||
def get_current_user(token: str):
|
def get_current_user(token: str):
|
||||||
email = decode_token(token)
|
email = decode_token(token)
|
||||||
if not email:
|
if not email:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid token"
|
|
||||||
)
|
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
user = db.query(User).filter(User.email == email).first()
|
user = db.query(User).filter(User.email == email).first()
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
return UserResponse(id=user.id, email=user.email, full_name=user.full_name, role=user.role)
|
||||||
detail="User not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
return UserResponse(
|
|
||||||
id=user.id,
|
|
||||||
email=user.email,
|
|
||||||
full_name=user.full_name
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/documents")
|
@app.get("/documents")
|
||||||
def get_documents():
|
def get_documents():
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|
||||||
documents = db.query(Document).all()
|
documents = db.query(Document).all()
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
|
|
||||||
for doc in documents:
|
for doc in documents:
|
||||||
result.append({
|
result.append({
|
||||||
"id": doc.id,
|
"id": doc.id,
|
||||||
@@ -135,7 +102,195 @@ def get_documents():
|
|||||||
"date": doc.meeting_date,
|
"date": doc.meeting_date,
|
||||||
"pdf_url": doc.pdf_url
|
"pdf_url": doc.pdf_url
|
||||||
})
|
})
|
||||||
|
db.close()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/migrate-add-role")
|
||||||
|
def migrate_add_role():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR DEFAULT 'user'"))
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Colonne role ajoutée avec succès"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"message": str(e)}
|
||||||
|
finally:
|
||||||
db.close()
|
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
|
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
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ from sqlalchemy.orm import sessionmaker, declarative_base
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "regwatch.db")
|
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "regwatch.db")
|
||||||
DATABASE_URL = f"sqlite:///{DB_PATH}"
|
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DB_PATH}")
|
||||||
|
|
||||||
|
CONNECT_ARGS = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
|
||||||
|
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
DATABASE_URL,
|
DATABASE_URL,
|
||||||
connect_args={"check_same_thread": False}
|
connect_args=CONNECT_ARGS
|
||||||
)
|
)
|
||||||
|
|
||||||
SessionLocal = sessionmaker(
|
SessionLocal = sessionmaker(
|
||||||
|
|||||||
+12
-3
@@ -1,17 +1,16 @@
|
|||||||
from sqlalchemy import Column, Integer, String
|
from sqlalchemy import Column, DateTime, Integer, String
|
||||||
from database.database import Base
|
from database.database import Base
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
email = Column(String, unique=True, index=True)
|
email = Column(String, unique=True, index=True)
|
||||||
password = Column(String)
|
password = Column(String)
|
||||||
full_name = Column(String, nullable=True)
|
full_name = Column(String, nullable=True)
|
||||||
|
role = Column(String, default="user") # ← nouveau
|
||||||
|
|
||||||
class Document(Base):
|
class Document(Base):
|
||||||
__tablename__ = "documents"
|
__tablename__ = "documents"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
title = Column(String)
|
title = Column(String)
|
||||||
ingredient = Column(String)
|
ingredient = Column(String)
|
||||||
@@ -19,3 +18,13 @@ class Document(Base):
|
|||||||
document_type = Column(String)
|
document_type = Column(String)
|
||||||
meeting_date = Column(String, nullable=True)
|
meeting_date = Column(String, nullable=True)
|
||||||
pdf_url = Column(String, unique=True)
|
pdf_url = Column(String, unique=True)
|
||||||
|
|
||||||
|
class ScraperRun(Base):
|
||||||
|
__tablename__ = "scraper_runs"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
source = Column(String, index=True)
|
||||||
|
status = Column(String, default="pending")
|
||||||
|
started_at = Column(DateTime(timezone=True))
|
||||||
|
finished_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
documents_added = Column(Integer, default=0)
|
||||||
|
message = Column(String, nullable=True)
|
||||||
@@ -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)"
|
||||||
@@ -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
|
||||||
@@ -9,3 +9,4 @@ python-jose[cryptography]
|
|||||||
passlib[argon2]
|
passlib[argon2]
|
||||||
python-multipart
|
python-multipart
|
||||||
pdfplumber
|
pdfplumber
|
||||||
|
psycopg2-binary
|
||||||
|
|||||||
@@ -107,7 +107,6 @@ Document text (first 3 pages):
|
|||||||
raw = response["message"]["content"].strip()
|
raw = response["message"]["content"].strip()
|
||||||
raw = re.sub(r"```json|```", "", raw).strip()
|
raw = re.sub(r"```json|```", "", raw).strip()
|
||||||
|
|
||||||
# Extrait le JSON même s'il y a du texte autour
|
|
||||||
match = re.search(r"\{.*\}", raw, re.DOTALL)
|
match = re.search(r"\{.*\}", raw, re.DOTALL)
|
||||||
if match:
|
if match:
|
||||||
raw = match.group(0)
|
raw = match.group(0)
|
||||||
|
|||||||
+190
-118
@@ -1,3 +1,17 @@
|
|||||||
|
"""
|
||||||
|
tests/test_regwatch_complet.py
|
||||||
|
==========================================================
|
||||||
|
Tests complets RegWatch — conformément au guide Nexa DBI
|
||||||
|
Mis à jour pour correspondre au vrai code main.py + auth.py
|
||||||
|
Couvre :
|
||||||
|
- Tests unitaires (scraper CIR)
|
||||||
|
- Tests d'intégration (endpoints API)
|
||||||
|
- Tests de sécurité (injection SQL, JWT, XSS)
|
||||||
|
- Conformité RGPD (mdp haché, données non exposées)
|
||||||
|
- Tests endpoints admin
|
||||||
|
==========================================================
|
||||||
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
@@ -24,47 +38,35 @@ def skip_no_app():
|
|||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
class TestUnitairesExtractPagesText:
|
class TestUnitairesExtractPagesText:
|
||||||
"""
|
"""Tests unitaires de extract_pages_text() — cir_scraper.py"""
|
||||||
Tests unitaires de extract_pages_text()
|
|
||||||
Réf. cir_scraper.py l.46 — extraction texte PDF
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_bytes_vide_retourne_chaine_vide(self):
|
def test_bytes_vide_retourne_chaine_vide(self):
|
||||||
"""Bytes vides → '' sans crash"""
|
|
||||||
from scrapers.cir_scraper import extract_pages_text
|
from scrapers.cir_scraper import extract_pages_text
|
||||||
assert extract_pages_text(b"") == ""
|
assert extract_pages_text(b"") == ""
|
||||||
|
|
||||||
def test_pdf_invalide_retourne_chaine_vide(self):
|
def test_pdf_invalide_retourne_chaine_vide(self):
|
||||||
"""Contenu non-PDF → '' (le except l.57 fonctionne)"""
|
|
||||||
from scrapers.cir_scraper import extract_pages_text
|
from scrapers.cir_scraper import extract_pages_text
|
||||||
assert extract_pages_text(b"ceci n est pas un pdf") == ""
|
assert extract_pages_text(b"ceci n est pas un pdf") == ""
|
||||||
|
|
||||||
def test_retour_toujours_une_str(self):
|
def test_retour_toujours_une_str(self):
|
||||||
"""La fonction retourne toujours str, jamais None"""
|
|
||||||
from scrapers.cir_scraper import extract_pages_text
|
from scrapers.cir_scraper import extract_pages_text
|
||||||
result = extract_pages_text(b"")
|
result = extract_pages_text(b"")
|
||||||
assert isinstance(result, str)
|
assert isinstance(result, str)
|
||||||
|
|
||||||
def test_limite_5000_caracteres(self):
|
def test_limite_5000_caracteres(self):
|
||||||
"""Le texte est tronqué à 5000 chars max (l.55 : [:5000])"""
|
|
||||||
long_text = "A" * 10000
|
long_text = "A" * 10000
|
||||||
assert len(long_text[:5000]) == 5000
|
assert len(long_text[:5000]) == 5000
|
||||||
|
|
||||||
|
|
||||||
class TestUnitairesFallbackOllama:
|
class TestUnitairesFallbackOllama:
|
||||||
"""
|
"""Tests unitaires du fallback Ollama"""
|
||||||
Tests unitaires du fallback Ollama (cir_scraper.py l.65)
|
|
||||||
Quand le texte extrait est vide, on utilise le nom du fichier PDF
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_fallback_utilise_nom_fichier(self):
|
def test_fallback_utilise_nom_fichier(self):
|
||||||
"""pdf_url → nom du fichier sans extension (l.66-68)"""
|
|
||||||
pdf_url = "https://cir-safety.org/sites/files/Retinol_2024.pdf"
|
pdf_url = "https://cir-safety.org/sites/files/Retinol_2024.pdf"
|
||||||
filename = pdf_url.split("/")[-1].replace("_", " ").replace(".pdf", "")
|
filename = pdf_url.split("/")[-1].replace("_", " ").replace(".pdf", "")
|
||||||
assert filename == "Retinol 2024"
|
assert filename == "Retinol 2024"
|
||||||
|
|
||||||
def test_fallback_retourne_4_cles(self):
|
def test_fallback_retourne_4_cles(self):
|
||||||
"""Le fallback retourne bien title, ingredient, document_type, date"""
|
|
||||||
fallback = {
|
fallback = {
|
||||||
"title": "Mon Document",
|
"title": "Mon Document",
|
||||||
"ingredient": None,
|
"ingredient": None,
|
||||||
@@ -73,46 +75,37 @@ class TestUnitairesFallbackOllama:
|
|||||||
}
|
}
|
||||||
assert all(k in fallback for k in ["title", "ingredient", "document_type", "date"])
|
assert all(k in fallback for k in ["title", "ingredient", "document_type", "date"])
|
||||||
assert fallback["ingredient"] is None
|
assert fallback["ingredient"] is None
|
||||||
assert fallback["document_type"] == "document"
|
|
||||||
|
|
||||||
def test_nettoyage_ingredient_null_string(self):
|
def test_nettoyage_ingredient_null_string(self):
|
||||||
"""Ollama retourne 'null'/'none'/'n/a' → converti en None (l.112-113)"""
|
|
||||||
for val in ["null", "none", "n/a", "", "NULL", "None"]:
|
for val in ["null", "none", "n/a", "", "NULL", "None"]:
|
||||||
result = None if str(val).strip().lower() in ("null", "none", "n/a", "") else val
|
result = None if str(val).strip().lower() in ("null", "none", "n/a", "") else val
|
||||||
assert result is None, f"'{val}' aurait dû être None"
|
assert result is None
|
||||||
|
|
||||||
def test_titre_tronque_200_chars(self):
|
def test_titre_tronque_200_chars(self):
|
||||||
"""Le titre est limité à 200 chars (l.117 : [:200])"""
|
|
||||||
long = "A" * 300
|
long = "A" * 300
|
||||||
assert len(str(long).strip()[:200]) == 200
|
assert len(str(long).strip()[:200]) == 200
|
||||||
|
|
||||||
|
|
||||||
class TestUnitairesTemplatesURL:
|
class TestUnitairesTemplatesURL:
|
||||||
"""
|
"""Tests unitaires des templates d'URL CIR"""
|
||||||
Tests unitaires des templates d'URL CIR (cir_scraper.py l.31-40)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_tous_les_templates_contiennent_num(self):
|
def test_tous_les_templates_contiennent_num(self):
|
||||||
"""Chaque template contient {num}"""
|
|
||||||
from scrapers.cir_scraper import url_templates
|
from scrapers.cir_scraper import url_templates
|
||||||
for t in url_templates:
|
for t in url_templates:
|
||||||
assert "{num}" in t
|
assert "{num}" in t
|
||||||
|
|
||||||
def test_tous_les_templates_sont_https(self):
|
def test_tous_les_templates_sont_https(self):
|
||||||
"""Tous les templates utilisent HTTPS"""
|
|
||||||
from scrapers.cir_scraper import url_templates
|
from scrapers.cir_scraper import url_templates
|
||||||
for t in url_templates:
|
for t in url_templates:
|
||||||
assert t.startswith("https://")
|
assert t.startswith("https://")
|
||||||
|
|
||||||
def test_format_url_remplace_num(self):
|
def test_format_url_remplace_num(self):
|
||||||
"""Le format {num} est bien remplacé"""
|
|
||||||
from scrapers.cir_scraper import url_templates
|
from scrapers.cir_scraper import url_templates
|
||||||
url = url_templates[0].format(num=150)
|
url = url_templates[0].format(num=150)
|
||||||
assert "150" in url
|
assert "150" in url
|
||||||
assert "{num}" not in url
|
assert "{num}" not in url
|
||||||
|
|
||||||
def test_meeting_numbers_couvre_115_a_174(self):
|
def test_meeting_numbers_couvre_115_a_174(self):
|
||||||
"""MEETING_NUMBERS = range(115, 175) → 60 réunions"""
|
|
||||||
from scrapers.cir_scraper import MEETING_NUMBERS
|
from scrapers.cir_scraper import MEETING_NUMBERS
|
||||||
nums = list(MEETING_NUMBERS)
|
nums = list(MEETING_NUMBERS)
|
||||||
assert nums[0] == 115
|
assert nums[0] == 115
|
||||||
@@ -120,66 +113,53 @@ class TestUnitairesTemplatesURL:
|
|||||||
assert len(nums) == 60
|
assert len(nums) == 60
|
||||||
|
|
||||||
def test_pdf_limit_est_100(self):
|
def test_pdf_limit_est_100(self):
|
||||||
"""PDF_LIMIT = 100 (l.32)"""
|
|
||||||
from scrapers.cir_scraper import PDF_LIMIT
|
from scrapers.cir_scraper import PDF_LIMIT
|
||||||
assert PDF_LIMIT == 100
|
assert PDF_LIMIT == 100
|
||||||
|
|
||||||
|
|
||||||
class TestUnitairesFiltragePDF:
|
class TestUnitairesFiltragePDF:
|
||||||
"""
|
"""Tests unitaires du filtrage des liens PDF"""
|
||||||
Tests unitaires du filtrage des liens PDF (cir_scraper.py l.148)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_skip_keywords_contient_les_3_valeurs(self):
|
def test_skip_keywords_contient_les_3_valeurs(self):
|
||||||
"""SKIP_KEYWORDS = ['Agenda', 'Minutes', 'Status Report']"""
|
|
||||||
from scrapers.cir_scraper import SKIP_KEYWORDS
|
from scrapers.cir_scraper import SKIP_KEYWORDS
|
||||||
assert "Agenda" in SKIP_KEYWORDS
|
assert "Agenda" in SKIP_KEYWORDS
|
||||||
assert "Minutes" in SKIP_KEYWORDS
|
assert "Minutes" in SKIP_KEYWORDS
|
||||||
assert "Status Report" in SKIP_KEYWORDS
|
assert "Status Report" in SKIP_KEYWORDS
|
||||||
|
|
||||||
def test_regex_pdf_detecte_extension_pdf(self):
|
def test_regex_pdf_detecte_extension_pdf(self):
|
||||||
"""PDF_RE détecte .pdf / .PDF / .Pdf"""
|
|
||||||
from scrapers.cir_scraper import PDF_RE
|
from scrapers.cir_scraper import PDF_RE
|
||||||
assert PDF_RE.search("document.pdf")
|
assert PDF_RE.search("document.pdf")
|
||||||
assert PDF_RE.search("document.PDF")
|
assert PDF_RE.search("document.PDF")
|
||||||
assert PDF_RE.search("rapport.Pdf")
|
assert PDF_RE.search("rapport.Pdf")
|
||||||
|
|
||||||
def test_regex_pdf_ne_detecte_pas_autres(self):
|
def test_regex_pdf_ne_detecte_pas_autres(self):
|
||||||
"""PDF_RE ne détecte pas .docx, .png"""
|
|
||||||
from scrapers.cir_scraper import PDF_RE
|
from scrapers.cir_scraper import PDF_RE
|
||||||
assert not PDF_RE.search("document.docx")
|
assert not PDF_RE.search("document.docx")
|
||||||
assert not PDF_RE.search("image.png")
|
assert not PDF_RE.search("image.png")
|
||||||
|
|
||||||
def test_agenda_est_filtre(self):
|
def test_agenda_est_filtre(self):
|
||||||
"""Un contexte contenant 'Agenda' → filtré"""
|
|
||||||
from scrapers.cir_scraper import SKIP_KEYWORDS
|
from scrapers.cir_scraper import SKIP_KEYWORDS
|
||||||
context = "116th Expert Panel Meeting Agenda"
|
context = "116th Expert Panel Meeting Agenda"
|
||||||
assert any(kw in context for kw in SKIP_KEYWORDS)
|
assert any(kw in context for kw in SKIP_KEYWORDS)
|
||||||
|
|
||||||
def test_final_report_non_filtre(self):
|
def test_final_report_non_filtre(self):
|
||||||
"""Un 'Final Report' passe le filtre"""
|
|
||||||
from scrapers.cir_scraper import SKIP_KEYWORDS
|
from scrapers.cir_scraper import SKIP_KEYWORDS
|
||||||
context = "Final Report on the Safety Assessment of Retinol"
|
context = "Final Report on the Safety Assessment of Retinol"
|
||||||
assert not any(kw in context for kw in SKIP_KEYWORDS)
|
assert not any(kw in context for kw in SKIP_KEYWORDS)
|
||||||
|
|
||||||
|
|
||||||
class TestUnitairesDeduplication:
|
class TestUnitairesDeduplication:
|
||||||
"""
|
"""Tests unitaires de la logique de déduplication"""
|
||||||
Tests unitaires de la logique de déduplication (cir_scraper.py l.172)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_nouvelle_url_acceptee(self):
|
def test_nouvelle_url_acceptee(self):
|
||||||
"""Une URL absente du set → à insérer"""
|
existing = {"https://cir.org/doc1.pdf"}
|
||||||
existing = {"https://cir.org/doc1.pdf", "https://cir.org/doc2.pdf"}
|
|
||||||
assert "https://cir.org/doc3.pdf" not in existing
|
assert "https://cir.org/doc3.pdf" not in existing
|
||||||
|
|
||||||
def test_url_existante_rejetee(self):
|
def test_url_existante_rejetee(self):
|
||||||
"""Une URL déjà présente → doublons ignoré"""
|
|
||||||
existing = {"https://cir.org/doc1.pdf"}
|
existing = {"https://cir.org/doc1.pdf"}
|
||||||
assert "https://cir.org/doc1.pdf" in existing
|
assert "https://cir.org/doc1.pdf" in existing
|
||||||
|
|
||||||
def test_structure_doc_6_champs(self):
|
def test_structure_doc_6_champs(self):
|
||||||
"""Un doc à sauvegarder contient les 6 champs requis"""
|
|
||||||
doc = {
|
doc = {
|
||||||
"title": "Final Report on Retinol",
|
"title": "Final Report on Retinol",
|
||||||
"ingredient": "Retinol",
|
"ingredient": "Retinol",
|
||||||
@@ -192,24 +172,19 @@ class TestUnitairesDeduplication:
|
|||||||
assert champ in doc
|
assert champ in doc
|
||||||
|
|
||||||
def test_source_est_cir(self):
|
def test_source_est_cir(self):
|
||||||
"""La source est toujours 'CIR' pour ce scraper"""
|
|
||||||
assert "CIR" == "CIR"
|
assert "CIR" == "CIR"
|
||||||
|
|
||||||
|
|
||||||
class TestUnitairesHeadersHTTP:
|
class TestUnitairesHeadersHTTP:
|
||||||
"""
|
"""Tests unitaires des headers HTTP anti-blocage"""
|
||||||
Tests unitaires des headers HTTP anti-blocage (cir_scraper.py l.22)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_user_agent_simule_chrome(self):
|
def test_user_agent_simule_chrome(self):
|
||||||
"""User-Agent contient Mozilla et Chrome"""
|
|
||||||
from scrapers.cir_scraper import headers
|
from scrapers.cir_scraper import headers
|
||||||
assert "User-Agent" in headers
|
assert "User-Agent" in headers
|
||||||
assert "Mozilla" in headers["User-Agent"]
|
assert "Mozilla" in headers["User-Agent"]
|
||||||
assert "Chrome" in headers["User-Agent"]
|
assert "Chrome" in headers["User-Agent"]
|
||||||
|
|
||||||
def test_session_utilise_les_headers(self):
|
def test_session_utilise_les_headers(self):
|
||||||
"""La session requests hérite des headers"""
|
|
||||||
from scrapers.cir_scraper import session
|
from scrapers.cir_scraper import session
|
||||||
assert "User-Agent" in session.headers
|
assert "User-Agent" in session.headers
|
||||||
assert "Mozilla" in session.headers["User-Agent"]
|
assert "Mozilla" in session.headers["User-Agent"]
|
||||||
@@ -220,7 +195,7 @@ class TestUnitairesHeadersHTTP:
|
|||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
class TestIntegrationRacine:
|
class TestIntegrationRacine:
|
||||||
"""Tests de GET / (main.py l.21)"""
|
"""Tests de GET /"""
|
||||||
|
|
||||||
def test_racine_200(self):
|
def test_racine_200(self):
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
@@ -239,7 +214,7 @@ class TestIntegrationRacine:
|
|||||||
|
|
||||||
|
|
||||||
class TestIntegrationSignup:
|
class TestIntegrationSignup:
|
||||||
"""Tests de POST /signup (main.py l.28)"""
|
"""Tests de POST /signup"""
|
||||||
|
|
||||||
def test_email_invalide_422(self):
|
def test_email_invalide_422(self):
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
@@ -252,15 +227,14 @@ class TestIntegrationSignup:
|
|||||||
assert r.status_code == 422
|
assert r.status_code == 422
|
||||||
|
|
||||||
def test_email_deja_existant_400(self):
|
def test_email_deja_existant_400(self):
|
||||||
"""Email déjà pris → 400 'Email already registered' (l.33-37)"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
email = f"doublon_{int(time.time())}@regwatch.fr"
|
email = f"doublon_{int(time.time())}@regwatch.fr"
|
||||||
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
|
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
|
||||||
r = client.post("/signup", json={"email": email, "password": "autre", "full_name": "T"})
|
r = client.post("/signup", json={"email": email, "password": "autre", "full_name": "T"})
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
def test_signup_reussi_retourne_id_email(self):
|
def test_signup_reussi_retourne_id_email_role(self):
|
||||||
"""Signup valide → 200 avec id et email"""
|
"""UserResponse retourne maintenant id, email, full_name ET role"""
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
email = f"ok_{int(time.time())}@regwatch.fr"
|
email = f"ok_{int(time.time())}@regwatch.fr"
|
||||||
r = client.post("/signup", json={"email": email, "password": "test123", "full_name": "OK"})
|
r = client.post("/signup", json={"email": email, "password": "test123", "full_name": "OK"})
|
||||||
@@ -268,20 +242,20 @@ class TestIntegrationSignup:
|
|||||||
data = r.json()
|
data = r.json()
|
||||||
assert "id" in data
|
assert "id" in data
|
||||||
assert data["email"] == email
|
assert data["email"] == email
|
||||||
|
assert "role" in data
|
||||||
|
assert data["role"] == "user" # role par défaut
|
||||||
|
|
||||||
|
|
||||||
class TestIntegrationLogin:
|
class TestIntegrationLogin:
|
||||||
"""Tests de POST /login (main.py l.55)"""
|
"""Tests de POST /login"""
|
||||||
|
|
||||||
def test_email_inexistant_401(self):
|
def test_email_inexistant_401(self):
|
||||||
"""Email inconnu → 401 'User not found' (l.65-68)"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
r = client.post("/login", json={"email": "xyz_inconnu@regwatch.fr", "password": "test"})
|
r = client.post("/login", json={"email": "xyz_inconnu@regwatch.fr", "password": "test"})
|
||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
assert "not found" in r.json()["detail"].lower()
|
assert "not found" in r.json()["detail"].lower()
|
||||||
|
|
||||||
def test_mauvais_mot_de_passe_401(self):
|
def test_mauvais_mot_de_passe_401(self):
|
||||||
"""Mauvais mdp → 401 'Invalid password' (l.70-74)"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
email = f"mdp_{int(time.time())}@regwatch.fr"
|
email = f"mdp_{int(time.time())}@regwatch.fr"
|
||||||
client.post("/signup", json={"email": email, "password": "bonmdp", "full_name": "T"})
|
client.post("/signup", json={"email": email, "password": "bonmdp", "full_name": "T"})
|
||||||
@@ -289,7 +263,6 @@ class TestIntegrationLogin:
|
|||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
|
|
||||||
def test_login_reussi_retourne_token_bearer(self):
|
def test_login_reussi_retourne_token_bearer(self):
|
||||||
"""Login valide → token JWT + token_type='bearer' (l.76-82)"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
email = f"jwt_{int(time.time())}@regwatch.fr"
|
email = f"jwt_{int(time.time())}@regwatch.fr"
|
||||||
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
|
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
|
||||||
@@ -306,36 +279,36 @@ class TestIntegrationLogin:
|
|||||||
|
|
||||||
|
|
||||||
class TestIntegrationMe:
|
class TestIntegrationMe:
|
||||||
"""Tests de GET /me?token=... (main.py l.88)"""
|
"""Tests de GET /me?token=..."""
|
||||||
|
|
||||||
def test_sans_token_422(self):
|
def test_sans_token_422(self):
|
||||||
"""Paramètre token manquant → 422"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
assert client.get("/me").status_code == 422
|
assert client.get("/me").status_code == 422
|
||||||
|
|
||||||
def test_token_invalide_401(self):
|
def test_token_invalide_401(self):
|
||||||
"""Token JWT falsifié → 401 'Invalid token' (l.90-93)"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
r = client.get("/me?token=token_completement_faux")
|
r = client.get("/me?token=token_completement_faux")
|
||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
assert "invalid" in r.json()["detail"].lower() or "token" in r.json()["detail"].lower()
|
|
||||||
|
|
||||||
def test_token_valide_retourne_user(self):
|
def test_token_valide_retourne_user_avec_role(self):
|
||||||
"""Token valide → 200 avec email de l'utilisateur"""
|
"""GET /me retourne maintenant le role dans UserResponse"""
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
email = f"me_{int(time.time())}@regwatch.fr"
|
email = f"me_{int(time.time())}@regwatch.fr"
|
||||||
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
|
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
|
||||||
token = client.post("/login", json={"email": email, "password": "test123"}).json()["access_token"]
|
token = client.post("/login", json={"email": email, "password": "test123"}).json()["access_token"]
|
||||||
r = client.get(f"/me?token={token}")
|
r = client.get(f"/me?token={token}")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json()["email"] == email
|
data = r.json()
|
||||||
|
assert data["email"] == email
|
||||||
|
assert "role" in data
|
||||||
|
assert data["role"] == "user"
|
||||||
|
|
||||||
|
|
||||||
class TestIntegrationDocuments:
|
class TestIntegrationDocuments:
|
||||||
"""Tests de GET /documents (main.py l.104)"""
|
"""Tests de GET /documents"""
|
||||||
|
|
||||||
def test_sans_auth_retourne_200(self):
|
def test_sans_auth_retourne_200(self):
|
||||||
"""Pas de JWT requis sur cet endpoint (main.py l.104)"""
|
"""GET /documents ne requiert pas de JWT"""
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
assert client.get("/documents").status_code == 200
|
assert client.get("/documents").status_code == 200
|
||||||
|
|
||||||
@@ -344,7 +317,7 @@ class TestIntegrationDocuments:
|
|||||||
assert isinstance(client.get("/documents").json(), list)
|
assert isinstance(client.get("/documents").json(), list)
|
||||||
|
|
||||||
def test_structure_7_champs_par_document(self):
|
def test_structure_7_champs_par_document(self):
|
||||||
"""Chaque doc a id, title, ingredient, source, type, date, pdf_url (l.115-123)"""
|
"""Chaque doc a id, title, ingredient, source, type, date, pdf_url"""
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
docs = client.get("/documents").json()
|
docs = client.get("/documents").json()
|
||||||
if docs:
|
if docs:
|
||||||
@@ -352,64 +325,56 @@ class TestIntegrationDocuments:
|
|||||||
assert champ in docs[0], f"Champ manquant : {champ}"
|
assert champ in docs[0], f"Champ manquant : {champ}"
|
||||||
|
|
||||||
def test_source_cir_ou_sccs(self):
|
def test_source_cir_ou_sccs(self):
|
||||||
"""La source est 'CIR' ou 'SCCS' pour chaque document"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
docs = client.get("/documents").json()
|
docs = client.get("/documents").json()
|
||||||
for doc in docs:
|
for doc in docs:
|
||||||
assert doc["source"] in ["CIR", "SCCS"], f"Source inattendue : {doc['source']}"
|
assert doc["source"] in ["CIR", "SCCS"]
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
# PARTIE 3 — TESTS DE SÉCURITÉ
|
# PARTIE 3 — TESTS SÉCURITÉ
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
class TestSecuriteInjectionSQL:
|
class TestSecuriteInjectionSQL:
|
||||||
"""Tests d'injection SQL — SQLAlchemy protège nativement via requêtes paramétrées"""
|
"""Tests injection SQL — SQLAlchemy ORM protège nativement"""
|
||||||
|
|
||||||
def test_injection_sql_dans_email_login(self):
|
def test_injection_sql_dans_email_login(self):
|
||||||
"""' OR 1=1 -- dans l'email → 401 ou 422, JAMAIS 500"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
r = client.post("/login", json={"email": "' OR 1=1 --", "password": "test"})
|
r = client.post("/login", json={"email": "' OR 1=1 --", "password": "test"})
|
||||||
assert r.status_code in [401, 422], f"Possible injection SQL : {r.status_code}"
|
assert r.status_code in [401, 422]
|
||||||
|
|
||||||
def test_injection_sql_dans_email_signup(self):
|
def test_injection_sql_dans_email_signup(self):
|
||||||
"""'; DROP TABLE users; -- → 400 ou 422, JAMAIS 500"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
r = client.post("/signup", json={
|
r = client.post("/signup", json={
|
||||||
"email": "'; DROP TABLE users; --",
|
"email": "'; DROP TABLE users; --",
|
||||||
"password": "test123",
|
"password": "test123",
|
||||||
"full_name": "Hacker"
|
"full_name": "Hacker"
|
||||||
})
|
})
|
||||||
assert r.status_code in [400, 422], f"Possible injection SQL : {r.status_code}"
|
assert r.status_code in [400, 422]
|
||||||
|
|
||||||
def test_injection_sql_guillemets_doubles(self):
|
def test_injection_sql_guillemets_doubles(self):
|
||||||
"""\" OR \"1\"=\"1 dans l'email → pas de 500"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
r = client.post("/login", json={"email": "\" OR \"1\"=\"1", "password": "test"})
|
r = client.post("/login", json={"email": "\" OR \"1\"=\"1", "password": "test"})
|
||||||
assert r.status_code != 500
|
assert r.status_code != 500
|
||||||
|
|
||||||
|
|
||||||
class TestSecuriteJWT:
|
class TestSecuriteJWT:
|
||||||
"""Tests de sécurité JWT"""
|
"""Tests sécurité JWT"""
|
||||||
|
|
||||||
def test_token_falsifie_retourne_401(self):
|
def test_token_falsifie_retourne_401(self):
|
||||||
"""JWT avec signature incorrecte → 401 'Invalid token'"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
r = client.get("/me?token=eyJhbGciOiJIUzI1NiJ9.faux.mauvaise_signature")
|
r = client.get("/me?token=eyJhbGciOiJIUzI1NiJ9.faux.mauvaise_signature")
|
||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
|
|
||||||
def test_token_vide_retourne_401_ou_422(self):
|
def test_token_vide_retourne_401_ou_422(self):
|
||||||
"""Token vide → 401 ou 422"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
assert client.get("/me?token=").status_code in [401, 422]
|
assert client.get("/me?token=").status_code in [401, 422]
|
||||||
|
|
||||||
def test_token_malformed_retourne_401(self):
|
def test_token_malformed_retourne_401(self):
|
||||||
"""Token sans points (format JWT invalide) → 401"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
assert client.get("/me?token=cecinestunepasjwt").status_code == 401
|
assert client.get("/me?token=cecinestunepasjwt").status_code == 401
|
||||||
|
|
||||||
def test_token_valide_sur_bon_endpoint(self):
|
def test_token_valide_sur_bon_endpoint(self):
|
||||||
"""Un vrai token JWT permet l'accès à /me"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
email = f"jwt_ok_{int(time.time())}@regwatch.fr"
|
email = f"jwt_ok_{int(time.time())}@regwatch.fr"
|
||||||
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
|
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
|
||||||
@@ -418,10 +383,9 @@ class TestSecuriteJWT:
|
|||||||
|
|
||||||
|
|
||||||
class TestSecuriteXSS:
|
class TestSecuriteXSS:
|
||||||
"""Tests XSS — l'API REST retourne du JSON, React échappe côté frontend"""
|
"""Tests XSS"""
|
||||||
|
|
||||||
def test_xss_dans_full_name_stocke_sans_crash(self):
|
def test_xss_dans_full_name_stocke_sans_crash(self):
|
||||||
"""Payload XSS dans full_name → accepté sans crash (200 ou 422)"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
email = f"xss_{int(time.time())}@regwatch.fr"
|
email = f"xss_{int(time.time())}@regwatch.fr"
|
||||||
r = client.post("/signup", json={
|
r = client.post("/signup", json={
|
||||||
@@ -432,7 +396,6 @@ class TestSecuriteXSS:
|
|||||||
assert r.status_code in [200, 422]
|
assert r.status_code in [200, 422]
|
||||||
|
|
||||||
def test_xss_dans_email_ne_plante_pas(self):
|
def test_xss_dans_email_ne_plante_pas(self):
|
||||||
"""Payload XSS dans l'email → 422 (validation format email)"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
r = client.post("/login", json={
|
r = client.post("/login", json={
|
||||||
"email": "<script>alert(1)</script>@test.com",
|
"email": "<script>alert(1)</script>@test.com",
|
||||||
@@ -446,16 +409,9 @@ class TestSecuriteXSS:
|
|||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
class TestRGPD:
|
class TestRGPD:
|
||||||
"""
|
"""Tests conformité RGPD"""
|
||||||
Tests de conformité RGPD — exigés par le guide Nexa
|
|
||||||
Vérifie que les données personnelles sont bien protégées
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_mot_de_passe_absent_de_la_reponse_signup(self):
|
def test_mot_de_passe_absent_de_la_reponse_signup(self):
|
||||||
"""
|
|
||||||
Le mot de passe ne doit JAMAIS apparaître dans la réponse API
|
|
||||||
UserResponse = id + email + full_name SEULEMENT (pas de password)
|
|
||||||
"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
email = f"rgpd_{int(time.time())}@regwatch.fr"
|
email = f"rgpd_{int(time.time())}@regwatch.fr"
|
||||||
r = client.post("/signup", json={
|
r = client.post("/signup", json={
|
||||||
@@ -464,25 +420,19 @@ class TestRGPD:
|
|||||||
"full_name": "RGPD Test"
|
"full_name": "RGPD Test"
|
||||||
})
|
})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
# Le mot de passe en clair ne doit pas apparaître
|
|
||||||
assert "mon_super_secret_123" not in r.text
|
assert "mon_super_secret_123" not in r.text
|
||||||
# Le champ password ne doit pas être dans la réponse
|
|
||||||
assert "password" not in r.json()
|
assert "password" not in r.json()
|
||||||
|
|
||||||
def test_mot_de_passe_absent_de_la_reponse_login(self):
|
def test_mot_de_passe_absent_de_la_reponse_login(self):
|
||||||
"""Le login retourne uniquement access_token et token_type"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
email = f"rgpd2_{int(time.time())}@regwatch.fr"
|
email = f"rgpd2_{int(time.time())}@regwatch.fr"
|
||||||
client.post("/signup", json={"email": email, "password": "secret456", "full_name": "T"})
|
client.post("/signup", json={"email": email, "password": "secret456", "full_name": "T"})
|
||||||
r = client.post("/login", json={"email": email, "password": "secret456"})
|
r = client.post("/login", json={"email": email, "password": "secret456"})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert "secret456" not in r.text
|
assert "secret456" not in r.text
|
||||||
data = r.json()
|
assert "password" not in r.json()
|
||||||
assert "password" not in data
|
|
||||||
assert set(data.keys()) <= {"access_token", "token_type"}
|
|
||||||
|
|
||||||
def test_mot_de_passe_absent_de_la_reponse_me(self):
|
def test_mot_de_passe_absent_de_la_reponse_me(self):
|
||||||
"""GET /me ne retourne pas le mot de passe"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
email = f"rgpd3_{int(time.time())}@regwatch.fr"
|
email = f"rgpd3_{int(time.time())}@regwatch.fr"
|
||||||
client.post("/signup", json={"email": email, "password": "secret789", "full_name": "T"})
|
client.post("/signup", json={"email": email, "password": "secret789", "full_name": "T"})
|
||||||
@@ -493,39 +443,161 @@ class TestRGPD:
|
|||||||
assert "password" not in r.json()
|
assert "password" not in r.json()
|
||||||
|
|
||||||
def test_hachage_argon2_dans_requirements(self):
|
def test_hachage_argon2_dans_requirements(self):
|
||||||
"""
|
req_path = os.path.join(os.path.dirname(__file__), "..", "requirements.txt")
|
||||||
La dépendance passlib[argon2] est bien dans requirements.txt
|
|
||||||
→ preuve que les mots de passe sont hachés avec Argon2
|
|
||||||
"""
|
|
||||||
req_path = os.path.join(
|
|
||||||
os.path.dirname(__file__), "..", "requirements.txt"
|
|
||||||
)
|
|
||||||
if os.path.exists(req_path):
|
if os.path.exists(req_path):
|
||||||
with open(req_path) as f:
|
with open(req_path) as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
assert "argon2" in content.lower(), \
|
assert "argon2" in content.lower()
|
||||||
"passlib[argon2] absent de requirements.txt"
|
|
||||||
else:
|
else:
|
||||||
pytest.skip("requirements.txt non trouvé")
|
pytest.skip("requirements.txt non trouvé")
|
||||||
|
|
||||||
def test_token_jwt_expire_apres_acces(self):
|
def test_token_jwt_structure_3_parties(self):
|
||||||
"""
|
|
||||||
Un token JWT est bien structuré (3 parties séparées par des points)
|
|
||||||
→ preuve que l'expiration est encodée dans le payload
|
|
||||||
"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
email = f"expire_{int(time.time())}@regwatch.fr"
|
email = f"expire_{int(time.time())}@regwatch.fr"
|
||||||
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
|
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
|
||||||
token = client.post("/login", json={"email": email, "password": "test123"}).json()["access_token"]
|
token = client.post("/login", json={"email": email, "password": "test123"}).json()["access_token"]
|
||||||
# Un JWT valide a toujours 3 parties séparées par des points
|
|
||||||
parts = token.split(".")
|
parts = token.split(".")
|
||||||
assert len(parts) == 3, "Le token JWT n'a pas la structure header.payload.signature"
|
assert len(parts) == 3
|
||||||
|
|
||||||
def test_cors_autorise_frontend(self):
|
def test_cors_autorise_frontend(self):
|
||||||
"""
|
|
||||||
Le middleware CORS permet au frontend React de communiquer avec l'API
|
|
||||||
allow_origins=["*"] configuré dans main.py l.13-18
|
|
||||||
"""
|
|
||||||
skip_no_app()
|
skip_no_app()
|
||||||
r = client.get("/", headers={"Origin": "http://localhost:5173"})
|
r = client.get("/", headers={"Origin": "http://localhost:5173"})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# PARTIE 5 — TESTS ENDPOINTS ADMIN
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def get_admin_token():
|
||||||
|
"""Crée un compte admin et retourne son token"""
|
||||||
|
master_email = "master_admin@regwatch.fr"
|
||||||
|
master_password = "master123"
|
||||||
|
|
||||||
|
# Essai de login master
|
||||||
|
login_r = client.post("/login", json={"email": master_email, "password": master_password})
|
||||||
|
|
||||||
|
if login_r.status_code != 200:
|
||||||
|
# Crée le master
|
||||||
|
signup_r = client.post("/signup", json={
|
||||||
|
"email": master_email,
|
||||||
|
"password": master_password,
|
||||||
|
"full_name": "Master Admin"
|
||||||
|
})
|
||||||
|
print(f"\n[DEBUG] signup master: {signup_r.status_code} {signup_r.json()}")
|
||||||
|
|
||||||
|
token_r = client.post("/login", json={"email": master_email, "password": master_password})
|
||||||
|
print(f"[DEBUG] login master: {token_r.status_code} {token_r.json()}")
|
||||||
|
master_token = token_r.json()["access_token"]
|
||||||
|
|
||||||
|
# Se promeut lui-même (aucun admin n'existe encore)
|
||||||
|
promote_r = client.post(f"/admin/make-admin?token={master_token}&target_email={master_email}")
|
||||||
|
print(f"[DEBUG] promote master: {promote_r.status_code} {promote_r.json()}")
|
||||||
|
|
||||||
|
# Re-login
|
||||||
|
master_token = client.post("/login", json={
|
||||||
|
"email": master_email, "password": master_password
|
||||||
|
}).json()["access_token"]
|
||||||
|
else:
|
||||||
|
master_token = login_r.json()["access_token"]
|
||||||
|
print(f"\n[DEBUG] master déjà existant, token ok")
|
||||||
|
|
||||||
|
# Vérifie que master est bien admin
|
||||||
|
stats_r = client.get(f"/admin/stats?token={master_token}")
|
||||||
|
print(f"[DEBUG] stats avec master token: {stats_r.status_code} {stats_r.json()}")
|
||||||
|
|
||||||
|
return master_token, master_email
|
||||||
|
class TestAdminStats:
|
||||||
|
"""Tests de GET /admin/stats"""
|
||||||
|
|
||||||
|
def test_sans_token_401(self):
|
||||||
|
skip_no_app()
|
||||||
|
r = client.get("/admin/stats?token=faux")
|
||||||
|
assert r.status_code in [401, 403]
|
||||||
|
|
||||||
|
def test_avec_token_user_403(self):
|
||||||
|
skip_no_app()
|
||||||
|
email = f"user_{int(time.time())}@regwatch.fr"
|
||||||
|
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
|
||||||
|
token = client.post("/login", json={"email": email, "password": "test123"}).json()["access_token"]
|
||||||
|
r = client.get(f"/admin/stats?token={token}")
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
def test_avec_token_admin_retourne_stats(self):
|
||||||
|
skip_no_app()
|
||||||
|
token, _ = get_admin_token()
|
||||||
|
r = client.get(f"/admin/stats?token={token}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert "total_documents" in data
|
||||||
|
assert "cir_documents" in data
|
||||||
|
assert "sccs_documents" in data
|
||||||
|
assert "total_users" in data
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminUsers:
|
||||||
|
"""Tests de GET /admin/users et DELETE /admin/users/{id}"""
|
||||||
|
|
||||||
|
def test_get_users_sans_admin_403(self):
|
||||||
|
skip_no_app()
|
||||||
|
email = f"user_{int(time.time())}@regwatch.fr"
|
||||||
|
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
|
||||||
|
token = client.post("/login", json={"email": email, "password": "test123"}).json()["access_token"]
|
||||||
|
r = client.get(f"/admin/users?token={token}")
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
def test_get_users_avec_admin_retourne_liste(self):
|
||||||
|
skip_no_app()
|
||||||
|
token, _ = get_admin_token()
|
||||||
|
r = client.get(f"/admin/users?token={token}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert isinstance(r.json(), list)
|
||||||
|
|
||||||
|
def test_delete_user_inexistant_404(self):
|
||||||
|
skip_no_app()
|
||||||
|
token, _ = get_admin_token()
|
||||||
|
r = client.delete(f"/admin/users/999999?token={token}")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
def test_patch_role_user_inexistant_404(self):
|
||||||
|
skip_no_app()
|
||||||
|
token, _ = get_admin_token()
|
||||||
|
r = client.patch(f"/admin/users/999999/role?token={token}", json={"role": "admin"})
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminDocuments:
|
||||||
|
"""Tests de DELETE /admin/documents/{id}"""
|
||||||
|
|
||||||
|
def test_delete_document_sans_admin_403(self):
|
||||||
|
skip_no_app()
|
||||||
|
email = f"user_{int(time.time())}@regwatch.fr"
|
||||||
|
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
|
||||||
|
token = client.post("/login", json={"email": email, "password": "test123"}).json()["access_token"]
|
||||||
|
r = client.delete(f"/admin/documents/1?token={token}")
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
def test_delete_document_inexistant_404(self):
|
||||||
|
skip_no_app()
|
||||||
|
token, _ = get_admin_token()
|
||||||
|
r = client.delete(f"/admin/documents/999999?token={token}")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminMakeAdmin:
|
||||||
|
"""Tests de POST /admin/make-admin"""
|
||||||
|
|
||||||
|
def test_make_admin_utilisateur_inexistant_404(self):
|
||||||
|
skip_no_app()
|
||||||
|
token, _ = get_admin_token()
|
||||||
|
r = client.post(f"/admin/make-admin?token={token}&target_email=inexistant@regwatch.fr")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
def test_make_admin_retourne_message(self):
|
||||||
|
skip_no_app()
|
||||||
|
token, _ = get_admin_token()
|
||||||
|
email = f"futuradmin_{int(time.time())}@regwatch.fr"
|
||||||
|
client.post("/signup", json={"email": email, "password": "test123", "full_name": "T"})
|
||||||
|
r = client.post(f"/admin/make-admin?token={token}&target_email={email}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "admin" in r.json()["message"].lower()
|
||||||
Reference in New Issue
Block a user