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

1"""Unit tests for BaseToolPlugin subprocess and timeout methods.""" 

2 

3from __future__ import annotations 

4 

5import subprocess 

6from typing import TYPE_CHECKING 

7from unittest.mock import MagicMock, patch 

8 

9import pytest 

10from assertpy import assert_that 

11 

12from lintro.models.core.tool_result import ToolResult 

13 

14if TYPE_CHECKING: 

15 from tests.unit.plugins.conftest import FakeToolPlugin 

16 

17 

18# ============================================================================= 

19# BaseToolPlugin._run_subprocess Tests 

20# ============================================================================= 

21 

22 

23def test_run_subprocess_successful_command(fake_tool_plugin: FakeToolPlugin) -> None: 

24 """Verify successful command returns True and captured stdout. 

25 

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

36 

37 assert_that(success).is_true() 

38 assert_that(output).is_equal_to("output") 

39 

40 

41def test_run_subprocess_failed_command(fake_tool_plugin: FakeToolPlugin) -> None: 

42 """Verify failed command returns False and captured stderr. 

43 

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

54 

55 assert_that(success).is_false() 

56 assert_that(output).is_equal_to("error") 

57 

58 

59def test_run_subprocess_timeout_expired_raises( 

60 fake_tool_plugin: FakeToolPlugin, 

61) -> None: 

62 """Verify TimeoutExpired exception is raised when command times out. 

63 

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 ) 

72 

73 with pytest.raises(subprocess.TimeoutExpired): 

74 fake_tool_plugin._run_subprocess(["long", "cmd"], timeout=30) 

75 

76 

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. 

81 

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

87 

88 with pytest.raises(FileNotFoundError, match="Command not found"): 

89 fake_tool_plugin._run_subprocess(["nonexistent"]) 

90 

91 

92# ============================================================================= 

93# BaseToolPlugin._get_effective_timeout Tests 

94# ============================================================================= 

95 

96 

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. 

111 

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 

120 

121 result = fake_tool_plugin._get_effective_timeout(override) 

122 

123 assert_that(result).is_equal_to(expected) 

124 

125 

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. 

130 

131 Args: 

132 fake_tool_plugin: The fake tool plugin instance to test. 

133 """ 

134 fake_tool_plugin.options.pop("timeout", None) 

135 

136 result = fake_tool_plugin._get_effective_timeout() 

137 

138 assert_that(result).is_equal_to(30.0) # FakeToolPlugin default_timeout 

139 

140 

141# ============================================================================= 

142# BaseToolPlugin._validate_subprocess_command Tests 

143# ============================================================================= 

144 

145 

146def test_validate_subprocess_command_valid(fake_tool_plugin: FakeToolPlugin) -> None: 

147 """Verify valid command list passes validation without raising. 

148 

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 

154 

155 

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. 

169 

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] 

177 

178 

179def test_validate_subprocess_command_non_string_argument( 

180 fake_tool_plugin: FakeToolPlugin, 

181) -> None: 

182 """Verify non-string argument in command raises ValueError. 

183 

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] 

189 

190 

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. 

195 

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. 

199 

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

205 

206 

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. 

211 

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

218 

219 

220# ============================================================================= 

221# BaseToolPlugin._verify_tool_version Tests 

222# ============================================================================= 

223 

224 

225def test_verify_tool_version_passes_returns_none( 

226 fake_tool_plugin: FakeToolPlugin, 

227) -> None: 

228 """Verify None is returned when version check passes. 

229 

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) 

235 

236 result = fake_tool_plugin._verify_tool_version() 

237 

238 assert_that(result).is_none() 

239 

240 

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. 

245 

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 ) 

256 

257 result = fake_tool_plugin._verify_tool_version() 

258 

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]