diff --git a/tests/unit/test_resolve_lea_zip_template.py b/tests/unit/test_resolve_lea_zip_template.py new file mode 100644 index 000000000..017f2447e --- /dev/null +++ b/tests/unit/test_resolve_lea_zip_template.py @@ -0,0 +1,75 @@ +"""Tests unitaires pour _resolve_lea_zip_template (DETTE-024). + +La fonction est injectable (full_path, legacy_path en paramètres) +→ testable sans instancier Flask ni lire le vrai deploy/. + +Pattern anti-DETTE-013 : os.environ.setdefault avant l'import du module. +""" +import os + +os.environ.setdefault("DASHBOARD_AUTH_DISABLED", "true") + +import pytest # noqa: E402 +from web_dashboard.app import _resolve_lea_zip_template # noqa: E402 + + +class TestResolveLéaZipTemplate: + """DETTE-024 — sélection du ZIP template pour le download fleet.""" + + def test_full_present_retourne_full(self, tmp_path): + """Si le ZIP complet autoportant est présent, il est retourné.""" + full = tmp_path / "Lea_full_v1.0.1.zip" + legacy = tmp_path / "Lea_v1.0.0.zip" + full.write_bytes(b"full-stub") + legacy.write_bytes(b"legacy-stub") + + result = _resolve_lea_zip_template(full_path=full, legacy_path=legacy) + + assert result == full, f"Attendu full ({full}), obtenu {result}" + + def test_full_absent_retourne_legacy_avec_warning(self, tmp_path, caplog): + """Si le ZIP complet est absent, le legacy est retourné + WARNING loggué. + + Le WARNING est le signal observable en production (DETTE-024) : + sans lui, le fallback silencieux rendait le problème invisible. + """ + import logging + + full = tmp_path / "Lea_full_v1.0.1.zip" + legacy = tmp_path / "Lea_v1.0.0.zip" + # full intentionnellement absent + legacy.write_bytes(b"legacy-stub") + + with caplog.at_level(logging.WARNING): + result = _resolve_lea_zip_template(full_path=full, legacy_path=legacy) + + assert result == legacy, f"Attendu legacy ({legacy}), obtenu {result}" + # Le WARNING DETTE-024 doit apparaître dans les logs + assert any( + "DETTE-024" in record.message for record in caplog.records + ), ( + "Un WARNING DETTE-024 doit être émis quand le ZIP complet est absent " + f"(logs: {[r.message for r in caplog.records]})" + ) + + def test_full_et_legacy_absents_retourne_none(self, tmp_path): + """Si aucun ZIP n'existe, retourne None (la route renvoie 500).""" + full = tmp_path / "Lea_full_v1.0.1.zip" + legacy = tmp_path / "Lea_v1.0.0.zip" + # aucun des deux créés + + result = _resolve_lea_zip_template(full_path=full, legacy_path=legacy) + + assert result is None, f"Attendu None, obtenu {result}" + + def test_full_prime_sur_legacy(self, tmp_path): + """Le full est retourné même si le legacy existe aussi (priorité correcte).""" + full = tmp_path / "Lea_full_v1.0.1.zip" + legacy = tmp_path / "Lea_v1.0.0.zip" + full.write_bytes(b"full-stub") + legacy.write_bytes(b"legacy-stub") + + result = _resolve_lea_zip_template(full_path=full, legacy_path=legacy) + + assert result == full + assert result != legacy diff --git a/web_dashboard/app.py b/web_dashboard/app.py index d9c96f320..af08283ff 100644 --- a/web_dashboard/app.py +++ b/web_dashboard/app.py @@ -2231,16 +2231,37 @@ _LEA_ZIP_TEMPLATE_FULL = BASE_PATH / "deploy" / "build" / "Lea_full_v1.0.1.zip" _LEA_ZIP_TEMPLATE_LEGACY = BASE_PATH / "deploy" / "Lea_v1.0.0.zip" -def _resolve_lea_zip_template(): +def _resolve_lea_zip_template( + full_path: Path = _LEA_ZIP_TEMPLATE_FULL, + legacy_path: Path = _LEA_ZIP_TEMPLATE_LEGACY, +) -> "Path | None": """Résout le ZIP à servir, à la volée (le complet peut être buildé après le démarrage du dashboard). Préfère le ZIP complet autoportant ; retombe sur l'ancien ZIP léger uniquement s'il existe. Retourne None si aucun template n'est présent. + + Les paramètres full_path/legacy_path sont injectables pour les tests + (évite de démarrer Flask — DETTE-013). + + ⚠️ DETTE-024 : si le ZIP complet est absent, un avertissement est loggué + explicitement pour ne pas masquer silencieusement l'absence du full. """ - if _LEA_ZIP_TEMPLATE_FULL.exists(): - return _LEA_ZIP_TEMPLATE_FULL - if _LEA_ZIP_TEMPLATE_LEGACY.exists(): - return _LEA_ZIP_TEMPLATE_LEGACY + if full_path.exists(): + return full_path + # Full absent → fallback sur le legacy, mais log d'avertissement obligatoire. + if legacy_path.exists(): + try: + api_logger.warning( + "DETTE-024 — ZIP Léa complet autoportant ABSENT (%s) ; " + "fallback sur ZIP léger NON autoportant (%s). " + "Le poste recevra un ZIP sans Python embarqué → non installable " + "sans Python système. Exécuter deploy/build_package_full.sh.", + full_path, + legacy_path, + ) + except Exception: + pass # api_logger pas encore initialisé au module load (import tardif ok) + return legacy_path return None @@ -2389,8 +2410,14 @@ def download_agent_package(machine_id): # Sécurité : l'auth Basic est déjà gérée par before_request # 1. Résoudre + vérifier que le ZIP template existe (à la volée) + # _resolve_lea_zip_template() logue un WARNING si le full est absent (DETTE-024). zip_template = _resolve_lea_zip_template() if zip_template is None: + api_logger.error( + "download_agent_package(%s) — aucun ZIP template présent. " + "full=%s legacy=%s", + machine_id, _LEA_ZIP_TEMPLATE_FULL, _LEA_ZIP_TEMPLATE_LEGACY, + ) return jsonify({ 'error': 'ZIP template introuvable', 'detail': ( @@ -2399,6 +2426,13 @@ def download_agent_package(machine_id): 'autoportant) ou deploy/build_package.sh (ZIP léger).' ), }), 500 + is_full = (zip_template == _LEA_ZIP_TEMPLATE_FULL) + zip_kind = "full-autoportant" if is_full else "legacy-léger⚠️" + api_logger.info( + "download_agent_package(%s) — ZIP sélectionné : %s (%s, %d Ko)", + machine_id, zip_template.name, zip_kind, + zip_template.stat().st_size // 1024, + ) # 2. Vérifier que le machine_id est enregistré agent = _fetch_fleet_agent(machine_id)