Files
pyssh-client/tests/test_credentials.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

167 lines
6.8 KiB
Python

"""Credential Vault : abstraction CredentialStore, backend Windows Credential
Manager reel (nettoye apres coup), et integration avec les flux de mot de
passe (ConnectionController, bastion, suppression de session).
SSHConnection.start() est neutralisee pour les tests d'integration qui
construisent un onglet reel (aucune connexion reseau necessaire)."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import json
import tempfile
from pathlib import Path
from unittest.mock import patch
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
from app.bastion import BastionTarget
from app.controllers.broadcast_manager import BroadcastManager
from app.controllers.connection_controller import ConnectionController
from app.controllers.session_controller import SessionController
from app.credentials import (
CredentialStore,
NullCredentialStore,
WindowsCredentialStore,
default_credential_store,
session_credential_key,
)
from app.session_manager import AUTH_PASSWORD, PASSWORD_STORAGE_SECURE, Session, SessionStore
from app.ssh_connection import SSHConnection
app = QApplication.instance() or QApplication([])
tmp = Path(tempfile.mkdtemp())
no_ssh_thread = patch.object(SSHConnection, "start", lambda self: None)
no_ssh_thread.start()
class FakeCredentialStore(CredentialStore):
"""Trousseau en memoire : deterministe, sans dependre du vrai Windows
Credential Manager pour les tests qui ne visent pas specifiquement a le
verifier lui-meme."""
def __init__(self):
self._data: dict[str, str] = {}
def is_available(self) -> bool:
return True
def set_secret(self, key: str, secret: str) -> bool:
self._data[key] = secret
return True
def get_secret(self, key: str):
return self._data.get(key)
def delete_secret(self, key: str) -> None:
self._data.pop(key, None)
print("=== 1. NullCredentialStore : jamais disponible, jamais memorise ===")
null_store = NullCredentialStore()
assert not null_store.is_available()
assert null_store.set_secret("x", "y") is False
assert null_store.get_secret("x") is None
null_store.delete_secret("x") # ne doit pas lever
print(" OK")
print()
print("=== 2. WindowsCredentialStore : aller-retour reel via Windows Credential Manager ===")
win_store = WindowsCredentialStore()
if win_store.is_available():
test_key = "test-vault-phase11"
win_store.delete_secret(test_key) # nettoyage prealable si un run precedent a echoue
assert win_store.get_secret(test_key) is None
ok = win_store.set_secret(test_key, "s3cret-valeur-de-test")
assert ok
assert win_store.get_secret(test_key) == "s3cret-valeur-de-test"
win_store.delete_secret(test_key)
assert win_store.get_secret(test_key) is None
print(" ecriture/lecture/suppression reelles : OK")
else:
print(" Windows Credential Manager indisponible sur ce poste : test saute")
print(" OK")
print()
print("=== 3. default_credential_store() ne leve jamais, meme si indisponible ===")
store = default_credential_store()
assert isinstance(store, CredentialStore)
print(" OK")
print()
print("=== 4. Session.to_dict() ne contient jamais le secret, seulement le mode ===")
session = Session(name="prod", host="10.0.0.1", username="root", auth_type=AUTH_PASSWORD,
password_storage=PASSWORD_STORAGE_SECURE)
serialized = json.dumps(session.to_dict())
print(" cle presente:", "password_storage" in serialized, "| secret absent: 's3cret' not in json")
assert "password_storage" in serialized
assert "s3cret" not in serialized
assert "password" not in session.to_dict() or True # aucun champ "password" n'existe sur Session
assert not hasattr(session, "password")
print(" OK")
print()
print("=== 5. ConnectionController : memorise puis reutilise le mot de passe ===")
session_store = SessionStore(path=tmp / "sessions.json")
key_manager_broadcast_widget = QWidget()
broadcast = BroadcastManager(lambda: [key_manager_broadcast_widget])
from app.key_manager import KeyManager
key_manager = KeyManager(registry_path=tmp / "keys.json")
fake_store = FakeCredentialStore()
cc = ConnectionController(session_store, key_manager, broadcast, credential_store=fake_store,
parent_widget=key_manager_broadcast_widget)
remembered = Session(name="r", host="10.0.0.2", username="root", auth_type=AUTH_PASSWORD,
password_storage=PASSWORD_STORAGE_SECURE)
session_store.add(remembered)
with patch("app.controllers.connection_controller.ask_secret", return_value=("motdepasse123", True)) as asked:
ok, secret = cc._ask_session_password(remembered)
assert ok and secret == "motdepasse123"
assert asked.called, "premiere fois : doit demander"
print(" premiere connexion -> mot de passe demande puis memorise")
assert fake_store.get_secret(session_credential_key(remembered.id)) == "motdepasse123"
with patch("app.controllers.connection_controller.ask_secret") as asked2:
ok2, secret2 = cc._ask_session_password(remembered)
assert ok2 and secret2 == "motdepasse123"
assert not asked2.called, "deuxieme fois : ne doit pas redemander"
print(" deuxieme connexion -> mot de passe reutilise sans redemander : OK")
print()
print("=== 6. Suppression de session : le secret memorise est aussi supprime ===")
sc = SessionController(session_store, key_manager, broadcast.button,
parent_widget=key_manager_broadcast_widget, credential_store=fake_store,
openssh_config_path=tmp / "no-ssh-config")
with patch("app.controllers.session_controller.QMessageBox.question", return_value=QMessageBox.Yes):
sc.delete_session(remembered)
assert fake_store.get_secret(session_credential_key(remembered.id)) is None
print(" OK")
print()
print("=== 7. Bastion en mode session reutilise le mot de passe deja memorise ===")
bastion_session = Session(name="bastion", host="10.0.0.3", username="jump", auth_type=AUTH_PASSWORD,
password_storage=PASSWORD_STORAGE_SECURE)
session_store.add(bastion_session)
fake_store.set_secret(session_credential_key(bastion_session.id), "bastion-secret")
target = BastionTarget(
host=bastion_session.host, port=22, username=bastion_session.username, auth_type=AUTH_PASSWORD,
credential_key=session_credential_key(bastion_session.id),
)
with patch("app.controllers.connection_controller.ask_secret") as asked3:
session2 = Session(name="cible", host="10.0.0.4", username="root")
with patch("app.controllers.connection_controller.resolve_bastion_target", return_value=target):
ok3, resolved_target, password3 = cc._resolve_bastion(session2)
assert ok3 and password3 == "bastion-secret"
assert not asked3.called, "le mot de passe du bastion doit etre reutilise, pas redemande"
print(" OK")
no_ssh_thread.stop()
print()
print("CREDENTIAL VAULT : TOUS LES TESTS OK")