284 lines
9.4 KiB
Python
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)
|