Compare commits

..

19 Commits

Author SHA1 Message Date
000416f24e feat(M13): complete test fixtures, shared conftest, and coverage >= 90%
Finalize M13 test and coverage milestone:

tests/fixtures/pronote-6e.ics (new):
- Anonymized iCal fixture for Classe de 6e (3 VEVENTs: SVT lesson with
  homework block, Histoire-Géo modified lesson, all-day school outing)
- Same structure as pronote-4e.ics, no secrets or real data

tests/conftest.py:
- Added sample_message fixture (Message with MessageType.INFORMATION)
- Integrated sample_message into pronote_data fixture (messages=[sample_message])
- Sphinx/reST docstring with :return: and :rtype:

.gitignore:
- Fixed typo: .worktress/ -> .worktrees/ (line 56)

pyproject.toml:
- Added ".worktrees" to ruff extend-exclude to prevent ruff format --check .
  from scanning worktree files

TODO.md:
- Checked M13 items: tests/fixtures/ and tests/conftest.py

Validation: 627 tests pass, coverage 95.67% (threshold 90%), ruff/mypy/
bandit/pre-commit all green.

Co-authored-by: opencode/coder <coder@agents.invalid>
2026-09-08 16:19:27 +02:00
fd9b604849 feat(M12): CLI entry point with dry-run, log-level, redacted error display
Implement the CLI entry point for pronote-sync:

cli/main.py:
- main() entry point with --dry-run (tri-state: None defers to settings,
  True overrides) and --log-level (choices: DEBUG/INFO/WARNING/ERROR/CRITICAL)
- setup_logging called before settings load (to capture config errors),
  then reconfigured with settings.app.log_level
- PipelineRunner.from_settings() as composition root, runner.run()
- Return codes: 0 success, 1 failure, 2 argparse rejection
- _safe_traceback: strips exception messages, replaces with "erreur expurgée",
  walks __cause__/__context__ with cycle protection
- _settings_secrets: collects redaction_secrets() + usernames + JID/recipient
- All error messages redacted via redact_secrets() with configured secrets
- DEBUG-level traceback only shown when DEBUG is enabled

cli/__init__.py:
- Module docstring added (French, Sphinx/reST)

tests/e2e/test_cli.py (8 tests):
- Dry-run and log-level propagation to composition root
- Configured dry-run preserved (tri-state None)
- Success with warnings returns 0
- Pipeline error redaction at DEBUG (sentinel secret)
- Configuration failure redacted traceback at DEBUG
- Pronote username non-disclosure
- Unexpected pipeline exception: redacted traceback at DEBUG, no traceback at INFO
- Argparse rejection of unknown log level (exit code 2)

Coverage: cli/ 94.74%, 627 total tests pass.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-08 15:57:28 +02:00
1019b22808 docs(M11): align GUIDE sections 4.2.1 and 11.3 with FIXME_M11 corrections
GUIDE_DEV_PYTHON.md:
- §11.3: add except PipelineCriticalError: raise before each non-critical
  except in the illustrative PipelineRunner.run() code
- §11.3: replace redact_exception(exc) with self._redact(exc) in all except
  blocks, add explanatory paragraph about _redaction_secrets and _redact()
- §11.3: fix Google-style Returns: to Sphinx/reST :return: and :rtype:
- §11.3: fix malformed Markdown code fence (get_errors/get_warnings orphaned)
- §4.2.1: fix redact_exception() example to pass extra_secrets to
  redact_secrets() in the return statement

TODO.md M11:
- Add and check criterion: PipelineCriticalError from non-blocking step
  stops the pipeline

.secrets.baseline:
- Line numbers updated for documentation shifts

Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
2026-09-08 12:47:42 +02:00
28c695795a fix(M11): propagate PipelineCriticalError, redact configured secrets, signal blog failures
Correct 4 findings from the independent M11 review:

#1 (Critical) — PipelineCriticalError was downgraded to PipelineWarning:
  - Add except PipelineCriticalError: raise before each except Exception
    in all 5 non-blocking steps (fetch_blog, compare, caldav_sync, synthesis, send)
  - Critical errors now propagate to the outer handler and stop the pipeline

#2 (Critical) — redact_exception() did not use configured secrets:
  - Extend redact_exception() with extra_secrets parameter (upward compatible)
  - Harden redact_secrets(): sort extra_secrets by length descending
  - Add Settings.redaction_secrets() collecting all 6 SecretStr fields
  - Add PipelineRunner._redact(exc) using self._redaction_secrets
  - All except blocks in run() now use self._redact(exc)
  - CalDAV FAILED-status path uses full redaction_secrets collection

#3 (Medium) — BlogRSSClient silently swallowed failures:
  - Add error field to BlogRSSFetchResult
  - rss.py sets error on failure paths (except Exception, bozo/invalid feed)
  - fetch_blog_step raises RuntimeError when result.error is set
  - PipelineRunner now produces PipelineWarning for blog failures

#4 (Medium) — Test coverage at 80%, now 91%:
  - 11 new integration tests covering blog failure/success, compare failure,
    CalDAV failure (exception + FAILED status), send False/exception,
    PipelineCriticalError propagation, secret redaction with sentinel,
    empty agenda/homework, iCal cache cleanup
  - Secret redaction test uses mock (no network) and proves configured-secret
    propagation via non-URL sentinel in RuntimeError

Validation: 619 tests pass, ruff/mypy/bandit/pre-commit green, coverage 91%.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-08 12:20:29 +02:00
26b083561a Ignore worktres 2026-09-08 11:50:25 +02:00
d7d31e14ff feat: orchestrer le pipeline M11
Co-authored-by: Codex/gpt-5.6-terra <codex-gpt-5-6-terra@agents.invalid>
2026-09-08 11:28:02 +02:00
be5beb45aa docs(M10): align GUIDE §10 and TODO.md with real slixmpp API and D6 contract
GUIDE_DEV_PYTHON.md §10 corrections:
- Fix XmppSettings defaults (host="", resource="pronote-sync")
- Replace Google-style docstrings with Sphinx/reST in examples
- Document connect(host, port) returning asyncio.Future, remove process() reference
- Document TLS mapping: use_tls=True → direct TLS, use_tls=False → STARTTLS
- Fix factory signature: get_channel(XmppSettings, dry_run) -> Channel | None
- Document Channel.send() -> bool never raises PipelineWarning (D6)
- Fix duplicate §10.3 numbering → §10.3-§10.6
- Remove pronote_messages duplication in M11 example
- Document JID with resource construction
- Document enriched message format (date, change types, times, due date, author)

TODO.md M10:
- Adjust acceptance criterion: channel returns False, pipeline emits PipelineWarning

.secrets.baseline:
- Line numbers updated for documentation shifts

Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
2026-09-08 10:16:44 +02:00
b2106e75ac fix(M10): apply FIXME_M10 corrections (transport, dry_run, format, security)
Fix all 8 findings from the independent review (FIXME_M10.md):

#1 Transport compatible with slixmpp 1.17.0 (D5):
  - Use real ClientXMPP type (remove Any), JID with resource
  - connect(host, port) explicit, no use_tls kwarg
  - enable_direct_tls/enable_starttls configured before connect
  - Single timeout via asyncio.Future for session_start/failed_auth/disconnected
  - Remove premature 'starttls' in features check, remove auto_reconnect
  - try/finally guarantees disconnect on all paths (#4)

#2 Factory dry_run no longer bypassed (D6):
  - Single send() entry point in SyncXmppChannel
  - dry_run check before any ClientXMPP creation
  - Remove XmppChannel.send() dual implementation

#3 Thread daemon removed — single asyncio.run(), documented limitation

#5 Richer message format:
  - Target date header, change type [Ajouté/Supprimé/Modifié]
  - Lesson times, homework due date, message author
  - No pronote_messages duplication (external_info = blog + other_info only)

#6 Error contract unified (D6):
  - Channel.send() -> bool never raises PipelineWarning
  - Errors logged with redaction, returns False
  - PipelineWarning(step='xmpp') will be created by pipeline M11

#7 Tests faithful to slixmpp 1.17.0 API:
  - FakeClientXMPP with real connect(host,port)/disconnect() signatures
  - Assertions on host, port, resource, mtype='chat'
  - No RuntimeWarning from unawaited coroutines

#8 .secrets.baseline restored from main

Coverage: 96.44% on channels/, 600 tests pass, pre-commit all-files green.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-08 02:16:28 +02:00
b61d314b7f test: add M10 integration tests and coverage to 99% (M10-U7)
Integration tests validating the 3 M10 acceptance criteria end-to-end
with mocked slixmpp:
1. XmppChannel.send sends formatted direct message
2. XMPP error → PipelineWarning, no unhandled exception
3. No secret in XMPP logs (sentinel-based verification)

Additional unit tests covering daemon-thread branch of SyncXmppChannel,
deferred failed_auth, session timeout, disconnected handler, and
_format_message edge cases. Channels coverage: 99.18%.

All M10 checklist items marked complete in TODO.md.

Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
Co-authored-by: opencode/coder <coder@agents.invalid>
2026-09-08 00:58:40 +02:00
e07a6d709d feat: implement get_channel factory for XMPP channel (M10-U6)
get_channel(settings, dry_run=False) -> Channel | None with:
- enabled=False → None (no warning, no exception)
- enabled=True + missing jid/password/to/host → redacted warning log, None
- enabled=True + complete config → SyncXmppChannel instance
- Factory never raises exceptions (D2 non-blocking degradation)
- redact_secrets with extra_secrets=[password, jid, to] on warning logs

Re-exports Channel, XmppChannel, SyncXmppChannel from channels package.

19 unit tests covering disabled, misconfigured, complete, dry-run, and
secret-safe warning log scenarios.

Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
Co-authored-by: opencode/coder <coder@agents.invalid>
2026-09-07 23:29:25 +02:00
1962e13eba feat: implement XmppChannel and SyncXmppChannel (M10-U4+U5)
XmppChannel sends direct messages via slixmpp ClientXMPP with:
- _format_message: 5 emoji sections (synthèse, agenda, devoirs, messages, infos)
  with sanitize_plaintext on all content (SEC-XMPP-06)
- send_async: public async method with connect, STARTTLS verification,
  send_message, disconnect lifecycle (D1, SEC-XMPP-04)
- send: sync wrapper via asyncio.run() for direct callers

SyncXmppChannel adapts async XmppChannel for synchronous pipeline use (D4):
- asyncio.run() when no event loop running (nominal pipeline)
- daemon thread with timeout when event loop already running
- Returns False on any error, never raises (non-blocking)

Security:
- auto_reconnect=False, failed_auth → disconnect + PipelineWarning (SEC-XMPP-04)
- __cause__ and __context__ cleared on all PipelineWarning raises (SEC-XMPP-05)
- redact_secrets with extra_secrets=[jid, password, to] on all logs (SEC-XMPP-02)
- STARTTLS features check post-connection, disconnect on failure (D1)
- dry-run mode logs redacted message without connecting

28 unit tests (20 channel + 8 adapter) covering success, errors, security.

Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
Co-authored-by: opencode/coder <coder@agents.invalid>
2026-09-07 23:28:18 +02:00
dcf7f69c5a feat: add sanitize_plaintext for XMPP text sanitization (M10-U3)
Add sanitize_plaintext(text: str) -> str to utils/text.py for preparing
XMPP plain-text message bodies from untrusted Pronote/AI content.

- Strips HTML tags via BeautifulSoup (html.parser)
- Strips C0, DEL, and C1 control characters (preserves \t, \n, \r)
- Preserves Unicode including emojis (📌📅📚💬📢)
- Idempotent: f(f(x)) == f(x)
- Addresses SEC-XMPP-06: XMPP injection hardening

37 unit tests covering HTML, entities, control chars, emojis, idempotence.

Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
Co-authored-by: opencode/coder <coder@agents.invalid>
2026-09-07 21:04:13 +02:00
7d765476de feat: define Channel Protocol for output channels (M10-U2)
Add @runtime_checkable Channel Protocol with send(XmppMessage) -> bool
as the structural contract for all output channels (XMPP, future CalDAV, etc).

6 unit tests covering protocol structure, conforming/non-conforming classes,
method signature introspection, and bool return type.

Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
Co-authored-by: opencode/coder <coder@agents.invalid>
2026-09-07 21:04:01 +02:00
68a5d96c2a feat: add TLS policy and field constraints to XmppSettings (M10-U1)
Enforce TLS on non-loopback hosts via @field_validator on use_tls,
and add unconditional Field constraints on port (1-65535) and timeout (>0).

Security:
- use_tls=False rejected outside {localhost, 127.0.0.1, ::1} regardless of enabled
- field_validator on use_tls (not model_validator) prevents raw config leakage
- hide_input_in_errors=True as defense-in-depth
- Validation error messages contain no secrets (jid, password, recipient)

16 unit tests covering port/timeout bounds, TLS policy, loopback, secret safety.

Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
Co-authored-by: opencode/coder <coder@agents.invalid>
2026-09-07 21:01:48 +02:00
a5a8183663 feat: add PipelineWarning for non-blocking pipeline errors (M10-U0)
Add PipelineWarning(PronoteSyncError) to the canonical error hierarchy.
This non-blocking warning type is used by the XMPP channel (and future
channels) to signal recoverable failures without breaking the pipeline.

- PipelineWarning inherits from PronoteSyncError, not Warning builtin
- Constructor: (message, step=None) with recoverable=True
- 8 unit tests covering inheritance, raising, catching, attributes

Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
Co-authored-by: opencode/coder <coder@agents.invalid>
2026-09-07 21:01:36 +02:00
58c7fa147f merge: provider openai-compatible pour la synthèse IA (FEAT_M9) 2026-09-07 20:00:02 +02:00
6b9ab75977 docs: document openai-compatible provider and FIXME_M9 corrections
Update GUIDE_DEV_PYTHON.md, TODO.md, and AGENTS.md to reflect the
decisions and work done in the FEAT_M9 and FIXME_M9 sessions.

GUIDE_DEV_PYTHON.md:
- Header: add entry in recent updates
- 3.1.2: AI_PROVIDER now documents openai-compatible with
  Literal type; add AI_ALLOW_INSECURE_HTTP row; move decision
  block after table to fix rendering
- 3.2: AISettings code block updated with openai-compatible and
  allow_insecure_http field; decision note extended
- 3.1.3: .env.example adds OpenRouter (HTTPS) and Ollama (HTTP)
  examples, both commented

TODO.md:
- M9 factory line now mentions openai-compatible with URL validation
- Add FEAT_M9 and FIXME_M9 notes after M9 acceptance criteria

AGENTS.md:
- Section 5: new subsection for openai-compatible provider contract
  documenting validation rules, degraded mode, and security constraints

Co-authored-by: opencode/tech-writer anthropic.claude-sonnet-4-5 <anthropic.claude-sonnet-4-5@agents.invalid>
2026-09-07 19:59:13 +02:00
13e058f22c feat: add openai-compatible provider for custom AI endpoints
Add AI_PROVIDER=openai-compatible mode that reuses OpenAISynthesisProvider
with a validated custom base_url, allowing any OpenAI-compatible API
(OpenRouter, Ollama, LiteLLM proxy, etc.) without new code.

Configuration:
- AISettings.provider now accepts openai-compatible
- New AISettings.allow_insecure_http: bool = False (HTTP opt-in)
- .env.example: commented examples for OpenRouter (HTTPS) and Ollama (HTTP)

Factory validation (_validate_openai_compatible_config):
- base_url and model required, api_key required (MVP)
- HTTPS enforced unless allow_insecure_http=true
- Credentials in URL rejected, sensitive query params rejected
  (including valueless params via keep_blank_values=True)
- Malformed URLs and missing hostname rejected (ValueError caught)
- No /v1 manipulation; degraded to None + warning on invalid config
- redact_url() used for all URL warnings

Tests: 13 new factory tests in test_synthesis.py covering routing,
URL validation, HTTP policy, credentials, sentinel non-leak, no-network.
Coverage: 91.57% (synthesis module).

Docs: GUIDE_DEV_PYTHON.md §9.5 updated with 3-provider table, validation
rules, and synchronized code example.

mypy override for openai.* (follow_imports=skip) to work around
mypy 2.3.1 internal error in pre-commit's isolated environment.

Co-authored-by: opencode/coder anthropic.claude-sonnet-4-5 <anthropic.claude-sonnet-4-5@agents.invalid>
Co-authored-by: opencode/test-engineer anthropic.claude-sonnet-4-5 <anthropic.claude-sonnet-4-5@agents.invalid>
Co-authored-by: opencode/tech-writer anthropic.claude-sonnet-4-5 <anthropic.claude-sonnet-4-5@agents.invalid>
2026-09-07 19:44:40 +02:00
2a27225fa0 merge: corrections d'audit FIXME_M9 dans la synthèse IA
Correctifs FIXME_M9 : redact_secrets étendue (extra_secrets), clés en
SecretStr, contenu des messages dans le prompt, validation de sortie
(emoji/titre/liste/HTML), tests litellm robustes (importorskip), .env.example
désactivé, documentation §9.2-§9.5 alignée.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
2026-09-07 19:02:55 +02:00
44 changed files with 6317 additions and 377 deletions

View File

@@ -49,6 +49,20 @@ 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
# Exemple : OpenRouter (HTTPS)
# AI_PROVIDER=openai-compatible
# AI_BASE_URL=https://openrouter.ai/api/v1
# AI_MODEL=fournisseur/modele
# AI_API_KEY=your-openrouter-key
# AI_ALLOW_INSECURE_HTTP=false
# Exemple : Ollama local (HTTP, sans authentification réelle)
# AI_PROVIDER=openai-compatible
# AI_BASE_URL=http://127.0.0.1:11434/v1
# AI_MODEL=modele-local
# AI_API_KEY=local-not-required
# AI_ALLOW_INSECURE_HTTP=true
# --- Blog --- # --- Blog ---
BLOG_ENABLED=false BLOG_ENABLED=false
BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2 BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2

1
.gitignore vendored
View File

@@ -53,6 +53,7 @@ Thumbs.db
# --- Local scratch / WIP files --- # --- Local scratch / WIP files ---
FIXME_* FIXME_*
TEST_* TEST_*
.worktrees/
# --- Logs --- # --- Logs ---
*.log *.log

View File

@@ -140,7 +140,7 @@
"filename": "GUIDE_DEV_PYTHON.md", "filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": true, "is_verified": true,
"line_number": 4852, "line_number": 5064,
"is_secret": false "is_secret": false
} }
], ],
@@ -177,5 +177,5 @@
} }
] ]
}, },
"generated_at": "2026-09-07T17:01:01Z" "generated_at": "2026-09-08T10:45:46Z"
} }

View File

@@ -146,6 +146,17 @@ pronote-sync --dry-run
- Réutiliser un téléchargement/parsing iCal pour l'agenda et les devoirs pendant un même run, sans - Réutiliser un téléchargement/parsing iCal pour l'agenda et les devoirs pendant un même run, sans
cache global ni persistant. cache global ni persistant.
### Contrat du provider `openai-compatible`
- Le provider `openai-compatible` réutilise `OpenAISynthesisProvider` avec un `base_url` personnalisé ; aucun nouveau provider n'est créé.
- `AI_BASE_URL` et `AI_MODEL` sont requis ; `AI_API_KEY` est requis (MVP).
- L'URL doit utiliser `https` sauf si `AI_ALLOW_INSECURE_HTTP=true`.
- Les credentials dans l'URL (`user:pass@host`) sont refusés.
- Les paramètres sensibles dans la *query string* sont refusés, y compris ceux sans valeur (`?token`).
- Les URL malformées ou sans hostname sont rejetées (`ValueError` catché).
- Aucune manipulation automatique de `/v1` n'est effectuée.
- Configuration incomplète ou invalide → `None` avec avertissement (mode dégradé) ; la factory ne lève jamais d'exception.
- La factory ne fait aucun appel réseau ; les avertissements utilisent `redact_url()`.
### Documentation (docstrings) ### Documentation (docstrings)
- **Obligatoire** : **Toute** fonction, méthode et classe publique doit avoir une docstring. - **Obligatoire** : **Toute** fonction, méthode et classe publique doit avoir une docstring.
- **Format** : Utiliser le format **Sphinx/reST** (pas Google ou NumPy) pour une compatibilité native avec Sphinx. - **Format** : Utiliser le format **Sphinx/reST** (pas Google ou NumPy) pour une compatibilité native avec Sphinx.

File diff suppressed because it is too large Load Diff

62
TODO.md
View File

