transfer from private server/git server
This commit is contained in:
0
web/tests/__init__.py
Normal file
0
web/tests/__init__.py
Normal file
88
web/tests/conftest.py
Normal file
88
web/tests/conftest.py
Normal 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
|
||||
73
web/tests/test_integration_routes.py
Normal file
73
web/tests/test_integration_routes.py
Normal 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
97
web/tests/test_rgpd.py
Normal 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
|
||||
89
web/tests/test_security.py
Normal file
89
web/tests/test_security.py
Normal 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 "<script>" 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 !"
|
||||
41
web/tests/test_unit_annonce_service.py
Normal file
41
web/tests/test_unit_annonce_service.py
Normal 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")
|
||||
22
web/tests/test_unit_auth_service.py
Normal file
22
web/tests/test_unit_auth_service.py
Normal 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
|
||||
56
web/tests/test_unit_fichier_service.py
Normal file
56
web/tests/test_unit_fichier_service.py
Normal 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
|
||||
Reference in New Issue
Block a user