Coverage for tests / unit / tools / osv_scanner / test_osv_scanner_plugin.py: 100%
134 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 OSV-Scanner plugin."""
3from __future__ import annotations
5import json
6import os
7import subprocess
8from pathlib import Path
9from typing import cast
10from unittest.mock import patch
12import pytest
13from assertpy import assert_that
15from lintro.enums.severity_level import SeverityLevel
16from lintro.enums.tool_type import ToolType
17from lintro.parsers.osv_scanner.osv_scanner_issue import OsvScannerIssue
18from lintro.tools.definitions.osv_scanner import (
19 OSV_SCANNER_DEFAULT_TIMEOUT,
20 OsvScannerPlugin,
21)
23# =============================================================================
24# Tests for definition
25# =============================================================================
28def test_definition_name(osv_scanner_plugin: OsvScannerPlugin) -> None:
29 """Definition has correct name."""
30 assert_that(osv_scanner_plugin.definition.name).is_equal_to("osv_scanner")
33def test_definition_type(osv_scanner_plugin: OsvScannerPlugin) -> None:
34 """Definition has correct tool type."""
35 assert_that(osv_scanner_plugin.definition.tool_type).is_equal_to(ToolType.SECURITY)
38def test_definition_cannot_fix(osv_scanner_plugin: OsvScannerPlugin) -> None:
39 """Definition reports no fix support."""
40 assert_that(osv_scanner_plugin.definition.can_fix).is_false()
43def test_default_timeout(osv_scanner_plugin: OsvScannerPlugin) -> None:
44 """Default timeout has correct value."""
45 assert_that(osv_scanner_plugin.options.get("timeout")).is_equal_to(
46 OSV_SCANNER_DEFAULT_TIMEOUT,
47 )
50# =============================================================================
51# Tests for set_options validation
52# =============================================================================
55def test_set_options_validates_timeout_type(
56 osv_scanner_plugin: OsvScannerPlugin,
57) -> None:
58 """set_options rejects non-integer timeout."""
59 with pytest.raises(ValueError, match="timeout must be an integer"):
60 osv_scanner_plugin.set_options(timeout="fast")
63def test_set_options_validates_timeout_negative(
64 osv_scanner_plugin: OsvScannerPlugin,
65) -> None:
66 """set_options rejects negative timeout."""
67 with pytest.raises(ValueError, match="timeout must be positive"):
68 osv_scanner_plugin.set_options(timeout=-1)
71def test_set_options_validates_timeout_zero(
72 osv_scanner_plugin: OsvScannerPlugin,
73) -> None:
74 """set_options rejects zero timeout."""
75 with pytest.raises(ValueError, match="timeout must be positive"):
76 osv_scanner_plugin.set_options(timeout=0)
79def test_set_options_validates_timeout_bool(
80 osv_scanner_plugin: OsvScannerPlugin,
81) -> None:
82 """set_options rejects boolean timeout."""
83 with pytest.raises(ValueError, match="timeout must be an integer"):
84 osv_scanner_plugin.set_options(timeout=True)
87# =============================================================================
88# Tests for check method
89# =============================================================================
92def test_check_no_vulnerabilities(
93 osv_scanner_plugin: OsvScannerPlugin,
94 tmp_path: Path,
95) -> None:
96 """Check returns success when no vulnerabilities found.
98 Args:
99 osv_scanner_plugin: The OsvScannerPlugin instance to test.
100 tmp_path: Temporary directory path for test files.
101 """
102 lockfile = tmp_path / "requirements.txt"
103 lockfile.write_text("requests==2.32.3\n")
105 with patch.object(
106 osv_scanner_plugin,
107 "_run_subprocess",
108 return_value=(True, ""),
109 ):
110 result = osv_scanner_plugin.check([str(lockfile)], {})
112 assert_that(result.success).is_true()
113 assert_that(result.issues_count).is_equal_to(0)
116def test_check_with_vulnerabilities(
117 osv_scanner_plugin: OsvScannerPlugin,
118 tmp_path: Path,
119) -> None:
120 """Check returns issues when vulnerabilities found.
122 Args:
123 osv_scanner_plugin: The OsvScannerPlugin instance to test.
124 tmp_path: Temporary directory path for test files.
125 """
126 lockfile = tmp_path / "requirements.txt"
127 lockfile.write_text("requests==2.25.0\n")
129 osv_output = json.dumps(
130 {
131 "results": [
132 {
133 "source": {"path": str(lockfile)},
134 "packages": [
135 {
136 "package": {
137 "name": "requests",
138 "version": "2.25.0",
139 "ecosystem": "PyPI",
140 },
141 "groups": [
142 {
143 "ids": ["GHSA-9wx4-h78v-vm56"],
144 "max_severity": "HIGH",
145 },
146 ],
147 "vulnerabilities": [
148 {
149 "id": "GHSA-9wx4-h78v-vm56",
150 "summary": "Session verify bypass",
151 "affected": [
152 {
153 "package": {
154 "name": "requests",
155 "ecosystem": "PyPI",
156 },
157 "ranges": [
158 {
159 "type": "ECOSYSTEM",
160 "events": [
161 {"introduced": "0"},
162 {"fixed": "2.32.0"},
163 ],
164 },
165 ],
166 },
167 ],
168 },
169 ],
170 },
171 ],
172 },
173 ],
174 },
175 )
177 with patch.object(
178 osv_scanner_plugin,
179 "_run_subprocess",
180 return_value=(False, osv_output),
181 ):
182 result = osv_scanner_plugin.check([str(lockfile)], {})
184 assert_that(result.success).is_false()
185 assert_that(result.issues_count).is_equal_to(1)
186 assert_that(result.issues).is_not_none()
187 issues = cast(list[OsvScannerIssue], result.issues)
188 assert_that(issues[0].vuln_id).is_equal_to("GHSA-9wx4-h78v-vm56")
189 assert_that(issues[0].package_name).is_equal_to("requests")
190 assert_that(issues[0].severity).is_equal_to("HIGH")
191 assert_that(issues[0].fixed_version).is_equal_to("2.32.0")
194def test_check_timeout(
195 osv_scanner_plugin: OsvScannerPlugin,
196 tmp_path: Path,
197) -> None:
198 """Check handles timeout correctly.
200 Args:
201 osv_scanner_plugin: The OsvScannerPlugin instance to test.
202 tmp_path: Temporary directory path for test files.
203 """
204 lockfile = tmp_path / "requirements.txt"
205 lockfile.write_text("requests==2.25.0\n")
207 with patch.object(
208 osv_scanner_plugin,
209 "_run_subprocess",
210 side_effect=subprocess.TimeoutExpired(
211 cmd=["osv-scanner"],
212 timeout=120,
213 ),
214 ):
215 result = osv_scanner_plugin.check([str(lockfile)], {})
217 assert_that(result.success).is_false()
218 assert_that(result.output).contains("timed out")
221def test_check_empty_paths(
222 osv_scanner_plugin: OsvScannerPlugin,
223) -> None:
224 """Check returns early when given no paths.
226 Args:
227 osv_scanner_plugin: The OsvScannerPlugin instance to test.
228 """
229 result = osv_scanner_plugin.check([], {})
231 assert_that(result.success).is_true()
232 assert_that(result.issues_count).is_equal_to(0)
235# =============================================================================
236# Tests for fix method
237# =============================================================================
240def test_fix_raises_not_implemented(osv_scanner_plugin: OsvScannerPlugin) -> None:
241 """Fix method raises NotImplementedError.
243 Args:
244 osv_scanner_plugin: The OsvScannerPlugin instance to test.
245 """
246 with pytest.raises(NotImplementedError, match="cannot automatically fix"):
247 osv_scanner_plugin.fix(["src/"], {})
250# =============================================================================
251# Tests for OsvScannerIssue DEFAULT_SEVERITY
252# =============================================================================
255def test_issue_default_severity_is_error() -> None:
256 """OsvScannerIssue falls back to ERROR severity when severity is empty."""
257 issue = OsvScannerIssue(
258 vuln_id="GHSA-test-1234",
259 severity="",
260 package_name="foo",
261 package_version="1.0.0",
262 )
263 assert_that(issue.get_severity()).is_equal_to(SeverityLevel.ERROR)
266# =============================================================================
267# Tests for suppression staleness detection
268# =============================================================================
271def test_check_with_suppressions_detects_stale(
272 osv_scanner_plugin: OsvScannerPlugin,
273 tmp_path: Path,
274) -> None:
275 """Check classifies suppressions when .osv-scanner.toml exists."""
276 lockfile = tmp_path / "requirements.txt"
277 lockfile.write_text("requests==2.32.3\n")
279 # Create a config with one suppression
280 config = tmp_path / ".osv-scanner.toml"
281 config.write_text(
282 "[[IgnoredVulns]]\n"
283 'id = "GHSA-stale-1234"\n'
284 "ignoreUntil = 2027-12-31\n"
285 'reason = "Test suppression"\n',
286 )
288 # Gating scan: no issues (vuln is suppressed)
289 # Probe scan: also no issues (vuln was fixed upstream → stale)
290 with patch.object(
291 osv_scanner_plugin,
292 "_run_subprocess",
293 side_effect=[
294 (True, ""), # gating scan
295 (True, ""), # probe scan
296 ],
297 ):
298 result = osv_scanner_plugin.check([str(lockfile)], {})
300 assert_that(result.success).is_true()
301 assert_that(result.ai_metadata).is_not_none()
302 assert result.ai_metadata is not None # narrow type for mypy
303 suppressions = result.ai_metadata["suppressions"]
304 assert_that(suppressions).is_length(1)
305 assert_that(suppressions[0]["id"]).is_equal_to("GHSA-stale-1234")
306 assert_that(suppressions[0]["status"]).is_equal_to("stale")
309def test_check_without_config_no_metadata(
310 osv_scanner_plugin: OsvScannerPlugin,
311 tmp_path: Path,
312) -> None:
313 """Check returns no ai_metadata when no .osv-scanner.toml exists."""
314 lockfile = tmp_path / "requirements.txt"
315 lockfile.write_text("requests==2.32.3\n")
317 with patch.object(
318 osv_scanner_plugin,
319 "_run_subprocess",
320 return_value=(True, ""),
321 ):
322 result = osv_scanner_plugin.check([str(lockfile)], {})
324 assert_that(result.success).is_true()
325 assert_that(result.ai_metadata).is_none()
328def test_check_suppressions_disabled(
329 osv_scanner_plugin: OsvScannerPlugin,
330 tmp_path: Path,
331) -> None:
332 """No probe scan when check_suppressions is False."""
333 lockfile = tmp_path / "requirements.txt"
334 lockfile.write_text("requests==2.32.3\n")
336 config = tmp_path / ".osv-scanner.toml"
337 config.write_text(
338 "[[IgnoredVulns]]\n"
339 'id = "GHSA-1111-aaaa-bbbb"\n'
340 "ignoreUntil = 2027-12-31\n"
341 'reason = "Test"\n',
342 )
344 with patch.object(
345 osv_scanner_plugin,
346 "_run_subprocess",
347 return_value=(True, ""),
348 ) as mock_run:
349 result = osv_scanner_plugin.check(
350 [str(lockfile)],
351 {"check_suppressions": False},
352 )
354 # Only one subprocess call (gating scan, no probe)
355 assert_that(mock_run.call_count).is_equal_to(1)
356 assert_that(result.ai_metadata).is_none()
359def test_check_suppressions_probe_timeout(
360 osv_scanner_plugin: OsvScannerPlugin,
361 tmp_path: Path,
362) -> None:
363 """Graceful fallback when probe scan times out."""
364 lockfile = tmp_path / "requirements.txt"
365 lockfile.write_text("requests==2.32.3\n")
367 config = tmp_path / ".osv-scanner.toml"
368 config.write_text(
369 "[[IgnoredVulns]]\n"
370 'id = "GHSA-1111-aaaa-bbbb"\n'
371 "ignoreUntil = 2027-12-31\n"
372 'reason = "Test"\n',
373 )
375 with patch.object(
376 osv_scanner_plugin,
377 "_run_subprocess",
378 side_effect=[
379 (True, ""), # gating scan succeeds
380 subprocess.TimeoutExpired(cmd=["osv-scanner"], timeout=120), # probe
381 ],
382 ):
383 result = osv_scanner_plugin.check([str(lockfile)], {})
385 assert_that(result.success).is_true()
386 assert_that(result.ai_metadata).is_none()
389def test_build_probe_command_internal(
390 osv_scanner_plugin: OsvScannerPlugin,
391 tmp_path: Path,
392) -> None:
393 """Probe command includes --recursive and --config with null device.
395 Tests the private _build_probe_command directly because exercising it
396 through check() requires complex subprocess mocking with two sequential
397 calls (gating scan + probe scan) that obscures the command structure
398 being verified.
399 """
400 cmd = osv_scanner_plugin._build_probe_command(tmp_path)
402 assert_that(cmd).contains("--recursive")
403 assert_that(cmd).contains("--config")
404 assert_that(cmd).contains(os.devnull)
405 assert_that(cmd).contains(str(tmp_path))
408def test_find_config_file_in_scan_root(tmp_path: Path) -> None:
409 """Finds .osv-scanner.toml in the scan root."""
410 config = tmp_path / ".osv-scanner.toml"
411 config.write_text("")
413 result = OsvScannerPlugin._find_config_file(tmp_path)
414 assert_that(result).is_equal_to(config)
417def test_find_config_file_in_parent(tmp_path: Path) -> None:
418 """Finds .osv-scanner.toml in a parent directory."""
419 config = tmp_path / ".osv-scanner.toml"
420 config.write_text("")
422 child = tmp_path / "frontend"
423 child.mkdir()
425 result = OsvScannerPlugin._find_config_file(child)
426 assert_that(result).is_equal_to(config)
429def test_find_config_file_not_found(tmp_path: Path) -> None:
430 """Returns None when no .osv-scanner.toml exists."""
431 result = OsvScannerPlugin._find_config_file(tmp_path)
432 assert_that(result).is_none()
435def test_set_options_validates_check_suppressions(
436 osv_scanner_plugin: OsvScannerPlugin,
437) -> None:
438 """set_options rejects non-boolean check_suppressions."""
439 with pytest.raises(ValueError, match="check_suppressions must be a boolean"):
440 osv_scanner_plugin.set_options(check_suppressions="yes")