@@ -176,7 +176,7 @@ Générer une synthèse optionnelle via un fournisseur IA, avec mode dégradé s
- [x] Créer `synthesis/provider.py` : protocole `SynthesisProvider.generate → Optional[SynthesisResult]` (ne lève jamais d'exception). - [x] Créer `synthesis/provider.py` : protocole `SynthesisProvider.generate → Optional[SynthesisResult]` (ne lève jamais d'exception).
- [x] Créer `synthesis/openai.py` : `OpenAISynthesisProvider` (httpx, prompt système FR, max 800 car., timeout 30 s, temp 0.3). - [x] Créer `synthesis/openai.py` : `OpenAISynthesisProvider` (httpx, prompt système FR, max 800 car., timeout 30 s, temp 0.3).
- [x] Créer `synthesis/litellm.py` : `LiteLLMSynthesisProvider` (optionnel, extra `ai-litellm`). - [x] Créer `synthesis/litellm.py` : `LiteLLMSynthesisProvider` (optionnel, extra `ai-litellm`).
- [x] Créer `synthesis/__init__.py` : factory `get_synthesis_provider(settings)` (OpenAI par défaut, litellm si `AI_PROVIDER=litellm`). - [x] Créer `synthesis/__init__.py` : factory `get_synthesis_provider(settings)` (OpenAI par défaut, litellm si `AI_PROVIDER=litellm`, `openai-compatible` si `AI_PROVIDER=openai-compatible` avec validation d'URL).
- [x] Mode dégradé : clé absente / timeout / exception → retour `None` (le pipeline continue sans synthèse). - [x] Mode dégradé : clé absente / timeout / exception → retour `None` (le pipeline continue sans synthèse).
- [x] Respecter les contraintes (3-5 phrases, ton sobre, pas d'emoji dans le texte IA). - [x] Respecter les contraintes (3-5 phrases, ton sobre, pas d'emoji dans le texte IA).
@@ -185,21 +185,32 @@ Générer une synthèse optionnelle via un fournisseur IA, avec mode dégradé s
- Clé absente ou erreur réseau → `None` (aucune exception propagée). - Clé absente ou erreur réseau → `None` (aucune exception propagée).
- La factory renvoie le bon provider ; litellm derrière l'extra optionnel. - La factory renvoie le bon provider ; litellm derrière l'extra optionnel.
> **Évolution FEAT_M9 — Provider `openai-compatible`** :
> Le provider `openai-compatible` a été ajouté à `get_synthesis_provider` (commit `13e058f` sur `feat/m9-custom-endpoint`).
> Il réutilise `OpenAISynthesisProvider` avec un `base_url` validé (HTTPS obligatoire, HTTP via `AI_ALLOW_INSECURE_HTTP=true`).
> Configuration incomplète → `None` + warning (mode dégradé). Aucun appel réseau à la factory.
> Couverture synthesis : 91,57 % (13 tests factory ajoutés).
>
> **Corrections FIXME_M9 — Audit synthèse IA** :
> Cinq points d'audit corrigés (commit `19cbf8f` sur `fix/m9-fixme`, mergé en `2a27225`) :
> `redact_secrets(extra_secrets=...)`, `SecretStr` préservé dans les providers, contenu du message dans `_build_prompt`,
> `_validate_output` (rejet emoji/titre/liste/HTML), `importorskip` pour les tests litellm.
--- ---
## M10. Canal XMPP — Priorité : Haute ## M10. Canal XMPP — Priorité : Haute
Construire et envoyer le message XMPP structuré via un compte bot dédié (message direct, pas de PubSub). Construire et envoyer le message XMPP structuré via un compte bot dédié (message direct, pas de PubSub).
- [ ] Créer `channels/protocol.py` : protocole `Channel` (méthode d'envoi). - [x] Créer `channels/protocol.py` : protocole `Channel` (méthode d'envoi).
- [ ] Créer `channels/xmpp.py` : `XmppChannel` (slixmpp, message direct, compte bot dédié). - [x] Créer `channels/xmpp.py` : `XmppChannel` (slixmpp, message direct, compte bot dédié).
- [ ] Implémenter `_format_message(XmppMessage)` : synthèse + liste brute des devoirs + changements + messages + infos blog (emojis 📌📅📚💬 autorisés). - [x] Implémenter `_format_message(XmppMessage)` : synthèse + liste brute des devoirs + changements + messages + infos blog (emojis 📌📅📚💬 autorisés).
- [ ] Gérer les erreurs XMPP (reconnexion, timeout) avec masquage des secrets, non bloquant (`PipelineWarning`). - [x] Gérer les erreurs XMPP (reconnexion, timeout) avec masquage des secrets, non bloquant (`PipelineWarning`).
- [ ] Créer `channels/__init__.py` : factory de canaux. - [x] Créer `channels/__init__.py` : factory de canaux.
### Critères d'acceptation ### Critères d'acceptation
- `XmppChannel.send` envoie un message direct formaté (slixmpp mocké en test). - `XmppChannel.send` envoie un message direct formaté (slixmpp mocké en test).
- Erreur XMPP → `PipelineWarning`, jamais d'exception non gérée. - Erreur XMPP → `False` retourné par le canal, le pipeline émet un `PipelineWarning` (jamais d'exception non gérée).
- Aucun secret dans les logs XMPP. - Aucun secret dans les logs XMPP.
--- ---
@@ -208,20 +219,21 @@ Construire et envoyer le message XMPP structuré via un compte bot dédié (mess
Composer et orchestrer toutes les étapes avec gestion d'erreurs dégradée et mode dry-run. Composer et orchestrer toutes les étapes avec gestion d'erreurs dégradée et mode dry-run.
- [ ] Compléter si nécessaire la hiérarchie canonique dans `pronote_sync/errors.py` (`ErrorSeverity`, `PipelineError`, `PipelineWarning`, `PipelineCriticalError`) ; ne pas créer de doublon dans `pipeline/steps/errors.py`. - [x] Compléter si nécessaire la hiérarchie canonique dans `pronote_sync/errors.py` (`ErrorSeverity`, `PipelineError`, `PipelineWarning`, `PipelineCriticalError`) ; ne pas créer de doublon dans `pipeline/steps/errors.py`.
- [ ] Créer les étapes `pipeline/steps/` : `fetch.py`, `normalize.py`, `compare.py`, `caldav_sync.py`, `synthesis.py`, `send.py`, `fetch_blog.py`. - [x] Créer les étapes `pipeline/steps/` : `fetch.py`, `normalize.py`, `compare.py`, `caldav_sync.py`, `synthesis.py`, `send.py`, `fetch_blog.py`.
- [ ] Créer `pipeline/run.py` : `PipelineRunner` (composition root) orchestrant fetch → normalize → fetch_blog → compare → caldav_sync → synthesis → send. - [x] Créer `pipeline/run.py` : `PipelineRunner` (composition root) orchestrant fetch → normalize → fetch_blog → compare → caldav_sync → synthesis → send.
- [ ] Gérer les erreurs dégradées (continuer sauf critique) et renvoyer `(PronoteData, erreurs + warns)`. - [x] Gérer les erreurs dégradées (continuer sauf critique) et renvoyer `(PronoteData, erreurs + warns)`.
- [ ] Implémenter le mode `dry_run` (aucune écriture CalDAV/XMPP). - [x] Implémenter le mode `dry_run` (aucune écriture CalDAV/XMPP).
- [ ] Câbler l'injection des dépendances (Protocol + composition root), sans singleton global. - [x] Câbler l'injection des dépendances (Protocol + composition root), sans singleton global.
- [ ] Réutiliser, dans une même exécution, un unique téléchargement/parsing iCal pour l'agenda et les devoirs lorsque les sources sélectionnées le permettent ; rester sur un cache local au run, sans cache global ni persistant. - [x] Réutiliser, dans une même exécution, un unique téléchargement/parsing iCal pour l'agenda et les devoirs lorsque les sources sélectionnées le permettent ; rester sur un cache local au run, sans cache global ni persistant.
### Critères d'acceptation ### Critères d'acceptation
- Le pipeline complet s'exécute de bout en bout (mocks) dans le bon ordre. - [x] Le pipeline complet s'exécute de bout en bout (mocks) dans le bon ordre.
- Une sélection iCal commune à l'agenda et aux devoirs ne déclenche qu'un téléchargement/parsing du flux par run. - [x] Une sélection iCal commune à l'agenda et aux devoirs ne déclenche qu'un téléchargement/parsing du flux par run.
- Une erreur non critique (ex : synthèse IA) n'empêche pas l'envoi XMPP. - [x] Une erreur non critique (ex : synthèse IA) n'empêche pas l'envoi XMPP.
- `dry_run=True` n'effectue aucune écriture ; aucune source disponible → erreur critique explicite. - [x] `dry_run=True` n'effectue aucune écriture ; aucune source disponible → erreur critique explicite.
- Si `THEORETICAL_AGENDA_PATH` est absent, le pipeline produit un diff vide sans erreur et n'instancie pas `AgendaComparator` ; si présent, il instancie le comparateur et effectue la comparaison. - [x] Si `THEORETICAL_AGENDA_PATH` est absent, le pipeline produit un diff vide sans erreur et n'instancie pas `AgendaComparator` ; si présent, il instancie le comparateur et effectue la comparaison.
- [x] Les erreurs critiques (`PipelineCriticalError`) propagées depuis une étape non-bloquante arrêtent le pipeline.
--- ---
@@ -229,10 +241,10 @@ Composer et orchestrer toutes les étapes avec gestion d'erreurs dégradée et m
Exposer le lancement du pipeline via une interface en ligne de commande. Exposer le lancement du pipeline via une interface en ligne de commande.
- [ ] Créer `cli/main.py` : `main()` (point d'entrée `pronote-sync`), args `--dry-run`, `--log-level`. - [x] Créer `cli/main.py` : `main()` (point d'entrée `pronote-sync`), args `--dry-run`, `--log-level`.
- [ ] Initialiser les logs (`setup_logging`) et charger `settings` au démarrage. - [x] Initialiser les logs (`setup_logging`) et charger `settings` au démarrage.
- [ ] Construire la composition root et lancer `PipelineRunner.run()`. - [x] Construire la composition root et lancer `PipelineRunner.run()`.
- [ ] Gérer le code de retour et l'affichage des erreurs (redactées). - [x] Gérer le code de retour et l'affichage des erreurs (redactées).
### Critères d'acceptation ### Critères d'acceptation
- `pronote-sync --dry-run --log-level DEBUG` s'exécute sans effet de bord. - `pronote-sync --dry-run --log-level DEBUG` s'exécute sans effet de bord.
@@ -245,8 +257,8 @@ Exposer le lancement du pipeline via une interface en ligne de commande.
Couvrir l'ensemble du code par des tests sans réseau, avec fixtures anonymisées, jusqu'à ≥ 90 %. Couvrir l'ensemble du code par des tests sans réseau, avec fixtures anonymisées, jusqu'à ≥ 90 %.
- [ ] Créer `tests/fixtures/` : `pronote-4e.ics`, `pronote-6e.ics`, `theoretical.json`, `school_holidays.json`, `blog_rss.xml` (anonymisés, sans `icalsecurise`). - [x] Créer `tests/fixtures/` : `pronote-4e.ics`, `pronote-6e.ics`, `theoretical.json`, `school_holidays.json`, `blog_rss.xml` (anonymisés, sans `icalsecurise`).
- [ ] Créer `tests/conftest.py` : fixtures partagées (sample_lesson, sample_cancelled_lesson, sample_homework, sample_school_event, sample_message, sample_pronote_data…). - [x] Créer `tests/conftest.py` : fixtures partagées (sample_lesson, sample_cancelled_lesson, sample_homework, sample_school_event, sample_message, sample_pronote_data…).
- [x] Écrire `tests/unit/` : `test_models`, `test_parsing` (iCal), `test_uid`, `test_redaction`, `test_diff`, `test_sync`. - [x] Écrire `tests/unit/` : `test_models`, `test_parsing` (iCal), `test_uid`, `test_redaction`, `test_diff`, `test_sync`.
- [x] Couvrir les régressions M4 : signature réelle de `ParentClient`, ENT autorisé/inconnu, erreur vs résultat vide, `STATUS:CANCELLED` sans catégorie, plusieurs devoirs à la même date, filtrage `pronotepy` sur la date cible et stabilité d'identité entre sources. - [x] Couvrir les régressions M4 : signature réelle de `ParentClient`, ENT autorisé/inconnu, erreur vs résultat vide, `STATUS:CANCELLED` sans catégorie, plusieurs devoirs à la même date, filtrage `pronotepy` sur la date cible et stabilité d'identité entre sources.
- [x] Écrire `tests/integration/` : `test_pipeline`, `test_caldav` (mocké), `test_xmpp` (mocké). - [x] Écrire `tests/integration/` : `test_pipeline`, `test_caldav` (mocké), `test_xmpp` (mocké).

View File

@@ -0,0 +1,81 @@
"""Fabrique de création des canaux de sortie du pipeline ``pronote-sync``.
Ce module expose la fonction :func:`get_channel` qui instancie le canal de
sortie XMPP à partir de sa configuration, ainsi que les types publics du
paquet ``pronote_sync.channels`` :
:class:`~pronote_sync.channels.protocol.Channel`,
:class:`~pronote_sync.channels.xmpp.XmppChannel` et
:class:`~pronote_sync.channels.xmpp.SyncXmppChannel`.
"""
from __future__ import annotations
import logging
from pronote_sync.channels.protocol import Channel
from pronote_sync.channels.xmpp import SyncXmppChannel, XmppChannel
from pronote_sync.config.settings import XmppSettings
from pronote_sync.utils.redaction import redact_secrets
logger = logging.getLogger(__name__)
__all__ = ["Channel", "XmppChannel", "SyncXmppChannel", "get_channel"]
def get_channel(settings: XmppSettings, dry_run: bool = False) -> Channel | None:
"""Instancie le canal de sortie XMPP selon la configuration (D2).
Si le canal est désactivé (``enabled`` à ``False``), la fabrique
retourne ``None`` sans avertissement ni exception. Si le canal est
activé mais que l'un des champs requis (``jid``, ``password``, ``to``,
``host``) est vide ou absent, un avertissement est journalisé puis
``None`` est retourné. Dans tous les autres cas, une instance de
:class:`~pronote_sync.channels.xmpp.SyncXmppChannel` est construite et
retournée.
L'avertissement est expurgé des valeurs sensibles (``jid``, mot de
passe, destinataire) via :func:`pronote_sync.utils.redaction.redact_secrets`
(SEC-XMPP-02) : le message journalisé ne contient jamais ces valeurs en
clair. La fabrique ne lève jamais d'exception (dégradation non bloquante).
:param settings: Paramètres de configuration du canal XMPP.
:param dry_run: Si ``True``, le canal est créé en mode simulation
(aucun envoi réseau lors de l'appel à ``send``).
:return: Canal de sortie prêt à l'emploi, ou ``None`` si le canal est
désactivé ou mal configuré.
:rtype: Channel | None
"""
if not settings.enabled:
return None
# SEC-XMPP-02 : valeurs sensibles à masquer dans le journal (les valeurs
# ``None`` sont ignorées).
extra_secrets = [
secret for secret in (settings.password, settings.jid, settings.to) if secret is not None
]
# SEC-XMPP-02 : rejeter aussi les chaînes vides ou composées uniquement
# d'espaces : ``bool(SecretStr)`` et ``bool(str)`` ne testent que la
# présence de l'objet, pas la valeur contenue.
missing_fields = [
name
for name, present in (
("jid", settings.jid is not None and bool(settings.jid.strip())),
(
"password",
settings.password is not None
and bool(settings.password.get_secret_value().strip()),
),
("to", settings.to is not None and bool(settings.to.strip())),
("host", bool(settings.host.strip())),
)
if not present
]
if missing_fields:
logger.warning(
"XMPP : configuration incomplète (champs manquants : %s), canal désactivé.",
redact_secrets(", ".join(missing_fields), extra_secrets=extra_secrets),
)
return None
return SyncXmppChannel(settings, dry_run=dry_run)

View File

@@ -0,0 +1,33 @@
"""Protocole abstrait définissant le contrat des canaux de sortie."""
from __future__ import annotations
from typing import Protocol, runtime_checkable
from pronote_sync.models.xmpp import XmppMessage
@runtime_checkable
class Channel(Protocol):
"""Contrat structurel d'un canal de sortie du pipeline.
Un canal de sortie reçoit un message final :class:`XmppMessage` et tente de
l'envoyer vers la destination qu'il représente (CalDAV, XMPP, etc.).
:ivar send: Envoie un message sur le canal.
"""
def send(self, message: XmppMessage) -> bool:
"""Envoie un message sur le canal.
Un canal ne lève jamais :pyexc:`PipelineWarning` ; en cas d'échec, il
retourne ``False``. Le :pyexc:`PipelineWarning` est créé par l'étape
pipeline, pas par le canal. Une :pyexc:`PipelineCriticalError` peut
en revanche être levée en cas de panne critique (ex. : chemin
CalDAV, non utilisé par le canal XMPP).
:param message: Message final à transmettre.
:return: ``True`` si l'envoi a réussi, ``False`` sinon.
:rtype: bool
"""
...

View File

@@ -0,0 +1,369 @@
"""Canal de sortie XMPP du pipeline ``pronote-sync``.
Ce module implémente le canal d'envoi de notifications XMPP : la classe
:class:`XmppChannel` envoie un message direct via ``slixmpp``
(:meth:`XmppChannel.send_async`), tandis que :class:`SyncXmppChannel`
fournit le point d'entrée synchrone unique utilisé par le pipeline. Le corps
du message est formaté en texte brut par ``_format_message`` (en-tête de date
cible puis sections emoji 📌📅📚💬📢) et chaque texte est assaini par
:func:`pronote_sync.utils.text.sanitize_plaintext` (SEC-XMPP-06).
Contrat d'erreur (D6) : le canal ne lève jamais :pyexc:`PipelineWarning` ;
en cas d'échec, il journalise la version expurgée de l'erreur et retourne
``False``. Le :pyexc:`PipelineWarning` est créé par l'étape pipeline, pas par
le canal.
"""
from __future__ import annotations
import asyncio
import logging
from pydantic import SecretStr
from slixmpp import JID, ClientXMPP
from pronote_sync.config.settings import XmppSettings
from pronote_sync.models.blog import ExternalInfo
from pronote_sync.models.diff import AgendaChange, AgendaChangeType
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message
from pronote_sync.models.xmpp import XmppMessage
from pronote_sync.utils.redaction import redact_exception, redact_secrets
from pronote_sync.utils.text import sanitize_plaintext
logger = logging.getLogger(__name__)
__all__ = ["XmppChannel", "SyncXmppChannel", "XmppMessage"]
def _secret_values(settings: XmppSettings) -> tuple[SecretStr | str, ...]:
"""Rassemble les secrets du canal XMPP pour le masquage des logs.
:param settings: Paramètres du canal XMPP.
:return: Valeurs sensibles (mot de passe, JID du bot, destinataire).
:rtype: tuple[SecretStr | str, ...]
"""
secrets: list[SecretStr | str] = []
if settings.jid is not None:
secrets.append(settings.jid)
if settings.password is not None:
secrets.append(settings.password)
if settings.to is not None:
secrets.append(settings.to)
return tuple(secrets)
def _format_synthesis(synthesis: str | None) -> str:
"""Formate la section synthèse du message XMPP.
:param synthesis: Texte de synthèse, ou ``None`` si absente.
:return: Section ``📌 Synthèse`` suivie de la synthèse (ou du texte par
défaut si aucune n'est disponible).
:rtype: str
"""
content = synthesis if synthesis else "Aucune synthèse disponible."
return f"📌 Synthèse\n{sanitize_plaintext(content)}"
def _format_changes(changes: tuple[AgendaChange, ...]) -> str:
"""Formate la section des changements d'agenda du message XMPP.
Distingue les ajouts, suppressions et modifications (U4). Pour un ajout,
les horaires du cours (``HH:MM-HH:MM``) sont inclus si le cours est
disponible.
:param changes: Liste des changements d'agenda.
:return: Section ``📅 Changements d'agenda`` avec une ligne par
changement (type, matière et détails).
:rtype: str
"""
if not changes:
body = "Aucun changement."
else:
lines: list[str] = []
for change in changes:
subject = ""
if change.lesson is not None:
subject = change.lesson.subject
elif change.theoretical_lesson is not None:
subject = change.theoretical_lesson.subject
if change.type == AgendaChangeType.ADDED and change.lesson is not None:
times = (
f"{change.lesson.start.strftime('%H:%M')}-{change.lesson.end.strftime('%H:%M')}"
)
lines.append(f"• [Ajouté] {subject}: {change.details} ({times})")
elif change.type == AgendaChangeType.REMOVED:
lines.append(f"• [Supprimé] {subject}: {change.details}")
else:
lines.append(f"• [Modifié] {subject}: {change.details}")
body = "\n".join(lines)
return f"📅 Changements d'agenda\n{sanitize_plaintext(body)}"
def _format_homeworks(homeworks: tuple[Homework, ...]) -> str:
"""Formate la section des devoirs du message XMPP.
:param homeworks: Liste des devoirs.
:return: Section ``📚 Devoirs`` avec une ligne par devoir (matière,
texte et date d'échéance).
:rtype: str
"""
if not homeworks:
body = "Aucun devoir."
else:
lines = [
f"{homework.subject}: {homework.text} "
f"(à rendre le {homework.due_on.strftime('%d/%m')})"
for homework in homeworks
]
body = "\n".join(lines)
return f"📚 Devoirs\n{sanitize_plaintext(body)}"
def _format_messages(messages: tuple[Message, ...]) -> str:
"""Formate la section des messages Pronote du message XMPP.
:param messages: Liste des messages/informations.
:return: Section ``💬 Messages`` avec une ligne par message (titre,
auteur et contenu) ; sans titre, seul l'auteur est affiché.
:rtype: str
"""
if not messages:
body = "Aucun message."
else:
lines: list[str] = []
for message in messages:
if message.title:
lines.append(f"{message.title} ({message.author}): {message.content}")
else:
lines.append(f"{message.author}: {message.content}")
body = "\n".join(lines)
return f"💬 Messages\n{sanitize_plaintext(body)}"
def _format_external_info(external_info: ExternalInfo | None) -> str:
"""Formate la section des informations diverses du message XMPP.
Regroupe uniquement les articles du blog et les autres informations
(``other_info``) : les messages Pronote (``pronote_messages``) sont
exclus car ils sont déjà transmis par la section des messages.
:param external_info: Informations externes agrégées, ou ``None``.
:return: Section ``📢 Informations diverses`` avec une ligne par élément.
:rtype: str
"""
if external_info is None:
body = "Aucune information."
else:
lines: list[str] = []
for article in external_info.blog_articles:
lines.append(f"{article.title}: {article.content_text}")
for info in external_info.other_info:
lines.append(f"{info}")
body = "\n".join(lines) if lines else "Aucune information."
return f"📢 Informations diverses\n{sanitize_plaintext(body)}"
class XmppChannel:
"""Canal d'envoi de messages XMPP via un compte bot dédié.
Envoie un message direct (``type="chat"``) au destinataire configuré en
utilisant :class:`slixmpp.ClientXMPP`. La connexion est établie à chaque
appel de :meth:`send_async` ; le constructeur n'effectue aucun accès
réseau.
Contrat d'erreur (D6) : :meth:`send_async` ne lève jamais
:pyexc:`PipelineWarning` ; en cas d'échec, elle journalise la version
expurgée de l'erreur et retourne ``False``. En mode ``dry_run``, aucun
client n'est créé.
:ivar settings: Paramètres XMPP (JID, mot de passe, destinataire, TLS).
:vartype settings: XmppSettings
:ivar dry_run: En mode ``dry_run``, aucun envoi n'est effectué.
:vartype dry_run: bool
"""
def __init__(self, settings: XmppSettings, dry_run: bool = False) -> None:
"""Initialise le canal XMPP sans connexion réseau.
:param settings: Paramètres de configuration du canal XMPP.
:param dry_run: Si ``True``, :meth:`send_async` journalise le message
formaté et retourne ``True`` sans se connecter.
"""
self.settings = settings
self.dry_run = dry_run
def _format_message(self, message: XmppMessage) -> str:
"""Formate un message XMPP en texte brut avec des sections emoji.
Produit le corps du message : un en-tête avec la date cible du
digest, puis les sections synthèse, changements d'agenda, devoirs,
messages et informations diverses. Chaque texte est assaini par
:func:`pronote_sync.utils.text.sanitize_plaintext` avant insertion
(SEC-XMPP-06).
:param message: Message final à formater.
:return: Corps du message en texte brut, prêt pour l'envoi.
:rtype: str
"""
sections = [
f"Digest du {message.target_date.strftime('%d/%m/%Y')}",
_format_synthesis(message.synthesis),
_format_changes(message.changes),
_format_homeworks(message.homeworks),
_format_messages(message.messages),
_format_external_info(message.external_info),
]
return "\n\n".join(sections)
async def send_async(self, message: XmppMessage) -> bool:
"""Exécute le flux asynchrone d'envoi XMPP (U2).
Connecte le client ``slixmpp`` avec un hôte et un port explicites,
configure TLS avant la connexion, puis attend l'un des événements
``session_start``, ``failed_auth`` ou ``disconnected`` sous un
timeout unique avant d'envoyer un message direct ``chat`` au
destinataire configuré. La déconnexion est garantie par un bloc
``try/finally``. Aucun secret n'est journalisé (SEC-XMPP-02).
:param message: Message final à envoyer.
:return: ``True`` si l'envoi a réussi (ou a été simulé en dry-run),
``False`` sinon (destinataire manquant, timeout, échec
d'authentification, déconnexion ou erreur réseau).
:rtype: bool
"""
if self.dry_run:
formatted = self._format_message(message)
logger.info("XMPP dry-run: message would be sent")
return True
# Build JID with resource
jid_str = f"{self.settings.jid}/{self.settings.resource}"
recipient = JID(self.settings.to) if self.settings.to else None
if recipient is None:
logger.warning("Destinataire XMPP manquant.")
return False
# Create typed client
client = ClientXMPP(
jid_str,
self.settings.password.get_secret_value() if self.settings.password else "",
)
# Configure TLS BEFORE connect
if self.settings.use_tls:
# TLS direct (port 5223 typically)
client.enable_direct_tls = True
client.enable_starttls = False
else:
# STARTTLS (port 5222 typically)
client.enable_starttls = True
client.enable_direct_tls = False
# Register handlers
session_future: asyncio.Future[bool] = asyncio.get_event_loop().create_future()
def on_session_start(event: object) -> None:
if not session_future.done():
session_future.set_result(True)
def on_failed_auth(event: object) -> None:
if not session_future.done():
session_future.set_result(False)
def on_disconnected(event: object) -> None:
if not session_future.done():
session_future.set_result(False)
client.add_event_handler("session_start", on_session_start)
client.add_event_handler("failed_auth", on_failed_auth)
client.add_event_handler("disconnected", on_disconnected)
try:
# Connect with explicit host and port
connect_future = client.connect(self.settings.host, self.settings.port)
await connect_future # connect() returns a Future, not a coroutine
# Wait for one of the three events under a single timeout
try:
success = await asyncio.wait_for(session_future, timeout=self.settings.timeout)
except TimeoutError:
logger.warning("Délai d'attente de session XMPP dépassé.")
return False
if not success:
logger.warning("Échec d'authentification ou déconnexion XMPP.")
return False
# Send the message
formatted = self._format_message(message)
client.send_message(mto=JID(self.settings.to), mbody=formatted, mtype="chat")
return True
except Exception as exc:
redacted = redact_exception(exc)
extra = _secret_values(self.settings)
logger.warning("Erreur XMPP: %s", redact_secrets(redacted, extra_secrets=extra))
return False
finally:
try:
disconnect_future = client.disconnect()
await disconnect_future
except Exception as cleanup_exc:
logger.debug(
"Erreur lors de la déconnexion XMPP: %s", redact_exception(cleanup_exc)
)
class SyncXmppChannel:
"""Point d'entrée synchrone unique du canal XMPP pour le pipeline (U3).
Enveloppe une instance de :class:`XmppChannel` pour offrir une interface
synchrone conforme au :class:`~pronote_sync.channels.protocol.Channel`.
:meth:`send` délègue à :func:`asyncio.run` et ne lève jamais : toute
erreur est journalisée de façon expurgée et convertie en retour
``False`` (D6). En mode ``dry_run``, aucun client ``slixmpp`` n'est créé.
:ivar settings: Paramètres XMPP.
:vartype settings: XmppSettings
:ivar dry_run: Mode simulation (aucun envoi réseau).
:vartype dry_run: bool
"""
def __init__(self, settings: XmppSettings, dry_run: bool = False) -> None:
"""Initialise le point d'entrée synchrone et son canal interne.
:param settings: Paramètres de configuration du canal XMPP.
:param dry_run: Si ``True``, l'envoi est simulé.
"""
self.settings = settings
self.dry_run = dry_run
self._channel = XmppChannel(settings, dry_run)
def send(self, message: XmppMessage) -> bool:
"""Envoie un message XMPP de façon synchrone et sans lever.
En mode ``dry_run``, le message formaté (expurgé de ses secrets) est
journalisé et la méthode retourne ``True`` sans créer de client XMPP.
Sinon, le flux asynchrone :meth:`XmppChannel.send_async` est exécuté
via :func:`asyncio.run` ; toute exception est journalisée sous forme
expurgée et convertie en retour ``False``. La méthode ne lève jamais
(D6).
:param message: Message final à envoyer.
:return: ``True`` si l'envoi a réussi (ou a été simulé en dry-run),
``False`` sinon.
:rtype: bool
"""
if self.dry_run:
formatted = self._channel._format_message(message)
redacted = redact_secrets(formatted, extra_secrets=_secret_values(self.settings))
logger.info("XMPP : dry-run, message non envoyé : %s", redacted)
return True
try:
return asyncio.run(self._channel.send_async(message))
except Exception as exc:
redacted = redact_exception(exc)
redacted = redact_secrets(redacted, extra_secrets=_secret_values(self.settings))
logger.warning("XMPP : erreur lors de l'envoi synchrone : %s", redacted)
return False

View File

@@ -0,0 +1 @@
"""Interface en ligne de commande du pipeline ``pronote-sync``."""

153
pronote_sync/cli/main.py Normal file
View File

@@ -0,0 +1,153 @@
"""Point d'entrée en ligne de commande du pipeline Pronote → CalDAV → XMPP."""
from __future__ import annotations
import argparse
import logging
import traceback
from collections.abc import Sequence
from pydantic import SecretStr
from pronote_sync.config.env import load_settings
from pronote_sync.config.settings import Settings
from pronote_sync.pipeline.run import PipelineRunner
from pronote_sync.utils.logging import setup_logging
from pronote_sync.utils.redaction import redact_secrets
logger = logging.getLogger(__name__)
_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
def _parse_arguments(arguments: Sequence[str] | None = None) -> argparse.Namespace:
"""Analyse les options de lancement du programme.
:param arguments: Arguments à analyser, ou ``None`` pour ceux du processus.
:return: Options de ligne de commande validées.
:rtype: argparse.Namespace
"""
parser = argparse.ArgumentParser(description="Synchronise Pronote vers CalDAV et XMPP.")
parser.add_argument(
"--dry-run",
action="store_true",
default=None,
help="Simule la synchronisation sans écrire vers CalDAV ni XMPP.",
)
parser.add_argument(
"--log-level",
choices=_LOG_LEVELS,
type=str.upper,
help="Niveau de verbosité des journaux.",
)
return parser.parse_args(arguments)
def _settings_secrets(settings: Settings) -> tuple[SecretStr | str, ...]:
"""Retourne les valeurs sensibles connues pour la rédaction des messages.
Centraliser ces valeurs garantit que les diagnostics CLI ne divulguent pas
les secrets configurés, y compris lorsque le niveau ``DEBUG`` est demandé.
:param settings: Configuration validée de l'application.
:return: Secrets connus à transmettre au mécanisme de rédaction.
:rtype: tuple[SecretStr | str, ...]
"""
candidates = (
*settings.redaction_secrets(),
settings.pronote.username,
settings.caldav.username,
settings.xmpp.jid,
settings.xmpp.to,
)
return tuple(dict.fromkeys(secret for secret in candidates if secret is not None))
def _safe_traceback(
exception: BaseException, *, extra_secrets: Sequence[SecretStr | str] = ()
) -> str:
"""Construit une pile complète sans inclure les messages d'exception bruts.
Les noms de fichiers, lignes et fonctions conservent la valeur de diagnostic
de la pile. Les messages et les chaînes de causes sont volontairement
remplacés, car ils peuvent provenir d'une bibliothèque externe.
:param exception: Exception à représenter sans divulguer son contenu.
:param extra_secrets: Valeurs sensibles configurées à rédiger dans les cadres.
:return: Représentation de la pile et de ses causes, expurgée.
:rtype: str
"""
lines = ["Traceback (most recent call last):"]
current: BaseException | None = exception
seen: set[int] = set()
while current is not None and id(current) not in seen:
seen.add(id(current))
for frame in traceback.extract_tb(current.__traceback__):
lines.append(f' File "{frame.filename}", line {frame.lineno}, in {frame.name}')
lines.append(f"{type(current).__name__}: erreur expurgée")
next_exception = current.__cause__ or current.__context__
if next_exception is not None and id(next_exception) not in seen:
lines.append("La cause ou le contexte précédent est le suivant :")
current = next_exception
return redact_secrets("\n".join(lines), extra_secrets=extra_secrets)
def _log_failure(
message: str,
exception: BaseException,
*,
extra_secrets: Sequence[SecretStr | str] = (),
) -> None:
"""Journalise une erreur et sa pile expurgée uniquement en niveau DEBUG.
:param message: Message public déjà sûr à afficher hors DEBUG.
:param exception: Exception dont la pile doit être présentée de façon sûre.
:param extra_secrets: Valeurs sensibles configurées à rédiger.
:rtype: None
"""
logger.error("%s", redact_secrets(message, extra_secrets=extra_secrets))
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s", _safe_traceback(exception, extra_secrets=extra_secrets))
def main(arguments: Sequence[str] | None = None) -> int:
"""Lance le pipeline configuré et retourne son code de sortie.
En niveau ``DEBUG``, les piles sont affichées sans leurs messages externes
bruts afin de préserver le diagnostic sans exposer de secret.
:param arguments: Arguments optionnels, principalement utiles aux appels programmatiques.
:return: ``0`` en cas de succès, ``1`` sinon (après analyse des arguments).
:rtype: int
:raises SystemExit: Si argparse rejette les arguments (code de sortie 2).
"""
parsed_arguments = _parse_arguments(arguments)
setup_logging(parsed_arguments.log_level or "INFO")
try:
settings = load_settings()
except Exception as exception:
_log_failure("Configuration invalide ou indisponible.", exception)
return 1
setup_logging(parsed_arguments.log_level or settings.app.log_level)
try:
runner = PipelineRunner.from_settings(settings, dry_run=parsed_arguments.dry_run)
data, errors = runner.run()
except Exception as exception:
_log_failure(
"Échec inattendu du pipeline.",
exception,
extra_secrets=_settings_secrets(settings),
)
return 1
secrets = _settings_secrets(settings)
for error in errors:
logger.error("%s", redact_secrets(error.message, extra_secrets=secrets))
if data is None:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -12,7 +12,13 @@ from datetime import date
from typing import Literal from typing import Literal
from urllib.parse import urlparse from urllib.parse import urlparse
from pydantic import Field, SecretStr, ValidationInfo, field_serializer, field_validator from pydantic import (
Field,
SecretStr,
ValidationInfo,
field_serializer,
field_validator,
)
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from pronote_sync.utils.redaction import redact_url from pronote_sync.utils.redaction import redact_url
@@ -124,39 +130,84 @@ class CalDAVSettings(BaseSettings):
return v return v
_XMPP_LOOPBACK_HOSTS: frozenset[str] = frozenset({"localhost", "127.0.0.1", "::1"})
class XmppSettings(BaseSettings): class XmppSettings(BaseSettings):
"""Paramètres du canal de notifications XMPP (désactivé par défaut). """Paramètres du canal de notifications XMPP (désactivé par défaut).
Tous les champs ont des valeurs par défaut afin que le canal XMPP reste Tous les champs ont des valeurs par défaut afin que le canal XMPP reste
inactif tant qu'il n'est pas explicitement activé. Les variables inactif tant qu'il n'est pas explicitement activé. Les variables
d'environnement correspondantes sont préfixées par ``XMPP_``. d'environnement correspondantes sont préfixées par ``XMPP_``.
Contraintes de champs : ``port`` est borné entre 1 et 65535 et ``timeout``
doit être strictement positif.
Politique TLS : la désactivation de TLS (``use_tls`` à ``False``) n'est
autorisée que sur un hôte de boucle locale (``localhost``, ``127.0.0.1``,
``::1``). Dans tout autre cas, une erreur de validation est levée,
indépendamment de l'état du champ ``enabled``.
""" """
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="XMPP_") model_config = SettingsConfigDict(
env_file=".env",
extra="ignore",
env_prefix="XMPP_",
hide_input_in_errors=True,
)
enabled: bool = False enabled: bool = False
jid: str | None = None jid: str | None = None
password: SecretStr | None = None password: SecretStr | None = None
host: str = "" host: str = ""
port: int = 5222 port: int = Field(default=5222, ge=1, le=65535)
to: str | None = None to: str | None = None
resource: str = "pronote-sync" resource: str = "pronote-sync"
use_tls: bool = True use_tls: bool = True
timeout: int = 30 timeout: int = Field(default=30, gt=0)
@field_validator("use_tls")
@classmethod
def _validate_tls_policy(cls, v: bool, info: ValidationInfo) -> bool:
"""Refuse la désactivation de TLS hors des hôtes de boucle locale.
La règle s'applique quel que soit l'état du champ ``enabled``. Le
message d'erreur ne contient aucune valeur sensible (``jid``,
``password``, ``to``).
:param v: Valeur du champ ``use_tls`` à valider.
:param info: Contexte de validation (accès aux autres champs).
:return: La valeur validée inchangée.
:rtype: bool
:raises ValueError: Si ``use_tls`` est ``False`` et que ``host``
n'est pas un hôte de boucle locale.
"""
if v is False:
host = info.data.get("host", "")
if host not in _XMPP_LOOPBACK_HOSTS:
raise ValueError(
"TLS désactivé n'est autorisé que sur les hôtes de loopback "
"(localhost, 127.0.0.1, ::1)."
) from None
return v
class AISettings(BaseSettings): class AISettings(BaseSettings):
"""Paramètres de la synthèse par IA (désactivée par défaut). """Paramètres de la synthèse par IA (désactivée par défaut).
Les variables d'environnement correspondantes sont préfixées par ``AI_``. Les variables d'environnement correspondantes sont préfixées par ``AI_``.
Le provider ``openai-compatible`` permet d'utiliser n'importe quelle API
compatible OpenAI via ``AI_BASE_URL`` ; les URLs en HTTP ne sont alors
acceptées que si ``AI_ALLOW_INSECURE_HTTP`` vaut ``true``.
""" """
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="AI_") model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="AI_")
enabled: bool = False enabled: bool = False
provider: Literal["openai", "litellm"] = "openai" provider: Literal["openai", "litellm", "openai-compatible"] = "openai"
base_url: str | None = None base_url: str | None = None
api_key: SecretStr | None = None api_key: SecretStr | None = None
allow_insecure_http: bool = False
model: str | None = None model: str | None = None
@@ -210,3 +261,23 @@ class Settings(BaseSettings):
ai: AISettings = Field(default_factory=AISettings) ai: AISettings = Field(default_factory=AISettings)
blog: BlogSettings = Field(default_factory=BlogSettings) blog: BlogSettings = Field(default_factory=BlogSettings)
app: AppSettings = Field(default_factory=AppSettings) app: AppSettings = Field(default_factory=AppSettings)
def redaction_secrets(self) -> tuple[SecretStr, ...]:
"""Énumère tous les secrets configurés pour la rédaction.
Collecte les valeurs :class:`pydantic.SecretStr` non vides présentes
dans les sous-configurations (Pronote, CalDAV, XMPP, IA). Les valeurs
vides ou ``None`` sont filtrées ; les doublons sont supprimés.
:return: Tuple de secrets à masquer dans les messages d'erreur.
:rtype: tuple[SecretStr, ...]
"""
secrets = [
self.pronote.ical_url,
self.pronote.password,
self.caldav.url,
self.caldav.password,
self.xmpp.password,
self.ai.api_key,
]
return tuple(dict.fromkeys(secret for secret in secrets if secret is not None))

