"""Unit tests for the optional local worker module. Tests cover: - Worker start idempotency (VAL-WORKER-001) - Worker stop idempotency (VAL-WORKER-002) - Worker status reporting (VAL-WORKER-003) - Worker failure isolation (VAL-WORKER-004) - One-shot mode when worker is unavailable (VAL-WORKER-005) - Worker is optional (VAL-WORKER-006) - Worker lifecycle integration (VAL-CROSS-008) """ from __future__ import annotations import sys from pathlib import Path _scripts_dir = Path(__file__).resolve().parents[2] / "scripts" if str(_scripts_dir) not in sys.path: sys.path.insert(0, str(_scripts_dir)) import json import os import signal import socket import tempfile import time from unittest import mock import pytest from binary_analysis.worker.client import ( WorkerClient, _pid_path, _socket_path, get_worker_status, read_pid, read_started_at, ) from binary_analysis.worker.resolver import is_worker_available, resolve_adapter from binary_analysis.worker.server import ( WorkerServer, _ensure_worker_dir, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @pytest.fixture def clean_worker_state(monkeypatch: pytest.MonkeyPatch) -> None: """Ensure no stale worker state interferes with tests.""" # Use a temp directory for worker state instead of ~/.binary-analysis tmpdir = tempfile.mkdtemp(prefix="worker-test-") monkeypatch.setattr("binary_analysis.worker.client.WORKER_DIR", tmpdir) monkeypatch.setattr("binary_analysis.worker.server.WORKER_DIR", tmpdir) # Also patch the path helpers in client monkeypatch.setattr( "binary_analysis.worker.client._socket_path", lambda: os.path.join(tmpdir, "worker.sock"), ) monkeypatch.setattr( "binary_analysis.worker.client._pid_path", lambda: os.path.join(tmpdir, "worker.pid"), ) monkeypatch.setattr( "binary_analysis.worker.client._started_at_path", lambda: os.path.join(tmpdir, "worker.started_at"), ) monkeypatch.setattr( "binary_analysis.worker.server._socket_path", lambda: os.path.join(tmpdir, "worker.sock"), ) monkeypatch.setattr( "binary_analysis.worker.server._pid_path", lambda: os.path.join(tmpdir, "worker.pid"), ) monkeypatch.setattr( "binary_analysis.worker.server._started_at_path", lambda: os.path.join(tmpdir, "worker.started_at"), ) yield # Clean up import shutil shutil.rmtree(tmpdir, ignore_errors=True) # --------------------------------------------------------------------------- # VAL-WORKER-003: Worker status reports accurate state # --------------------------------------------------------------------------- class TestWorkerStatus: """Tests for worker status reporting.""" def test_status_stopped_when_no_worker(self, clean_worker_state: None) -> None: """When no worker is running, status should report 'stopped' with pid=null.""" status = get_worker_status() assert status["state"] == "stopped" assert status["pid"] is None assert status["uptime_seconds"] is None def test_status_running_when_worker_running(self, clean_worker_state: None) -> None: """When a worker is running, status should report 'running' with correct PID.""" import subprocess import sys # Start a worker server in a subprocess proc = subprocess.Popen( [ sys.executable, "-c", """ import sys sys.path.insert(0, "skills/binary-analysis/scripts") from binary_analysis.worker.server import run_worker run_worker() """, ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) # Write PID file manually since we can't control the test paths easily # We'll test the client's status reading with a mock instead try: os.kill(proc.pid, signal.SIGTERM) proc.wait(timeout=5) except Exception: proc.kill() def test_status_json_structure(self, clean_worker_state: None) -> None: """Status result must have state, pid, and uptime_seconds fields.""" status = get_worker_status() assert "state" in status assert "pid" in status assert "uptime_seconds" in status assert status["state"] in ("running", "stopped") def test_status_pid_null_when_stopped(self, clean_worker_state: None) -> None: """PID must be null (JSON null/None) when worker is stopped.""" status = get_worker_status() assert status["state"] == "stopped" assert status["pid"] is None def test_status_pid_matches_os_when_running( self, clean_worker_state: None, monkeypatch: pytest.MonkeyPatch ) -> None: """When running, reported PID should match actual OS PID.""" real_pid = 12345 monkeypatch.setattr("binary_analysis.worker.client.read_pid", lambda: real_pid) monkeypatch.setattr("binary_analysis.worker.client._is_pid_alive", lambda: True) monkeypatch.setattr( "binary_analysis.worker.client.read_started_at", lambda: time.monotonic() - 42.5 ) status = get_worker_status() assert status["state"] == "running" assert status["pid"] == real_pid assert status["uptime_seconds"] is not None assert status["uptime_seconds"] >= 42.0 def test_status_uptime_positive_when_running( self, clean_worker_state: None, monkeypatch: pytest.MonkeyPatch ) -> None: """When running, uptime_seconds must be a positive number.""" monkeypatch.setattr("binary_analysis.worker.client.read_pid", lambda: 12345) monkeypatch.setattr("binary_analysis.worker.client._is_pid_alive", lambda: True) monkeypatch.setattr( "binary_analysis.worker.client.read_started_at", lambda: time.monotonic() - 10.0 ) status = get_worker_status() assert status["uptime_seconds"] is not None assert status["uptime_seconds"] > 0 # --------------------------------------------------------------------------- # VAL-WORKER-001: Worker start is idempotent # --------------------------------------------------------------------------- class TestWorkerStartIdempotency: """Tests for worker start idempotency.""" def test_start_already_running(self, monkeypatch: pytest.MonkeyPatch) -> None: """Second start should succeed with 'already running' message.""" # Patch at the source location to ensure cli.worker picks up the mock monkeypatch.setattr( "binary_analysis.worker.client.get_worker_status", lambda: {"state": "running", "pid": 12345, "uptime_seconds": 42.0}, ) import importlib import binary_analysis.cli.worker as cli_worker importlib.reload(cli_worker) import argparse args = argparse.Namespace() result = cli_worker.execute_start(args) assert result["success"] is True assert result["data"]["status"] == "already_running" assert result["data"]["pid"] == 12345 assert any("already running" in d["message"].lower() for d in result["diagnostics"]) def test_start_when_stopped_starts_worker(self, monkeypatch: pytest.MonkeyPatch) -> None: """First start when stopped should start the worker.""" call_count = [0] def mock_status() -> dict: call_count[0] += 1 if call_count[0] <= 1: return {"state": "stopped", "pid": None, "uptime_seconds": None} return {"state": "running", "pid": 12345, "uptime_seconds": 0.1} monkeypatch.setattr("binary_analysis.worker.client.get_worker_status", mock_status) # Mock Popen through subprocess mock_process = mock.MagicMock() mock_process.poll.return_value = None import subprocess monkeypatch.setattr(subprocess, "Popen", lambda *a, **kw: mock_process) import importlib import binary_analysis.cli.worker as cli_worker importlib.reload(cli_worker) import argparse args = argparse.Namespace() result = cli_worker.execute_start(args) assert result["success"] is True assert result["data"]["status"] == "started" assert result["data"]["pid"] == 12345 # --------------------------------------------------------------------------- # VAL-WORKER-002: Worker stop is idempotent # --------------------------------------------------------------------------- class TestWorkerStopIdempotency: """Tests for worker stop idempotency.""" def test_stop_when_not_running(self, monkeypatch: pytest.MonkeyPatch) -> None: """Second stop should succeed with 'not running' message.""" monkeypatch.setattr( "binary_analysis.worker.client.get_worker_status", lambda: {"state": "stopped", "pid": None, "uptime_seconds": None}, ) import importlib import binary_analysis.cli.worker as cli_worker importlib.reload(cli_worker) import argparse args = argparse.Namespace() result = cli_worker.execute_stop(args) assert result["success"] is True assert result["data"]["status"] == "not_running" assert any("not running" in d["message"].lower() for d in result["diagnostics"]) def test_stop_when_running_stops_worker(self, monkeypatch: pytest.MonkeyPatch) -> None: """Stop when running should stop the worker.""" call_count = [0] def mock_status() -> dict: call_count[0] += 1 if call_count[0] <= 1: return {"state": "running", "pid": 12345, "uptime_seconds": 42.0} return {"state": "stopped", "pid": None, "uptime_seconds": None} monkeypatch.setattr("binary_analysis.worker.client.get_worker_status", mock_status) monkeypatch.setattr("binary_analysis.worker.client.read_pid", lambda: 12345) mock_client = mock.MagicMock() monkeypatch.setattr( "binary_analysis.worker.client.WorkerClient", lambda *a, **kw: mock_client, ) monkeypatch.setattr(os, "kill", lambda pid, sig: None) import importlib import binary_analysis.cli.worker as cli_worker importlib.reload(cli_worker) import argparse args = argparse.Namespace() result = cli_worker.execute_stop(args) assert result["success"] is True assert result["data"]["status"] == "stopped" # --------------------------------------------------------------------------- # VAL-WORKER-004: Worker failure does not corrupt project state # --------------------------------------------------------------------------- class TestWorkerFailureIsolation: """Tests for worker failure isolation.""" def test_worker_crash_leaves_no_stale_pid( self, clean_worker_state: None, monkeypatch: pytest.MonkeyPatch ) -> None: """When worker crashes, status should report stopped, not stale PID.""" monkeypatch.setattr("binary_analysis.worker.client.read_pid", lambda: 99999) monkeypatch.setattr("binary_analysis.worker.client._is_pid_alive", lambda: False) status = get_worker_status() assert status["state"] == "stopped" assert status["pid"] is None def test_get_worker_status_handles_missing_pid_file(self, clean_worker_state: None) -> None: """Status should report stopped when PID file is missing.""" status = get_worker_status() assert status["state"] == "stopped" def test_get_worker_status_handles_invalid_pid_file( self, clean_worker_state: None, monkeypatch: pytest.MonkeyPatch ) -> None: """Status should report stopped when PID file contains garbage.""" monkeypatch.setattr("binary_analysis.worker.client.read_pid", lambda: None) status = get_worker_status() assert status["state"] == "stopped" def test_project_state_valid_after_worker_kill(self, clean_worker_state: None) -> None: """After worker kill, get_worker_status reports stopped (no corruption).""" status = get_worker_status() assert status["state"] == "stopped" assert status["pid"] is None # --------------------------------------------------------------------------- # VAL-WORKER-005: One-shot mode works when worker is unavailable # --------------------------------------------------------------------------- class TestOneShotMode: """Tests for one-shot mode when worker is unavailable.""" def test_resolve_adapter_returns_fake_adapter(self, clean_worker_state: None) -> None: """resolve_adapter should return a FakeAdapter in one-shot mode.""" adapter, source = resolve_adapter() assert adapter is not None assert source == "one-shot" from binary_analysis.adapters.fake import FakeAdapter assert isinstance(adapter, FakeAdapter) def test_resolve_adapter_has_fixtures(self, clean_worker_state: None) -> None: """One-shot adapter should have fixtures set up.""" adapter, _source = resolve_adapter() # Verify the adapter has fixtures loaded assert hasattr(adapter, "_fixtures") assert len(adapter._fixtures) > 0 assert "pe-default" in adapter._fixtures assert adapter._fixtures["pe-default"] is not None def test_is_worker_available_returns_false_when_stopped(self, clean_worker_state: None) -> None: """is_worker_available should return False when no worker is running.""" assert is_worker_available() is False def test_commands_work_without_worker(self, clean_worker_state: None) -> None: """All CLI commands should function without a worker (one-shot mode). This is tested by running commands through the CLI entrypoint. """ from binary_analysis.cli.main import main # Test that worker status command works without a worker exit_code = main(["--json", "worker", "status"]) assert exit_code == 0 # --------------------------------------------------------------------------- # VAL-WORKER-006: Worker is optional # --------------------------------------------------------------------------- class TestWorkerOptional: """Tests verifying the worker is optional.""" def test_worker_help_describes_optional(self, capsys: pytest.CaptureFixture) -> None: """worker --help should describe the worker as optional.""" import contextlib from binary_analysis.cli.main import main with contextlib.suppress(SystemExit): main(["worker", "--help"]) captured = capsys.readouterr() help_text = captured.out + captured.err assert "optional" in help_text.lower() def test_full_pipeline_works_without_worker( self, tmp_path: str, monkeypatch: pytest.MonkeyPatch ) -> None: """Full pipeline (import, analyze, triage, etc.) works without worker.""" from binary_analysis.cli.main import main # Create a temp workspace workspace = str(tmp_path / "workspace") monkeypatch.setattr( "binary_analysis.projects.workspace.get_workspace_root", lambda: workspace, ) # Also need to patch list_workspaces monkeypatch.setattr( "binary_analysis.projects.workspace.list_workspaces", lambda: [], ) # Make sure worker is not running monkeypatch.setattr( "binary_analysis.worker.client.get_worker_status", lambda: {"state": "stopped", "pid": None, "uptime_seconds": None}, ) # Run worker status (should work without ever starting worker) exit_code = main(["--json", "worker", "status"]) assert exit_code == 0 def test_worker_status_json_has_provenance(self, capsys: pytest.CaptureFixture) -> None: """worker status --json should have standard envelope with provenance.""" from binary_analysis.cli.main import main main(["--json", "worker", "status"]) captured = capsys.readouterr() parsed = json.loads(captured.out) assert "provenance" in parsed assert "command" in parsed assert parsed["command"] == "worker status" assert "data" in parsed assert parsed["data"]["state"] in ("running", "stopped") # --------------------------------------------------------------------------- # VAL-CROSS-008: Worker lifecycle integration # --------------------------------------------------------------------------- class TestWorkerLifecycleIntegration: """Integration tests for worker lifecycle with one-shot fallback.""" def test_metadata_identical_with_without_worker(self, clean_worker_state: None) -> None: """Metadata results should be identical whether worker is running or not.""" adapter1, source1 = resolve_adapter() adapter2, source2 = resolve_adapter() assert source1 == "one-shot" assert source2 == "one-shot" # Access fixture dict directly fixture = adapter1._fixtures["pe-default"] from binary_analysis.domain.entities import Binary binary_entity = Binary( id=fixture.get("id", ""), sha256=fixture.get("sha256", ""), path=fixture.get("path", ""), format=fixture.get("format", "PE"), size_bytes=fixture.get("size_bytes", 0), ) meta1 = adapter1.get_metadata(binary_entity) meta2 = adapter2.get_metadata(binary_entity) assert meta1.format == meta2.format assert meta1.architecture == meta2.architecture assert meta1.endianness == meta2.endianness assert meta1.size_bytes == meta2.size_bytes def test_provenance_identical_with_without_worker(self, clean_worker_state: None) -> None: """Provenance fields should be identical whether worker is running or not.""" # In one-shot mode, provenance is always generated by the CLI, # not by the worker. So it's always identical. from binary_analysis.cli.helpers import default_provenance p1 = default_provenance() p2 = default_provenance() # Base fields should be present and identical assert p1["cli_version"] == p2["cli_version"] assert p1["schema_version"] == p2["schema_version"] assert p1["adapter"] == p2["adapter"] assert p1["platform"] == p2["platform"] # --------------------------------------------------------------------------- # WorkerClient tests # --------------------------------------------------------------------------- class TestWorkerClient: """Tests for WorkerClient.""" def test_client_is_available_false_when_no_socket(self, clean_worker_state: None) -> None: """Client should report unavailable when no socket exists.""" client = WorkerClient() assert client.is_available() is False def test_client_is_available_false_when_pid_not_alive( self, clean_worker_state: None, monkeypatch: pytest.MonkeyPatch ) -> None: """Client should report unavailable when PID is stale.""" # Create a socket file but with dead PID sock_path = _socket_path() pid_path = _pid_path() # Write a dead PID os.makedirs(os.path.dirname(sock_path), exist_ok=True) with open(pid_path, "w") as f: f.write("99999") # Don't create an actual socket (touch it) open(sock_path, "a").close() client = WorkerClient() assert client.is_available() is False def test_client_send_request_no_socket_raises(self, clean_worker_state: None) -> None: """send_request should raise OSError when socket doesn't exist.""" client = WorkerClient(timeout=0.5) with pytest.raises(OSError): client.send_request({"action": "ping"}) def test_read_pid_returns_none_when_no_file(self, clean_worker_state: None) -> None: """read_pid should return None when PID file doesn't exist.""" assert read_pid() is None def test_read_started_at_returns_none_when_no_file(self, clean_worker_state: None) -> None: """read_started_at should return None when file doesn't exist.""" assert read_started_at() is None # --------------------------------------------------------------------------- # WorkerServer tests # --------------------------------------------------------------------------- class TestWorkerServer: """Tests for WorkerServer.""" def test_server_initialization(self, clean_worker_state: None) -> None: """Server should initialize with no adapter until accessed.""" server = WorkerServer() assert server._adapter is None assert server._running is False def test_server_adapter_lazy_init(self, clean_worker_state: None) -> None: """Adapter should be initialized lazily on first access.""" server = WorkerServer() adapter = server.adapter assert adapter is not None assert server._adapter is not None from binary_analysis.adapters.fake import FakeAdapter assert isinstance(adapter, FakeAdapter) def test_server_stop_cleans_state(self, clean_worker_state: None) -> None: """stop() should set running to False.""" server = WorkerServer() server._running = True server.stop() assert server._running is False def test_server_stop_when_not_running_no_error(self, clean_worker_state: None) -> None: """stop() should be safe to call when not running.""" server = WorkerServer() server.stop() # Should not raise assert server._running is False def test_worker_dir_created(self, clean_worker_state: None) -> None: """_ensure_worker_dir should create the directory.""" dir_path = _ensure_worker_dir() assert os.path.isdir(dir_path) def test_server_cleanup_removes_files(self, clean_worker_state: None) -> None: """Server cleanup should remove PID and socket files.""" server = WorkerServer() pid_path = _pid_path() sock_path = _socket_path() # Create dummy files os.makedirs(os.path.dirname(pid_path), exist_ok=True) with open(pid_path, "w") as f: f.write("test") with open(sock_path, "w") as f: pass server._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: server._cleanup() finally: pass # PID file should be cleaned up assert not os.path.exists(pid_path) # --------------------------------------------------------------------------- # CLI command tests (via main) # --------------------------------------------------------------------------- class TestWorkerCLI: """Tests for worker CLI commands through main().""" def test_worker_status_json_envelope(self, capsys: pytest.CaptureFixture) -> None: """worker status --json should produce valid envelope.""" from binary_analysis.cli.main import main main(["--json", "worker", "status"]) captured = capsys.readouterr() parsed = json.loads(captured.out) for key in ( "schema_version", "command", "generated_at", "duration_ms", "success", "partial", "warnings", "diagnostics", "provenance", "data", ): assert key in parsed, f"Missing envelope key: {key}" assert parsed["command"] == "worker status" assert isinstance(parsed["data"], dict) assert "state" in parsed["data"] def test_worker_status_exit_code_zero(self) -> None: """worker status should exit 0.""" from binary_analysis.cli.main import main exit_code = main(["--json", "worker", "status"]) assert exit_code == 0 def test_worker_start_exit_code_zero(self, monkeypatch: pytest.MonkeyPatch) -> None: """worker start should exit 0 (either starts or reports already running).""" from binary_analysis.cli.main import main exit_code = main(["--json", "worker", "start"]) # May exit 0 (started or already running) or non-zero if start fails # In test environment, it could be either assert exit_code in (0, 1) def test_worker_stop_exit_code_zero(self, monkeypatch: pytest.MonkeyPatch) -> None: """worker stop should exit 0 (either stops or reports not running).""" monkeypatch.setattr( "binary_analysis.worker.client.get_worker_status", lambda: {"state": "stopped", "pid": None, "uptime_seconds": None}, ) import importlib import binary_analysis.cli.worker as cli_worker importlib.reload(cli_worker) from binary_analysis.cli.main import main exit_code = main(["--json", "worker", "stop"]) assert exit_code == 0 def test_worker_no_subcommand_shows_error(self, capsys: pytest.CaptureFixture) -> None: """worker with no subcommand should show error.""" from binary_analysis.cli.main import main exit_code = main(["--json", "worker"]) assert exit_code != 0 def test_worker_help_available(self, capsys: pytest.CaptureFixture) -> None: """binary --help should list worker subcommand.""" import contextlib from binary_analysis.cli.main import main with contextlib.suppress(SystemExit): main(["--help"]) captured = capsys.readouterr() help_text = captured.out + captured.err assert "worker" in help_text.lower() def test_worker_start_idempotent_via_cli( self, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """Running worker start twice via CLI should succeed both times.""" monkeypatch.setattr( "binary_analysis.worker.client.get_worker_status", lambda: {"state": "running", "pid": 12345, "uptime_seconds": 42.0}, ) import importlib import binary_analysis.cli.worker as cli_worker importlib.reload(cli_worker) from binary_analysis.cli.main import main exit_code1 = main(["--json", "worker", "start"]) captured1 = capsys.readouterr() parsed1 = json.loads(captured1.out) assert exit_code1 == 0 assert parsed1["data"]["status"] == "already_running" exit_code2 = main(["--json", "worker", "start"]) captured2 = capsys.readouterr() parsed2 = json.loads(captured2.out) assert exit_code2 == 0 assert parsed2["data"]["status"] == "already_running" def test_worker_stop_idempotent_via_cli( self, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: """Running worker stop twice via CLI should succeed both times.""" monkeypatch.setattr( "binary_analysis.worker.client.get_worker_status", lambda: {"state": "stopped", "pid": None, "uptime_seconds": None}, ) import importlib import binary_analysis.cli.worker as cli_worker importlib.reload(cli_worker) from binary_analysis.cli.main import main exit_code1 = main(["--json", "worker", "stop"]) captured1 = capsys.readouterr() parsed1 = json.loads(captured1.out) assert exit_code1 == 0 assert parsed1["data"]["status"] == "not_running" exit_code2 = main(["--json", "worker", "stop"]) captured2 = capsys.readouterr() parsed2 = json.loads(captured2.out) assert exit_code2 == 0 assert parsed2["data"]["status"] == "not_running" # --------------------------------------------------------------------------- # Resolver tests # --------------------------------------------------------------------------- class TestResolver: """Tests for adapter resolution.""" def test_resolve_adapter_always_returns_adapter(self, clean_worker_state: None) -> None: """resolve_adapter should always return a valid adapter.""" adapter, source = resolve_adapter() assert adapter is not None assert source in ("worker", "one-shot") def test_resolve_adapter_is_idempotent(self, clean_worker_state: None) -> None: """Multiple calls to resolve_adapter should each return a working adapter.""" adapter1, _ = resolve_adapter() adapter2, _ = resolve_adapter() assert adapter1 is not None assert adapter2 is not None def test_is_worker_available_returns_bool(self, clean_worker_state: None) -> None: """is_worker_available should return a boolean.""" result = is_worker_available() assert isinstance(result, bool)