Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75dd78ee1e |
+1
-8
@@ -32,7 +32,7 @@ CALDAV_CALENDAR_PATH=/pronote-sync/
|
|||||||
# Autoriser HTTP (non-HTTPS) pour un serveur CalDAV local (localhost uniquement)
|
# Autoriser HTTP (non-HTTPS) pour un serveur CalDAV local (localhost uniquement)
|
||||||
CALDAV_ALLOW_INSECURE_HTTP=false
|
CALDAV_ALLOW_INSECURE_HTTP=false
|
||||||
|
|
||||||
# Fenêtre de synchronisation (jours) — entier >= 0 ; 0 = aucune journée supplémentaire de ce côté (le jour courant reste inclus). Valeurs négatives refusées au chargement (ValidationError).
|
# Fenêtre de synchronisation (jours)
|
||||||
SYNC_PAST_DAYS=7
|
SYNC_PAST_DAYS=7
|
||||||
SYNC_FUTURE_DAYS=30
|
SYNC_FUTURE_DAYS=30
|
||||||
|
|
||||||
@@ -66,13 +66,6 @@ AI_BASE_URL=https://api.openai.com/v1
|
|||||||
# AI_API_KEY=
|
# AI_API_KEY=
|
||||||
# AI_MODEL=gpt-4o-mini # exemple recommandé, non activé par défaut
|
# AI_MODEL=gpt-4o-mini # exemple recommandé, non activé par défaut
|
||||||
|
|
||||||
# NOTE : la validation structurelle de AI_BASE_URL s'applique à TOUS les
|
|
||||||
# providers (openai, litellm, openai-compatible) : HTTPS obligatoire sauf si
|
|
||||||
# AI_ALLOW_INSECURE_HTTP=true, aucun credential embarqué (user:pass@hôte),
|
|
||||||
# aucun paramètre sensible dans la query string (token, key, api_key,
|
|
||||||
# secret, password, auth), et aucune manipulation automatique de /v1.
|
|
||||||
# Seul le provider openai-compatible exige AI_BASE_URL et AI_MODEL.
|
|
||||||
|
|
||||||
# Exemple : OpenRouter (HTTPS)
|
# Exemple : OpenRouter (HTTPS)
|
||||||
# AI_PROVIDER=openai-compatible
|
# AI_PROVIDER=openai-compatible
|
||||||
# AI_BASE_URL=https://openrouter.ai/api/v1
|
# AI_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
|||||||
+2
-2
@@ -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": 5186
|
"line_number": 5125
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tests/unit/test_caldav_gateway.py": [
|
"tests/unit/test_caldav_gateway.py": [
|
||||||
@@ -185,5 +185,5 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"generated_at": "2026-09-12T17:57:39Z"
|
"generated_at": "2026-09-12T12:04:07Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,12 +159,9 @@ pronote-sync --dry-run
|
|||||||
- Après chaque login réussi, les credentials exportées par `pronotepy.export_credentials()` sont
|
- Après chaque login réussi, les credentials exportées par `pronotepy.export_credentials()` sont
|
||||||
persistées dans `.pronote_auth_state.json` (permissions `0600`, format JSON versionné, écriture
|
persistées dans `.pronote_auth_state.json` (permissions `0600`, format JSON versionné, écriture
|
||||||
atomique). Le token rotate à chaque session et peut également être rafraîchi pendant l'exécution
|
atomique). Le token rotate à chaque session et peut également être rafraîchi pendant l'exécution
|
||||||
(refresh automatique pronotepy après une `PronoteAPIError`). La persistance s'applique après
|
(refresh automatique pronotepy après une `PronoteAPIError`). Les credentials sont persistées après
|
||||||
chaque login réussi **et après chaque opération de données réussie** (agenda, devoirs, messages)
|
chaque login réussi **et après chaque opération de données réussie** (agenda, devoirs, messages,
|
||||||
pour garantir la persistance du token valide ; seules les opérations qui se connectent réellement
|
informations) pour garantir la persistance du token valide.
|
||||||
et récupèrent des données déclenchent la persistance. En mode `qr_token`, `get_informations()`
|
|
||||||
est ignorée (retour immédiat `[]` sans connexion ni verrou) et ne déclenche donc aucune
|
|
||||||
persistance.
|
|
||||||
- Les logins suivants utilisent `pronotepy.token_login(**credentials)` avec le token persisté.
|
- Les logins suivants utilisent `pronotepy.token_login(**credentials)` avec le token persisté.
|
||||||
- En cas d'échec de `token_login` (token expiré/invalide), une `PronoteAuthRotationError` est levée.
|
- En cas d'échec de `token_login` (token expiré/invalide), une `PronoteAuthRotationError` est levée.
|
||||||
Cette erreur se propage sans wrapping à travers `PronoteFetcher` et `fetch_step` jusqu'à
|
Cette erreur se propage sans wrapping à travers `PronoteFetcher` et `fetch_step` jusqu'à
|
||||||
@@ -175,11 +172,6 @@ pronote-sync --dry-run
|
|||||||
- `PronoteAuthRotationError` est re-levée telle quelle (`except PronoteAuthRotationError: raise`)
|
- `PronoteAuthRotationError` est re-levée telle quelle (`except PronoteAuthRotationError: raise`)
|
||||||
dans toutes les couches d'enveloppement du chemin critique (fetch_agenda, fetch_homework,
|
dans toutes les couches d'enveloppement du chemin critique (fetch_agenda, fetch_homework,
|
||||||
fetch_step). Ne pas l'attraper avec `except Exception` sans la re-léver d'abord.
|
fetch_step). Ne pas l'attraper avec `except Exception` sans la re-léver d'abord.
|
||||||
- En mode `qr_token`, `get_informations()` retourne **inconditionnellement** une liste vide
|
|
||||||
(`[]`) sans connexion, verrou, chargement d'état ni appel réseau, et journalise un message
|
|
||||||
INFO unique : l'endpoint `PageActualites` renvoie une erreur pronotepy 20 sur les instances
|
|
||||||
HubEduConnect testées, provoquant un refresh redondant du token. Ce contournement n'est pas
|
|
||||||
configurable ; aucun état anti-répétition n'est conservé.
|
|
||||||
- Le fichier `.pronote_auth_state.json` ne doit jamais être committé (couvert par `.gitignore`).
|
- Le fichier `.pronote_auth_state.json` ne doit jamais être committé (couvert par `.gitignore`).
|
||||||
Son contenu (token vivant) ne doit jamais apparaître dans les logs, les messages d'erreur ou
|
Son contenu (token vivant) ne doit jamais apparaître dans les logs, les messages d'erreur ou
|
||||||
les notifications XMPP.
|
les notifications XMPP.
|
||||||
|
|||||||
+5
-1
@@ -25,13 +25,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- `PRONOTE_URL` ignoré à cause du double préfixe `env_prefix` (renommage `pronote_url` → `url` dans `PronoteSettings`)
|
- `PRONOTE_URL` ignoré à cause du double préfixe `env_prefix` (renommage `pronote_url` → `url` dans `PronoteSettings`)
|
||||||
- `PRONOTE_ENT` rendu optionnel pour les connexions pronotepy directes
|
- `PRONOTE_ENT` rendu optionnel pour les connexions pronotepy directes
|
||||||
- `.env.example` corrigé (`eleve.html` → `parent.html`)
|
- `.env.example` corrigé (`eleve.html` → `parent.html`)
|
||||||
- #20/#21 — `get_informations()` ignorée en mode `qr_token` : retourne `[]` immédiatement, sans connexion, verrou ni appel réseau, évitant l'échec systématique de l'endpoint `PageActualites` (erreur pronotepy 20 sur les instances HubEduConnect testées) et le refresh redondant du token associé.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Wiki `GuidePronote` enrichi : section "Quand l'ENT est obligatoire" (EduConnect/HubEduConnect), exemple Bordeaux
|
- Wiki `GuidePronote` enrichi : section "Quand l'ENT est obligatoire" (EduConnect/HubEduConnect), exemple Bordeaux
|
||||||
- `AGENTS.md` : ajout de la section §13 "Versionnage et releases"
|
- `AGENTS.md` : ajout de la section §13 "Versionnage et releases"
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
|
||||||
|
- #20 — Triple authentification pronotepy (double INIT + refresh) lors d'un run
|
||||||
|
- #21 — Erreur pronotepy 20 « La page a expiré ! (11) » sur `get_informations`
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
- 694 tests passés, couverture 94.93%
|
- 694 tests passés, couverture 94.93%
|
||||||
|
|||||||
+46
-107
@@ -299,18 +299,18 @@ d'un besoin réel et testé.
|
|||||||
| `PRONOTE_AGENDA_SOURCE` | Source pour l'agenda (`auto`, `ical`, `pronotepy`). | `auto` | `Literal` |
|
| `PRONOTE_AGENDA_SOURCE` | Source pour l'agenda (`auto`, `ical`, `pronotepy`). | `auto` | `Literal` |
|
||||||
| `PRONOTE_HOMEWORK_SOURCE` | Source pour les devoirs (`auto`, `ical`, `pronotepy`). | `auto` | `Literal` |
|
| `PRONOTE_HOMEWORK_SOURCE` | Source pour les devoirs (`auto`, `ical`, `pronotepy`). | `auto` | `Literal` |
|
||||||
| `PRONOTE_MESSAGES_SOURCE` | Source pour les messages (`pronotepy` uniquement). | `pronotepy` | `Literal` |
|
| `PRONOTE_MESSAGES_SOURCE` | Source pour les messages (`pronotepy` uniquement). | `pronotepy` | `Literal` |
|
||||||
| `SYNC_PAST_DAYS` | Nombre de jours dans le passé pour la sync CalDAV, entier `>= 0` (`0` = aucune journée supplémentaire de ce côté ; le jour courant reste inclus). | `7` | `int` |
|
| `SYNC_PAST_DAYS` | Nombre de jours dans le passé pour la sync CalDAV. | `7` | `int` |
|
||||||
| `SYNC_FUTURE_DAYS` | Nombre de jours dans le futur pour la sync CalDAV, entier `>= 0` (`0` = aucune journée supplémentaire de ce côté ; le jour courant reste inclus). | `30` | `int` |
|
| `SYNC_FUTURE_DAYS` | Nombre de jours dans le futur pour la sync CalDAV. | `30` | `int` |
|
||||||
| `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier JSON de l'agenda théorique. | `None` | `str \| None`|
|
| `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier JSON de l'agenda théorique. | `None` | `str \| None`|
|
||||||
| `SCHOOL_HOLIDAYS_PATH` | Chemin vers le fichier JSON des vacances scolaires. | `None` | `str \| None`|
|
| `SCHOOL_HOLIDAYS_PATH` | Chemin vers le fichier JSON des vacances scolaires. | `None` | `str \| None`|
|
||||||
| `THEORETICAL_WEEK_ANCHOR_DATE` | Date de référence pour la parité des semaines (paire/impaire). | `None` | `date \| None`|
|
| `THEORETICAL_WEEK_ANCHOR_DATE` | Date de référence pour la parité des semaines (paire/impaire). | `None` | `date \| None`|
|
||||||
| `THEORETICAL_WEEK_ANCHOR_TYPE` | Parité de la semaine de référence (`even` ou `odd`). | `None` | `Literal["even", "odd"] \| None`|
|
| `THEORETICAL_WEEK_ANCHOR_TYPE` | Parité de la semaine de référence (`even` ou `odd`). | `None` | `Literal["even", "odd"] \| None`|
|
||||||
| `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` |
|
| `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` |
|
||||||
| `AI_PROVIDER` | Fournisseur IA (`openai`, `openai-compatible` ou `litellm`). | `openai` | `Literal["openai", "litellm", "openai-compatible"]` |
|
| `AI_PROVIDER` | Fournisseur IA (`openai`, `openai-compatible` ou `litellm`). | `openai` | `Literal["openai", "litellm", "openai-compatible"]` |
|
||||||
| `AI_BASE_URL` | URL de base pour l'API IA (validée structurellement pour tous les providers, voir ci-dessous). | `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 (exemple recommandé : `gpt-4o-mini`). | `None` | `str \| None`|
|
| `AI_MODEL` | Modèle IA à utiliser (exemple recommandé : `gpt-4o-mini`). | `None` | `str \| None`|
|
||||||
| `AI_ALLOW_INSECURE_HTTP` | Autoriser HTTP (non sécurisé) pour tous les providers (openai, litellm, openai-compatible). | `False` | `bool` |
|
| `AI_ALLOW_INSECURE_HTTP` | Autoriser HTTP (non sécurisé) pour `openai-compatible` uniquement. | `False` | `bool` |
|
||||||
| `DRY_RUN` | Simulation sans sortie distante ni état local persistant ; incompatible avec `qr_token`. | `False` | `bool` |
|
| `DRY_RUN` | Simulation sans sortie distante ni état local persistant ; incompatible avec `qr_token`. | `False` | `bool` |
|
||||||
| `LOG_LEVEL` | Niveau de log (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | `INFO` | `str` |
|
| `LOG_LEVEL` | Niveau de log (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | `INFO` | `str` |
|
||||||
|
|
||||||
@@ -394,7 +394,7 @@ LOG_LEVEL=INFO
|
|||||||
> `CalDAVSettings.calendar_path` a pour valeur par défaut `"/pronote-sync/"`.
|
> `CalDAVSettings.calendar_path` a pour valeur par défaut `"/pronote-sync/"`.
|
||||||
> `XmppSettings.resource` a pour valeur par défaut `"pronote-sync"`.
|
> `XmppSettings.resource` a pour valeur par défaut `"pronote-sync"`.
|
||||||
> > ``AISettings.provider`` accepte également ``openai-compatible`` (réutilise ``OpenAISynthesisProvider`` avec un ``base_url`` personnalisé).
|
> > ``AISettings.provider`` accepte également ``openai-compatible`` (réutilise ``OpenAISynthesisProvider`` avec un ``base_url`` personnalisé).
|
||||||
> > ``AISettings.allow_insecure_http`` (défaut ``False``) autorise les URLs HTTP pour les trois providers (``openai``, ``litellm`` et ``openai-compatible``).
|
> > ``AISettings.allow_insecure_http`` (défaut ``False``) autorise les URLs HTTP pour le provider ``openai-compatible`` uniquement.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@@ -421,8 +421,8 @@ class CalDAVSettings(BaseSettings):
|
|||||||
password: SecretStr | None = None
|
password: SecretStr | None = None
|
||||||
calendar_path: str = "/pronote-sync/"
|
calendar_path: str = "/pronote-sync/"
|
||||||
allow_insecure_http: bool = False
|
allow_insecure_http: bool = False
|
||||||
sync_past_days: int = Field(default=7, ge=0)
|
sync_past_days: int = 7
|
||||||
sync_future_days: int = Field(default=30, ge=0)
|
sync_future_days: int = 30
|
||||||
|
|
||||||
|
|
||||||
class AISettings(BaseSettings):
|
class AISettings(BaseSettings):
|
||||||
@@ -2279,12 +2279,6 @@ méthodes agenda/devoirs ne transforment jamais une erreur en liste vide : elles
|
|||||||
version expurgée puis lèvent une erreur expurgée avec `from None`. Les méthodes de messages et
|
version expurgée puis lèvent une erreur expurgée avec `from None`. Les méthodes de messages et
|
||||||
d'informations sont non critiques et peuvent retourner une liste vide avec un warning.
|
d'informations sont non critiques et peuvent retourner une liste vide avec un warning.
|
||||||
|
|
||||||
En mode `qr_token`, `get_informations()` est ignorée : elle retourne immédiatement `[]` sans
|
|
||||||
connexion, verrou ni appel réseau, et journalise un message INFO unique. L'endpoint
|
|
||||||
`PageActualites` renvoie en effet une erreur pronotepy 20 sur les instances HubEduConnect
|
|
||||||
testées, provoquant un refresh redondant du token. Ce comportement n'est pas configurable ;
|
|
||||||
`get_messages()` n'est pas concernée par ce contournement.
|
|
||||||
|
|
||||||
Les objets renvoyés par `client.homework(start, end)` couvrent une fenêtre. Le résultat destiné à
|
Les objets renvoyés par `client.homework(start, end)` couvrent une fenêtre. Le résultat destiné à
|
||||||
un jour cible est donc filtré explicitement sur `homework.date == target_date`.
|
un jour cible est donc filtré explicitement sur `homework.date == target_date`.
|
||||||
|
|
||||||
@@ -2296,13 +2290,11 @@ d'authentification et de récupération par un verrou POSIX local non bloquant,
|
|||||||
`.pronote_auth_state.json.lock`, à côté de `.pronote_auth_state.json`.
|
`.pronote_auth_state.json.lock`, à côté de `.pronote_auth_state.json`.
|
||||||
|
|
||||||
Le verrou couvre l'ensemble du cycle QR/token : chargement de l'état, connexion par token ou
|
Le verrou couvre l'ensemble du cycle QR/token : chargement de l'état, connexion par token ou
|
||||||
enrôlement QR initial, opération de données (agenda, devoirs, messages ; informations hors mode
|
enrôlement QR initial, opération de données (agenda, devoirs, messages ou informations), puis
|
||||||
`qr_token`), puis persistance des credentials actualisées. En mode `qr_token`, `get_informations()`
|
persistance des credentials actualisées. Une tentative concurrente échoue immédiatement avec une
|
||||||
est ignorée (retour immédiat `[]` sans connexion ni verrou) : elle n'acquiert pas le verrou et ne
|
erreur d'état d'authentification expurgée ; elle ne patiente pas et ne relance pas
|
||||||
déclenche aucune persistance. Une tentative concurrente échoue immédiatement avec une erreur d'état
|
l'authentification. Le contenu du token, le PIN et les autres credentials ne sont jamais inclus
|
||||||
d'authentification expurgée ; elle ne patiente pas et ne relance pas l'authentification. Le contenu
|
dans les logs ni dans ce message d'erreur.
|
||||||
du token, le PIN et les autres credentials ne sont jamais inclus dans les logs ni dans ce message
|
|
||||||
d'erreur.
|
|
||||||
|
|
||||||
Ce mécanisme est un contrat **local** : il coordonne des processus sur le même hôte Linux et un
|
Ce mécanisme est un contrat **local** : il coordonne des processus sur le même hôte Linux et un
|
||||||
filesystem local. Pour des déploiements conteneurisés, les conteneurs qui partagent le même compte
|
filesystem local. Pour des déploiements conteneurisés, les conteneurs qui partagent le même compte
|
||||||
@@ -3925,26 +3917,16 @@ L'import de `litellm` est conditionnel avec `try/except ImportError` → `None`.
|
|||||||
| ``openai-compatible`` | Proxy ou serveur compatible OpenAI | ``OpenAISynthesisProvider`` |
|
| ``openai-compatible`` | Proxy ou serveur compatible OpenAI | ``OpenAISynthesisProvider`` |
|
||||||
| ``litellm`` | Bibliothèque LiteLLM embarquée | ``LiteLLMSynthesisProvider`` |
|
| ``litellm`` | Bibliothèque LiteLLM embarquée | ``LiteLLMSynthesisProvider`` |
|
||||||
|
|
||||||
Pour le provider ``openai-compatible``, ``AI_BASE_URL`` et ``AI_MODEL`` sont requis ; pour
|
Pour le provider ``openai-compatible``, la validation de la configuration est stricte :
|
||||||
``openai`` et ``litellm``, ils sont optionnels. La validation structurelle de ``AI_BASE_URL``
|
|
||||||
(partagée via ``_validate_base_url``) s'applique de façon identique aux trois providers dès que
|
|
||||||
l'URL est renseignée :
|
|
||||||
|
|
||||||
- URL parsable par ``urlparse`` (``ValueError`` ou schéma vide → refusée) et hostname non vide.
|
- ``AI_BASE_URL`` est requis.
|
||||||
- Schéma limité à ``http``/``https`` ; ``http`` refusé sauf si ``AI_ALLOW_INSECURE_HTTP=true``.
|
- ``AI_MODEL`` est requis et ne doit pas être vide.
|
||||||
- Credentials dans l'URL (``user:pass@host``) refusés.
|
- ``AI_API_KEY`` est requis (MVP).
|
||||||
- Paramètres sensibles dans la *query string* refusés, y compris sans valeur
|
- L'URL doit utiliser le schéma ``https`` sauf si ``AI_ALLOW_INSECURE_HTTP=true``.
|
||||||
(``token``, ``key``, ``api_key``, ``secret``, ``password``, ``auth``).
|
- Les credentials dans l'URL sont refusés.
|
||||||
|
- Les paramètres sensibles dans la *query string* sont refusés.
|
||||||
- Aucune manipulation automatique de ``/v1`` n'est effectuée.
|
- Aucune manipulation automatique de ``/v1`` n'est effectuée.
|
||||||
- En cas de violation, un avertissement est journalisé (URL masquée via ``redact_url``) et la
|
- Si la configuration est incomplète, la factory retourne ``None`` avec un avertissement (mode dégradé).
|
||||||
factory retourne ``None`` (mode dégradé) ; la factory ne lève jamais d'exception et ne fait
|
|
||||||
aucun appel réseau.
|
|
||||||
|
|
||||||
| Provider | `AI_BASE_URL` | `AI_MODEL` | Validation structurelle |
|
|
||||||
|---------------------|---------------|------------|---------------------------------------------|
|
|
||||||
| ``openai`` | optionnel | optionnel | `_validate_base_url("openai", ...)` |
|
|
||||||
| ``litellm`` | optionnel | optionnel | `_validate_base_url("litellm", ...)` |
|
|
||||||
| ``openai-compatible`` | requis | requis | `_validate_openai_compatible_config` (présence puis `_validate_base_url`) |
|
|
||||||
|
|
||||||
La politique hors réseau de la table des modèles litellm est gérée par `LITELLM_LOCAL_MODEL_COST_MAP=true`. Les tests utilisent `pytest.importorskip("litellm")`.
|
La politique hors réseau de la table des modèles litellm est gérée par `LITELLM_LOCAL_MODEL_COST_MAP=true`. Les tests utilisent `pytest.importorskip("litellm")`.
|
||||||
|
|
||||||
@@ -3960,69 +3942,37 @@ from ..utils.redaction import redact_url
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
_SENSITIVE_QUERY_PARAMS = {"token", "key", "api_key", "secret", "password", "auth"}
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_base_url(provider: str, url: str, allow_insecure_http: bool) -> str | None:
|
|
||||||
"""Valide structurellement une URL de base IA, partagée entre providers."""
|
|
||||||
try:
|
|
||||||
parsed = urlparse(url)
|
|
||||||
if not parsed.scheme:
|
|
||||||
logger.warning("URL invalide pour le provider %s : %s", provider, redact_url(url))
|
|
||||||
return None
|
|
||||||
if not parsed.hostname:
|
|
||||||
logger.warning(
|
|
||||||
"URL sans hostname pour le provider %s : %s", provider, redact_url(url)
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
if parsed.scheme not in ("http", "https"):
|
|
||||||
logger.warning(
|
|
||||||
"Schéma d'URL non supporté pour le provider %s : %s", provider, redact_url(url)
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
if parsed.scheme == "http" and not allow_insecure_http:
|
|
||||||
logger.warning(
|
|
||||||
"URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true pour le provider %s : %s",
|
|
||||||
provider,
|
|
||||||
redact_url(url),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
if parsed.username is not None or parsed.password is not None:
|
|
||||||
logger.warning(
|
|
||||||
"Credentials dans l'URL refusés pour le provider %s : %s",
|
|
||||||
provider,
|
|
||||||
redact_url(url),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
param_names = [
|
|
||||||
name.lower() for name, _ in parse_qsl(parsed.query, keep_blank_values=True)
|
|
||||||
]
|
|
||||||
if any(name in _SENSITIVE_QUERY_PARAMS for name in param_names):
|
|
||||||
logger.warning(
|
|
||||||
"Paramètres sensibles dans l'URL refusés pour le provider %s : %s",
|
|
||||||
provider,
|
|
||||||
redact_url(url),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
# Accéder à parsed.port peut lever ValueError (port invalide/hors bornes).
|
|
||||||
parsed.port # noqa: B018
|
|
||||||
except ValueError:
|
|
||||||
logger.warning("URL invalide pour le provider %s : %s", provider, redact_url(url))
|
|
||||||
return None
|
|
||||||
return url
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_openai_compatible_config(
|
def _validate_openai_compatible_config(
|
||||||
url: str | None, model: str | None, allow_insecure_http: bool
|
url: str | None, model: str | None, allow_insecure_http: bool
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Valide la configuration du provider ``openai-compatible``."""
|
"""Valide la configuration du provider ``openai-compatible``."""
|
||||||
if not url:
|
if not url or not model:
|
||||||
logger.warning("URL de base requise pour le provider openai-compatible")
|
|
||||||
return None
|
return None
|
||||||
if not model:
|
try:
|
||||||
logger.warning("Modèle requis pour le provider openai-compatible")
|
parsed = urlparse(url)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("URL invalide : %s", redact_url(url))
|
||||||
return None
|
return None
|
||||||
return _validate_base_url("openai-compatible", url, allow_insecure_http)
|
if not parsed.hostname:
|
||||||
|
logger.warning("URL sans hostname : %s", redact_url(url))
|
||||||
|
return None
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
return None
|
||||||
|
if parsed.scheme == "http" and not allow_insecure_http:
|
||||||
|
return None
|
||||||
|
if parsed.username is not None or parsed.password is not None:
|
||||||
|
logger.warning("Credentials dans l'URL refusés : %s", redact_url(url))
|
||||||
|
return None
|
||||||
|
sensitive_names = {"token", "key", "api_key", "secret", "password", "auth"}
|
||||||
|
param_names = [
|
||||||
|
name.lower() for name, _ in parse_qsl(parsed.query, keep_blank_values=True)
|
||||||
|
]
|
||||||
|
if any(name in sensitive_names for name in param_names):
|
||||||
|
logger.warning(
|
||||||
|
"Paramètres sensibles dans l'URL refusés : %s", redact_url(url)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
||||||
@@ -4031,10 +3981,7 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
|||||||
Retourne ``None`` lorsque la synthèse IA est désactivée ou qu'aucune clé
|
Retourne ``None`` lorsque la synthèse IA est désactivée ou qu'aucune clé
|
||||||
API n'est configurée. Pour le provider ``litellm``, le paquet ``litellm``
|
API n'est configurée. Pour le provider ``litellm``, le paquet ``litellm``
|
||||||
(extra ``ai-litellm``) est requis : s'il est absent, un avertissement est
|
(extra ``ai-litellm``) est requis : s'il est absent, un avertissement est
|
||||||
journalisé et ``None`` est retourné. Pour ``openai`` et ``litellm``,
|
journalisé et ``None`` est retourné.
|
||||||
une ``base_url`` éventuelle est validée par :func:`_validate_base_url` ;
|
|
||||||
pour le provider ``openai-compatible``, la configuration est validée par
|
|
||||||
:func:`_validate_openai_compatible_config`.
|
|
||||||
|
|
||||||
:param settings: Paramètres IA.
|
:param settings: Paramètres IA.
|
||||||
:return: Le fournisseur configuré, ou ``None`` si désactivé ou sans clé API.
|
:return: Le fournisseur configuré, ou ``None`` si désactivé ou sans clé API.
|
||||||
@@ -4054,10 +4001,6 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("Extra 'ai-litellm' requis pour le provider litellm")
|
logger.warning("Extra 'ai-litellm' requis pour le provider litellm")
|
||||||
return None
|
return None
|
||||||
if base_url is not None and (
|
|
||||||
_validate_base_url("litellm", base_url, settings.allow_insecure_http) is None
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
return LiteLLMSynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
return LiteLLMSynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
||||||
|
|
||||||
if settings.provider == "openai-compatible":
|
if settings.provider == "openai-compatible":
|
||||||
@@ -4068,10 +4011,6 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
|||||||
return None
|
return None
|
||||||
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=url, model=model)
|
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=url, model=model)
|
||||||
|
|
||||||
if base_url is not None and (
|
|
||||||
_validate_base_url("openai", base_url, settings.allow_insecure_http) is None
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -75,7 +75,7 @@ The following variables can be safely pre-configured in `/etc/pronote-sync/prono
|
|||||||
- `XMPP_USE_TLS` is deprecated but still supported (aliased to `XMPP_TLS_MODE`)
|
- `XMPP_USE_TLS` is deprecated but still supported (aliased to `XMPP_TLS_MODE`)
|
||||||
|
|
||||||
- **AI:**
|
- **AI:**
|
||||||
- `AI_ENABLED`, `AI_PROVIDER`, `AI_BASE_URL`, `AI_MODEL`, `AI_ALLOW_INSECURE_HTTP` — la validation structurelle de `AI_BASE_URL` s'applique à tous les providers (HTTPS sauf `AI_ALLOW_INSECURE_HTTP=true`, pas de credentials ni de paramètres sensibles dans l'URL, pas de `/v1` automatique) ; seul `openai-compatible` exige `AI_BASE_URL` et `AI_MODEL`.
|
- `AI_ENABLED`, `AI_PROVIDER`, `AI_BASE_URL`, `AI_MODEL`, `AI_ALLOW_INSECURE_HTTP`
|
||||||
|
|
||||||
- **Blog:**
|
- **Blog:**
|
||||||
- `BLOG_ENABLED`, `BLOG_RSS_URL`
|
- `BLOG_ENABLED`, `BLOG_RSS_URL`
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ processus. Le mode `PRONOTE_AUTH_MODE=qr_token` est incompatible avec cette gara
|
|||||||
le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant.
|
le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant.
|
||||||
Le dry-run ne remplace pas une vérification des paramètres réellement chargés.
|
Le dry-run ne remplace pas une vérification des paramètres réellement chargés.
|
||||||
|
|
||||||
|
Si le blog RSS est activé, ses GUID ne sont acquittés qu'après confirmation de
|
||||||
|
l'envoi XMPP. Un refus, une exception, l'absence de canal ou un `--dry-run`
|
||||||
|
laisse donc les articles récupérables à l'exécution suivante ; les en-têtes
|
||||||
|
HTTP associés à ces articles suivent la même règle pour éviter un `304` qui
|
||||||
|
masquerait une livraison non confirmée.
|
||||||
|
|
||||||
En mode `PRONOTE_AUTH_MODE=qr_token`, le fichier
|
En mode `PRONOTE_AUTH_MODE=qr_token`, le fichier
|
||||||
`.pronote_auth_state.json` et son verrou frère sont créés dans le répertoire
|
`.pronote_auth_state.json` et son verrou frère sont créés dans le répertoire
|
||||||
de travail du service (par exemple `/var/lib/pronote-sync`) avec le mode
|
de travail du service (par exemple `/var/lib/pronote-sync`) avec le mode
|
||||||
|
|||||||
@@ -293,8 +293,8 @@ class AppSettings(BaseSettings):
|
|||||||
school_holidays_path: str | None = None
|
school_holidays_path: str | None = None
|
||||||
theoretical_week_anchor_date: date | None = None
|
theoretical_week_anchor_date: date | None = None
|
||||||
theoretical_week_anchor_type: Literal["even", "odd"] | None = None
|
theoretical_week_anchor_type: Literal["even", "odd"] | None = None
|
||||||
sync_past_days: int = Field(default=7, ge=0)
|
sync_past_days: int = 7
|
||||||
sync_future_days: int = Field(default=30, ge=0)
|
sync_future_days: int = 30
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
|
|||||||
@@ -230,11 +230,13 @@ class PipelineRunner:
|
|||||||
data = normalize_step(fetched, generated_at=now)
|
data = normalize_step(fetched, generated_at=now)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
blog_articles = fetch_blog_step(self._blog_client, self._blog_state)
|
blog_result = fetch_blog_step(self._blog_client, self._blog_state)
|
||||||
|
blog_articles = list(blog_result.articles)
|
||||||
except PipelineCriticalError:
|
except PipelineCriticalError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._warn("fetch_blog", self._redact(exc))
|
self._warn("fetch_blog", self._redact(exc))
|
||||||
|
blog_result = None
|
||||||
blog_articles = []
|
blog_articles = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -290,8 +292,11 @@ class PipelineRunner:
|
|||||||
)
|
)
|
||||||
if self._channel is not None and not self._dry_run:
|
if self._channel is not None and not self._dry_run:
|
||||||
try:
|
try:
|
||||||
if not send_step(self._channel, message):
|
delivered = send_step(self._channel, message)
|
||||||
|
if not delivered:
|
||||||
self._warn("send", "Le canal XMPP a refusé l'envoi")
|
self._warn("send", "Le canal XMPP a refusé l'envoi")
|
||||||
|
elif blog_result is not None and self._blog_state is not None:
|
||||||
|
self._blog_state.acknowledge(blog_result)
|
||||||
except PipelineCriticalError:
|
except PipelineCriticalError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -2,23 +2,28 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pronote_sync.models.blog import BlogArticle
|
from pronote_sync.sources.blog.result import BlogRSSFetchResult
|
||||||
from pronote_sync.sources.blog.rss import BlogRSSClient
|
from pronote_sync.sources.blog.rss import BlogRSSClient
|
||||||
from pronote_sync.sources.blog.state import BlogRSSState
|
from pronote_sync.sources.blog.state import BlogRSSState
|
||||||
from pronote_sync.utils.redaction import redact_exception
|
from pronote_sync.utils.redaction import redact_exception
|
||||||
|
|
||||||
|
|
||||||
def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> list[BlogArticle]:
|
def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> BlogRSSFetchResult:
|
||||||
"""Récupère les articles RSS nouveaux en conservant l'état du client.
|
"""Récupère les articles RSS nouveaux sans les acquitter.
|
||||||
|
|
||||||
|
L'état des GUID est acquitté séparément par le pipeline après confirmation
|
||||||
|
de la livraison XMPP. Les en-têtes de cache d'une réponse sans article
|
||||||
|
peuvent être conservés immédiatement, car aucune livraison n'est alors en
|
||||||
|
attente.
|
||||||
|
|
||||||
:param client: Client RSS configuré, ou ``None`` lorsque le blog est désactivé.
|
:param client: Client RSS configuré, ou ``None`` lorsque le blog est désactivé.
|
||||||
:param state: État de déduplication et de cache HTTP associé au run.
|
:param state: État de déduplication et de cache HTTP associé au run.
|
||||||
:return: Nouveaux articles du blog.
|
:return: Résultat de récupération, incluant les métadonnées de cache.
|
||||||
:rtype: list[BlogArticle]
|
:rtype: BlogRSSFetchResult
|
||||||
:raises RuntimeError: Si la récupération RSS injectée échoue.
|
:raises RuntimeError: Si la récupération RSS injectée échoue.
|
||||||
"""
|
"""
|
||||||
if client is None or state is None:
|
if client is None or state is None:
|
||||||
return []
|
return BlogRSSFetchResult()
|
||||||
try:
|
try:
|
||||||
etag, last_modified = state.get_cache_headers()
|
etag, last_modified = state.get_cache_headers()
|
||||||
result = client.fetch_and_parse(
|
result = client.fetch_and_parse(
|
||||||
@@ -26,9 +31,8 @@ def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) ->
|
|||||||
)
|
)
|
||||||
if result.error is not None:
|
if result.error is not None:
|
||||||
raise RuntimeError(result.error) from None
|
raise RuntimeError(result.error) from None
|
||||||
if not result.not_modified:
|
if not result.not_modified and not result.articles:
|
||||||
state.add_guids(article.id for article in result.articles)
|
|
||||||
state.update_cache_headers(result.etag, result.last_modified)
|
state.update_cache_headers(result.etag, result.last_modified)
|
||||||
return list(result.articles)
|
return result
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise RuntimeError(f"Récupération du blog échouée : {redact_exception(exc)}") from None
|
raise RuntimeError(f"Récupération du blog échouée : {redact_exception(exc)}") from None
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import logging
|
|||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pronote_sync.sources.blog.result import BlogRSSFetchResult
|
||||||
from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -156,6 +157,22 @@ class BlogRSSState:
|
|||||||
self._known_guids.update(new_guids)
|
self._known_guids.update(new_guids)
|
||||||
self._save()
|
self._save()
|
||||||
|
|
||||||
|
def acknowledge(self, result: BlogRSSFetchResult) -> None:
|
||||||
|
"""Acquitte une récupération RSS après sa livraison confirmée.
|
||||||
|
|
||||||
|
Les GUID et les en-têtes de cache sont enregistrés ensemble afin qu'un
|
||||||
|
article dont la livraison a échoué reste récupérable à l'exécution
|
||||||
|
suivante. Une réponse ``304 Not Modified`` n'a rien à acquitter.
|
||||||
|
|
||||||
|
:param result: Résultat RSS livré avec succès.
|
||||||
|
"""
|
||||||
|
if result.not_modified:
|
||||||
|
return
|
||||||
|
self._known_guids.update(article.id for article in result.articles)
|
||||||
|
self._etag = result.etag
|
||||||
|
self._last_modified = result.last_modified
|
||||||
|
self._save()
|
||||||
|
|
||||||
def get_cache_headers(self) -> tuple[str | None, str | None]:
|
def get_cache_headers(self) -> tuple[str | None, str | None]:
|
||||||
"""Renvoie les en-têtes de cache HTTP mémorisés.
|
"""Renvoie les en-têtes de cache HTTP mémorisés.
|
||||||
|
|
||||||
|
|||||||
@@ -488,23 +488,9 @@ class PronoteClient:
|
|||||||
Chaque entrée est mappée sur un modèle :class:`Message` de type
|
Chaque entrée est mappée sur un modèle :class:`Message` de type
|
||||||
``SURVEY`` si c'est un sondage, ``INFORMATION`` sinon.
|
``SURVEY`` si c'est un sondage, ``INFORMATION`` sinon.
|
||||||
|
|
||||||
En mode ``qr_token``, la récupération est ignorée sans connexion ni
|
:return: Liste des informations et sondages ; vide en cas d'erreur.
|
||||||
appel réseau : l'endpoint ``PageActualites`` renvoie une erreur
|
|
||||||
pronotepy 20 sur les instances HubEduConnect testées, provoquant un
|
|
||||||
refresh redondant du token. La méthode retourne alors immédiatement
|
|
||||||
une liste vide et journalise un message INFO unique ; ce comportement
|
|
||||||
n'est pas configurable.
|
|
||||||
|
|
||||||
:return: Liste des informations et sondages ; vide en cas d'erreur ou
|
|
||||||
en mode ``qr_token``.
|
|
||||||
:rtype: list[Message]
|
:rtype: list[Message]
|
||||||
"""
|
"""
|
||||||
if self._settings.auth_mode == "qr_token":
|
|
||||||
logger.info(
|
|
||||||
"Récupération des informations Pronote ignorée : endpoint "
|
|
||||||
"PageActualites indisponible en mode d'authentification qr_token."
|
|
||||||
)
|
|
||||||
return []
|
|
||||||
with self._qr_token_operation_lock():
|
with self._qr_token_operation_lock():
|
||||||
try:
|
try:
|
||||||
client = self._connect()
|
client = self._connect()
|
||||||
|
|||||||
@@ -15,83 +15,20 @@ logger = logging.getLogger(__name__)
|
|||||||
__all__ = ["get_synthesis_provider", "SynthesisProvider", "OpenAISynthesisProvider"]
|
__all__ = ["get_synthesis_provider", "SynthesisProvider", "OpenAISynthesisProvider"]
|
||||||
|
|
||||||
|
|
||||||
_SENSITIVE_QUERY_PARAMS = {"token", "key", "api_key", "secret", "password", "auth"}
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_base_url(provider: str, url: str, allow_insecure_http: bool) -> str | None:
|
|
||||||
"""Valide structurellement une URL de base IA, partagée entre providers.
|
|
||||||
|
|
||||||
Applique les règles structurelles identiques aux trois providers
|
|
||||||
(``openai``, ``litellm`` et ``openai-compatible``) : URL parsable par
|
|
||||||
``urlparse`` (``ValueError`` rejeté), hostname non vide, schéma limité
|
|
||||||
à ``http``/``https`` (HTTP refusé sauf si ``allow_insecure_http`` vaut
|
|
||||||
``True``), absence d'identifiants dans le netloc et de paramètres
|
|
||||||
sensibles dans la requête (y compris les paramètres sans valeur). L'URL
|
|
||||||
est retournée strictement inchangée : aucune manipulation automatique
|
|
||||||
du suffixe ``/v1`` n'est effectuée. En cas de violation, un
|
|
||||||
avertissement est journalisé (l'URL est toujours masquée via
|
|
||||||
:func:`redact_url`) et ``None`` est retourné ; la fonction ne lève
|
|
||||||
jamais d'exception et n'effectue aucun appel réseau.
|
|
||||||
|
|
||||||
:param provider: Nom du provider (utilisé pour le message d'avertissement).
|
|
||||||
:param url: URL de base à valider (non vide).
|
|
||||||
:param allow_insecure_http: Autorise ou non les URLs en HTTP.
|
|
||||||
:return: L'URL validée, strictement inchangée, ou ``None`` si invalide.
|
|
||||||
:rtype: str | None
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
parsed = urlparse(url)
|
|
||||||
if not parsed.scheme:
|
|
||||||
logger.warning("URL invalide pour le provider %s : %s", provider, redact_url(url))
|
|
||||||
return None
|
|
||||||
if not parsed.hostname:
|
|
||||||
logger.warning("URL sans hostname pour le provider %s : %s", provider, redact_url(url))
|
|
||||||
return None
|
|
||||||
if parsed.scheme not in ("http", "https"):
|
|
||||||
logger.warning(
|
|
||||||
"Schéma d'URL non supporté pour le provider %s : %s", provider, redact_url(url)
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
if parsed.scheme == "http" and not allow_insecure_http:
|
|
||||||
logger.warning(
|
|
||||||
"URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true pour le provider %s : %s",
|
|
||||||
provider,
|
|
||||||
redact_url(url),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
if parsed.username is not None or parsed.password is not None:
|
|
||||||
logger.warning(
|
|
||||||
"Credentials dans l'URL refusés pour le provider %s : %s",
|
|
||||||
provider,
|
|
||||||
redact_url(url),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
param_names = [name.lower() for name, _ in parse_qsl(parsed.query, keep_blank_values=True)]
|
|
||||||
if any(name in _SENSITIVE_QUERY_PARAMS for name in param_names):
|
|
||||||
logger.warning(
|
|
||||||
"Paramètres sensibles dans l'URL refusés pour le provider %s : %s",
|
|
||||||
provider,
|
|
||||||
redact_url(url),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
# Accéder à parsed.port peut lever ValueError (port invalide/hors bornes).
|
|
||||||
parsed.port # noqa: B018
|
|
||||||
except ValueError:
|
|
||||||
logger.warning("URL invalide pour le provider %s : %s", provider, redact_url(url))
|
|
||||||
return None
|
|
||||||
return url
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_openai_compatible_config(
|
def _validate_openai_compatible_config(
|
||||||
url: str | None, model: str | None, allow_insecure_http: bool
|
url: str | None, model: str | None, allow_insecure_http: bool
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Valide la configuration du provider ``openai-compatible``.
|
"""Valide la configuration du provider ``openai-compatible``.
|
||||||
|
|
||||||
Vérifie d'abord la présence de l'URL de base et du modèle (spécifique
|
Vérifie la présence de l'URL de base et du modèle, le schéma de l'URL
|
||||||
à ``openai-compatible``), puis délègue les règles structurelles
|
(HTTPS obligatoire, HTTP accepté uniquement si ``allow_insecure_http``
|
||||||
partagées à :func:`_validate_base_url`. En cas d'échec, un
|
vaut ``True``), la présence d'un hostname non vide, l'absence
|
||||||
avertissement est journalisé et ``None`` est retourné : la synthèse IA
|
d'identifiants dans le netloc et de paramètres sensibles dans la
|
||||||
se dégrade silencieusement, sans jamais lever d'exception.
|
requête (y compris les paramètres sans valeur). Une URL malformée
|
||||||
|
(``ValueError`` levé par ``urlparse``) est également rejetée. En cas
|
||||||
|
d'échec, un avertissement est journalisé (l'URL est toujours masquée
|
||||||
|
via :func:`redact_url`) et ``None`` est retourné : la synthèse IA se
|
||||||
|
dégrade silencieusement, sans jamais lever d'exception.
|
||||||
|
|
||||||
:param url: URL de base de l'API compatible OpenAI.
|
:param url: URL de base de l'API compatible OpenAI.
|
||||||
:param model: Identifiant du modèle à utiliser.
|
:param model: Identifiant du modèle à utiliser.
|
||||||
@@ -106,7 +43,42 @@ def _validate_openai_compatible_config(
|
|||||||
if not model:
|
if not model:
|
||||||
logger.warning("Modèle requis pour le provider openai-compatible")
|
logger.warning("Modèle requis pour le provider openai-compatible")
|
||||||
return None
|
return None
|
||||||
return _validate_base_url("openai-compatible", url, allow_insecure_http)
|
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
"URL invalide pour le provider openai-compatible : %s",
|
||||||
|
redact_url(url),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if not parsed.hostname:
|
||||||
|
logger.warning(
|
||||||
|
"URL sans hostname pour le provider openai-compatible : %s",
|
||||||
|
redact_url(url),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
logger.warning(
|
||||||
|
"Schéma d'URL non supporté pour le provider openai-compatible : %s",
|
||||||
|
redact_url(url),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if parsed.scheme == "http" and not allow_insecure_http:
|
||||||
|
logger.warning(
|
||||||
|
"URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true : %s",
|
||||||
|
redact_url(url),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if parsed.username is not None or parsed.password is not None:
|
||||||
|
logger.warning("Credentials dans l'URL refusés : %s", redact_url(url))
|
||||||
|
return None
|
||||||
|
sensitive_names = {"token", "key", "api_key", "secret", "password", "auth"}
|
||||||
|
param_names = [name.lower() for name, _ in parse_qsl(parsed.query, keep_blank_values=True)]
|
||||||
|
if any(name in sensitive_names for name in param_names):
|
||||||
|
logger.warning("Paramètres sensibles dans l'URL refusés : %s", redact_url(url))
|
||||||
|
return None
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
||||||
@@ -115,15 +87,14 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
|||||||
Retourne ``None`` lorsque la synthèse IA est désactivée ou qu'aucune clé
|
Retourne ``None`` lorsque la synthèse IA est désactivée ou qu'aucune clé
|
||||||
API n'est configurée. Pour le provider ``litellm``, le paquet ``litellm``
|
API n'est configurée. Pour le provider ``litellm``, le paquet ``litellm``
|
||||||
(extra ``ai-litellm``) est requis : s'il est absent, un avertissement est
|
(extra ``ai-litellm``) est requis : s'il est absent, un avertissement est
|
||||||
journalisé et ``None`` est retourné. Pour ``openai`` et ``litellm``,
|
journalisé et ``None`` est retourné. Pour le provider
|
||||||
une ``base_url`` éventuelle est validée par :func:`_validate_base_url` ;
|
``openai-compatible``, la configuration (URL de base et modèle) est
|
||||||
pour le provider ``openai-compatible``, la configuration (URL de base
|
validée par :func:`_validate_openai_compatible_config` ; en cas de
|
||||||
et modèle) est validée par :func:`_validate_openai_compatible_config` ;
|
rejet, ``None`` est retourné avec un avertissement.
|
||||||
en cas de rejet, ``None`` est retourné avec un avertissement.
|
|
||||||
|
|
||||||
:param settings: Paramètres IA.
|
:param settings: Paramètres IA.
|
||||||
:return: Le fournisseur configuré, ou ``None`` si désactivé, sans clé API
|
:return: Le fournisseur configuré, ou ``None`` si désactivé, sans clé API
|
||||||
ou avec une configuration invalide.
|
ou avec une configuration ``openai-compatible`` invalide.
|
||||||
:rtype: SynthesisProvider | None
|
:rtype: SynthesisProvider | None
|
||||||
"""
|
"""
|
||||||
if not settings.enabled:
|
if not settings.enabled:
|
||||||
@@ -140,10 +111,6 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("Extra 'ai-litellm' requis pour le provider litellm")
|
logger.warning("Extra 'ai-litellm' requis pour le provider litellm")
|
||||||
return None
|
return None
|
||||||
if base_url is not None and (
|
|
||||||
_validate_base_url("litellm", base_url, settings.allow_insecure_http) is None
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
return LiteLLMSynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
return LiteLLMSynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
||||||
|
|
||||||
if settings.provider == "openai-compatible":
|
if settings.provider == "openai-compatible":
|
||||||
@@ -154,8 +121,4 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
|||||||
return None
|
return None
|
||||||
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=url, model=model)
|
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=url, model=model)
|
||||||
|
|
||||||
if base_url is not None and (
|
|
||||||
_validate_base_url("openai", base_url, settings.allow_insecure_http) is None
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
||||||
|
|||||||
@@ -1040,6 +1040,110 @@ def test_runner_blog_success_delivers_articles_into_xmpp_message_external_info(
|
|||||||
assert xmpp_message.external_info.blog_articles[0].title == "Test Article"
|
assert xmpp_message.external_info.blog_articles[0].title == "Test Article"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("channel_kind", "dry_run", "should_acknowledge"),
|
||||||
|
[
|
||||||
|
("success", False, True),
|
||||||
|
("false", False, False),
|
||||||
|
("exception", False, False),
|
||||||
|
("none", False, False),
|
||||||
|
("success", True, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_runner_acknowledges_blog_only_after_confirmed_xmpp_delivery(
|
||||||
|
pipeline_inputs: tuple[Lesson, Homework],
|
||||||
|
tmp_path: Any,
|
||||||
|
channel_kind: str,
|
||||||
|
dry_run: bool,
|
||||||
|
should_acknowledge: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Les GUID RSS restent rejouables tant que XMPP n'a pas confirmé l'envoi.
|
||||||
|
|
||||||
|
:param pipeline_inputs: Données Pronote de test.
|
||||||
|
:param tmp_path: Répertoire temporaire pour l'état RSS.
|
||||||
|
:param channel_kind: Comportement du canal XMPP simulé.
|
||||||
|
:param dry_run: Active ou non le mode simulation.
|
||||||
|
:param should_acknowledge: Indique si l'état RSS doit être acquitté.
|
||||||
|
"""
|
||||||
|
lesson, homework = pipeline_inputs
|
||||||
|
calls: list[str] = []
|
||||||
|
state_file = tmp_path / "blog-state.json"
|
||||||
|
|
||||||
|
class SuccessfulBlogClient:
|
||||||
|
"""Client RSS renvoyant un article non encore livré."""
|
||||||
|
|
||||||
|
def fetch_and_parse(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
known_guids: frozenset[str] | None = None,
|
||||||
|
etag: str | None = None,
|
||||||
|
last_modified: str | None = None,
|
||||||
|
) -> BlogRSSFetchResult:
|
||||||
|
"""Retourne un article et des en-têtes de cache déterministes.
|
||||||
|
|
||||||
|
:param known_guids: GUID déjà connus, ignorés dans ce faux client.
|
||||||
|
:param etag: ETag mémorisé, ignoré dans ce faux client.
|
||||||
|
:param last_modified: Date HTTP mémorisée, ignorée dans ce faux client.
|
||||||
|
:return: Résultat RSS avec un article à livrer.
|
||||||
|
:rtype: BlogRSSFetchResult
|
||||||
|
"""
|
||||||
|
del known_guids, etag, last_modified
|
||||||
|
return BlogRSSFetchResult(
|
||||||
|
articles=(
|
||||||
|
BlogArticle(
|
||||||
|
id="article-to-deliver",
|
||||||
|
title="Article à livrer",
|
||||||
|
url="https://example.com/article-to-deliver",
|
||||||
|
published_at=datetime(2026, 9, 8, 12, 0),
|
||||||
|
updated_at=None,
|
||||||
|
category=None,
|
||||||
|
author=None,
|
||||||
|
content_html="<p>Contenu</p>",
|
||||||
|
content_text="Contenu",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
etag="etag-after-delivery",
|
||||||
|
last_modified="Tue, 08 Sep 2026 12:00:00 GMT",
|
||||||
|
)
|
||||||
|
|
||||||
|
channel: Any
|
||||||
|
if channel_kind == "success":
|
||||||
|
channel = StubChannel(calls)
|
||||||
|
elif channel_kind == "false":
|
||||||
|
channel = FailingChannel()
|
||||||
|
elif channel_kind == "exception":
|
||||||
|
channel = ExceptionalChannel()
|
||||||
|
else:
|
||||||
|
channel = None
|
||||||
|
|
||||||
|
runner = PipelineRunner(
|
||||||
|
settings=Settings(blog=Settings().blog.model_copy(update={"enabled": True})),
|
||||||
|
pronote_fetcher=StubFetcher(calls, lesson, homework),
|
||||||
|
caldav_synchronizer=lambda data, settings: successful_sync_result(),
|
||||||
|
agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)),
|
||||||
|
blog_client=cast("BlogRSSClient | None", SuccessfulBlogClient()),
|
||||||
|
blog_state=BlogRSSState(state_file),
|
||||||
|
channel=channel,
|
||||||
|
dry_run=dry_run,
|
||||||
|
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
data, errors = runner.run()
|
||||||
|
|
||||||
|
assert data is not None
|
||||||
|
if should_acknowledge:
|
||||||
|
acknowledged_state = BlogRSSState(state_file)
|
||||||
|
assert acknowledged_state.get_known_guids() == frozenset({"article-to-deliver"})
|
||||||
|
assert acknowledged_state.get_cache_headers() == (
|
||||||
|
"etag-after-delivery",
|
||||||
|
"Tue, 08 Sep 2026 12:00:00 GMT",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
assert not state_file.exists()
|
||||||
|
if channel_kind in {"false", "exception"}:
|
||||||
|
assert any(error.step == "send" for error in errors)
|
||||||
|
|
||||||
|
|
||||||
def test_runner_secret_redaction_in_pipeline_errors(
|
def test_runner_secret_redaction_in_pipeline_errors(
|
||||||
pipeline_inputs: tuple[Lesson, Homework],
|
pipeline_inputs: tuple[Lesson, Homework],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ Tous les tests utilisent des fichiers temporaires via la fixture ``tmp_path``.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from pronote_sync.models.blog import BlogArticle
|
||||||
|
from pronote_sync.sources.blog.result import BlogRSSFetchResult
|
||||||
from pronote_sync.sources.blog.state import BlogRSSState
|
from pronote_sync.sources.blog.state import BlogRSSState
|
||||||
|
|
||||||
|
|
||||||
@@ -116,6 +119,38 @@ def test_add_guids_empty_noop(tmp_path: Path) -> None:
|
|||||||
assert state_file.read_text(encoding="utf-8") == original_content
|
assert state_file.read_text(encoding="utf-8") == original_content
|
||||||
|
|
||||||
|
|
||||||
|
def test_acknowledge_persists_guids_and_cache_headers_together(tmp_path: Path) -> None:
|
||||||
|
"""Vérifie l'acquittement atomique après une livraison confirmée.
|
||||||
|
|
||||||
|
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
state_file = tmp_path / "state.json"
|
||||||
|
state = BlogRSSState(state_file)
|
||||||
|
article = BlogArticle(
|
||||||
|
id="guid-1",
|
||||||
|
title="Article",
|
||||||
|
url="https://example.com/article",
|
||||||
|
published_at=datetime(2026, 9, 12, 8, 0, tzinfo=UTC),
|
||||||
|
updated_at=None,
|
||||||
|
category=None,
|
||||||
|
author=None,
|
||||||
|
content_html="<p>Contenu</p>",
|
||||||
|
content_text="Contenu",
|
||||||
|
)
|
||||||
|
|
||||||
|
state.acknowledge(
|
||||||
|
BlogRSSFetchResult(
|
||||||
|
articles=(article,),
|
||||||
|
etag="etag-1",
|
||||||
|
last_modified="Sat, 12 Sep 2026 08:00:00 GMT",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert state.get_known_guids() == frozenset({"guid-1"})
|
||||||
|
assert state.get_cache_headers() == ("etag-1", "Sat, 12 Sep 2026 08:00:00 GMT")
|
||||||
|
|
||||||
|
|
||||||
def test_state_load_persisted_guids(tmp_path: Path) -> None:
|
def test_state_load_persisted_guids(tmp_path: Path) -> None:
|
||||||
"""Vérifie que les GUID persistés sont rechargés dans une nouvelle instance.
|
"""Vérifie que les GUID persistés sont rechargés dans une nouvelle instance.
|
||||||
|
|
||||||
|
|||||||
+3
-120
@@ -10,10 +10,10 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import SecretStr, ValidationError
|
from pydantic import SecretStr
|
||||||
|
|
||||||
from pronote_sync.config.env import load_settings
|
from pronote_sync.config.env import load_settings
|
||||||
from pronote_sync.config.settings import AppSettings, PronoteSettings, Settings
|
from pronote_sync.config.settings import PronoteSettings, Settings
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from _pytest.monkeypatch import MonkeyPatch
|
from _pytest.monkeypatch import MonkeyPatch
|
||||||
@@ -207,121 +207,4 @@ def test_qr_pin_in_redaction_secrets(monkeypatch: MonkeyPatch) -> None:
|
|||||||
assert "**********" in repr(settings.pronote.qr_pin)
|
assert "**********" in repr(settings.pronote.qr_pin)
|
||||||
|
|
||||||
|
|
||||||
def test_sync_past_days_negative_direct_instantiation() -> None:
|
# Ensure trailing newline
|
||||||
"""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
|
|
||||||
|
|||||||
@@ -621,17 +621,12 @@ def test_get_messages_degraded_on_error(
|
|||||||
|
|
||||||
|
|
||||||
def test_get_informations_degraded_on_error(
|
def test_get_informations_degraded_on_error(
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
||||||
pronote_settings: PronoteSettings,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Vérifie que get_informations retourne une liste vide en cas d'erreur réseau.
|
"""Vérifie que get_informations retourne une liste vide en cas d'erreur réseau.
|
||||||
|
|
||||||
Assert que le chemin d'erreur retourne toujours [] avec un log ERROR.
|
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
:param pronote_settings: Paramètres Pronote valides.
|
:param pronote_settings: Paramètres Pronote valides.
|
||||||
:param caplog: Fixture pour capturer les logs.
|
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
mock_client = mocker.MagicMock()
|
mock_client = mocker.MagicMock()
|
||||||
@@ -639,254 +634,14 @@ def test_get_informations_degraded_on_error(
|
|||||||
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
||||||
|
|
||||||
client = PronoteClient(pronote_settings)
|
client = PronoteClient(pronote_settings)
|
||||||
|
messages = client.get_informations()
|
||||||
with caplog.at_level(logging.ERROR, logger="pronote_sync.sources.pronote.client"):
|
|
||||||
messages = client.get_informations()
|
|
||||||
|
|
||||||
assert messages == []
|
assert messages == []
|
||||||
# Assert ERROR log is present
|
|
||||||
error_records = [r for r in caplog.records if r.levelno == logging.ERROR]
|
|
||||||
assert len(error_records) >= 1
|
|
||||||
assert any(
|
|
||||||
"Échec de la récupération des informations Pronote" in r.message for r in error_records
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- QR code / token authentication tests ---
|
# --- QR code / token authentication tests ---
|
||||||
|
|
||||||
|
|
||||||
# --- get_informations qr_token mode guard tests ---
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_informations_skips_in_qr_token_mode(
|
|
||||||
mocker: pytest_mock.MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie que get_informations retourne [] immédiatement en mode qr_token.
|
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
settings = PronoteSettings(
|
|
||||||
url="https://pronote.example.com",
|
|
||||||
username="testuser",
|
|
||||||
password=SecretStr("testpass"),
|
|
||||||
ent="bordeaux",
|
|
||||||
account_type="parent",
|
|
||||||
auth_mode="qr_token",
|
|
||||||
)
|
|
||||||
client = PronoteClient(settings)
|
|
||||||
assert client._client is None
|
|
||||||
|
|
||||||
messages = client.get_informations()
|
|
||||||
|
|
||||||
assert messages == []
|
|
||||||
assert client._client is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_informations_no_connect_in_qr_token_mode(
|
|
||||||
mocker: pytest_mock.MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie que _connect n'est jamais appelé en mode qr_token pour get_informations.
|
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
settings = PronoteSettings(
|
|
||||||
url="https://pronote.example.com",
|
|
||||||
username="testuser",
|
|
||||||
password=SecretStr("testpass"),
|
|
||||||
ent="bordeaux",
|
|
||||||
account_type="parent",
|
|
||||||
auth_mode="qr_token",
|
|
||||||
)
|
|
||||||
connect_spy = mocker.spy(PronoteClient, "_connect")
|
|
||||||
|
|
||||||
client = PronoteClient(settings)
|
|
||||||
messages = client.get_informations()
|
|
||||||
|
|
||||||
assert messages == []
|
|
||||||
connect_spy.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_informations_no_information_and_surveys_in_qr_token_mode(
|
|
||||||
mocker: pytest_mock.MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie que information_and_surveys n'est jamais appelé en mode qr_token.
|
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
settings = PronoteSettings(
|
|
||||||
url="https://pronote.example.com",
|
|
||||||
username="testuser",
|
|
||||||
password=SecretStr("testpass"),
|
|
||||||
ent="bordeaux",
|
|
||||||
account_type="parent",
|
|
||||||
auth_mode="qr_token",
|
|
||||||
)
|
|
||||||
mock_client = mocker.MagicMock()
|
|
||||||
mock_client.information_and_surveys = mocker.MagicMock()
|
|
||||||
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
|
||||||
|
|
||||||
client = PronoteClient(settings)
|
|
||||||
messages = client.get_informations()
|
|
||||||
|
|
||||||
assert messages == []
|
|
||||||
mock_client.information_and_surveys.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_informations_qr_token_no_side_effects(
|
|
||||||
mocker: pytest_mock.MockerFixture,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Prouve le contrat complet sans effet de bordure du chemin de saut qr_token.
|
|
||||||
|
|
||||||
Avec PronoteAuthState et _qr_token_operation_lock et _persist_credentials
|
|
||||||
mockés, assert que sur le saut : le gestionnaire de contexte de verrou n'est
|
|
||||||
PAS entré, auth-state load() n'est PAS appelé, _persist_credentials() n'est
|
|
||||||
PAS appelé, et l'export des credentials n'est PAS invoqué. Assert aussi que
|
|
||||||
self._client est inchangé.
|
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
|
||||||
:param caplog: Fixture pour capturer les logs.
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
|
||||||
auth_state.load = mocker.MagicMock()
|
|
||||||
auth_state.lock = mocker.MagicMock()
|
|
||||||
|
|
||||||
settings = PronoteSettings(
|
|
||||||
url="https://pronote.example.com",
|
|
||||||
username="testuser",
|
|
||||||
password=SecretStr("testpass"),
|
|
||||||
ent="bordeaux",
|
|
||||||
account_type="parent",
|
|
||||||
auth_mode="qr_token",
|
|
||||||
)
|
|
||||||
|
|
||||||
client = PronoteClient(settings, auth_state=auth_state)
|
|
||||||
sentinel = MagicMock()
|
|
||||||
client._client = sentinel
|
|
||||||
persist_spy = mocker.spy(client, "_persist_credentials")
|
|
||||||
|
|
||||||
with caplog.at_level(logging.INFO, logger="pronote_sync.sources.pronote.client"):
|
|
||||||
messages = client.get_informations()
|
|
||||||
|
|
||||||
# Assert no side effects
|
|
||||||
assert messages == []
|
|
||||||
assert client._client is sentinel
|
|
||||||
assert isinstance(sentinel, MagicMock)
|
|
||||||
sentinel.export_credentials.assert_not_called()
|
|
||||||
persist_spy.assert_not_called()
|
|
||||||
auth_state.load.assert_not_called()
|
|
||||||
auth_state.lock.assert_not_called()
|
|
||||||
|
|
||||||
# Assert exactly one INFO log record with the exact message
|
|
||||||
info_records = [r for r in caplog.records if r.levelno == logging.INFO]
|
|
||||||
assert len(info_records) == 1
|
|
||||||
assert (
|
|
||||||
info_records[0].message
|
|
||||||
== "Récupération des informations Pronote ignorée : endpoint PageActualites "
|
|
||||||
"indisponible en mode d'authentification qr_token."
|
|
||||||
)
|
|
||||||
# Assert no secret sentinel appears in any log record
|
|
||||||
for record in caplog.records:
|
|
||||||
assert "testpass" not in record.message
|
|
||||||
assert "testuser" not in record.message
|
|
||||||
assert "pronote.example.com" not in record.message
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_informations_logs_info_in_qr_token_mode(
|
|
||||||
mocker: pytest_mock.MockerFixture,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie que get_informations log un message INFO exact en mode qr_token.
|
|
||||||
|
|
||||||
Assert exactement un enregistrement logging.INFO avec le message exact
|
|
||||||
(inspection de caplog.records, pas seulement caplog.text), et qu'aucune
|
|
||||||
sentinelle de secret n'apparaît.
|
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
|
||||||
:param caplog: Fixture pour capturer les logs.
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
settings = PronoteSettings(
|
|
||||||
url="https://pronote.example.com",
|
|
||||||
username="testuser",
|
|
||||||
password=SecretStr("testpass"),
|
|
||||||
ent="bordeaux",
|
|
||||||
account_type="parent",
|
|
||||||
auth_mode="qr_token",
|
|
||||||
)
|
|
||||||
|
|
||||||
with caplog.at_level(logging.INFO, logger="pronote_sync.sources.pronote.client"):
|
|
||||||
client = PronoteClient(settings)
|
|
||||||
messages = client.get_informations()
|
|
||||||
|
|
||||||
assert messages == []
|
|
||||||
# Assert exactly one INFO record with the exact message
|
|
||||||
info_records = [r for r in caplog.records if r.levelno == logging.INFO]
|
|
||||||
assert len(info_records) == 1
|
|
||||||
assert (
|
|
||||||
info_records[0].message
|
|
||||||
== "Récupération des informations Pronote ignorée : endpoint PageActualites "
|
|
||||||
"indisponible en mode d'authentification qr_token."
|
|
||||||
)
|
|
||||||
# Assert no secret sentinel appears in any record
|
|
||||||
for record in caplog.records:
|
|
||||||
assert "testpass" not in record.message
|
|
||||||
assert "testuser" not in record.message
|
|
||||||
assert "pronote.example.com" not in record.message
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_informations_unchanged_in_password_mode(
|
|
||||||
mocker: pytest_mock.MockerFixture,
|
|
||||||
pronote_settings: PronoteSettings,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie que get_informations en mode password reste inchangé (régression).
|
|
||||||
|
|
||||||
Assert que _connect() A ÉTÉ appelé et information_and_surveys() A ÉTÉ appelé
|
|
||||||
(en cas de succès), en conservant les assertions de mappage existantes.
|
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
|
||||||
:param pronote_settings: Paramètres Pronote valides en mode password.
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
mock_client = mocker.MagicMock()
|
|
||||||
mock_info = mocker.MagicMock()
|
|
||||||
mock_info.id = "info-456"
|
|
||||||
mock_info.title = "Important Info"
|
|
||||||
mock_info.content.return_value = "Important content"
|
|
||||||
mock_info.author = "Admin"
|
|
||||||
mock_info.creation_date = datetime(2024, 9, 2, 14, 30, 0)
|
|
||||||
mock_info.read = False
|
|
||||||
mock_info.survey = True
|
|
||||||
mock_client.information_and_surveys.return_value = [mock_info]
|
|
||||||
|
|
||||||
# Patch _connect to return mock_client and track calls
|
|
||||||
connect_patch = mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
|
||||||
|
|
||||||
client = PronoteClient(pronote_settings)
|
|
||||||
messages = client.get_informations()
|
|
||||||
|
|
||||||
# Assert _connect() WAS called and information_and_surveys() WAS called
|
|
||||||
connect_patch.assert_called_once()
|
|
||||||
mock_client.information_and_surveys.assert_called_once()
|
|
||||||
|
|
||||||
# Retain existing mapping assertions
|
|
||||||
assert isinstance(messages, list)
|
|
||||||
assert len(messages) == 1
|
|
||||||
message = messages[0]
|
|
||||||
assert isinstance(message, Message)
|
|
||||||
assert message.id == "info-456"
|
|
||||||
assert message.type == MessageType.SURVEY
|
|
||||||
assert message.title == "Important Info"
|
|
||||||
assert message.content == "Important content"
|
|
||||||
assert message.author == "Admin"
|
|
||||||
assert message.date == datetime(2024, 9, 2, 14, 30, 0)
|
|
||||||
assert message.read is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_connect_password_mode_unchanged(
|
def test_connect_password_mode_unchanged(
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
pronote_settings: PronoteSettings,
|
pronote_settings: PronoteSettings,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ factory de sélection, en vérifiant :
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime, time
|
from datetime import date, datetime, time
|
||||||
from typing import TYPE_CHECKING, Any, Literal
|
from typing import TYPE_CHECKING, Any
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -29,7 +29,6 @@ from pronote_sync.models.diff import AgendaChange, AgendaChangeType, AgendaDiff
|
|||||||
from pronote_sync.models.message import Message, MessageType
|
from pronote_sync.models.message import Message, MessageType
|
||||||
from pronote_sync.models.synthesis import SynthesisInput
|
from pronote_sync.models.synthesis import SynthesisInput
|
||||||
from pronote_sync.synthesis import get_synthesis_provider
|
from pronote_sync.synthesis import get_synthesis_provider
|
||||||
from pronote_sync.synthesis.litellm import LiteLLMSynthesisProvider
|
|
||||||
from pronote_sync.synthesis.openai import OpenAISynthesisProvider
|
from pronote_sync.synthesis.openai import OpenAISynthesisProvider
|
||||||
from pronote_sync.synthesis.provider import SynthesisProvider
|
from pronote_sync.synthesis.provider import SynthesisProvider
|
||||||
|
|
||||||
@@ -873,70 +872,6 @@ def test_openai_compatible_valid_https_url_accepted() -> None:
|
|||||||
assert isinstance(result, OpenAISynthesisProvider)
|
assert isinstance(result, OpenAISynthesisProvider)
|
||||||
|
|
||||||
|
|
||||||
def test_openai_compatible_url_returned_unchanged() -> None:
|
|
||||||
"""Vérifie que l'URL est retournée strictement inchangée, sans manipulation de /v1."""
|
|
||||||
custom_url = "https://api.example.com/custom/path?query=value"
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="openai-compatible",
|
|
||||||
base_url=custom_url,
|
|
||||||
model="test-model",
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert isinstance(result, OpenAISynthesisProvider)
|
|
||||||
# OpenAI SDK appends a trailing slash to base_url, so we check the string representation
|
|
||||||
assert str(result._client.base_url).rstrip("/") == custom_url
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_url_returned_unchanged() -> None:
|
|
||||||
"""Vérifie que l'URL est retournée strictement inchangée pour openai."""
|
|
||||||
custom_url = "https://api.example.com/custom/path?query=value"
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="openai",
|
|
||||||
base_url=custom_url,
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert isinstance(result, OpenAISynthesisProvider)
|
|
||||||
# OpenAI SDK appends a trailing slash to base_url, so we check the string representation
|
|
||||||
assert str(result._client.base_url).rstrip("/") == custom_url
|
|
||||||
|
|
||||||
|
|
||||||
def test_litellm_url_returned_unchanged() -> None:
|
|
||||||
"""Vérifie que l'URL est retournée strictement inchangée pour litellm."""
|
|
||||||
pytest.importorskip("litellm")
|
|
||||||
custom_url = "https://api.example.com/custom/path?query=value"
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="litellm",
|
|
||||||
base_url=custom_url,
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is not None
|
|
||||||
assert isinstance(result, LiteLLMSynthesisProvider)
|
|
||||||
assert result._base_url == custom_url
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_compatible_malformed_port_refused(
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie qu'un port malformé est refusé pour openai-compatible."""
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="openai-compatible",
|
|
||||||
base_url="https://host:bad/v1",
|
|
||||||
model="test-model",
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is None
|
|
||||||
assert "URL invalide" in caplog.text
|
|
||||||
assert "openai-compatible" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_compatible_http_refused_by_default(
|
def test_openai_compatible_http_refused_by_default(
|
||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -952,7 +887,6 @@ def test_openai_compatible_http_refused_by_default(
|
|||||||
result = get_synthesis_provider(settings)
|
result = get_synthesis_provider(settings)
|
||||||
assert result is None
|
assert result is None
|
||||||
assert "URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true" in caplog.text
|
assert "URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true" in caplog.text
|
||||||
assert "openai-compatible" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_compatible_http_accepted_with_allow_insecure_http() -> None:
|
def test_openai_compatible_http_accepted_with_allow_insecure_http() -> None:
|
||||||
@@ -983,7 +917,6 @@ def test_openai_compatible_credentials_in_url_refused(
|
|||||||
result = get_synthesis_provider(settings)
|
result = get_synthesis_provider(settings)
|
||||||
assert result is None
|
assert result is None
|
||||||
assert "Credentials dans l'URL refusés" in caplog.text
|
assert "Credentials dans l'URL refusés" in caplog.text
|
||||||
assert "openai-compatible" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_compatible_sensitive_query_params_refused(
|
def test_openai_compatible_sensitive_query_params_refused(
|
||||||
@@ -1000,24 +933,6 @@ def test_openai_compatible_sensitive_query_params_refused(
|
|||||||
result = get_synthesis_provider(settings)
|
result = get_synthesis_provider(settings)
|
||||||
assert result is None
|
assert result is None
|
||||||
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
|
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
|
||||||
assert "openai-compatible" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_compatible_sensitive_query_params_valueless_refused(
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie que les query params sensibles sans valeur sont refusés pour openai-compatible."""
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="openai-compatible",
|
|
||||||
base_url="https://host/v1?token",
|
|
||||||
model="test-model",
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is None
|
|
||||||
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
|
|
||||||
assert "openai-compatible" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_compatible_connection_error_returns_none(
|
def test_openai_compatible_connection_error_returns_none(
|
||||||
@@ -1076,383 +991,3 @@ def test_openai_compatible_factory_no_network_calls(
|
|||||||
assert isinstance(result, OpenAISynthesisProvider)
|
assert isinstance(result, OpenAISynthesisProvider)
|
||||||
mock_get.assert_not_called()
|
mock_get.assert_not_called()
|
||||||
mock_post.assert_not_called()
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
# --- Tests de validation AI_BASE_URL pour openai et litellm (Issue #17) ---
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_valid_https_base_url_accepted() -> None:
|
|
||||||
"""Vérifie qu'une URL HTTPS valide est acceptée pour openai."""
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="openai",
|
|
||||||
base_url="https://api.openai.com/v1",
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert isinstance(result, OpenAISynthesisProvider)
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_http_refused_by_default(
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie que HTTP est refusé par défaut pour openai."""
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="openai",
|
|
||||||
base_url="http://127.0.0.1:11434/v1",
|
|
||||||
allow_insecure_http=False,
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is None
|
|
||||||
assert "URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true" in caplog.text
|
|
||||||
assert "openai" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_http_accepted_with_allow_insecure_http() -> None:
|
|
||||||
"""Vérifie que HTTP est accepté avec allow_insecure_http=True pour openai."""
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="openai",
|
|
||||||
base_url="http://127.0.0.1:11434/v1",
|
|
||||||
allow_insecure_http=True,
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert isinstance(result, OpenAISynthesisProvider)
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_credentials_in_url_refused(
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie que les credentials dans l'URL sont refusés pour openai."""
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="openai",
|
|
||||||
base_url="https://user:pass@host/v1", # pragma: allowlist secret
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is None
|
|
||||||
assert "Credentials dans l'URL refusés" in caplog.text
|
|
||||||
assert "openai" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_sensitive_query_params_refused(
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie que les query params sensibles sont refusés pour openai."""
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="openai",
|
|
||||||
base_url="https://host/v1?token=secret",
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is None
|
|
||||||
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
|
|
||||||
assert "openai" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_sensitive_query_params_valueless_refused(
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie que les query params sensibles sans valeur sont refusés pour openai."""
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="openai",
|
|
||||||
base_url="https://host/v1?token",
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is None
|
|
||||||
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
|
|
||||||
assert "openai" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_malformed_url_refused(
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie qu'une URL malformée est refusée pour openai."""
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="openai",
|
|
||||||
base_url="not-a-valid-url",
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is None
|
|
||||||
assert "URL invalide" in caplog.text
|
|
||||||
assert "openai" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_no_hostname_url_refused(
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie qu'une URL sans hostname est refusée pour openai."""
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="openai",
|
|
||||||
base_url="https:///v1",
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is None
|
|
||||||
assert "URL sans hostname" in caplog.text
|
|
||||||
assert "openai" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_malformed_port_refused(
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie qu'un port malformé est refusé pour openai."""
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="openai",
|
|
||||||
base_url="https://host:bad/v1",
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is None
|
|
||||||
assert "URL invalide" in caplog.text
|
|
||||||
assert "openai" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_no_network_calls_during_validation(
|
|
||||||
mocker: MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie qu'aucun appel réseau n'est effectué pendant la validation pour openai."""
|
|
||||||
mock_get = mocker.patch("requests.get")
|
|
||||||
mock_post = mocker.patch("requests.post")
|
|
||||||
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="openai",
|
|
||||||
base_url="https://api.example.com/v1",
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert isinstance(result, OpenAISynthesisProvider)
|
|
||||||
mock_get.assert_not_called()
|
|
||||||
mock_post.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_sentinel_key_not_in_logs(
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie qu'une clé sentinelle est absente des logs pour openai."""
|
|
||||||
sentinel = "sk-SENTINEL-OPENAI-BASE-URL-12345"
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr(sentinel),
|
|
||||||
provider="openai",
|
|
||||||
base_url="https://user:pass@host/v1", # pragma: allowlist secret
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is None
|
|
||||||
assert sentinel not in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_litellm_valid_https_base_url_accepted() -> None:
|
|
||||||
"""Vérifie qu'une URL HTTPS valide est acceptée pour litellm."""
|
|
||||||
pytest.importorskip("litellm")
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="litellm",
|
|
||||||
base_url="https://api.litellm.ai/v1",
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_litellm_http_refused_by_default(
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie que HTTP est refusé par défaut pour litellm."""
|
|
||||||
pytest.importorskip("litellm")
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="litellm",
|
|
||||||
base_url="http://127.0.0.1:11434/v1",
|
|
||||||
allow_insecure_http=False,
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is None
|
|
||||||
assert "URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true" in caplog.text
|
|
||||||
assert "litellm" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_litellm_http_accepted_with_allow_insecure_http() -> None:
|
|
||||||
"""Vérifie que HTTP est accepté avec allow_insecure_http=True pour litellm."""
|
|
||||||
pytest.importorskip("litellm")
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="litellm",
|
|
||||||
base_url="http://127.0.0.1:11434/v1",
|
|
||||||
allow_insecure_http=True,
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_litellm_credentials_in_url_refused(
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie que les credentials dans l'URL sont refusés pour litellm."""
|
|
||||||
pytest.importorskip("litellm")
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="litellm",
|
|
||||||
base_url="https://user:pass@host/v1", # pragma: allowlist secret
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is None
|
|
||||||
assert "Credentials dans l'URL refusés" in caplog.text
|
|
||||||
assert "litellm" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_litellm_sensitive_query_params_refused(
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie que les query params sensibles sont refusés pour litellm."""
|
|
||||||
pytest.importorskip("litellm")
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="litellm",
|
|
||||||
base_url="https://host/v1?token=secret",
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is None
|
|
||||||
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
|
|
||||||
assert "litellm" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_litellm_sensitive_query_params_valueless_refused(
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie que les query params sensibles sans valeur sont refusés pour litellm."""
|
|
||||||
pytest.importorskip("litellm")
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="litellm",
|
|
||||||
base_url="https://host/v1?token",
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is None
|
|
||||||
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
|
|
||||||
assert "litellm" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_litellm_malformed_url_refused(
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie qu'une URL malformée est refusée pour litellm."""
|
|
||||||
pytest.importorskip("litellm")
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="litellm",
|
|
||||||
base_url="not-a-valid-url",
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is None
|
|
||||||
assert "URL invalide" in caplog.text
|
|
||||||
assert "litellm" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_litellm_no_hostname_url_refused(
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie qu'une URL sans hostname est refusée pour litellm."""
|
|
||||||
pytest.importorskip("litellm")
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="litellm",
|
|
||||||
base_url="https:///v1",
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is None
|
|
||||||
assert "URL sans hostname" in caplog.text
|
|
||||||
assert "litellm" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_litellm_malformed_port_refused(
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie qu'un port malformé est refusé pour litellm."""
|
|
||||||
pytest.importorskip("litellm")
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="litellm",
|
|
||||||
base_url="https://host:bad/v1",
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is None
|
|
||||||
assert "URL invalide" in caplog.text
|
|
||||||
assert "litellm" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_litellm_no_network_calls_during_validation(
|
|
||||||
mocker: MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie qu'aucun appel réseau n'est effectué pendant la validation pour litellm."""
|
|
||||||
pytest.importorskip("litellm")
|
|
||||||
mock_get = mocker.patch("requests.get")
|
|
||||||
mock_post = mocker.patch("requests.post")
|
|
||||||
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider="litellm",
|
|
||||||
base_url="https://api.example.com/v1",
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is not None
|
|
||||||
mock_get.assert_not_called()
|
|
||||||
mock_post.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_litellm_sentinel_key_not_in_logs(
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie qu'une clé sentinelle est absente des logs pour litellm."""
|
|
||||||
pytest.importorskip("litellm")
|
|
||||||
sentinel = "sk-SENTINEL-LITELLM-BASE-URL-67890"
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr(sentinel),
|
|
||||||
provider="litellm",
|
|
||||||
base_url="https://user:pass@host/v1", # pragma: allowlist secret
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is None
|
|
||||||
assert sentinel not in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("provider", ["openai", "litellm", "openai-compatible"])
|
|
||||||
def test_all_providers_http_refused_same_warning(
|
|
||||||
provider: Literal["openai", "litellm", "openai-compatible"], caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""Vérifie que tous les providers émettent le même message d'avertissement pour HTTP refusé."""
|
|
||||||
if provider == "litellm":
|
|
||||||
pytest.importorskip("litellm")
|
|
||||||
settings = AISettings(
|
|
||||||
enabled=True,
|
|
||||||
api_key=SecretStr("test"),
|
|
||||||
provider=provider,
|
|
||||||
base_url="http://127.0.0.1:11434/v1",
|
|
||||||
allow_insecure_http=False,
|
|
||||||
model="test-model" if provider == "openai-compatible" else None,
|
|
||||||
)
|
|
||||||
result = get_synthesis_provider(settings)
|
|
||||||
assert result is None
|
|
||||||
assert "URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true" in caplog.text
|
|
||||||
|
|||||||
Reference in New Issue
Block a user