feat(update): DETTE-022 — noyau MAJ silencieuse client Léa (gated, swap en stub)
Logique PURE testée : parse_version semver (R3), decide_update code-only/full (R2), should_update client (double garde anti-downgrade), download_update (staging only + SHA256, downloader injectable). Endpoint GET /api/v1/agents/update/check gated (RPA_AUTO_UPDATE_SERVER_ENABLED). Flags client+serveur OFF par défaut. Swap fichiers / Lea.bat / restart = STUBS no-op réservés révision humaine. 34 tests TDD. refs DETTE-022 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
85
tests/integration/test_update_check_endpoint.py
Normal file
85
tests/integration/test_update_check_endpoint.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Tests intégration HTTP de GET /api/v1/agents/update/check — DETTE-022 v2.
|
||||
|
||||
Endpoint GATED (flag RPA_AUTO_UPDATE_SERVER_ENABLED), best-effort :
|
||||
- flag OFF par défaut → 503 (anti-régression : aucun effet sur le pipeline).
|
||||
- flag ON → 200 + payload {update_available, latest_version, update_type, url}.
|
||||
- auth Bearer requise (dépendance globale _verify_token).
|
||||
|
||||
La logique PURE est testée sans serveur dans tests/unit/test_update_check_server.py
|
||||
(DETTE-013). Ici on vérifie le branchement HTTP minimal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = str(Path(__file__).resolve().parents[2])
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_TEST_API_TOKEN = "test_update_check_endpoint_token"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
monkeypatch.setenv("RPA_API_TOKEN", _TEST_API_TOKEN)
|
||||
from fastapi.testclient import TestClient
|
||||
from agent_v0.server_v1 import api_stream
|
||||
|
||||
monkeypatch.setattr(api_stream, "API_TOKEN", _TEST_API_TOKEN)
|
||||
return TestClient(api_stream.app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def _auth_headers():
|
||||
return {"Authorization": f"Bearer {_TEST_API_TOKEN}"}
|
||||
|
||||
|
||||
class TestUpdateCheckEndpointFlag:
|
||||
def test_disabled_by_default_returns_503(self, client, monkeypatch):
|
||||
monkeypatch.delenv("RPA_AUTO_UPDATE_SERVER_ENABLED", raising=False)
|
||||
resp = client.get(
|
||||
"/api/v1/agents/update/check?current_version=1.0.1",
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
assert "RPA_AUTO_UPDATE_SERVER_ENABLED" in resp.text
|
||||
|
||||
|
||||
class TestUpdateCheckEndpointEnabled:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_flag(self, monkeypatch):
|
||||
monkeypatch.setenv("RPA_AUTO_UPDATE_SERVER_ENABLED", "true")
|
||||
# Version cible explicite pour rendre le test déterministe.
|
||||
monkeypatch.setenv("RPA_AGENT_LATEST_VERSION", "1.0.2")
|
||||
|
||||
def test_update_available(self, client):
|
||||
resp = client.get(
|
||||
"/api/v1/agents/update/check?current_version=1.0.1&machine_id=pc-1",
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["update_available"] is True
|
||||
assert body["latest_version"] == "1.0.2"
|
||||
assert body["update_type"] == "code-only"
|
||||
assert "1.0.2" in body["url"]
|
||||
|
||||
def test_up_to_date(self, client):
|
||||
resp = client.get(
|
||||
"/api/v1/agents/update/check?current_version=1.0.2&machine_id=pc-1",
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["update_available"] is False
|
||||
|
||||
def test_requires_auth(self, client):
|
||||
resp = client.get(
|
||||
"/api/v1/agents/update/check?current_version=1.0.1",
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
225
tests/unit/test_agent_v1_updater.py
Normal file
225
tests/unit/test_agent_v1_updater.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""TDD — DETTE-022 MAJ silencieuse v2 : NOYAU client de mise à jour Léa.
|
||||
|
||||
Périmètre testé (parties PURES / testables, GATED, OFF par défaut) :
|
||||
- `parse_version` / `is_newer` côté client (R3, self-contained — le bundle
|
||||
client n'embarque pas server_v1).
|
||||
- `should_update(local_version, server_response)` : décision « faut-il
|
||||
updater ? quelle version/type ? » à partir de la réponse serveur.
|
||||
- `download_update(...)` via un `downloader` callable INJECTABLE : AUCUN
|
||||
réseau réel en test. Vérifie le SHA256, écrit le ZIP dans le staging,
|
||||
retourne un plan d'update — SANS toucher aux fichiers vivants.
|
||||
- Flag `RPA_AUTO_UPDATE_ENABLED` (défaut OFF) : `auto_update_enabled()`.
|
||||
|
||||
HORS périmètre (réservé révision humaine — trop risqué pour un agent) :
|
||||
swap réel des fichiers, édition Lea.bat, redémarrage. Le module expose des
|
||||
STUBS explicites (`apply_update`, `write_boot_ok_marker`) marqués TODO.
|
||||
|
||||
Le module est chargé par chemin (importlib) pour ne dépendre d'aucun import
|
||||
lourd du package client (cf. DETTE-013, comme test_agent_v1_log_shipper).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_MOD_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "agent_v0" / "agent_v1" / "network" / "updater.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_module():
|
||||
spec = importlib.util.spec_from_file_location("lea_updater", _MOD_PATH)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mod():
|
||||
return _load_module()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R3 — parse_version côté client (self-contained)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestClientParseVersion:
|
||||
def test_ordre_semver(self, mod):
|
||||
assert mod.parse_version("1.0.2") < mod.parse_version("1.0.10")
|
||||
assert mod.is_newer("1.0.10", "1.0.2") is True
|
||||
assert mod.is_newer("1.0.1", "1.0.1") is False
|
||||
|
||||
def test_tolerant_et_fallback(self, mod):
|
||||
assert mod.parse_version("v1.2.3") == (1, 2, 3)
|
||||
assert mod.parse_version("garbage") == (0,)
|
||||
assert mod.parse_version(None) == (0,)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flag RPA_AUTO_UPDATE_ENABLED — OFF par défaut
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFlag:
|
||||
def test_off_par_defaut(self, mod, monkeypatch):
|
||||
monkeypatch.delenv("RPA_AUTO_UPDATE_ENABLED", raising=False)
|
||||
assert mod.auto_update_enabled() is False
|
||||
|
||||
def test_on_si_active(self, mod, monkeypatch):
|
||||
for val in ("true", "1", "yes", "on", "TRUE"):
|
||||
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", val)
|
||||
assert mod.auto_update_enabled() is True
|
||||
|
||||
def test_off_si_valeur_invalide(self, mod, monkeypatch):
|
||||
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "false")
|
||||
assert mod.auto_update_enabled() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# should_update — décision à partir de la réponse serveur
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestShouldUpdate:
|
||||
def test_pas_de_maj_si_response_negative(self, mod):
|
||||
plan = mod.should_update(
|
||||
"1.0.1", {"update_available": False, "latest_version": "1.0.1"}
|
||||
)
|
||||
assert plan is None
|
||||
|
||||
def test_maj_si_serveur_propose_version_plus_recente(self, mod):
|
||||
plan = mod.should_update(
|
||||
"1.0.1",
|
||||
{
|
||||
"update_available": True,
|
||||
"latest_version": "1.0.2",
|
||||
"update_type": "code-only",
|
||||
"url": "http://srv/api/fleet/download/pc-1?type=code-only&version=1.0.2",
|
||||
},
|
||||
)
|
||||
assert plan is not None
|
||||
assert plan["target_version"] == "1.0.2"
|
||||
assert plan["update_type"] == "code-only"
|
||||
|
||||
def test_double_garde_pas_de_downgrade(self, mod):
|
||||
# Même si le serveur dit update_available, le client revérifie semver :
|
||||
# il ne descend JAMAIS vers une version <= locale (défense en profondeur).
|
||||
plan = mod.should_update(
|
||||
"1.0.5",
|
||||
{"update_available": True, "latest_version": "1.0.2",
|
||||
"update_type": "code-only", "url": "http://x"},
|
||||
)
|
||||
assert plan is None
|
||||
|
||||
def test_type_inconnu_normalise_code_only(self, mod):
|
||||
plan = mod.should_update(
|
||||
"1.0.1",
|
||||
{"update_available": True, "latest_version": "1.0.2",
|
||||
"update_type": "weird", "url": "http://x"},
|
||||
)
|
||||
assert plan["update_type"] == "code-only"
|
||||
|
||||
def test_response_malformee_pas_de_crash(self, mod):
|
||||
assert mod.should_update("1.0.1", {}) is None
|
||||
assert mod.should_update("1.0.1", None) is None
|
||||
assert mod.should_update("1.0.1", {"update_available": True}) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# download_update — downloader INJECTABLE, SHA256, aucun réseau réel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDownloadUpdate:
|
||||
def test_telecharge_et_verifie_sha256_ok(self, mod, tmp_path):
|
||||
payload = b"PK\x03\x04 fake zip bytes"
|
||||
sha = hashlib.sha256(payload).hexdigest()
|
||||
|
||||
calls = {}
|
||||
|
||||
def fake_downloader(url):
|
||||
calls["url"] = url
|
||||
return payload
|
||||
|
||||
plan = {
|
||||
"target_version": "1.0.2",
|
||||
"update_type": "code-only",
|
||||
"url": "http://srv/dl?version=1.0.2",
|
||||
"sha256": sha,
|
||||
}
|
||||
result = mod.download_update(
|
||||
plan, staging_dir=tmp_path, downloader=fake_downloader
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert calls["url"] == "http://srv/dl?version=1.0.2"
|
||||
# Le ZIP est écrit dans le staging (Lea_next-like), PAS dans les fichiers vivants.
|
||||
staged = Path(result["staged_zip"])
|
||||
assert staged.exists()
|
||||
assert staged.read_bytes() == payload
|
||||
assert staged.parent == tmp_path
|
||||
|
||||
def test_sha256_mismatch_rejette_et_nettoie(self, mod, tmp_path):
|
||||
payload = b"corrupted"
|
||||
|
||||
def fake_downloader(url):
|
||||
return payload
|
||||
|
||||
plan = {
|
||||
"target_version": "1.0.2",
|
||||
"update_type": "code-only",
|
||||
"url": "http://x",
|
||||
"sha256": "0" * 64, # ne correspond pas
|
||||
}
|
||||
result = mod.download_update(
|
||||
plan, staging_dir=tmp_path, downloader=fake_downloader
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert "sha256" in result["error"].lower()
|
||||
# Aucun ZIP corrompu laissé dans le staging.
|
||||
assert list(tmp_path.glob("*.zip")) == []
|
||||
|
||||
def test_sha256_absent_accepte_avec_avertissement(self, mod, tmp_path):
|
||||
# Pas de sha256 fourni : best-effort, on accepte mais on le signale.
|
||||
payload = b"PK no-sha"
|
||||
|
||||
plan = {
|
||||
"target_version": "1.0.2",
|
||||
"update_type": "code-only",
|
||||
"url": "http://x",
|
||||
}
|
||||
result = mod.download_update(
|
||||
plan, staging_dir=tmp_path, downloader=lambda u: payload
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result.get("sha256_verified") is False
|
||||
|
||||
def test_downloader_leve_pas_de_crash(self, mod, tmp_path):
|
||||
def boom(url):
|
||||
raise RuntimeError("réseau down")
|
||||
|
||||
plan = {"target_version": "1.0.2", "update_type": "code-only",
|
||||
"url": "http://x", "sha256": "x"}
|
||||
result = mod.download_update(plan, staging_dir=tmp_path, downloader=boom)
|
||||
assert result["ok"] is False
|
||||
assert "error" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stubs réservés à la révision humaine — DOIVENT être no-op explicites
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDangerousPartsAreStubs:
|
||||
def test_apply_update_est_un_stub_non_implemente(self, mod, tmp_path):
|
||||
# Le swap réel est réservé révision humaine : le stub NE TOUCHE RIEN
|
||||
# et signale qu'il n'est pas implémenté.
|
||||
result = mod.apply_update(
|
||||
{"target_version": "1.0.2", "update_type": "code-only",
|
||||
"staged_zip": str(tmp_path / "x.zip")}
|
||||
)
|
||||
assert result["applied"] is False
|
||||
assert "human" in result["reason"].lower() or "supervis" in result["reason"].lower()
|
||||
|
||||
def test_write_boot_ok_marker_est_un_stub(self, mod):
|
||||
result = mod.write_boot_ok_marker("1.0.2")
|
||||
assert result["written"] is False
|
||||
135
tests/unit/test_update_check_server.py
Normal file
135
tests/unit/test_update_check_server.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""TDD — DETTE-022 MAJ silencieuse v2 : logique PURE serveur de décision d'update.
|
||||
|
||||
Périmètre testé ICI = parties PURES, testables sans démarrer le serveur
|
||||
(DETTE-013 : on N'IMPORTE PAS `api_stream` — on charge le module
|
||||
`update_check.py` par chemin, comme test_agent_v1_log_shipper).
|
||||
|
||||
Couvre :
|
||||
- R3 `parse_version()` : tuple d'entiers, "1.0.2" < "1.0.10", égalité,
|
||||
"v1.2.3"/espaces tolérés, format invalide → fallback sans crash.
|
||||
- R2 logique de décision PURE `decide_update()` : compare version courante
|
||||
vs dernière dispo, choisit `update_type` (code-only/full), construit la
|
||||
réponse `{update_available, latest_version, update_type, url}`.
|
||||
|
||||
Le NOYAU dangereux (swap fichiers / Lea.bat / restart) est HORS périmètre.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_MOD_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "agent_v0" / "server_v1" / "update_check.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_module():
|
||||
spec = importlib.util.spec_from_file_location("rpa_update_check", _MOD_PATH)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mod():
|
||||
return _load_module()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R3 — parse_version : tuple d'entiers (semver), pas comparaison lexicale
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseVersion:
|
||||
def test_parse_basique(self, mod):
|
||||
assert mod.parse_version("1.0.2") == (1, 0, 2)
|
||||
assert mod.parse_version("1.0.10") == (1, 0, 10)
|
||||
|
||||
def test_ordre_semver_pas_lexical(self, mod):
|
||||
# Le bug classique : "1.0.2" < "1.0.10" est FAUX en lexicographique.
|
||||
assert mod.parse_version("1.0.2") < mod.parse_version("1.0.10")
|
||||
assert mod.parse_version("1.0.10") > mod.parse_version("1.0.2")
|
||||
assert mod.parse_version("2.0.0") > mod.parse_version("1.99.99")
|
||||
|
||||
def test_egalite(self, mod):
|
||||
assert mod.parse_version("1.0.1") == mod.parse_version("1.0.1")
|
||||
|
||||
def test_prefixe_v_et_espaces_toleres(self, mod):
|
||||
assert mod.parse_version("v1.2.3") == mod.parse_version("1.2.3")
|
||||
assert mod.parse_version(" 1.2.3 ") == (1, 2, 3)
|
||||
assert mod.parse_version("V1.2.3") == (1, 2, 3)
|
||||
|
||||
def test_format_invalide_fallback_sans_crash(self, mod):
|
||||
# Ne doit jamais lever — fallback (0,) (= la plus basse).
|
||||
assert mod.parse_version("") == (0,)
|
||||
assert mod.parse_version("abc") == (0,)
|
||||
assert mod.parse_version(None) == (0,)
|
||||
assert mod.parse_version("1.x.3") == (0,)
|
||||
# Une version valide reste toujours > au fallback invalide.
|
||||
assert mod.parse_version("0.0.1") > mod.parse_version("garbage")
|
||||
|
||||
def test_is_newer_helper(self, mod):
|
||||
assert mod.is_newer("1.0.2", "1.0.1") is True
|
||||
assert mod.is_newer("1.0.10", "1.0.2") is True
|
||||
assert mod.is_newer("1.0.1", "1.0.1") is False
|
||||
assert mod.is_newer("1.0.0", "1.0.1") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R2 — decide_update : logique PURE de décision serveur
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDecideUpdate:
|
||||
def test_pas_de_maj_si_a_jour(self, mod):
|
||||
resp = mod.decide_update(current_version="1.0.1", latest_version="1.0.1")
|
||||
assert resp["update_available"] is False
|
||||
assert resp["latest_version"] == "1.0.1"
|
||||
assert resp["update_type"] is None
|
||||
assert resp["url"] is None
|
||||
|
||||
def test_pas_de_maj_si_client_plus_recent(self, mod):
|
||||
# Client en avance (dev local) → jamais de downgrade.
|
||||
resp = mod.decide_update(current_version="1.0.5", latest_version="1.0.2")
|
||||
assert resp["update_available"] is False
|
||||
|
||||
def test_maj_disponible_code_only_par_defaut(self, mod):
|
||||
resp = mod.decide_update(current_version="1.0.1", latest_version="1.0.2")
|
||||
assert resp["update_available"] is True
|
||||
assert resp["latest_version"] == "1.0.2"
|
||||
# R2 : code-only = défaut (99% des cas, ~500 Ko).
|
||||
assert resp["update_type"] == "code-only"
|
||||
assert "1.0.2" in resp["url"]
|
||||
assert "code-only" in resp["url"]
|
||||
|
||||
def test_maj_full_si_demande(self, mod):
|
||||
resp = mod.decide_update(
|
||||
current_version="1.0.1", latest_version="1.1.0", update_type="full"
|
||||
)
|
||||
assert resp["update_available"] is True
|
||||
assert resp["update_type"] == "full"
|
||||
assert "full" in resp["url"]
|
||||
|
||||
def test_update_type_invalide_retombe_sur_code_only(self, mod):
|
||||
resp = mod.decide_update(
|
||||
current_version="1.0.1", latest_version="1.0.2", update_type="banana"
|
||||
)
|
||||
assert resp["update_type"] == "code-only"
|
||||
|
||||
def test_ordre_semver_dans_decision(self, mod):
|
||||
# 1.0.2 < 1.0.10 → MAJ dispo (pas de faux négatif lexical).
|
||||
resp = mod.decide_update(current_version="1.0.2", latest_version="1.0.10")
|
||||
assert resp["update_available"] is True
|
||||
|
||||
def test_url_inclut_machine_id_si_fourni(self, mod):
|
||||
resp = mod.decide_update(
|
||||
current_version="1.0.1", latest_version="1.0.2", machine_id="pc-7"
|
||||
)
|
||||
assert "pc-7" in resp["url"]
|
||||
|
||||
def test_versions_invalides_pas_de_crash_pas_de_maj(self, mod):
|
||||
# latest illisible → on ne propose RIEN (prudence : pas de MAJ douteuse).
|
||||
resp = mod.decide_update(current_version="1.0.1", latest_version="garbage")
|
||||
assert resp["update_available"] is False
|
||||
resp2 = mod.decide_update(current_version="", latest_version="")
|
||||
assert resp2["update_available"] is False
|
||||
Reference in New Issue
Block a user