"""Sprint S6 — garde-fous de reproductibilité institutionnelle. Ces tests verrouillent les contraintes de déploiement BnF : S6.1 / S6.2 — Tesseract version pinée dans Dockerfile S6.3 — Bornes supérieures sur les dépendances Python S6.4 — OLLAMA_ORIGINS restreint en docker-compose """ from __future__ import annotations import re from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] # ────────────────────────────────────────────────────────────────────── # S6.2 — Le Dockerfile pin Tesseract à une version Debian précise # ────────────────────────────────────────────────────────────────────── class TestTesseractInDockerfile: """Le Dockerfile doit installer ``tesseract-ocr`` + les 6 modèles de langues du corpus institutionnel BnF (fra, lat, eng, deu, ita, spa). Sprint S6.1 a tenté un pin exact ``=5.3.0-2`` mais Debian point-release rebump fréquemment, cassant le build. La reproductibilité passe désormais par : 1. Base image Python pinée par digest SHA256. 2. ``requirements-docker.lock`` côté Python. 3. ``RunManifest.dependencies_lock`` qui capture la version Tesseract effective au runtime (``tesseract --version``). """ def setup_method(self) -> None: self.text = (REPO_ROOT / "Dockerfile").read_text(encoding="utf-8") def test_tesseract_ocr_installed(self) -> None: # Pattern : ``tesseract-ocr`` au début d'un mot (suivi de # whitespace, ``\``, ou ``=``), pour ne pas matcher # ``tesseract-ocr-fra`` etc. assert re.search(r"\btesseract-ocr(?:[\s\\=]|$)", self.text), ( "Le Dockerfile n'installe pas ``tesseract-ocr``." ) def test_all_language_models_installed(self) -> None: """Les modèles de langues du corpus BnF doivent tous être installés (fra, lat, eng, deu, ita, spa). """ languages = ("fra", "lat", "eng", "deu", "ita", "spa") for lang in languages: pattern = rf"tesseract-ocr-{lang}(?:[\s\\=]|$)" assert re.search(pattern, self.text), ( f"Modèle ``tesseract-ocr-{lang}`` non installé dans " f"le Dockerfile." ) # ────────────────────────────────────────────────────────────────────── # S6.3 — Bornes supérieures sur les dépendances core # ────────────────────────────────────────────────────────────────────── class TestDependencyUpperBounds: """Sans borne supérieure, ``pip install picarones`` en 2027 peut remonter ``click==9.0`` qui casse l'API.""" def setup_method(self) -> None: self.text = (REPO_ROOT / "pyproject.toml").read_text( encoding="utf-8", ) def test_core_deps_have_upper_bound(self) -> None: """Chaque dépendance core listée doit avoir un caplock ``=X.Y..."``. dep_lines = re.findall(r'"([a-zA-Z][\w\-]*)>=[^"]+"', block) unbounded = [] for name in dep_lines: # Pattern ``"name>=X.Y...,=X.Y...,=[^"]+,\s*<[^"]+"' if not re.search(pattern, block): unbounded.append(name) assert not unbounded, ( f"Dépendances sans borne supérieure : {unbounded}.\n" f"Ajouter `` None: text = (REPO_ROOT / "docker-compose.yml").read_text( encoding="utf-8", ) # ``OLLAMA_ORIGINS=*`` brut (sans variable d'env override) # doit être absent. assert "OLLAMA_ORIGINS=*" not in text, ( "``docker-compose.yml`` configure ``OLLAMA_ORIGINS=*`` " "qui désactive la protection CORS de Ollama. Restreindre " "à un origin explicite ou à une variable d'env " "``${OLLAMA_ORIGINS}`` avec un défaut sécurisé." ) def test_docker_compose_uses_env_override_for_ollama_origins( self, ) -> None: """La config doit utiliser la forme ``${OLLAMA_ORIGINS:-...}`` avec un défaut sécurisé pour permettre une override contrôlée par l'opérateur.""" text = (REPO_ROOT / "docker-compose.yml").read_text( encoding="utf-8", ) assert "OLLAMA_ORIGINS=${OLLAMA_ORIGINS" in text, ( "``docker-compose.yml`` doit utiliser " "``OLLAMA_ORIGINS=${OLLAMA_ORIGINS:-...}`` pour permettre " "une override par variable d'env (avec un défaut " "restrictif)." ) class TestComposeStartabilityCoherence: """Régression P0 — ``docker compose up`` (chemin local documenté) doit démarrer SANS configuration : un défaut ``CSRF_REQUIRED=1`` sans ``CSRF_SECRET`` fait échouer ``validate_csrf_config`` au lifespan. Le durcissement CSRF vit dans l'override prod, qui EXIGE le secret via la substitution Compose ``:?``.""" def _compose(self, name: str) -> str: return (REPO_ROOT / name).read_text(encoding="utf-8") def test_local_compose_does_not_force_csrf_required(self) -> None: text = self._compose("docker-compose.yml") assert "PICARONES_CSRF_REQUIRED=${PICARONES_CSRF_REQUIRED:-0}" in text, ( "Le compose local ne doit PAS forcer CSRF_REQUIRED=1 : " "sans CSRF_SECRET, validate_csrf_config refuse le " "démarrage et ``docker compose up`` casse." ) def test_local_compose_default_env_passes_startup_guards(self) -> None: """Simule les defaults du compose local et vérifie que les garde-fous lifespan ne lèvent pas.""" import os from picarones.interfaces.web.security import ( check_deployment_coherence, validate_csrf_config, ) snapshot = dict(os.environ) try: for k in ( "PICARONES_CSRF_REQUIRED", "PICARONES_CSRF_SECRET", "PICARONES_SECURE_COOKIES", "SPACE_ID", ): os.environ.pop(k, None) os.environ["PICARONES_PUBLIC_MODE"] = "1" # défaut compose local validate_csrf_config() # ne doit pas lever check_deployment_coherence() # ne doit pas lever finally: os.environ.clear() os.environ.update(snapshot) def test_prod_override_requires_csrf_secret(self) -> None: """``docker-compose.prod.yml`` doit exiger le secret via la substitution Compose ``${PICARONES_CSRF_SECRET:?...}`` — Compose refuse AVANT de lancer le conteneur, message clair.""" text = self._compose("docker-compose.prod.yml") assert "PICARONES_CSRF_SECRET=${PICARONES_CSRF_SECRET:?" in text, ( "L'override prod doit rendre le secret OBLIGATOIRE " "(forme ``:?`` — échec Compose explicite, pas un crash " "conteneur au lifespan)." ) assert "PICARONES_CSRF_REQUIRED=1" in text assert "PICARONES_SECURE_COOKIES=1" in text class TestDockerPortCoherence: """Régression P0.4 — le conteneur sert sur 7860 (EXPOSE + CMD). Tout mapping ``8000:8000`` est cassé (rien n'écoute sur :8000 côté conteneur). Verrouille l'absence de drift Dockerfile / Makefile / compose.""" def test_dockerfile_serves_and_exposes_7860(self) -> None: text = (REPO_ROOT / "Dockerfile").read_text(encoding="utf-8") assert "EXPOSE 7860" in text assert '"--port", "7860"' in text assert "8000:8000" not in text, ( "commentaires Dockerfile : mapping 8000:8000 trompeur " "(le conteneur sert sur 7860)." ) def test_dockerfile_version_label_not_hardcoded(self) -> None: text = (REPO_ROOT / "Dockerfile").read_text(encoding="utf-8") assert 'LABEL version="1.0.0"' not in text, ( "version Docker figée à 1.0.0 — dérive de la version " "réelle (setuptools-scm). Utiliser ARG PICARONES_VERSION." ) assert "ARG PICARONES_VERSION" in text def test_makefile_docker_targets_use_7860(self) -> None: text = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") # La cible docker-run mappait 8000:8000 → conteneur muet. assert "-p 8000:8000" not in text, ( "make docker-run mappait 8000:8000 alors que le conteneur " "sert sur 7860 — cible cassée." ) assert "-p 7860:7860" in text assert "picarones:1.0.0" not in text, ( "tag Docker figé picarones:1.0.0 — dériver la version." )