Skip to content

Commit 4cb709d

Browse files
devatsecureclaude
andcommitted
test: Add 7 test files covering 241 tests for previously untested modules
New coverage for: runtime_security_monitor (52), disclosure_generator (70), reachability_analyzer (27), decision_analyzer (25), chain_visualizer (24), enrichment_pipeline (22), consensus_builder (21). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6c93a18 commit 4cb709d

7 files changed

+3166
-0
lines changed
Lines changed: 389 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,389 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Unit Tests for Chain Visualizer
4+
5+
Tests cover:
6+
- ChainVisualizer initialization (color map)
7+
- generate_markdown_report
8+
- print_console_report
9+
- generate_ascii_graph
10+
- generate_json_summary
11+
- Helper methods (_color, _get_risk_color, _get_severity_color)
12+
- Edge cases (empty chains, missing fields)
13+
"""
14+
15+
import json
16+
import sys
17+
from pathlib import Path
18+
19+
import pytest
20+
21+
# Add scripts directory to path
22+
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "scripts"))
23+
24+
from chain_visualizer import ChainVisualizer
25+
26+
# ---------------------------------------------------------------------------
27+
# Fixtures
28+
# ---------------------------------------------------------------------------
29+
30+
31+
@pytest.fixture
32+
def visualizer():
33+
return ChainVisualizer()
34+
35+
36+
@pytest.fixture
37+
def sample_chains_data():
38+
return {
39+
"timestamp": "2026-01-01T00:00:00Z",
40+
"duration_seconds": 12.5,
41+
"total_vulnerabilities": 10,
42+
"total_chains": 2,
43+
"statistics": {
44+
"critical_chains": 1,
45+
"high_chains": 1,
46+
"avg_chain_length": 3.0,
47+
"avg_risk_score": 7.5,
48+
"max_risk_score": 9.2,
49+
"by_exploitability": {"trivial": 1, "moderate": 1},
50+
},
51+
"chains": [
52+
{
53+
"chain_id": "chain-001",
54+
"risk_score": 9.2,
55+
"exploitability": "trivial",
56+
"complexity": "low",
57+
"estimated_exploit_time": "1 hour",
58+
"base_risk": 7.0,
59+
"amplification_factor": 1.31,
60+
"chain_length": 3,
61+
"final_impact": "Full data breach via SQL injection chain",
62+
"mitigation_priority": 9,
63+
"vulnerabilities": [
64+
{
65+
"category": "SAST",
66+
"severity": "high",
67+
"file_path": "src/api/users.py",
68+
"title": "SQL injection in user query",
69+
},
70+
{
71+
"category": "SAST",
72+
"severity": "high",
73+
"file_path": "src/api/auth.py",
74+
"title": "Missing authentication check",
75+
},
76+
{
77+
"category": "SAST",
78+
"severity": "critical",
79+
"file_path": "src/db/connection.py",
80+
"title": "Unrestricted database access",
81+
},
82+
],
83+
},
84+
{
85+
"chain_id": "chain-002",
86+
"risk_score": 5.8,
87+
"exploitability": "moderate",
88+
"complexity": "medium",
89+
"estimated_exploit_time": "4 hours",
90+
"base_risk": 4.5,
91+
"amplification_factor": 1.29,
92+
"chain_length": 2,
93+
"final_impact": "XSS escalation to session hijack",
94+
"mitigation_priority": 6,
95+
"vulnerabilities": [
96+
{
97+
"category": "SAST",
98+
"severity": "medium",
99+
"file_path": "src/views/profile.py",
100+
"title": "Reflected XSS in profile page",
101+
},
102+
{
103+
"category": "SAST",
104+
"severity": "high",
105+
"file_path": "src/session/manager.py",
106+
"title": "Session cookie without HttpOnly",
107+
},
108+
],
109+
},
110+
],
111+
}
112+
113+
114+
# ---------------------------------------------------------------------------
115+
# Initialization
116+
# ---------------------------------------------------------------------------
117+
118+
119+
class TestChainVisualizerInit:
120+
"""Test ChainVisualizer initialization"""
121+
122+
def test_color_map_defined(self, visualizer):
123+
assert "critical" in visualizer.color_map
124+
assert "high" in visualizer.color_map
125+
assert "medium" in visualizer.color_map
126+
assert "low" in visualizer.color_map
127+
assert "info" in visualizer.color_map
128+
assert "reset" in visualizer.color_map
129+
130+
def test_color_map_has_ansi_codes(self, visualizer):
131+
for _key, value in visualizer.color_map.items():
132+
assert "\033[" in value
133+
134+
135+
# ---------------------------------------------------------------------------
136+
# Markdown Report
137+
# ---------------------------------------------------------------------------
138+
139+
140+
class TestMarkdownReport:
141+
"""Test generate_markdown_report"""
142+
143+
def test_creates_file(self, visualizer, sample_chains_data, tmp_path):
144+
output_file = tmp_path / "report.md"
145+
visualizer.generate_markdown_report(sample_chains_data, str(output_file))
146+
assert output_file.exists()
147+
148+
def test_contains_header(self, visualizer, sample_chains_data, tmp_path):
149+
output_file = tmp_path / "report.md"
150+
visualizer.generate_markdown_report(sample_chains_data, str(output_file))
151+
content = output_file.read_text()
152+
assert "Vulnerability Chaining Analysis Report" in content
153+
154+
def test_contains_statistics(self, visualizer, sample_chains_data, tmp_path):
155+
output_file = tmp_path / "report.md"
156+
visualizer.generate_markdown_report(sample_chains_data, str(output_file))
157+
content = output_file.read_text()
158+
assert "10" in content # total vulnerabilities
159+
assert "2" in content # total chains
160+
assert "9.2" in content # max risk score
161+
162+
def test_contains_chain_details(self, visualizer, sample_chains_data, tmp_path):
163+
output_file = tmp_path / "report.md"
164+
visualizer.generate_markdown_report(sample_chains_data, str(output_file))
165+
content = output_file.read_text()
166+
assert "Chain #1" in content
167+
assert "Chain #2" in content
168+
assert "SQL injection" in content
169+
assert "Full data breach" in content
170+
171+
def test_creates_parent_directories(self, visualizer, sample_chains_data, tmp_path):
172+
output_file = tmp_path / "subdir" / "nested" / "report.md"
173+
visualizer.generate_markdown_report(sample_chains_data, str(output_file))
174+
assert output_file.exists()
175+
176+
def test_limits_to_10_chains(self, visualizer, tmp_path):
177+
chains_data = {
178+
"timestamp": "2026-01-01T00:00:00Z",
179+
"duration_seconds": 1.0,
180+
"total_vulnerabilities": 50,
181+
"total_chains": 15,
182+
"statistics": {
183+
"critical_chains": 5,
184+
"high_chains": 5,
185+
"avg_chain_length": 2.0,
186+
"avg_risk_score": 5.0,
187+
"max_risk_score": 9.0,
188+
"by_exploitability": {},
189+
},
190+
"chains": [
191+
{
192+
"chain_id": f"chain-{i:03d}",
193+
"risk_score": 9.0 - i * 0.5,
194+
"exploitability": "moderate",
195+
"complexity": "medium",
196+
"base_risk": 5.0,
197+
"amplification_factor": 1.5,
198+
"chain_length": 2,
199+
"vulnerabilities": [
200+
{"category": "SAST", "severity": "high", "file_path": f"file{i}.py", "title": f"Vuln {i}"},
201+
],
202+
}
203+
for i in range(15)
204+
],
205+
}
206+
207+
output_file = tmp_path / "report.md"
208+
visualizer.generate_markdown_report(chains_data, str(output_file))
209+
content = output_file.read_text()
210+
# Should have chains 1 through 10, not 11+
211+
assert "Chain #10" in content
212+
assert "Chain #11" not in content
213+
214+
215+
# ---------------------------------------------------------------------------
216+
# Console Report
217+
# ---------------------------------------------------------------------------
218+
219+
220+
class TestConsoleReport:
221+
"""Test print_console_report"""
222+
223+
def test_prints_without_error(self, visualizer, sample_chains_data, capsys):
224+
visualizer.print_console_report(sample_chains_data, max_chains=2)
225+
captured = capsys.readouterr()
226+
assert "VULNERABILITY CHAINING ANALYSIS REPORT" in captured.out
227+
228+
def test_prints_statistics(self, visualizer, sample_chains_data, capsys):
229+
visualizer.print_console_report(sample_chains_data, max_chains=1)
230+
captured = capsys.readouterr()
231+
assert "Total Vulnerabilities: 10" in captured.out
232+
assert "Attack Chains Found: 2" in captured.out
233+
234+
def test_prints_chain_details(self, visualizer, sample_chains_data, capsys):
235+
visualizer.print_console_report(sample_chains_data, max_chains=1)
236+
captured = capsys.readouterr()
237+
assert "Chain #1" in captured.out
238+
assert "9.2" in captured.out
239+
240+
def test_respects_max_chains(self, visualizer, sample_chains_data, capsys):
241+
visualizer.print_console_report(sample_chains_data, max_chains=1)
242+
captured = capsys.readouterr()
243+
assert "Chain #1" in captured.out
244+
# Should NOT contain chain #2
245+
assert "Chain #2" not in captured.out
246+
247+
248+
# ---------------------------------------------------------------------------
249+
# ASCII Graph
250+
# ---------------------------------------------------------------------------
251+
252+
253+
class TestAsciiGraph:
254+
"""Test generate_ascii_graph"""
255+
256+
def test_generates_graph(self, visualizer, sample_chains_data):
257+
chain = sample_chains_data["chains"][0]
258+
graph = visualizer.generate_ascii_graph(chain)
259+
assert isinstance(graph, str)
260+
assert "Attack Chain" in graph
261+
assert "9.2" in graph
262+
263+
def test_graph_contains_vulnerabilities(self, visualizer, sample_chains_data):
264+
chain = sample_chains_data["chains"][0]
265+
graph = visualizer.generate_ascii_graph(chain)
266+
assert "SAST" in graph
267+
assert "HIGH" in graph or "CRITICAL" in graph
268+
269+
def test_graph_contains_impact(self, visualizer, sample_chains_data):
270+
chain = sample_chains_data["chains"][0]
271+
graph = visualizer.generate_ascii_graph(chain)
272+
assert "Full data breach" in graph
273+
274+
def test_graph_single_vulnerability(self, visualizer):
275+
chain = {
276+
"risk_score": 5.0,
277+
"vulnerabilities": [
278+
{"category": "SAST", "severity": "medium", "file_path": "test.py", "title": "Issue"},
279+
],
280+
"final_impact": "Minor impact",
281+
}
282+
graph = visualizer.generate_ascii_graph(chain)
283+
assert "MEDIUM" in graph
284+
assert "Minor impact" in graph
285+
286+
def test_graph_no_final_impact(self, visualizer):
287+
chain = {
288+
"risk_score": 3.0,
289+
"vulnerabilities": [
290+
{"category": "SAST", "severity": "low", "file_path": "test.py", "title": "Issue"},
291+
],
292+
}
293+
graph = visualizer.generate_ascii_graph(chain)
294+
assert "High Impact" in graph # Default fallback
295+
296+
297+
# ---------------------------------------------------------------------------
298+
# JSON Summary
299+
# ---------------------------------------------------------------------------
300+
301+
302+
class TestJsonSummary:
303+
"""Test generate_json_summary"""
304+
305+
def test_creates_file(self, visualizer, sample_chains_data, tmp_path):
306+
output_file = tmp_path / "summary.json"
307+
visualizer.generate_json_summary(sample_chains_data, str(output_file))
308+
assert output_file.exists()
309+
310+
def test_valid_json(self, visualizer, sample_chains_data, tmp_path):
311+
output_file = tmp_path / "summary.json"
312+
visualizer.generate_json_summary(sample_chains_data, str(output_file))
313+
data = json.loads(output_file.read_text())
314+
assert isinstance(data, dict)
315+
316+
def test_contains_metadata(self, visualizer, sample_chains_data, tmp_path):
317+
output_file = tmp_path / "summary.json"
318+
visualizer.generate_json_summary(sample_chains_data, str(output_file))
319+
data = json.loads(output_file.read_text())
320+
assert "metadata" in data
321+
assert data["metadata"]["timestamp"] == "2026-01-01T00:00:00Z"
322+
323+
def test_contains_summary(self, visualizer, sample_chains_data, tmp_path):
324+
output_file = tmp_path / "summary.json"
325+
visualizer.generate_json_summary(sample_chains_data, str(output_file))
326+
data = json.loads(output_file.read_text())
327+
assert data["summary"]["total_vulnerabilities"] == 10
328+
assert data["summary"]["total_chains"] == 2
329+
assert data["summary"]["critical_chains"] == 1
330+
331+
def test_contains_top_chains(self, visualizer, sample_chains_data, tmp_path):
332+
output_file = tmp_path / "summary.json"
333+
visualizer.generate_json_summary(sample_chains_data, str(output_file))
334+
data = json.loads(output_file.read_text())
335+
assert len(data["top_chains"]) == 2
336+
assert data["top_chains"][0]["chain_id"] == "chain-001"
337+
assert data["top_chains"][0]["risk_score"] == 9.2
338+
339+
def test_creates_parent_directories(self, visualizer, sample_chains_data, tmp_path):
340+
output_file = tmp_path / "deep" / "nested" / "summary.json"
341+
visualizer.generate_json_summary(sample_chains_data, str(output_file))
342+
assert output_file.exists()
343+
344+
345+
# ---------------------------------------------------------------------------
346+
# Helper Methods
347+
# ---------------------------------------------------------------------------
348+
349+
350+
class TestHelperMethods:
351+
"""Test _color, _get_risk_color, _get_severity_color"""
352+
353+
def test_color_with_known_severity(self, visualizer):
354+
result = visualizer._color("critical", "42")
355+
assert "42" in result
356+
assert "\033[" in result
357+
358+
def test_color_with_unknown_severity(self, visualizer):
359+
result = visualizer._color("unknown", "text")
360+
assert "text" in result
361+
362+
def test_get_risk_color_critical(self, visualizer):
363+
color = visualizer._get_risk_color(9.5)
364+
assert color == visualizer.color_map["critical"]
365+
366+
def test_get_risk_color_high(self, visualizer):
367+
color = visualizer._get_risk_color(7.5)
368+
assert color == visualizer.color_map["high"]
369+
370+
def test_get_risk_color_medium(self, visualizer):
371+
color = visualizer._get_risk_color(5.5)
372+
assert color == visualizer.color_map["medium"]
373+
374+
def test_get_risk_color_low(self, visualizer):
375+
color = visualizer._get_risk_color(3.0)
376+
assert color == visualizer.color_map["low"]
377+
378+
def test_get_severity_color_known(self, visualizer):
379+
assert visualizer._get_severity_color("critical") == visualizer.color_map["critical"]
380+
assert visualizer._get_severity_color("high") == visualizer.color_map["high"]
381+
assert visualizer._get_severity_color("MEDIUM") == visualizer.color_map["medium"]
382+
383+
def test_get_severity_color_unknown(self, visualizer):
384+
result = visualizer._get_severity_color("unknown")
385+
assert result == visualizer.color_map["reset"]
386+
387+
388+
if __name__ == "__main__":
389+
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)