"""TDD — DETTE-022 v2 : CANARY server-side pour la MAJ silencieuse Léa. Périmètre testé ICI = logique PURE de la POLITIQUE de déploiement canary, testable sans démarrer le serveur (DETTE-013 : on N'IMPORTE PAS `api_stream` — on charge `update_policy.py` par chemin, comme test_update_check_server). Objectif SÉCURITÉ (10+ postes cliniques live) : une MAJ ne doit JAMAIS partir sur toute la flotte d'un coup. Le canary résout la version cible *par machine* : - un poste dans la liste canary reçoit la version `canary` (Émilie d'abord) ; - tous les autres restent sur la version `stable` (floor) tant que le canary n'est pas promu. `resolve_target_version(machine_id, ...)` est la brique PURE ; `decide_update` côté serveur l'appelle pour choisir la version cible avant de comparer. Le NOYAU dangereux (swap fichiers / Lea.bat / restart) reste 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_policy.py" ) def _load_module(): spec = importlib.util.spec_from_file_location("rpa_update_policy", _MOD_PATH) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod @pytest.fixture def mod(): return _load_module() # --------------------------------------------------------------------------- # parse_canary_machines — liste d'allow-list (CSV / espaces tolérés) # --------------------------------------------------------------------------- class TestParseCanaryMachines: def test_liste_csv(self, mod): assert mod.parse_canary_machines("lea-4zbgwxty") == {"lea-4zbgwxty"} assert mod.parse_canary_machines("a,b,c") == {"a", "b", "c"} def test_espaces_et_vides_toleres(self, mod): assert mod.parse_canary_machines(" a , b , ") == {"a", "b"} assert mod.parse_canary_machines("") == set() assert mod.parse_canary_machines(None) == set() def test_supporte_separateurs_espace_et_point_virgule(self, mod): # Tolérant : virgule, point-virgule, espace comme séparateurs. assert mod.parse_canary_machines("a; b c") == {"a", "b", "c"} # --------------------------------------------------------------------------- # resolve_target_version — LE cœur canary (sécurité) # --------------------------------------------------------------------------- class TestResolveTargetVersion: def test_machine_canary_recoit_version_canary(self, mod): # Émilie (canary) reçoit la nouvelle version en premier. target = mod.resolve_target_version( machine_id="lea-4zbgwxty", stable_version="1.0.1", canary_version="1.0.2", canary_machines={"lea-4zbgwxty"}, ) assert target == "1.0.2" def test_machine_hors_canary_reste_sur_stable(self, mod): # Tous les autres postes restent sur la version stable (floor). target = mod.resolve_target_version( machine_id="lea-autre-poste", stable_version="1.0.1", canary_version="1.0.2", canary_machines={"lea-4zbgwxty"}, ) assert target == "1.0.1" def test_pas_de_canary_configure_tout_le_monde_stable(self, mod): # Aucun canary défini → personne ne monte (défaut ultra-prudent). target = mod.resolve_target_version( machine_id="lea-4zbgwxty", stable_version="1.0.1", canary_version="1.0.2", canary_machines=set(), ) assert target == "1.0.1" def test_canary_version_absente_retombe_sur_stable(self, mod): # Si canary_version n'est pas fournie, même un poste canary reste stable. target = mod.resolve_target_version( machine_id="lea-4zbgwxty", stable_version="1.0.1", canary_version=None, canary_machines={"lea-4zbgwxty"}, ) assert target == "1.0.1" def test_machine_id_none_reste_stable(self, mod): # machine_id inconnu / non fourni → jamais canary (prudence). target = mod.resolve_target_version( machine_id=None, stable_version="1.0.1", canary_version="1.0.2", canary_machines={"lea-4zbgwxty"}, ) assert target == "1.0.1" def test_canary_ne_downgrade_jamais_en_dessous_de_stable(self, mod): # GARDE-FOU : si le canary_version est PLUS ANCIEN que stable (erreur # de config), on NE descend PAS le poste canary — on sert stable. target = mod.resolve_target_version( machine_id="lea-4zbgwxty", stable_version="1.0.5", canary_version="1.0.2", # plus ancien → config douteuse canary_machines={"lea-4zbgwxty"}, ) assert target == "1.0.5" # --------------------------------------------------------------------------- # Lecture depuis l'environnement (pilotage sans rebuild) — défauts prudents # --------------------------------------------------------------------------- class TestEnvPolicy: def test_defauts_prudents_aucune_maj(self, mod, monkeypatch): # Aucune var positionnée → stable par défaut, pas de canary. for var in ( "RPA_AGENT_STABLE_VERSION", "RPA_AGENT_CANARY_VERSION", "RPA_AGENT_CANARY_MACHINES", ): monkeypatch.delenv(var, raising=False) assert mod.stable_version_from_env() == "1.0.1" assert mod.canary_version_from_env() is None assert mod.canary_machines_from_env() == set() # Un poste quelconque reste sur stable. assert mod.resolve_target_version_from_env("lea-4zbgwxty") == "1.0.1" def test_canary_actif_via_env_seul_le_poste_canary_monte(self, mod, monkeypatch): monkeypatch.setenv("RPA_AGENT_STABLE_VERSION", "1.0.1") monkeypatch.setenv("RPA_AGENT_CANARY_VERSION", "1.0.2") monkeypatch.setenv("RPA_AGENT_CANARY_MACHINES", "lea-4zbgwxty") assert mod.resolve_target_version_from_env("lea-4zbgwxty") == "1.0.2" assert mod.resolve_target_version_from_env("autre-poste") == "1.0.1" def test_promotion_toute_la_flotte_suit(self, mod, monkeypatch): # Promotion : on met stable = version canary, on vide la liste canary. monkeypatch.setenv("RPA_AGENT_STABLE_VERSION", "1.0.2") monkeypatch.delenv("RPA_AGENT_CANARY_VERSION", raising=False) monkeypatch.delenv("RPA_AGENT_CANARY_MACHINES", raising=False) assert mod.resolve_target_version_from_env("autre-poste") == "1.0.2" assert mod.resolve_target_version_from_env("lea-4zbgwxty") == "1.0.2"