refactor(config): migrer RSS et Pronote vers l'endpoint commun et durcir le contrat #64
+3
-3
@@ -140,7 +140,7 @@
|
|||||||
"filename": "GUIDE_DEV_PYTHON.md",
|
"filename": "GUIDE_DEV_PYTHON.md",
|
||||||
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
||||||
"is_verified": false,
|
"is_verified": false,
|
||||||
"line_number": 5198
|
"line_number": 5205
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tests/unit/test_caldav_gateway.py": [
|
"tests/unit/test_caldav_gateway.py": [
|
||||||
@@ -172,7 +172,7 @@
|
|||||||
"filename": "tests/unit/test_caldav_security.py",
|
"filename": "tests/unit/test_caldav_security.py",
|
||||||
"hashed_secret": "6b554cd7b7e0115065fb4907307a74f1902154d4",
|
"hashed_secret": "6b554cd7b7e0115065fb4907307a74f1902154d4",
|
||||||
"is_verified": false,
|
"is_verified": false,
|
||||||
"line_number": 28
|
"line_number": 29
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tests/unit/test_xmpp_channel.py": [
|
"tests/unit/test_xmpp_channel.py": [
|
||||||
@@ -185,5 +185,5 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"generated_at": "2026-09-13T13:32:54Z"
|
"generated_at": "2026-09-13T13:46:01Z"
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-12
@@ -1632,11 +1632,14 @@ from ..models.agenda import RawCalendarData
|
|||||||
|
|
||||||
def fetch_ical(url: str, timeout: int = 20) -> str:
|
def fetch_ical(url: str, timeout: int = 20) -> str:
|
||||||
"""
|
"""
|
||||||
Récupère le flux iCal depuis une URL Pronote.
|
Récupère le flux iCal depuis une URL Pronote (HTTPS uniquement).
|
||||||
Inspiré de src/sources/pronote/fetch.ts.
|
Inspiré de src/sources/pronote/fetch.ts.
|
||||||
|
|
||||||
|
Le schéma ``file://`` n'est **pas** supporté : les fixtures locales sont
|
||||||
|
injectées via un mock HTTP (``responses``) dans les tests.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: URL du flux iCal (peut être file:// pour les tests).
|
url: URL HTTPS du flux iCal (avec token ``icalsecurise``).
|
||||||
timeout: Timeout en secondes (défaut: 20s).
|
timeout: Timeout en secondes (défaut: 20s).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -1651,16 +1654,7 @@ def fetch_ical(url: str, timeout: int = 20) -> str:
|
|||||||
"user-agent": "pronote-sync",
|
"user-agent": "pronote-sync",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Gestion des URLs file:// pour les tests
|
# Récupération HTTP (aucun accès fichier local)
|
||||||
if url.startswith("file://"):
|
|
||||||
import pathlib
|
|
||||||
file_path = pathlib.Path(url.replace("file://", ""))
|
|
||||||
content = file_path.read_text(encoding="utf-8")
|
|
||||||
if "BEGIN:VCALENDAR" not in content:
|
|
||||||
raise ValueError(f"Fichier iCal invalide: {redact_url(url)}")
|
|
||||||
return content
|
|
||||||
|
|
||||||
# Récupération HTTP
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
url,
|
url,
|
||||||
@@ -1686,6 +1680,19 @@ def fetch_ical(url: str, timeout: int = 20) -> str:
|
|||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
# Test unitaire réseau mocké (aucun accès fichier)
|
||||||
|
@responses.activate
|
||||||
|
def test_fetch_ical_https() -> None:
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://pronote.example.test/ical.ics",
|
||||||
|
body="BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR",
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
content = fetch_ical("https://pronote.example.test/ical.ics")
|
||||||
|
assert content.startswith("BEGIN:VCALENDAR")
|
||||||
|
|
||||||
|
|
||||||
def get_calendar_name(raw_ical: str) -> str | None:
|
def get_calendar_name(raw_ical: str) -> str | None:
|
||||||
"""
|
"""
|
||||||
Extrait le nom du calendrier depuis X-WR-CALNAME.
|
Extrait le nom du calendrier depuis X-WR-CALNAME.
|
||||||
|
|||||||
@@ -244,13 +244,12 @@ class CalDAVSettings(BaseSettings):
|
|||||||
def _migrate_legacy_url(cls, data: object) -> object:
|
def _migrate_legacy_url(cls, data: object) -> object:
|
||||||
"""Migre ``url`` vers l'endpoint commun avec un avertissement.
|
"""Migre ``url`` vers l'endpoint commun avec un avertissement.
|
||||||
|
|
||||||
L'alias historique ``CALDAV_URL`` conserve ses sémantiques passées
|
L'alias historique ``CALDAV_URL`` est migré via le chemin de validation
|
||||||
pendant la transition : lorsqu'il embarque des identifiants
|
canonique : il est donc soumis exactement aux mêmes règles que
|
||||||
(``user:pass@hôte``), l'endpoint est construit sans revalidation pour
|
``CALDAV_ENDPOINT__URL`` (schémas réseau uniquement, hôte obligatoire,
|
||||||
ne pas casser une configuration existante, alors que le contrat
|
port valide, credentials embarqués refusés). La politique de transport
|
||||||
canonique ``CALDAV_ENDPOINT__URL`` refuse désormais les credentials
|
(HTTPS, ou HTTP loopback uniquement avec ``allow_insecure_http``) reste
|
||||||
embarqués. La politique de transport (HTTPS, ou HTTP loopback
|
appliquée ensuite.
|
||||||
uniquement avec ``allow_insecure_http``) reste appliquée ensuite.
|
|
||||||
|
|
||||||
:param data: Données brutes du modèle.
|
:param data: Données brutes du modèle.
|
||||||
:return: Données complétées avec ``endpoint`` si nécessaire.
|
:return: Données complétées avec ``endpoint`` si nécessaire.
|
||||||
@@ -265,20 +264,7 @@ class CalDAVSettings(BaseSettings):
|
|||||||
stacklevel=2,
|
stacklevel=2,
|
||||||
)
|
)
|
||||||
if migrated_data.get("endpoint") is None:
|
if migrated_data.get("endpoint") is None:
|
||||||
legacy_url = migrated_data["url"]
|
migrated_data["endpoint"] = {"url": migrated_data["url"]}
|
||||||
raw_url = (
|
|
||||||
legacy_url.get_secret_value()
|
|
||||||
if isinstance(legacy_url, SecretStr)
|
|
||||||
else str(legacy_url)
|
|
||||||
)
|
|
||||||
parsed = urlparse(raw_url)
|
|
||||||
if parsed.username is not None or parsed.password is not None:
|
|
||||||
# Alias obsolète : grandfathered, on préserve l'URL telle quelle.
|
|
||||||
migrated_data["endpoint"] = ExternalEndpoint.model_construct(
|
|
||||||
url=legacy_url if isinstance(legacy_url, SecretStr) else SecretStr(legacy_url)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
migrated_data["endpoint"] = {"url": legacy_url}
|
|
||||||
return migrated_data
|
return migrated_data
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Récupération et parsing du flux iCal Pronote.
|
"""Récupération et parsing du flux iCal Pronote.
|
||||||
|
|
||||||
Ce module fournit le téléchargement du flux iCal Pronote (via HTTP ou
|
Ce module fournit le téléchargement réseau du flux iCal Pronote ainsi que
|
||||||
``file://`` pour les tests) ainsi que son parsing en modèles : cours
|
son parsing en modèles : cours
|
||||||
(:class:`~pronote_sync.models.agenda.Lesson`), événements scolaires
|
(:class:`~pronote_sync.models.agenda.Lesson`), événements scolaires
|
||||||
(:class:`~pronote_sync.models.agenda.SchoolEvent`) et devoirs
|
(:class:`~pronote_sync.models.agenda.SchoolEvent`) et devoirs
|
||||||
(:class:`~pronote_sync.models.homework.Homework`).
|
(:class:`~pronote_sync.models.homework.Homework`).
|
||||||
@@ -15,10 +15,8 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
import urllib.parse
|
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from html import unescape
|
from html import unescape
|
||||||
from pathlib import Path
|
|
||||||
from typing import NamedTuple, TypedDict
|
from typing import NamedTuple, TypedDict
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -70,35 +68,21 @@ class ParsedHomeworkBlock(NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
def fetch_ical(url: str, timeout: int = 20) -> str:
|
def fetch_ical(url: str, timeout: int = 20) -> str:
|
||||||
"""Récupère le contenu brut d'un flux iCal Pronote.
|
"""Récupère le contenu brut d'un flux iCal Pronote par HTTPS.
|
||||||
|
|
||||||
Gère les URLs ``file://`` pour les tests locaux (le chemin est
|
Le schéma ``file://`` n'est pas supporté : les fixtures locales doivent
|
||||||
décodé de l'échappement URI, ex. ``%20`` → espace) et valide que
|
être injectées via un mock HTTP (``responses``) dans les tests. Valide que
|
||||||
le flux commence bien par ``BEGIN:VCALENDAR``. Toutes les erreurs
|
le flux commence bien par ``BEGIN:VCALENDAR``. Toutes les erreurs sont
|
||||||
sont relancées avec un message dont les secrets (token
|
relancées avec un message dont les secrets (token ``icalsecurise``) sont
|
||||||
``icalsecurise``) sont masqués.
|
masqués.
|
||||||
|
|
||||||
:param url: URL du flux iCal (avec token ``icalsecurise``) ou chemin ``file://``.
|
:param url: URL HTTPS du flux iCal (avec token ``icalsecurise``).
|
||||||
:param timeout: Timeout HTTP en secondes (défaut : 20).
|
:param timeout: Timeout HTTP en secondes (défaut : 20).
|
||||||
:return: Contenu brut du flux iCal.
|
:return: Contenu brut du flux iCal.
|
||||||
:rtype: str
|
:rtype: str
|
||||||
:raises OSError: Si le fichier local ``file://`` est illisible.
|
|
||||||
:raises requests.RequestException: Si la récupération HTTP échoue.
|
:raises requests.RequestException: Si la récupération HTTP échoue.
|
||||||
:raises ValueError: Si le flux ne commence pas par ``BEGIN:VCALENDAR``.
|
:raises ValueError: Si le flux ne commence pas par ``BEGIN:VCALENDAR``.
|
||||||
"""
|
"""
|
||||||
if url.startswith("file://"):
|
|
||||||
parsed_url = urllib.parse.urlparse(url)
|
|
||||||
path = Path(urllib.parse.unquote(parsed_url.path))
|
|
||||||
try:
|
|
||||||
content = path.read_text(encoding="utf-8")
|
|
||||||
except OSError as exc:
|
|
||||||
raise OSError(
|
|
||||||
f"Impossible de lire le fichier iCal {redact_url(url)} : {redact_exception(exc)}"
|
|
||||||
) from exc
|
|
||||||
if not content.lstrip().startswith("BEGIN:VCALENDAR"):
|
|
||||||
raise ValueError(f"Fichier iCal invalide (pas de BEGIN:VCALENDAR) : {redact_url(url)}")
|
|
||||||
return content
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, headers=_HEADERS, timeout=timeout)
|
response = requests.get(url, headers=_HEADERS, timeout=timeout)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from unittest.mock import MagicMock
|
|||||||
import pytest
|
import pytest
|
||||||
from pydantic import SecretStr, ValidationError
|
from pydantic import SecretStr, ValidationError
|
||||||
|
|
||||||
from pronote_sync.config.settings import CalDAVSettings
|
from pronote_sync.config.settings import CalDAVSettings, ExternalEndpoint
|
||||||
from pronote_sync.errors import PronoteSyncError
|
from pronote_sync.errors import PronoteSyncError
|
||||||
from pronote_sync.sync.caldav import CalDAVGateway
|
from pronote_sync.sync.caldav import CalDAVGateway
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
# Sentinelles pour détecter les fuites de secrets dans les tests
|
# Sentinelles pour détecter les fuites de secrets dans les tests
|
||||||
SENTINEL_URL = "https://user:pass-super-secret-12345@caldav.example.com/secret-path/"
|
SENTINEL_URL = "https://user:pass-super-secret-12345@caldav.example.com/secret-path/"
|
||||||
|
SENTINEL_ENDPOINT_URL = "https://caldav.example.com/secret-path/?token=pass-super-secret-12345"
|
||||||
SENTINEL_PASSWORD = "super-secret-password-67890"
|
SENTINEL_PASSWORD = "super-secret-password-67890"
|
||||||
SENTINEL_HTTP_URL = "http://caldav.example.com/"
|
SENTINEL_HTTP_URL = "http://caldav.example.com/"
|
||||||
SENTINEL_HTTP_LOCALHOST = "http://localhost:5232/caldav/"
|
SENTINEL_HTTP_LOCALHOST = "http://localhost:5232/caldav/"
|
||||||
@@ -37,13 +38,13 @@ class TestCalDAVSettingsSecurity:
|
|||||||
def test_url_redaction_in_repr(self) -> None:
|
def test_url_redaction_in_repr(self) -> None:
|
||||||
"""Vérifie que l'URL brute n'apparaît pas dans repr(settings)."""
|
"""Vérifie que l'URL brute n'apparaît pas dans repr(settings)."""
|
||||||
settings = CalDAVSettings(
|
settings = CalDAVSettings(
|
||||||
url=SecretStr(SENTINEL_URL),
|
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
|
||||||
username="test-user",
|
username="test-user",
|
||||||
password=SecretStr(SENTINEL_PASSWORD),
|
password=SecretStr(SENTINEL_PASSWORD),
|
||||||
calendar_path="/cal/",
|
calendar_path="/cal/",
|
||||||
)
|
)
|
||||||
repr_str = repr(settings)
|
repr_str = repr(settings)
|
||||||
assert SENTINEL_URL not in repr_str
|
assert SENTINEL_ENDPOINT_URL not in repr_str
|
||||||
assert "pass-super-secret-12345" not in repr_str
|
assert "pass-super-secret-12345" not in repr_str
|
||||||
# L'URL est masquée par redact_url qui retourne REDACTED_URL ou une URL avec REDACTED
|
# L'URL est masquée par redact_url qui retourne REDACTED_URL ou une URL avec REDACTED
|
||||||
assert "REDACTED" in repr_str or "**********" in repr_str
|
assert "REDACTED" in repr_str or "**********" in repr_str
|
||||||
@@ -51,13 +52,13 @@ class TestCalDAVSettingsSecurity:
|
|||||||
def test_url_redaction_in_str(self) -> None:
|
def test_url_redaction_in_str(self) -> None:
|
||||||
"""Vérifie que l'URL brute n'apparaît pas dans str(settings)."""
|
"""Vérifie que l'URL brute n'apparaît pas dans str(settings)."""
|
||||||
settings = CalDAVSettings(
|
settings = CalDAVSettings(
|
||||||
url=SecretStr(SENTINEL_URL),
|
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
|
||||||
username="test-user",
|
username="test-user",
|
||||||
password=SecretStr(SENTINEL_PASSWORD),
|
password=SecretStr(SENTINEL_PASSWORD),
|
||||||
calendar_path="/cal/",
|
calendar_path="/cal/",
|
||||||
)
|
)
|
||||||
str_str = str(settings)
|
str_str = str(settings)
|
||||||
assert SENTINEL_URL not in str_str
|
assert SENTINEL_ENDPOINT_URL not in str_str
|
||||||
assert "pass-super-secret-12345" not in str_str
|
assert "pass-super-secret-12345" not in str_str
|
||||||
# L'URL est masquée par redact_url
|
# L'URL est masquée par redact_url
|
||||||
assert "REDACTED" in str_str or "**********" in str_str
|
assert "REDACTED" in str_str or "**********" in str_str
|
||||||
@@ -65,7 +66,7 @@ class TestCalDAVSettingsSecurity:
|
|||||||
def test_url_redaction_in_model_dump(self) -> None:
|
def test_url_redaction_in_model_dump(self) -> None:
|
||||||
"""Vérifie que l'URL brute n'apparaît pas dans model_dump()."""
|
"""Vérifie que l'URL brute n'apparaît pas dans model_dump()."""
|
||||||
settings = CalDAVSettings(
|
settings = CalDAVSettings(
|
||||||
url=SecretStr(SENTINEL_URL),
|
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
|
||||||
username="test-user",
|
username="test-user",
|
||||||
password=SecretStr(SENTINEL_PASSWORD),
|
password=SecretStr(SENTINEL_PASSWORD),
|
||||||
calendar_path="/cal/",
|
calendar_path="/cal/",
|
||||||
@@ -74,7 +75,7 @@ class TestCalDAVSettingsSecurity:
|
|||||||
# Vérifie que l'URL n'est pas dans les valeurs du dict
|
# Vérifie que l'URL n'est pas dans les valeurs du dict
|
||||||
for value in dumped.values():
|
for value in dumped.values():
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
assert SENTINEL_URL not in value
|
assert SENTINEL_ENDPOINT_URL not in value
|
||||||
assert "pass-super-secret-12345" not in value
|
assert "pass-super-secret-12345" not in value
|
||||||
# Vérifie que la version rédigée est présente
|
# Vérifie que la version rédigée est présente
|
||||||
assert "REDACTED" in str(dumped)
|
assert "REDACTED" in str(dumped)
|
||||||
@@ -201,7 +202,7 @@ class TestCalDAVGatewaySecurity:
|
|||||||
raise Exception(f"Connection failed to {SENTINEL_URL}?token={SENTINEL_PASSWORD}")
|
raise Exception(f"Connection failed to {SENTINEL_URL}?token={SENTINEL_PASSWORD}")
|
||||||
|
|
||||||
settings = CalDAVSettings(
|
settings = CalDAVSettings(
|
||||||
url=SecretStr(SENTINEL_URL),
|
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
|
||||||
username="test-user",
|
username="test-user",
|
||||||
password=SecretStr(SENTINEL_PASSWORD),
|
password=SecretStr(SENTINEL_PASSWORD),
|
||||||
calendar_path="/cal/",
|
calendar_path="/cal/",
|
||||||
@@ -215,12 +216,14 @@ class TestCalDAVGatewaySecurity:
|
|||||||
# Vérifie que le message d'erreur ne contient pas les sentinelles
|
# Vérifie que le message d'erreur ne contient pas les sentinelles
|
||||||
error_msg = str(exc_info.value)
|
error_msg = str(exc_info.value)
|
||||||
assert SENTINEL_URL not in error_msg
|
assert SENTINEL_URL not in error_msg
|
||||||
|
assert SENTINEL_ENDPOINT_URL not in error_msg
|
||||||
assert SENTINEL_PASSWORD not in error_msg
|
assert SENTINEL_PASSWORD not in error_msg
|
||||||
|
|
||||||
# Vérifie que les logs ne contiennent pas les sentinelles
|
# Vérifie que les logs ne contiennent pas les sentinelles
|
||||||
for record in caplog.records:
|
for record in caplog.records:
|
||||||
log_msg = record.getMessage()
|
log_msg = record.getMessage()
|
||||||
assert SENTINEL_URL not in log_msg
|
assert SENTINEL_URL not in log_msg
|
||||||
|
assert SENTINEL_ENDPOINT_URL not in log_msg
|
||||||
assert SENTINEL_PASSWORD not in log_msg
|
assert SENTINEL_PASSWORD not in log_msg
|
||||||
|
|
||||||
def test_exception_cause_and_context_is_none(self) -> None:
|
def test_exception_cause_and_context_is_none(self) -> None:
|
||||||
@@ -230,7 +233,7 @@ class TestCalDAVGatewaySecurity:
|
|||||||
raise Exception(f"Connection failed to {SENTINEL_URL}?token={SENTINEL_PASSWORD}")
|
raise Exception(f"Connection failed to {SENTINEL_URL}?token={SENTINEL_PASSWORD}")
|
||||||
|
|
||||||
settings = CalDAVSettings(
|
settings = CalDAVSettings(
|
||||||
url=SecretStr(SENTINEL_URL),
|
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
|
||||||
username="test-user",
|
username="test-user",
|
||||||
password=SecretStr(SENTINEL_PASSWORD),
|
password=SecretStr(SENTINEL_PASSWORD),
|
||||||
calendar_path="/cal/",
|
calendar_path="/cal/",
|
||||||
@@ -246,7 +249,7 @@ class TestCalDAVGatewaySecurity:
|
|||||||
def test_logs_redacted_on_list_managed_events_error(self, caplog: LogCaptureFixture) -> None:
|
def test_logs_redacted_on_list_managed_events_error(self, caplog: LogCaptureFixture) -> None:
|
||||||
"""Vérifie que les logs sont expurgés lors d'une erreur dans list_managed_events."""
|
"""Vérifie que les logs sont expurgés lors d'une erreur dans list_managed_events."""
|
||||||
settings = CalDAVSettings(
|
settings = CalDAVSettings(
|
||||||
url=SecretStr(SENTINEL_URL),
|
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
|
||||||
username="test-user",
|
username="test-user",
|
||||||
password=SecretStr(SENTINEL_PASSWORD),
|
password=SecretStr(SENTINEL_PASSWORD),
|
||||||
calendar_path="/cal/",
|
calendar_path="/cal/",
|
||||||
@@ -274,12 +277,14 @@ class TestCalDAVGatewaySecurity:
|
|||||||
# Vérifie que le message d'erreur ne contient pas les sentinelles
|
# Vérifie que le message d'erreur ne contient pas les sentinelles
|
||||||
error_msg = str(exc_info.value)
|
error_msg = str(exc_info.value)
|
||||||
assert SENTINEL_URL not in error_msg
|
assert SENTINEL_URL not in error_msg
|
||||||
|
assert SENTINEL_ENDPOINT_URL not in error_msg
|
||||||
assert SENTINEL_PASSWORD not in error_msg
|
assert SENTINEL_PASSWORD not in error_msg
|
||||||
|
|
||||||
# Vérifie que les logs ne contiennent pas les sentinelles
|
# Vérifie que les logs ne contiennent pas les sentinelles
|
||||||
for record in caplog.records:
|
for record in caplog.records:
|
||||||
log_msg = record.getMessage()
|
log_msg = record.getMessage()
|
||||||
assert SENTINEL_URL not in log_msg
|
assert SENTINEL_URL not in log_msg
|
||||||
|
assert SENTINEL_ENDPOINT_URL not in log_msg
|
||||||
assert SENTINEL_PASSWORD not in log_msg
|
assert SENTINEL_PASSWORD not in log_msg
|
||||||
|
|
||||||
# Vérifie que l'exception n'est chaînée à aucune exception brute
|
# Vérifie que l'exception n'est chaînée à aucune exception brute
|
||||||
@@ -289,7 +294,7 @@ class TestCalDAVGatewaySecurity:
|
|||||||
def test_logs_redacted_on_upsert_event_error(self, caplog: LogCaptureFixture) -> None:
|
def test_logs_redacted_on_upsert_event_error(self, caplog: LogCaptureFixture) -> None:
|
||||||
"""Vérifie que les logs sont expurgés lors d'une erreur dans upsert_event."""
|
"""Vérifie que les logs sont expurgés lors d'une erreur dans upsert_event."""
|
||||||
settings = CalDAVSettings(
|
settings = CalDAVSettings(
|
||||||
url=SecretStr(SENTINEL_URL),
|
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
|
||||||
username="test-user",
|
username="test-user",
|
||||||
password=SecretStr(SENTINEL_PASSWORD),
|
password=SecretStr(SENTINEL_PASSWORD),
|
||||||
calendar_path="/cal/",
|
calendar_path="/cal/",
|
||||||
@@ -312,12 +317,14 @@ class TestCalDAVGatewaySecurity:
|
|||||||
# Vérifie que le message d'erreur ne contient pas les sentinelles
|
# Vérifie que le message d'erreur ne contient pas les sentinelles
|
||||||
error_msg = str(exc_info.value)
|
error_msg = str(exc_info.value)
|
||||||
assert SENTINEL_URL not in error_msg
|
assert SENTINEL_URL not in error_msg
|
||||||
|
assert SENTINEL_ENDPOINT_URL not in error_msg
|
||||||
assert SENTINEL_PASSWORD not in error_msg
|
assert SENTINEL_PASSWORD not in error_msg
|
||||||
|
|
||||||
# Vérifie que les logs ne contiennent pas les sentinelles
|
# Vérifie que les logs ne contiennent pas les sentinelles
|
||||||
for record in caplog.records:
|
for record in caplog.records:
|
||||||
log_msg = record.getMessage()
|
log_msg = record.getMessage()
|
||||||
assert SENTINEL_URL not in log_msg
|
assert SENTINEL_URL not in log_msg
|
||||||
|
assert SENTINEL_ENDPOINT_URL not in log_msg
|
||||||
assert SENTINEL_PASSWORD not in log_msg
|
assert SENTINEL_PASSWORD not in log_msg
|
||||||
|
|
||||||
# Vérifie que l'exception n'est chaînée à aucune exception brute
|
# Vérifie que l'exception n'est chaînée à aucune exception brute
|
||||||
@@ -327,7 +334,7 @@ class TestCalDAVGatewaySecurity:
|
|||||||
def test_logs_redacted_on_delete_event_error(self, caplog: LogCaptureFixture) -> None:
|
def test_logs_redacted_on_delete_event_error(self, caplog: LogCaptureFixture) -> None:
|
||||||
"""Vérifie que les logs sont expurgés lors d'une erreur dans delete_event."""
|
"""Vérifie que les logs sont expurgés lors d'une erreur dans delete_event."""
|
||||||
settings = CalDAVSettings(
|
settings = CalDAVSettings(
|
||||||
url=SecretStr(SENTINEL_URL),
|
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
|
||||||
username="test-user",
|
username="test-user",
|
||||||
password=SecretStr(SENTINEL_PASSWORD),
|
password=SecretStr(SENTINEL_PASSWORD),
|
||||||
calendar_path="/cal/",
|
calendar_path="/cal/",
|
||||||
@@ -349,12 +356,14 @@ class TestCalDAVGatewaySecurity:
|
|||||||
# Vérifie que le message d'erreur ne contient pas les sentinelles
|
# Vérifie que le message d'erreur ne contient pas les sentinelles
|
||||||
error_msg = str(exc_info.value)
|
error_msg = str(exc_info.value)
|
||||||
assert SENTINEL_URL not in error_msg
|
assert SENTINEL_URL not in error_msg
|
||||||
|
assert SENTINEL_ENDPOINT_URL not in error_msg
|
||||||
assert SENTINEL_PASSWORD not in error_msg
|
assert SENTINEL_PASSWORD not in error_msg
|
||||||
|
|
||||||
# Vérifie que les logs ne contiennent pas les sentinelles
|
# Vérifie que les logs ne contiennent pas les sentinelles
|
||||||
for record in caplog.records:
|
for record in caplog.records:
|
||||||
log_msg = record.getMessage()
|
log_msg = record.getMessage()
|
||||||
assert SENTINEL_URL not in log_msg
|
assert SENTINEL_URL not in log_msg
|
||||||
|
assert SENTINEL_ENDPOINT_URL not in log_msg
|
||||||
assert SENTINEL_PASSWORD not in log_msg
|
assert SENTINEL_PASSWORD not in log_msg
|
||||||
|
|
||||||
# Vérifie que l'exception n'est chaînée à aucune exception brute
|
# Vérifie que l'exception n'est chaînée à aucune exception brute
|
||||||
@@ -364,7 +373,7 @@ class TestCalDAVGatewaySecurity:
|
|||||||
def test_redacted_url_stored_in_gateway(self) -> None:
|
def test_redacted_url_stored_in_gateway(self) -> None:
|
||||||
"""Vérifie que l'URL rédigée est stockée sur l'instance de la passerelle."""
|
"""Vérifie que l'URL rédigée est stockée sur l'instance de la passerelle."""
|
||||||
settings = CalDAVSettings(
|
settings = CalDAVSettings(
|
||||||
url=SecretStr(SENTINEL_URL),
|
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
|
||||||
username="test-user",
|
username="test-user",
|
||||||
password=SecretStr(SENTINEL_PASSWORD),
|
password=SecretStr(SENTINEL_PASSWORD),
|
||||||
calendar_path="/cal/",
|
calendar_path="/cal/",
|
||||||
@@ -372,8 +381,9 @@ class TestCalDAVGatewaySecurity:
|
|||||||
gateway = CalDAVGateway(settings)
|
gateway = CalDAVGateway(settings)
|
||||||
# Vérifie que l'URL rédigée est stockée
|
# Vérifie que l'URL rédigée est stockée
|
||||||
assert gateway._redacted_url is not None
|
assert gateway._redacted_url is not None
|
||||||
assert SENTINEL_URL not in gateway._redacted_url
|
assert SENTINEL_ENDPOINT_URL not in gateway._redacted_url
|
||||||
|
assert "pass-super-secret-12345" not in gateway._redacted_url
|
||||||
assert "REDACTED" in gateway._redacted_url
|
assert "REDACTED" in gateway._redacted_url
|
||||||
# Vérifie que l'URL brute n'est pas stockée en clair
|
# Vérifie que l'URL brute n'est pas stockée en clair
|
||||||
assert gateway._url_secret is not None
|
assert gateway._url_secret is not None
|
||||||
assert SENTINEL_URL not in str(gateway._url_secret)
|
assert SENTINEL_ENDPOINT_URL not in str(gateway._url_secret)
|
||||||
|
|||||||
@@ -212,6 +212,32 @@ def test_caldav_legacy_url_migrates_with_warning() -> None:
|
|||||||
assert settings.endpoint.url.get_secret_value() == "https://caldav.example.test/dav"
|
assert settings.endpoint.url.get_secret_value() == "https://caldav.example.test/dav"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url",
|
||||||
|
[
|
||||||
|
"https://user:pass@caldav.example.test/dav", # pragma: allowlist secret
|
||||||
|
"https:///missing-host",
|
||||||
|
"https://host:bad",
|
||||||
|
"file:///tmp/fixture.ics",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_caldav_legacy_url_alias_rejects_invalid_urls(url: str) -> None:
|
||||||
|
"""L'alias obsolète ``CALDAV_URL`` ne contourne pas la validation canonique.
|
||||||
|
|
||||||
|
Contrat corrigé : ``ExternalEndpoint.model_construct`` n'est plus utilisé
|
||||||
|
pour grandfathered les identifiants embarqués. L'alias est migré via le
|
||||||
|
chemin de validation normal, donc les credentials embarqués, l'hôte
|
||||||
|
manquant, le port invalide et le schéma ``file://`` sont refusés,
|
||||||
|
exactement comme pour ``CALDAV_ENDPOINT__URL``.
|
||||||
|
|
||||||
|
:param url: URL invalide fournie via l'alias historique.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
with pytest.warns(DeprecationWarning, match="CALDAV_URL"):
|
||||||
|
CalDAVSettings(url=SecretStr(url))
|
||||||
|
|
||||||
|
|
||||||
def test_pronote_endpoints_load_from_nested_environment(monkeypatch: MonkeyPatch) -> None:
|
def test_pronote_endpoints_load_from_nested_environment(monkeypatch: MonkeyPatch) -> None:
|
||||||
"""Vérifie le chargement des deux endpoints Pronote depuis l'environnement.
|
"""Vérifie le chargement des deux endpoints Pronote depuis l'environnement.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user