Compare commits

...

5 Commits

Author SHA1 Message Date
b474f02e90 fix(M14): harden secret scanner — prefixed vars, index blobs, extensionless files
Correct five findings from independent review and security audit of the
M14 deployment secret scanner:

scripts/check_secrets.py:
- Regex: \b[a-z0-9_]* prefix before sensitive keywords to detect
  PRONOTE_PASSWORD, CALDAV_PASSWORD, AI_API_KEY and similar prefixed
  variable names (was: \b which doesn't match before underscore)
- Regex: minimum secret value length reduced from {8,} to {3,} for
  literal, unquoted, and URL parameter patterns
- Regex: unquoted pattern {3,} -> {2,} for 3-char total minimum
- --staged: reads Git index blobs via `git show :<path>` instead of
  working-tree files (ContentProvider type alias, _staged_content_provider)
- Extensionless deployment files: _EXTRA_NAMES allowlist for pronote_sync
- ContentProvider type alias documented with #: Sphinx comment

tests/unit/test_check_secrets.py (4 new tests, 9 total):
- test_main_detects_prefixed_secret_assignment: PRONOTE_PASSWORD detected
- test_main_detects_short_secret_assignment: 6-char secret detected
- test_staged_mode_reads_index_content_not_working_tree: working-tree
  content set to non-matching value to distinguish index from worktree
- test_main_scans_extensionless_deployment_file: pronote_sync scanned

TODO.md: all 5 M14 checklist items checked

Validation: 636 tests, coverage 95.67%, ruff/mypy/bandit/pre-commit green.

Co-authored-by: opencode/coder <coder@agents.invalid>
2026-09-08 17:20:08 +02:00
e6e4b10047 Merge branch 'main' into feature/m14-deployment 2026-09-08 16:43:15 +02:00
d60357a017 feat(M14): add deployment artifacts and secret check
Co-authored-by: Codex/gpt-5.6-terra <codex-gpt-5.6-terra@agents.invalid>
2026-09-08 16:42:59 +02:00
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
15 changed files with 1114 additions and 15 deletions

2
.gitignore vendored
View File

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

22
TODO.md
View File

@@ -241,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.
- [ ] 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.
- [ ] Construire la composition root et lancer `PipelineRunner.run()`.
- [ ] Gérer le code de retour et l'affichage des erreurs (redactées).
- [x] Créer `cli/main.py` : `main()` (point d'entrée `pronote-sync`), args `--dry-run`, `--log-level`.
- [x] Initialiser les logs (`setup_logging`) et charger `settings` au démarrage.
- [x] Construire la composition root et lancer `PipelineRunner.run()`.
- [x] Gérer le code de retour et l'affichage des erreurs (redactées).
### Critères d'acceptation
- `pronote-sync --dry-run --log-level DEBUG` s'exécute sans effet de bord.
@@ -257,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 %.
- [ ] 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/fixtures/` : `pronote-4e.ics`, `pronote-6e.ics`, `theoretical.json`, `school_holidays.json`, `blog_rss.xml` (anonymisés, sans `icalsecurise`).
- [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] 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é).
@@ -277,11 +277,11 @@ Couvrir l'ensemble du code par des tests sans réseau, avec fixtures anonymisée
Mettre en production de façon supervisée (planification, rotation des logs, vérification des secrets).
- [ ] Créer une unité systemd (`pronote-sync.service` + timer) ou une ligne cron (exécution quotidienne).
- [ ] Créer `logrotate.d/pronote_sync` (daily, rotate 7, compress, delaycompress).
- [ ] Ajouter un script de vérification des secrets (§13.6) exécuté avant chaque déploiement.
- [ ] Documenter la supervision (logs, alertes en cas d'échec) et la maintenance (maj dépendances, dry-run avant MAJ).
- [ ] Vérifier `pip check` et tester le dry-run avant mise en production.
- [x] Créer une unité systemd (`pronote-sync.service` + timer) ou une ligne cron (exécution quotidienne).
- [x] Créer `logrotate.d/pronote_sync` (daily, rotate 7, compress, delaycompress).
- [x] Ajouter un script de vérification des secrets (§13.6) exécuté avant chaque déploiement.
- [x] Documenter la supervision (logs, alertes en cas d'échec) et la maintenance (maj dépendances, dry-run avant MAJ).
- [x] Vérifier `pip check` et tester le dry-run avant mise en production.
### Critères d'acceptation
- Le service/timer systemd (ou cron) lance le pipeline quotidiennement.

View File

@@ -0,0 +1,9 @@
/var/log/pronote-sync/pronote-sync.log {
daily
missingok
rotate 7
compress
delaycompress
notifempty
create 0640 pronote-sync pronote-sync
}

View File

@@ -0,0 +1,23 @@
[Unit]
Description=Synchronisation Pronote vers CalDAV et XMPP
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=pronote-sync
Group=pronote-sync
WorkingDirectory=/var/lib/pronote-sync
EnvironmentFile=/etc/pronote-sync/pronote-sync.env
Environment=PYTHONUNBUFFERED=1
StateDirectory=pronote-sync
LogsDirectory=pronote-sync
ExecStartPre=/opt/pronote-sync/.venv/bin/python /opt/pronote-sync/scripts/check_secrets.py
ExecStart=/opt/pronote-sync/.venv/bin/pronote-sync
StandardOutput=append:/var/log/pronote-sync/pronote-sync.log
StandardError=append:/var/log/pronote-sync/pronote-sync.log
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/var/lib/pronote-sync /var/log/pronote-sync

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Exécution quotidienne de pronote-sync
[Timer]
OnCalendar=*-*-* 18:00:00
Persistent=true
Unit=pronote-sync.service
[Install]
WantedBy=timers.target

143
docs/exploitation.md Normal file
View File

@@ -0,0 +1,143 @@
# Exploitation de `pronote-sync`
Ce guide décrit l'installation et l'exploitation des artefacts de déploiement
fournis par le projet. Les paramètres de l'unité systemd fournie sont des
exemples d'installation : adaptez-les à l'hôte cible avant son installation.
Ne placez jamais de secret dans une unité systemd, une commande shell, un
journal ou ce document.
## Préparer l'hôte
Installez le projet et ses dépendances dans le répertoire choisi, puis créez le
fichier d'environnement référencé par l'unité à partir de `.env.example`. Il
doit rester local et lisible uniquement par le compte de service :
```bash
sudo install -d -m 0700 -o <utilisateur-service> -g <groupe-service> <repertoire-configuration>
sudo install -m 0600 -o <utilisateur-service> -g <groupe-service> .env <fichier-environnement>
```
Les unités fournies nécessitent l'interface CLI livrée au jalon M12. Avant de
les installer, vérifiez que la version installée contient bien ce point
d'entrée :
```bash
.venv/bin/pronote-sync --help
```
Avant toute activation ou mise à jour, exécutez les contrôles depuis la racine
du projet :
```bash
.venv/bin/python scripts/check_secrets.py
.venv/bin/python -m pip check
.venv/bin/pronote-sync --dry-run
```
Le contrôle des secrets doit réussir avant le déploiement. Il inspecte les
fichiers textuels de l'artefact, en excluant volontairement `.env`, les
environnements virtuels, les répertoires générés, `tests/` et
`GUIDE_DEV_PYTHON.md` ; les sentinelles et exemples de ces deux derniers ne
bloquent donc pas le déploiement. Il ne valide ni les valeurs ni les permissions
du fichier d'environnement. Pour analyser seulement le contenu indexé avant un
commit, utilisez `scripts/check_secrets.py --staged`.
Le dry-run vérifie le pipeline sans appliquer les écritures de synchronisation ;
il ne remplace pas une vérification des paramètres réellement chargés.
## Installation systemd
Les fichiers versionnés sont :
- `deploy/systemd/pronote-sync.service` ;
- `deploy/systemd/pronote-sync.timer`.
Copiez-les dans le répertoire d'unités systemd de l'hôte. Avant de les activer,
adaptez `User`, `Group`, `WorkingDirectory`, `EnvironmentFile`, les chemins des
exécutables dans `ExecStartPre` et `ExecStart`, ainsi que les chemins de
`StateDirectory`, `LogsDirectory` et `ReadWritePaths`. L'artefact fourni prend
pour exemple le compte `pronote-sync`, le code dans `/opt/pronote-sync`, l'état
dans `/var/lib/pronote-sync`, les logs dans `/var/log/pronote-sync` et le fichier
d'environnement `/etc/pronote-sync/pronote-sync.env`. Ne copiez pas de valeur
secrète dans l'unité.
```bash
sudo install -m 0644 deploy/systemd/pronote-sync.service /etc/systemd/system/
sudo install -m 0644 deploy/systemd/pronote-sync.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now pronote-sync.timer
systemctl list-timers pronote-sync.timer
```
Pour tester une exécution sans attendre la prochaine échéance :
```bash
sudo systemctl start pronote-sync.service
sudo systemctl status pronote-sync.service
```
Une exécution en échec laisse l'unité `pronote-sync.service` en état `failed`.
La supervision de l'hôte doit donc déclencher une alerte sur cet état ou sur un
échec du timer/service ; le transport de cette alerte (courriel, XMPP ou système
de supervision) relève de l'exploitation locale.
## Journaux et alertes
La configuration systemd redirige la sortie standard et la sortie d'erreur vers
`/var/log/pronote-sync/pronote-sync.log`. Consultez ce fichier ou, selon la
configuration de l'hôte, le journal de l'unité :
```bash
sudo tail -f /var/log/pronote-sync/pronote-sync.log
sudo journalctl -u pronote-sync.service --since today
sudo journalctl -u pronote-sync.service -f
systemctl status pronote-sync.timer
```
Traitez un statut non nul ou une unité `failed` comme un échec à investiguer.
Les logs applicatifs masquent les secrets configurés, mais évitez tout de même
de partager sans relecture un export de journal : une donnée sensible issue de
l'environnement ou d'un outil tiers ne doit pas être supposée sûre par défaut.
## Rotation des journaux
L'artefact `deploy/logrotate/pronote_sync` cible le fichier
`/var/log/pronote-sync/pronote-sync.log` utilisé par l'unité fournie. Installez-
le puis validez sa syntaxe avant activation :
```bash
sudo install -m 0644 deploy/logrotate/pronote_sync /etc/logrotate.d/pronote_sync
sudo logrotate --debug /etc/logrotate.d/pronote_sync
```
La rotation configurée est quotidienne, conserve sept archives et utilise
`compress` avec `delaycompress`. Elle recrée le fichier avec les droits `0640`
pour le compte de service. Si vous modifiez le chemin de journal dans l'unité,
mettez aussi à jour la règle logrotate correspondante.
## Mise à jour et retour au service
Avant de remplacer les dépendances ou le code, conservez une copie protégée du
fichier d'environnement local, sans l'ajouter au dépôt. Après la mise à jour,
réexécutez, dans cet ordre, les contrôles de secrets, de cohérence des paquets
et le dry-run :
```bash
.venv/bin/python scripts/check_secrets.py
.venv/bin/python -m pip check
.venv/bin/pronote-sync --dry-run
```
Rechargez ensuite les unités si leurs fichiers ont changé, puis vérifiez une
exécution et son journal :
```bash
sudo systemctl daemon-reload
sudo systemctl restart pronote-sync.timer
sudo systemctl start pronote-sync.service
journalctl -u pronote-sync.service -n 100 --no-pager
```
En cas d'échec, ne relancez pas automatiquement après avoir modifié des
identifiants : corrigez la configuration locale, repassez le contrôle des
secrets et le dry-run, puis consultez le journal expurgé.

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

@@ -94,7 +94,7 @@ skips = ["B101"] # Ignorer les assertions (utilisées dans les tests)
line-length = 100
target-version = "py313"
# 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]
select = [

245
scripts/check_secrets.py Normal file
View File

@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""Vérifie l'absence de secrets littéraux avant un déploiement.
Le script inspecte le contenu textuel du dépôt, ou uniquement les fichiers
ajoutés/modifiés dans l'index avec ``--staged``. Il ne transmet jamais la
valeur détectée : les résultats ne contiennent que le chemin, le numéro de
ligne et le type de motif. Les fichiers d'environnement et les répertoires
générés sont exclus, car ils ne doivent pas être versionnés ni déployés.
"""
from __future__ import annotations
import argparse
import re
import subprocess # nosec B404
from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass
from pathlib import Path
_EXCLUDED_PARTS = frozenset({".git", ".venv", ".worktrees", "__pycache__", ".."})
_EXCLUDED_NAMES = frozenset({".env", ".secrets.baseline", "GUIDE_DEV_PYTHON.md"})
_EXCLUDED_TOP_LEVEL = frozenset({"tests"})
_ALLOWLIST_MARKER = "secret-check: allow"
_UNQUOTED_CONFIG_SUFFIXES = frozenset({".conf", ".ini", ".toml", ".yaml", ".yml"})
_TEXT_SUFFIXES = frozenset(
{".conf", ".ini", ".json", ".md", ".py", ".service", ".timer", ".toml", ".txt", ".yaml", ".yml"}
)
_LITERAL_SECRET_RE = re.compile(
r"(?ix)\b[a-z0-9_]*(?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
r"\s*[:=]\s*['\"][^'\"\r\n]{3,}['\"]"
)
_UNQUOTED_SECRET_RE = re.compile(
r"(?ix)\b[a-z0-9_]*(?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
r"\s*[:=]\s*[a-z0-9][a-z0-9._~+/-]{2,}"
)
_URL_SECRET_RE = re.compile(
r"(?ix)[?&](?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
r"=([^&#\s]{3,})"
)
_EXTRA_NAMES = frozenset({"pronote_sync"})
@dataclass(frozen=True)
class SecretFinding:
"""Représente un motif sensible détecté sans exposer sa valeur.
:ivar path: Chemin relatif du fichier concerné.
:ivar line: Numéro de ligne du motif.
:ivar rule: Règle ayant détecté le motif.
"""
path: Path
line: int
rule: str
CommandRunner = Callable[..., subprocess.CompletedProcess[str]]
#: Fournisseur de contenu pour un chemin relatif ; retourne ``None`` pour ignorer.
ContentProvider = Callable[[Path], str | None]
def _is_candidate(path: Path) -> bool:
"""Indique si un chemin peut être analysé comme fichier texte.
Les fichiers de déploiement sans extension, nommés explicitement dans
``_EXTRA_NAMES``, sont également retenus.
:param path: Chemin relatif au dépôt.
:return: ``True`` lorsque le fichier est textuel et non exclu.
:rtype: bool
"""
return (
not path.is_absolute()
and path.name not in _EXCLUDED_NAMES
and path.parts[0] not in _EXCLUDED_TOP_LEVEL
and not any(part in _EXCLUDED_PARTS for part in path.parts)
and (path.suffix in _TEXT_SUFFIXES or path.name in _EXTRA_NAMES)
)
def _repository_files(root: Path) -> list[Path]:
"""Liste les fichiers textuels présents dans le dépôt de travail.
Les tests et la spécification historique ne font pas partie de l'artefact
déployé : leurs sentinelles et exemples intentionnels ne doivent donc pas
bloquer le déploiement.
:param root: Racine du dépôt à analyser.
:return: Chemins relatifs triés des fichiers analysables.
:rtype: list[Path]
"""
return sorted(
path.relative_to(root)
for path in root.rglob("*")
if path.is_file() and _is_candidate(path.relative_to(root))
)
def _staged_files(root: Path, runner: CommandRunner) -> list[Path]:
"""Retourne les fichiers ajoutés ou modifiés actuellement indexés.
:param root: Racine du dépôt Git.
:param runner: Exécuteur de sous-processus injectable pour les tests.
:return: Chemins relatifs triés des fichiers indexés analysables.
:rtype: list[Path]
:raises RuntimeError: Si Git ne peut pas fournir les fichiers indexés.
"""
result = runner(
["git", "diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"],
cwd=root,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError("Impossible de lister les fichiers Git indexés") from None
paths = [Path(value) for value in result.stdout.split("\0") if value]
return sorted(path for path in paths if _is_candidate(path))
def _staged_content_provider(root: Path, runner: CommandRunner) -> ContentProvider:
"""Retourne un lecteur de contenu depuis l'index Git.
Lit le blob indexé via ``git show :<chemin>`` afin de ne pas dépendre de
l'état du working tree, dont la copie de travail peut différer de l'index.
:param root: Racine du dépôt Git.
:param runner: Exécuteur de sous-processus injectable pour les tests.
:return: Fonction de lecture du contenu indexé ; ``None`` si indisponible.
:rtype: ContentProvider
"""
def provider(relative_path: Path) -> str | None:
result = runner(
["git", "show", f":{relative_path}"],
cwd=root,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return None
return result.stdout
return provider
def find_secrets(
root: Path,
files: Iterable[Path],
content_provider: ContentProvider | None = None,
) -> list[SecretFinding]:
"""Détecte les motifs de secrets littéraux dans les fichiers désignés.
Les lignes explicitement marquées ``secret-check: allow`` sont exclues :
cette échappatoire doit rester locale à une fixture ou un exemple contrôlé.
:param root: Racine du dépôt analysé.
:param files: Chemins relatifs à inspecter.
:param content_provider: Lecteur optionnel du contenu d'un fichier ; par
défaut le contenu est lu depuis le working tree via ``read_text``.
Si le lecteur retourne ``None`` ou lève une erreur d'encodage, le
fichier est ignoré.
:return: Résultats triés par chemin, ligne et règle.
:rtype: list[SecretFinding]
"""
findings: list[SecretFinding] = []
for relative_path in files:
path = root / relative_path
try:
if content_provider is not None:
content = content_provider(relative_path)
else:
content = path.read_text(encoding="utf-8")
if content is None:
continue
except (OSError, UnicodeDecodeError):
continue
for number, line in enumerate(content.splitlines(), start=1):
if _ALLOWLIST_MARKER in line:
continue
is_literal_secret = _LITERAL_SECRET_RE.search(line) or (
relative_path.suffix in _UNQUOTED_CONFIG_SUFFIXES
and _UNQUOTED_SECRET_RE.search(line)
)
if is_literal_secret:
findings.append(SecretFinding(relative_path, number, "affectation-litterale"))
if _URL_SECRET_RE.search(line):
findings.append(SecretFinding(relative_path, number, "parametre-url"))
return sorted(findings, key=lambda finding: (str(finding.path), finding.line, finding.rule))
def _parse_arguments(arguments: Sequence[str] | None = None) -> argparse.Namespace:
"""Analyse les options de vérification.
:param arguments: Arguments explicites, ou ``None`` pour ceux du processus.
:return: Options validées.
:rtype: argparse.Namespace
"""
parser = argparse.ArgumentParser(description="Vérifie les secrets avant déploiement.")
parser.add_argument(
"--staged",
action="store_true",
help="Analyse uniquement les fichiers ajoutés ou modifiés dans l'index Git.",
)
return parser.parse_args(arguments)
def main(
arguments: Sequence[str] | None = None,
*,
root: Path | None = None,
runner: CommandRunner = subprocess.run,
) -> int:
"""Exécute la vérification de secrets et retourne un code de sortie.
:param arguments: Arguments de ligne de commande.
:param root: Racine à analyser ; le dépôt du script par défaut.
:param runner: Exécuteur Git injectable pour les tests.
:return: ``0`` sans motif, ``1`` si un motif est trouvé, ``2`` si le contrôle échoue.
:rtype: int
"""
parsed_arguments = _parse_arguments(arguments)
repository_root = root or Path(__file__).resolve().parents[1]
try:
if parsed_arguments.staged:
files = _staged_files(repository_root, runner)
content_provider = _staged_content_provider(repository_root, runner)
else:
files = _repository_files(repository_root)
content_provider = None
except RuntimeError as error:
print(f"ERREUR: {error}")
return 2
findings = find_secrets(repository_root, files, content_provider=content_provider)
if not findings:
print("OK: aucun secret littéral détecté.")
return 0
for finding in findings:
print(f"ECHEC: {finding.path}:{finding.line} ({finding.rule})")
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -14,6 +14,7 @@ from pydantic import SecretStr
from pronote_sync.config.settings import CalDAVSettings
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message, MessageType
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
def pronote_data(
sample_lesson: Lesson,
sample_cancelled_lesson: Lesson,
sample_homework: Homework,
sample_school_event: SchoolEvent,
sample_message: Message,
) -> 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(
lessons=[sample_lesson, sample_cancelled_lesson],
homeworks=[sample_homework],
school_events=[sample_school_event],
messages=[],
messages=[sample_message],
target_date=date(2026, 1, 15),
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

View File

@@ -0,0 +1,249 @@
"""Tests unitaires du contrôle de secrets de déploiement."""
from __future__ import annotations
import importlib.util
import subprocess
import sys
from pathlib import Path
from types import ModuleType
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from _pytest.capture import CaptureFixture
@pytest.fixture
def secret_checker() -> ModuleType:
"""Charge le script de vérification sans l'exécuter comme programme.
:return: Module du script de contrôle de secrets.
:rtype: ModuleType
"""
script_path = Path(__file__).parents[2] / "scripts" / "check_secrets.py"
specification = importlib.util.spec_from_file_location("check_secrets", script_path)
assert specification is not None
assert specification.loader is not None
module = importlib.util.module_from_spec(specification)
sys.modules[specification.name] = module
try:
specification.loader.exec_module(module)
finally:
del sys.modules[specification.name]
return module
def test_main_accepts_clean_files_and_ignores_environment_file(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie qu'un dépôt propre réussit sans analyser le fichier d'environnement.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
(tmp_path / "application.py").write_text("value = 'safe'\n", encoding="utf-8")
ignored_environment_secret = 'password = "private-value"\n' # pragma: allowlist secret
(tmp_path / ".env").write_text(
ignored_environment_secret, encoding="utf-8"
) # secret-check: allow
assert secret_checker.main([], root=tmp_path) == 0
assert "OK:" in capsys.readouterr().out
def test_main_reports_a_literal_secret_without_disclosing_its_value(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie qu'un secret littéral échoue sans fuite de sa valeur.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
sentinel = "m14-literal-sentinel"
(tmp_path / "settings.py").write_text(
f'password = "{sentinel}"\n', encoding="utf-8"
) # secret-check: allow
assert secret_checker.main([], root=tmp_path) == 1
output = capsys.readouterr().out
assert "settings.py:1 (affectation-litterale)" in output
assert sentinel not in output
def test_main_reports_an_unquoted_configuration_secret_without_disclosing_its_value(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie qu'un secret de configuration non cité échoue sans fuite de sa valeur.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
sentinel = "m14-unquoted-sentinel"
(tmp_path / "settings.yaml").write_text(
f"password: {sentinel}\n", encoding="utf-8"
) # secret-check: allow
assert secret_checker.main([], root=tmp_path) == 1
output = capsys.readouterr().out
assert "settings.yaml:1 (affectation-litterale)" in output
assert sentinel not in output
def test_main_detects_sensitive_url_parameter(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie qu'un paramètre URL sensible déclenche un échec.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
sentinel = "m14-url-sentinel"
(tmp_path / "settings.yaml").write_text(
f"url: https://example.invalid/calendar?icalsecurise={sentinel}\n", encoding="utf-8"
) # secret-check: allow
assert secret_checker.main([], root=tmp_path) == 1
output = capsys.readouterr().out
assert "settings.yaml:1 (parametre-url)" in output
assert sentinel not in output
def test_staged_mode_inspects_only_paths_provided_by_git(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie que l'option staged ignore les fichiers non indexés.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
(tmp_path / "indexed.py").write_text("answer = 42\n", encoding="utf-8")
untracked_secret = 'api_key = "m14-untracked-sentinel"\n' # pragma: allowlist secret
(tmp_path / "untracked.py").write_text(
untracked_secret, encoding="utf-8"
) # secret-check: allow
def runner(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]:
"""Simule Git avec un seul fichier indexé.
:return: Résultat Git simulé.
:rtype: subprocess.CompletedProcess[str]
"""
return subprocess.CompletedProcess([], 0, stdout="indexed.py\0", stderr="")
assert secret_checker.main(["--staged"], root=tmp_path, runner=runner) == 0
assert "OK:" in capsys.readouterr().out
def test_main_detects_prefixed_secret_assignment(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie qu'une variable préfixée (PRONOTE_PASSWORD) est détectée.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
sentinel = "m14-prefixed-secret"
(tmp_path / "config.py").write_text(
f'PRONOTE_PASSWORD = "{sentinel}"\n', encoding="utf-8"
) # secret-check: allow
assert secret_checker.main([], root=tmp_path) == 1
output = capsys.readouterr().out
assert "config.py:1" in output
assert sentinel not in output
def test_main_detects_short_secret_assignment(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie qu'un secret court (< 8 caractères) est détecté.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
sentinel = "s3cr3t"
(tmp_path / "config.py").write_text(
f'password = "{sentinel}"\n', encoding="utf-8"
) # secret-check: allow
assert secret_checker.main([], root=tmp_path) == 1
output = capsys.readouterr().out
assert "config.py:1" in output
assert sentinel not in output
def test_staged_mode_reads_index_content_not_working_tree(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie que --staged lit le contenu indexé, pas le working tree.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
indexed_secret = "m14-indexed-only-secret" # pragma: allowlist secret
(tmp_path / "staged.py").write_text(
f'password = "{indexed_secret}"\n', encoding="utf-8"
) # secret-check: allow
(tmp_path / "staged.py").write_text('value = "safe"\n', encoding="utf-8")
def runner(*args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]:
"""Simule Git en renvoyant le contenu indexé pour le blob demandé.
:return: Résultat Git simulé.
:rtype: subprocess.CompletedProcess[str]
"""
first_argument = args[0] if args else []
command = (
[str(argument) for argument in first_argument]
if isinstance(first_argument, list)
else []
)
if "show" in command:
return subprocess.CompletedProcess(
command, 0, stdout=f'password = "{indexed_secret}"\n', stderr=""
)
return subprocess.CompletedProcess(command, 0, stdout="staged.py\0", stderr="")
assert secret_checker.main(["--staged"], root=tmp_path, runner=runner) == 1
output = capsys.readouterr().out
assert "staged.py:1" in output
assert indexed_secret not in output
def test_main_scans_extensionless_deployment_file(
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
) -> None:
"""Vérifie qu'un fichier de déploiement sans extension est scanné.
:param secret_checker: Module du script sous test.
:param tmp_path: Répertoire temporaire représentant un dépôt.
:param capsys: Fixture de capture de sortie.
:return: None
"""
sentinel = "m14-logrotate-secret"
(tmp_path / "pronote_sync").write_text(
f'password = "{sentinel}"\n', encoding="utf-8"
) # secret-check: allow
assert secret_checker.main([], root=tmp_path) == 1
output = capsys.readouterr().out
assert "pronote_sync:1" in output
assert sentinel not in output