"""Tests for building and encrypting the viewer PDF report.""" from __future__ import annotations import json from io import BytesIO from itertools import product from typing import TYPE_CHECKING import pytest from pypdf import PdfReader from pypdf.errors import WrongPasswordError from reportlab.lib.styles import ParagraphStyle from reportlab.platypus import Paragraph from strix.interface.viewer.report_pdf import ( _duration, _inline_md, _normalize_severity, build_encrypted_report, encrypt_pdf, generate_password, generate_report_pdf, ) from strix.report.triage import read_triaged_vulnerabilities, triage_finding if TYPE_CHECKING: from pathlib import Path def _make_run(base: Path, name: str = "sample") -> Path: run_dir = base / "strix_runs" / name run_dir.mkdir(parents=True) record = { "run_name": name, "targets_info": [{"original": "https://example.com"}], "scan_mode": "deep", "status": "completed", "start_time": "2026-01-01T00:00:00Z", "end_time": "2026-01-01T01:02:03Z", "scan_results": { "executive_summary": "Summary with an ampersand & an bracket.", "recommendations": "Patch things.", }, } (run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8") vulns = [ { "id": "vuln-0001", "title": "SQL Injection", "severity": "CRITICAL", "cvss": 9.8, "description": "User input reaches the query.", "impact": "Full database read.", "technical_analysis": "Details here.", "poc_description": "Send a crafted parameter.", "poc_script_code": "print('exploit')", "evidence": "HTTP 500 with SQL error.", "remediation_steps": ["Use parameterized queries", "Validate input"], "target": "https://example.com", "endpoint": "/login", "method": "POST", }, {"id": "vuln-0002", "title": "Informational note", "severity": "info"}, ] (run_dir / "vulnerabilities.json").write_text(json.dumps(vulns), encoding="utf-8") return run_dir def _pdf_text(pdf: bytes) -> str: return "\n".join(page.extract_text() or "" for page in PdfReader(BytesIO(pdf)).pages) def test_generate_report_pdf_has_pdf_header(tmp_path: Path) -> None: run_dir = _make_run(tmp_path) pdf = generate_report_pdf(run_dir) assert pdf.startswith(b"%PDF-") assert len(pdf) > 1000 def test_generate_report_pdf_labels_original_scan_and_detected_counts(tmp_path: Path) -> None: run_dir = _make_run(tmp_path) pdf = generate_report_pdf(run_dir) reader = PdfReader(BytesIO(pdf)) cover = reader.pages[0].extract_text() text = " ".join(_pdf_text(pdf).split()) assert "Original scan report" in cover assert "Subsequent local triage decisions" in cover assert "false-positive closures and notes, are excluded." in " ".join(cover.split()) assert "2 detected findings across this assessment." in text assert "Detected findings" in text assert reader.metadata is not None assert reader.metadata.title == "Strix Original Scan Report" def test_original_report_preserves_closed_findings_and_excludes_triage_notes( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr("strix.telemetry.triage.record_triage", lambda *_args, **_kwargs: None) run_dir = _make_run(tmp_path) original = _pdf_text(generate_report_pdf(run_dir)) for finding in read_triaged_vulnerabilities(run_dir): triage_finding( run_dir, finding["id"], status="closed", expected_revision=finding["triage_revision"], reviewed_digest=finding["finding_digest"], reason_code="incorrect_assumption", note="Private local review context must stay out of the original report.", surface="viewer", ) assert all(finding["status"] == "closed" for finding in read_triaged_vulnerabilities(run_dir)) exported = _pdf_text(generate_report_pdf(run_dir)) assert exported == original assert "2 detected findings" in exported assert "SQL Injection" in exported assert "HTTP 500 with SQL error." in exported assert "Informational note" in exported assert "Private local review context" not in exported def test_generate_password_is_long_and_random() -> None: first = generate_password() second = generate_password() assert len(first) >= 20 assert first != second def test_encrypt_pdf_roundtrip(tmp_path: Path) -> None: run_dir = _make_run(tmp_path) pdf = generate_report_pdf(run_dir) password = generate_password() encrypted = encrypt_pdf(pdf, password) reader = PdfReader(BytesIO(encrypted)) assert reader.is_encrypted assert reader.decrypt(password) # A correct password unlocks the pages. assert len(reader.pages) >= 1 def test_wrong_password_is_rejected(tmp_path: Path) -> None: run_dir = _make_run(tmp_path) encrypted = encrypt_pdf(generate_report_pdf(run_dir), "correct-horse-battery") with pytest.raises(WrongPasswordError): PdfReader(BytesIO(encrypted), password="not-the-password") # nosec B106 def test_build_encrypted_report(tmp_path: Path) -> None: run_dir = _make_run(tmp_path, name="run-42") pdf_bytes, password, filename = build_encrypted_report(run_dir) assert filename == "strix-original-scan-report-run-42.pdf" assert len(password) >= 20 reader = PdfReader(BytesIO(pdf_bytes)) assert reader.is_encrypted assert reader.decrypt(password) @pytest.mark.parametrize( ("text", "expected"), [ ("**bold**", "bold"), ("__bold__", "bold"), ("*italic*", "italic"), ("***both***", "both"), ("**bold with *italic* inside**", "bold with italic inside"), ("*outer **bold** inner*", "outer bold inner"), (r"\*literal\*", "*literal*"), ("******", "******"), ("`a * < &`", 'a * < &'), ( "![alt](https://example.invalid/image.png)", "![alt](https://example.invalid/image.png)", ), ("", "<https://example.invalid>"), ], ) def test_inline_md_emits_only_safe_balanced_markup(text: str, expected: str) -> None: markup = _inline_md(text) assert markup == expected Paragraph(markup, ParagraphStyle("test")) @pytest.mark.parametrize( "text", [ "*a **b* c**", "**a *b** c*", "*outer **inner* end**", "__a *b__ c*", "***__***__", "__***__***", "", "x", "", "\x000\x00 `code` \x0099\x00", "\ud800", ], ) def test_inline_md_survives_malformed_external_text(text: str) -> None: markup = _inline_md(text) assert "\x00" not in markup assert "\ud800" not in markup Paragraph(markup, ParagraphStyle("test")) def test_inline_md_generated_corpus_never_breaks_reportlab() -> None: style = ParagraphStyle("test") for length in range(1, 6): for chars in product("*_`a ", repeat=length): Paragraph(_inline_md("".join(chars)), style) def test_generate_report_pdf_survives_hostile_run_fields(tmp_path: Path) -> None: run_dir = _make_run(tmp_path) hostile = "****** \x000\x00 \ud800" record = json.loads((run_dir / "run.json").read_text(encoding="utf-8")) record.update( { "run_name": hostile, "targets_info": [{"original": hostile}], "scan_mode": hostile, "status": hostile, "start_time": hostile, "end_time": hostile, "scan_results": { "executive_summary": hostile, "methodology": hostile, "technical_analysis": hostile, "recommendations": hostile, }, } ) (run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8") text = _pdf_text(generate_report_pdf(run_dir)) assert "******" in text assert "" in text assert "" in text def test_generate_report_pdf_survives_hostile_finding_fields(tmp_path: Path) -> None: run_dir = _make_run(tmp_path) hostile = "****** \x000\x00 \ud800" vulnerability = { "title": hostile, "severity": hostile, "cvss": hostile, "description": hostile, "impact": hostile, "technical_analysis": hostile, "poc_description": hostile, "poc_script_code": hostile, "evidence": hostile, "remediation_steps": [hostile], "target": hostile, "endpoint": hostile, "method": hostile, } (run_dir / "vulnerabilities.json").write_text(json.dumps([vulnerability]), encoding="utf-8") text = _pdf_text(generate_report_pdf(run_dir)) assert "******" in text assert "" in text assert "" in text assert text.count("LOW") == 2 # severity grid label plus canonicalized finding badge @pytest.mark.parametrize( ("value", "expected"), [ ("CRITICAL", "critical"), (" info ", "info"), ("informational", "info"), ("", "low"), ({"severity": "critical"}, "low"), (None, "low"), ], ) def test_normalize_severity_restricts_badge_markup(value: object, expected: str) -> None: assert _normalize_severity(value) == expected def test_duration_rejects_mixed_timezone_awareness() -> None: assert _duration("2026-01-01T00:00:00", "2026-01-01T01:00:00Z") == "n/a"