232 lines
8.9 KiB
Python
232 lines
8.9 KiB
Python
"""Tests: update receipts embed ONLY their own pm sync sections.
|
|
|
|
The updater embeds the sync receipt that carries ITS correlation id —
|
|
read from pm.receipt's per-context completions, never from latest.json.
|
|
A sync that finished before the update began, or one running in a
|
|
concurrent thread, must never be misattributed to the invoking update.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
|
|
import pytest
|
|
|
|
import hermes_cli.update_receipt as ur
|
|
|
|
|
|
@pytest.fixture
|
|
def homed(tmp_path, monkeypatch):
|
|
import hermes_constants
|
|
|
|
monkeypatch.setattr(hermes_constants, "get_hermes_home", lambda: tmp_path)
|
|
import pm.receipt as pm_receipt_mod
|
|
|
|
monkeypatch.setattr(pm_receipt_mod, "_receipt_dir", lambda: tmp_path / "logs" / "update_receipts")
|
|
monkeypatch.setattr(ur, "_receipt_dir", lambda: tmp_path / "logs" / "update_receipts")
|
|
return tmp_path
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolated_receipt_state():
|
|
"""No leaked in-flight receipts, completion maps, or correlation
|
|
between tests."""
|
|
import pm.receipt as pm_receipt_mod
|
|
|
|
pm_receipt_mod._current.set(None)
|
|
pm_receipt_mod._completed_by_update.set(None)
|
|
ur._current.set(None)
|
|
yield
|
|
pm_receipt_mod._current.set(None)
|
|
pm_receipt_mod._completed_by_update.set(None)
|
|
ur._current.set(None)
|
|
|
|
|
|
def _sync_under_update(**sections):
|
|
"""A pm sync that runs WHILE the update receipt is open — the real
|
|
in-process update flow (sync_venv)."""
|
|
import pm.receipt as pm_receipt_mod
|
|
|
|
pm_receipt_mod.begin("sync")
|
|
if "venv_rebuild" in sections:
|
|
pm_receipt_mod.record_venv_rebuild(**sections.pop("venv_rebuild"))
|
|
if "features" in sections:
|
|
pm_receipt_mod.record_feature_list(sections.pop("features"))
|
|
if "warnings" in sections:
|
|
for message in sections.pop("warnings"):
|
|
pm_receipt_mod.record_warning(message)
|
|
if "refusal" in sections:
|
|
pm_receipt_mod.record_refusal(*sections.pop("refusal"))
|
|
pm_receipt_mod.finalize("ok")
|
|
|
|
|
|
def test_update_receipt_embeds_pm_sections(homed):
|
|
ur.begin_update_receipt()
|
|
_sync_under_update(
|
|
venv_rebuild={"ok": True, "reason": ""},
|
|
features=["web", "acp"],
|
|
)
|
|
ur.record_step("git-pull", True)
|
|
path = ur.finalize_update_receipt("success")
|
|
|
|
data = json.loads((homed / "logs" / "update_receipts" / "latest.json").read_text(encoding="utf-8"))
|
|
assert json.loads(path.read_text(encoding="utf-8")) == data
|
|
assert data["outcome"] == "success"
|
|
assert data["pm_venv_rebuild"] == {"ok": True, "reason": ""}
|
|
assert data["pm_feature_list"] == ["web", "acp"]
|
|
assert data["pm_sync_outcome"] == "ok"
|
|
assert data["update_id"]
|
|
|
|
|
|
def test_legacy_worker_receipt_fields_still_embed(homed):
|
|
from pm.receipt import accept_worker_receipt
|
|
|
|
ur.begin_update_receipt()
|
|
update_id = ur.current_correlation_id()
|
|
legacy = {"update_id": update_id, "outcome": "ok",
|
|
"plugin_bisect": [{"plugin": "old-plugin", "action": "disabled"}]}
|
|
accept_worker_receipt(legacy, update_id)
|
|
path = ur.finalize_update_receipt("success")
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
assert data["pm_plugin_bisect"] == legacy["plugin_bisect"]
|
|
|
|
|
|
|
|
def test_stale_sync_from_before_the_update_is_never_embedded(homed):
|
|
"""The audit defect: an old sync receipt on disk (latest.json's newest)
|
|
must not ride along on an update that caused no sync of its own."""
|
|
import pm.receipt as pm_receipt_mod
|
|
|
|
pm_receipt_mod.begin("sync")
|
|
pm_receipt_mod.record_venv_rebuild(True, "stale run")
|
|
pm_receipt_mod.finalize("ok")
|
|
assert pm_receipt_mod.latest()["venv_rebuild"]["reason"] == "stale run"
|
|
|
|
ur.begin_update_receipt()
|
|
path = ur.finalize_update_receipt("success")
|
|
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
assert "pm_venv_rebuild" not in data
|
|
assert "pm_sync_outcome" not in data
|
|
|
|
|
|
def test_concurrent_thread_sync_is_never_embedded(homed):
|
|
"""A sync finalizing in another thread (cadence plugin-check, parallel
|
|
ensure) finalizes into ITS context — the invoking update must not
|
|
embed it, even though it hit latest.json first."""
|
|
import pm.receipt as pm_receipt_mod
|
|
|
|
ur.begin_update_receipt()
|
|
seen: dict = {}
|
|
|
|
def concurrent_sync():
|
|
pm_receipt_mod.begin("sync")
|
|
pm_receipt_mod.record_venv_rebuild(True, "not my update")
|
|
seen["path"] = pm_receipt_mod.finalize("ok")
|
|
|
|
t = threading.Thread(target=concurrent_sync)
|
|
t.start()
|
|
t.join()
|
|
assert seen["path"] is not None # it did write a receipt...
|
|
|
|
path = ur.finalize_update_receipt("success")
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
assert "pm_venv_rebuild" not in data # ...but not into THIS update
|
|
|
|
|
|
def test_nested_update_ids_do_not_cross_embed(homed):
|
|
"""The steering-required scenario: outer update → outer sync → nested
|
|
update → nested sync → finalize inner → finalize outer. BOTH update
|
|
receipts finalize with a path, and each embeds ONLY its own sync —
|
|
the nested sync never displaces the outer update's."""
|
|
import pm.receipt as pm_receipt_mod
|
|
|
|
ur.begin_update_receipt()
|
|
outer_id = ur.current_correlation_id()
|
|
pm_receipt_mod.begin("sync")
|
|
pm_receipt_mod.record_venv_rebuild(True, "outer rebuild")
|
|
pm_receipt_mod.record_step("outer-sync", True)
|
|
pm_receipt_mod.finalize("ok")
|
|
|
|
ur.begin_update_receipt() # nested update — new id, outer preserved behind the token
|
|
inner_id = ur.current_correlation_id()
|
|
assert inner_id != outer_id
|
|
pm_receipt_mod.begin("sync")
|
|
pm_receipt_mod.record_venv_rebuild(True, "inner rebuild")
|
|
pm_receipt_mod.record_step("inner-sync", True)
|
|
pm_receipt_mod.finalize("ok")
|
|
|
|
inner_path = ur.finalize_update_receipt("success")
|
|
assert inner_path is not None
|
|
assert json.loads(inner_path.read_text(encoding="utf-8"))["pm_venv_rebuild"]["reason"] == "inner rebuild"
|
|
assert pm_receipt_mod.last_for_update(inner_id) is None
|
|
assert ur.current_correlation_id() == outer_id
|
|
assert pm_receipt_mod.last_for_update(outer_id)["steps"][0]["name"] == "outer-sync"
|
|
|
|
# outer finalize: the outer sync is STILL its own completion — the
|
|
# nested update's finalize restored the outer receipt and must not
|
|
# have dropped it or let the inner sync ride along.
|
|
outer_path = ur.finalize_update_receipt("partial")
|
|
assert outer_path is not None
|
|
outer_data = json.loads(outer_path.read_text(encoding="utf-8"))
|
|
assert outer_data["update_id"] == outer_id
|
|
assert outer_data["pm_venv_rebuild"]["reason"] == "outer rebuild"
|
|
assert "inner rebuild" not in json.dumps(outer_data)
|
|
assert outer_data["pm_steps"][0]["name"] == "outer-sync"
|
|
assert pm_receipt_mod.last_for_update(outer_id) is None
|
|
assert inner_path != outer_path
|
|
inner_data = json.loads(inner_path.read_text(encoding="utf-8"))
|
|
assert inner_data["update_id"] == inner_id
|
|
assert inner_data["pm_steps"][0]["name"] == "inner-sync"
|
|
assert inner_data["pm_venv_rebuild"]["reason"] == "inner rebuild"
|
|
latest = json.loads((homed / "logs/update_receipts/latest.json").read_text(encoding="utf-8"))
|
|
assert latest == outer_data
|
|
|
|
# outermost finalize popped the last receipt: no correlation remains
|
|
assert ur.current_correlation_id() is None
|
|
|
|
|
|
|
|
def test_sync_after_nested_update_finalizes_embeds_into_outer(homed):
|
|
"""Continuing the outer update after the nested one closed: a sync
|
|
begun under the restored outer id embeds into the outer receipt."""
|
|
import pm.receipt as pm_receipt_mod
|
|
|
|
ur.begin_update_receipt()
|
|
ur.begin_update_receipt()
|
|
ur.finalize_update_receipt("success")
|
|
pm_receipt_mod.begin("sync")
|
|
pm_receipt_mod.record_venv_rebuild(False, "already in sync")
|
|
pm_receipt_mod.finalize("ok")
|
|
path = ur.finalize_update_receipt("success")
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
assert data["pm_venv_rebuild"] == {"ok": False, "reason": "already in sync"}
|
|
|
|
|
|
def test_pm_warnings_and_refusal_embed(homed):
|
|
ur.begin_update_receipt()
|
|
_sync_under_update(
|
|
warnings=["shim quarantine skipped: file locked"],
|
|
refusal=("lazy-install", "extras outside the frozen feature set"),
|
|
)
|
|
path = ur.finalize_update_receipt("success")
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
assert data["pm_warnings"][0]["message"] == "shim quarantine skipped: file locked"
|
|
assert data["pm_refusal"]["code"] == "lazy-install"
|
|
assert data["pm_refusal"]["detail"] == "extras outside the frozen feature set"
|
|
|
|
|
|
def test_embed_failure_never_breaks_the_update_receipt(homed, monkeypatch):
|
|
def boom():
|
|
raise RuntimeError("pm import exploded")
|
|
|
|
import pm.receipt as pm_receipt_mod
|
|
|
|
monkeypatch.setattr(pm_receipt_mod, "last_for_update", boom)
|
|
ur.begin_update_receipt()
|
|
path = ur.finalize_update_receipt("success")
|
|
assert path is not None # receipt written despite the embed failure
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
assert data["outcome"] == "success"
|