transfer from private server/git server

This commit is contained in:
M1n-0
2026-08-21 09:12:02 +02:00
parent 45c8982178
commit 6ecb014238
75 changed files with 4344 additions and 274 deletions

0
web/tests/__init__.py Normal file
View File

88
web/tests/conftest.py Normal file
View File

@@ -0,0 +1,88 @@
"""
conftest.py — Fixtures pytest partagées.
La suite de tests s'exécute contre une VRAIE base MariaDB de test
(gesthub_test), jamais contre des mocks de couche SQL : cela permet de
détecter de vraies erreurs de requête (typos de colonnes, contraintes
violées...) que des mocks ne détecteraient pas. Chaque test nettoie les
tables qu'il utilise pour rester indépendant des autres (pas d'ordre imposé).
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest
from config import TestConfig
from db import init_db
@pytest.fixture(scope="session")
def db():
database = init_db(TestConfig)
# Sanity check : la base de test doit être joignable avant de lancer la suite.
database.fetch_one("SELECT 1 AS ok")
return database
@pytest.fixture(autouse=True)
def clean_tables(db):
"""Vide les tables avant CHAQUE test pour garantir l'isolation."""
with db.cursor(commit=True) as cur:
for table in ("audit_log", "fichiers", "evenements", "annonces", "blocks"):
cur.execute(f"DELETE FROM {table}")
yield
@pytest.fixture(scope="session")
def app():
# Portée "session" : Flask interdit de redécorer un blueprint déjà
# enregistré (voir routes/auth.py), donc create_app() n'est appelé
# qu'une seule fois pour toute la suite. L'isolation entre tests est
# assurée par clean_tables (autouse) et par un nouveau test_client()
# (voir fixture client ci-dessous) à chaque test.
from app import create_app
flask_app = create_app(TestConfig)
flask_app.config["DB"] = init_db(TestConfig)
flask_app.config["TESTING"] = True
return flask_app
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def admin_session(app):
"""Simule une session utilisateur authentifiée avec le groupe /admin.
Utilise SON PROPRE test_client() (indépendant de la fixture `client` et
de `user_session`) : deux clients Flask ont chacun leur propre cookie
jar, donc leurs sessions ne s'écrasent pas quand un test dépend des deux
en même temps (cf. test_it07).
"""
test_client = app.test_client()
with test_client.session_transaction() as sess:
sess["user"] = {
"sub": "admin-sub-0001",
"preferred_username": "admin",
"groups": ["/admin"],
}
return test_client
@pytest.fixture
def user_session(app):
"""Simule une session utilisateur authentifiée SANS le groupe /admin."""
test_client = app.test_client()
with test_client.session_transaction() as sess:
sess["user"] = {
"sub": "user-sub-0002",
"preferred_username": "membre",
"groups": [],
}
return test_client

View File

@@ -0,0 +1,73 @@
"""
Tests d'intégration des routes Flask — IT-01 à IT-10 (client de test Flask).
Vérifient le comportement HTTP complet (routes -> services -> models -> DB
réelle), avec et sans authentification/autorisation.
"""
import json
def test_it01_annonces_sans_auth_401(client):
resp = client.get("/api/annonces")
assert resp.status_code == 401
def test_it02_annonces_avec_auth_200(user_session):
resp = user_session.get("/api/annonces")
assert resp.status_code == 200
assert resp.get_json() == []
def test_it03_creer_annonce_non_admin_403(user_session):
resp = user_session.post("/api/annonces", json={"titre": "T", "contenu": "C"})
assert resp.status_code == 403
def test_it04_creer_annonce_admin_201_puis_visible(admin_session):
resp = admin_session.post("/api/annonces", json={"titre": "Annonce test", "contenu": "Contenu"})
assert resp.status_code == 201
resp_list = admin_session.get("/api/annonces")
titres = [a["titre"] for a in resp_list.get_json()]
assert "Annonce test" in titres
def test_it05_modifier_annonce_admin_200(admin_session):
created = admin_session.post("/api/annonces", json={"titre": "A", "contenu": "B"}).get_json()
resp = admin_session.put(f"/api/annonces/{created['id']}", json={"titre": "A2", "contenu": "B2"})
assert resp.status_code == 200
def test_it06_supprimer_annonce_admin_200(admin_session):
created = admin_session.post("/api/annonces", json={"titre": "A", "contenu": "B"}).get_json()
resp = admin_session.delete(f"/api/annonces/{created['id']}")
assert resp.status_code == 200
def test_it07_is_admin_reflete_groupes(admin_session, user_session):
assert admin_session.get("/api/is_admin").get_json()["admin"] is True
assert user_session.get("/api/is_admin").get_json()["admin"] is False
def test_it08_upload_sans_fichier_400(user_session):
resp = user_session.post("/api/fichiers/upload", data={})
assert resp.status_code == 400
def test_it09_evenement_dates_invalides_400(admin_session):
resp = admin_session.post(
"/api/evenements",
json={"titre": "Réunion", "date_debut": "2026-09-10T10:00:00", "date_fin": "2026-09-09T10:00:00"},
)
assert resp.status_code == 400
def test_it10_accueil_sans_session_redirige_login(client):
resp = client.get("/", follow_redirects=False)
assert resp.status_code == 302
assert "/login" in resp.headers["Location"]
def test_it04b_creer_annonce_titre_manquant_400(admin_session):
resp = admin_session.post("/api/annonces", json={"contenu": "Sans titre"})
assert resp.status_code == 400