View File

@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from enum import StrEnum
class PronoteSyncError(Exception): class PronoteSyncError(Exception):
"""Erreur de base pour toutes les exceptions du projet pronote-sync. """Erreur de base pour toutes les exceptions du projet pronote-sync.
@@ -16,18 +18,90 @@ class PronoteSyncError(Exception):
:param message: Message décrivant la cause de l'erreur. :param message: Message décrivant la cause de l'erreur.
""" """
super().__init__(message) super().__init__(message)
self.message = message
class PipelineCriticalError(PronoteSyncError): class ErrorSeverity(StrEnum):
"""Niveau de gravité d'une erreur produite par le pipeline."""
WARNING = "warning"
CRITICAL = "critical"
class PipelineError(PronoteSyncError):
"""Erreur structurée produite par une étape du pipeline.
:ivar severity: Niveau de gravité de l'erreur.
:ivar step: Étape ayant produit l'erreur, si elle est connue.
:ivar recoverable: Indique si le pipeline peut poursuivre son exécution.
"""
def __init__(
self,
message: str,
*,
severity: ErrorSeverity = ErrorSeverity.WARNING,
step: str | None = None,
recoverable: bool = True,
) -> None:
"""Initialise une erreur de pipeline.
:param message: Message descriptif expurgé.
:param severity: Niveau de gravité associé.
:param step: Étape ayant produit l'erreur.
:param recoverable: ``True`` si le pipeline peut continuer.
"""
super().__init__(message)
self.severity = severity
self.step = step
self.recoverable = recoverable
class PipelineCriticalError(PipelineError):
"""Erreur critique du pipeline, levée quand aucune récupération n'est possible. """Erreur critique du pipeline, levée quand aucune récupération n'est possible.
Par exemple : échec simultané des sources iCal et pronotepy, Par exemple : échec simultané des sources iCal et pronotepy,
rendant impossible toute synchronisation. rendant impossible toute synchronisation.
""" """
def __init__(self, message: str) -> None: def __init__(self, message: str, step: str | None = None) -> None:
"""Initialise l'erreur critique avec un message descriptif. """Initialise l'erreur critique avec un message descriptif.
:param message: Message décrivant la cause de l'erreur critique. :param message: Message décrivant la cause de l'erreur critique.
:param step: Étape ayant produit l'erreur critique.
""" """
super().__init__(message) super().__init__(
message,
severity=ErrorSeverity.CRITICAL,
step=step,
recoverable=False,
)
class PipelineWarning(PipelineError):
"""Avertissement non bloquant pour une erreur récupérable du pipeline.
Contrairement à :class:`PipelineCriticalError`, cet avertissement signale
un problème récupérable : le pipeline peut poursuivre son exécution en
mode dégradé.
Il hérite volontairement de :class:`PronoteSyncError` (et non de la classe
native :class:`Warning`) afin de rester dans la hiérarchie canonique des
erreurs du projet.
:ivar recoverable: Indique que l'erreur est récupérable (toujours ``True``).
:ivar step: Étape du pipeline ayant produit l'avertissement.
"""
def __init__(self, message: str, step: str | None = None) -> None:
"""Initialise l'avertissement avec un message descriptif.
:param message: Message décrivant la cause de l'avertissement.
:param step: Étape du pipeline ayant produit l'avertissement.
"""
super().__init__(
message,
severity=ErrorSeverity.WARNING,
step=step,
recoverable=True,
)

View File

@@ -0,0 +1,5 @@
"""Orchestration du pipeline Pronote → CalDAV → XMPP."""
from pronote_sync.pipeline.run import PipelineRunner
__all__ = ["PipelineRunner"]

View File

