Coverage for tests / unit / plugins / base / test_subprocess.py: 100%
66 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Unit tests for BaseToolPlugin subprocess and timeout methods."""
3from __future__ import annotations
5import subprocess
6from typing import TYPE_CHECKING
7from unittest.mock import MagicMock, patch
9import pytest
10from assertpy import assert_that
12from lintro.models.core.tool_result import ToolResult
14if TYPE_CHECKING:
15 from tests.unit.plugins.conftest import FakeToolPlugin
18# =============================================================================
19# BaseToolPlugin._run_subprocess Tests
20# =============================================================================
23def test_run_subprocess_successful_command(fake_tool_plugin: FakeToolPlugin) -> None:
24 """Verify successful command returns True and captured stdout.
26 Args:
27 fake_tool_plugin: The fake tool plugin instance to test.
28 """
29 with patch("subprocess.run") as mock_run:
30 mock_run.return_value = MagicMock(
31 returncode=0,
32 stdout="output",
33 stderr="",
34 )
35 success, output = fake_tool_plugin._run_subprocess(["echo", "hello"])
37 assert_that(success).is_true()
38 assert_that(output).is_equal_to("output")
41def test_run_subprocess_failed_command(fake_tool_plugin: FakeToolPlugin) -> None:
42 """Verify failed command returns False and captured stderr.
44 Args:
45 fake_tool_plugin: The fake tool plugin instance to test.
46 """
47 with patch("subprocess.run") as mock_run:
48 mock_run.return_value = MagicMock(
49 returncode=1,
50 stdout="",
51 stderr="error",
52 )
53 success, output = fake_tool_plugin._run_subprocess(["false"])
55 assert_that(success).is_false()
56 assert_that(output).is_equal_to("error")
59def test_run_subprocess_timeout_expired_raises(
60 fake_tool_plugin: FakeToolPlugin,
61) -> None:
62 """Verify TimeoutExpired exception is raised when command times out.
64 Args:
65 fake_tool_plugin: The fake tool plugin instance to test.
66 """
67 with patch("subprocess.run") as mock_run:
68 mock_run.side_effect = subprocess.TimeoutExpired(
69 cmd=["long", "cmd"],
70 timeout=30,
71 )
73 with pytest.raises(subprocess.TimeoutExpired):
74 fake_tool_plugin._run_subprocess(["long", "cmd"], timeout=30)
77def test_run_subprocess_file_not_found_raises(
78 fake_tool_plugin: FakeToolPlugin,
79) -> None:
80 """Verify FileNotFoundError is raised when command is not found.
82 Args:
83 fake_tool_plugin: The fake tool plugin instance to test.
84 """
85 with patch("subprocess.run") as mock_run:
86 mock_run.side_effect = FileNotFoundError("not found")
88 with pytest.raises(FileNotFoundError, match="Command not found"):
89 fake_tool_plugin._run_subprocess(["nonexistent"])
92# =============================================================================
93# BaseToolPlugin._get_effective_timeout Tests
94# =============================================================================
97@pytest.mark.parametrize(
98 ("override", "options_timeout", "expected"),
99 [
100 pytest.param(60, None, 60.0, id="override_takes_precedence"),
101 pytest.param(None, 45, 45.0, id="options_timeout_used"),
102 ],
103)
104def test_get_effective_timeout_precedence(
105 fake_tool_plugin: FakeToolPlugin,
106 override: int | None,
107 options_timeout: int | None,
108 expected: float,
109) -> None:
110 """Verify timeout precedence: override > options > definition default.
112 Args:
113 fake_tool_plugin: The fake tool plugin instance to test.
114 override: The override timeout value.
115 options_timeout: The options timeout value.
116 expected: The expected effective timeout value.
117 """
118 if options_timeout is not None:
119 fake_tool_plugin.options["timeout"] = options_timeout
121 result = fake_tool_plugin._get_effective_timeout(override)
123 assert_that(result).is_equal_to(expected)
126def test_get_effective_timeout_falls_back_to_definition_default(
127 fake_tool_plugin: FakeToolPlugin,
128) -> None:
129 """Verify timeout falls back to definition default when no override or option.
131 Args:
132 fake_tool_plugin: The fake tool plugin instance to test.
133 """
134 fake_tool_plugin.options.pop("timeout", None)
136 result = fake_tool_plugin._get_effective_timeout()
138 assert_that(result).is_equal_to(30.0) # FakeToolPlugin default_timeout
141# =============================================================================
142# BaseToolPlugin._validate_subprocess_command Tests
143# =============================================================================
146def test_validate_subprocess_command_valid(fake_tool_plugin: FakeToolPlugin) -> None:
147 """Verify valid command list passes validation without raising.
149 Args:
150 fake_tool_plugin: The fake tool plugin instance to test.
151 """
152 fake_tool_plugin._validate_subprocess_command(["ls", "-la"])
153 # Should not raise
156@pytest.mark.parametrize(
157 ("command", "match_pattern"),
158 [
159 pytest.param([], "non-empty list", id="empty_list"),
160 pytest.param("ls -la", "non-empty list", id="string_not_list"),
161 ],
162)
163def test_validate_subprocess_command_invalid_structure(
164 fake_tool_plugin: FakeToolPlugin,
165 command: list[str] | str,
166 match_pattern: str,
167) -> None:
168 """Verify invalid command structure raises ValueError.
170 Args:
171 fake_tool_plugin: Fixture providing a FakeToolPlugin instance.
172 command: The command to validate.
173 match_pattern: Regex pattern expected in the ValueError message.
174 """
175 with pytest.raises(ValueError, match=match_pattern):
176 fake_tool_plugin._validate_subprocess_command(command) # type: ignore[arg-type]
179def test_validate_subprocess_command_non_string_argument(
180 fake_tool_plugin: FakeToolPlugin,
181) -> None:
182 """Verify non-string argument in command raises ValueError.
184 Args:
185 fake_tool_plugin: Fixture providing a FakeToolPlugin instance.
186 """
187 with pytest.raises(ValueError, match="must be strings"):
188 fake_tool_plugin._validate_subprocess_command(["ls", 123]) # type: ignore[list-item]
191def test_validate_subprocess_command_unsafe_characters_in_command_name(
192 fake_tool_plugin: FakeToolPlugin,
193) -> None:
194 """Verify command with shell injection characters in name raises ValueError.
196 Only the command name (first element) is validated for unsafe characters.
197 Arguments can contain special characters since shell=False passes them
198 literally to the subprocess.
200 Args:
201 fake_tool_plugin: Fixture providing a FakeToolPlugin instance.
202 """
203 with pytest.raises(ValueError, match="Unsafe character"):
204 fake_tool_plugin._validate_subprocess_command(["ls;rm", "-la"])
207def test_validate_subprocess_command_special_chars_allowed_in_args(
208 fake_tool_plugin: FakeToolPlugin,
209) -> None:
210 """Verify special characters in arguments are allowed with shell=False.
212 Args:
213 fake_tool_plugin: Fixture providing a FakeToolPlugin instance.
214 """
215 # Should NOT raise - shell=False makes these safe
216 fake_tool_plugin._validate_subprocess_command(["ls", "-la; rm -rf /"])
217 fake_tool_plugin._validate_subprocess_command(["semgrep", "--include", "*.py"])
220# =============================================================================
221# BaseToolPlugin._verify_tool_version Tests
222# =============================================================================
225def test_verify_tool_version_passes_returns_none(
226 fake_tool_plugin: FakeToolPlugin,
227) -> None:
228 """Verify None is returned when version check passes.
230 Args:
231 fake_tool_plugin: Fixture providing a FakeToolPlugin instance.
232 """
233 with patch("lintro.tools.core.version_requirements.check_tool_version") as mock:
234 mock.return_value = MagicMock(version_check_passed=True)
236 result = fake_tool_plugin._verify_tool_version()
238 assert_that(result).is_none()
241def test_verify_tool_version_fails_returns_skip_result(
242 fake_tool_plugin: FakeToolPlugin,
243) -> None:
244 """Verify skip result is returned when version check fails.
246 Args:
247 fake_tool_plugin: Fixture providing a FakeToolPlugin instance.
248 """
249 with patch("lintro.tools.core.version_requirements.check_tool_version") as mock:
250 mock.return_value = MagicMock(
251 version_check_passed=False,
252 error_message="Version too old",
253 min_version="1.0.0",
254 install_hint="pip install tool",
255 )
257 result = fake_tool_plugin._verify_tool_version()
259 assert_that(result).is_not_none()
260 assert_that(result).is_instance_of(ToolResult)
261 # result is verified non-None by assertpy above
262 assert_that(result.output).contains("Skipping") # type: ignore[union-attr]