Picarones / tests /architecture /test_deployment_invariants.py
Claude
test(rename): dé-sprintage tests/architecture (8 fichiers, git mv)
1181ae1 unverified
Raw
History Blame
10.4 kB
"""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.0`` (où X est la majeure suivante)."""
# Cherche le bloc ``dependencies = [...]``.
m = re.search(
r"^dependencies\s*=\s*\[(.*?)^\]",
self.text,
re.DOTALL | re.MULTILINE,
)
assert m, "Bloc ``dependencies`` introuvable dans pyproject.toml"
block = m.group(1)
# Liste les lignes ``"name>=X.Y..."``.
dep_lines = re.findall(r'"([a-zA-Z][\w\-]*)>=[^"]+"', block)
unbounded = []
for name in dep_lines:
# Pattern ``"name>=X.Y...,<Z..."`` ou ``"name>=X.Y...,<Z.W"``
pattern = rf'"{re.escape(name)}>=[^"]+,\s*<[^"]+"'
if not re.search(pattern, block):
unbounded.append(name)
assert not unbounded, (
f"Dépendances sans borne supérieure : {unbounded}.\n"
f"Ajouter ``<MAJEURE_SUIVANTE.0`` à chacune dans "
f"pyproject.toml."
)
# ──────────────────────────────────────────────────────────────────────
# S6.4 — OLLAMA_ORIGINS n'est pas ``*`` par défaut
# ──────────────────────────────────────────────────────────────────────
class TestOllamaOriginsRestricted:
"""``OLLAMA_ORIGINS=*`` permet à n'importe quel site web d'appeler
l'API Ollama interne via le navigateur de l'utilisateur (CSRF
cross-origin)."""
def test_docker_compose_does_not_set_ollama_origins_wildcard(
self,
) -> 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."
)