"""Tests for NodeAgent remediation dispatch: pull_dispatched_actions / process_dispatched_actions / _execute_dispatched_action. Covers the security gates required by docs/backlog.md "PROJEKT: remediacja bez SSH": the agent executes only actions addressed to its own node, refuses anything outside the container_restart whitelist, never restarts its own container, and treats a repeated dispatch of the same action_id as a no-op. """ from __future__ import annotations import json import shutil import time from unittest.mock import MagicMock import pytest import node_agent @pytest.fixture(autouse=True) def _isolated_runtime_state(): """ACTIONS_DIR/STATE_DIR/EVENTS_DIR are a single temp dir shared for the whole test session (conftest.py sets RUNTIME_PATH once at import time). Clear the dispatch/processed-actions/events state before and after each test so tests don't leak into each other or other test modules.""" def _clear(): for d in (node_agent.ACTIONS_DIR, node_agent.EVENTS_DIR, node_agent.STATE_DIR): if d.exists(): shutil.rmtree(d) d.mkdir(parents=True, exist_ok=True) _clear() yield _clear() def _write_dispatch(agent, action_id, node, action_type="container_restart", container_name="zigbee2mqtt"): inbox = agent._dispatch_inbox_dir() inbox.mkdir(parents=True, exist_ok=True) path = inbox / f"{action_id}.json" path.write_text(json.dumps({ "action_id": action_id, "type": action_type, "node": node, "service": container_name, "container_name": container_name, "dispatched_at": time.time(), })) return path def _fake_docker_client(): client = MagicMock() container = MagicMock() client.containers.get.return_value = container return client, container def _last_action_result_payload(agent): events_dir = agent._node_events_dir() files = sorted(events_dir.glob("evt-*-action_result-*.json")) assert files, "expected an action_result event to have been emitted" return json.loads(files[-1].read_text())["payload"] # --------------------------------------------------------------------------- # Node scoping # --------------------------------------------------------------------------- def test_executes_action_addressed_to_own_node(agent): agent.docker_client, container = _fake_docker_client() _write_dispatch(agent, "act-1", node=agent.node_name) agent.process_dispatched_actions() container.restart.assert_called_once() payload = _last_action_result_payload(agent) assert payload["action_id"] == "act-1" assert payload["success"] is True def test_refuses_action_addressed_to_another_node(agent): agent.docker_client, container = _fake_docker_client() _write_dispatch(agent, "act-2", node="some-other-node") # Force it into THIS node's inbox directly (simulates a mis-delivery — # the dispatch dir is normally already scoped by node name). inbox = agent._dispatch_inbox_dir() action_file = inbox / "act-2.json" data = json.loads(action_file.read_text()) assert data["node"] == "some-other-node" agent.process_dispatched_actions() container.restart.assert_not_called() payload = _last_action_result_payload(agent) assert payload["success"] is False assert "not '" + agent.node_name + "'" in payload["error"] # --------------------------------------------------------------------------- # Type whitelist # --------------------------------------------------------------------------- @pytest.mark.parametrize("bad_type", ["redeploy", "disk_cleanup", "shell_exec", "alert_only"]) def test_rejects_non_whitelisted_action_types(agent, bad_type): agent.docker_client, container = _fake_docker_client() _write_dispatch(agent, f"act-{bad_type}", node=agent.node_name, action_type=bad_type) agent.process_dispatched_actions() container.restart.assert_not_called() payload = _last_action_result_payload(agent) assert payload["success"] is False assert "not whitelisted" in payload["error"] def test_accepts_container_restart(agent): assert "container_restart" in node_agent.ALLOWED_DISPATCH_ACTION_TYPES assert node_agent.ALLOWED_DISPATCH_ACTION_TYPES == {"container_restart"} # --------------------------------------------------------------------------- # Self-restart guard # --------------------------------------------------------------------------- def test_refuses_to_restart_itself(agent): agent.docker_client, container = _fake_docker_client() _write_dispatch(agent, "act-self", node=agent.node_name, container_name="node-agent") agent.process_dispatched_actions() container.restart.assert_not_called() agent.docker_client.containers.get.assert_not_called() payload = _last_action_result_payload(agent) assert payload["success"] is False assert "restart itself" in payload["error"] # --------------------------------------------------------------------------- # Idempotency # --------------------------------------------------------------------------- def test_same_action_id_executes_only_once(agent): agent.docker_client, container = _fake_docker_client() _write_dispatch(agent, "act-dup", node=agent.node_name) agent.process_dispatched_actions() assert container.restart.call_count == 1 # Dispatch file is consumed after processing (real pipeline: rsync # --remove-source-files means it can't be re-pulled either), but even if # the SAME action_id is re-delivered, the processed marker must block it. _write_dispatch(agent, "act-dup", node=agent.node_name) agent.process_dispatched_actions() assert container.restart.call_count == 1, "action_id re-delivery must be a no-op" def test_dispatch_file_removed_after_processing(agent): agent.docker_client, _ = _fake_docker_client() action_file = _write_dispatch(agent, "act-cleanup", node=agent.node_name) agent.process_dispatched_actions() assert not action_file.exists() # --------------------------------------------------------------------------- # Docker restart failure surfaces as a failed action_result # --------------------------------------------------------------------------- def test_docker_restart_exception_reports_failure(agent): client = MagicMock() client.containers.get.side_effect = Exception("no such container") agent.docker_client = client _write_dispatch(agent, "act-fail", node=agent.node_name) agent.process_dispatched_actions() payload = _last_action_result_payload(agent) assert payload["success"] is False assert "no such container" in payload["error"] def test_no_docker_client_reports_failure(agent): agent.docker_client = None _write_dispatch(agent, "act-nodocker", node=agent.node_name) agent.process_dispatched_actions() payload = _last_action_result_payload(agent) assert payload["success"] is False assert "Docker SDK unavailable" in payload["error"] # --------------------------------------------------------------------------- # pull_dispatched_actions: rsync gating (mirrors test_ship_events_to_vps.py) # --------------------------------------------------------------------------- def test_pull_skips_when_vps_host_not_set(agent, monkeypatch): monkeypatch.setattr(node_agent, "VPS_EVENTS_HOST", "") fake_run = MagicMock() monkeypatch.setattr(node_agent.subprocess, "run", fake_run) agent.pull_dispatched_actions() fake_run.assert_not_called() def test_pull_skips_on_vps_node(agent, monkeypatch): agent.node_name = node_agent.VPS_NODE_NAME fake_run = MagicMock() monkeypatch.setattr(node_agent.subprocess, "run", fake_run) agent.pull_dispatched_actions() fake_run.assert_not_called() def test_pull_invokes_rsync_pull_direction(agent, monkeypatch): captured = {} def fake_run(cmd, **kwargs): captured["cmd"] = cmd return MagicMock(returncode=0, stderr="") monkeypatch.setattr(node_agent.subprocess, "run", fake_run) agent.pull_dispatched_actions() cmd = captured["cmd"] assert cmd[0] == "rsync" assert "--remove-source-files" in cmd # Source (remote VPS) comes before destination (local inbox) — pull, not push. remote_arg = f"{node_agent.VPS_EVENTS_USER}@{node_agent.VPS_EVENTS_HOST}:" assert any(a.startswith(remote_arg) for a in cmd[:-1]) assert cmd[-1] == str(agent._dispatch_inbox_dir()) + "/" def test_pull_treats_empty_source_returncodes_as_non_error(agent, monkeypatch, caplog): def fake_run(cmd, **kwargs): return MagicMock(returncode=23, stderr="rsync: some vanished-source message") monkeypatch.setattr(node_agent.subprocess, "run", fake_run) with caplog.at_level("WARNING"): agent.pull_dispatched_actions() assert "Dispatch pull failed" not in caplog.text