fix(M2): configuration, secrets et rédaction — correction des écarts FIXME_M2
Sécurité : - ical_url : str → SecretStr | None (masquage dans str/repr/model_dump_json) - En-têtes Authorization/Proxy-Authorization : masquage complet de la valeur - _URL_PATTERN : insensible à la casse (HTTPS:// reconnu) - redact_url() : masquage du nom d'utilisateur (userinfo complet) Configuration : - Sous-configs : Field(default_factory=...) pour rechargement à chaque appel - Suppression du singleton settings (injection de dépendances) - .env.example : ajout AI_PROVIDER et CALDAV_CALENDAR_PATH - Guide : BLOG_RSS_ENABLED → BLOG_ENABLED, AI_MODEL=None par défaut - .secrets.baseline : ligne décalée 5112 → 5117 (faux positif audité) - pre-commit : ajout de pytest aux additional_dependencies du hook mypy Tests : 15 tests (config + redaction) couvrant rechargement, masquage SecretStr, en-têtes Authorization, URL auth intégrée et casse variable. Co-authored-by: opencode/coder <coder@agents.invalid> Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
This commit is contained in:
@@ -13,6 +13,7 @@ PRONOTE_MESSAGES_SOURCE=pronotepy
|
|||||||
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
|
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
|
||||||
CALDAV_USERNAME=user@example.com
|
CALDAV_USERNAME=user@example.com
|
||||||
CALDAV_PASSWORD=your_caldav_password
|
CALDAV_PASSWORD=your_caldav_password
|
||||||
|
CALDAV_CALENDAR_PATH=/pronote-sync/
|
||||||
|
|
||||||
# Fenêtre de synchronisation (jours)
|
# Fenêtre de synchronisation (jours)
|
||||||
SYNC_PAST_DAYS=7
|
SYNC_PAST_DAYS=7
|
||||||
@@ -34,9 +35,10 @@ XMPP_TIMEOUT=30
|
|||||||
|
|
||||||
# --- IA (optionnelle) ---
|
# --- IA (optionnelle) ---
|
||||||
AI_ENABLED=true
|
AI_ENABLED=true
|
||||||
|
AI_PROVIDER=openai
|
||||||
AI_BASE_URL=https://api.openai.com/v1
|
AI_BASE_URL=https://api.openai.com/v1
|
||||||
AI_API_KEY=your_ai_api_key
|
AI_API_KEY=your_ai_api_key
|
||||||
AI_MODEL=gpt-4o-mini
|
# AI_MODEL=gpt-4o-mini # exemple recommandé, non activé par défaut
|
||||||
|
|
||||||
# --- Blog ---
|
# --- Blog ---
|
||||||
BLOG_ENABLED=false
|
BLOG_ENABLED=false
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ repos:
|
|||||||
name: mypy
|
name: mypy
|
||||||
entry: mypy
|
entry: mypy
|
||||||
language: python
|
language: python
|
||||||
additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0"]
|
additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0"]
|
||||||
types: [python]
|
types: [python]
|
||||||
pass_filenames: true
|
pass_filenames: true
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,7 @@
|
|||||||
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
||||||
"is_secret": false,
|
"is_secret": false,
|
||||||
"is_verified": true,
|
"is_verified": true,
|
||||||
"line_number": 5112
|
"line_number": 5117
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -267,6 +267,8 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
|
|||||||
| `CALDAV_URL` | URL du serveur CalDAV. | `https://caldav.example.com/calendars/...` | `str` |
|
| `CALDAV_URL` | URL du serveur CalDAV. | `https://caldav.example.com/calendars/...` | `str` |
|
||||||
| `CALDAV_USERNAME` | Identifiant CalDAV. | `user@example.com` | `str` |
|
| `CALDAV_USERNAME` | Identifiant CalDAV. | `user@example.com` | `str` |
|
||||||
| `CALDAV_PASSWORD` | Mot de passe CalDAV. | `SecretStr` (masqué) | `SecretStr` |
|
| `CALDAV_PASSWORD` | Mot de passe CalDAV. | `SecretStr` (masqué) | `SecretStr` |
|
||||||
|
| `CALDAV_CALENDAR_PATH` | Chemin du calendrier CalDAV de destination. | `/pronote-sync/` | `str` |
|
||||||
|
| `CALDAV_CALENDAR_PATH` | Chemin du calendrier CalDAV de destination. | `/pronote-sync/` | `str` |
|
||||||
| `XMPP_JID` | Identifiant XMPP (ex: `user@example.com`). | `user@example.com` | `str` |
|
| `XMPP_JID` | Identifiant XMPP (ex: `user@example.com`). | `user@example.com` | `str` |
|
||||||
| `XMPP_PASSWORD` | Mot de passe XMPP. | `SecretStr` (masqué) | `SecretStr` |
|
| `XMPP_PASSWORD` | Mot de passe XMPP. | `SecretStr` (masqué) | `SecretStr` |
|
||||||
| `XMPP_RECIPIENT` | Destinataire XMPP (ex: `parent@example.com`). | `parent@example.com` | `str` |
|
| `XMPP_RECIPIENT` | Destinataire XMPP (ex: `parent@example.com`). | `parent@example.com` | `str` |
|
||||||
@@ -291,9 +293,10 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
|
|||||||
> Leur placement dans `AppSettings` (sans préfixe) garantit un mappage correct avec `SYNC_PAST_DAYS` / `SYNC_FUTURE_DAYS`.
|
> Leur placement dans `AppSettings` (sans préfixe) garantit un mappage correct avec `SYNC_PAST_DAYS` / `SYNC_FUTURE_DAYS`.
|
||||||
| `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier iCal/CSV de l'agenda théorique. | `None` | `str \| None`|
|
| `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier iCal/CSV de l'agenda théorique. | `None` | `str \| None`|
|
||||||
| `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` |
|
| `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` |
|
||||||
|
| `AI_PROVIDER` | Fournisseur IA (`openai` ou `litellm`). | `openai` | `str` |
|
||||||
| `AI_BASE_URL` | URL de base pour l'API IA (ex: OpenAI compatible). | `None` | `str \| None`|
|
| `AI_BASE_URL` | URL de base pour l'API IA (ex: OpenAI compatible). | `None` | `str \| None`|
|
||||||
| `AI_API_KEY` | Clé API pour l'API IA. | `None` | `SecretStr` |
|
| `AI_API_KEY` | Clé API pour l'API IA. | `None` | `SecretStr` |
|
||||||
| `AI_MODEL` | Modèle IA à utiliser. | `gpt-4o-mini` | `str` |
|
| `AI_MODEL` | Modèle IA à utiliser (exemple recommandé : `gpt-4o-mini`). | `None` | `str \| None`|
|
||||||
| `DRY_RUN` | Mode dry-run (pas de modifications CalDAV/XMPP). | `False` | `bool` |
|
| `DRY_RUN` | Mode dry-run (pas de modifications CalDAV/XMPP). | `False` | `bool` |
|
||||||
| `LOG_LEVEL` | Niveau de log (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | `INFO` | `str` |
|
| `LOG_LEVEL` | Niveau de log (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | `INFO` | `str` |
|
||||||
|
|
||||||
@@ -316,6 +319,7 @@ PRONOTE_MESSAGES_SOURCE=pronotepy
|
|||||||
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
|
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
|
||||||
CALDAV_USERNAME=user@example.com
|
CALDAV_USERNAME=user@example.com
|
||||||
CALDAV_PASSWORD=your_caldav_password
|
CALDAV_PASSWORD=your_caldav_password
|
||||||
|
CALDAV_CALENDAR_PATH=/pronote-sync/
|
||||||
|
|
||||||
# Fenêtre de synchronisation (jours)
|
# Fenêtre de synchronisation (jours)
|
||||||
SYNC_PAST_DAYS=7
|
SYNC_PAST_DAYS=7
|
||||||
@@ -331,9 +335,10 @@ XMPP_RECIPIENT=parent@example.com
|
|||||||
|
|
||||||
# --- IA (optionnelle) ---
|
# --- IA (optionnelle) ---
|
||||||
AI_ENABLED=true
|
AI_ENABLED=true
|
||||||
|
AI_PROVIDER=openai
|
||||||
AI_BASE_URL=https://api.openai.com/v1
|
AI_BASE_URL=https://api.openai.com/v1
|
||||||
AI_API_KEY=your_ai_api_key
|
AI_API_KEY=your_ai_api_key
|
||||||
AI_MODEL=gpt-4o-mini
|
# AI_MODEL=gpt-4o-mini # exemple recommandé, non activé par défaut
|
||||||
|
|
||||||
# --- Divers ---
|
# --- Divers ---
|
||||||
DRY_RUN=false
|
DRY_RUN=false
|
||||||
@@ -1142,7 +1147,7 @@ Ajouter les variables suivantes dans la configuration :
|
|||||||
|
|
||||||
| **Variable** | **Description** | **Valeur par défaut** | **Type** |
|
| **Variable** | **Description** | **Valeur par défaut** | **Type** |
|
||||||
|----------------------------|-------------------------------------------------------------------------------|-----------------------|-------------------|
|
|----------------------------|-------------------------------------------------------------------------------|-----------------------|-------------------|
|
||||||
| `BLOG_RSS_ENABLED` | Activer la récupération du blog. | `False` | `bool` |
|
| `BLOG_ENABLED` | Activer la récupération du blog. | `False` | `bool` |
|
||||||
| `BLOG_RSS_URL` | URL du flux RSS du blog. | `https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2` | `str` |
|
| `BLOG_RSS_URL` | URL du flux RSS du blog. | `https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2` | `str` |
|
||||||
|
|
||||||
#### 5 bis.9.2 Modèle Pydantic pour la configuration du blog
|
#### 5 bis.9.2 Modèle Pydantic pour la configuration du blog
|
||||||
@@ -1177,7 +1182,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
```ini
|
```ini
|
||||||
# --- Blog du collège ---
|
# --- Blog du collège ---
|
||||||
BLOG_RSS_ENABLED=true
|
BLOG_ENABLED=true
|
||||||
BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2
|
BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import SecretStr
|
from pydantic import Field, SecretStr, field_serializer
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ class PronoteSettings(BaseSettings):
|
|||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="PRONOTE_")
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="PRONOTE_")
|
||||||
|
|
||||||
ical_url: str | None = None
|
ical_url: SecretStr | None = None
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
password: SecretStr | None = None
|
password: SecretStr | None = None
|
||||||
ent: str | None = None
|
ent: str | None = None
|
||||||
@@ -31,6 +31,18 @@ class PronoteSettings(BaseSettings):
|
|||||||
homework_source: Literal["auto", "ical", "pronotepy"] = "auto"
|
homework_source: Literal["auto", "ical", "pronotepy"] = "auto"
|
||||||
messages_source: Literal["pronotepy"] = "pronotepy"
|
messages_source: Literal["pronotepy"] = "pronotepy"
|
||||||
|
|
||||||
|
@field_serializer("ical_url")
|
||||||
|
def _serialize_ical_url(self, value: SecretStr | None) -> str | None:
|
||||||
|
"""Masque l'URL iCal lors de la sérialisation (repr, str, JSON).
|
||||||
|
|
||||||
|
:param value: Valeur du champ ``ical_url``.
|
||||||
|
:return: ``"**********"`` si la valeur est définie, ``None`` sinon.
|
||||||
|
:rtype: str | None
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return "**********"
|
||||||
|
|
||||||
|
|
||||||
class CalDAVSettings(BaseSettings):
|
class CalDAVSettings(BaseSettings):
|
||||||
"""Paramètres d'accès au serveur CalDAV de destination.
|
"""Paramètres d'accès au serveur CalDAV de destination.
|
||||||
@@ -121,12 +133,9 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
pronote: PronoteSettings = PronoteSettings()
|
pronote: PronoteSettings = Field(default_factory=PronoteSettings)
|
||||||
caldav: CalDAVSettings = CalDAVSettings()
|
caldav: CalDAVSettings = Field(default_factory=CalDAVSettings)
|
||||||
xmpp: XmppSettings = XmppSettings()
|
xmpp: XmppSettings = Field(default_factory=XmppSettings)
|
||||||
ai: AISettings = AISettings()
|
ai: AISettings = Field(default_factory=AISettings)
|
||||||
blog: BlogSettings = BlogSettings()
|
blog: BlogSettings = Field(default_factory=BlogSettings)
|
||||||
app: AppSettings = AppSettings()
|
app: AppSettings = Field(default_factory=AppSettings)
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ _SENSITIVE_QUERY_KEYS = frozenset(
|
|||||||
"authorization",
|
"authorization",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_URL_PATTERN = re.compile(r"https?://[^\s]+")
|
_URL_PATTERN = re.compile(r"https?://[^\s]+", re.IGNORECASE)
|
||||||
|
_AUTH_HEADER_PATTERN = re.compile(
|
||||||
|
r"((?:Proxy-)?Authorization)\s*[:=]\s*\S[^\r\n]*",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
_ISOLATED_SECRET_PATTERN = re.compile(
|
_ISOLATED_SECRET_PATTERN = re.compile(
|
||||||
r"\b(icalsecurise|access_token|api_key|apikey|authorization|token|password|secret|key|auth)"
|
r"\b(icalsecurise|access_token|api_key|apikey|authorization|token|password|secret|key|auth)"
|
||||||
r"(\s*['\"]?\s*[:=]\s*)"
|
r"(\s*['\"]?\s*[:=]\s*)"
|
||||||
@@ -40,8 +44,8 @@ def redact_url(url: str) -> str:
|
|||||||
"""Masque les identifiants et les paramètres sensibles d'une URL.
|
"""Masque les identifiants et les paramètres sensibles d'une URL.
|
||||||
|
|
||||||
Les informations d'authentification du netloc (``utilisateur:motdepasse@hôte``)
|
Les informations d'authentification du netloc (``utilisateur:motdepasse@hôte``)
|
||||||
sont masquées, ainsi que les paramètres sensibles de la requête
|
sont entièrement masquées (utilisateur et mot de passe), ainsi que les
|
||||||
(ex: ``icalsecurise``).
|
paramètres sensibles de la requête (ex: ``icalsecurise``).
|
||||||
|
|
||||||
:param url: URL pouvant contenir des informations sensibles (ex: ``icalsecurise``).
|
:param url: URL pouvant contenir des informations sensibles (ex: ``icalsecurise``).
|
||||||
:return: URL avec les éléments sensibles remplacés par ``REDACTED``,
|
:return: URL avec les éléments sensibles remplacés par ``REDACTED``,
|
||||||
@@ -51,15 +55,13 @@ def redact_url(url: str) -> str:
|
|||||||
try:
|
try:
|
||||||
parts = urlsplit(url)
|
parts = urlsplit(url)
|
||||||
if parts.username is not None or parts.password is not None:
|
if parts.username is not None or parts.password is not None:
|
||||||
# Netloc sûr : utilisateur:REDACTED@hôte:port. Le deux-point est
|
# Netloc sûr : REDACTED@hôte:port. L'utilisateur et le mot de
|
||||||
# conservé même en l'absence de mot de passe explicite.
|
# passe sont entièrement masqués.
|
||||||
userinfo = parts.username or ""
|
|
||||||
userinfo += ":REDACTED"
|
|
||||||
host = parts.hostname or ""
|
host = parts.hostname or ""
|
||||||
if parts.port is not None:
|
if parts.port is not None:
|
||||||
netloc = f"{userinfo}@{host}:{parts.port}"
|
netloc = f"{_REDACTED}@{host}:{parts.port}"
|
||||||
else:
|
else:
|
||||||
netloc = f"{userinfo}@{host}"
|
netloc = f"{_REDACTED}@{host}"
|
||||||
parts = parts._replace(netloc=netloc)
|
parts = parts._replace(netloc=netloc)
|
||||||
query: list[tuple[str, str]] = parse_qsl(parts.query, keep_blank_values=True)
|
query: list[tuple[str, str]] = parse_qsl(parts.query, keep_blank_values=True)
|
||||||
redacted_query = [
|
redacted_query = [
|
||||||
@@ -74,15 +76,18 @@ def redact_url(url: str) -> str:
|
|||||||
def redact_secrets(text: str) -> str:
|
def redact_secrets(text: str) -> str:
|
||||||
"""Masque les secrets présents dans un texte arbitraire.
|
"""Masque les secrets présents dans un texte arbitraire.
|
||||||
|
|
||||||
Les URLs sont d'abord traitées par :func:`redact_url`, puis les affectations
|
Les URLs sont d'abord traitées par :func:`redact_url`, puis les en-têtes
|
||||||
isolées de type ``cle=valeur`` ou ``cle:valeur`` (ex: ``icalsecurise=XXX``,
|
d'authentification (``Authorization``, ``Proxy-Authorization``) et les
|
||||||
``"token": "XXX"``) sont masquées, sans distinction de casse.
|
affectations isolées de type ``cle=valeur`` ou ``cle:valeur``
|
||||||
|
(ex: ``icalsecurise=XXX``, ``"token": "XXX"``) sont masquées, sans
|
||||||
|
distinction de casse.
|
||||||
|
|
||||||
:param text: Texte pouvant contenir des URLs ou des secrets en clair.
|
:param text: Texte pouvant contenir des URLs ou des secrets en clair.
|
||||||
:return: Texte avec les secrets remplacés par ``REDACTED``.
|
:return: Texte avec les secrets remplacés par ``REDACTED``.
|
||||||
:rtype: str
|
:rtype: str
|
||||||
"""
|
"""
|
||||||
redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text)
|
redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text)
|
||||||
|
redacted = _AUTH_HEADER_PATTERN.sub(r"\1: REDACTED", redacted)
|
||||||
return _ISOLATED_SECRET_PATTERN.sub(r"\1\2\3REDACTED", redacted)
|
return _ISOLATED_SECRET_PATTERN.sub(r"\1\2\3REDACTED", redacted)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
119
tests/unit/test_config.py
Normal file
119
tests/unit/test_config.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""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 typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import SecretStr
|
||||||
|
|
||||||
|
from pronote_sync.config.env import load_settings
|
||||||
|
from pronote_sync.config.settings import 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 "**********" 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Ensure trailing newline
|
||||||
128
tests/unit/test_redaction.py
Normal file
128
tests/unit/test_redaction.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""Tests unitaires pour les utilitaires de masquage des secrets.
|
||||||
|
|
||||||
|
Ce module vérifie que les fonctions de masquage des URLs, des en-têtes
|
||||||
|
et des secrets dans les textes fonctionnent correctement, sans fuite
|
||||||
|
d'informations sensibles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pronote_sync.utils.redaction import redact_exception, redact_secrets, redact_url
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_url_with_sensitive_query_param() -> None:
|
||||||
|
"""Vérifie que les paramètres sensibles dans une URL sont masqués.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
url = "https://example.fr/ical?icalsecurise=TOKEN123" # pragma: allowlist secret
|
||||||
|
redacted = redact_url(url)
|
||||||
|
assert "TOKEN123" not in redacted
|
||||||
|
assert "REDACTED" in redacted
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_url_with_embedded_auth() -> None:
|
||||||
|
"""Vérifie que les informations d'authentification intégrées dans une URL sont masquées.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
url = "https://user:pass@host/path" # pragma: allowlist secret
|
||||||
|
redacted = redact_url(url)
|
||||||
|
assert "user" not in redacted
|
||||||
|
assert "pass" not in redacted
|
||||||
|
assert "REDACTED" in redacted
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_url_with_uppercase_scheme() -> None:
|
||||||
|
"""Vérifie que les URLs avec un schéma en majuscules sont traitées correctement.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
url = "HTTPS://user:pass@host/path" # pragma: allowlist secret
|
||||||
|
redacted = redact_url(url)
|
||||||
|
assert "user" not in redacted
|
||||||
|
assert "pass" not in redacted
|
||||||
|
assert "REDACTED" in redacted
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_secrets_authorization_bearer() -> None:
|
||||||
|
"""Vérifie que les en-têtes Authorization: Bearer sont masqués.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
text = "Authorization: Bearer mytoken123" # pragma: allowlist secret
|
||||||
|
redacted = redact_secrets(text)
|
||||||
|
assert "mytoken123" not in redacted
|
||||||
|
assert "REDACTED" in redacted
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_secrets_proxy_authorization_basic() -> None:
|
||||||
|
"""Vérifie que les en-têtes Proxy-Authorization: Basic sont masqués.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
text = "Proxy-Authorization: Basic dXNlcjpwYXNz" # pragma: allowlist secret
|
||||||
|
redacted = redact_secrets(text)
|
||||||
|
assert "dXNlcjpwYXNz" not in redacted
|
||||||
|
assert "REDACTED" in redacted
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_secrets_lowercase_authorization() -> None:
|
||||||
|
"""Vérifie que les en-têtes authorization en minuscules sont masqués.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
text = "authorization: Bearer tok" # pragma: allowlist secret
|
||||||
|
redacted = redact_secrets(text)
|
||||||
|
assert "tok" not in redacted
|
||||||
|
assert "REDACTED" in redacted
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_secrets_text_key_value() -> None:
|
||||||
|
"""Vérifie que les affectations clé=valeur dans du texte sont masquées.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
text = "password=mysecret" # pragma: allowlist secret
|
||||||
|
redacted = redact_secrets(text)
|
||||||
|
assert "mysecret" not in redacted
|
||||||
|
assert "REDACTED" in redacted
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_secrets_json_key_value() -> None:
|
||||||
|
"""Vérifie que les secrets dans du JSON sont masqués.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
text = '{"api_key": "myapikey"}' # pragma: allowlist secret
|
||||||
|
redacted = redact_secrets(text)
|
||||||
|
assert "myapikey" not in redacted
|
||||||
|
assert "REDACTED" in redacted
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_exception() -> None:
|
||||||
|
"""Vérifie que les exceptions contenant des secrets sont masquées.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
exc = Exception("Authorization: Bearer leaked_token") # pragma: allowlist secret
|
||||||
|
redacted_msg = redact_exception(exc)
|
||||||
|
assert "leaked_token" not in redacted_msg
|
||||||
|
assert "REDACTED" in redacted_msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_url_preserves_host_and_path() -> None:
|
||||||
|
"""Vérifie que l'hôte et le chemin d'une URL sont préservés après masquage.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
url = "https://user:pass@example.fr/path?icalsecurise=tok" # pragma: allowlist secret
|
||||||
|
redacted = redact_url(url)
|
||||||
|
assert "example.fr" in redacted
|
||||||
|
assert "/path" in redacted
|
||||||
|
assert "user" not in redacted
|
||||||
|
assert "pass" not in redacted
|
||||||
|
assert "tok" not in redacted
|
||||||
|
|
||||||
|
|
||||||
|
# Ensure trailing newline
|
||||||
Reference in New Issue
Block a user