fix(config): refuser file:// et userinfo dans le contrat d'endpoint
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
This commit is contained in:
+240
-7
@@ -7,6 +7,7 @@ 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
|
||||
@@ -143,8 +144,7 @@ def test_url_from_pronote_url_env_var(monkeypatch: MonkeyPatch) -> None:
|
||||
"url",
|
||||
[
|
||||
"https://endpoint.example.test/api",
|
||||
"http://localhost:8080/test",
|
||||
"file:///tmp/fixture.ics",
|
||||
"https://endpoint.example.test:8443/ical?icalsecurise=TOKEN", # pragma: allowlist secret
|
||||
],
|
||||
)
|
||||
def test_external_endpoint_accepts_supported_schemes(url: str) -> None:
|
||||
@@ -177,11 +177,16 @@ def test_external_endpoint_is_immutable_and_redacted() -> None:
|
||||
|
||||
:return: None
|
||||
"""
|
||||
endpoint = ExternalEndpoint(url=SecretStr("https://user:secret@example.test/calendar"))
|
||||
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 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:
|
||||
@@ -214,7 +219,7 @@ def test_pronote_endpoints_load_from_nested_environment(monkeypatch: MonkeyPatch
|
||||
:return: None
|
||||
"""
|
||||
monkeypatch.setenv("PRONOTE_ENDPOINT__URL", "https://pronote.example.test/parent.html")
|
||||
monkeypatch.setenv("PRONOTE_ICAL_ENDPOINT__URL", "file:///tmp/pronote.ics")
|
||||
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
|
||||
@@ -222,14 +227,17 @@ def test_pronote_endpoints_load_from_nested_environment(monkeypatch: MonkeyPatch
|
||||
settings.pronote.endpoint.url.get_secret_value()
|
||||
== "https://pronote.example.test/parent.html"
|
||||
)
|
||||
assert settings.pronote.ical_endpoint.url.get_secret_value() == "file:///tmp/pronote.ics"
|
||||
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", "HTTPS ou file 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:
|
||||
@@ -492,3 +500,228 @@ def test_sync_future_days_positive_env_loading(monkeypatch: MonkeyPatch) -> 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
|
||||
|
||||
Reference in New Issue
Block a user