Coverage for tests / unit / parsers / test_osv_suppression_parser.py: 100%
87 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 suppression parser."""
3from __future__ import annotations
5from datetime import date
6from pathlib import Path
8import pytest
9from assertpy import assert_that
11from lintro.parsers.osv_scanner.suppression_models import SuppressionEntry
12from lintro.parsers.osv_scanner.suppression_parser import (
13 classify_suppressions,
14 parse_suppressions,
15)
16from lintro.parsers.osv_scanner.suppression_status import SuppressionStatus
18# =============================================================================
19# Tests for parse_suppressions
20# =============================================================================
23def test_parse_well_formed_toml(tmp_path: Path) -> None:
24 """Parse a valid .osv-scanner.toml with multiple entries."""
25 toml_file = tmp_path / ".osv-scanner.toml"
26 toml_file.write_text(
27 "[[IgnoredVulns]]\n"
28 'id = "GHSA-1111-aaaa-bbbb"\n'
29 "ignoreUntil = 2026-12-31\n"
30 'reason = "Low risk transitive dep"\n'
31 "\n"
32 "[[IgnoredVulns]]\n"
33 'id = "CVE-2024-99999"\n'
34 "ignoreUntil = 2026-06-15\n"
35 'reason = "No fix available yet"\n',
36 )
38 entries = parse_suppressions(toml_file)
40 assert_that(entries).is_length(2)
41 assert_that(entries[0].id).is_equal_to("GHSA-1111-aaaa-bbbb")
42 assert_that(entries[0].ignore_until).is_equal_to(date(2026, 12, 31))
43 assert_that(entries[0].reason).is_equal_to("Low risk transitive dep")
44 assert_that(entries[1].id).is_equal_to("CVE-2024-99999")
45 assert_that(entries[1].ignore_until).is_equal_to(date(2026, 6, 15))
48def test_parse_missing_file(tmp_path: Path) -> None:
49 """Return empty list for nonexistent file."""
50 entries = parse_suppressions(tmp_path / "nonexistent.toml")
51 assert_that(entries).is_equal_to([])
54def test_parse_empty_file(tmp_path: Path) -> None:
55 """Return empty list for empty TOML file."""
56 toml_file = tmp_path / ".osv-scanner.toml"
57 toml_file.write_text("")
59 entries = parse_suppressions(toml_file)
60 assert_that(entries).is_equal_to([])
63def test_parse_no_ignored_vulns(tmp_path: Path) -> None:
64 """Return empty list when TOML has no IgnoredVulns key."""
65 toml_file = tmp_path / ".osv-scanner.toml"
66 toml_file.write_text('[SomeOtherSection]\nkey = "value"\n')
68 entries = parse_suppressions(toml_file)
69 assert_that(entries).is_equal_to([])
72def test_parse_skips_entry_without_id(tmp_path: Path) -> None:
73 """Skip entries that have no id field."""
74 toml_file = tmp_path / ".osv-scanner.toml"
75 toml_file.write_text(
76 "[[IgnoredVulns]]\n" "ignoreUntil = 2026-12-31\n" 'reason = "Missing id"\n',
77 )
79 entries = parse_suppressions(toml_file)
80 assert_that(entries).is_equal_to([])
83def test_parse_skips_entry_without_ignore_until(tmp_path: Path) -> None:
84 """Skip entries that have no ignoreUntil field."""
85 toml_file = tmp_path / ".osv-scanner.toml"
86 toml_file.write_text(
87 "[[IgnoredVulns]]\n" 'id = "GHSA-1111-aaaa-bbbb"\n' 'reason = "Missing date"\n',
88 )
90 entries = parse_suppressions(toml_file)
91 assert_that(entries).is_equal_to([])
94def test_parse_missing_reason_defaults_empty(tmp_path: Path) -> None:
95 """Missing reason field defaults to empty string."""
96 toml_file = tmp_path / ".osv-scanner.toml"
97 toml_file.write_text(
98 "[[IgnoredVulns]]\n"
99 'id = "GHSA-1111-aaaa-bbbb"\n'
100 "ignoreUntil = 2026-12-31\n",
101 )
103 entries = parse_suppressions(toml_file)
104 assert_that(entries).is_length(1)
105 assert_that(entries[0].reason).is_equal_to("")
108def test_parse_invalid_toml(tmp_path: Path) -> None:
109 """Return empty list for malformed TOML."""
110 toml_file = tmp_path / ".osv-scanner.toml"
111 toml_file.write_text("this is not valid toml [[[")
113 entries = parse_suppressions(toml_file)
114 assert_that(entries).is_equal_to([])
117# =============================================================================
118# Tests for classify_suppressions
119# =============================================================================
122def test_classify_expired() -> None:
123 """Entry past ignoreUntil is EXPIRED."""
124 entry = SuppressionEntry(
125 id="GHSA-expired",
126 ignore_until=date(2025, 1, 1),
127 reason="Old suppression",
128 )
130 classified = classify_suppressions(
131 [entry],
132 probe_vuln_ids={"GHSA-expired"},
133 today=date(2026, 3, 26),
134 )
136 assert_that(classified).is_length(1)
137 assert_that(classified[0].status).is_equal_to(SuppressionStatus.EXPIRED)
140def test_classify_active() -> None:
141 """Entry still within date and found in probe is ACTIVE."""
142 entry = SuppressionEntry(
143 id="GHSA-active",
144 ignore_until=date(2027, 12, 31),
145 reason="Still present",
146 )
148 classified = classify_suppressions(
149 [entry],
150 probe_vuln_ids={"GHSA-active", "GHSA-other"},
151 today=date(2026, 3, 26),
152 )
154 assert_that(classified).is_length(1)
155 assert_that(classified[0].status).is_equal_to(SuppressionStatus.ACTIVE)
158def test_classify_stale() -> None:
159 """Entry within date but NOT found in probe is STALE."""
160 entry = SuppressionEntry(
161 id="GHSA-stale",
162 ignore_until=date(2027, 12, 31),
163 reason="Fixed upstream",
164 )
166 classified = classify_suppressions(
167 [entry],
168 probe_vuln_ids={"GHSA-other"},
169 today=date(2026, 3, 26),
170 )
172 assert_that(classified).is_length(1)
173 assert_that(classified[0].status).is_equal_to(SuppressionStatus.STALE)
176def test_classify_boundary_today_equals_ignore_until() -> None:
177 """Entry with ignoreUntil == today is NOT expired (still active/stale)."""
178 entry = SuppressionEntry(
179 id="GHSA-boundary",
180 ignore_until=date(2026, 3, 26),
181 reason="Boundary case",
182 )
184 # In probe (active, not expired)
185 classified = classify_suppressions(
186 [entry],
187 probe_vuln_ids={"GHSA-boundary"},
188 today=date(2026, 3, 26),
189 )
191 assert_that(classified[0].status).is_equal_to(SuppressionStatus.ACTIVE)
194def test_classify_empty_entries() -> None:
195 """Empty entries list returns empty classified list."""
196 classified = classify_suppressions(
197 [],
198 probe_vuln_ids={"GHSA-something"},
199 today=date(2026, 3, 26),
200 )
202 assert_that(classified).is_equal_to([])
205def test_classify_multiple_entries() -> None:
206 """Classify a mix of active, stale, and expired entries."""
207 entries = [
208 SuppressionEntry(
209 id="GHSA-active",
210 ignore_until=date(2027, 1, 1),
211 reason="Still present",
212 ),
213 SuppressionEntry(
214 id="GHSA-stale",
215 ignore_until=date(2027, 1, 1),
216 reason="Fixed upstream",
217 ),
218 SuppressionEntry(
219 id="GHSA-expired",
220 ignore_until=date(2025, 1, 1),
221 reason="Past date",
222 ),
223 ]
225 classified = classify_suppressions(
226 entries,
227 probe_vuln_ids={"GHSA-active"},
228 today=date(2026, 3, 26),
229 )
231 assert_that(classified).is_length(3)
232 assert_that(classified[0].status).is_equal_to(SuppressionStatus.ACTIVE)
233 assert_that(classified[1].status).is_equal_to(SuppressionStatus.STALE)
234 assert_that(classified[2].status).is_equal_to(SuppressionStatus.EXPIRED)
237@pytest.mark.parametrize(
238 ("ignore_until", "today", "in_probe", "expected"),
239 [
240 pytest.param(
241 date(2025, 1, 1),
242 date(2026, 1, 1),
243 True,
244 SuppressionStatus.EXPIRED,
245 id="expired_even_if_in_probe",
246 ),
247 pytest.param(
248 date(2025, 1, 1),
249 date(2026, 1, 1),
250 False,
251 SuppressionStatus.EXPIRED,
252 id="expired_and_not_in_probe",
253 ),
254 pytest.param(
255 date(2027, 1, 1),
256 date(2026, 1, 1),
257 True,
258 SuppressionStatus.ACTIVE,
259 id="future_and_in_probe",
260 ),
261 pytest.param(
262 date(2027, 1, 1),
263 date(2026, 1, 1),
264 False,
265 SuppressionStatus.STALE,
266 id="future_and_not_in_probe",
267 ),
268 ],
269)
270def test_classify_parametrized(
271 ignore_until: date,
272 today: date,
273 in_probe: bool,
274 expected: SuppressionStatus,
275) -> None:
276 """Parametrized classification covering all state combinations."""
277 entry = SuppressionEntry(id="GHSA-test", ignore_until=ignore_until, reason="test")
278 probe_ids = {"GHSA-test"} if in_probe else set()
280 classified = classify_suppressions([entry], probe_ids, today=today)
282 assert_that(classified[0].status).is_equal_to(expected)