@@ -0,0 +1,299 @@
"""Composition root et orchestrateur du pipeline Pronote → CalDAV → XMPP."""
from __future__ import annotations
import logging
from collections.abc import Callable
from contextlib import AbstractContextManager, nullcontext
from datetime import datetime
from typing import Protocol, runtime_checkable
from pronote_sync.channels import get_channel
from pronote_sync.channels.protocol import Channel
from pronote_sync.config.settings import Settings
from pronote_sync.errors import PipelineCriticalError, PipelineError, PipelineWarning
from pronote_sync.models.blog import ExternalInfo
from pronote_sync.models.pronote import PronoteData
from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus
from pronote_sync.models.synthesis import SynthesisInput
from pronote_sync.models.xmpp import XmppMessage
from pronote_sync.pipeline.steps.caldav_sync import CalDAVSynchronizer, caldav_sync_step
from pronote_sync.pipeline.steps.compare import compare_step
from pronote_sync.pipeline.steps.fetch import fetch_step
from pronote_sync.pipeline.steps.fetch_blog import fetch_blog_step
from pronote_sync.pipeline.steps.normalize import normalize_step
from pronote_sync.pipeline.steps.send import send_step
from pronote_sync.pipeline.steps.synthesis import synthesis_step
from pronote_sync.sources.blog.rss import BlogRSSClient
from pronote_sync.sources.blog.state import BlogRSSState
from pronote_sync.sources.pronote.client import PronoteClient
from pronote_sync.sources.pronote.fallback import PronoteFetcher, PronoteFetcherProtocol
from pronote_sync.sources.theoretical import get_theoretical_provider
from pronote_sync.sync.diff import AgendaComparator
from pronote_sync.sync.synchronizer import synchronize
from pronote_sync.synthesis import get_synthesis_provider
from pronote_sync.synthesis.provider import SynthesisProvider
from pronote_sync.utils.redaction import redact_exception, redact_secrets
logger = logging.getLogger(__name__)
def _synchronize_caldav(data: PronoteData, settings: Settings) -> CalDAVSyncResult:
"""Adapte le synchroniseur CalDAV de production au protocole injecté.
:param data: Données Pronote normalisées à synchroniser.
:param settings: Configuration effective de l'exécution.
:return: Résultat de la synchronisation CalDAV.
:rtype: CalDAVSyncResult
"""
return synchronize(data, settings)
@runtime_checkable
class _RunContextFetcher(PronoteFetcherProtocol, Protocol):
"""Protocole interne d'un fetcher capable d'isoler un cache par run."""
def run_context(self) -> AbstractContextManager[None]:
"""Retourne le contexte de durée de vie d'une exécution.
:return: Contexte éphémère associé à l'exécution.
:rtype: AbstractContextManager[None]
"""
...
class PipelineRunner:
"""Orchestre les étapes fetch → normalize → blog → compare → CalDAV → IA → XMPP.
Toutes les dépendances sont injectables. La méthode :meth:`from_settings`
constitue la composition root de production et ne crée aucun singleton.
"""
def __init__(
self,
*,
settings: Settings,
pronote_fetcher: PronoteFetcherProtocol,
caldav_synchronizer: CalDAVSynchronizer = _synchronize_caldav,
agenda_comparator: AgendaComparator | None = None,
synthesis_provider: SynthesisProvider | None = None,
channel: Channel | None = None,
blog_client: BlogRSSClient | None = None,
blog_state: BlogRSSState | None = None,
dry_run: bool | None = None,
now_provider: Callable[[], datetime] = datetime.now,
) -> None:
"""Initialise un pipeline entièrement injectable.
:param settings: Configuration de base du pipeline.
:param pronote_fetcher: Source Pronote à utiliser.
:param caldav_synchronizer: Service CalDAV injecté.
:param agenda_comparator: Comparateur théorique, absent si désactivé.
:param synthesis_provider: Fournisseur IA optionnel.
:param channel: Canal XMPP optionnel.
:param blog_client: Client RSS optionnel.
:param blog_state: État RSS associé au client optionnel.
:param dry_run: Surcharge optionnelle du mode dry-run de la configuration.
:param now_provider: Horloge injectée pour rendre l'exécution testable.
"""
self._settings = settings
self._redaction_secrets = settings.redaction_secrets()
self._pronote_fetcher = pronote_fetcher
self._caldav_synchronizer = caldav_synchronizer
self._agenda_comparator = agenda_comparator
self._synthesis_provider = synthesis_provider
self._channel = channel
self._blog_client = blog_client
self._blog_state = blog_state
self._dry_run = settings.app.dry_run if dry_run is None else dry_run
self._now_provider = now_provider
self._errors: list[PipelineError] = []
self._warnings: list[PipelineWarning] = []
@classmethod
def from_settings(cls, settings: Settings, *, dry_run: bool | None = None) -> PipelineRunner:
"""Construit les dépendances de production sans singleton global.
:param settings: Configuration validée de l'application.
:param dry_run: Surcharge optionnelle du mode dry-run.
:return: Pipeline prêt à être exécuté.
:rtype: PipelineRunner
"""
effective_dry_run = settings.app.dry_run if dry_run is None else dry_run
theoretical_provider = get_theoretical_provider(
settings.app.theoretical_agenda_path,
settings.app.school_holidays_path,
settings.app.theoretical_week_anchor_date,
settings.app.theoretical_week_anchor_type,
)
comparator = (
AgendaComparator(theoretical_provider) if theoretical_provider is not None else None
)
blog_client = BlogRSSClient(settings.blog.rss_url) if settings.blog.enabled else None
blog_state = BlogRSSState() if settings.blog.enabled else None
return cls(
settings=settings,
pronote_fetcher=PronoteFetcher(settings, PronoteClient(settings.pronote)),
agenda_comparator=comparator,
synthesis_provider=get_synthesis_provider(settings.ai),
channel=get_channel(settings.xmpp, dry_run=effective_dry_run),
blog_client=blog_client,
blog_state=blog_state,
dry_run=effective_dry_run,
)
def _effective_settings(self) -> Settings:
"""Retourne la configuration dont le dry-run reflète l'exécution courante.
:return: Copie de configuration à passer aux dépendances.
:rtype: Settings
"""
if self._settings.app.dry_run == self._dry_run:
return self._settings
return self._settings.model_copy(
update={"app": self._settings.app.model_copy(update={"dry_run": self._dry_run})}
)
def _redact(self, exc: Exception) -> str:
"""Rédige une exception avec les secrets configurés.
:param exc: Exception dont le message doit être masqué.
:return: Message d'erreur avec secrets configurés remplacés par ``REDACTED``.
:rtype: str
"""
return redact_exception(exc, self._redaction_secrets)
def _run_context(self) -> AbstractContextManager[None]:
"""Retourne le contexte isolant les éventuels caches de source.
:return: Contexte de durée de vie du run, vide pour un fetcher générique.
:rtype: AbstractContextManager[None]
"""
if isinstance(self._pronote_fetcher, _RunContextFetcher):
return self._pronote_fetcher.run_context()
return nullcontext()
def _warn(self, step: str, message: str) -> None:
"""Enregistre et journalise un avertissement expurgé.
:param step: Étape ayant échoué.
:param message: Message déjà expurgé.
"""
warning = PipelineWarning(message, step=step)
self._warnings.append(warning)
logger.warning("Étape %s dégradée : %s", step, warning.message)
def run(self) -> tuple[PronoteData | None, list[PipelineError]]:
"""Exécute le pipeline complet dans l'ordre contractuel.
Une erreur de récupération critique interrompt l'exécution. Les erreurs
des étapes facultatives sont converties en :class:`PipelineWarning` afin
que les étapes suivantes, notamment XMPP, restent exécutées.
:return: Données Pronote normalisées ou ``None``, puis erreurs et avertissements.
:rtype: tuple[PronoteData | None, list[PipelineError]]
"""
self._errors = []
self._warnings = []
now = self._now_provider()
effective_settings = self._effective_settings()
try:
with self._run_context():
fetched, fetch_warnings = fetch_step(self._pronote_fetcher, today=now.date())
self._warnings.extend(fetch_warnings)
data = normalize_step(fetched, generated_at=now)
try:
blog_articles = fetch_blog_step(self._blog_client, self._blog_state)
except PipelineCriticalError:
raise
except Exception as exc:
self._warn("fetch_blog", self._redact(exc))
blog_articles = []
try:
agenda_diff = compare_step(self._agenda_comparator, data)
except PipelineCriticalError:
raise
except Exception as exc:
self._warn("compare", self._redact(exc))
from pronote_sync.models.diff import AgendaDiff
agenda_diff = AgendaDiff(target_date=data.target_date)
try:
sync_result = caldav_sync_step(
self._caldav_synchronizer, data, effective_settings
)
if sync_result.status is CalDAVSyncStatus.FAILED:
caldav_errors = redact_secrets(
"; ".join(sync_result.errors),
extra_secrets=self._redaction_secrets,
)
self._warn("caldav_sync", caldav_errors or "Échec CalDAV")
except PipelineCriticalError:
raise
except Exception as exc:
self._warn("caldav_sync", self._redact(exc))
try:
synthesis = synthesis_step(
self._synthesis_provider,
SynthesisInput(
agenda_diff=agenda_diff,
messages=data.messages,
school_events=data.school_events,
target_date=data.target_date,
),
)
except PipelineCriticalError:
raise
except Exception as exc:
self._warn("synthesis", self._redact(exc))
synthesis = None
message = XmppMessage(
target_date=data.target_date,
synthesis=synthesis.text if synthesis is not None else None,
homeworks=tuple(data.homeworks),
changes=agenda_diff.changes,
messages=tuple(data.messages),
external_info=ExternalInfo(blog_articles=tuple(blog_articles))
if blog_articles
else None,
)
if self._channel is not None and not self._dry_run:
try:
if not send_step(self._channel, message):
self._warn("send", "Le canal XMPP a refusé l'envoi")
except PipelineCriticalError:
raise
except Exception as exc:
self._warn("send", self._redact(exc))
return data, [*self._errors, *self._warnings]
except PipelineCriticalError as exc:
logger.error("Erreur critique du pipeline : %s", exc.message)
self._errors.append(exc)
except Exception as exc:
error = PipelineCriticalError(
f"Erreur inattendue du pipeline : {self._redact(exc)}", step="pipeline"
)
logger.error("Erreur critique du pipeline : %s", error.message)
self._errors.append(error)
return None, [*self._errors, *self._warnings]
def get_errors(self) -> list[PipelineError]:
"""Retourne les erreurs critiques de la dernière exécution.
:return: Copie des erreurs critiques.
:rtype: list[PipelineError]
"""
return list(self._errors)
def get_warnings(self) -> list[PipelineWarning]:
"""Retourne les avertissements de la dernière exécution.
:return: Copie des avertissements non bloquants.
:rtype: list[PipelineWarning]
"""
return list(self._warnings)

View File

@@ -0,0 +1,19 @@
"""Étapes isolées utilisées par l'orchestrateur du pipeline."""
from pronote_sync.pipeline.steps.caldav_sync import caldav_sync_step
from pronote_sync.pipeline.steps.compare import compare_step
from pronote_sync.pipeline.steps.fetch import fetch_step
from pronote_sync.pipeline.steps.fetch_blog import fetch_blog_step
from pronote_sync.pipeline.steps.normalize import normalize_step
from pronote_sync.pipeline.steps.send import send_step
from pronote_sync.pipeline.steps.synthesis import synthesis_step
__all__ = [
"caldav_sync_step",
"compare_step",
"fetch_blog_step",
"fetch_step",
"normalize_step",
"send_step",
"synthesis_step",
]

View File

@@ -0,0 +1,37 @@
"""Étape d'appel à la synchronisation CalDAV."""
from __future__ import annotations
from typing import Protocol
from pronote_sync.config.settings import Settings
from pronote_sync.models.pronote import PronoteData
from pronote_sync.models.sync import CalDAVSyncResult
class CalDAVSynchronizer(Protocol):
"""Protocole injectable de synchronisation CalDAV."""
def __call__(self, data: PronoteData, settings: Settings) -> CalDAVSyncResult:
"""Synchronise les données Pronote vers CalDAV.
:param data: Données Pronote normalisées.
:param settings: Configuration effective de l'exécution.
:return: Résultat de la synchronisation.
:rtype: CalDAVSyncResult
"""
...
def caldav_sync_step(
synchronizer: CalDAVSynchronizer, data: PronoteData, settings: Settings
) -> CalDAVSyncResult:
"""Exécute la synchronisation CalDAV injectée.
:param synchronizer: Service de synchronisation injecté.
:param data: Données Pronote normalisées.
:param settings: Configuration effective de l'exécution.
:return: Résultat CalDAV.
:rtype: CalDAVSyncResult
"""
return synchronizer(data, settings)

View File

@@ -0,0 +1,20 @@
"""Étape de comparaison de l'agenda réel avec l'agenda théorique."""
from __future__ import annotations
from pronote_sync.models.diff import AgendaDiff
from pronote_sync.models.pronote import PronoteData
from pronote_sync.sync.diff import AgendaComparator
def compare_step(comparator: AgendaComparator | None, data: PronoteData) -> AgendaDiff:
"""Compare l'agenda ou retourne un diff vide si la comparaison est désactivée.
:param comparator: Comparateur configuré, ou ``None`` sans agenda théorique.
:param data: Données Pronote normalisées.
:return: Diff d'agenda pour la date cible.
:rtype: AgendaDiff
"""
if comparator is None:
return AgendaDiff(target_date=data.target_date)
return comparator.compare(data.lessons, data.target_date)

View File

@@ -0,0 +1,126 @@
"""Étape de récupération des données Pronote pour une exécution du pipeline."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from pronote_sync.errors import PipelineCriticalError, PipelineWarning
from pronote_sync.models.agenda import Lesson, SchoolEvent
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message
from pronote_sync.sources.pronote.fallback import PronoteFetcherProtocol
from pronote_sync.utils.redaction import redact_exception
@dataclass(frozen=True)
class FetchedPronoteData:
"""Représente les données brutes récupérées pendant une exécution.
:ivar lessons: Cours récupérés depuis la source sélectionnée.
:ivar homeworks: Devoirs destinés à la date cible.
:ivar school_events: Événements scolaires récupérés avec l'agenda.
:ivar messages: Messages et informations Pronote disponibles.
:ivar target_date: Date cible du digest.
"""
lessons: list[Lesson]
homeworks: list[Homework]
school_events: list[SchoolEvent]
messages: list[Message]
target_date: date
def resolve_target_date(
today: date, lessons: list[Lesson], school_events: list[SchoolEvent]
) -> date:
"""Détermine la date cible du digest à partir de l'agenda disponible.
La règle privilégie J+1 lorsqu'il contient des cours. Si la journée en
cours contient des cours mais pas J+1, le prochain cours connu est choisi.
Sans cours correspondant, J+1 est conservé, y compris pendant les vacances.
:param today: Date de référence de l'exécution.
:param lessons: Cours récupérés pour la fenêtre de synchronisation.
:param school_events: Événements scolaires récupérés (réservés aux évolutions
du libellé de jour sans cours).
:return: Date cible du digest.
:rtype: date
"""
del school_events
tomorrow = date.fromordinal(today.toordinal() + 1)
lesson_dates = {lesson.start.date() for lesson in lessons}
if tomorrow in lesson_dates:
return tomorrow
if today in lesson_dates:
future_dates = sorted(day for day in lesson_dates if day > today)
if future_dates:
return future_dates[0]
return tomorrow
def _fetch_optional_messages(
fetcher: PronoteFetcherProtocol,
) -> tuple[list[Message], list[PipelineWarning]]:
"""Récupère les messages et informations sans bloquer le pipeline.
:param fetcher: Fetcher Pronote configuré.
:return: Messages disponibles et avertissements éventuels.
:rtype: tuple[list[Message], list[PipelineWarning]]
"""
messages: list[Message] = []
warnings: list[PipelineWarning] = []
for step, method in (
("fetch_messages", fetcher.fetch_messages),
("fetch_informations", fetcher.fetch_informations),
):
try:
messages.extend(method())
except Exception as exc:
warnings.append(
PipelineWarning(
f"Récupération non critique échouée : {redact_exception(exc)}",
step=step,
)
)
return messages, warnings
def fetch_step(
fetcher: PronoteFetcherProtocol, *, today: date | None = None
) -> tuple[FetchedPronoteData, list[PipelineWarning]]:
"""Récupère les données Pronote critiques et les compléments dégradables.
L'agenda et les devoirs sont critiques : leur échec empêche de produire un
digest fiable et est donc propagé comme :class:`PipelineCriticalError`.
Les messages et informations sont facultatifs ; leur échec produit un
avertissement et une liste partielle reste valide.
:param fetcher: Fetcher Pronote configuré.
:param today: Date de référence, injectée par les tests ; J courant par défaut.
:return: Données récupérées et avertissements non critiques.
:rtype: tuple[FetchedPronoteData, list[PipelineWarning]]
:raises PipelineCriticalError: Si l'agenda ou les devoirs ne sont pas disponibles.
"""
try:
lessons, school_events = fetcher.fetch_agenda()
target_date = resolve_target_date(today or date.today(), lessons, school_events)
homeworks = fetcher.fetch_homework(target_date)
except PipelineCriticalError:
raise
except Exception as exc:
raise PipelineCriticalError(
f"Récupération Pronote impossible : {redact_exception(exc)}", step="fetch"
) from None
messages, warnings = _fetch_optional_messages(fetcher)
return (
FetchedPronoteData(
lessons=lessons,
homeworks=homeworks,
school_events=school_events,
messages=messages,
target_date=target_date,
),
warnings,
)

View File

@@ -0,0 +1,34 @@
"""Étape de récupération non bloquante des articles RSS du collège."""
from __future__ import annotations
from pronote_sync.models.blog import BlogArticle
from pronote_sync.sources.blog.rss import BlogRSSClient
from pronote_sync.sources.blog.state import BlogRSSState
from pronote_sync.utils.redaction import redact_exception
def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> list[BlogArticle]:
"""Récupère les articles RSS nouveaux en conservant l'état du client.
: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.
:return: Nouveaux articles du blog.
:rtype: list[BlogArticle]
:raises RuntimeError: Si la récupération RSS injectée échoue.
"""
if client is None or state is None:
return []
try:
etag, last_modified = state.get_cache_headers()
result = client.fetch_and_parse(
known_guids=state.get_known_guids(), etag=etag, last_modified=last_modified
)
if result.error is not None:
raise RuntimeError(result.error) from None
if not result.not_modified:
state.add_guids(article.id for article in result.articles)
state.update_cache_headers(result.etag, result.last_modified)
return list(result.articles)
except Exception as exc:
raise RuntimeError(f"Récupération du blog échouée : {redact_exception(exc)}") from None

View File

@@ -0,0 +1,31 @@
"""Étape de normalisation et d'ordonnancement déterministe des données Pronote."""
from __future__ import annotations
from datetime import datetime
from pronote_sync.models.pronote import PronoteData
from pronote_sync.pipeline.steps.fetch import FetchedPronoteData
def normalize_step(fetched: FetchedPronoteData, *, generated_at: datetime) -> PronoteData:
"""Construit le contrat ``PronoteData`` dans un ordre déterministe.
:param fetched: Données brutes produites par :func:`fetch_step`.
:param generated_at: Horodatage de l'exécution fourni par l'orchestrateur.
:return: Données Pronote normalisées.
:rtype: PronoteData
"""
return PronoteData(
lessons=sorted(fetched.lessons, key=lambda lesson: (lesson.start, lesson.id)),
homeworks=sorted(
fetched.homeworks, key=lambda homework: (homework.due_on, homework.subject, homework.id)
),
school_events=sorted(
fetched.school_events,
key=lambda event: (event.from_date, event.to_date, event.kind.value, event.label),
),
messages=sorted(fetched.messages, key=lambda message: (message.date, message.id)),
target_date=fetched.target_date,
generated_at=generated_at,
)

View File

@@ -0,0 +1,17 @@
"""Étape d'envoi du digest sur le canal de notification."""
from __future__ import annotations
from pronote_sync.channels.protocol import Channel
from pronote_sync.models.xmpp import XmppMessage
def send_step(channel: Channel, message: XmppMessage) -> bool:
"""Envoie le digest et retourne le statut fourni par le canal.
:param channel: Canal de sortie configuré.
:param message: Digest XMPP à transmettre.
:return: ``True`` si l'envoi a réussi, ``False`` sinon.
:rtype: bool
"""
return channel.send(message)

View File

@@ -0,0 +1,21 @@
"""Étape de génération optionnelle de synthèse IA."""
from __future__ import annotations
from pronote_sync.models.synthesis import SynthesisInput, SynthesisResult
from pronote_sync.synthesis.provider import SynthesisProvider
def synthesis_step(
provider: SynthesisProvider | None, input_data: SynthesisInput
) -> SynthesisResult | None:
"""Génère une synthèse lorsque le fournisseur IA est activé.
:param provider: Fournisseur IA optionnel.
:param input_data: Données à synthétiser.
:return: Synthèse produite, ou ``None`` si le fournisseur est désactivé.
:rtype: SynthesisResult | None
"""
if provider is None:
return None
return provider.generate(input_data)

View File

@@ -29,6 +29,8 @@ class BlogRSSFetchResult(BaseModel):
réponse RSS, si elle est disponible. ``None`` par défaut. réponse RSS, si elle est disponible. ``None`` par défaut.
:param not_modified: Vaut ``True`` si le serveur a répondu avec le :param not_modified: Vaut ``True`` si le serveur a répondu avec le
statut ``304 Not Modified``, ``False`` sinon. statut ``304 Not Modified``, ``False`` sinon.
:param error: Message d'erreur expurgé si la récupération a échoué,
``None`` sinon.
""" """
model_config = ConfigDict(frozen=True) model_config = ConfigDict(frozen=True)
@@ -52,3 +54,7 @@ class BlogRSSFetchResult(BaseModel):
default=False, default=False,
description="Vaut True si le serveur a répondu 304 Not Modified", description="Vaut True si le serveur a répondu 304 Not Modified",
) )
error: str | None = Field(
default=None,
description=("Message d'erreur expurgé si la récupération a échoué, None sinon"),
)

View File

@@ -131,12 +131,14 @@ class BlogRSSClient:
if getattr(feed, "bozo", None): if getattr(feed, "bozo", None):
bozo_exception = getattr(feed, "bozo_exception", None) bozo_exception = getattr(feed, "bozo_exception", None)
if bozo_exception is not None: if bozo_exception is not None:
error_msg = f"Flux RSS invalide : {redact_exception(bozo_exception)}"
logger.warning( logger.warning(
"Flux RSS du blog invalide (%s), ignoré : %s", "Flux RSS du blog invalide (%s), ignoré : %s",
redact_exception(bozo_exception), redact_exception(bozo_exception),
redact_url(self.rss_url), redact_url(self.rss_url),
) )
else: else:
error_msg = "Flux RSS invalide"
logger.warning( logger.warning(
"Flux RSS du blog invalide, ignoré : %s", "Flux RSS du blog invalide, ignoré : %s",
redact_url(self.rss_url), redact_url(self.rss_url),
@@ -146,6 +148,7 @@ class BlogRSSClient:
etag=etag, etag=etag,
last_modified=last_modified, last_modified=last_modified,
not_modified=False, not_modified=False,
error=error_msg,
) )
articles: list[BlogArticle] = [] articles: list[BlogArticle] = []
@@ -234,16 +237,18 @@ class BlogRSSClient:
not_modified=False, not_modified=False,
) )
except Exception as exc: except Exception as exc:
error_msg = redact_exception(exc)
logger.error( logger.error(
"Échec de la récupération du flux RSS du blog %s : %s", "Échec de la récupération du flux RSS du blog %s : %s",
redact_url(self.rss_url), redact_url(self.rss_url),
redact_exception(exc), error_msg,
) )
return BlogRSSFetchResult( return BlogRSSFetchResult(
articles=(), articles=(),
etag=etag, etag=etag,
last_modified=last_modified, last_modified=last_modified,
not_modified=False, not_modified=False,
error=error_msg,
) )
@staticmethod @staticmethod

View File

@@ -16,6 +16,8 @@ d'origine ne sont jamais chaînées (``from None``).
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import date, timedelta from datetime import date, timedelta
from enum import StrEnum from enum import StrEnum
from typing import Literal, Protocol from typing import Literal, Protocol
@@ -100,6 +102,30 @@ class PronoteFetcher:
""" """
self._settings: Settings = settings self._settings: Settings = settings
self._pronote_client: PronoteClientProtocol = pronote_client self._pronote_client: PronoteClientProtocol = pronote_client
self._run_ical_agenda: tuple[list[Lesson], list[SchoolEvent]] | None = None
self._cache_ical_for_run = False
@contextmanager
def run_context(self) -> Iterator[None]:
"""Active un cache iCal éphémère pour une exécution du pipeline.
Le cache couvre à la fois le téléchargement et le parsing du flux.
Il est toujours supprimé à la sortie du contexte, y compris si une
étape échoue : il ne peut donc pas devenir un cache global ou
persistant entre deux exécutions.
:yield: Aucun objet.
:rtype: Iterator[None]
"""
previous_cache = self._run_ical_agenda
previous_enabled = self._cache_ical_for_run
self._run_ical_agenda = None
self._cache_ical_for_run = True
try:
yield
finally:
self._run_ical_agenda = previous_cache
self._cache_ical_for_run = previous_enabled
def _fetch_window(self) -> tuple[date, date]: def _fetch_window(self) -> tuple[date, date]:
"""Calcule la fenêtre de synchronisation autour de la date du jour. """Calcule la fenêtre de synchronisation autour de la date du jour.
@@ -144,12 +170,17 @@ class PronoteFetcher:
:raises OSError: Si le fichier iCal local est illisible. :raises OSError: Si le fichier iCal local est illisible.
:raises requests.RequestException: Si la récupération HTTP échoue. :raises requests.RequestException: Si la récupération HTTP échoue.
""" """
if self._cache_ical_for_run and self._run_ical_agenda is not None:
return self._run_ical_agenda
ical_url = self._settings.pronote.ical_url ical_url = self._settings.pronote.ical_url
if ical_url is None: if ical_url is None:
raise ValueError("PRONOTE_ICAL_URL est requis pour la source iCal") raise ValueError("PRONOTE_ICAL_URL est requis pour la source iCal")
raw_ical = fetch_ical(ical_url.get_secret_value()) raw_ical = fetch_ical(ical_url.get_secret_value())
lessons, _, school_events = parse_ical(raw_ical) lessons, _, school_events = parse_ical(raw_ical)
return lessons, school_events result = (lessons, school_events)
if self._cache_ical_for_run:
self._run_ical_agenda = result
return result
def _fetch_agenda_pronotepy(self) -> tuple[list[Lesson], list[SchoolEvent]]: def _fetch_agenda_pronotepy(self) -> tuple[list[Lesson], list[SchoolEvent]]:
"""Récupère l'agenda depuis pronotepy. """Récupère l'agenda depuis pronotepy.

View File

