Files
pyssh-client/tests/test_key_protection.py
Florian c39ef9c038 Qualite : pyproject.toml (ruff/pytest/pyright) + CI Gitea (lint/tests/build)
pyproject.toml devient le point d'entree unique de configuration des outils
(pytest.ini supprime, sa config y est deplacee). Les dependances restent dans
requirements.txt/requirements-dev.txt (deja utilises par setup.bat) plutot que
dupliquees ici : ce projet se distribue par PyInstaller, pas par "pip install".

ruff : configure puis passe sur tout app/ + tests/ (154 corrections
automatiques, purement mecaniques - tri des imports, imports multiples sur une
ligne separes, alias depreciees) suivies de 7 corrections manuelles ciblees :
  - app/ssh_connection.py : `raise ... from None` pour le refus explicite de
    passphrase (ce n'est pas un echec a diagnostiquer).
  - app/controllers/workspace_manager.py : zip() sans strict= documente en
    commentaire (deliberement tolerant a un workspace legerement perime).
  - tests/test_ai_redaction.py : nom de variable ambigu renomme.
  - E702 (construction compacte "widget = X(); widget.setY(...)" dans
    sftp_panel.py/tunnel_dialog.py) et E402 (quelques tests qui importent un
    symbole juste avant la section qui l'utilise) : ignores explicitement, ce
    sont des styles deliberes preexistants, pas des oublis.
`ruff check app main.py tests` est desormais propre.

pyright : mode "basic" (le code n'a jamais ete type). Plus de 200 signalements
sur ~300 etaient le meme faux positif connu de l'ecosysteme PySide6 (acces
plat aux enums Qt, ex. Qt.UserRole, valide a l'execution mais que les stubs ne
reconnaissent que sous leur forme imbriquee) : desactive avec justification en
commentaire. Reste un socle de 77 signalements reels (surtout des acces
Optional non filtres dans app/ui/tabs.py et app/ssh_connection.py) : non
corrige dans cette phase - resserrer le typage la ou pyright a raison est un
travail a part, pas une consequence automatique de l'ajout de l'outil. La CI
le lance en mode informatif (continue-on-error) tant que ce socle n'est pas
resorbe.

.gitea/workflows/ci.yml : lint (ruff, bloquant), tests (pytest, bloquant),
typecheck (pyright, informatif), build (PyInstaller, sur main uniquement,
artefact du dossier portable). Necessite un runner Gitea Actions Windows
enregistre - PySide6/pywinpty/Credential Manager/ACL NTFS sont Windows
uniquement, comme l'application elle-meme ; non verifiable depuis ce poste.

Suite complete revalidee apres coup (33/33) pour confirmer qu'aucune des 154
corrections automatiques n'a modifie de comportement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 07:46:50 +02:00

92 lines
3.3 KiB
Python

"""Protection des cles privees : verifie la sequence d'appels icacls sous Windows
(sans dependre des ACL reelles du systeme de fichiers, pour rester fiable quelle
que soit la machine qui execute le test) et le bon aiguillage vers le repli chmod
ailleurs que sous Windows."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import os
import subprocess
import tempfile
from pathlib import Path
from unittest.mock import patch
from app.security.key_protection import _protect_windows, protect_private_key
tmp = Path(tempfile.mkdtemp())
key_file = tmp / "id_test"
key_file.write_text("contenu-cle-privee", encoding="utf-8")
print("=== 1. Sous Windows : /inheritance:r puis /grant:r pour l'utilisateur courant ===")
calls = []
def fake_run(cmd, **kwargs):
calls.append(cmd)
return subprocess.CompletedProcess(cmd, 0)
with patch.dict(os.environ, {"USERDOMAIN": "MACHINE", "USERNAME": "florian"}):
with patch("subprocess.run", side_effect=fake_run):
_protect_windows(key_file)
print(" commandes executees:")
for c in calls:
print(" ", c)
assert len(calls) == 2, calls
assert calls[0] == ["icacls", str(key_file), "/inheritance:r"], calls[0]
assert calls[1] == ["icacls", str(key_file), "/grant:r", "MACHINE\\florian:F"], calls[1]
print(" OK")
print()
print("=== 2. Sans domaine (poste hors domaine) : compte = USERNAME seul ===")
calls.clear()
with patch.dict(os.environ, {"USERDOMAIN": "", "USERNAME": "florian"}):
with patch("subprocess.run", side_effect=fake_run):
_protect_windows(key_file)
assert calls[1][-1] == "florian:F", calls[1]
print(" OK")
print()
print("=== 3. Un echec icacls ne remonte jamais (best effort, comme l'ancien chmod) ===")
with patch("subprocess.run", side_effect=OSError("icacls introuvable")):
_protect_windows(key_file) # ne doit lever aucune exception
print(" aucune exception propagee : OK")
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "icacls")):
_protect_windows(key_file)
print(" code de sortie non nul : aucune exception propagee non plus : OK")
print()
print("=== 4. protect_private_key() aiguille vers icacls sous Windows ===")
with patch("os.name", "nt"), patch("app.security.key_protection._protect_windows") as windows_impl:
protect_private_key(key_file)
assert windows_impl.called
print(" os.name == 'nt' -> _protect_windows() appele : OK")
print()
print("=== 5. protect_private_key() retombe sur chmod(0o600) hors Windows ===")
with patch("os.name", "posix"), patch("pathlib.Path.chmod") as chmod_mock:
protect_private_key(key_file)
chmod_mock.assert_called_once_with(0o600)
print(" os.name != 'nt' -> chmod(0o600) appele : OK")
print()
print("=== 6. Ne rend jamais la cle MOINS protegee : jamais d'octroi a un groupe large ===")
all_granted_users = set()
with patch.dict(os.environ, {"USERDOMAIN": "MACHINE", "USERNAME": "florian"}):
with patch("subprocess.run", side_effect=fake_run):
calls.clear()
_protect_windows(key_file)
for cmd in calls:
if "/grant:r" in cmd:
all_granted_users.add(cmd[-1].split(":")[0])
print(" comptes ayant reçu un acces:", all_granted_users)
assert all_granted_users == {"MACHINE\\florian"}, all_granted_users
print(" OK")
print()
print("KEY_PROTECTION_WINDOWS_ACL : TOUS LES TESTS OK")