97
web/tests/test_rgpd.py Normal file
View File

@@ -0,0 +1,97 @@
"""
Tests RGPD — droit à l'effacement (art. 17) : anonymisation d'un `sub`
OIDC dans toutes les tables qui le référencent (routes/rgpd.py,
services/rgpd_service.py, models/rgpd_model.py).
"""
from models import annonce_model, fichier_model, evenement_model, audit_model
from services import annonce_service, evenement_service, rgpd_service
def _seed_user_data(db, sub):
"""Crée une ligne dans chaque table référençant `sub`, y compris
audit_log (via les services, comme le ferait l'application réelle).
create_annonce() et create_evenement() journalisent déjà chacun une
ligne dans audit_log (CREATE_ANNONCE, CREATE_EVENEMENT) ; on ajoute un
LOGIN explicite pour obtenir 3 lignes audit_log au total pour ce sub.
"""
annonce_id = annonce_service.create_annonce(db, "Titre", "Contenu", sub)
fichier_model.insert(db, "doc.pdf", "uuid-stocke", "application/pdf", 10, sub)
evenement_service.create_evenement(
db, "Réunion", "desc", "2026-09-10T10:00:00", "2026-09-10T11:00:00", sub
)
from services import audit_service
audit_service.log(db, "LOGIN", None, "SUCCESS", sub, "127.0.0.1")
return annonce_id
# --- Accès -------------------------------------------------------------------
def test_rgpd01_sans_auth_401(client):
resp = client.delete("/api/admin/users/sub-cible/data")
assert resp.status_code == 401
def test_rgpd02_non_admin_403(user_session):
resp = user_session.delete("/api/admin/users/sub-cible/data")
assert resp.status_code == 403
def test_rgpd03_sub_inconnu_404(admin_session):
resp = admin_session.delete("/api/admin/users/sub-jamais-vu/data")
assert resp.status_code == 404
# --- Anonymisation effective ---------------------------------------------
def test_rgpd04_anonymisation_remplace_le_sub_partout(db, admin_session):
target_sub = "sub-a-effacer-0001"
annonce_id = _seed_user_data(db, target_sub)
resp = admin_session.delete(f"/api/admin/users/{target_sub}/data")
assert resp.status_code == 200
body = resp.get_json()
assert body["status"] == "anonymized"
pseudonym = body["pseudonym"]
assert pseudonym.startswith("anon:")
assert body["rows_updated"] == {
"annonces": 1, "fichiers": 1, "evenements": 1, "audit_log": 3,
}
# Le sub d'origine n'apparaît plus nulle part...
assert annonce_model.get(db, annonce_id)["auteur_sub"] == pseudonym
fichiers = fichier_model.fetch_all(db)
assert fichiers[0]["uploader_sub"] == pseudonym
evenements = evenement_model.fetch_all(db)
assert evenements[0]["createur_sub"] == pseudonym
recent = audit_model.fetch_recent(db)
subs_restants = [row["utilisateur_sub"] for row in recent]
assert target_sub not in subs_restants
# ... mais une entrée d'audit trace l'action d'anonymisation elle-même,
# journalisée sous l'identité de l'ADMIN qui l'a déclenchée (pas la
# cible, déjà pseudonymisée à ce stade).
actions = {row["action"] for row in recent}
assert "RGPD_ANONYMIZE_USER" in actions
def test_rgpd05_pseudonyme_deterministe_pour_un_meme_sub(db):
target_sub = "sub-deterministe-0002"
_seed_user_data(db, target_sub)
result = rgpd_service.anonymize_user(db, target_sub, pepper="pepper-de-test")
assert result["anonymized"] is True
# Toutes les tables ont reçu EXACTEMENT le même pseudonyme.
pseudo = result["pseudonym"]
assert annonce_model.fetch_all(db)[0]["auteur_sub"] == pseudo
assert fichier_model.fetch_all(db)[0]["uploader_sub"] == pseudo
def test_rgpd06_deja_anonymise_refuse(db, admin_session):
target_sub = "sub-double-appel-0003"
_seed_user_data(db, target_sub)
first = admin_session.delete(f"/api/admin/users/{target_sub}/data")
pseudonym = first.get_json()["pseudonym"]
second = admin_session.delete(f"/api/admin/users/{pseudonym}/data")
assert second.status_code == 400

