Retire le schéma file du contrat ExternalEndpoint (CalDAV, Pronote iCal, Blog RSS) : seuls https et le loopback http CalDAV explicite restent acceptés. Rejette les URLs contenant userinfo. Ajoute hide_input_in_errors aux Settings. Réécrit les fixtures file:// en https mocké et ajoute une matrice de tests paramétrée du contrat. Refs #63
728 lines
26 KiB
Python
728 lines
26 KiB
Python
"""Tests unitaires pour la configuration et le chargement des secrets.
|
|
|
|
Ce module vérifie que la configuration est chargée correctement depuis les
|
|
variables d'environnement, que les secrets sont masqués dans les représentations
|
|
textuelles et sérialisées, et que le rechargement fonctionne comme attendu.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
from pydantic import SecretStr, ValidationError
|
|
|
|
from pronote_sync.config.env import load_settings
|
|
from pronote_sync.config.settings import (
|
|
AppSettings,
|
|
BlogSettings,
|
|
CalDAVSettings,
|
|
ExternalEndpoint,
|
|
PronoteSettings,
|
|
Settings,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from _pytest.monkeypatch import MonkeyPatch
|
|
|
|
|
|
def test_env_prefix_loading(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que les variables d'environnement avec préfixe sont chargées correctement.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
monkeypatch.setenv("PRONOTE_USERNAME", "testuser")
|
|
settings = load_settings()
|
|
assert settings.pronote.username == "testuser"
|
|
|
|
|
|
def test_env_reload_via_load_settings(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que le rechargement de la configuration lit les nouvelles valeurs.
|
|
|
|
Ce test garantit que les champs configurés avec ``default_factory`` créent
|
|
des instances fraîches à chaque appel de ``load_settings()``, permettant ainsi
|
|
un rechargement dynamique des variables d'environnement.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
# Premier chargement (valeur par défaut None)
|
|
settings1 = load_settings()
|
|
assert settings1.pronote.username is None
|
|
|
|
# Modification de l'environnement
|
|
monkeypatch.setenv("PRONOTE_USERNAME", "newuser")
|
|
|
|
# Rechargement
|
|
settings2 = load_settings()
|
|
assert settings2.pronote.username == "newuser"
|
|
|
|
|
|
def test_secretstr_masking_ical_url() -> None:
|
|
"""Vérifie que l'URL iCal contenant un secret est masquée dans les représentations.
|
|
|
|
:return: None
|
|
"""
|
|
ical_url = "https://example.fr/ical?icalsecurise=SECRET_TOKEN" # pragma: allowlist secret
|
|
settings = Settings(pronote=PronoteSettings(ical_url=SecretStr(ical_url)))
|
|
|
|
# Vérification de la représentation string
|
|
str_repr = str(settings)
|
|
assert "SECRET_TOKEN" not in str_repr
|
|
assert "**********" in str_repr
|
|
|
|
# Vérification de la représentation repr
|
|
repr_repr = repr(settings)
|
|
assert "SECRET_TOKEN" not in repr_repr
|
|
assert "**********" in repr_repr
|
|
|
|
# Vérification de la sérialisation JSON
|
|
json_str = settings.model_dump_json()
|
|
assert "SECRET_TOKEN" not in json_str
|
|
assert "REDACTED" in json_str
|
|
|
|
|
|
def test_secretstr_masking_password() -> None:
|
|
"""Vérifie que le mot de passe est masqué dans les représentations.
|
|
|
|
:return: None
|
|
"""
|
|
password = "mypass123" # pragma: allowlist secret
|
|
settings = Settings(pronote=PronoteSettings(password=SecretStr(password)))
|
|
|
|
# Vérification de la représentation string
|
|
str_repr = str(settings)
|
|
assert "mypass123" not in str_repr
|
|
assert "**********" in str_repr
|
|
|
|
# Vérification de la représentation repr
|
|
repr_repr = repr(settings)
|
|
assert "mypass123" not in repr_repr
|
|
assert "**********" in repr_repr
|
|
|
|
# Vérification de la sérialisation JSON
|
|
json_str = settings.model_dump_json()
|
|
assert "mypass123" not in json_str
|
|
assert "**********" in json_str
|
|
|
|
|
|
def test_no_singleton_import() -> None:
|
|
"""Vérifie que l'import du singleton settings lève une ImportError.
|
|
|
|
Ce test garantit que le pattern singleton a été supprimé et que la
|
|
configuration doit être chargée explicitement via ``load_settings()``.
|
|
|
|
:return: None
|
|
"""
|
|
with pytest.raises(ImportError):
|
|
# Attempt to import a non-existent attribute to trigger ImportError
|
|
# as required by the spec
|
|
from pronote_sync.config.settings import ( # type: ignore[attr-defined] # noqa: F401
|
|
settings,
|
|
)
|
|
|
|
|
|
def test_url_from_pronote_url_env_var(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que PRONOTE_URL mappe au champ url via le préfixe PRONOTE_.
|
|
|
|
Ce test couvre la régression où PRONOTE_URL n'était pas mappé vers le
|
|
champ du modèle à cause du double préfixe PRONOTE_.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
test_url = "https://example.index-education.net/pronote/parent.html"
|
|
monkeypatch.setenv("PRONOTE_URL", test_url)
|
|
settings = load_settings()
|
|
assert settings.pronote.endpoint is not None
|
|
assert settings.pronote.endpoint.url.get_secret_value() == test_url
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"https://endpoint.example.test/api",
|
|
"https://endpoint.example.test:8443/ical?icalsecurise=TOKEN", # pragma: allowlist secret
|
|
],
|
|
)
|
|
def test_external_endpoint_accepts_supported_schemes(url: str) -> None:
|
|
"""Vérifie le socle commun des URL d'endpoints autorisés.
|
|
|
|
:param url: URL représentative du schéma à valider.
|
|
:return: None
|
|
"""
|
|
endpoint = ExternalEndpoint(url=SecretStr(url))
|
|
assert endpoint.url.get_secret_value() == url
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
["ftp://endpoint.example.test", "https:///missing-host", "https://host:bad", "file://"],
|
|
)
|
|
def test_external_endpoint_rejects_invalid_urls_without_leak(url: str) -> None:
|
|
"""Vérifie que le socle rejette les URL invalides sans les afficher.
|
|
|
|
:param url: URL invalide à refuser.
|
|
:return: None
|
|
"""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
ExternalEndpoint(url=SecretStr(url))
|
|
assert url not in str(exc_info.value)
|
|
|
|
|
|
def test_external_endpoint_is_immutable_and_redacted() -> None:
|
|
"""Vérifie le contrat immuable et expurgé du value object.
|
|
|
|
:return: None
|
|
"""
|
|
secret = "SECRET_TOKEN_XYZ" # pragma: allowlist secret
|
|
endpoint = ExternalEndpoint(
|
|
url=SecretStr(f"https://example.test/calendar?icalsecurise={secret}")
|
|
)
|
|
with pytest.raises(ValidationError):
|
|
endpoint.url = SecretStr("https://other.example.test")
|
|
assert secret not in endpoint.model_dump_json()
|
|
assert "REDACTED" in endpoint.model_dump_json()
|
|
with pytest.raises(ValidationError):
|
|
ExternalEndpoint(url=SecretStr("https://example.test/calendar"), unknown_field="x") # type: ignore[call-arg]
|
|
|
|
|
|
def test_caldav_endpoint_loads_from_nested_environment(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie le chargement du nouvel endpoint CalDAV depuis l'environnement.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
monkeypatch.setenv("CALDAV_ENDPOINT__URL", "https://caldav.example.test/dav")
|
|
settings = load_settings()
|
|
assert settings.caldav.endpoint is not None
|
|
assert settings.caldav.endpoint.url.get_secret_value() == "https://caldav.example.test/dav"
|
|
|
|
|
|
def test_caldav_legacy_url_migrates_with_warning() -> None:
|
|
"""Vérifie la migration temporaire du réglage CalDAV historique.
|
|
|
|
:return: None
|
|
"""
|
|
with pytest.warns(DeprecationWarning, match="CALDAV_URL"):
|
|
settings = CalDAVSettings(url=SecretStr("https://caldav.example.test/dav"))
|
|
assert settings.endpoint is not None
|
|
assert settings.endpoint.url.get_secret_value() == "https://caldav.example.test/dav"
|
|
|
|
|
|
def test_pronote_endpoints_load_from_nested_environment(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie le chargement des deux endpoints Pronote depuis l'environnement.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
monkeypatch.setenv("PRONOTE_ENDPOINT__URL", "https://pronote.example.test/parent.html")
|
|
monkeypatch.setenv("PRONOTE_ICAL_ENDPOINT__URL", "https://ical.example.test/pronote.ics")
|
|
settings = load_settings()
|
|
assert settings.pronote.endpoint is not None
|
|
assert settings.pronote.ical_endpoint is not None
|
|
assert (
|
|
settings.pronote.endpoint.url.get_secret_value()
|
|
== "https://pronote.example.test/parent.html"
|
|
)
|
|
assert (
|
|
settings.pronote.ical_endpoint.url.get_secret_value()
|
|
== "https://ical.example.test/pronote.ics"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("field", "url", "message"),
|
|
[
|
|
("endpoint", "http://pronote.example.test", "HTTPS requis"),
|
|
("ical_endpoint", "http://pronote.example.test/calendar", "URL iCal Pronote invalide"),
|
|
],
|
|
)
|
|
def test_pronote_endpoint_policy_rejects_insecure_url(field: str, url: str, message: str) -> None:
|
|
"""Vérifie la politique de transport des endpoints Pronote.
|
|
|
|
:param field: Nom du champ endpoint à alimenter.
|
|
:param url: URL non sûre à refuser.
|
|
:param message: Fragment attendu du message sûr.
|
|
:return: None
|
|
"""
|
|
endpoint = ExternalEndpoint(url=SecretStr(url))
|
|
with pytest.raises(ValidationError, match=message) as exc_info:
|
|
if field == "endpoint":
|
|
PronoteSettings(endpoint=endpoint)
|
|
else:
|
|
PronoteSettings(ical_endpoint=endpoint)
|
|
assert url not in str(exc_info.value)
|
|
|
|
|
|
def test_blog_endpoint_loads_from_nested_environment(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie le chargement du nouvel endpoint RSS depuis l'environnement.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
monkeypatch.setenv("BLOG_ENDPOINT__URL", "https://blog.example.test/feed")
|
|
settings = load_settings()
|
|
assert settings.blog.endpoint.url.get_secret_value() == "https://blog.example.test/feed"
|
|
|
|
|
|
def test_blog_legacy_rss_url_migrates_with_warning() -> None:
|
|
"""Vérifie la migration temporaire du réglage RSS historique.
|
|
|
|
:return: None
|
|
"""
|
|
with pytest.warns(DeprecationWarning, match="BLOG_RSS_URL"):
|
|
settings = BlogSettings(rss_url="https://blog.example.test/feed")
|
|
assert settings.endpoint.url.get_secret_value() == "https://blog.example.test/feed"
|
|
|
|
|
|
def test_blog_endpoint_rejects_insecure_url_without_leak() -> None:
|
|
"""Vérifie que l'URL RSS HTTP est refusée sans être exposée.
|
|
|
|
:return: None
|
|
"""
|
|
url = "http://user:secret@blog.example.test/feed" # pragma: allowlist secret
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
BlogSettings(endpoint=ExternalEndpoint(url=SecretStr(url)))
|
|
assert url not in str(exc_info.value)
|
|
|
|
|
|
def test_auth_mode_default_password() -> None:
|
|
"""Vérifie que ``auth_mode`` vaut ``"password"`` par défaut.
|
|
|
|
:return: None
|
|
"""
|
|
settings = PronoteSettings()
|
|
assert settings.auth_mode == "password"
|
|
|
|
|
|
def test_auth_mode_env_qr_token(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que ``PRONOTE_AUTH_MODE=qr_token`` est chargé correctement.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
monkeypatch.setenv("PRONOTE_AUTH_MODE", "qr_token")
|
|
settings = load_settings()
|
|
assert settings.pronote.auth_mode == "qr_token"
|
|
|
|
|
|
def test_qr_pin_loaded_as_secretstr_and_masked(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que ``PRONOTE_QR_PIN`` est chargé en ``SecretStr`` et masqué.
|
|
|
|
Le PIN ne doit apparaître nulle part dans les représentations textuelles
|
|
(str, repr, JSON) : seul le masque ``**********`` est visible.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
monkeypatch.setenv("PRONOTE_QR_PIN", "123456")
|
|
settings = load_settings()
|
|
assert isinstance(settings.pronote.qr_pin, SecretStr)
|
|
assert settings.pronote.qr_pin.get_secret_value() == "123456"
|
|
|
|
str_repr = str(settings)
|
|
assert "123456" not in str_repr
|
|
assert "**********" in str_repr
|
|
|
|
repr_repr = repr(settings)
|
|
assert "123456" not in repr_repr
|
|
assert "**********" in repr_repr
|
|
|
|
json_str = settings.model_dump_json()
|
|
assert "123456" not in json_str
|
|
assert "**********" in json_str
|
|
|
|
|
|
def test_qr_code_file_loaded_as_plain_string(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que ``PRONOTE_QR_CODE_FILE`` est chargé comme chaîne simple.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
monkeypatch.setenv("PRONOTE_QR_CODE_FILE", "/data/qr_code.png")
|
|
settings = load_settings()
|
|
assert isinstance(settings.pronote.qr_code_file, str)
|
|
assert settings.pronote.qr_code_file == "/data/qr_code.png"
|
|
|
|
|
|
def test_qr_pin_in_redaction_secrets(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que le PIN QR est collecté pour la rédaction des secrets.
|
|
|
|
Le ``SecretStr`` du PIN doit figurer dans ``redaction_secrets()`` et sa
|
|
représentation textuelle doit rester masquée.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
monkeypatch.setenv("PRONOTE_QR_PIN", "654321")
|
|
settings = load_settings()
|
|
secrets = settings.redaction_secrets()
|
|
assert settings.pronote.qr_pin in secrets
|
|
assert "654321" not in repr(settings.pronote.qr_pin)
|
|
assert "**********" in repr(settings.pronote.qr_pin)
|
|
|
|
|
|
def test_account_pin_loaded_as_secretstr_and_redacted(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que ``PRONOTE_ACCOUNT_PIN`` est secret et expurgé.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
monkeypatch.setenv("PRONOTE_ACCOUNT_PIN", "account-pin-42")
|
|
settings = load_settings()
|
|
|
|
assert isinstance(settings.pronote.account_pin, SecretStr)
|
|
assert settings.pronote.account_pin.get_secret_value() == "account-pin-42"
|
|
assert settings.pronote.account_pin in settings.redaction_secrets()
|
|
assert "account-pin-42" not in str(settings)
|
|
assert "account-pin-42" not in settings.model_dump_json()
|
|
assert "**********" in settings.model_dump_json()
|
|
|
|
|
|
def test_sync_past_days_negative_direct_instantiation() -> None:
|
|
"""Vérifie que ``sync_past_days`` négatif lève ``ValidationError`` à l'instanciation.
|
|
|
|
:return: None
|
|
"""
|
|
with pytest.raises(ValidationError):
|
|
AppSettings(sync_past_days=-1)
|
|
|
|
|
|
def test_sync_future_days_negative_direct_instantiation() -> None:
|
|
"""Vérifie que ``sync_future_days`` négatif lève ``ValidationError`` à l'instanciation.
|
|
|
|
:return: None
|
|
"""
|
|
with pytest.raises(ValidationError):
|
|
AppSettings(sync_future_days=-1)
|
|
|
|
|
|
def test_sync_past_days_zero_accepted() -> None:
|
|
"""Vérifie que ``sync_past_days=0`` est accepté.
|
|
|
|
:return: None
|
|
"""
|
|
settings = AppSettings(sync_past_days=0)
|
|
assert settings.sync_past_days == 0
|
|
|
|
|
|
def test_sync_future_days_zero_accepted() -> None:
|
|
"""Vérifie que ``sync_future_days=0`` est accepté.
|
|
|
|
:return: None
|
|
"""
|
|
settings = AppSettings(sync_future_days=0)
|
|
assert settings.sync_future_days == 0
|
|
|
|
|
|
def test_sync_past_days_positive_accepted() -> None:
|
|
"""Vérifie que ``sync_past_days`` positif est accepté.
|
|
|
|
:return: None
|
|
"""
|
|
settings = AppSettings(sync_past_days=7)
|
|
assert settings.sync_past_days == 7
|
|
|
|
|
|
def test_sync_future_days_positive_accepted() -> None:
|
|
"""Vérifie que ``sync_future_days`` positif est accepté.
|
|
|
|
:return: None
|
|
"""
|
|
settings = AppSettings(sync_future_days=30)
|
|
assert settings.sync_future_days == 30
|
|
|
|
|
|
def test_sync_past_days_negative_env_loading(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que ``SYNC_PAST_DAYS=-1`` lève ``ValidationError`` via chargement env.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
monkeypatch.setenv("SYNC_PAST_DAYS", "-1")
|
|
with pytest.raises(ValidationError):
|
|
load_settings()
|
|
|
|
|
|
def test_sync_future_days_negative_env_loading(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que ``SYNC_FUTURE_DAYS=-1`` lève ``ValidationError`` via chargement env.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
monkeypatch.setenv("SYNC_FUTURE_DAYS", "-1")
|
|
with pytest.raises(ValidationError):
|
|
load_settings()
|
|
|
|
|
|
def test_sync_past_days_zero_env_loading(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que ``SYNC_PAST_DAYS=0`` est accepté via chargement env.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
monkeypatch.setenv("SYNC_PAST_DAYS", "0")
|
|
settings = load_settings()
|
|
assert settings.app.sync_past_days == 0
|
|
|
|
|
|
def test_sync_future_days_zero_env_loading(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que ``SYNC_FUTURE_DAYS=0`` est accepté via chargement env.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
monkeypatch.setenv("SYNC_FUTURE_DAYS", "0")
|
|
settings = load_settings()
|
|
assert settings.app.sync_future_days == 0
|
|
|
|
|
|
def test_sync_past_days_positive_env_loading(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que ``SYNC_PAST_DAYS`` positif est accepté via chargement env.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
monkeypatch.setenv("SYNC_PAST_DAYS", "7")
|
|
settings = load_settings()
|
|
assert settings.app.sync_past_days == 7
|
|
|
|
|
|
def test_sync_future_days_positive_env_loading(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que ``SYNC_FUTURE_DAYS`` positif est accepté via chargement env.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
monkeypatch.setenv("SYNC_FUTURE_DAYS", "30")
|
|
settings = load_settings()
|
|
assert settings.app.sync_future_days == 30
|
|
|
|
|
|
# --- Contrat des endpoints externes (matrice partagée) ---
|
|
|
|
_ConnectorSettings = CalDAVSettings | PronoteSettings | BlogSettings
|
|
_CONNECTOR_BUILDERS: dict[str, Callable[[str], _ConnectorSettings]] = {
|
|
"caldav": lambda url: CalDAVSettings(endpoint=ExternalEndpoint(url=SecretStr(url))),
|
|
"pronote_ical": lambda url: PronoteSettings(ical_endpoint=ExternalEndpoint(url=SecretStr(url))),
|
|
"blog": lambda url: BlogSettings(endpoint=ExternalEndpoint(url=SecretStr(url))),
|
|
}
|
|
|
|
|
|
def test_external_endpoint_matrix_accepts_https() -> None:
|
|
"""HTTPS est accepté par les trois connecteurs (CalDAV, Pronote iCal, Blog RSS).
|
|
|
|
:return: None
|
|
"""
|
|
url = "https://endpoint.example.test/feed"
|
|
for name, build in _CONNECTOR_BUILDERS.items():
|
|
settings = build(url)
|
|
endpoint = getattr(settings, "endpoint", None) or settings.ical_endpoint # type: ignore[union-attr]
|
|
assert endpoint is not None
|
|
assert endpoint.url.get_secret_value() == url, name
|
|
|
|
|
|
@pytest.mark.parametrize("connector", ["caldav", "pronote_ical", "blog"])
|
|
def test_external_endpoint_matrix_rejects_file_scheme(connector: str) -> None:
|
|
"""Le schéma ``file://`` est refusé par les trois connecteurs.
|
|
|
|
Contrat corrigé : les endpoints externes doivent être réseau (HTTPS) ;
|
|
aucun connecteur n'accepte un fichier local.
|
|
|
|
:param connector: Nom du connecteur testé.
|
|
:return: None
|
|
"""
|
|
with pytest.raises(ValidationError):
|
|
_CONNECTOR_BUILDERS[connector]("file:///tmp/fixture.ics")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("connector", "url"),
|
|
[
|
|
("caldav", "http://endpoint.example.test/feed"),
|
|
("pronote_ical", "http://pronote.example.test/ical.ics"),
|
|
("blog", "http://blog.example.test/feed"),
|
|
("caldav", "ftp://endpoint.example.test/feed"),
|
|
("pronote_ical", "gopher://pronote.example.test/ical.ics"),
|
|
("blog", "ftp://blog.example.test/feed"),
|
|
],
|
|
)
|
|
def test_external_endpoint_matrix_rejects_insecure_and_other_schemes(
|
|
connector: str, url: str
|
|
) -> None:
|
|
"""HTTP non-loopback et les schémas non HTTP(S) sont refusés par les trois connecteurs.
|
|
|
|
:param connector: Nom du connecteur testé.
|
|
:param url: URL à refuser.
|
|
:return: None
|
|
"""
|
|
with pytest.raises(ValidationError):
|
|
_CONNECTOR_BUILDERS[connector](url)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("connector", "url"),
|
|
[
|
|
("caldav", "http://localhost:8080/dav"),
|
|
("caldav", "http://127.0.0.1:8080/dav"),
|
|
("caldav", "http://[::1]:8080/dav"),
|
|
("pronote_ical", "http://localhost/ical.ics"),
|
|
("blog", "http://localhost/feed"),
|
|
],
|
|
)
|
|
def test_external_endpoint_matrix_rejects_loopback_http_by_default(
|
|
connector: str, url: str
|
|
) -> None:
|
|
"""HTTP loopback est refusé sans autorisation explicite du mode non sécurisé.
|
|
|
|
:param connector: Nom du connecteur testé.
|
|
:param url: URL loopback HTTP à refuser.
|
|
:return: None
|
|
"""
|
|
with pytest.raises(ValidationError):
|
|
_CONNECTOR_BUILDERS[connector](url)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
["http://localhost:8080/dav", "http://127.0.0.1:8080/dav", "http://[::1]:8080/dav"],
|
|
)
|
|
def test_caldav_endpoint_accepts_loopback_http_only_with_insecure_flag(url: str) -> None:
|
|
"""CalDAV accepte HTTP loopback uniquement avec ``allow_insecure_http=True``.
|
|
|
|
:param url: URL loopback HTTP à accepter.
|
|
:return: None
|
|
"""
|
|
settings = CalDAVSettings(
|
|
endpoint=ExternalEndpoint(url=SecretStr(url)), allow_insecure_http=True
|
|
)
|
|
assert settings.endpoint is not None
|
|
assert settings.endpoint.url.get_secret_value() == url
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("connector", "url"),
|
|
[
|
|
("caldav", "https:///missing-host"),
|
|
("pronote_ical", "https:///missing-host"),
|
|
("blog", "https:///missing-host"),
|
|
("caldav", "https://host:bad"),
|
|
("pronote_ical", "https://host:bad"),
|
|
("blog", "https://host:bad"),
|
|
],
|
|
)
|
|
def test_external_endpoint_matrix_rejects_malformed_urls(connector: str, url: str) -> None:
|
|
"""Les URL malformées (hôte manquant, port invalide) sont refusées.
|
|
|
|
:param connector: Nom du connecteur testé.
|
|
:param url: URL malformée à refuser.
|
|
:return: None
|
|
"""
|
|
with pytest.raises(ValidationError):
|
|
_CONNECTOR_BUILDERS[connector](url)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"connector",
|
|
["caldav", "pronote_ical", "blog"],
|
|
)
|
|
def test_external_endpoint_matrix_rejects_userinfo(connector: str) -> None:
|
|
"""Les credentials dans l'URL (``user:pass@host``) sont refusés.
|
|
|
|
:param connector: Nom du connecteur testé.
|
|
:return: None
|
|
"""
|
|
url = "https://user:pass@endpoint.example.test/feed" # pragma: allowlist secret
|
|
with pytest.raises(ValidationError):
|
|
_CONNECTOR_BUILDERS[connector](url)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"connector",
|
|
["caldav", "pronote_ical", "blog"],
|
|
)
|
|
def test_external_endpoint_matrix_redacts_sensitive_query_params(connector: str) -> None:
|
|
"""Les paramètres sensibles de la query sont masqués à la sérialisation.
|
|
|
|
:param connector: Nom du connecteur testé.
|
|
:return: None
|
|
"""
|
|
secret = "SECRET_QUERY_TOKEN" # pragma: allowlist secret
|
|
url = f"https://endpoint.example.test/feed?icalsecurise={secret}"
|
|
settings = _CONNECTOR_BUILDERS[connector](url)
|
|
endpoint = getattr(settings, "endpoint", None) or settings.ical_endpoint # type: ignore[union-attr]
|
|
assert endpoint is not None
|
|
dumped = endpoint.model_dump_json()
|
|
assert secret not in dumped
|
|
assert "REDACTED" in dumped
|
|
assert secret not in repr(endpoint)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("connector", "legacy_field", "canonical_url", "legacy_url"),
|
|
[
|
|
("caldav", "url", "https://canonical.example.test/dav", "https://legacy.example.test/dav"),
|
|
(
|
|
"blog",
|
|
"rss_url",
|
|
"https://canonical.example.test/feed",
|
|
"https://legacy.example.test/feed",
|
|
),
|
|
(
|
|
"pronote",
|
|
"url",
|
|
"https://canonical.example.test/parent.html",
|
|
"https://legacy.example.test/parent.html",
|
|
),
|
|
(
|
|
"pronote",
|
|
"ical_url",
|
|
"https://canonical.example.test/ical.ics",
|
|
"https://legacy.example.test/ical.ics",
|
|
),
|
|
],
|
|
)
|
|
def test_deprecated_aliases_warn_and_canonical_wins(
|
|
connector: str, legacy_field: str, canonical_url: str, legacy_url: str
|
|
) -> None:
|
|
"""Les alias obsolètes émettent un DeprecationWarning et l'URL canonique gagne.
|
|
|
|
:param connector: Connecteur testé (``caldav``, ``blog`` ou ``pronote``).
|
|
:param legacy_field: Nom du champ obsolète.
|
|
:param canonical_url: URL canonique (doit gagner).
|
|
:param legacy_url: URL fournie via l'alias obsolète.
|
|
:return: None
|
|
"""
|
|
if connector == "caldav":
|
|
caldav_kwargs: dict[str, object] = {
|
|
legacy_field: SecretStr(legacy_url),
|
|
"endpoint": {"url": canonical_url},
|
|
}
|
|
with pytest.warns(DeprecationWarning, match="CALDAV_URL"):
|
|
caldav_settings = CalDAVSettings(**caldav_kwargs) # type: ignore[arg-type]
|
|
assert caldav_settings.endpoint is not None
|
|
assert caldav_settings.endpoint.url.get_secret_value() == canonical_url
|
|
elif connector == "blog":
|
|
blog_kwargs: dict[str, object] = {
|
|
legacy_field: legacy_url,
|
|
"endpoint": {"url": canonical_url},
|
|
}
|
|
with pytest.warns(DeprecationWarning, match="BLOG_RSS_URL"):
|
|
blog_settings = BlogSettings(**blog_kwargs) # type: ignore[arg-type]
|
|
assert blog_settings.endpoint.url.get_secret_value() == canonical_url
|
|
else:
|
|
canonical_field = "endpoint" if legacy_field == "url" else "ical_endpoint"
|
|
legacy_value: object = SecretStr(legacy_url) if legacy_field == "ical_url" else legacy_url
|
|
pronote_kwargs: dict[str, object] = {
|
|
legacy_field: legacy_value,
|
|
canonical_field: {"url": canonical_url},
|
|
}
|
|
with pytest.warns(DeprecationWarning, match="PRONOTE_"):
|
|
pronote_settings = PronoteSettings(**pronote_kwargs) # type: ignore[arg-type]
|
|
endpoint = getattr(pronote_settings, canonical_field)
|
|
assert endpoint is not None
|
|
assert endpoint.url.get_secret_value() == canonical_url
|