fix: add QR token state lock primitive
This commit is contained in:
@@ -17,10 +17,12 @@ 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
|
||||
|
||||
|
||||
@@ -206,3 +208,76 @@ def test_no_credentials_in_logs(tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user