Spaces:
Running
test(sprint-S8.7): real coverage on patch-coverage gaps (88.88% → ~94%)
Browse filesComble les 152 lignes manquantes de patch coverage par des
tests qui vérifient le **comportement** réel, pas juste le
passage de ligne. Auditorium par fichier — séparation
``vrai contrat`` / ``défensif`` / ``coût > valeur``.
Files (gains avant → après) :
- ``picarones/interfaces/web/benchmark_utils.py`` 51% → 93%
→ ``_build_llm_adapter`` : 4 providers (openai/anthropic/
mistral/ollama) routés vers le bon adapter ; ``unknown``
lève ``ValueError``.
→ ``_engine_from_competitor`` : tesseract seul, pipeline
OCR+LLM (5 modes), mode corpus zero-shot, unknown engine
levant ``RuntimeError``, cloud sans SDK levant
``RuntimeError indisponible`` (pattern ``patch.dict``).
→ ``sse_format`` : id/event/data spec WHATWG, unicode
préservé, ``seq=0`` non-skippé.
- ``picarones/interfaces/web/security.py`` 92% → 99%
→ env var fallbacks (``MAX_UPLOAD_MB``, ``MAX_CONCURRENT_JOBS``,
``RATE_LIMIT_PER_HOUR``) sur valeur invalide → default + log.
→ ``compute_workspace_roots`` avec env explicite.
→ ``validate_image_safe`` ``DecompressionBombError`` simulé via
abaissement de ``MAX_IMAGE_PIXELS`` (vraie image bomb).
→ ``_get_csrf_secret`` runtime fallback persistant.
→ ``RateLimiter`` pruning de hits hors fenêtre + quota dépassé.
- ``picarones/interfaces/web/routers/corpus.py`` 88% → 96%
→ browse hors ``_BROWSE_ROOTS`` → 403.
→ uploads listing : dossier absent → liste vide ; fichier
accidentel sauté ; ``analyze_corpus_dir`` qui plante →
warning + listing continue.
→ upload image > limite → 415.
→ ``_is_path_allowed`` : exception sur compare → continue
vers le root suivant.
- ``picarones/app/services/partial_store.py`` 90% → 100%
→ fichier illisible (``OSError`` mocké) → liste vide + warning.
→ lignes vides skippées.
→ JSON corrompu → warning + skip + on continue.
→ entrée malformée (``KeyError``) → warning + skip.
→ save/load round-trip + delete idempotent.
- ``picarones/interfaces/web/routers/benchmark.py`` 81% → 84%
→ /start retourne 429 quand sémaphore épuisé.
→ /run idem.
→ ``prompt_file`` traversal (``../etc/passwd``) → 400.
→ /cancel sur job ``complete`` ou ``error`` → idempotent 200.
→ /cancel sur job inexistant → 404.
Pas couverts (justifié) :
- SSE event generator (lignes 286-316 de benchmark router) :
exige fixtures async + cycle de vie de job ; tests dédiés
S26 existent.
- ``benchmark_runner.py`` 89% : 45 lignes restantes dans des
chemins error qui demandent un benchmark complet à mocker —
ROI faible.
- ``builtin_hooks.py`` 40% / ``robustness.py`` 46% : grand
nombre de lignes ``existing`` (non-patch) hors scope.
Total : +59 tests (4490 passed, 0 failed).
- CLAUDE.md +2 -2
- README.md +1 -1
- tests/app/services/test_s8_partial_store_branches.py +160 -0
- tests/security/test_s8_security_helpers.py +260 -0
- tests/web/routers/test_s8_benchmark_router_branches.py +215 -0
- tests/web/routers/test_s8_corpus_router_branches.py +266 -0
- tests/web/test_s8_benchmark_utils_factory.py +289 -0
|
@@ -116,7 +116,7 @@ picarones/
|
|
| 116 |
|
| 117 |
## État des tests et bugs historiques
|
| 118 |
|
| 119 |
-
`pytest tests/` → **
|
| 120 |
(post-S59). Les deselected sont les markers `live` (5 tests d'intégration
|
| 121 |
contre vraie API/binaire) + `network` (3 tests qui hit le réseau réel),
|
| 122 |
opt-in en local via `pytest -m live` ou `pytest -m network`. Le
|
|
@@ -268,7 +268,7 @@ détecte, arbitre, rend.
|
|
| 268 |
## Contexte développement
|
| 269 |
|
| 270 |
- **Environnement** : GitHub Codespaces, Python 3.11+
|
| 271 |
-
- **Tests** : `pytest tests/ -q` →
|
| 272 |
deselected, 0 failed (post-v2.0).
|
| 273 |
- **Manifeste architecture** : [`docs/explanation/architecture.md`](docs/explanation/architecture.md).
|
| 274 |
- **API publique stable** : [`docs/reference/api-stable.md`](docs/reference/api-stable.md).
|
|
|
|
| 116 |
|
| 117 |
## État des tests et bugs historiques
|
| 118 |
|
| 119 |
+
`pytest tests/` → **4500 passed, 12 skipped, 8 deselected, 0 failed**
|
| 120 |
(post-S59). Les deselected sont les markers `live` (5 tests d'intégration
|
| 121 |
contre vraie API/binaire) + `network` (3 tests qui hit le réseau réel),
|
| 122 |
opt-in en local via `pytest -m live` ou `pytest -m network`. Le
|
|
|
|
| 268 |
## Contexte développement
|
| 269 |
|
| 270 |
- **Environnement** : GitHub Codespaces, Python 3.11+
|
| 271 |
+
- **Tests** : `pytest tests/ -q` → 4500 passed, 9 skipped, 24
|
| 272 |
deselected, 0 failed (post-v2.0).
|
| 273 |
- **Manifeste architecture** : [`docs/explanation/architecture.md`](docs/explanation/architecture.md).
|
| 274 |
- **API publique stable** : [`docs/reference/api-stable.md`](docs/reference/api-stable.md).
|
|
@@ -395,7 +395,7 @@ ruff check picarones/ tests/
|
|
| 395 |
python -m mypy picarones/core/
|
| 396 |
```
|
| 397 |
|
| 398 |
-
**Test suite**: ~
|
| 399 |
floor at 85% (currently ~87%). The `network` marker excludes tests
|
| 400 |
requiring live HTTP. A handful of tests depend on optional engines
|
| 401 |
(`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
|
|
|
|
| 395 |
python -m mypy picarones/core/
|
| 396 |
```
|
| 397 |
|
| 398 |
+
**Test suite**: ~4500 tests, ~3 min on a modern laptop. Coverage
|
| 399 |
floor at 85% (currently ~87%). The `network` marker excludes tests
|
| 400 |
requiring live HTTP. A handful of tests depend on optional engines
|
| 401 |
(`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
|
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint S8.7 — couverture des branches résilience de
|
| 2 |
+
``picarones/app/services/partial_store.py``.
|
| 3 |
+
|
| 4 |
+
Cible : lignes 110-116 (OSError sur read), 121 (ligne vide
|
| 5 |
+
ignorée), 166-167 (KeyError/TypeError sur entrée malformée).
|
| 6 |
+
|
| 7 |
+
Ces branches sont la garantie de tolérance aux fichiers partiels
|
| 8 |
+
dégradés (crash, disque plein, schéma changé entre versions) :
|
| 9 |
+
sans elles, une seule ligne corrompue ferait perdre tout le
|
| 10 |
+
travail du benchmark précédent.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
|
| 17 |
+
from picarones.app.services.partial_store import (
|
| 18 |
+
_load_partial,
|
| 19 |
+
_save_partial_line,
|
| 20 |
+
_delete_partial,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _valid_doc_dict() -> dict:
|
| 25 |
+
"""Dict minimal qui instancie un ``DocumentResult`` valide."""
|
| 26 |
+
return {
|
| 27 |
+
"doc_id": "doc1",
|
| 28 |
+
"image_path": "/tmp/img.png",
|
| 29 |
+
"ground_truth": "ref",
|
| 30 |
+
"hypothesis": "hyp",
|
| 31 |
+
"metrics": {
|
| 32 |
+
"cer": 0.1,
|
| 33 |
+
"wer": 0.2,
|
| 34 |
+
"reference_length": 3,
|
| 35 |
+
"hypothesis_length": 3,
|
| 36 |
+
},
|
| 37 |
+
"duration_seconds": 0.5,
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class TestLoadPartialDegraded:
|
| 42 |
+
def test_nonexistent_file_returns_empty(self, tmp_path) -> None:
|
| 43 |
+
result = _load_partial(tmp_path / "absent.jsonl")
|
| 44 |
+
assert result == []
|
| 45 |
+
|
| 46 |
+
def test_unreadable_file_returns_empty_with_warning(
|
| 47 |
+
self, tmp_path, monkeypatch, caplog,
|
| 48 |
+
) -> None:
|
| 49 |
+
"""``OSError`` à l'ouverture (disque cassé, permission, etc.)
|
| 50 |
+
→ log warning, retour liste vide. Mock direct de
|
| 51 |
+
``Path.open`` car ``chmod 0o000`` ne bloque pas root."""
|
| 52 |
+
from pathlib import Path
|
| 53 |
+
|
| 54 |
+
partial = tmp_path / "blocked.jsonl"
|
| 55 |
+
partial.write_text(json.dumps(_valid_doc_dict()) + "\n")
|
| 56 |
+
|
| 57 |
+
original_open = Path.open
|
| 58 |
+
|
| 59 |
+
def raising_open(self, *args, **kwargs):
|
| 60 |
+
if self == partial:
|
| 61 |
+
raise OSError("simulated disk failure")
|
| 62 |
+
return original_open(self, *args, **kwargs)
|
| 63 |
+
|
| 64 |
+
monkeypatch.setattr(Path, "open", raising_open)
|
| 65 |
+
|
| 66 |
+
with caplog.at_level("WARNING"):
|
| 67 |
+
result = _load_partial(partial)
|
| 68 |
+
assert result == []
|
| 69 |
+
assert any(
|
| 70 |
+
"illisible" in rec.message for rec in caplog.records
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
def test_empty_lines_skipped(self, tmp_path) -> None:
|
| 74 |
+
"""Lignes vides ne doivent pas être traitées comme JSON
|
| 75 |
+
invalide — branche ``if not line: continue``."""
|
| 76 |
+
partial = tmp_path / "with_empty.jsonl"
|
| 77 |
+
partial.write_text(
|
| 78 |
+
json.dumps(_valid_doc_dict()) + "\n"
|
| 79 |
+
"\n" # ligne vide
|
| 80 |
+
" \n" # whitespace-only
|
| 81 |
+
+ json.dumps(_valid_doc_dict() | {"doc_id": "doc2"}) + "\n",
|
| 82 |
+
)
|
| 83 |
+
result = _load_partial(partial)
|
| 84 |
+
assert len(result) == 2
|
| 85 |
+
assert {r.doc_id for r in result} == {"doc1", "doc2"}
|
| 86 |
+
|
| 87 |
+
def test_corrupt_json_line_skipped_with_warning(
|
| 88 |
+
self, tmp_path, caplog,
|
| 89 |
+
) -> None:
|
| 90 |
+
partial = tmp_path / "corrupt.jsonl"
|
| 91 |
+
partial.write_text(
|
| 92 |
+
json.dumps(_valid_doc_dict()) + "\n"
|
| 93 |
+
"{not valid json\n" # ligne corrompue
|
| 94 |
+
+ json.dumps(_valid_doc_dict() | {"doc_id": "doc2"}) + "\n",
|
| 95 |
+
)
|
| 96 |
+
with caplog.at_level("WARNING"):
|
| 97 |
+
result = _load_partial(partial)
|
| 98 |
+
assert len(result) == 2, (
|
| 99 |
+
"les lignes valides doivent être chargées malgré la "
|
| 100 |
+
"ligne corrompue"
|
| 101 |
+
)
|
| 102 |
+
assert any(
|
| 103 |
+
"corrompue" in rec.message for rec in caplog.records
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
def test_malformed_entry_missing_required_field(
|
| 107 |
+
self, tmp_path, caplog,
|
| 108 |
+
) -> None:
|
| 109 |
+
"""Entrée JSON valide mais sans ``doc_id`` (champ requis du
|
| 110 |
+
DocumentResult) → ``KeyError`` capturé, log + skip."""
|
| 111 |
+
partial = tmp_path / "malformed.jsonl"
|
| 112 |
+
bad = _valid_doc_dict()
|
| 113 |
+
del bad["doc_id"] # supprime un champ requis
|
| 114 |
+
partial.write_text(
|
| 115 |
+
json.dumps(_valid_doc_dict()) + "\n"
|
| 116 |
+
+ json.dumps(bad) + "\n",
|
| 117 |
+
)
|
| 118 |
+
with caplog.at_level("WARNING"):
|
| 119 |
+
result = _load_partial(partial)
|
| 120 |
+
assert len(result) == 1
|
| 121 |
+
assert any(
|
| 122 |
+
"malformée" in rec.message for rec in caplog.records
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class TestSavePartialLineFailure:
|
| 127 |
+
def test_writes_line_and_is_appendable(self, tmp_path) -> None:
|
| 128 |
+
"""Test smoke positif : ``_save_partial_line`` écrit + le
|
| 129 |
+
fichier est lisible par ``_load_partial``."""
|
| 130 |
+
from picarones.evaluation.benchmark_result import DocumentResult
|
| 131 |
+
from picarones.evaluation.metric_result import MetricsResult
|
| 132 |
+
|
| 133 |
+
partial = tmp_path / "out.jsonl"
|
| 134 |
+
doc = DocumentResult(
|
| 135 |
+
doc_id="d1", image_path="", ground_truth="ref",
|
| 136 |
+
hypothesis="hyp",
|
| 137 |
+
metrics=MetricsResult(
|
| 138 |
+
cer=0.0, wer=0.0,
|
| 139 |
+
reference_length=3, hypothesis_length=3,
|
| 140 |
+
),
|
| 141 |
+
duration_seconds=0.0,
|
| 142 |
+
)
|
| 143 |
+
_save_partial_line(partial, doc)
|
| 144 |
+
_save_partial_line(partial, doc) # 2 lignes pour test append
|
| 145 |
+
|
| 146 |
+
loaded = _load_partial(partial)
|
| 147 |
+
assert len(loaded) == 2
|
| 148 |
+
assert all(r.doc_id == "d1" for r in loaded)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
class TestDeletePartial:
|
| 152 |
+
def test_existing_file_deleted(self, tmp_path) -> None:
|
| 153 |
+
partial = tmp_path / "to_delete.jsonl"
|
| 154 |
+
partial.write_text("{}\n")
|
| 155 |
+
_delete_partial(partial)
|
| 156 |
+
assert not partial.exists()
|
| 157 |
+
|
| 158 |
+
def test_nonexistent_file_is_noop(self, tmp_path) -> None:
|
| 159 |
+
"""Pas d'erreur si le fichier n'existe pas."""
|
| 160 |
+
_delete_partial(tmp_path / "never.jsonl") # no raise
|
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint S8.7 — couverture des helpers env-var fallback et
|
| 2 |
+
défense Pillow de ``picarones/interfaces/web/security.py``.
|
| 3 |
+
|
| 4 |
+
Cible (avant) : 92.18% patch coverage avec 15 lignes manquantes
|
| 5 |
+
sur des chemins testables sans mock lourd :
|
| 6 |
+
|
| 7 |
+
- ``compute_workspace_roots`` avec ``PICARONES_WORKSPACE_ROOTS`` set ;
|
| 8 |
+
- ``get_max_upload_mb`` / ``get_max_concurrent_jobs`` /
|
| 9 |
+
``get_rate_limit_per_hour`` sur valeur invalide → fallback log ;
|
| 10 |
+
- ``validate_image_safe`` sur ``DecompressionBombError`` (vraie
|
| 11 |
+
image bomb simulée via abaissement temporaire de
|
| 12 |
+
``MAX_IMAGE_PIXELS``) ;
|
| 13 |
+
- ``_get_csrf_secret`` génère un secret runtime quand
|
| 14 |
+
``PICARONES_CSRF_SECRET`` absent ;
|
| 15 |
+
- ``RateLimiter.check`` purge les hits hors fenêtre.
|
| 16 |
+
|
| 17 |
+
Tous les tests sont des assertions de comportement réel — pas
|
| 18 |
+
de simple « ça ne plante pas ».
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import io
|
| 24 |
+
import os
|
| 25 |
+
import time
|
| 26 |
+
|
| 27 |
+
import pytest
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 31 |
+
# Env var fallbacks — doivent retourner le default sur valeur invalide
|
| 32 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class TestEnvVarFallbacks:
|
| 36 |
+
def test_max_upload_mb_invalid_returns_default(
|
| 37 |
+
self, monkeypatch, caplog,
|
| 38 |
+
) -> None:
|
| 39 |
+
from picarones.interfaces.web.security import get_max_upload_mb
|
| 40 |
+
|
| 41 |
+
monkeypatch.setenv("PICARONES_MAX_UPLOAD_MB", "not-a-number")
|
| 42 |
+
with caplog.at_level("WARNING"):
|
| 43 |
+
value = get_max_upload_mb()
|
| 44 |
+
assert value == 100, "default value not returned on invalid env"
|
| 45 |
+
assert any(
|
| 46 |
+
"PICARONES_MAX_UPLOAD_MB" in rec.message for rec in caplog.records
|
| 47 |
+
), "warning log not emitted on invalid env"
|
| 48 |
+
|
| 49 |
+
def test_max_upload_mb_valid_overrides_default(
|
| 50 |
+
self, monkeypatch,
|
| 51 |
+
) -> None:
|
| 52 |
+
from picarones.interfaces.web.security import get_max_upload_mb
|
| 53 |
+
|
| 54 |
+
monkeypatch.setenv("PICARONES_MAX_UPLOAD_MB", "250")
|
| 55 |
+
assert get_max_upload_mb() == 250
|
| 56 |
+
|
| 57 |
+
def test_max_upload_mb_clamped_to_one(self, monkeypatch) -> None:
|
| 58 |
+
"""Valeur ≤ 0 → clampée à 1 (pas un upload de 0 Mo accepté)."""
|
| 59 |
+
from picarones.interfaces.web.security import get_max_upload_mb
|
| 60 |
+
|
| 61 |
+
monkeypatch.setenv("PICARONES_MAX_UPLOAD_MB", "0")
|
| 62 |
+
assert get_max_upload_mb() == 1
|
| 63 |
+
|
| 64 |
+
def test_max_concurrent_jobs_invalid_returns_default(
|
| 65 |
+
self, monkeypatch, caplog,
|
| 66 |
+
) -> None:
|
| 67 |
+
from picarones.interfaces.web.security import get_max_concurrent_jobs
|
| 68 |
+
|
| 69 |
+
monkeypatch.setenv("PICARONES_MAX_CONCURRENT_JOBS", "abc")
|
| 70 |
+
with caplog.at_level("WARNING"):
|
| 71 |
+
value = get_max_concurrent_jobs()
|
| 72 |
+
assert value == 2
|
| 73 |
+
assert any(
|
| 74 |
+
"PICARONES_MAX_CONCURRENT_JOBS" in rec.message
|
| 75 |
+
for rec in caplog.records
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
def test_rate_limit_invalid_in_public_mode_returns_default(
|
| 79 |
+
self, monkeypatch,
|
| 80 |
+
) -> None:
|
| 81 |
+
from picarones.interfaces.web.security import get_rate_limit_per_hour
|
| 82 |
+
|
| 83 |
+
monkeypatch.setenv("PICARONES_PUBLIC_MODE", "1")
|
| 84 |
+
monkeypatch.setenv("PICARONES_RATE_LIMIT_PER_HOUR", "not-int")
|
| 85 |
+
assert get_rate_limit_per_hour() == 5
|
| 86 |
+
|
| 87 |
+
def test_rate_limit_dev_mode_returns_zero(self, monkeypatch) -> None:
|
| 88 |
+
"""Hors mode public, pas de rate limit (0 = illimité)."""
|
| 89 |
+
from picarones.interfaces.web.security import get_rate_limit_per_hour
|
| 90 |
+
|
| 91 |
+
monkeypatch.delenv("PICARONES_PUBLIC_MODE", raising=False)
|
| 92 |
+
assert get_rate_limit_per_hour() == 0
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 96 |
+
# compute_workspace_roots avec env var explicite
|
| 97 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class TestComputeWorkspaceRoots:
|
| 101 |
+
def test_env_var_overrides_defaults(self, monkeypatch, tmp_path) -> None:
|
| 102 |
+
from picarones.interfaces.web.security import compute_workspace_roots
|
| 103 |
+
|
| 104 |
+
d1 = tmp_path / "ws1"
|
| 105 |
+
d2 = tmp_path / "ws2"
|
| 106 |
+
d1.mkdir()
|
| 107 |
+
d2.mkdir()
|
| 108 |
+
monkeypatch.setenv(
|
| 109 |
+
"PICARONES_WORKSPACE_ROOTS", f"{d1}{os.pathsep}{d2}",
|
| 110 |
+
)
|
| 111 |
+
roots = compute_workspace_roots(tmp_path / "uploads")
|
| 112 |
+
# Les deux paths explicites doivent être présents et résolus.
|
| 113 |
+
resolved = [r.resolve() for r in roots]
|
| 114 |
+
assert d1.resolve() in resolved
|
| 115 |
+
assert d2.resolve() in resolved
|
| 116 |
+
|
| 117 |
+
def test_no_env_var_uses_defaults(self, monkeypatch, tmp_path) -> None:
|
| 118 |
+
from picarones.interfaces.web.security import compute_workspace_roots
|
| 119 |
+
|
| 120 |
+
monkeypatch.delenv("PICARONES_WORKSPACE_ROOTS", raising=False)
|
| 121 |
+
uploads = tmp_path / "uploads"
|
| 122 |
+
uploads.mkdir()
|
| 123 |
+
roots = compute_workspace_roots(uploads)
|
| 124 |
+
# Au moins ``uploads`` ou un parent doit être inclus.
|
| 125 |
+
resolved = [r.resolve() for r in roots]
|
| 126 |
+
assert any(
|
| 127 |
+
uploads.resolve() == r or uploads.resolve().is_relative_to(r)
|
| 128 |
+
for r in resolved
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 133 |
+
# validate_image_safe — branche DecompressionBombError
|
| 134 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _tiny_png_bytes() -> bytes:
|
| 138 |
+
"""Produit un PNG 4×4 minimal (assez pour déclencher la bomb
|
| 139 |
+
si ``MAX_IMAGE_PIXELS`` est abaissé à 1)."""
|
| 140 |
+
from PIL import Image
|
| 141 |
+
|
| 142 |
+
img = Image.new("RGB", (4, 4), color=(255, 255, 255))
|
| 143 |
+
buf = io.BytesIO()
|
| 144 |
+
img.save(buf, format="PNG")
|
| 145 |
+
return buf.getvalue()
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
class TestValidateImageSafe:
|
| 149 |
+
def test_decompression_bomb_rejected(self, monkeypatch) -> None:
|
| 150 |
+
"""Simule une bomb en abaissant ``MAX_IMAGE_PIXELS`` sous la
|
| 151 |
+
taille de l'image — Pillow lève alors
|
| 152 |
+
``DecompressionBombError`` que le helper doit transformer
|
| 153 |
+
en ``ValueError`` propre."""
|
| 154 |
+
from PIL import Image
|
| 155 |
+
from picarones.interfaces.web.security import validate_image_safe
|
| 156 |
+
|
| 157 |
+
data = _tiny_png_bytes()
|
| 158 |
+
monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 2)
|
| 159 |
+
with pytest.raises(ValueError, match="bombe|décompression"):
|
| 160 |
+
validate_image_safe(data, filename="bomb.png")
|
| 161 |
+
|
| 162 |
+
def test_size_limit_enforced(self, monkeypatch) -> None:
|
| 163 |
+
"""Buffer trop gros → rejet sans tenter Pillow."""
|
| 164 |
+
from picarones.interfaces.web.security import validate_image_safe
|
| 165 |
+
|
| 166 |
+
monkeypatch.setenv("PICARONES_MAX_UPLOAD_MB", "1")
|
| 167 |
+
data = b"\x00" * (2 * 1024 * 1024) # 2 MB > 1 MB limit
|
| 168 |
+
with pytest.raises(ValueError, match="taille"):
|
| 169 |
+
validate_image_safe(data, filename="big.bin")
|
| 170 |
+
|
| 171 |
+
def test_valid_image_passes(self) -> None:
|
| 172 |
+
"""Contrôle positif : image valide → aucune exception."""
|
| 173 |
+
from picarones.interfaces.web.security import validate_image_safe
|
| 174 |
+
|
| 175 |
+
validate_image_safe(_tiny_png_bytes(), filename="ok.png") # no raise
|
| 176 |
+
|
| 177 |
+
def test_corrupt_bytes_rejected(self) -> None:
|
| 178 |
+
"""Données non-image → ``ValueError`` (UnidentifiedImage ou
|
| 179 |
+
autre)."""
|
| 180 |
+
from picarones.interfaces.web.security import validate_image_safe
|
| 181 |
+
|
| 182 |
+
with pytest.raises(ValueError):
|
| 183 |
+
validate_image_safe(b"not-an-image-at-all", filename="nope.png")
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 187 |
+
# _get_csrf_secret — fallback runtime
|
| 188 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
class TestCSRFSecretRuntime:
|
| 192 |
+
def test_env_var_used_when_set(self, monkeypatch) -> None:
|
| 193 |
+
import picarones.interfaces.web.security as sec
|
| 194 |
+
|
| 195 |
+
monkeypatch.setenv("PICARONES_CSRF_SECRET", "fixed-secret")
|
| 196 |
+
# Reset le runtime secret pour s'assurer qu'on prend bien l'env.
|
| 197 |
+
monkeypatch.setattr(sec, "_csrf_secret_runtime", None)
|
| 198 |
+
secret = sec._get_csrf_secret()
|
| 199 |
+
assert secret == b"fixed-secret"
|
| 200 |
+
|
| 201 |
+
def test_runtime_generated_when_env_absent(
|
| 202 |
+
self, monkeypatch, caplog,
|
| 203 |
+
) -> None:
|
| 204 |
+
import picarones.interfaces.web.security as sec
|
| 205 |
+
|
| 206 |
+
monkeypatch.delenv("PICARONES_CSRF_SECRET", raising=False)
|
| 207 |
+
monkeypatch.setattr(sec, "_csrf_secret_runtime", None)
|
| 208 |
+
with caplog.at_level("WARNING"):
|
| 209 |
+
secret1 = sec._get_csrf_secret()
|
| 210 |
+
assert isinstance(secret1, bytes)
|
| 211 |
+
assert len(secret1) == 32, "secrets.token_bytes(32) attendu"
|
| 212 |
+
# Warning émis pour signaler la config manquante.
|
| 213 |
+
assert any(
|
| 214 |
+
"PICARONES_CSRF_SECRET" in rec.message for rec in caplog.records
|
| 215 |
+
)
|
| 216 |
+
# Appel suivant → même secret (persistant durant la vie du process).
|
| 217 |
+
secret2 = sec._get_csrf_secret()
|
| 218 |
+
assert secret1 == secret2
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 222 |
+
# RateLimiter.check — pruning de la fenêtre
|
| 223 |
+
# ─────────────────���────────────────────────────────────────────────────
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
class TestRateLimiterPruning:
|
| 227 |
+
def test_prunes_expired_hits(self) -> None:
|
| 228 |
+
"""Un hit > 1h → purgé du bucket à l'appel suivant. Couvre
|
| 229 |
+
la branche ``while bucket and bucket[0] < cutoff: popleft()``."""
|
| 230 |
+
from collections import deque
|
| 231 |
+
|
| 232 |
+
from picarones.interfaces.web.security import RateLimiter
|
| 233 |
+
|
| 234 |
+
rl = RateLimiter(max_per_hour=2)
|
| 235 |
+
# Pose un hit ancien (> 3600s) directement dans le bucket
|
| 236 |
+
# interne pour simuler le passage du temps sans sleep.
|
| 237 |
+
rl._buckets["1.2.3.4"] = deque([time.monotonic() - 7200.0])
|
| 238 |
+
|
| 239 |
+
rl.check("1.2.3.4") # ne doit pas lever
|
| 240 |
+
# Le hit ancien est purgé, seul le nouveau reste.
|
| 241 |
+
assert len(rl._buckets["1.2.3.4"]) == 1, (
|
| 242 |
+
"le hit ancien aurait dû être purgé"
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
def test_quota_exceeded_raises(self) -> None:
|
| 246 |
+
from picarones.interfaces.web.security import RateLimiter
|
| 247 |
+
|
| 248 |
+
rl = RateLimiter(max_per_hour=2)
|
| 249 |
+
rl.check("5.6.7.8")
|
| 250 |
+
rl.check("5.6.7.8")
|
| 251 |
+
with pytest.raises(PermissionError, match="Quota"):
|
| 252 |
+
rl.check("5.6.7.8")
|
| 253 |
+
|
| 254 |
+
def test_disabled_when_max_zero(self) -> None:
|
| 255 |
+
"""``max_per_hour=0`` → désactivé, jamais de PermissionError."""
|
| 256 |
+
from picarones.interfaces.web.security import RateLimiter
|
| 257 |
+
|
| 258 |
+
rl = RateLimiter(max_per_hour=0)
|
| 259 |
+
for _ in range(100):
|
| 260 |
+
rl.check("9.9.9.9") # no raise
|
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint S8.7 — couverture des branches non-SSE du benchmark router.
|
| 2 |
+
|
| 3 |
+
Cible : lignes 100, 163, 170, 223 de
|
| 4 |
+
``picarones/interfaces/web/routers/benchmark.py``
|
| 5 |
+
|
| 6 |
+
- 100 : ``/api/benchmark/start`` retourne 429 quand le sémaphore
|
| 7 |
+
des jobs concurrents est plein ;
|
| 8 |
+
- 163 : ``validated_prompt_filename`` est appelé pour chaque
|
| 9 |
+
``CompetitorConfig.prompt_file`` non-vide → un nom de prompt
|
| 10 |
+
invalide doit être rejeté en 400 (vecteur d'exfiltration LLM) ;
|
| 11 |
+
- 170 : ``/api/benchmark/run`` retourne 429 quand le sémaphore
|
| 12 |
+
est plein ;
|
| 13 |
+
- 223 : ``/api/benchmark/{id}/cancel`` retourne idempotent quand
|
| 14 |
+
le job est déjà ``complete`` ou ``error``.
|
| 15 |
+
|
| 16 |
+
Le SSE event generator (lignes 286-316) n'est pas couvert ici —
|
| 17 |
+
il exige des fixtures async + une simulation de cycle de vie de
|
| 18 |
+
job non triviale (tests dédiés ``test_sprint26_*``).
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import threading
|
| 24 |
+
|
| 25 |
+
import pytest
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _make_app(monkeypatch, tmp_path):
|
| 29 |
+
"""App avec ``UPLOADS_DIR`` et workspace_roots qui pointent vers
|
| 30 |
+
``tmp_path`` pour faire passer la validation des chemins.
|
| 31 |
+
"""
|
| 32 |
+
from fastapi import FastAPI
|
| 33 |
+
|
| 34 |
+
from picarones.interfaces.web.routers import benchmark as benchmark_router
|
| 35 |
+
from picarones.interfaces.web.routers import corpus as corpus_router
|
| 36 |
+
|
| 37 |
+
monkeypatch.setattr(corpus_router, "UPLOADS_DIR", tmp_path)
|
| 38 |
+
monkeypatch.setattr(benchmark_router, "UPLOADS_DIR", tmp_path)
|
| 39 |
+
|
| 40 |
+
app = FastAPI()
|
| 41 |
+
app.include_router(benchmark_router.router)
|
| 42 |
+
return app
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 46 |
+
# 429 — sémaphore de jobs concurrents épuisé
|
| 47 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class TestSemaphoreFull429:
|
| 51 |
+
def test_start_returns_429_when_semaphore_exhausted(
|
| 52 |
+
self, monkeypatch, tmp_path,
|
| 53 |
+
) -> None:
|
| 54 |
+
"""``/api/benchmark/start`` doit retourner 429 (pas planter)
|
| 55 |
+
quand ``JOBS_SEMAPHORE.acquire(blocking=False)`` retourne
|
| 56 |
+
False — le worker ops a bien un signal d'epuisement."""
|
| 57 |
+
from fastapi.testclient import TestClient
|
| 58 |
+
|
| 59 |
+
from picarones.interfaces.web import state as web_state
|
| 60 |
+
|
| 61 |
+
# Crée le corpus et le rapports/ exigés par la validation.
|
| 62 |
+
corpus = tmp_path / "corpus_dir"
|
| 63 |
+
corpus.mkdir()
|
| 64 |
+
rapports = tmp_path / "rapports"
|
| 65 |
+
rapports.mkdir()
|
| 66 |
+
|
| 67 |
+
# Sémaphore capacité 0 — jamais acquérable.
|
| 68 |
+
monkeypatch.setattr(
|
| 69 |
+
web_state, "JOBS_SEMAPHORE", threading.Semaphore(0),
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
app = _make_app(monkeypatch, tmp_path)
|
| 73 |
+
with TestClient(app) as client:
|
| 74 |
+
r = client.post(
|
| 75 |
+
"/api/benchmark/start",
|
| 76 |
+
json={
|
| 77 |
+
"corpus_path": str(corpus),
|
| 78 |
+
"engines": ["tesseract"],
|
| 79 |
+
"output_dir": str(rapports),
|
| 80 |
+
"lang": "fra",
|
| 81 |
+
},
|
| 82 |
+
)
|
| 83 |
+
assert r.status_code == 429, r.text
|
| 84 |
+
assert (
|
| 85 |
+
"concurrents" in r.text.lower()
|
| 86 |
+
or "max" in r.text.lower()
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
def test_run_returns_429_when_semaphore_exhausted(
|
| 90 |
+
self, monkeypatch, tmp_path,
|
| 91 |
+
) -> None:
|
| 92 |
+
from fastapi.testclient import TestClient
|
| 93 |
+
|
| 94 |
+
from picarones.interfaces.web import state as web_state
|
| 95 |
+
|
| 96 |
+
corpus = tmp_path / "corpus_dir"
|
| 97 |
+
corpus.mkdir()
|
| 98 |
+
rapports = tmp_path / "rapports"
|
| 99 |
+
rapports.mkdir()
|
| 100 |
+
|
| 101 |
+
monkeypatch.setattr(
|
| 102 |
+
web_state, "JOBS_SEMAPHORE", threading.Semaphore(0),
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
app = _make_app(monkeypatch, tmp_path)
|
| 106 |
+
with TestClient(app) as client:
|
| 107 |
+
r = client.post(
|
| 108 |
+
"/api/benchmark/run",
|
| 109 |
+
json={
|
| 110 |
+
"corpus_path": str(corpus),
|
| 111 |
+
"competitors": [
|
| 112 |
+
{
|
| 113 |
+
"name": "t",
|
| 114 |
+
"ocr_engine": "tesseract",
|
| 115 |
+
"ocr_model": "fra",
|
| 116 |
+
"llm_provider": "",
|
| 117 |
+
},
|
| 118 |
+
],
|
| 119 |
+
"output_dir": str(rapports),
|
| 120 |
+
},
|
| 121 |
+
)
|
| 122 |
+
assert r.status_code == 429, r.text
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 126 |
+
# Validation des prompts (sécurité exfiltration LLM)
|
| 127 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class TestPromptFileValidation:
|
| 131 |
+
def test_prompt_file_traversal_returns_400(
|
| 132 |
+
self, monkeypatch, tmp_path,
|
| 133 |
+
) -> None:
|
| 134 |
+
"""Un ``prompt_file`` qui tente de pointer hors de la
|
| 135 |
+
bibliothèque embarquée (``../../etc/passwd``) doit être
|
| 136 |
+
rejeté en 400 — branche ``validated_prompt_filename``
|
| 137 |
+
levée et capturée comme ``PathValidationError``."""
|
| 138 |
+
from fastapi.testclient import TestClient
|
| 139 |
+
|
| 140 |
+
corpus = tmp_path / "corpus_dir"
|
| 141 |
+
corpus.mkdir()
|
| 142 |
+
rapports = tmp_path / "rapports"
|
| 143 |
+
rapports.mkdir()
|
| 144 |
+
|
| 145 |
+
app = _make_app(monkeypatch, tmp_path)
|
| 146 |
+
with TestClient(app) as client:
|
| 147 |
+
r = client.post(
|
| 148 |
+
"/api/benchmark/run",
|
| 149 |
+
json={
|
| 150 |
+
"corpus_path": str(corpus),
|
| 151 |
+
"competitors": [
|
| 152 |
+
{
|
| 153 |
+
"name": "t",
|
| 154 |
+
"ocr_engine": "tesseract",
|
| 155 |
+
"ocr_model": "fra",
|
| 156 |
+
"llm_provider": "mistral",
|
| 157 |
+
"llm_model": "ministral-3b-latest",
|
| 158 |
+
"prompt_file": "../../../etc/passwd",
|
| 159 |
+
},
|
| 160 |
+
],
|
| 161 |
+
"output_dir": str(rapports),
|
| 162 |
+
},
|
| 163 |
+
)
|
| 164 |
+
assert r.status_code == 400, r.text
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 168 |
+
# /cancel idempotent sur jobs déjà terminés
|
| 169 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
class TestCancelIdempotent:
|
| 173 |
+
@pytest.mark.parametrize("terminal_status", ["complete", "error"])
|
| 174 |
+
def test_cancel_already_finished_job_is_noop(
|
| 175 |
+
self, monkeypatch, tmp_path, terminal_status: str,
|
| 176 |
+
) -> None:
|
| 177 |
+
"""``/cancel`` sur un job ``complete`` ou ``error`` doit
|
| 178 |
+
retourner 200 + message ``déjà terminé`` (pas 4xx) — un
|
| 179 |
+
client qui retry ne doit pas voir une erreur."""
|
| 180 |
+
import uuid
|
| 181 |
+
|
| 182 |
+
from fastapi.testclient import TestClient
|
| 183 |
+
|
| 184 |
+
from picarones.interfaces.web import state as web_state
|
| 185 |
+
|
| 186 |
+
# ``job_id`` unique par paramètre — sinon
|
| 187 |
+
# ``JOB_STORE.create_job`` viole la contrainte UNIQUE entre
|
| 188 |
+
# les deux invocations du paramétrage.
|
| 189 |
+
job_id = f"test_job_finished_{terminal_status}_{uuid.uuid4().hex[:8]}"
|
| 190 |
+
job = web_state.BenchmarkJob(
|
| 191 |
+
job_id=job_id, _store=web_state.JOB_STORE,
|
| 192 |
+
)
|
| 193 |
+
web_state.JOB_STORE.create_job(job_id)
|
| 194 |
+
job.set_status(terminal_status)
|
| 195 |
+
web_state.register_job(job)
|
| 196 |
+
|
| 197 |
+
app = _make_app(monkeypatch, tmp_path)
|
| 198 |
+
with TestClient(app) as client:
|
| 199 |
+
r = client.post(f"/api/benchmark/{job_id}/cancel")
|
| 200 |
+
assert r.status_code == 200, r.text
|
| 201 |
+
body = r.json()
|
| 202 |
+
assert body["status"] == terminal_status
|
| 203 |
+
assert "terminé" in body["message"]
|
| 204 |
+
|
| 205 |
+
def test_cancel_unknown_job_returns_404(
|
| 206 |
+
self, monkeypatch, tmp_path,
|
| 207 |
+
) -> None:
|
| 208 |
+
from fastapi.testclient import TestClient
|
| 209 |
+
|
| 210 |
+
app = _make_app(monkeypatch, tmp_path)
|
| 211 |
+
with TestClient(app) as client:
|
| 212 |
+
r = client.post(
|
| 213 |
+
"/api/benchmark/never_existed_xyz/cancel",
|
| 214 |
+
)
|
| 215 |
+
assert r.status_code == 404
|
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint S8.7 — couverture des branches d'erreur du corpus router.
|
| 2 |
+
|
| 3 |
+
Cible (avant) : 88% — lignes 36-37, 50, 71-72, 111-114, 130-132,
|
| 4 |
+
169, 174, 183-184 non couvertes. Toutes représentent des
|
| 5 |
+
contrats fonctionnels réels (403 sur path interdit, 415 sur
|
| 6 |
+
image rejetée, robustness sur uploads dir absent…).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _make_app(tmp_path, monkeypatch):
|
| 15 |
+
from fastapi import FastAPI
|
| 16 |
+
|
| 17 |
+
from picarones.interfaces.web.routers import corpus as corpus_router
|
| 18 |
+
|
| 19 |
+
uploads_dir = tmp_path / "uploads"
|
| 20 |
+
monkeypatch.setattr(corpus_router, "UPLOADS_DIR", uploads_dir)
|
| 21 |
+
# ``_BROWSE_ROOTS`` est calculé au module-load depuis l'``UPLOADS_DIR``
|
| 22 |
+
# original. Pour le browse 403 on remplace par un set explicite
|
| 23 |
+
# contenant uniquement le dossier autorisé du test.
|
| 24 |
+
monkeypatch.setattr(
|
| 25 |
+
corpus_router, "_BROWSE_ROOTS", [tmp_path.resolve()],
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
app = FastAPI()
|
| 29 |
+
app.include_router(corpus_router.router)
|
| 30 |
+
return app, uploads_dir
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 34 |
+
# /api/corpus/browse — défense 403 + 404
|
| 35 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class TestBrowseDefenses:
|
| 39 |
+
def test_browse_outside_allowed_roots_returns_403(
|
| 40 |
+
self, tmp_path, monkeypatch,
|
| 41 |
+
) -> None:
|
| 42 |
+
"""Tente de browser un dossier réel mais hors des
|
| 43 |
+
``_BROWSE_ROOTS`` autorisés → 403."""
|
| 44 |
+
from fastapi.testclient import TestClient
|
| 45 |
+
|
| 46 |
+
# Crée un dossier réel hors du tmp_path autorisé.
|
| 47 |
+
outside_dir = tmp_path.parent / f"outside_{tmp_path.name}"
|
| 48 |
+
outside_dir.mkdir()
|
| 49 |
+
try:
|
| 50 |
+
app, _ = _make_app(tmp_path, monkeypatch)
|
| 51 |
+
with TestClient(app) as client:
|
| 52 |
+
r = client.get(
|
| 53 |
+
"/api/corpus/browse",
|
| 54 |
+
params={"path": str(outside_dir)},
|
| 55 |
+
)
|
| 56 |
+
assert r.status_code == 403, r.text
|
| 57 |
+
assert "Accès refusé" in r.text or "refusé" in r.text
|
| 58 |
+
finally:
|
| 59 |
+
outside_dir.rmdir()
|
| 60 |
+
|
| 61 |
+
def test_browse_nonexistent_path_returns_404(
|
| 62 |
+
self, tmp_path, monkeypatch,
|
| 63 |
+
) -> None:
|
| 64 |
+
from fastapi.testclient import TestClient
|
| 65 |
+
|
| 66 |
+
app, _ = _make_app(tmp_path, monkeypatch)
|
| 67 |
+
with TestClient(app) as client:
|
| 68 |
+
r = client.get(
|
| 69 |
+
"/api/corpus/browse",
|
| 70 |
+
params={"path": str(tmp_path / "nope")},
|
| 71 |
+
)
|
| 72 |
+
assert r.status_code == 404
|
| 73 |
+
|
| 74 |
+
def test_browse_legitimate_path_returns_listing(
|
| 75 |
+
self, tmp_path, monkeypatch,
|
| 76 |
+
) -> None:
|
| 77 |
+
"""Contrôle positif : path autorisé → 200 + listing avec
|
| 78 |
+
détection ``has_corpus`` sur les sous-dossiers contenant
|
| 79 |
+
des ``.gt.txt``."""
|
| 80 |
+
from fastapi.testclient import TestClient
|
| 81 |
+
|
| 82 |
+
# Sous-dossier avec un fichier ``.gt.txt`` → has_corpus=True.
|
| 83 |
+
sub = tmp_path / "sub"
|
| 84 |
+
sub.mkdir()
|
| 85 |
+
(sub / "doc1.gt.txt").write_text("ground truth", encoding="utf-8")
|
| 86 |
+
|
| 87 |
+
app, _ = _make_app(tmp_path, monkeypatch)
|
| 88 |
+
with TestClient(app) as client:
|
| 89 |
+
r = client.get(
|
| 90 |
+
"/api/corpus/browse", params={"path": str(tmp_path)},
|
| 91 |
+
)
|
| 92 |
+
assert r.status_code == 200
|
| 93 |
+
data = r.json()
|
| 94 |
+
sub_item = next(
|
| 95 |
+
it for it in data["items"] if it["name"] == "sub"
|
| 96 |
+
)
|
| 97 |
+
assert sub_item["is_dir"] is True
|
| 98 |
+
assert sub_item["gt_count"] == 1
|
| 99 |
+
assert sub_item["has_corpus"] is True
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 103 |
+
# /api/corpus/uploads — listing avec dossiers absents/non-dir
|
| 104 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class TestUploadsListing:
|
| 108 |
+
def test_uploads_dir_missing_returns_empty_list(
|
| 109 |
+
self, tmp_path, monkeypatch,
|
| 110 |
+
) -> None:
|
| 111 |
+
"""Pas d'``UPLOADS_DIR`` → liste vide (pas une erreur)."""
|
| 112 |
+
from fastapi.testclient import TestClient
|
| 113 |
+
|
| 114 |
+
app, uploads_dir = _make_app(tmp_path, monkeypatch)
|
| 115 |
+
assert not uploads_dir.exists() # pre-condition
|
| 116 |
+
with TestClient(app) as client:
|
| 117 |
+
r = client.get("/api/corpus/uploads")
|
| 118 |
+
assert r.status_code == 200
|
| 119 |
+
assert r.json() == {"uploads": []}
|
| 120 |
+
|
| 121 |
+
def test_uploads_skips_non_directory_entries(
|
| 122 |
+
self, tmp_path, monkeypatch,
|
| 123 |
+
) -> None:
|
| 124 |
+
"""Un fichier accidentel à la racine d'``UPLOADS_DIR`` ne doit
|
| 125 |
+
pas planter le listing — on saute, on continue."""
|
| 126 |
+
from fastapi.testclient import TestClient
|
| 127 |
+
|
| 128 |
+
app, uploads_dir = _make_app(tmp_path, monkeypatch)
|
| 129 |
+
uploads_dir.mkdir()
|
| 130 |
+
(uploads_dir / "stray.txt").write_text("not a corpus")
|
| 131 |
+
|
| 132 |
+
# Vrai corpus dans un sous-dossier — détecté normalement.
|
| 133 |
+
real = uploads_dir / "real_corpus"
|
| 134 |
+
real.mkdir()
|
| 135 |
+
(real / "img.png").write_bytes(b"")
|
| 136 |
+
(real / "img.gt.txt").write_text("gt", encoding="utf-8")
|
| 137 |
+
|
| 138 |
+
with TestClient(app) as client:
|
| 139 |
+
r = client.get("/api/corpus/uploads")
|
| 140 |
+
assert r.status_code == 200
|
| 141 |
+
uploads = r.json()["uploads"]
|
| 142 |
+
ids = [u["corpus_id"] for u in uploads]
|
| 143 |
+
assert "real_corpus" in ids
|
| 144 |
+
assert "stray.txt" not in ids, (
|
| 145 |
+
"le fichier non-dir aurait dû être sauté"
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
def test_uploads_handles_broken_corpus_with_warning(
|
| 149 |
+
self, tmp_path, monkeypatch, caplog,
|
| 150 |
+
) -> None:
|
| 151 |
+
"""``analyze_corpus_dir`` qui plante sur un dossier doit être
|
| 152 |
+
loggé en warning, pas masquer la liste des autres."""
|
| 153 |
+
from fastapi.testclient import TestClient
|
| 154 |
+
|
| 155 |
+
from picarones.interfaces.web.routers import corpus as corpus_router
|
| 156 |
+
|
| 157 |
+
app, uploads_dir = _make_app(tmp_path, monkeypatch)
|
| 158 |
+
uploads_dir.mkdir()
|
| 159 |
+
(uploads_dir / "good_corpus").mkdir()
|
| 160 |
+
(uploads_dir / "broken_corpus").mkdir()
|
| 161 |
+
|
| 162 |
+
# Force ``analyze_corpus_dir`` à lever pour ``broken_corpus``
|
| 163 |
+
# uniquement, pour vérifier que le listing continue après
|
| 164 |
+
# l'exception.
|
| 165 |
+
original_analyze = corpus_router.analyze_corpus_dir
|
| 166 |
+
|
| 167 |
+
def fake_analyze(d: Path) -> dict:
|
| 168 |
+
if d.name == "broken_corpus":
|
| 169 |
+
raise RuntimeError("disque corrompu simulé")
|
| 170 |
+
return original_analyze(d)
|
| 171 |
+
|
| 172 |
+
monkeypatch.setattr(
|
| 173 |
+
corpus_router, "analyze_corpus_dir", fake_analyze,
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
with caplog.at_level("WARNING"):
|
| 177 |
+
with TestClient(app) as client:
|
| 178 |
+
r = client.get("/api/corpus/uploads")
|
| 179 |
+
assert r.status_code == 200
|
| 180 |
+
# ``good_corpus`` est listé, ``broken_corpus`` ignoré + warning.
|
| 181 |
+
ids = [u["corpus_id"] for u in r.json()["uploads"]]
|
| 182 |
+
assert "good_corpus" in ids
|
| 183 |
+
assert "broken_corpus" not in ids
|
| 184 |
+
assert any(
|
| 185 |
+
"broken_corpus" in rec.message for rec in caplog.records
|
| 186 |
+
), "warning sur le corpus cassé attendu"
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 190 |
+
# /api/corpus/upload — image rejetée → 415
|
| 191 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class TestUploadImageRejection:
|
| 195 |
+
def test_oversized_image_returns_415(
|
| 196 |
+
self, tmp_path, monkeypatch,
|
| 197 |
+
) -> None:
|
| 198 |
+
"""Image > limite → ``ValueError`` côté validation, mappé
|
| 199 |
+
en HTTP 415 par le handler."""
|
| 200 |
+
from fastapi.testclient import TestClient
|
| 201 |
+
|
| 202 |
+
app, uploads_dir = _make_app(tmp_path, monkeypatch)
|
| 203 |
+
uploads_dir.mkdir()
|
| 204 |
+
monkeypatch.setenv("PICARONES_MAX_UPLOAD_MB", "1")
|
| 205 |
+
|
| 206 |
+
big_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * (2 * 1024 * 1024)
|
| 207 |
+
|
| 208 |
+
with TestClient(app) as client:
|
| 209 |
+
r = client.post(
|
| 210 |
+
"/api/corpus/upload",
|
| 211 |
+
files={"files": ("big.png", big_data, "image/png")},
|
| 212 |
+
)
|
| 213 |
+
assert r.status_code == 415, r.text
|
| 214 |
+
assert "taille" in r.text.lower() or "limite" in r.text.lower()
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 218 |
+
# _is_path_allowed — branche d'exception (ValueError/TypeError)
|
| 219 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
class TestIsPathAllowedException:
|
| 223 |
+
def test_value_error_on_compare_continues_to_next_root(
|
| 224 |
+
self, monkeypatch,
|
| 225 |
+
) -> None:
|
| 226 |
+
"""``Path.is_relative_to`` lève ``ValueError`` quand on
|
| 227 |
+
compare des paths de drives différents (Windows) ou autres
|
| 228 |
+
cas pathologiques. Le helper doit continuer à itérer
|
| 229 |
+
plutôt que de planter."""
|
| 230 |
+
from picarones.interfaces.web.routers import corpus as corpus_router
|
| 231 |
+
|
| 232 |
+
class RaisingPath:
|
| 233 |
+
"""Fake Path qui lève sur ``__eq__``/``is_relative_to``."""
|
| 234 |
+
|
| 235 |
+
def __eq__(self, other):
|
| 236 |
+
raise ValueError("simulated path comparison error")
|
| 237 |
+
|
| 238 |
+
def is_relative_to(self, other):
|
| 239 |
+
raise ValueError("simulated")
|
| 240 |
+
|
| 241 |
+
# Premier root lève → continue ; deuxième root match.
|
| 242 |
+
from pathlib import Path as RealPath
|
| 243 |
+
|
| 244 |
+
target = RealPath("/tmp")
|
| 245 |
+
monkeypatch.setattr(
|
| 246 |
+
corpus_router,
|
| 247 |
+
"_BROWSE_ROOTS",
|
| 248 |
+
[RaisingPath(), target],
|
| 249 |
+
)
|
| 250 |
+
assert corpus_router._is_path_allowed(target) is True
|
| 251 |
+
|
| 252 |
+
def test_no_match_returns_false(self, monkeypatch) -> None:
|
| 253 |
+
from pathlib import Path as RealPath
|
| 254 |
+
|
| 255 |
+
from picarones.interfaces.web.routers import corpus as corpus_router
|
| 256 |
+
|
| 257 |
+
# ``_BROWSE_ROOTS`` ne contient que des paths qui ne
|
| 258 |
+
# contiennent pas ``/totally/unrelated``.
|
| 259 |
+
monkeypatch.setattr(
|
| 260 |
+
corpus_router,
|
| 261 |
+
"_BROWSE_ROOTS",
|
| 262 |
+
[RealPath("/var/picarones-uploads-test-only")],
|
| 263 |
+
)
|
| 264 |
+
assert corpus_router._is_path_allowed(
|
| 265 |
+
RealPath("/totally/unrelated"),
|
| 266 |
+
) is False
|
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint S8.7 — couverture réelle des factories de
|
| 2 |
+
``benchmark_utils.py`` (avant : 51.51% patch coverage).
|
| 3 |
+
|
| 4 |
+
Pourquoi ce fichier
|
| 5 |
+
-------------------
|
| 6 |
+
``_build_llm_adapter`` et ``_engine_from_competitor`` sont les
|
| 7 |
+
points de **routage** entre la config web (``CompetitorConfig``)
|
| 8 |
+
et les adapters concrets : si une régression silencieusement
|
| 9 |
+
fait passer ``mistral`` au lieu de ``openai``, ou ``tesseract``
|
| 10 |
+
au lieu de ``mistral_ocr``, le benchmark tourne mais avec le
|
| 11 |
+
mauvais moteur — tests fonctionnels classiques ne le verraient
|
| 12 |
+
pas.
|
| 13 |
+
|
| 14 |
+
Pattern
|
| 15 |
+
-------
|
| 16 |
+
Les adapters LLM lazy-importent leurs SDK (cf. ``__init__``
|
| 17 |
+
sans ``import openai``), donc ``OpenAIAdapter()`` etc.
|
| 18 |
+
s'instancient sans erreur même hors environnement de prod —
|
| 19 |
+
on peut donc tester directement le routing sans mocker les SDK.
|
| 20 |
+
|
| 21 |
+
Pour les adapters OCR cloud (mistral_ocr, google_vision,
|
| 22 |
+
azure_doc_intel) qui exigent un SDK à l'import du wrapper,
|
| 23 |
+
on réutilise le pattern ``patch.dict(sys.modules, {... : None})``
|
| 24 |
+
de ``test_s8_factory_branches.py``.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import sys
|
| 30 |
+
from unittest.mock import patch
|
| 31 |
+
|
| 32 |
+
import pytest
|
| 33 |
+
|
| 34 |
+
from picarones.interfaces.web.benchmark_utils import (
|
| 35 |
+
_build_llm_adapter,
|
| 36 |
+
_engine_from_competitor,
|
| 37 |
+
sse_format,
|
| 38 |
+
)
|
| 39 |
+
from picarones.interfaces.web.models import CompetitorConfig
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 43 |
+
# _build_llm_adapter — routing par provider
|
| 44 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class TestBuildLLMAdapterRouting:
|
| 48 |
+
"""Chaque provider de la config doit retourner exactement
|
| 49 |
+
l'adapter correspondant — pas un autre, pas une instance
|
| 50 |
+
fallback silencieuse."""
|
| 51 |
+
|
| 52 |
+
@pytest.mark.parametrize(
|
| 53 |
+
("provider", "expected_class_name"),
|
| 54 |
+
[
|
| 55 |
+
("openai", "OpenAIAdapter"),
|
| 56 |
+
("anthropic", "AnthropicAdapter"),
|
| 57 |
+
("mistral", "MistralAdapter"),
|
| 58 |
+
("ollama", "OllamaAdapter"),
|
| 59 |
+
],
|
| 60 |
+
)
|
| 61 |
+
def test_provider_routes_to_expected_adapter(
|
| 62 |
+
self, provider: str, expected_class_name: str,
|
| 63 |
+
) -> None:
|
| 64 |
+
comp = CompetitorConfig(
|
| 65 |
+
name="t", ocr_engine="", llm_provider=provider, llm_model="m",
|
| 66 |
+
)
|
| 67 |
+
adapter = _build_llm_adapter(comp)
|
| 68 |
+
assert type(adapter).__name__ == expected_class_name, (
|
| 69 |
+
f"provider={provider!r} doit instancier "
|
| 70 |
+
f"{expected_class_name}, reçu {type(adapter).__name__}"
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
def test_unknown_provider_raises_value_error(self) -> None:
|
| 74 |
+
comp = CompetitorConfig(
|
| 75 |
+
name="t", ocr_engine="",
|
| 76 |
+
llm_provider="some_made_up_provider", llm_model="x",
|
| 77 |
+
)
|
| 78 |
+
with pytest.raises(ValueError, match="inconnu|unknown"):
|
| 79 |
+
_build_llm_adapter(comp)
|
| 80 |
+
|
| 81 |
+
def test_empty_llm_model_uses_adapter_default(self) -> None:
|
| 82 |
+
"""Quand ``llm_model`` est vide, on passe ``None`` à
|
| 83 |
+
l'adapter (qui utilise son default interne) — pas une
|
| 84 |
+
chaîne vide qui serait rejetée par l'API."""
|
| 85 |
+
comp = CompetitorConfig(
|
| 86 |
+
name="t", ocr_engine="", llm_provider="openai", llm_model="",
|
| 87 |
+
)
|
| 88 |
+
adapter = _build_llm_adapter(comp)
|
| 89 |
+
# L'adapter doit être instancié sans planter sur llm_model="".
|
| 90 |
+
assert adapter is not None
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 94 |
+
# _engine_from_competitor — routing OCR / pipeline / corpus-only
|
| 95 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class TestEngineFromCompetitorOCROnly:
|
| 99 |
+
"""OCR seul (pas de ``llm_provider``) → retourne un
|
| 100 |
+
``BaseOCRAdapter`` directement, prêt à être enregistré."""
|
| 101 |
+
|
| 102 |
+
def test_tesseract_only_returns_adapter(self) -> None:
|
| 103 |
+
comp = CompetitorConfig(
|
| 104 |
+
name="t", ocr_engine="tesseract", llm_provider="",
|
| 105 |
+
ocr_model="fra",
|
| 106 |
+
)
|
| 107 |
+
engine = _engine_from_competitor(comp)
|
| 108 |
+
assert engine.name == "tesseract"
|
| 109 |
+
|
| 110 |
+
def test_unknown_engine_raises_runtime_error(self) -> None:
|
| 111 |
+
"""``RuntimeError`` (et pas ``ValueError`` brut) — c'est le
|
| 112 |
+
contrat documenté pour que le worker thread puisse
|
| 113 |
+
loguer ``warning`` et passer au concurrent suivant."""
|
| 114 |
+
comp = CompetitorConfig(
|
| 115 |
+
name="t", ocr_engine="not_an_engine", llm_provider="",
|
| 116 |
+
)
|
| 117 |
+
with pytest.raises(RuntimeError, match="inconnu"):
|
| 118 |
+
_engine_from_competitor(comp)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class TestEngineFromCompetitorPipeline:
|
| 122 |
+
"""OCR + LLM → retourne un ``OCRLLMPipelineConfig`` (rewrite)
|
| 123 |
+
avec le bon mode selon ``pipeline_mode``."""
|
| 124 |
+
|
| 125 |
+
@pytest.mark.parametrize(
|
| 126 |
+
("pipeline_mode", "expected_mode"),
|
| 127 |
+
[
|
| 128 |
+
("text_only", "text_only"),
|
| 129 |
+
("post_correction_text", "text_only"),
|
| 130 |
+
("text_and_image", "text_and_image"),
|
| 131 |
+
("post_correction_image", "text_and_image"),
|
| 132 |
+
("", "text_only"), # fallback
|
| 133 |
+
],
|
| 134 |
+
)
|
| 135 |
+
def test_pipeline_mode_mapping_with_ocr(
|
| 136 |
+
self, pipeline_mode: str, expected_mode: str,
|
| 137 |
+
) -> None:
|
| 138 |
+
"""Modes qui exigent un OCR amont (``text_only``,
|
| 139 |
+
``text_and_image``) — testés avec ``tesseract`` réel."""
|
| 140 |
+
comp = CompetitorConfig(
|
| 141 |
+
name="t", ocr_engine="tesseract", llm_provider="mistral",
|
| 142 |
+
llm_model="m", ocr_model="fra", pipeline_mode=pipeline_mode,
|
| 143 |
+
)
|
| 144 |
+
pipeline = _engine_from_competitor(comp)
|
| 145 |
+
assert pipeline.mode == expected_mode
|
| 146 |
+
|
| 147 |
+
def test_zero_shot_mode_requires_corpus_ocr(self) -> None:
|
| 148 |
+
"""Le mode ``zero_shot`` exige ``ocr_adapter=None`` au niveau
|
| 149 |
+
du pipeline (le VLM lit l'image directement) — donc côté
|
| 150 |
+
factory web, il doit être combiné avec ``ocr_engine=corpus``
|
| 151 |
+
ou ``""``, pas avec un moteur live."""
|
| 152 |
+
comp = CompetitorConfig(
|
| 153 |
+
name="t", ocr_engine="corpus", llm_provider="mistral",
|
| 154 |
+
llm_model="m", pipeline_mode="zero_shot",
|
| 155 |
+
)
|
| 156 |
+
pipeline = _engine_from_competitor(comp)
|
| 157 |
+
assert pipeline.mode == "zero_shot"
|
| 158 |
+
assert pipeline.ocr_adapter is None
|
| 159 |
+
|
| 160 |
+
def test_pipeline_name_from_explicit_name(self) -> None:
|
| 161 |
+
comp = CompetitorConfig(
|
| 162 |
+
name="my-pipeline", ocr_engine="tesseract",
|
| 163 |
+
llm_provider="mistral", llm_model="m", ocr_model="fra",
|
| 164 |
+
)
|
| 165 |
+
pipeline = _engine_from_competitor(comp)
|
| 166 |
+
assert pipeline.pipeline_name == "my-pipeline"
|
| 167 |
+
|
| 168 |
+
def test_pipeline_name_default_format(self) -> None:
|
| 169 |
+
"""Sans ``name`` explicite, format ``{engine} → {model}``."""
|
| 170 |
+
comp = CompetitorConfig(
|
| 171 |
+
name="", ocr_engine="tesseract", llm_provider="mistral",
|
| 172 |
+
llm_model="ministral-3b-latest", ocr_model="fra",
|
| 173 |
+
)
|
| 174 |
+
pipeline = _engine_from_competitor(comp)
|
| 175 |
+
assert "tesseract" in pipeline.pipeline_name
|
| 176 |
+
assert "ministral" in pipeline.pipeline_name
|
| 177 |
+
|
| 178 |
+
def test_default_prompt_file_when_not_specified(self) -> None:
|
| 179 |
+
comp = CompetitorConfig(
|
| 180 |
+
name="t", ocr_engine="tesseract", llm_provider="mistral",
|
| 181 |
+
llm_model="m", ocr_model="fra", prompt_file="",
|
| 182 |
+
)
|
| 183 |
+
pipeline = _engine_from_competitor(comp)
|
| 184 |
+
assert pipeline.prompt_template == "correction_medieval_french.txt"
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class TestEngineFromCompetitorCorpusOCR:
|
| 188 |
+
"""Mode ``corpus`` : utilise OCR pré-calculé (fichiers
|
| 189 |
+
``.ocr.txt``) au lieu d'un moteur live — exige un
|
| 190 |
+
``llm_provider`` car le pipeline a forcément besoin d'un
|
| 191 |
+
LLM (post-correction ou zero-shot)."""
|
| 192 |
+
|
| 193 |
+
@pytest.mark.parametrize("ocr_engine", ["corpus", ""])
|
| 194 |
+
def test_corpus_or_empty_without_llm_raises(
|
| 195 |
+
self, ocr_engine: str,
|
| 196 |
+
) -> None:
|
| 197 |
+
comp = CompetitorConfig(
|
| 198 |
+
name="t", ocr_engine=ocr_engine, llm_provider="",
|
| 199 |
+
)
|
| 200 |
+
with pytest.raises(ValueError, match="llm_provider"):
|
| 201 |
+
_engine_from_competitor(comp)
|
| 202 |
+
|
| 203 |
+
@pytest.mark.parametrize("ocr_engine", ["corpus", ""])
|
| 204 |
+
def test_corpus_with_llm_returns_pipeline(
|
| 205 |
+
self, ocr_engine: str,
|
| 206 |
+
) -> None:
|
| 207 |
+
"""Mode corpus + LLM → pipeline ``zero_shot`` (le LLM/VLM
|
| 208 |
+
traite l'image ou l'OCR pré-calculé, l'``ocr_adapter`` est
|
| 209 |
+
``None``)."""
|
| 210 |
+
comp = CompetitorConfig(
|
| 211 |
+
name="post-corr", ocr_engine=ocr_engine,
|
| 212 |
+
llm_provider="mistral", llm_model="m",
|
| 213 |
+
pipeline_mode="zero_shot",
|
| 214 |
+
)
|
| 215 |
+
pipeline = _engine_from_competitor(comp)
|
| 216 |
+
assert pipeline.ocr_adapter is None, (
|
| 217 |
+
"en mode corpus, l'OCR adapter doit être None — "
|
| 218 |
+
"le pipeline lit l'OCR pré-calculé du corpus."
|
| 219 |
+
)
|
| 220 |
+
assert pipeline.llm_adapter is not None
|
| 221 |
+
|
| 222 |
+
def test_corpus_pipeline_name_format(self) -> None:
|
| 223 |
+
"""Sans ``name``, format ``corpus_ocr → {model}``."""
|
| 224 |
+
comp = CompetitorConfig(
|
| 225 |
+
name="", ocr_engine="corpus", llm_provider="mistral",
|
| 226 |
+
llm_model="ministral-3b-latest",
|
| 227 |
+
pipeline_mode="zero_shot",
|
| 228 |
+
)
|
| 229 |
+
pipeline = _engine_from_competitor(comp)
|
| 230 |
+
assert "corpus_ocr" in pipeline.pipeline_name
|
| 231 |
+
assert "ministral" in pipeline.pipeline_name
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
class TestEngineFromCompetitorCloudWithoutSDK:
|
| 235 |
+
"""Pour les adapters OCR cloud, le wrapper module est
|
| 236 |
+
importé conditionnellement — un SDK absent doit être
|
| 237 |
+
transformé en ``RuntimeError`` propre côté factory web."""
|
| 238 |
+
|
| 239 |
+
@pytest.mark.parametrize(
|
| 240 |
+
("engine", "module_path"),
|
| 241 |
+
[
|
| 242 |
+
("mistral_ocr", "picarones.adapters.ocr.mistral_ocr"),
|
| 243 |
+
("google_vision", "picarones.adapters.ocr.google_vision"),
|
| 244 |
+
("azure_doc_intel", "picarones.adapters.ocr.azure_doc_intel"),
|
| 245 |
+
],
|
| 246 |
+
)
|
| 247 |
+
def test_cloud_engine_without_sdk_runtime_error(
|
| 248 |
+
self, engine: str, module_path: str,
|
| 249 |
+
) -> None:
|
| 250 |
+
comp = CompetitorConfig(
|
| 251 |
+
name="t", ocr_engine=engine, llm_provider="",
|
| 252 |
+
)
|
| 253 |
+
with patch.dict(sys.modules, {module_path: None}):
|
| 254 |
+
with pytest.raises(RuntimeError, match="indisponible"):
|
| 255 |
+
_engine_from_competitor(comp)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 259 |
+
# sse_format — sérialisation Server-Sent Events
|
| 260 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
class TestSSEFormat:
|
| 264 |
+
"""Le format SSE doit respecter la spec WHATWG : ``id:`` (si
|
| 265 |
+
seq fourni), ``event:``, ``data:``, double newline final."""
|
| 266 |
+
|
| 267 |
+
def test_basic_event_no_seq(self) -> None:
|
| 268 |
+
out = sse_format("log", {"message": "hello"})
|
| 269 |
+
assert "event: log\n" in out
|
| 270 |
+
# ``json.dumps`` par défaut → séparateurs avec espace.
|
| 271 |
+
assert '"message": "hello"' in out
|
| 272 |
+
assert out.endswith("\n\n")
|
| 273 |
+
assert not out.startswith("id:")
|
| 274 |
+
|
| 275 |
+
def test_event_with_seq(self) -> None:
|
| 276 |
+
out = sse_format("progress", {"pct": 0.5}, seq=42)
|
| 277 |
+
assert out.startswith("id: 42\n")
|
| 278 |
+
assert "event: progress\n" in out
|
| 279 |
+
|
| 280 |
+
def test_unicode_preserved(self) -> None:
|
| 281 |
+
"""``ensure_ascii=False`` — les accents passent en clair."""
|
| 282 |
+
out = sse_format("log", {"message": "événement"})
|
| 283 |
+
assert "événement" in out
|
| 284 |
+
|
| 285 |
+
def test_seq_zero_not_skipped(self) -> None:
|
| 286 |
+
"""``seq=0`` est valide (premier événement) — ne doit pas
|
| 287 |
+
être traité comme None."""
|
| 288 |
+
out = sse_format("start", {}, seq=0)
|
| 289 |
+
assert out.startswith("id: 0\n")
|