Migration de la suite de tests vers pytest, sans reecrire les scripts existants

pytest + pytest-qt (requirements-dev.txt) deviennent le point d'entree unique
de la suite ("pytest" depuis la racine), sans toucher aux 30+ scripts
test_*.py existants (convention deliberement conservee - voir tests/conftest.py) :
tests/test_all_scripts.py les lance chacun comme sous-processus isole (comme
c'etait deja fait manuellement a chaque phase), avec QT_QPA_PLATFORM=offscreen
pour tourner sans affichage (verifie : la suite complete passe a l'identique
en mode hors-ecran, prerequis pour une future integration continue).

tests/run_all.bat corrige au passage : il ne lancait plus que 4 tests sur 30+
(liste figee, jamais mise a jour au fil des phases) ; il delegue maintenant a
pytest.

Audit de couverture (Phase 13 du plan) : fingerprint, stockage atomique,
migration de schema, config OpenSSH, ProxyJump, CredentialStore, redaction de
secrets et detection de commandes dangereuses etaient deja couverts au fil des
phases precedentes. Seul point manquant identifie : aucun test ne verifiait
qu'une cle API IA ne puisse fuiter dans un message d'erreur - ajoute dans
tests/test_no_secrets_in_errors.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Florian
2026-09-05 07:32:55 +02:00
parent 0b2ff38bfd
commit 09b7ed092c
7 changed files with 164 additions and 12 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
venv/ venv/
__pycache__/ __pycache__/
*.pyc *.pyc
.pytest_cache/
# generes localement (chemins absolus / dependants du poste) # generes localement (chemins absolus / dependants du poste)
*.lnk *.lnk

3
pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
python_files = test_all_scripts.py

3
requirements-dev.txt Normal file
View File

@@ -0,0 +1,3 @@
-r requirements.txt
pytest>=8.0
pytest-qt>=4.4

16
tests/conftest.py Normal file
View File

@@ -0,0 +1,16 @@
"""Les tests de ce projet sont des scripts autonomes (print/assert), executes
chacun dans son propre processus - convention en place depuis le debut du
projet, et deliberement conservee (voir tests/test_all_scripts.py) plutot que
reecrite en fonctions pytest : cela evite tout risque de contamination entre
tests (patches, QApplication, fichiers temporaires) qu'importerait leur
execution dans un seul et meme processus.
Pytest ne doit donc PAS les importer/collecter directement comme modules (ce
qui les executerait une deuxieme fois, et dans un processus partage) : seul
test_all_scripts.py, qui les lance chacun en sous-processus isole, est un
vrai module de test pytest.
"""
import pathlib
_HERE = pathlib.Path(__file__).parent
collect_ignore_glob = [p.name for p in _HERE.glob("test_*.py") if p.name != "test_all_scripts.py"]

View File

@@ -12,18 +12,8 @@ REM QT_QPA_PLATFORM=offscreen : les tests construisent de vraies fenetres Qt,
REM mais sans les afficher (utile en console comme en integration continue). REM mais sans les afficher (utile en console comme en integration continue).
set QT_QPA_PLATFORM=offscreen set QT_QPA_PLATFORM=offscreen
set PYTHONIOENCODING=utf-8 set PYTHONIOENCODING=utf-8
set PYTHONUTF8=1
set FAILED=0 venv\Scripts\python.exe -m pytest tests -v
for %%T in (test_detect test_clipboard test_sftp test_full) do (
echo.
echo ================== %%T ==================
venv\Scripts\python.exe tests\%%T.py || set FAILED=1
)
echo.
if "%FAILED%"=="1" (
echo Au moins un test a echoue.
) else (
echo Tous les tests sont passes.
)
pause pause

50
tests/test_all_scripts.py Normal file
View File

@@ -0,0 +1,50 @@
"""Point d'entree pytest pour la suite : lance chaque tests/test_*.py existant
comme un sous-processus isole (voir conftest.py pour la raison de cet
isolement), avec les variables d'environnement necessaires sous Windows
(encodage UTF-8 pour les caracteres/emoji, rendu Qt hors-ecran pour tourner
sans affichage - typiquement en integration continue).
`pytest tests/` (ou simplement `pytest`) devient ainsi le point d'entree
unique de la suite, tout en preservant chaque script existant tel quel.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
import pytest
TESTS_DIR = Path(__file__).parent
PROJECT_ROOT = TESTS_DIR.parent
SCRIPTS = sorted(
p for p in TESTS_DIR.glob("test_*.py")
if p.name not in ("test_all_scripts.py",)
)
def _run_env() -> dict:
env = dict(os.environ)
env["PYTHONIOENCODING"] = "utf-8"
env["PYTHONUTF8"] = "1"
env.setdefault("QT_QPA_PLATFORM", "offscreen")
return env
@pytest.mark.parametrize("script", SCRIPTS, ids=lambda p: p.stem)
def test_script(script: Path) -> None:
result = subprocess.run(
[sys.executable, str(script)],
cwd=PROJECT_ROOT,
env=_run_env(),
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
pytest.fail(
f"{script.name} a echoue (code {result.returncode}) :\n"
f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}",
pytrace=False,
)

View File

@@ -0,0 +1,89 @@
"""Aucun secret (mot de passe de session, cle API IA) ne doit jamais apparaitre
dans un message d'erreur, une exception ou sa representation textuelle - ce
sont les seuls endroits, en dehors des fichiers de stockage deja couverts par
test_credentials.py/test_ai_settings.py, ou un secret pourrait fuiter sans
etre ecrit intentionnellement (une boite de dialogue d'erreur, une trace
affichee a l'utilisateur ou capturee dans un rapport de bug)."""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import io
import urllib.error
from unittest.mock import patch, MagicMock
from app.ai.provider import OpenAICompatibleProvider, AIError
SECRET_API_KEY = "sk-tres-secret-ne-jamais-fuiter-1234567890"
def make_http_error(code: int, body: bytes = b"") -> urllib.error.HTTPError:
return urllib.error.HTTPError(url="http://x/chat/completions", code=code, msg="err",
hdrs=None, fp=io.BytesIO(body))
provider = OpenAICompatibleProvider(
base_url="https://api.example.com/v1", api_key=SECRET_API_KEY, model="gpt-test",
)
print("=== 1. Erreur 401 : le message ne contient jamais la cle API ===")
with patch("app.ai.provider.urllib.request.urlopen", side_effect=make_http_error(401, b"unauthorized")):
try:
provider.chat([{"role": "user", "content": "x"}])
raise AssertionError("aurait du lever AIError")
except AIError as exc:
message = str(exc)
print(" message:", message)
assert SECRET_API_KEY not in message
print(" OK")
print()
print("=== 2. Erreur 500 avec corps de reponse : la cle API n'y apparait jamais ===")
# le corps de la reponse est celui du FOURNISSEUR (pas la requete) : il ne peut
# pas contenir la cle, mais on verifie que rien ne l'y ajoute par erreur.
with patch("app.ai.provider.urllib.request.urlopen", side_effect=make_http_error(500, b"internal error")):
try:
provider.chat([{"role": "user", "content": "x"}])
raise AssertionError("aurait du lever AIError")
except AIError as exc:
message = str(exc)
print(" message:", message)
assert SECRET_API_KEY not in message
print(" OK")
print()
print("=== 3. Erreur reseau : le message ne contient jamais la cle API ===")
with patch("app.ai.provider.urllib.request.urlopen", side_effect=urllib.error.URLError("connexion refusee")):
try:
provider.chat([{"role": "user", "content": "x"}])
raise AssertionError("aurait du lever AIError")
except AIError as exc:
message = str(exc)
print(" message:", message)
assert SECRET_API_KEY not in message
print(" OK")
print()
print("=== 4. Reponse invalide (JSON incoherent) : le message ne contient jamais la cle API ===")
bad_response = MagicMock()
bad_response.read.return_value = b"{\"unexpected\": \"shape\"}"
bad_response.__enter__.return_value = bad_response
bad_response.__exit__.return_value = False
with patch("app.ai.provider.urllib.request.urlopen", return_value=bad_response):
try:
provider.chat([{"role": "user", "content": "x"}])
raise AssertionError("aurait du lever AIError")
except AIError as exc:
message = str(exc)
print(" message:", message)
assert SECRET_API_KEY not in message
print(" OK")
print()
print("=== 5. La cle API n'apparait pas non plus dans repr(provider) ===")
print(" repr:", repr(provider))
assert SECRET_API_KEY not in repr(provider)
assert SECRET_API_KEY not in str(provider)
print(" OK")
print()
print("NO SECRETS IN ERRORS : TOUS LES TESTS OK")