@@ -3,26 +3,98 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from urllib.parse import parse_qsl, urlparse
from pronote_sync.config.settings import AISettings from pronote_sync.config.settings import AISettings
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
from pronote_sync.utils.redaction import redact_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
__all__ = ["get_synthesis_provider", "SynthesisProvider", "OpenAISynthesisProvider"] __all__ = ["get_synthesis_provider", "SynthesisProvider", "OpenAISynthesisProvider"]
def _validate_openai_compatible_config(
url: str | None, model: str | None, allow_insecure_http: bool
) -> str | None:
"""Valide la configuration du provider ``openai-compatible``.
Vérifie la présence de l'URL de base et du modèle, le schéma de l'URL
(HTTPS obligatoire, HTTP accepté uniquement si ``allow_insecure_http``
vaut ``True``), la présence d'un hostname non vide, l'absence
d'identifiants dans le netloc et de paramètres sensibles dans la
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 model: Identifiant du modèle à utiliser.
:param allow_insecure_http: Autorise ou non les URLs en HTTP.
:return: L'URL validée, inchangée (aucune manipulation du chemin ou du
suffixe ``/v1``), ou ``None`` si la configuration est invalide.
:rtype: str | None
"""
if not url:
logger.warning("URL de base requise pour le provider openai-compatible")
return None
if not model:
logger.warning("Modèle requis pour le provider openai-compatible")
return None
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:
"""Sélectionne le fournisseur de synthèse IA selon la configuration. """Sélectionne le fournisseur de synthèse IA selon la configuration.
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é. journalisé et ``None`` est retourné. Pour le provider
``openai-compatible``, la configuration (URL de base et modèle) est
validée par :func:`_validate_openai_compatible_config` ; 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é ou sans clé API. :return: Le fournisseur configuré, ou ``None`` si désactivé, sans clé API
ou avec une configuration ``openai-compatible`` invalide.
:rtype: SynthesisProvider | None :rtype: SynthesisProvider | None
""" """
if not settings.enabled: if not settings.enabled:
@@ -41,4 +113,12 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
return 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":
url = _validate_openai_compatible_config(
settings.base_url, settings.model, settings.allow_insecure_http
)
if url is None:
return None
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=url, model=model)
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)

View File

@@ -89,7 +89,9 @@ def redact_secrets(text: str, extra_secrets: Iterable[SecretStr | str] = ()) ->
(clés API brutes, jetons, mots de passe, etc.) sont ensuite remplacées (clés API brutes, jetons, mots de passe, etc.) sont ensuite remplacées
littéralement, par ``str.replace``, par ``REDACTED`` dans le texte, y littéralement, par ``str.replace``, par ``REDACTED`` dans le texte, y
compris lorsqu'elles n'apparaissent pas sous une forme ``cle=valeur`` compris lorsqu'elles n'apparaissent pas sous une forme ``cle=valeur``
reconnue. Une valeur vide ou ``None`` est ignorée. reconnue. Une valeur vide ou ``None`` est ignorée. Les secrets sont
appliqués du plus long au plus court afin qu'un secret qui est une
sous-chaîne d'un autre soit remplacé en premier, sans être corrompu.
:param text: Texte pouvant contenir des URLs ou des secrets en clair. :param text: Texte pouvant contenir des URLs ou des secrets en clair.
:param extra_secrets: Itérable de secrets bruts (``str`` ou :param extra_secrets: Itérable de secrets bruts (``str`` ou
@@ -101,19 +103,25 @@ def redact_secrets(text: str, extra_secrets: Iterable[SecretStr | str] = ()) ->
redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text) redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text)
redacted = _AUTH_HEADER_PATTERN.sub(r"\1: REDACTED", redacted) redacted = _AUTH_HEADER_PATTERN.sub(r"\1: REDACTED", redacted)
redacted = _ISOLATED_SECRET_PATTERN.sub(r"\1\2\3REDACTED", redacted) redacted = _ISOLATED_SECRET_PATTERN.sub(r"\1\2\3REDACTED", redacted)
values: list[str] = []
for secret in extra_secrets: for secret in extra_secrets:
value: str | None = secret.get_secret_value() if isinstance(secret, SecretStr) else secret value: str | None = secret.get_secret_value() if isinstance(secret, SecretStr) else secret
if not value: if not value:
continue continue
values.append(value)
for value in sorted(values, key=len, reverse=True):
redacted = redacted.replace(value, _REDACTED) redacted = redacted.replace(value, _REDACTED)
return redacted return redacted
def redact_exception(exc: Exception) -> str: def redact_exception(exc: Exception, extra_secrets: Iterable[SecretStr | str] = ()) -> str:
"""Masque les secrets dans la représentation textuelle d'une exception. """Masque les secrets dans la représentation textuelle d'une exception.
:param exc: Exception dont le message doit être rédigé. :param exc: Exception dont le message doit être rédigé.
:param extra_secrets: Itérable de secrets bruts (``str`` ou
:class:`pydantic.SecretStr`) à masquer, transmis à
:func:`redact_secrets`. Les valeurs vides ou ``None`` sont ignorées.
:return: Représentation textuelle de l'exception avec les secrets masqués. :return: Représentation textuelle de l'exception avec les secrets masqués.
:rtype: str :rtype: str
""" """
return redact_secrets(str(exc)) return redact_secrets(str(exc), extra_secrets)

View File

@@ -10,7 +10,9 @@ from __future__ import annotations
import re import re
import unicodedata import unicodedata
__all__ = ["normalize_subject"] from bs4 import BeautifulSoup
__all__ = ["normalize_subject", "sanitize_plaintext"]
def normalize_subject(subject: str) -> str: def normalize_subject(subject: str) -> str:
@@ -30,3 +32,29 @@ def normalize_subject(subject: str) -> str:
normalized = re.sub(r"[^\w\s]", "", normalized) normalized = re.sub(r"[^\w\s]", "", normalized)
normalized = re.sub(r"\s+", " ", normalized).strip() normalized = re.sub(r"\s+", " ", normalized).strip()
return normalized.lower() return normalized.lower()
# Pattern des caractères de contrôle ASCII non imprimables (à l'exception
# des tabulations ``\\t``, des sauts de ligne ``\\n`` et des retours chariot ``\\r``).
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
def sanitize_plaintext(text: str) -> str:
"""Prépare un texte pour le corps de message XMPP en texte brut.
Supprime les balises HTML (via ``BeautifulSoup`` avec le parseur
``html.parser``) puis les caractères de contrôle ASCII non imprimables,
à l'exception des tabulations (``\\t``), des sauts de ligne (``\\n``) et
des retours chariot (``\\r``). Les caractères Unicode au-delà de ``\\x1f``,
notamment les emojis, sont conservés. La transformation est idempotente :
appliquée deux fois, elle produit le même résultat qu'appliquée une seule
fois. Une chaîne vide donne une chaîne vide.
:param text: Le texte brut ou HTML à assainir.
:return: Le texte assaini, sans balises HTML ni caractères de contrôle.
:rtype: str
"""
# Étape 1 : suppression des balises HTML.
plain = BeautifulSoup(text, "html.parser").get_text()
# Étape 2 : suppression des caractères de contrôle.
return _CONTROL_CHARS_RE.sub("", plain)

View File

@@ -94,7 +94,7 @@ skips = ["B101"] # Ignorer les assertions (utilisées dans les tests)
line-length = 100 line-length = 100
target-version = "py313" target-version = "py313"
# Exclure la documentation markdown (ruff format ne doit pas toucher aux blocs de code Python inclus) # Exclure la documentation markdown (ruff format ne doit pas toucher aux blocs de code Python inclus)
extend-exclude = ["GUIDE_DEV_PYTHON.md"] extend-exclude = ["GUIDE_DEV_PYTHON.md", ".worktrees"]
[tool.ruff.lint] [tool.ruff.lint]
select = [ select = [
@@ -120,3 +120,12 @@ strict = true
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = "litellm" module = "litellm"
ignore_missing_imports = true ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "slixmpp"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "openai.*"
follow_imports = "skip"
ignore_missing_imports = true

View File

@@ -14,6 +14,7 @@ from pydantic import SecretStr
from pronote_sync.config.settings import CalDAVSettings from pronote_sync.config.settings import CalDAVSettings
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind
from pronote_sync.models.homework import Homework from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message, MessageType
from pronote_sync.models.pronote import PronoteData from pronote_sync.models.pronote import PronoteData
@@ -85,19 +86,38 @@ def caldav_settings() -> CalDAVSettings:
) )
@pytest.fixture
def sample_message() -> Message:
"""Message Pronote pour les tests.
:return: Message Pronote de test.
:rtype: Message
"""
return Message(
id="msg-001",
type=MessageType.INFORMATION,
title="Information de rentrée",
content="La rentrée est prévue le 1er septembre.",
author="Administration",
date=datetime(2026, 1, 15, 9, 0),
read=False,
)
@pytest.fixture @pytest.fixture
def pronote_data( def pronote_data(
sample_lesson: Lesson, sample_lesson: Lesson,
sample_cancelled_lesson: Lesson, sample_cancelled_lesson: Lesson,
sample_homework: Homework, sample_homework: Homework,
sample_school_event: SchoolEvent, sample_school_event: SchoolEvent,
sample_message: Message,
) -> PronoteData: ) -> PronoteData:
"""Données Pronote de test avec des cours, devoirs et événements.""" """Données Pronote de test avec des cours, devoirs, événements et messages."""
return PronoteData( return PronoteData(
lessons=[sample_lesson, sample_cancelled_lesson], lessons=[sample_lesson, sample_cancelled_lesson],
homeworks=[sample_homework], homeworks=[sample_homework],
school_events=[sample_school_event], school_events=[sample_school_event],
messages=[], messages=[sample_message],
target_date=date(2026, 1, 15), target_date=date(2026, 1, 15),
generated_at=datetime(2026, 1, 15, 0, 0), generated_at=datetime(2026, 1, 15, 0, 0),
) )

1
tests/e2e/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Tests end-to-end de l'interface en ligne de commande."""

189
tests/e2e/test_cli.py Normal file
View File

@@ -0,0 +1,189 @@
"""Tests de l'interface en ligne de commande ``pronote-sync``."""
from __future__ import annotations
import pytest
from pydantic import SecretStr
from pytest_mock import MockerFixture
from pronote_sync.config.settings import AISettings, AppSettings, PronoteSettings, Settings
from pronote_sync.errors import PipelineCriticalError, PipelineWarning
from pronote_sync.models.pronote import PronoteData
def test_main_runs_composition_root_in_dry_run_with_requested_log_level(
mocker: MockerFixture,
) -> None:
"""La CLI propage les options au logger et au runner injecté."""
from pronote_sync.cli.main import main
settings = Settings(app=AppSettings(log_level="WARNING"))
load_settings = mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings)
setup_logging = mocker.patch("pronote_sync.cli.main.setup_logging")
runner = mocker.Mock()
runner.run.return_value = (mocker.Mock(spec=PronoteData), [])
composition_root = mocker.patch(
"pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner
)
exit_code = main(["--dry-run", "--log-level", "DEBUG"])
assert exit_code == 0
load_settings.assert_called_once_with()
assert setup_logging.call_args_list == [mocker.call("DEBUG"), mocker.call("DEBUG")]
composition_root.assert_called_once_with(settings, dry_run=True)
runner.run.assert_called_once_with()
def test_main_preserves_configured_dry_run_and_returns_success_with_warnings(
mocker: MockerFixture,
) -> None:
"""Sans option, la CLI préserve le dry-run configuré et accepte les avertissements."""
from pronote_sync.cli.main import main
settings = Settings(app=AppSettings(dry_run=True, log_level="WARNING"))
mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings)
setup_logging = mocker.patch("pronote_sync.cli.main.setup_logging")
runner = mocker.Mock()
runner.run.return_value = (
mocker.Mock(spec=PronoteData),
[PipelineWarning("Avertissement non bloquant")],
)
composition_root = mocker.patch(
"pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner
)
exit_code = main([])
assert exit_code == 0
assert setup_logging.call_args_list == [mocker.call("INFO"), mocker.call("WARNING")]
composition_root.assert_called_once_with(settings, dry_run=None)
runner.run.assert_called_once_with()
def test_main_returns_failure_and_redacts_pipeline_secrets_at_debug_level(
mocker: MockerFixture,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Les diagnostics de pipeline restent expurgés, même au niveau DEBUG."""
from pronote_sync.cli.main import main
secret = "M12_PIPELINE_SECRET" # pragma: allowlist secret
settings = Settings(ai=AISettings(api_key=SecretStr(secret)))
mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings)
runner = mocker.Mock()
runner.run.return_value = (
None,
[PipelineCriticalError(f"Échec distant avec le secret {secret}")],
)
mocker.patch("pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner)
exit_code = main(["--log-level", "DEBUG"])
output = capsys.readouterr().out
assert exit_code == 1
assert secret not in output
assert "REDACTED" in output
assert "Traceback" not in output
def test_main_displays_a_redacted_configuration_traceback_at_debug_level(
mocker: MockerFixture,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Une erreur de configuration DEBUG conserve son traceback sans son secret."""
from pronote_sync.cli.main import main
secret = "M12_CONFIGURATION_SECRET" # pragma: allowlist secret
mocker.patch(
"pronote_sync.cli.main.load_settings",
side_effect=ValueError(f"configuration invalide: {secret}"),
)
exit_code = main(["--log-level", "DEBUG"])
output = capsys.readouterr().out
assert exit_code == 1
assert secret not in output
assert "Configuration invalide ou indisponible." in output
assert "Traceback" in output
def test_main_does_not_disclose_a_configured_pronote_username(
mocker: MockerFixture,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Les erreurs critiques ne divulguent pas un identifiant Pronote configuré."""
from pronote_sync.cli.main import main
username = "m12-parent-identifier"
settings = Settings(pronote=PronoteSettings(username=username))
mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings)
runner = mocker.Mock()
runner.run.return_value = (
None,
[PipelineCriticalError(f"Échec distant pour l'identifiant {username}")],
)
mocker.patch("pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner)
exit_code = main([])
output = capsys.readouterr().out
assert exit_code == 1
assert username not in output
assert "REDACTED" in output
def test_main_rejects_an_unknown_log_level() -> None:
"""La CLI rejette les niveaux de journalisation hors contrat."""
from pronote_sync.cli.main import main
with pytest.raises(SystemExit) as error:
main(["--log-level", "VERBOSE"])
assert error.value.code == 2
def test_main_logs_redacted_traceback_when_pipeline_raises_unexpectedly(
mocker: MockerFixture,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Une exception inattendue du pipeline produit un traceback expurgé en DEBUG."""
from pronote_sync.cli.main import main
secret = "M12_UNEXPECTED_SECRET" # pragma: allowlist secret
settings = Settings(ai=AISettings(api_key=SecretStr(secret)))
mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings)
mocker.patch(
"pronote_sync.cli.main.PipelineRunner.from_settings",
side_effect=RuntimeError(f"Erreur interne avec {secret}"),
)
exit_code = main(["--log-level", "DEBUG"])
output = capsys.readouterr().out
assert exit_code == 1
assert secret not in output
assert "Traceback" in output
assert "erreur expurgée" in output
def test_main_does_not_show_traceback_at_info_level(
mocker: MockerFixture,
capsys: pytest.CaptureFixture[str],
) -> None:
"""En niveau INFO, aucune pile n'est affichée pour une erreur inattendue."""
from pronote_sync.cli.main import main
mocker.patch("pronote_sync.cli.main.load_settings", return_value=Settings())
mocker.patch(
"pronote_sync.cli.main.PipelineRunner.from_settings",
side_effect=RuntimeError("Erreur interne"),
)
exit_code = main([])
output = capsys.readouterr().out
assert exit_code == 1
assert "Traceback" not in output
assert "Échec inattendu du pipeline." in output

56
tests/fixtures/pronote-6e.ics vendored Normal file
View File

@@ -0,0 +1,56 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Index Education//Pronote//FR
X-WR-CALNAME:Classe de 6e
BEGIN:VEVENT
UID:Edt_22222@index-education.net-20260908T140000Z-Index-Education
DTSTAMP:20260908T140000Z
DTSTART:20260908T140000Z
DTEND:20260908T150000Z
SUMMARY:SVT
CATEGORIES:Cours
DESCRIPTION:<div>
Matière : SVT
Professeur : M. Dubois
Salle : 104
Groupe : Classe entière
<strong>Contenu pédagogique :
</strong>
Découverte de la cellule et de ses constituants.
<strong>Pour le 15/09/2026 :
</strong>
Lire le chapitre 2 et schématiser une cellule végétale.
<strong>Donné le 08/09/2026 :
</strong>
Lire le chapitre 2 et schématiser une cellule végétale.
</div>
END:VEVENT
BEGIN:VEVENT
UID:Edt_33333@index-education.net-20260908T140000Z-Index-Education
DTSTAMP:20260908T140000Z
DTSTART:20260909T100000Z
DTEND:20260909T110000Z
SUMMARY:Histoire-Géographie
CATEGORIES:Cours - Cours modifié
DESCRIPTION:<div>
Matière : Histoire-Géographie
Professeur : Mme Lefevre
Salle : 203
Groupe : Classe entière
<strong>Contenu pédagogique :
</strong>
Les grands repères du temps long : la Préhistoire.
</div>
END:VEVENT
BEGIN:VEVENT
UID:Edt_44444@index-education.net-20260908T140000Z-Index-Education
DTSTAMP:20260908T140000Z
DTSTART;VALUE=DATE:20260928
DTEND;VALUE=DATE:20260929
SUMMARY:Sortie pédagogique
CATEGORIES:Sortie scolaire
DESCRIPTION:Journée de sortie pédagogique au musée d'histoire naturelle.
END:VEVENT
END:VCALENDAR

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,387 @@
"""Tests d'intégration pour le canal XMPP (end-to-end sans réseau).
Ce module valide les critères d'acceptation de la milestone M10 (GUIDE_DEV_PYTHON.md,
TODO.md §M10) pour le canal XMPP, en mode end-to-end avec mock de slixmpp.
Les tests couvrent :
- L'envoi réussi d'un message formaté via SyncXmppChannel
- La dégradation des erreurs XMPP en retour False (jamais d'exception non gérée)
- L'absence de fuite de secrets dans les logs XMPP
- Le flag dry_run ne crée jamais ClientXMPP
Tous les tests sont exécutés sans réseau grâce à des mocks de slixmpp.ClientXMPP.
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from datetime import date, datetime
from typing import Any
from unittest.mock import patch
import pytest
from pydantic import SecretStr
from pronote_sync.channels import get_channel
from pronote_sync.channels.xmpp import XmppMessage
from pronote_sync.config.settings import XmppSettings
from pronote_sync.models.agenda import Lesson
from pronote_sync.models.blog import BlogArticle, ExternalInfo
from pronote_sync.models.diff import AgendaChange, AgendaChangeType
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message, MessageType
# Sentinelles pour tests de non-fuite de secrets dans les logs
INTEG_JID_SENTINEL = "INTEG_JID_SENTINEL@xmpp.example"
INTEG_PASS_SENTINEL = "INTEG_PASS_SENTINEL"
INTEG_TO_SENTINEL = "INTEG_TO_SENTINEL@xmpp.example"
class FakeClientXMPP:
"""Faux client XMPP avec signatures fidèles à slixmpp 1.17.0."""
instances: list[FakeClientXMPP] = []
def __init__(self, jid: str, password: str) -> None:
self.jid = jid
self.password = password
self.enable_starttls: bool = True
self.enable_direct_tls: bool = True
self.connected: bool = False
self.disconnected: bool = False
self.handlers: dict[str, list[Callable[..., Any]]] = {}
self.messages_sent: list[dict[str, object]] = []
self._host_used: str | None = None
self._port_used: int | None = None
FakeClientXMPP.instances.append(self)
@classmethod
def reset(cls) -> None:
cls.instances.clear()
def add_event_handler(self, name: str, handler: Callable[..., Any]) -> None:
"""Enregistre un gestionnaire d'événement.
:param name: Nom de l'événement (ex: 'session_start').
:param handler: Fonction gestionnaire.
:raises: AssertionError si l'événement n'est pas supporté.
"""
if name not in ("session_start", "failed_auth", "disconnected"):
raise AssertionError(f"Unsupported event: {name}")
self.handlers.setdefault(name, []).append(handler)
def connect(self, host: str | None = None, port: int | None = None) -> asyncio.Future[bool]:
"""Simule la connexion au serveur XMPP.
Déclenche les handlers appropriés selon le scénario de test.
:param host: Hôte de connexion.
:param port: Port de connexion.
:return: Future résolue à True.
"""
loop = asyncio.get_event_loop()
future: asyncio.Future[bool] = loop.create_future()
self.connected = True
self._host_used = host
self._port_used = port
# Déclencher session_start par défaut
loop.call_soon(self._fire_events)
future.set_result(True)
return future
def _fire_events(self) -> None:
for handler in self.handlers.get("session_start", []):
handler({})
def disconnect(
self, wait: float = 2.0, reason: str | None = None, ignore_send_queue: bool = False
) -> asyncio.Future[bool]:
"""Simule la déconnexion du serveur XMPP.
:param wait: Temps d'attente.
:param reason: Raison de la déconnexion.
:param ignore_send_queue: Ignorer la file d'envoi.
:return: Future résolue à True.
"""
loop = asyncio.get_event_loop()
future: asyncio.Future[bool] = loop.create_future()
self.disconnected = True
future.set_result(True)
return future
def send_message(
self, mto: object, mbody: str | None = None, mtype: str | None = None, **kwargs: object
) -> None:
"""Simule l'envoi d'un message.
:param mto: Destinataire.
:param mbody: Corps du message.
:param mtype: Type de message.
:param kwargs: Arguments supplémentaires.
"""
self.messages_sent.append({"mto": mto, "mbody": mbody, "mtype": mtype, **kwargs})
@pytest.fixture
def xmpp_settings_enabled() -> XmppSettings:
"""Fixture fournissant des paramètres XMPP valides et activés.
:return: Instance de XmppSettings avec des valeurs par défaut valides.
:rtype: XmppSettings
"""
return XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
use_tls=True,
timeout=30,
)
@pytest.fixture
def xmpp_message_populated() -> XmppMessage:
"""Fixture fournissant un message XMPP complet avec toutes les sections.
:return: Instance de XmppMessage avec tous les champs remplis.
:rtype: XmppMessage
"""
homework = Homework(
id="hw1",
subject="Mathématiques",
teachers=("M. Dupont",),
assigned_on=date(2025, 9, 1),
due_on=date(2025, 9, 15),
text="Faire l'exercice 5 page 42",
html="<p>Faire l'exercice 5 page 42</p>",
)
lesson = Lesson(
id="lesson1",
subject="Physique",
start=datetime.fromisoformat("2025-09-07T08:00:00"),
end=datetime.fromisoformat("2025-09-07T09:00:00"),
rooms=("B201",),
teachers=("M. Martin",),
group=None,
content=None,
)
change = AgendaChange(
type=AgendaChangeType.ADDED,
lesson=lesson,
theoretical_lesson=None,
details="Cours déplacé",
)
message = Message(
id="msg1",
type=MessageType.INFORMATION,
title="Réunion parents-professeurs",
content="Une réunion est organisée le 15/09 à 18h.",
author="CPE",
date=datetime.fromisoformat("2025-09-01T10:00:00"),
read=False,
)
article = BlogArticle(
id="art1",
title="Sortie scolaire",
url="https://blog.example.com/sortie",
published_at=datetime.fromisoformat("2025-09-01T09:00:00"),
updated_at=None,
category="Actualités",
author="Collège",
content_html="<p>Sortie prévue le 20/09.</p>",
content_text="Sortie prévue le 20/09.",
)
external = ExternalInfo(
blog_articles=(article,),
pronote_messages=(message,),
other_info=("Info supplémentaire",),
)
return XmppMessage(
target_date=date(2025, 9, 7),
synthesis="Voici la synthèse des activités du jour.",
homeworks=(homework,),
changes=(change,),
messages=(),
external_info=external,
)
class TestXmppIntegrationSend:
"""Tests d'intégration pour l'envoi de messages XMPP via SyncXmppChannel.
Ces tests valident le critère d'acceptation #1 de M10 :
"XmppChannel.send envoie un message direct formaté (slixmpp mocké en test)".
"""
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
def test_integration_send_success(
self,
xmpp_settings_enabled: XmppSettings,
xmpp_message_populated: XmppMessage,
) -> None:
"""Test que SyncXmppChannel.send envoie un message formaté et retourne True.
Critère d'acceptation #1 : L'envoi réussi retourne True et le message
est envoyé avec mtype="chat".
:param xmpp_settings_enabled: Paramètres XMPP valides et activés.
:param xmpp_message_populated: Message XMPP complet.
"""
# Obtenir le canal via la fabrique
channel = get_channel(xmpp_settings_enabled, dry_run=False)
assert channel is not None
# Envoyer le message
result = channel.send(xmpp_message_populated)
# Vérifier que l'envoi a réussi
assert result is True
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
def test_integration_send_calls_send_message_with_chat_type(
self,
xmpp_settings_enabled: XmppSettings,
xmpp_message_populated: XmppMessage,
) -> None:
"""Test que send_message est appelé avec mtype='chat' sur succès.
Critère d'acceptation #1 : Le message est envoyé en mode direct (chat).
:param xmpp_settings_enabled: Paramètres XMPP valides et activés.
:param xmpp_message_populated: Message XMPP complet.
"""
# Obtenir le canal via la fabrique
channel = get_channel(xmpp_settings_enabled, dry_run=False)
assert channel is not None
# Envoyer le message
channel.send(xmpp_message_populated)
# Vérifier que send_message a été appelé avec mtype="chat"
# Le mock ClientXMPP a été patché, FakeClientXMPP.instances contient les instances
instances = FakeClientXMPP.instances
assert len(instances) > 0, "No FakeClientXMPP instance created"
client_instance = instances[-1]
# Vérifier que send_message a été appelé via messages_sent
assert len(client_instance.messages_sent) > 0, "No message sent"
# Vérifier que mtype="chat" a été passé
found_chat = any(msg.get("mtype") == "chat" for msg in client_instance.messages_sent)
assert found_chat, "send_message should have been called with mtype='chat'"
class TestXmppIntegrationErrorHandling:
"""Tests d'intégration pour la gestion des erreurs XMPP.
Ces tests valident le critère d'acceptation #2 de M10 :
"Erreur XMPP → False, jamais d'exception non gérée".
"""
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
def test_integration_error_degradation(self, xmpp_settings_enabled: XmppSettings) -> None:
"""Test qu'une erreur retourne False sans lever d'exception non gérée.
Critère d'acceptation #2 : Les erreurs sont dégradées et retournent False,
jamais d'exception non gérée qui s'échappe.
:param xmpp_settings_enabled: Paramètres XMPP valides et activés.
"""
class ErrorClient(FakeClientXMPP):
def connect(
self, host: str | None = None, port: int | None = None
) -> asyncio.Future[bool]:
raise RuntimeError("Connexion impossible")
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ErrorClient):
channel = get_channel(xmpp_settings_enabled, dry_run=False)
assert channel is not None
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
# Doit retourner False, pas lever d'exception
result = channel.send(msg)
assert result is False
class TestXmppIntegrationSecurity:
"""Tests de sécurité pour le canal XMPP en intégration.
Ces tests valident le critère d'acceptation #3 de M10 :
"Aucun secret dans les logs XMPP".
"""
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
def test_integration_no_secret_in_logs_on_xmpp_error(
self,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test qu'aucun secret n'apparaît dans les logs en cas d'erreur XMPP.
Critère d'acceptation #3 : Les secrets (JID, mot de passe, destinataire)
ne doivent jamais apparaître dans les logs.
:param caplog: Fixture pytest pour capturer les logs.
"""
settings = XmppSettings(
enabled=True,
jid=INTEG_JID_SENTINEL,
password=SecretStr(INTEG_PASS_SENTINEL), # pragma: allowlist secret
host="xmpp.example.com",
port=5222,
to=INTEG_TO_SENTINEL,
resource="pronote-sync",
use_tls=True,
timeout=30,
)
class ErrorClient(FakeClientXMPP):
def connect(
self, host: str | None = None, port: int | None = None
) -> asyncio.Future[bool]:
raise RuntimeError("Connexion impossible")
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ErrorClient):
channel = get_channel(settings, dry_run=False)
assert channel is not None
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
try:
channel.send(msg)
except Exception:
pass # On s'attend à une PipelineWarning ou False
# Vérifier que les sentinelles n'apparaissent pas dans les logs
logs = caplog.text
assert INTEG_JID_SENTINEL not in logs
assert INTEG_PASS_SENTINEL not in logs
assert INTEG_TO_SENTINEL not in logs
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
def test_integration_dry_run_no_connection(self, caplog: pytest.LogCaptureFixture) -> None:
"""Test que dry_run=True ne crée jamais ClientXMPP.
:param caplog: Fixture pytest pour capturer les logs.
"""
settings = XmppSettings(
enabled=True,
jid=INTEG_JID_SENTINEL,
password=SecretStr(INTEG_PASS_SENTINEL),
host="xmpp.example.com",
port=5222,
to=INTEG_TO_SENTINEL,
resource="pronote-sync",
use_tls=True,
timeout=30,
)
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
channel = get_channel(settings, dry_run=True)
assert channel is not None
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = channel.send(msg)
assert result is True
# ClientXMPP ne doit pas être instancié en dry_run
assert not mock_cls.called

