"""Tests unitaires pour le gestionnaire d'état d'authentification Pronote. Ce module valide le comportement de :class:`PronoteAuthState` dans :mod:`pronote_sync.sources.pronote.auth_state`. Les tests couvrent : - Le chargement des credentials (absent, corrompu, version invalide), - La persistance et le rechargement des credentials, - Les permissions ``0600`` du fichier d'état, - La suppression via :meth:`clear`, - L'absence de fuite des credentials dans les journaux. Tous les tests utilisent des fichiers temporaires via la fixture ``tmp_path``. """ from __future__ import annotations import json import logging import os from fcntl import LOCK_EX, LOCK_NB, LOCK_UN, flock from pathlib import Path import pytest from pronote_sync.errors import PronoteAuthStateLockError from pronote_sync.sources.pronote.auth_state import PronoteAuthState def test_load_no_file_returns_none(tmp_path: Path) -> None: """Vérifie qu'un fichier d'état absent renvoie ``None``. :param tmp_path: Fixture pytest pour un répertoire temporaire. :return: None """ state = PronoteAuthState(tmp_path / "missing.json") assert state.load() is None def test_save_then_load_roundtrip(tmp_path: Path) -> None: """Vérifie que des credentials sauvegardés sont rechargés à l'identique. :param tmp_path: Fixture pytest pour un répertoire temporaire. :return: None """ state_file = tmp_path / "auth.json" credentials = { "pronote_url": "https://example.com/pronote", "username": "parent-1", "password": "token-123", # pragma: allowlist secret "uuid": "uuid-456", } state = PronoteAuthState(state_file) state.save(credentials) loaded = PronoteAuthState(state_file).load() assert loaded == credentials def test_load_corrupted_json_returns_none(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: """Vérifie qu'un fichier JSON corrompu renvoie ``None`` et journalise un avertissement. :param tmp_path: Fixture pytest pour un répertoire temporaire. :param caplog: Fixture pytest pour capturer les logs. :return: None """ state_file = tmp_path / "corrupt.json" state_file.write_text("not json{", encoding="utf-8") with caplog.at_level("WARNING"): result = PronoteAuthState(state_file).load() assert result is None assert "Impossible de charger le fichier d'état d'authentification Pronote" in caplog.text def test_load_wrong_version_returns_none(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: """Vérifie qu'une version non supportée renvoie ``None`` et journalise un avertissement. :param tmp_path: Fixture pytest pour un répertoire temporaire. :param caplog: Fixture pytest pour capturer les logs. :return: None """ state_file = tmp_path / "wrong_version.json" state_file.write_text( json.dumps( { "version": 2, "credentials": { "pronote_url": "https://example.com", "username": "u", "password": "t", "uuid": "i", }, } ), encoding="utf-8", ) with caplog.at_level("WARNING"): result = PronoteAuthState(state_file).load() assert result is None assert "version absente ou non supportée" in caplog.text def test_load_missing_version_returns_none( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """Vérifie qu'un fichier sans champ version renvoie ``None``. :param tmp_path: Fixture pytest pour un répertoire temporaire. :param caplog: Fixture pytest pour capturer les logs. :return: None """ state_file = tmp_path / "missing_version.json" state_file.write_text( json.dumps( { "credentials": { "pronote_url": "https://example.com", "username": "u", "password": "t", "uuid": "i", } } ), encoding="utf-8", ) with caplog.at_level("WARNING"): result = PronoteAuthState(state_file).load() assert result is None def test_clear_removes_file(tmp_path: Path) -> None: """Vérifie que clear supprime le fichier d'état existant. :param tmp_path: Fixture pytest pour un répertoire temporaire. :return: None """ state_file = tmp_path / "auth.json" state = PronoteAuthState(state_file) state.save({"pronote_url": "u", "username": "u", "password": "t", "uuid": "i"}) assert state_file.exists() state.clear() assert not state_file.exists() def test_clear_no_file_noop(tmp_path: Path) -> None: """Vérifie que clear ne fait rien quand le fichier n'existe pas. :param tmp_path: Fixture pytest pour un répertoire temporaire. :return: None """ state = PronoteAuthState(tmp_path / "missing.json") state.clear() def test_save_creates_file_with_0600_permissions(tmp_path: Path) -> None: """Vérifie que le fichier d'état est créé avec les permissions ``0600``. Le fichier contient un token vivant : il doit être lisible uniquement par le propriétaire. :param tmp_path: Fixture pytest pour un répertoire temporaire. :return: None """ state_file = tmp_path / "auth.json" state = PronoteAuthState(state_file) state.save({"pronote_url": "u", "username": "u", "password": "t", "uuid": "i"}) assert os.stat(state_file).st_mode & 0o777 == 0o600 def test_no_credentials_in_logs(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: """Vérifie qu'aucun contenu des credentials n'apparaît dans les journaux. Des sentinelles distinctes sont utilisées pour ``pronote_url``, ``username``, ``password`` et ``uuid`` ; aucun de ces marqueurs ne doit apparaître dans les messages journalisés lors d'une sauvegarde, d'un chargement et d'une suppression. :param tmp_path: Fixture pytest pour un répertoire temporaire. :param caplog: Fixture pytest pour capturer les logs. :return: None """ state_file = tmp_path / "auth.json" credentials = { "pronote_url": "https://SENTINEL_URL_ZZZ.example/pronote", "username": "SENTINEL_USER_ZZZ", "password": "SENTINEL_PASSWORD_ZZZ", # pragma: allowlist secret "uuid": "SENTINEL_UUID_ZZZ", } state = PronoteAuthState(state_file) with caplog.at_level(logging.DEBUG): state.save(credentials) state.load() state.clear() assert "SENTINEL_URL_ZZZ" not in caplog.text assert "SENTINEL_USER_ZZZ" not in caplog.text assert "SENTINEL_PASSWORD_ZZZ" not in caplog.text assert "SENTINEL_UUID_ZZZ" not in caplog.text def test_lock_rejects_concurrent_access_with_a_redacted_dedicated_error(tmp_path: Path) -> None: """Vérifie qu'un verrou concurrent échoue immédiatement sans fuite interne. :param tmp_path: Fixture pytest pour un répertoire temporaire. :return: None """ state_file = tmp_path / ".pronote_auth_state.json" state = PronoteAuthState(state_file) competing_state = PronoteAuthState(state_file) with state.lock(): assert state_file.with_name(f"{state_file.name}.lock").exists() with pytest.raises(PronoteAuthStateLockError) as exc_info: with competing_state.lock(): pass assert "BlockingIOError" not in str(exc_info.value) assert exc_info.value.__cause__ is None assert exc_info.value.__context__ is None def test_lock_open_failure_does_not_log_sensitive_lock_path( tmp_path: Path, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """Vérifie qu'un échec d'ouverture du verrou ne divulgue pas son chemin. :param tmp_path: Fixture pytest pour un répertoire temporaire. :param caplog: Fixture pytest pour capturer les logs. :param monkeypatch: Fixture pytest pour remplacer l'ouverture du verrou. :return: None """ sentinel_path = "/SENTINEL_LOCK_PATH_ZZZ/.pronote_auth_state.json.lock" def raise_lock_open_error(*args: object, **kwargs: object) -> int: """Simule un refus d'ouverture portant un chemin sensible.""" del args, kwargs raise OSError(13, "Permission denied", sentinel_path) monkeypatch.setattr(os, "open", raise_lock_open_error) with caplog.at_level(logging.ERROR): with pytest.raises(PronoteAuthStateLockError) as exc_info: with PronoteAuthState(tmp_path / ".pronote_auth_state.json").lock(): pass assert "Impossible d'ouvrir le verrou d'état d'authentification Pronote" in caplog.text assert "SENTINEL_LOCK_PATH_ZZZ" not in caplog.text assert exc_info.value.__cause__ is None assert exc_info.value.__context__ is None def test_lock_is_released_when_the_protected_operation_raises(tmp_path: Path) -> None: """Vérifie que le verrou est libéré même si le bloc protégé échoue. :param tmp_path: Fixture pytest pour un répertoire temporaire. :return: None """ state_file = tmp_path / ".pronote_auth_state.json" lock_file = state_file.with_name(f"{state_file.name}.lock") state = PronoteAuthState(state_file) with pytest.raises(RuntimeError, match="échec simulé"): with state.lock(): raise RuntimeError("échec simulé") descriptor = os.open(lock_file, os.O_RDWR) try: flock(descriptor, LOCK_EX | LOCK_NB) flock(descriptor, LOCK_UN) finally: os.close(descriptor)