View File

@@ -0,0 +1,89 @@
"""
Tests de sécurité — TS-01 à TS-06 (Bloc 1 - C8 ; OWASP Top 10 2021).
Approche adversariale : se mettre dans la peau d'un attaquant pour chaque
vecteur identifié dans le tableau de risques du dossier technique.
"""
import io
import os
import yaml
from werkzeug.datastructures import FileStorage
from models import annonce_model
from services import annonce_service, fichier_service
# --- TS-01 : Injection SQL (A03:2021) ----------------------------------------
def test_ts01_injection_sql_dans_titre_est_neutralisee(db):
payload = "Test'; DROP TABLE annonces; --"
new_id = annonce_service.create_annonce(db, payload, "contenu", "sub-attaquant")
# La requête paramétrée traite le payload comme une simple chaîne : la
# table annonces existe toujours et contient bien la ligne créée.
row = annonce_model.get(db, new_id)
assert row["titre"] == payload
all_rows = annonce_model.fetch_all(db)
assert len(all_rows) >= 1 # la table n'a pas été supprimée
# --- TS-02 : Accès sans token (A01:2021 — Broken Access Control) ------------
def test_ts02_acces_sans_token_refuse(client):
resp = client.get("/api/fichiers")
assert resp.status_code == 401
resp2 = client.post("/api/annonces", json={"titre": "x", "contenu": "y"})
assert resp2.status_code == 401
# --- TS-03 : Cookie de session falsifié (token forgé) -----------------------
def test_ts03_cookie_session_falsifie_refuse(client):
client.set_cookie("session", "valeur-totalement-forgee-non-signee")
resp = client.get("/api/annonces")
# Flask ne parvient pas à décoder un cookie non signé par SECRET_KEY :
# la session est traitée comme vide -> 401, pas d'accès accordé.
assert resp.status_code == 401
# --- TS-04 : XSS stocké (A03:2021) ------------------------------------------
def test_ts04_contenu_avec_script_est_echappe_au_rendu(app, db):
payload = "<script>alert('xss')</script>"
annonce_service.create_annonce(db, "Titre", payload, "sub-1")
with app.test_request_context():
from flask import render_template_string
rendered = render_template_string("{{ contenu }}", contenu=payload)
# Jinja2 (autoescape actif par défaut sur .html) transforme les balises
# en entités HTML : le script n'est jamais exécutable tel quel.
assert "<script>" not in rendered
assert "&lt;script&gt;" in rendered
# --- TS-05 : Accès aux ressources d'autrui (A01:2021) -----------------------
def test_ts05_telechargement_fichier_dautrui_refuse(db, tmp_path, monkeypatch):
from config import Config
monkeypatch.setattr(Config, "UPLOAD_DIR", str(tmp_path))
fs = FileStorage(stream=io.BytesIO(b"contenu prive"), filename="prive.pdf", content_type="application/pdf")
fichier_id = fichier_service.handle_upload(db, fs, "sub-proprietaire", ip="127.0.0.1")
try:
fichier_service.prepare_download(db, fichier_id, "sub-attaquant", is_admin=False)
assert False, "le téléchargement aurait dû être refusé"
except PermissionError:
pass
# Le propriétaire, lui, peut télécharger son propre fichier.
path, name = fichier_service.prepare_download(db, fichier_id, "sub-proprietaire", is_admin=False)
assert os.path.exists(path)
# --- TS-06 : Exposition des ports internes (A05:2021 — Security Misconfig) -
def test_ts06_seul_caddy_expose_des_ports_publics():
compose_path = os.path.join(os.path.dirname(__file__), "..", "..", "docker-compose.yml")
with open(compose_path) as f:
compose = yaml.safe_load(f)
for service_name, service_def in compose["services"].items():
if service_name == "caddy":
assert "ports" in service_def
else:
assert "ports" not in service_def, f"{service_name} expose un port public !"

