mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-09-14 03:46:38 +00:00
Track owned test processes and restrict fallback cleanup to verified EOS servers using the test configuration. Add regression coverage for denied inspection and cleanup failures. Fixes #1297
306 lines
11 KiB
Python
306 lines
11 KiB
Python
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock
|
|
|
|
import psutil
|
|
import pytest
|
|
import requests
|
|
from conftest import cleanup_eos_eosdash, server_base
|
|
from xprocess import ProcessStarter
|
|
|
|
|
|
@pytest.fixture
|
|
def cleanup_environment(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> SimpleNamespace:
|
|
"""Isolate every process and network operation performed by server cleanup."""
|
|
processes: dict[int, Mock] = {}
|
|
process_type = psutil.Process
|
|
|
|
def make_process(pid: int, module: str = "akkudoktoreos.server.eos") -> Mock:
|
|
process = Mock(spec=process_type)
|
|
# Track either platform's termination method with the same mock.
|
|
process.terminate = process.kill
|
|
process.pid = pid
|
|
process.cmdline.return_value = ["python", "-m", module]
|
|
process.environ.return_value = {"EOS_CONFIG_DIR": str(tmp_path)}
|
|
process.children.return_value = []
|
|
process.status.return_value = psutil.STATUS_RUNNING
|
|
processes[pid] = process
|
|
return process
|
|
|
|
def get_process(pid: int) -> Mock:
|
|
if pid not in processes:
|
|
raise psutil.NoSuchProcess(pid)
|
|
return processes[pid]
|
|
|
|
connections = Mock(return_value=[])
|
|
health = Mock(side_effect=requests.ConnectionError)
|
|
wait = Mock(return_value=([], []))
|
|
monkeypatch.setattr("conftest.psutil.Process", get_process)
|
|
monkeypatch.setattr("conftest.psutil.net_connections", connections)
|
|
monkeypatch.setattr("conftest.psutil.wait_procs", wait)
|
|
monkeypatch.setattr("conftest.requests.get", health)
|
|
|
|
return SimpleNamespace(
|
|
make_process=make_process,
|
|
connections=connections,
|
|
health=health,
|
|
wait=wait,
|
|
config_dir=str(tmp_path),
|
|
)
|
|
|
|
|
|
def run_cleanup(environment: SimpleNamespace, *processes: psutil.Process) -> None:
|
|
cleanup_eos_eosdash(
|
|
"127.0.0.1",
|
|
8503,
|
|
"127.0.0.1",
|
|
8555,
|
|
owned_processes=processes,
|
|
config_dir=environment.config_dir,
|
|
)
|
|
|
|
|
|
def connection(pid: int | None, port: int) -> SimpleNamespace:
|
|
return SimpleNamespace(pid=pid, laddr=SimpleNamespace(port=port))
|
|
|
|
|
|
@pytest.mark.parametrize("error", [psutil.AccessDenied(1), PermissionError(1, "Denied")])
|
|
def test_cleanup_owned_tree_when_enumeration_is_denied(
|
|
cleanup_environment: SimpleNamespace, error: Exception
|
|
) -> None:
|
|
"""Protected system processes must not prevent termination of owned servers."""
|
|
env = cleanup_environment
|
|
parent = env.make_process(101)
|
|
child = env.make_process(102, "akkudoktoreos.server.eosdash")
|
|
parent.children.return_value = [child]
|
|
env.connections.side_effect = error
|
|
|
|
run_cleanup(env, parent)
|
|
|
|
parent.kill.assert_called_once_with()
|
|
child.kill.assert_called_once_with()
|
|
env.connections.assert_called_once_with(kind="inet")
|
|
assert env.wait.call_args.args[0] == [parent, child]
|
|
assert 0 <= env.wait.call_args.kwargs["timeout"] <= 10
|
|
|
|
|
|
def test_cleanup_uses_verified_health_pid_without_connection_inspection(
|
|
cleanup_environment: SimpleNamespace,
|
|
) -> None:
|
|
"""A restarted test server can be found without system-wide connections."""
|
|
env = cleanup_environment
|
|
restarted = env.make_process(103)
|
|
# EOS restarts using the script path rather than `python -m`.
|
|
script = Path(__file__).parent.parent / "src/akkudoktoreos/server/eos.py"
|
|
restarted.cmdline.return_value = ["python", str(script)]
|
|
response = Mock(status_code=200)
|
|
response.json.return_value = {"pid": restarted.pid}
|
|
env.health.side_effect = [response, requests.ConnectionError()]
|
|
env.connections.side_effect = psutil.AccessDenied(1)
|
|
|
|
run_cleanup(env)
|
|
|
|
restarted.kill.assert_called_once_with()
|
|
|
|
|
|
def test_cleanup_connection_fallback_is_limited_to_verified_test_servers(
|
|
cleanup_environment: SimpleNamespace,
|
|
) -> None:
|
|
"""Port matches alone must not kill other applications or another EOS instance."""
|
|
env = cleanup_environment
|
|
eos = env.make_process(101)
|
|
dashboard = env.make_process(102, "akkudoktoreos.server.eosdash")
|
|
other_application = env.make_process(103, "unrelated.application")
|
|
other_test = env.make_process(104)
|
|
other_test.environ.return_value = {"EOS_CONFIG_DIR": "/another/test"}
|
|
misleading_module = env.make_process(105, "akkudoktoreos.server.eos_extra")
|
|
wrong_port = env.make_process(106, "akkudoktoreos.server.eosdash")
|
|
env.connections.return_value = [
|
|
connection(101, 8503),
|
|
connection(101, 8503),
|
|
connection(102, 8555),
|
|
connection(103, 8503),
|
|
connection(104, 8503),
|
|
connection(105, 8503),
|
|
connection(106, 8504),
|
|
connection(None, 8503),
|
|
]
|
|
|
|
run_cleanup(env)
|
|
|
|
eos.kill.assert_called_once_with()
|
|
dashboard.kill.assert_called_once_with()
|
|
for process in (other_application, other_test, misleading_module, wrong_port):
|
|
process.kill.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize("pid", [103, True, "103", -1, None])
|
|
def test_cleanup_does_not_trust_health_pid(
|
|
cleanup_environment: SimpleNamespace, pid: object
|
|
) -> None:
|
|
"""A health response cannot authorize termination of an unrelated process."""
|
|
env = cleanup_environment
|
|
unrelated = env.make_process(103, "unrelated.application")
|
|
response = Mock(status_code=200)
|
|
response.json.return_value = {"pid": pid}
|
|
env.health.side_effect = [response, requests.ConnectionError()]
|
|
|
|
run_cleanup(env)
|
|
|
|
unrelated.kill.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize("operation", ["cmdline", "environ"])
|
|
def test_cleanup_skips_inaccessible_fallback_process(
|
|
cleanup_environment: SimpleNamespace, operation: str
|
|
) -> None:
|
|
"""One inaccessible process must not hide a subsequent verified server."""
|
|
env = cleanup_environment
|
|
protected = env.make_process(103)
|
|
getattr(protected, operation).side_effect = psutil.AccessDenied(103)
|
|
owned = env.make_process(104)
|
|
env.connections.return_value = [connection(103, 8503), connection(104, 8503)]
|
|
|
|
run_cleanup(env)
|
|
|
|
protected.kill.assert_not_called()
|
|
owned.kill.assert_called_once_with()
|
|
|
|
|
|
def test_cleanup_stops_owned_process_when_children_are_inaccessible(
|
|
cleanup_environment: SimpleNamespace,
|
|
) -> None:
|
|
env = cleanup_environment
|
|
owned = env.make_process(101)
|
|
owned.children.side_effect = psutil.AccessDenied(101)
|
|
|
|
run_cleanup(env, owned)
|
|
|
|
owned.kill.assert_called_once_with()
|
|
|
|
|
|
def test_cleanup_tolerates_process_exit_during_termination(
|
|
cleanup_environment: SimpleNamespace,
|
|
) -> None:
|
|
env = cleanup_environment
|
|
exited = env.make_process(101)
|
|
exited.kill.side_effect = psutil.NoSuchProcess(101)
|
|
remaining = env.make_process(102)
|
|
|
|
run_cleanup(env, exited, remaining)
|
|
|
|
remaining.kill.assert_called_once_with()
|
|
|
|
|
|
@pytest.mark.parametrize("status", [psutil.STATUS_ZOMBIE, psutil.STATUS_RUNNING])
|
|
def test_cleanup_reports_only_live_processes_after_bounded_wait(
|
|
cleanup_environment: SimpleNamespace, status: str
|
|
) -> None:
|
|
env = cleanup_environment
|
|
process = env.make_process(101)
|
|
process.status.return_value = status
|
|
env.wait.return_value = ([], [process])
|
|
|
|
if status == psutil.STATUS_RUNNING:
|
|
with pytest.raises(AssertionError, match="cleanup timed out.*101"):
|
|
run_cleanup(env, process)
|
|
else:
|
|
run_cleanup(env, process)
|
|
|
|
|
|
@pytest.mark.parametrize("startup_failure", [False, True])
|
|
def test_server_base_cleans_owned_process_and_directory_on_failure(
|
|
cleanup_environment: SimpleNamespace,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
startup_failure: bool,
|
|
) -> None:
|
|
"""Both startup failures and exceptions from the test body run teardown."""
|
|
env = cleanup_environment
|
|
process = env.make_process(101)
|
|
xprocess = Mock()
|
|
xprocess.getinfo.return_value.pid = process.pid
|
|
config_dirs: list[Path] = []
|
|
monkeypatch.setattr("conftest.subprocess.run", Mock())
|
|
monkeypatch.setattr("conftest.ProcessStarter.wait", Mock(return_value=True))
|
|
|
|
def ensure(name: str, starter_type: type[ProcessStarter]) -> tuple[int, str]:
|
|
config_dirs.append(Path(starter_type.env["EOS_CONFIG_DIR"]))
|
|
assert name == f"eos-{config_dirs[-1].name}"
|
|
starter = starter_type(None, xprocess)
|
|
starter.wait(Mock())
|
|
if startup_failure:
|
|
raise RuntimeError("startup failed")
|
|
return process.pid, "server.log"
|
|
|
|
xprocess.ensure.side_effect = ensure
|
|
with pytest.raises(RuntimeError, match="failed"):
|
|
with server_base(xprocess):
|
|
raise RuntimeError("test body failed")
|
|
|
|
process.kill.assert_called_once_with()
|
|
assert config_dirs and not config_dirs[0].exists()
|
|
|
|
|
|
@pytest.mark.parametrize("reported_pid", [101, 999])
|
|
def test_server_startup_check_rejects_another_server_on_the_same_port(
|
|
cleanup_environment: SimpleNamespace,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
reported_pid: int,
|
|
) -> None:
|
|
"""A pre-existing server must never be mistaken for this test's process."""
|
|
env = cleanup_environment
|
|
process = env.make_process(101)
|
|
xprocess = Mock()
|
|
xprocess.getinfo.return_value.pid = process.pid
|
|
monkeypatch.setattr("conftest.subprocess.run", Mock())
|
|
monkeypatch.setattr("conftest.ProcessStarter.wait", Mock(return_value=True))
|
|
response = Mock(status_code=200)
|
|
response.json.return_value = {"pid": reported_pid}
|
|
env.health.side_effect = None
|
|
env.health.return_value = response
|
|
|
|
def ensure(name: str, starter_type: type[ProcessStarter]) -> tuple[int, str]:
|
|
starter = starter_type(None, xprocess)
|
|
starter.wait(Mock())
|
|
assert starter.startup_check() is (reported_pid == process.pid)
|
|
return process.pid, "server.log"
|
|
|
|
xprocess.ensure.side_effect = ensure
|
|
with server_base(xprocess):
|
|
pass
|
|
|
|
|
|
@pytest.mark.parametrize("enumeration_denied", [False, True])
|
|
def test_cleanup_terminates_real_owned_subprocess(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, enumeration_denied: bool
|
|
) -> None:
|
|
"""Exercise real process termination with available and denied enumeration."""
|
|
connections = Mock(return_value=[])
|
|
if enumeration_denied:
|
|
connections.side_effect = psutil.AccessDenied(1)
|
|
monkeypatch.setattr("conftest.psutil.net_connections", connections)
|
|
monkeypatch.setattr("conftest.requests.get", Mock(side_effect=requests.ConnectionError))
|
|
process = subprocess.Popen(
|
|
[sys.executable, "-c", "import sys; sys.stdin.buffer.read()"],
|
|
stdin=subprocess.PIPE,
|
|
)
|
|
try:
|
|
owned = psutil.Process(process.pid)
|
|
cleanup_eos_eosdash(
|
|
"127.0.0.1",
|
|
8503,
|
|
"127.0.0.1",
|
|
8504,
|
|
owned_processes=[owned],
|
|
config_dir=str(tmp_path),
|
|
)
|
|
assert not owned.is_running()
|
|
finally:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait(timeout=5)
|
|
if process.stdin is not None:
|
|
process.stdin.close()
|