211 lines
7.2 KiB
Python
211 lines
7.2 KiB
Python
"""CLI surface for `opensquilla uninstall` — flags, guards, confirmation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from opensquilla.cli import codetask_cmd
|
|
from opensquilla.cli.main import app
|
|
from opensquilla.uninstall import actions as actions_module
|
|
from opensquilla.uninstall import inventory as inventory_module
|
|
from opensquilla.uninstall.actions import ActionResult, ExecutionResult
|
|
from opensquilla.uninstall.inventory import DataBucket, Inventory
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
def _fake_inventory(home: Path) -> Inventory:
|
|
state = home / "state"
|
|
state.mkdir(parents=True, exist_ok=True)
|
|
(home / "config.toml").write_text("x")
|
|
return Inventory(
|
|
method="pip",
|
|
home=home,
|
|
state_root=state,
|
|
config_path=None,
|
|
entrypoints=[],
|
|
program_paths=[],
|
|
package_uninstall=["pip", "uninstall", "-y", "opensquilla"],
|
|
buckets=[
|
|
DataBucket("config.toml", home / "config.toml", "config", "config"),
|
|
DataBucket("state directory", state, "user-data", "state"),
|
|
],
|
|
services=[],
|
|
receipt=None,
|
|
notes=[],
|
|
home_recognized=True,
|
|
)
|
|
|
|
|
|
def _patch_discover(monkeypatch, home: Path) -> None:
|
|
monkeypatch.setattr(inventory_module, "discover", lambda: _fake_inventory(home))
|
|
|
|
|
|
def test_dry_run_json_emits_plan_and_does_nothing(monkeypatch, tmp_path: Path) -> None:
|
|
_patch_discover(monkeypatch, tmp_path / "home")
|
|
|
|
def _boom(*_a, **_k):
|
|
raise AssertionError("execute must not run during --dry-run")
|
|
|
|
monkeypatch.setattr(actions_module, "execute", _boom)
|
|
|
|
result = runner.invoke(app, ["uninstall", "--dry-run", "--json"])
|
|
assert result.exit_code == 0, result.stdout
|
|
payload = json.loads(result.stdout)
|
|
assert payload["dry_run"] is True
|
|
assert payload["plan"]["method"] == "pip"
|
|
|
|
|
|
def test_dry_run_json_subprocess_stdout_stays_machine_readable(tmp_path: Path) -> None:
|
|
"""Import-time env loading must not pollute stdout before JSON output."""
|
|
home = tmp_path / "home"
|
|
env_home = home / ".opensquilla"
|
|
env_home.mkdir(parents=True)
|
|
(env_home / ".env").write_text("OPENSQUILLA_ENV_JSON_TEST=1\n", encoding="utf-8")
|
|
|
|
env = os.environ.copy()
|
|
env.update(
|
|
{
|
|
"HOME": str(home),
|
|
"USERPROFILE": str(home),
|
|
"HTTP_PROXY": "http://127.0.0.1:9",
|
|
}
|
|
)
|
|
env.pop("OPENSQUILLA_TRUST_ENV", None)
|
|
|
|
completed = subprocess.run(
|
|
[sys.executable, "-m", "opensquilla.cli.main", "uninstall", "--dry-run", "--json"],
|
|
cwd=Path(__file__).resolve().parents[2],
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
check=False,
|
|
)
|
|
|
|
assert completed.returncode == 0, completed.stderr + completed.stdout
|
|
assert completed.stdout.lstrip().startswith("{")
|
|
payload = json.loads(completed.stdout)
|
|
assert payload["dry_run"] is True
|
|
|
|
|
|
def test_non_interactive_without_yes_refuses(monkeypatch, tmp_path: Path) -> None:
|
|
_patch_discover(monkeypatch, tmp_path / "home")
|
|
|
|
def _boom(*_a, **_k):
|
|
raise AssertionError("execute must not run without confirmation")
|
|
|
|
monkeypatch.setattr(actions_module, "execute", _boom)
|
|
|
|
result = runner.invoke(app, ["uninstall", "--json"])
|
|
assert result.exit_code == 2, result.stdout
|
|
assert "CONFIRMATION_REQUIRED" in (result.stdout + (result.stderr or ""))
|
|
|
|
|
|
def test_yes_json_executes(monkeypatch, tmp_path: Path) -> None:
|
|
_patch_discover(monkeypatch, tmp_path / "home")
|
|
captured = {}
|
|
|
|
def _fake_execute(plan, inventory, **_kwargs):
|
|
captured["ran"] = True
|
|
return ExecutionResult(
|
|
results=[ActionResult("run-package-uninstall", "ok", ok=True)], ok=True
|
|
)
|
|
|
|
monkeypatch.setattr(actions_module, "execute", _fake_execute)
|
|
|
|
result = runner.invoke(app, ["uninstall", "--yes", "--json"])
|
|
assert result.exit_code == 0, result.stdout
|
|
assert captured.get("ran") is True
|
|
payload = json.loads(result.stdout)
|
|
assert payload["ok"] is True
|
|
|
|
|
|
@pytest.mark.parametrize("purge_flag", ["--purge-state", "--purge-config", "--purge-all"])
|
|
def test_desktop_profile_routes_data_deletion_to_complete_desktop_cleanup(
|
|
monkeypatch,
|
|
tmp_path: Path,
|
|
purge_flag: str,
|
|
) -> None:
|
|
monkeypatch.setenv("OPENSQUILLA_PROFILE_KIND", "desktop-primary")
|
|
|
|
def _boom(*_args, **_kwargs):
|
|
raise AssertionError("generic Desktop purge must not inspect or delete partial data")
|
|
|
|
monkeypatch.setattr(inventory_module, "discover", _boom)
|
|
monkeypatch.setattr(actions_module, "execute", _boom)
|
|
|
|
result = runner.invoke(app, ["uninstall", purge_flag, "--yes", "--json"])
|
|
|
|
assert result.exit_code == 2, result.stdout
|
|
assert "DESKTOP_CLEANUP_REQUIRED" in (result.stdout + (result.stderr or ""))
|
|
|
|
|
|
def test_purge_all_requires_confirmation_phrase(monkeypatch, tmp_path: Path) -> None:
|
|
_patch_discover(monkeypatch, tmp_path / "home")
|
|
monkeypatch.setattr(codetask_cmd, "_stdin_is_tty", lambda: True) # allow interactive path
|
|
|
|
def _boom(*_a, **_k):
|
|
raise AssertionError("execute must not run on a mismatched phrase")
|
|
|
|
monkeypatch.setattr(actions_module, "execute", _boom)
|
|
|
|
result = runner.invoke(app, ["uninstall", "--purge-all"], input="nope\n")
|
|
assert result.exit_code == 2, result.stdout
|
|
assert "requires confirmation" in (result.stdout + (result.stderr or ""))
|
|
|
|
|
|
def test_yes_purge_all_without_phrase_is_refused(monkeypatch, tmp_path: Path) -> None:
|
|
"""`--yes --purge-all` must NOT wipe without the explicit second-factor phrase."""
|
|
_patch_discover(monkeypatch, tmp_path / "home")
|
|
|
|
def _boom(*_a, **_k):
|
|
raise AssertionError("execute must not run without the purge-all phrase")
|
|
|
|
monkeypatch.setattr(actions_module, "execute", _boom)
|
|
|
|
result = runner.invoke(app, ["uninstall", "--yes", "--purge-all", "--json"])
|
|
assert result.exit_code == 2, result.stdout
|
|
assert "CONFIRMATION_REQUIRED" in (result.stdout + (result.stderr or ""))
|
|
|
|
|
|
def test_yes_purge_all_with_phrase_executes(monkeypatch, tmp_path: Path) -> None:
|
|
_patch_discover(monkeypatch, tmp_path / "home")
|
|
captured = {}
|
|
|
|
def _fake_execute(plan, inventory, **_kwargs):
|
|
captured["ran"] = True
|
|
return ExecutionResult(results=[], ok=True)
|
|
|
|
monkeypatch.setattr(actions_module, "execute", _fake_execute)
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
["uninstall", "--yes", "--purge-all", "--json", "--confirm-purge-all", "delete everything"],
|
|
)
|
|
assert result.exit_code == 0, result.stdout
|
|
assert captured.get("ran") is True
|
|
|
|
|
|
def test_purge_all_proceeds_on_correct_phrase(monkeypatch, tmp_path: Path) -> None:
|
|
_patch_discover(monkeypatch, tmp_path / "home")
|
|
monkeypatch.setattr(codetask_cmd, "_stdin_is_tty", lambda: True)
|
|
captured = {}
|
|
|
|
def _fake_execute(plan, inventory, **_kwargs):
|
|
captured["ran"] = True
|
|
return ExecutionResult(results=[], ok=True)
|
|
|
|
monkeypatch.setattr(actions_module, "execute", _fake_execute)
|
|
|
|
result = runner.invoke(app, ["uninstall", "--purge-all"], input="delete everything\n")
|
|
assert result.exit_code == 0, result.stdout
|
|
assert captured.get("ran") is True
|