Files
hermes-agent/tests/hermes_cli/test_data_uninstall_holders.py
2026-10-08 17:22:54 +02:00

166 lines
6.1 KiB
Python

"""Data deletion must establish quiescence, never infer it from a signal count."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from hermes_cli import uninstall
from tests.hermes_cli.test_data_uninstall import layout
@pytest.mark.parametrize("mode", ["confirmed", "cancel", "dry-run"])
def test_unconfirmed_removal_never_contacts_the_gateway(layout, monkeypatch, mode):
home, _, data = layout
calls = []
def identify(target, **kwargs):
calls.append(target)
return {"pid": 42, "hermes_home": str(home), "supervisor": "systemd"}
monkeypatch.setattr("gateway.control_socket.identify_gateway", identify)
monkeypatch.setattr("builtins.input", lambda *args: "no")
args = SimpleNamespace(yes=mode != "cancel", dry_run=mode == "dry-run")
if mode == "confirmed":
with pytest.raises(SystemExit):
uninstall.run_data_uninstall(args)
assert calls == [home]
else:
uninstall.run_data_uninstall(args)
assert calls == []
assert all(path.exists() for path in data)
def test_live_chat_lease_blocks_data_deletion(layout):
from hermes_cli.active_sessions import release_active_session, try_acquire_active_session
home, _, data = layout
lease, refusal = try_acquire_active_session(
session_id="fixture-session", surface="cli", config={}, registry_home=home,
track_liveness=True,
)
assert lease is not None and refusal is None
try:
with pytest.raises(SystemExit):
uninstall.run_data_uninstall(SimpleNamespace(yes=True))
assert all(path.exists() for path in data)
finally:
release_active_session(lease)
def test_manual_gateway_drains_over_real_control_transport_before_deletion(layout):
import asyncio
import subprocess
import sys
from threading import Event, Thread
from gateway.control_socket import GatewayControlServer
from gateway.status import get_process_start_time
home, _, data = layout
# A disposable child is the declared process. Control dispatch is real;
# the fixture handler stops only that child, never any running gateway.
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)"], cwd=home)
loop = asyncio.new_event_loop()
ready = Event()
paused = Event()
holders = []
def identify():
return {"pid": child.pid, "start_time": get_process_start_time(child.pid),
"hermes_home": str(home), "supervisor": "manual"}
def drain():
paused.set()
child.terminate()
child.wait(timeout=5)
loop.call_soon_threadsafe(loop.stop)
return {"pid": child.pid, "pausing": True, "drain_timeout": 0}
server = GatewayControlServer(home, verb_handlers={"identify": identify, "pause-for-update": drain})
def serve():
asyncio.set_event_loop(loop)
started = loop.run_until_complete(server.start())
holders.append(started)
ready.set()
if started:
loop.run_forever()
loop.run_until_complete(server.stop())
loop.close()
thread = Thread(target=serve, daemon=True)
thread.start()
try:
assert ready.wait(timeout=10) and holders == [True]
uninstall.run_data_uninstall(SimpleNamespace(yes=True))
assert paused.is_set() and child.poll() is not None
assert all(not path.exists() for path in data)
finally:
if child.poll() is None:
child.terminate()
child.wait(timeout=5)
if thread.is_alive():
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=10)
assert not thread.is_alive()
@pytest.mark.parametrize("profile", ["", "sibling"])
def test_backend_initial_profile_is_not_its_write_scope(layout, profile):
from hermes_cli.process_identity import register_self
_, _, data = layout
assert register_self("serve", project_root=uninstall.get_project_root(), detail={"profile": profile})
with pytest.raises(SystemExit):
uninstall.run_data_uninstall(SimpleNamespace(yes=True))
assert all(path.exists() for path in data)
@pytest.mark.parametrize("terminal", [False, True])
def test_detached_cron_attempt_must_finish_before_data_removal(layout, monkeypatch, terminal):
from cron import executions
_, _, data = layout
monkeypatch.setattr(executions, "_emit_execution_state", lambda *args, **kwargs: None)
record = executions.create_execution("temporary-job", source="manual")
if terminal:
executions.finish_execution(record["id"], success=True)
uninstall.run_data_uninstall(SimpleNamespace(yes=True))
assert all(not path.exists() for path in data)
else:
with pytest.raises(SystemExit):
uninstall.run_data_uninstall(SimpleNamespace(yes=True))
assert all(path.exists() for path in data)
def test_named_profile_cannot_delete_data_served_by_the_default_multiplexer(layout, monkeypatch):
home, _, _ = layout
profile = home / "profiles" / "active"
profile.mkdir()
config = profile / "config.yaml"
config.write_text("{}", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(profile))
monkeypatch.setattr(uninstall, "get_hermes_home", lambda: profile)
def identify(target, **kwargs):
if target == home:
return {"pid": 42, "hermes_home": str(home), "served_profiles": ["active"]}
return None
monkeypatch.setattr("gateway.control_socket.identify_gateway", identify)
monkeypatch.setattr("gateway.control_socket.pause_gateway_for_update",
lambda *args, **kwargs: pytest.fail("must not stop other profiles' gateway"))
with pytest.raises(SystemExit):
uninstall.run_data_uninstall(SimpleNamespace(yes=True))
assert config.read_text(encoding="utf-8") == "{}"
def test_data_removal_respects_the_checkpoint_store_owner(layout):
from tools.checkpoint_pruning import store_lock
home, _, data = layout
with store_lock(home / "checkpoints"):
with pytest.raises(SystemExit):
uninstall.run_data_uninstall(SimpleNamespace(yes=True))
assert all(path.exists() for path in data)