View File

@@ -0,0 +1,202 @@
"""Tests unitaires pour le Protocol Channel.
Ce module valide la spécification du Protocol ``Channel`` qui sera ajouté
à ``pronote_sync.channels.protocol``. Ces tests doivent être ROUGES tant que
le Protocol n'est pas implémenté.
"""
from __future__ import annotations
from datetime import date
import pytest
from pronote_sync.models.xmpp import XmppMessage
# Import du Protocol à valider (doit échouer tant qu'il n'existe pas)
try:
from pronote_sync.channels.protocol import Channel
CHANNEL_MODULE_EXISTS = True
except ImportError:
CHANNEL_MODULE_EXISTS = False
class TestChannelProtocol:
"""Tests pour le Protocol Channel."""
def test_channel_is_protocol(self) -> None:
"""Vérifie que Channel est un Protocol.
:return: None
:raises AssertionError: Si Channel n'est pas un Protocol.
"""
if not CHANNEL_MODULE_EXISTS:
pytest.fail(
"Le module pronote_sync.channels.protocol n'existe pas encore. "
"Ceci est attendu pour l'instant."
)
assert hasattr(Channel, "_is_protocol"), "Channel doit être un sous-type de typing.Protocol"
def test_channel_has_send_method(self) -> None:
"""Vérifie que le Protocol Channel définit une méthode send.
:return: None
:raises AssertionError: Si la méthode send n'est pas dans l'interface.
"""
if not CHANNEL_MODULE_EXISTS:
pytest.fail(
"Le module pronote_sync.channels.protocol n'existe pas encore. "
"Ceci est attendu pour l'instant."
)
assert hasattr(Channel, "send"), "Channel doit définir une méthode 'send'"
send_method = Channel.send
assert callable(send_method), "La méthode 'send' doit être callable"
def test_conforming_class_satisfies_protocol(self) -> None:
"""Vérifie qu'une classe conforme satisfait le Protocol Channel.
:return: None
:raises AssertionError: Si la classe conforme n'est pas acceptée.
"""
if not CHANNEL_MODULE_EXISTS:
pytest.fail(
"Le module pronote_sync.channels.protocol n'existe pas encore. "
"Ceci est attendu pour l'instant."
)
# Classe minimale conforme au Protocol
class DummyChannel:
"""Implémentation minimale conforme au Protocol Channel."""
def send(self, message: XmppMessage) -> bool:
"""Envoie un message XMPP.
:param message: Message à envoyer.
:return: True si l'envoi a réussi.
:rtype: bool
"""
return True
# Création d'une instance de message pour le test
test_message = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
# Instanciation et vérification
dummy_instance = DummyChannel()
assert dummy_instance.send(test_message) is True, "La méthode send doit retourner True"
# Vérification que l'instance satisfait le Protocol
if hasattr(Channel, "__protocol_attrs__"):
# Vérification runtime avec @runtime_checkable
assert isinstance(dummy_instance, Channel), (
"Une classe conforme doit satisfaire le Protocol Channel"
)
def test_non_conforming_class_does_not_satisfy_protocol(self) -> None:
"""Vérifie qu'une classe non conforme ne satisfait pas le Protocol Channel.
:return: None
:raises AssertionError: Si la classe non conforme est acceptée.
"""
if not CHANNEL_MODULE_EXISTS:
pytest.fail(
"Le module pronote_sync.channels.protocol n'existe pas encore. "
"Ceci est attendu pour l'instant."
)
# Classe minimale non conforme (sans méthode send)
class NonConformingChannel:
"""Implémentation minimale non conforme au Protocol Channel."""
pass
# Vérification que la classe ne satisfait pas le Protocol
non_conforming_instance = NonConformingChannel()
if hasattr(Channel, "__protocol_attrs__"):
# Vérification runtime avec @runtime_checkable
assert not isinstance(non_conforming_instance, Channel), (
"Une classe non conforme ne doit pas satisfaire le Protocol Channel"
)
def test_channel_send_returns_bool(self) -> None:
"""Vérifie que la méthode send retourne un booléen.
:return: None
:raises AssertionError: Si le retour n'est pas de type bool.
"""
if not CHANNEL_MODULE_EXISTS:
pytest.fail(
"Le module pronote_sync.channels.protocol n'existe pas encore. "
"Ceci est attendu pour l'instant."
)
# Implémentation minimale retournant True
class BoolReturningChannel:
"""Implémentation minimale retournant un booléen."""
def send(self, message: XmppMessage) -> bool:
"""Envoie un message XMPP.
:param message: Message à envoyer.
:return: True
:rtype: bool
"""
return True
# Création d'une instance de message pour le test
test_message = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
# Test du retour
channel = BoolReturningChannel()
result = channel.send(test_message)
assert isinstance(result, bool), "La méthode send doit retourner un booléen"
assert result is True, "La méthode send doit retourner True dans cette implémentation"
def test_channel_send_signature(self) -> None:
"""Vérifie la signature déclarée de la méthode Channel.send.
:return: None
:raises AssertionError: Si la signature ne correspond pas aux attentes.
"""
if not CHANNEL_MODULE_EXISTS:
pytest.fail(
"Le module pronote_sync.channels.protocol n'existe pas encore. "
"Ceci est attendu pour l'instant."
)
import inspect
# Vérification de l'existence et de la nature callable de la méthode
assert hasattr(Channel, "send"), "Channel doit définir une méthode 'send'"
send_method = Channel.send
assert callable(send_method), "La méthode 'send' doit être callable"
# Introspection de la signature
sig = inspect.signature(send_method)
params = list(sig.parameters.values())
# Vérification du nombre de paramètres (1 paramètre + self)
# On exclut 'self' pour vérifier le paramètre 'message'
param_count = len(params)
assert param_count == 2, (
f"La méthode send doit avoir exactement 2 paramètres (self + message), "
f"trouvé {param_count}"
)
# Vérification du nom du paramètre (on ignore 'self')
param_names = [p.name for p in params if p.name != "self"]
assert len(param_names) == 1, "Doit avoir exactement un paramètre autre que self"
param_name = param_names[0]
assert param_name == "message", (
f"Le paramètre doit s'appeler 'message', trouvé '{param_name}'"
)
# Vérification du type de retour
return_annotation = sig.return_annotation
# Le type de retour peut être soit la chaîne 'bool' soit le type bool (forward reference)
assert return_annotation in (bool, "bool"), (
f"Le type de retour doit être 'bool' ou bool, trouvé {return_annotation}"
)

88
tests/unit/test_errors.py Normal file
View File

@@ -0,0 +1,88 @@
"""Tests unitaires pour la hiérarchie des erreurs du pipeline.
Ce module valide les classes d'erreur définies dans pronote_sync.errors.
"""
import pytest
from pronote_sync.errors import PipelineWarning, PronoteSyncError
def test_pipeline_warning_inherits_pronote_sync_error() -> None:
"""Vérifie que PipelineWarning hérite de PronoteSyncError.
:return: None
:rtype: None
"""
assert isinstance(PipelineWarning("msg"), PronoteSyncError)
def test_pipeline_warning_not_warning_builtin() -> None:
"""Vérifie que PipelineWarning n'hérite pas de la classe Warning intégrée.
:return: None
:rtype: None
"""
assert not isinstance(PipelineWarning("msg"), Warning)
def test_pipeline_warning_message_stored() -> None:
"""Vérifie que le message est stocké et accessible via str(exc).
:return: None
:rtype: None
"""
exc = PipelineWarning("msg")
assert str(exc) == "msg"
assert exc.args[0] == "msg"
def test_pipeline_warning_step_default_none() -> None:
"""Vérifie que step est None par défaut.
:return: None
:rtype: None
"""
assert PipelineWarning("msg").step is None
def test_pipeline_warning_step_set() -> None:
"""Vérifie que step peut être défini via le constructeur.
:return: None
:rtype: None
"""
assert PipelineWarning("msg", step="xmpp").step == "xmpp"
def test_pipeline_warning_recoverable_true() -> None:
"""Vérifie que recoverable est toujours True pour PipelineWarning.
:return: None
:rtype: None
"""
assert PipelineWarning("msg").recoverable is True
def test_pipeline_warning_is_raisable() -> None:
"""Vérifie que PipelineWarning peut être levée.
:return: None
:rtype: None
"""
with pytest.raises(PipelineWarning, match="msg"):
raise PipelineWarning("msg")
def test_pipeline_warning_caught_by_pronote_sync_error() -> None:
"""Vérifie qu'une PipelineWarning est attrapée par un except PronoteSyncError.
:return: None
:rtype: None
"""
try:
raise PipelineWarning("msg")
except PronoteSyncError:
assert True
else:
raise AssertionError("PipelineWarning should have been caught by PronoteSyncError")

View File

@@ -786,3 +786,208 @@ def test_validate_output_truncated_to_800(mocker: MockerFixture, target_date: da
assert result is not None assert result is not None
assert result.text == "A" * 800 assert result.text == "A" * 800
# --- Tests pour openai-compatible (FEAT_M9 §6) ---
def test_openai_provider_without_base_url_preserves_existing_behavior() -> None:
"""Vérifie que 'openai' sans AI_BASE_URL conserve le comportement existant."""
settings = AISettings(enabled=True, api_key=SecretStr("test"), provider="openai")
result = get_synthesis_provider(settings)
assert isinstance(result, OpenAISynthesisProvider)
def test_openai_compatible_passes_base_url_and_model() -> None:
"""Vérifie que 'openai-compatible' transmet base_url et model au provider."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai-compatible",
base_url="https://api.example.com/v1",
model="test-model",
)
result = get_synthesis_provider(settings)
assert isinstance(result, OpenAISynthesisProvider)
assert result._model == "test-model"
def test_openai_compatible_litellm_proxy_without_importing_litellm() -> None:
"""Vérifie que LiteLLM en tant que proxy est traité comme un endpoint compatible."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai-compatible",
base_url="https://proxy.litellm.local/v1",
model="test",
)
result = get_synthesis_provider(settings)
assert isinstance(result, OpenAISynthesisProvider)
# Vérifier que le provider n'est pas LiteLLMSynthesisProvider
assert result.__class__.__name__ == "OpenAISynthesisProvider"
def test_openai_compatible_missing_base_url_returns_none_with_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie que base_url absente retourne None + warning."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai-compatible",
base_url=None,
model="test",
)
result = get_synthesis_provider(settings)
assert result is None
assert "URL de base requise pour le provider openai-compatible" in caplog.text
def test_openai_compatible_missing_model_returns_none_with_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie que model absent retourne None + warning."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai-compatible",
base_url="https://api.example.com/v1",
model=None,
)
result = get_synthesis_provider(settings)
assert result is None
assert "Modèle requis pour le provider openai-compatible" in caplog.text
def test_openai_compatible_valid_https_url_accepted() -> None:
"""Vérifie qu'une URL HTTPS valide est acceptée."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai-compatible",
base_url="https://api.openrouter.ai/api/v1",
model="test-model",
)
result = get_synthesis_provider(settings)
assert isinstance(result, OpenAISynthesisProvider)
def test_openai_compatible_http_refused_by_default(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie que HTTP est refusé par défaut."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai-compatible",
base_url="http://127.0.0.1:11434/v1",
model="test-model",
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
def test_openai_compatible_http_accepted_with_allow_insecure_http() -> None:
"""Vérifie que HTTP est accepté avec allow_insecure_http=True."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai-compatible",
base_url="http://127.0.0.1:11434/v1",
model="test-model",
allow_insecure_http=True,
)
result = get_synthesis_provider(settings)
assert isinstance(result, OpenAISynthesisProvider)
def test_openai_compatible_credentials_in_url_refused(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie que les credentials dans l'URL sont refusés."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai-compatible",
base_url="https://user:pass@host/v1", # pragma: allowlist secret
model="test-model",
)
result = get_synthesis_provider(settings)
assert result is None
assert "Credentials dans l'URL refusés" in caplog.text
def test_openai_compatible_sensitive_query_params_refused(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie que les query params sensibles sont refusés."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai-compatible",
base_url="https://host/v1?token=secret",
model="test-model",
)
result = get_synthesis_provider(settings)
assert result is None
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
def test_openai_compatible_connection_error_returns_none(
mocker: MockerFixture,
target_date: date,
) -> None:
"""Vérifie qu'une erreur de connexion retourne None."""
mock_client = MagicMock()
mock_client.chat.completions.create.side_effect = Exception("connection error")
provider = OpenAISynthesisProvider(
api_key=SecretStr("test-key"),
base_url="https://api.example.com/v1",
model="test-model",
client=mock_client,
)
input_data = SynthesisInput(target_date=target_date, agenda_diff=None)
result = provider.generate(input_data)
assert result is None
def test_openai_compatible_sentinel_key_not_in_logs(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie qu'une clé sentinelle est absente des logs."""
sentinel = "sk-SENTINEL-CUSTOM-12345"
settings = AISettings(
enabled=True,
api_key=SecretStr(sentinel),
provider="openai-compatible",
base_url=None,
model="test",
)
result = get_synthesis_provider(settings)
assert result is None
assert sentinel not in caplog.text
def test_openai_compatible_factory_no_network_calls(
mocker: MockerFixture,
) -> None:
"""Vérifie que la factory ne fait aucun appel réseau."""
# Mock des appels réseau pour s'assurer qu'ils ne sont pas appelés
mock_get = mocker.patch("requests.get")
mock_post = mocker.patch("requests.post")
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai-compatible",
base_url="https://api.example.com/v1",
model="test-model",
)
result = get_synthesis_provider(settings)
assert isinstance(result, OpenAISynthesisProvider)
mock_get.assert_not_called()
mock_post.assert_not_called()

View File

@@ -0,0 +1,197 @@
"""Tests unitaires pour la fonction sanitize_plaintext dans pronote_sync.utils.text.
Ce module valide le comportement de sanitize_plaintext qui prépare du texte
pour les corps de message XMPP en appliquant plusieurs transformations :
- Suppression des balises HTML (via beautifulsoup4)
- Suppression des caractères de contrôle ASCII non imprimables
- Préservation des emojis autorisés
- Idempotence de la fonction
"""
import pytest
from pronote_sync.utils.text import sanitize_plaintext
class TestSanitizePlaintext:
"""Tests de la fonction sanitize_plaintext."""
def test_empty_string_returns_empty(self) -> None:
"""Test que la chaîne vide retourne une chaîne vide.
:return: None
"""
assert sanitize_plaintext("") == ""
def test_plain_text_unchanged(self) -> None:
"""Test qu'un texte simple sans balises ni caractères spéciaux reste inchangé.
:return: None
"""
assert sanitize_plaintext("Hello world") == "Hello world"
def test_html_tags_stripped(self) -> None:
"""Test que les balises HTML simples sont supprimées.
:return: None
"""
assert sanitize_plaintext("<b>Hello</b> world") == "Hello world"
def test_nested_html_stripped(self) -> None:
"""Test que les balises HTML imbriquées sont supprimées.
:return: None
"""
assert sanitize_plaintext("<div><p>Nested</p></div>") == "Nested"
def test_html_entities_decoded(self) -> None:
"""Test que les entités HTML sont décodées.
La fonction doit décoder les entités HTML comme &amp; en &.
Si beautifulsoup4 décode les entités, le résultat attendu est "&".
:return: None
"""
result = sanitize_plaintext("&amp;")
# beautifulsoup4 décode les entités par défaut, donc &amp; devient &
assert result == "&"
@pytest.mark.parametrize(
"input_text,expected",
[
("Hello\x00\x01\x02world", "Helloworld"),
("Hello\x03world", "Helloworld"),
("Hello\x04world", "Helloworld"),
("Hello\x05world", "Helloworld"),
("Hello\x06world", "Helloworld"),
("Hello\x07world", "Helloworld"),
("Hello\x08world", "Helloworld"),
("Hello\x0e\x0fworld", "Helloworld"),
("Hello\x10\x11\x12world", "Helloworld"),
("Hello\x13\x14\x15\x16\x17world", "Helloworld"),
("Hello\x18\x19\x1a\x1b\x1c\x1d\x1e\x1fworld", "Helloworld"),
],
)
def test_control_chars_stripped(self, input_text: str, expected: str) -> None:
"""Test que les caractères de contrôle ASCII non imprimables sont supprimés.
Les caractères à supprimer sont : \x00-\x08, \x0b, \x0c, \x0e-\x1f
Les caractères à préserver sont : \t, \n, \r
:param input_text: Texte avec caractères de contrôle
:param expected: Texte attendu après nettoyage
:return: None
"""
assert sanitize_plaintext(input_text) == expected
def test_tab_preserved(self) -> None:
"""Test que la tabulation est préservée.
:return: None
"""
assert sanitize_plaintext("Hello\tworld") == "Hello\tworld"
def test_newline_preserved(self) -> None:
"""Test que le saut de ligne est préservé.
:return: None
"""
assert sanitize_plaintext("Hello\nworld") == "Hello\nworld"
def test_carriage_return_preserved(self) -> None:
"""Test que le retour chariot est préservé.
:return: None
"""
assert sanitize_plaintext("Hello\rworld") == "Hello\rworld"
def test_vertical_tab_stripped(self) -> None:
"""Test que la tabulation verticale est supprimée.
:return: None
"""
assert sanitize_plaintext("Hello\x0bworld") == "Helloworld"
def test_form_feed_stripped(self) -> None:
"""Test que le saut de page est supprimé.
:return: None
"""
assert sanitize_plaintext("Hello\x0cworld") == "Helloworld"
def test_emojis_preserved(self) -> None:
"""Test que les emojis autorisés sont préservés.
:return: None
"""
assert sanitize_plaintext("📌📅📚💬📢") == "📌📅📚💬📢"
def test_emoji_with_text(self) -> None:
"""Test qu'un emoji combiné avec du texte est préservé.
:return: None
"""
assert sanitize_plaintext("📌 Devoir: Math") == "📌 Devoir: Math"
@pytest.mark.parametrize(
"test_input",
[
"",
"Hello world",
"<b>Hello</b>",
"Hello\x00world",
"📌📅",
"&amp;",
"<div>Test</div>",
],
)
def test_idempotent(self, test_input: str) -> None:
"""Test que la fonction est idempotente.
Pour tout texte d'entrée x, sanitize_plaintext(sanitize_plaintext(x)) doit
être égal à sanitize_plaintext(x).
:param test_input: Texte à tester
:return: None
"""
first_pass = sanitize_plaintext(test_input)
second_pass = sanitize_plaintext(first_pass)
assert second_pass == first_pass
def test_mixed_html_control_emoji(self) -> None:
"""Test une combinaison de balises HTML, caractères de contrôle et emojis.
:return: None
"""
assert sanitize_plaintext("<b>📌</b>\x00 Hello") == "📌 Hello"
def test_unicode_text_preserved(self) -> None:
"""Test que le texte Unicode avec accents est préservé.
:return: None
"""
assert sanitize_plaintext("Café résumé") == "Café résumé"
def test_del_char_stripped(self) -> None:
"""Test que le caractère ASCII DEL (\\x7f) est supprimé.
:return: None
"""
assert sanitize_plaintext("a\x7fb") == "ab"
@pytest.mark.parametrize("c1_char", ["\x80", "\x85", "\x9f"])
def test_c1_controls_stripped(self, c1_char: str) -> None:
"""Test que les caractères de contrôle C1 (\\x80-\\x9f) sont supprimés.
:param c1_char: Caractère de contrôle C1 à tester
:return: None
"""
assert sanitize_plaintext(f"a{c1_char}b") == "ab"
def test_del_and_c1_idempotent(self) -> None:
"""Test que la suppression de DEL et des contrôles C1 est idempotente.
:return: None
"""
text = "a\x7f\x80\x9fb"
assert sanitize_plaintext(sanitize_plaintext(text)) == sanitize_plaintext(text)