View File

@@ -0,0 +1,41 @@
"""Tests unitaires du service Annonces — UT-01 à UT-05 (Bloc 3 - C7)."""
import pytest
from models import annonce_model
from services import annonce_service
def test_ut01_create_annonce_valide(db):
new_id = annonce_service.create_annonce(db, "Titre test", "Contenu test", "sub-1")
assert new_id is not None
row = annonce_model.get(db, new_id)
assert row["titre"] == "Titre test"
assert row["contenu"] == "Contenu test"
def test_ut02_create_annonce_titre_vide(db):
with pytest.raises(annonce_service.ValidationError):
annonce_service.create_annonce(db, "", "Contenu", "sub-1")
def test_ut03_create_annonce_contenu_vide(db):
with pytest.raises(annonce_service.ValidationError):
annonce_service.create_annonce(db, "Titre", " ", "sub-1")
def test_ut04_update_annonce_existante(db):
new_id = annonce_service.create_annonce(db, "Avant", "Avant contenu", "sub-1")
annonce_service.update_annonce(db, new_id, "Après", "Après contenu", "sub-1")
row = annonce_model.get(db, new_id)
assert row["titre"] == "Après"
def test_ut05_update_annonce_inexistante(db):
with pytest.raises(annonce_model.AnnonceNotFoundError):
annonce_service.update_annonce(db, 999999, "Titre", "Contenu", "sub-1")
def test_ut05b_delete_annonce_inexistante(db):
with pytest.raises(annonce_model.AnnonceNotFoundError):
annonce_service.delete_annonce(db, 999999, "sub-1")

View File

@@ -0,0 +1,22 @@
"""Tests unitaires du service d'authentification — UT-09 à UT-12."""
from services.auth_service import is_admin
def test_ut09_is_admin_avec_groupe_admin():
user = {"sub": "1", "groups": ["/admin", "/staff"]}
assert is_admin(user) is True
def test_ut10_is_admin_sans_groupe_admin():
user = {"sub": "1", "groups": ["/staff"]}
assert is_admin(user) is False
def test_ut11_is_admin_utilisateur_none():
assert is_admin(None) is False
def test_ut12_is_admin_sans_champ_groups():
user = {"sub": "1"}
assert is_admin(user) is False

View File

@@ -0,0 +1,56 @@
"""Tests unitaires du module Fichiers — UT-06 à UT-08 (décomposition en 8 étapes)."""
import io
import pytest
from werkzeug.datastructures import FileStorage
from services import fichier_service
def _file_storage(content=b"contenu de test", filename="rapport.pdf", mimetype="application/pdf"):
return FileStorage(stream=io.BytesIO(content), filename=filename, content_type=mimetype)
def test_ut06_fichier_trop_grand():
fs = _file_storage(content=b"x" * 100)
with pytest.raises(fichier_service.FileValidationError):
fichier_service.step2_check_size(fs, max_size=50)
def test_ut06b_fichier_vide():
fs = _file_storage(content=b"")
with pytest.raises(fichier_service.FileValidationError):
fichier_service.step2_check_size(fs)
def test_ut07_extension_interdite():
with pytest.raises(fichier_service.FileValidationError):
fichier_service.step4_check_extension("script.exe")
def test_ut07b_extension_autorisee():
ext = fichier_service.step4_check_extension("rapport.pdf")
assert ext == "pdf"
def test_ut08_nom_genere_est_unique():
name1 = fichier_service.step5_generate_safe_name("pdf")
name2 = fichier_service.step5_generate_safe_name("pdf")
assert name1 != name2
assert name1.endswith(".pdf")
def test_ut08b_presence_fichier_absent():
with pytest.raises(fichier_service.FileValidationError):
fichier_service.step1_check_presence(None)
def test_upload_complet_end_to_end(db, tmp_path, monkeypatch):
"""Vérifie l'enchaînement complet des 8 étapes avec un fichier valide."""
from config import Config
monkeypatch.setattr(Config, "UPLOAD_DIR", str(tmp_path))
fs = _file_storage(content=b"%PDF-1.4 contenu factice", filename="doc.pdf", mimetype="application/pdf")
fichier_id = fichier_service.handle_upload(db, fs, "sub-1", ip="127.0.0.1")
assert fichier_id is not None