feat(update): scaffold MAJ silencieuse + canary par machine (DETTE-022, gated OFF, swap encore stub)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-07-01 12:36:48 +02:00
parent 2a1b1ed80e
commit 61664c9a36
10 changed files with 879 additions and 7 deletions

View File

@@ -223,3 +223,114 @@ class TestDangerousPartsAreStubs:
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
# ---------------------------------------------------------------------------
# run_update_cycle — orchestrateur GATED (check → décide → stage → stub apply)
# AUCUN réseau réel, AUCUN swap réel : checker/downloader INJECTABLES, le swap
# reste un stub no-op (réservé révision humaine).
# ---------------------------------------------------------------------------
class TestRunUpdateCycle:
def _checker(self, response):
"""Fabrique un checker injectable qui renvoie `response`."""
def _c(local_version, machine_id):
return response
return _c
def test_gate_off_ne_fait_rien(self, mod, tmp_path, monkeypatch):
# Flag OFF (défaut) : le cycle ne doit RIEN faire (pas d'appel réseau).
monkeypatch.delenv("RPA_AUTO_UPDATE_ENABLED", raising=False)
called = {"n": 0}
def _checker(local_version, machine_id):
called["n"] += 1
return {"update_available": True, "latest_version": "9.9.9",
"url": "http://x", "sha256": None}
result = mod.run_update_cycle(
local_version="1.0.1",
machine_id="pc-1",
staging_dir=tmp_path,
checker=_checker,
downloader=lambda u: b"x",
)
assert result["status"] == "disabled"
assert called["n"] == 0 # aucun appel réseau quand OFF
def test_a_jour_ne_stage_rien(self, mod, tmp_path, monkeypatch):
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true")
result = mod.run_update_cycle(
local_version="1.0.1",
machine_id="pc-1",
staging_dir=tmp_path,
checker=self._checker(
{"update_available": False, "latest_version": "1.0.1"}
),
downloader=lambda u: b"should-not-be-called",
)
assert result["status"] == "up_to_date"
assert list(tmp_path.glob("*.zip")) == []
def test_maj_dispo_telecharge_en_staging_mais_ne_swappe_pas(
self, mod, tmp_path, monkeypatch
):
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true")
payload = b"PK\x03\x04 fake"
sha = hashlib.sha256(payload).hexdigest()
result = mod.run_update_cycle(
local_version="1.0.1",
machine_id="pc-1",
staging_dir=tmp_path,
checker=self._checker({
"update_available": True,
"latest_version": "1.0.2",
"update_type": "code-only",
"url": "http://srv/dl?version=1.0.2",
"sha256": sha,
}),
downloader=lambda u: payload,
)
# Téléchargé + vérifié + STAGÉ, mais PAS appliqué (swap = stub humain).
assert result["status"] == "staged"
assert result["target_version"] == "1.0.2"
assert result["sha256_verified"] is True
staged = Path(result["staged_zip"])
assert staged.exists() and staged.parent == tmp_path
# Le swap est explicitement NON fait (réservé révision humaine).
assert result["applied"] is False
def test_sha256_mismatch_ne_stage_pas(self, mod, tmp_path, monkeypatch):
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true")
result = mod.run_update_cycle(
local_version="1.0.1",
machine_id="pc-1",
staging_dir=tmp_path,
checker=self._checker({
"update_available": True,
"latest_version": "1.0.2",
"update_type": "code-only",
"url": "http://x",
"sha256": "0" * 64,
}),
downloader=lambda u: b"corrupted",
)
assert result["status"] == "download_failed"
assert list(tmp_path.glob("*.zip")) == []
def test_checker_qui_leve_pas_de_crash(self, mod, tmp_path, monkeypatch):
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true")
def _boom(local_version, machine_id):
raise RuntimeError("serveur down / 503")
result = mod.run_update_cycle(
local_version="1.0.1",
machine_id="pc-1",
staging_dir=tmp_path,
checker=_boom,
downloader=lambda u: b"x",
)
# Best-effort : jamais d'exception ne remonte (ne casse pas Léa).
assert result["status"] == "check_failed"