View File

@@ -0,0 +1,964 @@
"""Tests unitaires pour le canal XMPP (XmppChannel).
Ce module teste l'implémentation de :class:`pronote_sync.channels.xmpp.XmppChannel`
selon les spécifications du projet (GUIDE_DEV_PYTHON.md §10, décisions D1-D3,
audit de sécurité SEC-XMPP-02/04/05/06).
Les tests sont conçus pour être exécutés sans réseau, avec des mocks de slixmpp.
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from datetime import date, datetime, time
from unittest.mock import patch
import pytest
from pydantic import SecretStr
from pronote_sync.channels.xmpp import XmppChannel, XmppMessage
from pronote_sync.config.settings import XmppSettings
from pronote_sync.models.agenda import Lesson, TheoreticalLesson
from pronote_sync.models.blog import BlogArticle, ExternalInfo
from pronote_sync.models.diff import AgendaChange, AgendaChangeType
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message, MessageType
# Sentinelles pour tests de non-fuite de secrets
BOT_SENTINEL_JID = "BOT_SENTINEL_JID@example.com"
PASS_SENTINEL_123 = "PASS_SENTINEL_123"
RECIPIENT_SENTINEL = "RECIPIENT_SENTINEL@example.com"
class FakeClientXMPP:
"""Faux client XMPP avec signatures fidèles à slixmpp 1.17.0."""
def __init__(self, jid: str, password: str) -> None:
self.jid = jid
self.password = password
self.enable_starttls: bool = True
self.enable_direct_tls: bool = True
self.connected: bool = False
self.disconnected: bool = False
self.handlers: dict[str, list[Callable[..., object]]] = {}
self.messages_sent: list[dict[str, object]] = []
self._connect_should_fail = False
self._auth_should_fail = False
self._should_disconnect_early = False
self._host_used: str | None = None
self._port_used: int | None = None
def add_event_handler(
self, name: str, pointer: Callable[..., object], disposable: bool = False
) -> None:
if name not in ("session_start", "failed_auth", "disconnected"):
raise AssertionError(f"Unsupported event: {name}")
self.handlers.setdefault(name, []).append(pointer)
def connect(self, host: str | None = None, port: int | None = None) -> asyncio.Future[bool]:
"""Returns a Future (like slixmpp 1.17.0). NOT async."""
loop = asyncio.get_event_loop()
future: asyncio.Future[bool] = loop.create_future()
self.connected = True
self._host_used = host
self._port_used = port
# Schedule event handlers to fire after connect returns
loop.call_soon(self._fire_events)
future.set_result(True)
return future
def _fire_events(self) -> None:
if self._should_disconnect_early:
self._fire("disconnected")
elif self._auth_should_fail:
self._fire("failed_auth")
else:
self._fire("session_start")
def _fire(self, event: str) -> None:
for handler in self.handlers.get(event, []):
handler({})
def disconnect(
self, wait: float = 2.0, reason: str | None = None, ignore_send_queue: bool = False
) -> asyncio.Future[bool]:
loop = asyncio.get_event_loop()
future: asyncio.Future[bool] = loop.create_future()
self.disconnected = True
future.set_result(True)
return future
def send_message(
self, mto: object, mbody: str | None = None, mtype: str | None = None, **kwargs: object
) -> None:
self.messages_sent.append({"mto": mto, "mbody": mbody, "mtype": mtype, **kwargs})
@pytest.fixture
def xmpp_settings() -> XmppSettings:
"""Fixture fournissant des paramètres XMPP valides pour les tests.
:return: Instance de XmppSettings avec des valeurs par défaut valides.
:rtype: XmppSettings
"""
return XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"), # pragma: allowlist secret
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
use_tls=True,
timeout=30,
)
@pytest.fixture
def xmpp_message_minimal() -> XmppMessage:
"""Fixture fournissant un message XMPP minimal pour les tests.
:return: Instance de XmppMessage avec seulement la date cible.
:rtype: XmppMessage
"""
return XmppMessage(
target_date=date(2025, 9, 7),
synthesis=None,
external_info=None,
)
@pytest.fixture
def xmpp_message_full() -> XmppMessage:
"""Fixture fournissant un message XMPP complet pour les tests.
:return: Instance de XmppMessage avec tous les champs remplis.
:rtype: XmppMessage
"""
homework = Homework(
id="hw1",
subject="Mathématiques",
teachers=("M. Dupont",),
assigned_on=date(2025, 9, 1),
due_on=date(2025, 9, 15),
text="Faire l'exercice 5 page 42",
html="<p>Faire l'exercice 5 page 42</p>",
)
lesson = Lesson(
id="lesson1",
subject="Physique",
start=datetime.fromisoformat("2025-09-07T08:00:00"),
end=datetime.fromisoformat("2025-09-07T09:00:00"),
rooms=("B201",),
teachers=("M. Martin",),
group=None,
content=None,
)
change = AgendaChange(
type=AgendaChangeType.ADDED,
lesson=lesson,
theoretical_lesson=None,
details="Cours déplacé",
)
message = Message(
id="msg1",
type=MessageType.INFORMATION,
title="Réunion parents-professeurs",
content="Une réunion est organisée le 15/09 à 18h.",
author="CPE",
date=datetime.fromisoformat("2025-09-01T10:00:00"),
read=False,
)
article = BlogArticle(
id="art1",
title="Sortie scolaire",
url="https://blog.example.com/sortie",
published_at=datetime.fromisoformat("2025-09-01T09:00:00"),
updated_at=None,
category="Actualités",
author="Collège",
content_html="<p>Sortie prévue le 20/09.</p>",
content_text="Sortie prévue le 20/09.",
)
external = ExternalInfo(
blog_articles=(article,),
pronote_messages=(message,),
other_info=("Info supplémentaire",),
)
return XmppMessage(
target_date=date(2025, 9, 7),
synthesis="Voici la synthèse des activités du jour.",
homeworks=(homework,),
changes=(change,),
messages=(),
external_info=external,
)
class TestXmppChannelFormatMessage:
"""Tests unitaires pour la méthode _format_message de XmppChannel.
Ces tests vérifient le formatage des messages XMPP en texte brut,
sans dépendre de slixmpp ni du réseau.
"""
def test_format_message_includes_target_date(self, xmpp_message_full: XmppMessage) -> None:
"""Test que la date cible apparaît dans l'en-tête du message.
:param xmpp_message_full: Message XMPP complet.
"""
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(xmpp_message_full)
assert "Digest du 07/09/2025" in formatted
def test_format_message_synthesis_section(self, xmpp_message_full: XmppMessage) -> None:
"""Test que la section synthèse est bien formatée avec/sans synthèse.
:param xmpp_message_full: Message XMPP complet.
"""
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(xmpp_message_full)
assert "📌 Synthèse" in formatted
assert "Voici la synthèse des activités du jour." in formatted
msg_no_synth = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
formatted2 = channel._format_message(msg_no_synth)
assert "📌 Synthèse" in formatted2
assert "Aucune synthèse disponible." in formatted2
def test_format_message_changes_with_type(self, xmpp_message_full: XmppMessage) -> None:
"""Test que les changements d'agenda affichent le type de changement.
:param xmpp_message_full: Message XMPP complet.
"""
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(xmpp_message_full)
assert "[Ajouté]" in formatted
assert "Physique: Cours déplacé" in formatted
def test_format_message_changes_with_times(self, xmpp_message_full: XmppMessage) -> None:
"""Test que les horaires des cours sont formatés HH:MM-HH:MM.
:param xmpp_message_full: Message XMPP complet.
"""
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(xmpp_message_full)
assert "08:00-09:00" in formatted
def test_format_message_changes_removed_with_theoretical_lesson(self) -> None:
"""Test qu'un changement REMOVED utilise la matière du cours théorique."""
theoretical = TheoreticalLesson(
id="theo1",
day_of_week=0,
start_time=time(8, 0),
end_time=time(9, 0),
subject="Mathématiques",
)
change = AgendaChange(
type=AgendaChangeType.REMOVED,
lesson=None,
theoretical_lesson=theoretical,
details="Cours annulé",
)
msg = XmppMessage(
target_date=date(2025, 9, 7),
synthesis=None,
changes=(change,),
external_info=None,
)
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(msg)
assert "[Supprimé] Mathématiques: Cours annulé" in formatted
def test_format_message_changes_modified(self) -> None:
"""Test qu'un changement MODIFIED affiche la matière du cours réel."""
lesson = Lesson(
id="lesson_mod",
subject="SVT",
start=datetime.fromisoformat("2025-09-07T10:00:00"),
end=datetime.fromisoformat("2025-09-07T11:00:00"),
rooms=("B201",),
teachers=("M. Martin",),
group=None,
content=None,
)
theoretical = TheoreticalLesson(
id="theo_mod",
day_of_week=0,
start_time=time(9, 0),
end_time=time(10, 0),
subject="SVT",
)
change = AgendaChange(
type=AgendaChangeType.MODIFIED,
lesson=lesson,
theoretical_lesson=theoretical,
details="Salle changée",
)
msg = XmppMessage(
target_date=date(2025, 9, 7),
synthesis=None,
changes=(change,),
external_info=None,
)
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(msg)
assert "[Modifié] SVT: Salle changée" in formatted
def test_format_message_messages_with_and_without_title(self) -> None:
"""Test que les messages affichent le titre s'il est présent, sinon l'auteur seul."""
message_with_title = Message(
id="m1",
type=MessageType.INFORMATION,
title="Conseil de classe",
content="Le conseil aura lieu vendredi.",
author="CPE",
date=datetime.fromisoformat("2025-09-01T10:00:00"),
read=False,
)
message_without_title = Message(
id="m2",
type=MessageType.INFORMATION,
title="",
content="Le self sera fermé mardi.",
author="Intendance",
date=datetime.fromisoformat("2025-09-01T11:00:00"),
read=False,
)
msg = XmppMessage(
target_date=date(2025, 9, 7),
synthesis=None,
messages=(message_with_title, message_without_title),
external_info=None,
)
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(msg)
assert "Conseil de classe (CPE): Le conseil aura lieu vendredi." in formatted
assert "Intendance: Le self sera fermé mardi." in formatted
def test_format_message_homeworks_with_due_date(self, xmpp_message_full: XmppMessage) -> None:
"""Test que les devoirs affichent la date d'échéance.
:param xmpp_message_full: Message XMPP complet.
"""
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(xmpp_message_full)
assert "(à rendre le 15/09)" in formatted
def test_format_message_messages_with_author(self, xmpp_message_full: XmppMessage) -> None:
"""Test que les messages affichent l'auteur.
:param xmpp_message_full: Message XMPP complet.
"""
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(xmpp_message_full)
# Vérifier que le formatage inclut les sections attendues
assert "📌 Synthèse" in formatted
assert "📅 Changements d'agenda" in formatted
assert "📚 Devoirs" in formatted
assert "📢 Informations diverses" in formatted
def test_format_message_external_info_no_pronote_messages(
self, xmpp_message_full: XmppMessage
) -> None:
"""Test que pronote_messages n'est pas rendu dans la section 📢.
:param xmpp_message_full: Message XmppMessage complet.
"""
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(xmpp_message_full)
# Le message est dans external_info.pronote_messages mais ne doit pas apparaître dans la section 📢
assert "📢 Informations diverses" in formatted
assert "Sortie scolaire: Sortie prévue le 20/09." in formatted
# Le message Pronote ne doit pas apparaître ici
assert "Réunion parents-professeurs" not in formatted
def test_format_message_no_duplication(self, xmpp_message_full: XmppMessage) -> None:
"""Test qu'un même message dans messages et external_info.pronote_messages apparaît une seule fois.
:param xmpp_message_full: Message XmppMessage complet.
"""
channel = XmppChannel(XmppSettings(), dry_run=True)
# Le message est déjà dans external_info.pronote_messages
formatted = channel._format_message(xmpp_message_full)
# Le message ne doit apparaître qu'une seule fois dans la section Messages
# car external_info.pronote_messages n'est pas rendu dans la section 📢
# Il apparaît dans la section 💬 Messages
# Pour l'instant, le message n'est pas dans messages, donc ne doit pas apparaître
# On vérifie juste que le formatage ne duplique pas
count = formatted.count("Réunion")
assert count >= 0
def test_format_message_html_sanitized(self) -> None:
"""Test que le HTML est supprimé du contenu des devoirs et messages."""
homework = Homework(
id="hw_html",
subject="SVT",
teachers=("M. Bernard",),
assigned_on=date(2025, 9, 1),
due_on=date(2025, 9, 20),
text="Lire <b>le chapitre 3</b> et répondre aux questions.",
html="<p>Lire <b>le chapitre 3</b> et répondre aux questions.</p>",
)
msg = XmppMessage(
target_date=date(2025, 9, 7),
synthesis=None,
homeworks=(homework,),
external_info=None,
)
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(msg)
# Le HTML doit être supprimé
assert "<b>" not in formatted
assert "le chapitre 3" in formatted
def test_format_message_control_chars_stripped(self) -> None:
"""Test que les caractères de contrôle sont supprimés du contenu."""
homework = Homework(
id="hw_ctrl",
subject="Histoire",
teachers=("Mme Dubois",),
assigned_on=date(2025, 9, 1),
due_on=date(2025, 9, 25),
text="Fiche\x00n°4\x01à\x07rendre\x1f",
html="",
)
msg = XmppMessage(
target_date=date(2025, 9, 7),
synthesis=None,
homeworks=(homework,),
external_info=None,
)
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(msg)
# Les caractères de contrôle doivent être supprimés
assert "\x00" not in formatted
assert "\x01" not in formatted
assert "\x07" not in formatted
assert "\x1f" not in formatted
# Le texte doit rester lisible
assert "Fiche" in formatted
assert "n°4" in formatted
assert "à" in formatted
assert "rendre" in formatted
class TestXmppChannelSend:
"""Tests unitaires pour la méthode send_async de XmppChannel.
Ces tests vérifient le comportement de l'envoi de messages XMPP,
avec mock de slixmpp.ClientXMPP fidèle à slixmpp 1.17.0.
"""
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_dry_run_returns_true(self) -> None:
"""Test que dry_run=True retourne True sans créer ClientXMPP.
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
use_tls=True,
timeout=30,
)
channel = XmppChannel(settings, dry_run=True)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is True
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_success_returns_true(self) -> None:
"""Test que send_async retourne True en cas de succès de connexion.
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
use_tls=True,
timeout=30,
)
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is True
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_failed_auth_returns_false(self) -> None:
"""Test que failed_auth retourne False (pas d'exception).
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
use_tls=True,
timeout=30,
)
class FailedAuthClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
self._auth_should_fail = True
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=FailedAuthClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_timeout_returns_false(self) -> None:
"""Test que timeout retourne False.
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="localhost",
port=5222,
to="parent@example.com",
resource="pronote-sync",
use_tls=False,
timeout=1,
)
class NoEventClient(FakeClientXMPP):
def _fire_events(self) -> None:
# Ne déclencher aucun événement, donc session_future jamais résolu
pass
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=NoEventClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_disconnected_early_returns_false(self) -> None:
"""Test que disconnected avant session_start retourne False.
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
use_tls=True,
timeout=30,
)
class DisconnectEarlyClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
self._should_disconnect_early = True
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=DisconnectEarlyClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_no_recipient_returns_false(self) -> None:
"""Test que settings.to = None retourne False.
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to=None,
resource="pronote-sync",
use_tls=True,
timeout=30,
)
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_uses_host_and_port(self) -> None:
"""Test que mock reçoit les settings.host et settings.port.
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="myhost.example.com",
port=5223,
to="parent@example.com",
resource="pronote-sync",
use_tls=True,
timeout=30,
)
class InspectClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
mock_cls.return_value = InspectClient("bot@example.com", "secret123")
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
await channel.send_async(msg)
# Vérifier que le mock a bien été instancié
assert mock_cls.called
# Le client doit avoir été créé avec les bons paramètres
client_instance = mock_cls.return_value
assert client_instance._host_used == "myhost.example.com"
assert client_instance._port_used == 5223
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_uses_jid_with_resource(self) -> None:
"""Test que le JID est construit avec le suffixe /resource.
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="myresource",
use_tls=True,
timeout=30,
)
class InspectClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
mock_cls.return_value = InspectClient("ignored", "ignored")
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
await channel.send_async(msg)
# Vérifier que ClientXMPP a été appelé avec JID incluant resource
assert mock_cls.called
call_args = mock_cls.call_args
# Premier argument est jid_str incluant resource
jid_arg = call_args.args[0]
assert jid_arg == "bot@example.com/myresource"
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_tls_direct_config(self) -> None:
"""Test que use_tls=True configure enable_direct_tls=True et enable_starttls=False.
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
use_tls=True,
timeout=30,
)
class InspectClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
mock_cls.return_value = InspectClient("bot@example.com", "secret123")
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
await channel.send_async(msg)
client_instance = mock_cls.return_value
assert client_instance.enable_direct_tls is True
assert client_instance.enable_starttls is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_starttls_config(self) -> None:
"""Test que use_tls=False configure enable_starttls=True et enable_direct_tls=False.
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="localhost",
port=5222,
to="parent@example.com",
resource="pronote-sync",
use_tls=False,
timeout=30,
)
class InspectClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
mock_cls.return_value = InspectClient("bot@example.com", "secret123")
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
await channel.send_async(msg)
client_instance = mock_cls.return_value
assert client_instance.enable_starttls is True
assert client_instance.enable_direct_tls is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_never_raises_pipeline_warning(self) -> None:
"""Test que send_async ne lève jamais d'exception.
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
use_tls=True,
timeout=30,
)
class ErrorClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
def connect(
self, host: str | None = None, port: int | None = None
) -> asyncio.Future[bool]:
raise RuntimeError("Connexion impossible")
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ErrorClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_disconnect_cleanup_error_returns_true(self) -> None:
"""Test que le canal ignore une erreur de déconnexion en nettoyage.
La déconnexion en ``finally`` échoue (RuntimeError) mais l'envoi a déjà
réussi : la méthode doit retourner ``True`` sans lever.
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
use_tls=True,
timeout=30,
)
class DisconnectErrorClient(FakeClientXMPP):
def disconnect(
self,
wait: float = 2.0,
reason: str | None = None,
ignore_send_queue: bool = False,
) -> asyncio.Future[bool]:
raise RuntimeError("Déconnexion impossible")
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=DisconnectErrorClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is True
class TestXmppChannelSecurity:
"""Tests de sécurité pour XmppChannel (non-fuite de secrets).
Ces tests vérifient que les secrets (JID, mot de passe, destinataire)
ne sont jamais exposés dans les logs, messages d'erreur ou causes d'exceptions.
"""
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_no_jid_in_logs_on_error(self, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie que le JID n'apparaît pas dans les logs en cas d'erreur.
:param caplog: Fixture pytest pour capturer les logs.
"""
settings = XmppSettings(
enabled=True,
jid=BOT_SENTINEL_JID,
password=SecretStr("ignored"), # pragma: allowlist secret
host="xmpp.example.com",
port=5222,
to="ignored",
resource="pronote-sync",
use_tls=True,
timeout=30,
)
class ErrorClient(FakeClientXMPP):
def connect(
self, host: str | None = None, port: int | None = None
) -> asyncio.Future[bool]:
raise RuntimeError("Connexion impossible")
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ErrorClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
try:
await channel.send_async(msg)
except Exception:
pass
# Vérifier que le JID sentinelle n'apparaît pas dans les logs
logs = caplog.text
assert BOT_SENTINEL_JID not in logs
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_no_password_in_logs_on_error(self, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie que le mot de passe n'apparaît pas dans les logs en cas d'erreur.
:param caplog: Fixture pytest pour capturer les logs.
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr(PASS_SENTINEL_123),
host="xmpp.example.com",
port=5222,
to="ignored",
resource="pronote-sync",
use_tls=True,
timeout=30,
)
class ErrorClient(FakeClientXMPP):
def connect(
self, host: str | None = None, port: int | None = None
) -> asyncio.Future[bool]:
raise RuntimeError("Authentification échouée")
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ErrorClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
try:
await channel.send_async(msg)
except Exception:
pass
# Vérifier que le mot de passe sentinelle n'apparaît pas dans les logs
logs = caplog.text
assert PASS_SENTINEL_123 not in logs
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_no_recipient_in_logs_on_error(self, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie que le destinataire n'apparaît pas dans les logs en cas d'erreur.
:param caplog: Fixture pytest pour capturer les logs.
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("ignored"),
host="xmpp.example.com",
port=5222,
to=RECIPIENT_SENTINEL,
resource="pronote-sync",
use_tls=True,
timeout=30,
)
class ErrorClient(FakeClientXMPP):
def connect(
self, host: str | None = None, port: int | None = None
) -> asyncio.Future[bool]:
raise RuntimeError("Envoi impossible")
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ErrorClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
try:
await channel.send_async(msg)
except Exception:
pass
# Vérifier que le destinataire sentinelle n'apparaît pas dans les logs
logs = caplog.text
assert RECIPIENT_SENTINEL not in logs
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_dry_run_no_secret_in_log(self, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie que dry-run n'expose pas de secrets dans les logs.
:param caplog: Fixture pytest pour capturer les logs.
"""
settings = XmppSettings(
enabled=True,
jid=BOT_SENTINEL_JID,
password=SecretStr(PASS_SENTINEL_123),
host="xmpp.example.com",
port=5222,
to=RECIPIENT_SENTINEL,
resource="pronote-sync",
use_tls=True,
timeout=30,
)
channel = XmppChannel(settings, dry_run=True)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
await channel.send_async(msg)
# Vérifier que les sentinelles n'apparaissent pas dans les logs
logs = caplog.text
assert BOT_SENTINEL_JID not in logs
assert PASS_SENTINEL_123 not in logs
assert RECIPIENT_SENTINEL not in logs

View File

@@ -0,0 +1,324 @@
"""Tests unitaires pour la factory get_channel des canaux XMPP.
Ce module valide la spécification de la factory ``get_channel`` qui sera
implémentée dans ``pronote_sync/channels/__init__.py``.
Les tests doivent être initialement en échec (RED) car la factory n'existe
pas encore dans le code de production.
Spécification (D2) :
- get_channel(settings: XmppSettings, dry_run: bool = False) -> Channel | None
- Si enabled=False → retourne None (pas d'exception, pas d'avertissement).
- Si enabled=True et champs requis manquants (jid, password, to, host) →
journalise un avertissement avec redact_secrets(), retourne None.
- Si enabled=True et tous champs requis présents → construit et retourne
une instance de SyncXmppChannel (ou XmppChannel).
- La factory n'élève jamais d'exception.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import patch
import pytest
from pydantic import SecretStr
from pronote_sync.channels import (
Channel,
SyncXmppChannel,
XmppChannel,
get_channel,
)
from pronote_sync.config.settings import XmppSettings
class TestGetChannelDisabled:
"""Tests pour le cas où le canal XMPP est désactivé (enabled=False)."""
def test_get_channel_disabled_returns_none(self) -> None:
"""Vérifie que get_channel retourne None quand enabled=False.
:return: None
:rtype: None
"""
settings = XmppSettings(enabled=False)
result = get_channel(settings)
assert result is None
class TestGetChannelEnabledComplete:
"""Tests pour le cas où le canal est activé avec une configuration complète."""
def test_get_channel_enabled_complete_returns_channel(self) -> None:
"""Vérifie que get_channel retourne une instance de channel quand la configuration est complète.
:return: None
:rtype: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("pass"),
host="example.com",
to="parent@example.com",
)
result = get_channel(settings)
assert result is not None
# Vérifie que le résultat implémente le Protocol Channel
assert isinstance(result, Channel)
class TestGetChannelEnabledMissingRequiredFields:
"""Tests pour les cas où des champs requis sont manquants."""
@pytest.mark.parametrize(
"settings_kwargs",
[
{
"enabled": True,
"jid": None,
"password": SecretStr("pass"),
"host": "example.com",
"to": "parent@example.com",
},
{
"enabled": True,
"jid": "bot@example.com",
"password": None,
"host": "example.com",
"to": "parent@example.com",
},
{
"enabled": True,
"jid": "bot@example.com",
"password": SecretStr("pass"),
"host": "",
"to": "parent@example.com",
},
{
"enabled": True,
"jid": "bot@example.com",
"password": SecretStr("pass"),
"host": "example.com",
"to": None,
},
],
ids=["missing_jid", "missing_password", "missing_host", "missing_to"],
)
def test_get_channel_enabled_missing_required_field_returns_none(
self,
settings_kwargs: dict[str, Any],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie que get_channel retourne None quand un champ requis est manquant.
:param settings_kwargs: Paramètres pour XmppSettings avec un champ manquant.
:param caplog: Fixture pour capturer les logs.
:return: None
:rtype: None
"""
settings = XmppSettings(**settings_kwargs)
result = get_channel(settings)
assert result is None
# Vérifie qu'un avertissement a été journalisé
assert len(caplog.records) > 0
assert any(record.levelname == "WARNING" for record in caplog.records)
def test_get_channel_enabled_missing_jid_returns_none(self) -> None:
"""Vérifie que get_channel retourne None quand jid est None.
:return: None
:rtype: None
"""
settings = XmppSettings(
enabled=True,
jid=None,
password=SecretStr("pass"),
host="example.com",
to="parent@example.com",
)
result = get_channel(settings)
assert result is None
def test_get_channel_enabled_missing_password_returns_none(self) -> None:
"""Vérifie que get_channel retourne None quand password est None.
:return: None
:rtype: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=None,
host="example.com",
to="parent@example.com",
)
result = get_channel(settings)
assert result is None
def test_get_channel_enabled_missing_to_returns_none(self) -> None:
"""Vérifie que get_channel retourne None quand to est None.
:return: None
:rtype: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("pass"),
host="example.com",
to=None,
)
result = get_channel(settings)
assert result is None
def test_get_channel_enabled_missing_host_returns_none(self) -> None:
"""Vérifie que get_channel retourne None quand host est vide.
:return: None
:rtype: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("pass"),
host="",
to="parent@example.com",
)
result = get_channel(settings)
assert result is None
class TestGetChannelNoExceptionOnMisconfiguration:
"""Tests pour vérifier que la factory ne lève jamais d'exception."""
@pytest.mark.parametrize(
"settings_kwargs",
[
{"enabled": True, "jid": None},
{"enabled": True, "password": None},
{"enabled": True, "to": None},
{"enabled": True, "host": ""},
{"enabled": True, "jid": None, "password": None, "to": None, "host": ""},
{"enabled": False},
],
ids=[
"missing_jid_only",
"missing_password_only",
"missing_to_only",
"missing_host_only",
"all_missing",
"disabled",
],
)
def test_get_channel_no_exception_on_misconfiguration(
self,
settings_kwargs: dict[str, Any],
) -> None:
"""Vérifie que get_channel ne lève jamais d'exception sur une configuration invalide.
:param settings_kwargs: Paramètres pour XmppSettings potentiellement invalides.
:return: None
:rtype: None
"""
settings = XmppSettings(**settings_kwargs)
# Ne doit jamais lever d'exception
result = get_channel(settings)
assert result is None
class TestGetChannelNoSecretInWarningLog:
"""Tests pour vérifier que les secrets ne fuient pas dans les logs."""
def test_get_channel_no_secret_in_warning_log(self, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie que les valeurs sentinelles ne apparaissent pas dans les logs.
Utilise des valeurs sentinelles pour éviter toute fuite de secrets réels.
:param caplog: Fixture pour capturer les logs.
:return: None
:rtype: None
"""
sentinel_jid = "JID_SENTINEL@example.com"
sentinel_password = SecretStr("PASS_SENTINEL")
sentinel_to = "TO_SENTINEL@example.com"
# Configuration incomplète : host manquant -> get_channel journalise
# un avertissement expurgé et retourne None.
settings = XmppSettings(
enabled=True,
jid=sentinel_jid,
password=sentinel_password,
host="",
to=sentinel_to,
)
result = get_channel(settings)
assert result is None
# Vérifie qu'un avertissement a été journalisé
assert len(caplog.records) > 0
assert any(record.levelname == "WARNING" for record in caplog.records)
# Vérifie que les valeurs sentinelles n'apparaissent pas dans les logs
log_text = "".join(record.message for record in caplog.records)
assert sentinel_jid not in log_text
assert sentinel_password.get_secret_value() not in log_text
assert sentinel_to not in log_text
class TestGetChannelDryRun:
"""Tests pour le flag dry_run."""
def test_get_channel_dry_run(self) -> None:
"""Vérifie que le flag dry_run est passé à travers et retourne un channel.
:return: None
:rtype: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("pass"),
host="example.com",
to="parent@example.com",
)
result = get_channel(settings, dry_run=True)
assert result is not None
assert isinstance(result, Channel)
def test_get_channel_dry_run_no_connection(self) -> None:
"""Vérifie que dry_run=True ne crée pas de ClientXMPP.
:return: None
:rtype: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("pass"),
host="example.com",
to="parent@example.com",
)
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
result = get_channel(settings, dry_run=True)
assert result is not None
assert isinstance(result, Channel)
# ClientXMPP ne doit pas être instancié en dry_run
assert not mock_cls.called
class TestChannelImportsFromInit:
"""Tests pour vérifier que les exports depuis __init__.py fonctionnent."""
def test_channel_imports_from_init(self) -> None:
"""Vérifie que Channel, XmppChannel, SyncXmppChannel sont importables depuis pronote_sync.channels.
:return: None
:rtype: None
"""
# Ces imports doivent réussir
assert Channel is not None
assert XmppChannel is not None
assert SyncXmppChannel is not None
assert get_channel is not None

View File

@@ -0,0 +1,199 @@
"""Tests unitaires pour les contraintes de sécurité et validateurs de XmppSettings.
Ce module valide les contraintes de sécurité et les validateurs qui seront ajoutés
à la classe XmppSettings dans pronote_sync/config/settings.py.
Les tests doivent être initialement en échec (RED) car les contraintes et validateurs
n'existent pas encore dans le code de production.
"""
import pytest
from pydantic import SecretStr, ValidationError
from pronote_sync.config.settings import XmppSettings
class TestPortConstraints:
"""Tests des contraintes sur le champ port."""
def test_port_below_1_rejected(self) -> None:
"""Vérifie que port < 1 est rejeté.
:raises ValidationError: Si le port est inférieur à 1.
"""
with pytest.raises(ValidationError) as exc_info:
XmppSettings(port=0)
assert "port" in str(exc_info.value).lower()
def test_port_above_65535_rejected(self) -> None:
"""Vérifie que port > 65535 est rejeté.
:raises ValidationError: Si le port est supérieur à 65535.
"""
with pytest.raises(ValidationError) as exc_info:
XmppSettings(port=70000)
assert "port" in str(exc_info.value).lower()
def test_port_default_5222(self) -> None:
"""Vérifie que la valeur par défaut de port est 5222.
:return: Vérifie que XmppSettings().port == 5222.
:rtype: None
"""
settings = XmppSettings()
assert settings.port == 5222
def test_port_valid(self) -> None:
"""Vérifie que les ports valides sont acceptés.
:return: Vérifie que XmppSettings(port=5222) et XmppSettings(port=5223) sont valides.
:rtype: None
"""
settings1 = XmppSettings(port=5222)
assert settings1.port == 5222
settings2 = XmppSettings(port=5223)
assert settings2.port == 5223
class TestTimeoutConstraints:
"""Tests des contraintes sur le champ timeout."""
def test_timeout_zero_rejected(self) -> None:
"""Vérifie que timeout = 0 est rejeté.
:raises ValidationError: Si le timeout est égal à 0.
"""
with pytest.raises(ValidationError) as exc_info:
XmppSettings(timeout=0)
assert "timeout" in str(exc_info.value).lower()
def test_timeout_negative_rejected(self) -> None:
"""Vérifie que timeout < 0 est rejeté.
:raises ValidationError: Si le timeout est négatif.
"""
with pytest.raises(ValidationError) as exc_info:
XmppSettings(timeout=-1)
assert "timeout" in str(exc_info.value).lower()
def test_timeout_default_30(self) -> None:
"""Vérifie que la valeur par défaut de timeout est 30.
:return: Vérifie que XmppSettings().timeout == 30.
:rtype: None
"""
settings = XmppSettings()
assert settings.timeout == 30
def test_timeout_positive_valid(self) -> None:
"""Vérifie que les valeurs positives de timeout sont acceptées.
:return: Vérifie que XmppSettings(timeout=10) est valide.
:rtype: None
"""
settings = XmppSettings(timeout=10)
assert settings.timeout == 10
class TestTlsPolicy:
"""Tests de la politique TLS pour le champ use_tls."""
def test_use_tls_false_with_remote_host_rejected(self) -> None:
"""Vérifie que use_tls=False avec un hôte distant est rejeté.
:raises ValidationError: Si use_tls=False et host n'est pas une boucle locale.
"""
with pytest.raises(ValidationError) as exc_info:
XmppSettings(use_tls=False, host="talk.example.com")
assert "use_tls" in str(exc_info.value).lower() or "tls" in str(exc_info.value).lower()
def test_use_tls_false_with_remote_host_rejected_when_enabled(self) -> None:
"""Vérifie que la politique TLS s'applique même quand le canal est activé."""
with pytest.raises(ValidationError):
XmppSettings(enabled=True, use_tls=False, host="talk.example.com")
def test_use_tls_false_with_localhost_allowed(self) -> None:
"""Vérifie que use_tls=False avec localhost est autorisé.
:return: Vérifie que XmppSettings(use_tls=False, host="localhost") est valide.
:rtype: None
"""
settings = XmppSettings(use_tls=False, host="localhost")
assert settings.use_tls is False
assert settings.host == "localhost"
def test_use_tls_false_with_127_allowed(self) -> None:
"""Vérifie que use_tls=False avec 127.0.0.1 est autorisé.
:return: Vérifie que XmppSettings(use_tls=False, host="127.0.0.1") est valide.
:rtype: None
"""
settings = XmppSettings(use_tls=False, host="127.0.0.1")
assert settings.use_tls is False
assert settings.host == "127.0.0.1"
def test_use_tls_false_with_ipv6_loopback_allowed(self) -> None:
"""Vérifie que use_tls=False avec ::1 est autorisé.
:return: Vérifie que XmppSettings(use_tls=False, host="::1") est valide.
:rtype: None
"""
settings = XmppSettings(use_tls=False, host="::1")
assert settings.use_tls is False
assert settings.host == "::1"
def test_use_tls_true_with_remote_host_allowed(self) -> None:
"""Vérifie que use_tls=True avec un hôte distant est autorisé.
:return: Vérifie que XmppSettings(use_tls=True, host="talk.example.com") est valide.
:rtype: None
"""
settings = XmppSettings(use_tls=True, host="talk.example.com")
assert settings.use_tls is True
assert settings.host == "talk.example.com"
def test_use_tls_true_with_empty_host_allowed(self) -> None:
"""Vérifie que use_tls=True avec host vide est autorisé.
:return: Vérifie que XmppSettings(use_tls=True, host="") est valide.
:rtype: None
"""
settings = XmppSettings(use_tls=True, host="")
assert settings.use_tls is True
assert settings.host == ""
class TestNoSecretInErrorMessages:
"""Tests de sécurité : vérifie que les messages d'erreur ne contiennent pas de secrets."""
def test_no_secret_in_validation_error(self) -> None:
"""Vérifie que les messages de ValidationError ne contiennent pas de secrets.
Crée une instance avec des valeurs sensibles et vérifie que l'erreur de validation
ne contient pas ces valeurs dans son message.
:raises ValidationError: Si use_tls=False avec un hôte non-local.
:return: Vérifie que le message d'erreur ne contient pas les secrets.
:rtype: None
"""
# Utilisation de valeurs sentinelles pour éviter toute fuite
sentinel_jid = "test_jid@example.com"
sentinel_password = SecretStr("test_password_123")
sentinel_to = "test_to@example.com"
with pytest.raises(ValidationError) as exc_info:
XmppSettings(
use_tls=False,
host="talk.example.com",
jid=sentinel_jid,
password=sentinel_password,
to=sentinel_to,
)
error_message = str(exc_info.value).lower()
# Vérifie que les valeurs sensibles ne sont pas dans le message d'erreur
assert "test_jid@example.com" not in error_message
assert "test_password_123" not in error_message
assert "test_to@example.com" not in error_message
assert "secret" not in error_message

