159 lines
6.9 KiB
Python
159 lines
6.9 KiB
Python
"""Data deletion respects declared runtime ownership, confirmation and scope."""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import uninstall
|
|
|
|
|
|
@pytest.fixture
|
|
def layout(tmp_path, monkeypatch):
|
|
user = tmp_path / "user"
|
|
user.mkdir()
|
|
home = user / "hermes-data"
|
|
source = home / "workspace" / "custom-source"
|
|
store = home / "machine" / "tool-store"
|
|
userdata = user / "desktop-data"
|
|
witnesses = [source / "hermes_cli" / "__init__.py", store / "python" / "python.exe",
|
|
home / "installs" / "other-install" / "facts.json", home / "bin" / "hermes.cmd",
|
|
home / "profiles" / "sibling" / "config.yaml",
|
|
home / "cache" / "partials" / ".locks" / "other-profile-transfer"]
|
|
for path in witnesses:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text("keep", encoding="utf-8")
|
|
data = [home / "config.yaml", home / "sessions" / "session.json", home / "workspace" / "notes.txt",
|
|
home / ".env", home / "logs" / "file"]
|
|
for path in data:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text("{}" if path.suffix in {".yaml", ".json"} else "user data", encoding="utf-8")
|
|
monkeypatch.setattr(Path, "home", lambda: user)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store))
|
|
monkeypatch.setattr(uninstall, "get_project_root", lambda: source)
|
|
monkeypatch.setattr(uninstall, "get_hermes_home", lambda: home)
|
|
monkeypatch.setattr("hermes_cli.gui_uninstall.desktop_userdata_dir", lambda: userdata)
|
|
return home, witnesses, data
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["confirmed", "cancel", "dry-run"])
|
|
def test_data_only_preserves_runtime_and_sibling_homes(layout, monkeypatch, mode):
|
|
import json
|
|
from pm.environments import install_state_dir, runtime_facts_path, selected_venv
|
|
|
|
monkeypatch.delattr(Path, "is_junction", raising=False)
|
|
home, witnesses, data = layout
|
|
source = uninstall.get_project_root()
|
|
generation = install_state_dir(source) / "environments" / "selected"
|
|
generation.mkdir(parents=True)
|
|
(generation / "pyvenv.cfg").write_text("include-system-site-packages = false", encoding="utf-8")
|
|
runtime_facts_path(source).write_text(json.dumps({"packages": {"venv": {"environment": str(generation)}}}), encoding="utf-8")
|
|
monkeypatch.setattr("builtins.input", lambda *args: "no")
|
|
uninstall.run_data_uninstall(SimpleNamespace(yes=mode != "cancel", dry_run=mode == "dry-run"))
|
|
assert selected_venv(source) == generation
|
|
assert all(path.read_text(encoding="utf-8") == "keep" for path in witnesses)
|
|
assert all(path.exists() is (mode != "confirmed") for path in data)
|
|
assert home.is_dir()
|
|
|
|
|
|
def test_data_only_reports_a_failed_removal(layout, monkeypatch, capsys):
|
|
home, witnesses, _ = layout
|
|
real = uninstall.shutil.rmtree
|
|
|
|
def refuse(path, *args, **kwargs):
|
|
if Path(path) == home / "sessions":
|
|
raise PermissionError("fixture holds sessions")
|
|
return real(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(uninstall.shutil, "rmtree", refuse)
|
|
with pytest.raises(SystemExit) as failure:
|
|
uninstall.run_data_uninstall(SimpleNamespace(yes=True, dry_run=False))
|
|
assert failure.value.code != 0
|
|
output = capsys.readouterr().out
|
|
assert "Hermes data removed" not in output
|
|
assert str(home / "sessions") in output
|
|
assert all(path.exists() for path in witnesses)
|
|
|
|
|
|
def test_named_home_does_not_erase_siblings_or_desktop_data(layout, monkeypatch):
|
|
home, witnesses, _ = layout
|
|
active = home / "profiles" / "active"
|
|
active.mkdir()
|
|
config = active / "config.yaml"
|
|
config.write_text("model: selected", encoding="utf-8")
|
|
userdata = home.parent / "desktop-data"
|
|
userdata.mkdir()
|
|
(userdata / "preferences.json").write_text("keep", encoding="utf-8")
|
|
monkeypatch.setenv("HERMES_HOME", str(active))
|
|
monkeypatch.setattr(uninstall, "get_hermes_home", lambda: active)
|
|
uninstall.run_data_uninstall(SimpleNamespace(yes=True))
|
|
assert not config.exists()
|
|
assert all(path.exists() for path in witnesses)
|
|
assert (userdata / "preferences.json").read_text(encoding="utf-8") == "keep"
|
|
|
|
|
|
def test_directory_replaced_with_a_link_does_not_expand_removal(layout, tmp_path):
|
|
from hermes_cli.data_cleanup import plan_data_removal, remove_data
|
|
|
|
home, _, _ = layout
|
|
original = home / "workspace"
|
|
foreign = tmp_path / "foreign"
|
|
foreign.mkdir()
|
|
witness = foreign / "notes.txt"
|
|
witness.write_text("not Hermes data", encoding="utf-8")
|
|
probe = tmp_path / "symlink-probe"
|
|
try:
|
|
probe.symlink_to(foreign, target_is_directory=True)
|
|
except OSError as exc:
|
|
pytest.skip(f"native symlink creation unavailable: {exc}")
|
|
probe.unlink()
|
|
plan = plan_data_removal(home, uninstall.get_project_root())
|
|
original.rename(home / "workspace-moved")
|
|
original.symlink_to(foreign, target_is_directory=True)
|
|
_, failures = remove_data(plan)
|
|
assert failures
|
|
assert witness.read_text(encoding="utf-8") == "not Hermes data"
|
|
|
|
|
|
def test_data_only_preserves_the_containing_bundled_application(layout, monkeypatch):
|
|
import json
|
|
from hermes_cli.bundled_app import PAYLOAD_DIR_NAME
|
|
|
|
home, _, data = layout
|
|
app = home / "installed-app"
|
|
payload = app / "resources" / PAYLOAD_DIR_NAME
|
|
project = payload / "repo"
|
|
project.mkdir(parents=True)
|
|
(project / "install-stamp.json").write_text(json.dumps({"payload": "bundled"}), encoding="utf-8")
|
|
shell = app / "Hermes.exe"
|
|
shell.write_bytes(b"retained app bytes")
|
|
(payload / "venv").mkdir()
|
|
manifest = payload / "manifest.json"
|
|
manifest.write_text(json.dumps({"repo": "repo", "venv": "venv"}), encoding="utf-8")
|
|
monkeypatch.setattr(uninstall, "get_project_root", lambda: project)
|
|
uninstall.run_data_uninstall(SimpleNamespace(yes=True))
|
|
assert shell.read_bytes() == b"retained app bytes"
|
|
assert manifest.is_file()
|
|
assert all(not path.exists() for path in data)
|
|
|
|
|
|
def test_data_only_works_from_a_self_contained_runtime_without_an_app(layout, monkeypatch):
|
|
"""A Termux-shaped runtime (sealed, APT-owned, no Electron app around it)
|
|
must plan a data-only removal instead of hunting for a desktop app."""
|
|
import json
|
|
|
|
home, _, data = layout
|
|
package = home.parent / "usr" / "lib" / "hermes-agent"
|
|
project = package / "app"
|
|
(project / "hermes_cli").mkdir(parents=True)
|
|
(project / "hermes_cli" / "__init__.py").write_text("", encoding="utf-8")
|
|
(project / "install-stamp.json").write_text(json.dumps(
|
|
{"payload": "runtime", "distribution": "apt-termux", "updateMechanism": "external"}), encoding="utf-8")
|
|
(package / "venv").mkdir()
|
|
monkeypatch.setattr(uninstall, "get_project_root", lambda: project)
|
|
uninstall.run_data_uninstall(SimpleNamespace(yes=True))
|
|
assert all(not path.exists() for path in data)
|
|
assert (project / "install-stamp.json").is_file() and (package / "venv").is_dir()
|