fix(dashboard): DETTE-024 — download fleet, fallback legacy rendu visible

_resolve_lea_zip_template() reste résolu à la volée (full buildé après démarrage OK) ;
ajout d'un WARNING explicite quand le full est absent et qu'on retombe sur le ZIP
léger non autoportant (plus de fallback silencieux). Fonction injectable pour tests.
4 tests + 32 non-régression verts. refs DETTE-024

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-06-30 16:20:28 +02:00
parent 3ed9798f06
commit a50057d499
2 changed files with 114 additions and 5 deletions

View File

@@ -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

View File

@@ -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)