Files

794 lines
28 KiB
Python

"""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)