From 09b7ed092c8195953ae4c7689f6947c109010afa Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 5 Sep 2026 07:32:55 +0200 Subject: [PATCH] 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 --- .gitignore | 1 + pytest.ini | 3 + requirements-dev.txt | 3 + tests/conftest.py | 16 ++++++ tests/run_all.bat | 14 +---- tests/test_all_scripts.py | 50 +++++++++++++++++ tests/test_no_secrets_in_errors.py | 89 ++++++++++++++++++++++++++++++ 7 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_all_scripts.py create mode 100644 tests/test_no_secrets_in_errors.py diff --git a/.gitignore b/.gitignore index 87ce805..b6b9803 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ venv/ __pycache__/ *.pyc +.pytest_cache/ # generes localement (chemins absolus / dependants du poste) *.lnk diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..b9f1401 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +python_files = test_all_scripts.py diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..f9937f2 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest>=8.0 +pytest-qt>=4.4 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..05b1640 --- /dev/null +++ b/tests/conftest.py @@ -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"] diff --git a/tests/run_all.bat b/tests/run_all.bat index d7242f1..c6e0984 100644 --- a/tests/run_all.bat +++ b/tests/run_all.bat @@ -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). set QT_QPA_PLATFORM=offscreen set PYTHONIOENCODING=utf-8 +set PYTHONUTF8=1 -set FAILED=0 -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 -) +venv\Scripts\python.exe -m pytest tests -v -echo. -if "%FAILED%"=="1" ( - echo Au moins un test a echoue. -) else ( - echo Tous les tests sont passes. -) pause diff --git a/tests/test_all_scripts.py b/tests/test_all_scripts.py new file mode 100644 index 0000000..baca213 --- /dev/null +++ b/tests/test_all_scripts.py @@ -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, + ) diff --git a/tests/test_no_secrets_in_errors.py b/tests/test_no_secrets_in_errors.py new file mode 100644 index 0000000..adf3882 --- /dev/null +++ b/tests/test_no_secrets_in_errors.py @@ -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")