Keep downloads bound to their remote representation and publish through atomic destination-local staging. Serialize shared partial ownership. Keep explicit CA trust scoped to provider probes. Preserve checkpoint history and edited files, validate all profile inputs before dependency publication, and separate data removal from installed runtime ownership. Exclude machine-specific PM state from portable transfers. Keep plugin files and nested skill tools intact. Preserve native test isolation. Focused native Windows receipts cover the individual repairs and their integration. This commit does not claim a full-suite or release build.
166 lines
6.1 KiB
Python
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 # noqa: F401 — isolated 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)
|