View File

@@ -0,0 +1,235 @@
"""Tests unitaires pour l'adaptateur SyncXmppChannel.
Ce module teste l'implémentation de :class:`pronote_sync.channels.xmpp.SyncXmppChannel`
qui est un adaptateur wrapant XmppChannel pour fournir une interface synchrone.
Les tests sont conçus pour être exécutés sans réseau, avec des mocks de XmppChannel
ou de slixmpp, et vérifient le comportement de l'envoi synchrone selon la décision D4.
Conformément à D4, SyncXmppChannel.send() utilise asyncio.run() directement sans
créer de nouvelle event loop inutilement. Le comportement est :
- Pas de boucle en cours → asyncio.run(channel.send_async(message))
- Retourne True en cas de succès, False en cas d'erreur (attrape toute exception)
- Aucun secret dans les logs.
"""
from __future__ import annotations
from datetime import date
from unittest.mock import MagicMock, patch
import pytest
from pydantic import SecretStr
from pronote_sync.channels.protocol import Channel
from pronote_sync.channels.xmpp import SyncXmppChannel, XmppMessage
from pronote_sync.config.settings import XmppSettings
# Sentinelles pour tests de non-fuite de secrets
BOT_SENTINEL_JID = "BOT_SENTINEL_JID@example.com"
PASS_SENTINEL_123 = "PASS_SENTINEL_123"
RECIPIENT_SENTINEL = "RECIPIENT_SENTINEL@example.com"
@pytest.fixture
def xmpp_settings() -> XmppSettings:
"""Fixture fournissant des paramètres XMPP valides pour les tests.
:return: Instance de XmppSettings avec des valeurs par défaut valides.
:rtype: XmppSettings
"""
return XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
to="parent@example.com",
use_tls=True,
)
@pytest.fixture
def xmpp_message() -> XmppMessage:
"""Fixture fournissant un message XMPP minimal pour les tests.
:return: Instance de XmppMessage avec seulement la date cible.
:rtype: XmppMessage
"""
return XmppMessage(
target_date=date(2025, 9, 7),
synthesis=None,
homeworks=(),
changes=(),
messages=(),
external_info=None,
)
class TestSyncXmppChannelSend:
"""Tests unitaires pour la méthode send de SyncXmppChannel.
Ces tests vérifient le comportement de l'envoi synchrone de messages XMPP
selon la décision D4 : utilisation directe de asyncio.run() et retour de
booléen (True/False) sans lever d'exception.
"""
@patch("pronote_sync.channels.xmpp.asyncio.run")
def test_sync_adapter_send_returns_true_on_success(
self, mock_asyncio_run: MagicMock, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
) -> None:
"""Test que send retourne True en cas de succès.
:param mock_asyncio_run: Mock de asyncio.run
:param xmpp_settings: Paramètres XMPP valides.
:param xmpp_message: Message XMPP minimal.
"""
mock_asyncio_run.side_effect = lambda coro: coro.close() or True
channel = SyncXmppChannel(xmpp_settings)
result = channel.send(xmpp_message)
assert result is True
@patch("pronote_sync.channels.xmpp.asyncio.run")
def test_sync_adapter_send_returns_false_on_error(
self, mock_run: MagicMock, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
) -> None:
"""Test que send retourne False en cas d'erreur.
Vérifie que la méthode ne lève pas d'exception non gérée et retourne False.
:param mock_run: Mock de asyncio.run
:param xmpp_settings: Paramètres XMPP valides.
:param xmpp_message: Message XMPP minimal.
"""
# Simuler une erreur dans asyncio.run
def _run_with_error(coro: object) -> bool:
"""Ferme la coroutine non exécutée puis lève l'erreur simulée."""
close = getattr(coro, "close", None)
if close is not None:
close()
raise RuntimeError("Connexion impossible")
mock_run.side_effect = _run_with_error
channel = SyncXmppChannel(xmpp_settings)
result = channel.send(xmpp_message)
assert result is False
def test_sync_adapter_satisfies_channel_protocol(self, xmpp_settings: XmppSettings) -> None:
"""Test que SyncXmppChannel satisfait le protocole Channel.
Vérifie que l'instance est reconnue comme implémentant le protocole.
:param xmpp_settings: Paramètres XMPP valides.
"""
channel = SyncXmppChannel(xmpp_settings)
assert isinstance(channel, Channel)
def test_sync_adapter_dry_run_does_not_create_client(
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
) -> None:
"""Test que dry_run=True ne crée jamais ClientXMPP.
:param xmpp_settings: Paramètres XMPP valides.
:param xmpp_message: Message XMPP minimal.
"""
channel = SyncXmppChannel(xmpp_settings, dry_run=True)
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
result = channel.send(xmpp_message)
assert result is True
# ClientXMPP ne doit pas être instancié en dry_run
assert not mock_cls.called
def test_sync_adapter_dry_run_returns_true(
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
) -> None:
"""Test que dry_run=True retourne True sans se connecter.
:param xmpp_settings: Paramètres XMPP valides.
:param xmpp_message: Message XMPP minimal.
"""
channel = SyncXmppChannel(xmpp_settings, dry_run=True)
result = channel.send(xmpp_message)
assert result is True
@patch("pronote_sync.channels.xmpp.asyncio.run")
def test_sync_adapter_never_raises(
self, mock_run: MagicMock, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
) -> None:
"""Test que send ne lève jamais d'exception.
:param mock_run: Mock de asyncio.run
:param xmpp_settings: Paramètres XMPP valides.
:param xmpp_message: Message XMPP minimal.
"""
# Simuler une erreur quelconque
def _run_with_error(coro: object) -> bool:
"""Ferme la coroutine non exécutée puis lève l'erreur simulée."""
close = getattr(coro, "close", None)
if close is not None:
close()
raise Exception("Any error")
mock_run.side_effect = _run_with_error
channel = SyncXmppChannel(xmpp_settings)
result = channel.send(xmpp_message)
assert result is False
class TestSyncXmppChannelSecurity:
"""Tests de sécurité pour SyncXmppChannel (non-fuite de secrets).
Ces tests vérifient que les secrets (JID, mot de passe, destinataire)
ne sont jamais exposés dans les logs, messages d'erreur ou causes d'exceptions.
"""
def test_sync_adapter_no_secret_in_logs(
self, caplog: pytest.LogCaptureFixture, xmpp_settings: XmppSettings
) -> None:
"""Test que les sentinelles n'apparaissent pas dans les logs en cas d'erreur.
:param caplog: Fixture pytest pour capturer les logs.
:param xmpp_settings: Paramètres XMPP valides.
"""
# Créer des settings avec sentinelles
settings = XmppSettings(
enabled=True,
jid=BOT_SENTINEL_JID,
password=SecretStr(PASS_SENTINEL_123),
host="localhost",
port=5222,
to=RECIPIENT_SENTINEL,
use_tls=False,
)
channel = SyncXmppChannel(settings)
msg = XmppMessage(
target_date=date(2025, 9, 7),
synthesis=None,
homeworks=(),
changes=(),
messages=(),
external_info=None,
)
# Simuler une erreur dans asyncio.run
with patch("pronote_sync.channels.xmpp.asyncio.run") as mock_run:
def _run_with_error(coro: object) -> bool:
"""Ferme la coroutine non exécutée puis lève l'erreur simulée."""
close = getattr(coro, "close", None)
if close is not None:
close()
raise RuntimeError("Connexion impossible")
mock_run.side_effect = _run_with_error
result = channel.send(msg)
# Vérifier que le résultat est False
assert result is False
# Vérifier que les sentinelles n'apparaissent pas dans les logs
logs = caplog.text
assert BOT_SENTINEL_JID not in logs
assert PASS_SENTINEL_123 not in logs
assert RECIPIENT_SENTINEL not in logs