1
0
This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Timmy-time-dashboard/tests/test_hands_shell.py

184 lines
5.5 KiB
Python
Raw Permalink Normal View History

"""Tests for the Shell Execution Hand.
Covers:
- Command validation (allow-list and block-list)
- Successful command execution
- Command failure (non-zero exit code)
- Timeout enforcement
- ShellResult dataclass defaults
- Status summary
"""
import asyncio
import pytest
# ---------------------------------------------------------------------------
# Command validation
# ---------------------------------------------------------------------------
def test_validate_allows_safe_commands():
"""Commands matching the allow-list should pass validation."""
from infrastructure.hands.shell import ShellHand
hand = ShellHand()
assert hand._validate_command("make test") is None
assert hand._validate_command("pytest -q") is None
assert hand._validate_command("git status") is None
assert hand._validate_command("python -c 'print(1)'") is None
assert hand._validate_command("ls -la") is None
def test_validate_blocks_unknown_commands():
"""Commands not in the allow-list should be rejected."""
from infrastructure.hands.shell import ShellHand
hand = ShellHand()
err = hand._validate_command("reboot")
assert err is not None
assert "not in allow-list" in err
def test_validate_blocks_dangerous_commands():
"""Explicitly dangerous commands should be blocked."""
from infrastructure.hands.shell import ShellHand
hand = ShellHand()
err = hand._validate_command("rm -rf /")
assert err is not None
assert "blocked by safety filter" in err
def test_validate_empty_command():
"""Empty commands should be rejected."""
from infrastructure.hands.shell import ShellHand
hand = ShellHand()
err = hand._validate_command("")
assert err is not None
def test_validate_strips_path_prefix():
"""Command with a path prefix should still match allow-list."""
from infrastructure.hands.shell import ShellHand
hand = ShellHand()
assert hand._validate_command("/usr/bin/python --version") is None
assert hand._validate_command("/usr/local/bin/make test") is None
# ---------------------------------------------------------------------------
# Execution — success path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_run_echo():
"""A simple echo command should succeed."""
from infrastructure.hands.shell import ShellHand
hand = ShellHand()
result = await hand.run("echo hello world")
assert result.success is True
assert result.exit_code == 0
assert "hello world" in result.stdout
assert result.latency_ms > 0
# ---------------------------------------------------------------------------
# Execution — failure path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_run_blocked_command():
"""Running a blocked command returns success=False without executing."""
from infrastructure.hands.shell import ShellHand
hand = ShellHand()
result = await hand.run("shutdown now")
assert result.success is False
assert "not in allow-list" in result.error
@pytest.mark.asyncio
async def test_run_nonzero_exit():
"""A command that exits non-zero should return success=False."""
from infrastructure.hands.shell import ShellHand
hand = ShellHand()
result = await hand.run("ls /nonexistent_path_that_does_not_exist_xyz")
assert result.success is False
assert result.exit_code != 0
# ---------------------------------------------------------------------------
# Timeout
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_run_timeout():
"""A command exceeding timeout should be killed and return timed_out=True."""
from unittest.mock import AsyncMock, MagicMock, patch
from infrastructure.hands.shell import ShellHand
hand = ShellHand()
# Mock subprocess that never finishes until killed
mock_proc = MagicMock()
mock_proc.kill = MagicMock()
mock_proc.wait = AsyncMock()
mock_proc.returncode = -9
async def slow_communicate():
await asyncio.sleep(999)
mock_proc.communicate = slow_communicate
with patch("asyncio.create_subprocess_shell", return_value=mock_proc):
result = await hand.run("python -c 'import time; time.sleep(30)'", timeout=1)
assert result.success is False
assert result.timed_out is True
mock_proc.kill.assert_called_once()
# ---------------------------------------------------------------------------
# ShellResult dataclass
# ---------------------------------------------------------------------------
def test_shell_result_defaults():
"""ShellResult should have sensible defaults."""
from infrastructure.hands.shell import ShellResult
r = ShellResult(command="echo hi", success=True)
assert r.exit_code == -1
assert r.stdout == ""
assert r.stderr == ""
assert r.error == ""
assert r.latency_ms == 0.0
assert r.timed_out is False
assert r.metadata == {}
# ---------------------------------------------------------------------------
# Status summary
# ---------------------------------------------------------------------------
def test_status_returns_summary():
"""status() should return a dict with enabled, working_dir, etc."""
from infrastructure.hands.shell import ShellHand
hand = ShellHand()
s = hand.status()
assert "enabled" in s
assert "working_dir" in s
assert "default_timeout" in s
assert "allowed_prefixes" in s
assert isinstance(s["allowed_prefixes"], list)