Files
college-infos/tests/unit/test_pronote_auth_state.py

284 lines
9.4 KiB
Python

"""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)