84 lines
3.0 KiB
Python
84 lines
3.0 KiB
Python
"""Régressions sur le contexte des exceptions expurgées."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import traceback
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from pronote_sync.errors import PipelineCriticalError
|
|
from pronote_sync.models.agenda import Lesson, SchoolEvent
|
|
from pronote_sync.models.homework import Homework
|
|
from pronote_sync.models.message import Message
|
|
from pronote_sync.pipeline.steps.fetch import fetch_step
|
|
|
|
|
|
class _FailingFetcher:
|
|
"""Fetcher minimal qui expose une erreur externe porteuse d'un secret."""
|
|
|
|
def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]:
|
|
"""Déclenche une erreur externe pendant la récupération critique."""
|
|
raise RuntimeError("password=fetch-context-secret")
|
|
|
|
def fetch_homework(self, target_date: date) -> list[Homework]:
|
|
"""Retourne une liste vide pour compléter le protocole du fetcher."""
|
|
return []
|
|
|
|
def fetch_messages(self) -> list[Message]:
|
|
"""Retourne une liste vide pour compléter le protocole du fetcher."""
|
|
return []
|
|
|
|
def fetch_informations(self) -> list[Message]:
|
|
"""Retourne une liste vide pour compléter le protocole du fetcher."""
|
|
return []
|
|
|
|
|
|
def test_fetch_step_does_not_retain_external_exception_context() -> None:
|
|
"""Vérifie qu'une erreur critique ne conserve ni secret ni contexte externe."""
|
|
with pytest.raises(PipelineCriticalError) as exc_info:
|
|
fetch_step(_FailingFetcher())
|
|
|
|
error = exc_info.value
|
|
formatted = "".join(traceback.format_exception(error))
|
|
assert "fetch-context-secret" not in str(error)
|
|
assert "fetch-context-secret" not in formatted
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
|
|
|
|
def test_production_raise_from_none_is_never_inside_except() -> None:
|
|
"""Vérifie structurellement que les exceptions expurgées sont levées hors des handlers."""
|
|
root = Path(__file__).parents[2] / "pronote_sync"
|
|
violations: list[str] = []
|
|
|
|
class Visitor(ast.NodeVisitor):
|
|
"""Collecte les levées ``from None`` imbriquées dans un handler."""
|
|
|
|
def __init__(self) -> None:
|
|
self._inside_except = False
|
|
|
|
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
"""Visite un handler en marquant son périmètre lexical."""
|
|
previous = self._inside_except
|
|
self._inside_except = True
|
|
self.generic_visit(node)
|
|
self._inside_except = previous
|
|
|
|
def visit_Raise(self, node: ast.Raise) -> None:
|
|
"""Signale une levée ``from None`` dans un handler."""
|
|
if (
|
|
self._inside_except
|
|
and isinstance(node.cause, ast.Constant)
|
|
and node.cause.value is None
|
|
):
|
|
violations.append(f"{path}:{node.lineno}")
|
|
self.generic_visit(node)
|
|
|
|
for path in sorted(root.rglob("*.py")):
|
|
Visitor().visit(ast.parse(path.read_text(encoding="utf-8")))
|
|
|
|
assert violations == []
|