"""Integration tests for evidence_summary.py — HTML/PDF summary generation.""" from __future__ import annotations from typing import Any import pytest from fieldwitness.evidence_summary import build_summaries, generate_html_summary # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _minimal_manifest(**overrides: Any) -> dict[str, Any]: """Return a minimal manifest dict with sensible defaults.""" base: dict[str, Any] = { "exported_at": "2026-03-15T10:00:00+00:00", "investigation": "test-investigation", "attestation_records": [ { "filename": "photo.jpg", "file_size": "1.2 MB", "sha256": "a" * 64, "attestor_fingerprint": "dead" * 8, "timestamp": "2026-03-15T09:30:00+00:00", "image_hashes": { "sha256": "a" * 64, }, } ], "chain_records": [ { "chain_index": 7, "record_hash": "b" * 64, } ], "anchors": [], } base.update(overrides) return base # --------------------------------------------------------------------------- # test_generate_html_summary_basic # --------------------------------------------------------------------------- class TestGenerateHtmlSummaryBasic: def test_returns_complete_html_document(self): manifest = _minimal_manifest() html = generate_html_summary(manifest) assert html.startswith("") assert "" in html def test_contains_file_information_section(self): manifest = _minimal_manifest() html = generate_html_summary(manifest) assert "File Information" in html def test_contains_attestation_details_section(self): manifest = _minimal_manifest() html = generate_html_summary(manifest) assert "Attestation Details" in html def test_contains_chain_position_section(self): manifest = _minimal_manifest() html = generate_html_summary(manifest) assert "Chain Position" in html def test_filename_appears_in_output(self): manifest = _minimal_manifest() html = generate_html_summary(manifest) assert "photo.jpg" in html def test_investigation_label_appears(self): manifest = _minimal_manifest() html = generate_html_summary(manifest) assert "test-investigation" in html def test_sha256_abbreviated_appears(self): manifest = _minimal_manifest() html = generate_html_summary(manifest) # The abbreviated form should be present (first 16 chars of "a"*64) assert "aaaaaaaaaaaaaaaa" in html def test_chain_index_appears(self): manifest = _minimal_manifest() html = generate_html_summary(manifest) assert "7" in html def test_verification_instructions_present(self): manifest = _minimal_manifest() html = generate_html_summary(manifest) assert "verify.py" in html assert "python verify.py" in html def test_version_in_title(self): html = generate_html_summary(_minimal_manifest(), version="0.3.0") assert "0.3.0" in html def test_empty_manifest_does_not_raise(self): """An entirely empty manifest must not raise — all fields have fallbacks.""" html = generate_html_summary({}) assert "" in html # --------------------------------------------------------------------------- # test_generate_html_summary_with_anchors # --------------------------------------------------------------------------- class TestGenerateHtmlSummaryWithAnchors: def test_anchor_section_title_present(self): manifest = _minimal_manifest( anchors=[ { "anchor": { "anchored_at": "2026-03-15T11:00:00+00:00", "digest": "c" * 64, } } ] ) html = generate_html_summary(manifest) assert "RFC 3161" in html def test_anchor_timestamp_renders(self): manifest = _minimal_manifest( anchors=[ { "anchor": { "anchored_at": "2026-03-15T11:00:00+00:00", "digest": "c" * 64, } } ] ) html = generate_html_summary(manifest) # The timestamp should appear in some form (machine or human readable) assert "2026-03-15" in html def test_anchor_digest_renders(self): manifest = _minimal_manifest( anchors=[ { "anchor": { "anchored_at": "2026-03-15T11:00:00+00:00", "digest": "c" * 64, } } ] ) html = generate_html_summary(manifest) assert "c" * 16 in html # at least the abbreviated form def test_multiple_anchors_all_labeled(self): manifest = _minimal_manifest( anchors=[ {"anchor": {"anchored_at": "2026-03-15T11:00:00+00:00", "digest": "d" * 64}}, {"anchor": {"anchored_at": "2026-03-16T09:00:00+00:00", "digest": "e" * 64}}, ] ) html = generate_html_summary(manifest) assert "Anchor 1" in html assert "Anchor 2" in html def test_no_anchors_shows_none_recorded(self): manifest = _minimal_manifest(anchors=[]) html = generate_html_summary(manifest) assert "None recorded" in html # --------------------------------------------------------------------------- # test_generate_html_summary_with_perceptual_hashes # --------------------------------------------------------------------------- class TestGenerateHtmlSummaryWithPerceptualHashes: def test_perceptual_hash_section_present_when_phash_set(self): manifest = _minimal_manifest() manifest["attestation_records"][0]["image_hashes"]["phash"] = "f" * 16 manifest["attestation_records"][0]["image_hashes"]["dhash"] = "0" * 16 html = generate_html_summary(manifest) assert "Perceptual Hashes" in html def test_phash_value_renders(self): manifest = _minimal_manifest() manifest["attestation_records"][0]["image_hashes"]["phash"] = "aabbccdd11223344" html = generate_html_summary(manifest) assert "aabbccdd11223344" in html def test_dhash_value_renders(self): manifest = _minimal_manifest() manifest["attestation_records"][0]["image_hashes"]["phash"] = "1234" * 4 manifest["attestation_records"][0]["image_hashes"]["dhash"] = "5678" * 4 html = generate_html_summary(manifest) assert "5678" * 4 in html def test_perceptual_hash_note_present(self): manifest = _minimal_manifest() manifest["attestation_records"][0]["image_hashes"]["phash"] = "f" * 16 html = generate_html_summary(manifest) assert "format conversion" in html or "mild compression" in html # --------------------------------------------------------------------------- # test_generate_html_summary_no_perceptual_hashes # --------------------------------------------------------------------------- class TestGenerateHtmlSummaryNoPerceptualHashes: def test_perceptual_hash_section_absent_for_non_image(self): """The Perceptual Hashes section must not appear when phash and dhash are absent. generate_html_summary gates the entire section on ``if phash or dhash``, so for non-image files the section is simply omitted — it does not render a 'Not applicable' placeholder. """ manifest = _minimal_manifest() manifest["attestation_records"][0]["image_hashes"] = {"sha256": "a" * 64} html = generate_html_summary(manifest) assert "Perceptual Hashes" not in html def test_empty_string_hashes_omit_section(self): """Empty-string phash/dhash must be treated the same as missing keys — section absent.""" manifest = _minimal_manifest() manifest["attestation_records"][0]["image_hashes"]["phash"] = "" manifest["attestation_records"][0]["image_hashes"]["dhash"] = "" html = generate_html_summary(manifest) assert "Perceptual Hashes" not in html def test_sha256_still_shown_without_perceptual_hashes(self): """SHA-256 must still appear in File Information even without perceptual hashes.""" manifest = _minimal_manifest() manifest["attestation_records"][0]["image_hashes"] = {"sha256": "a" * 64} html = generate_html_summary(manifest) # The abbreviated SHA-256 appears in the File Information section. assert "aaaaaaaaaaaaaaaa" in html # --------------------------------------------------------------------------- # test_generate_html_summary_multiple_records # --------------------------------------------------------------------------- class TestGenerateHtmlSummaryMultipleRecords: def test_multi_record_note_appears(self): second_record = { "filename": "photo2.jpg", "file_size": "800 KB", "sha256": "b" * 64, "attestor_fingerprint": "cafe" * 8, "timestamp": "2026-03-15T10:00:00+00:00", "image_hashes": {"sha256": "b" * 64}, } manifest = _minimal_manifest() manifest["attestation_records"].append(second_record) html = generate_html_summary(manifest) assert "2 attested file" in html def test_multi_record_refers_to_manifest_json(self): second = { "filename": "doc.pdf", "sha256": "c" * 64, "attestor_fingerprint": "beef" * 8, "timestamp": "2026-03-15T10:30:00+00:00", "image_hashes": {"sha256": "c" * 64}, } manifest = _minimal_manifest() manifest["attestation_records"].append(second) html = generate_html_summary(manifest) assert "manifest.json" in html def test_first_record_details_are_shown(self): """With multiple records, the first record's filename appears in File Information.""" second = { "filename": "other.jpg", "sha256": "d" * 64, "attestor_fingerprint": "0000" * 8, "timestamp": "2026-03-15T11:00:00+00:00", "image_hashes": {"sha256": "d" * 64}, } manifest = _minimal_manifest() manifest["attestation_records"].append(second) html = generate_html_summary(manifest) # First record's filename must be present assert "photo.jpg" in html def test_single_record_has_no_multi_note(self): manifest = _minimal_manifest() assert len(manifest["attestation_records"]) == 1 html = generate_html_summary(manifest) assert "attested file" not in html # --------------------------------------------------------------------------- # test_build_summaries_returns_html # --------------------------------------------------------------------------- class TestBuildSummaries: def test_always_returns_summary_html(self): manifest = _minimal_manifest() result = build_summaries(manifest) assert "summary.html" in result assert isinstance(result["summary.html"], bytes) def test_html_is_valid_utf8(self): manifest = _minimal_manifest() result = build_summaries(manifest) # Must decode without error decoded = result["summary.html"].decode("utf-8") assert "" in decoded def test_pdf_returned_when_xhtml2pdf_available(self): """If xhtml2pdf is installed, summary.pdf must be in the result.""" try: import xhtml2pdf # noqa: F401 except ImportError: pytest.skip("xhtml2pdf not installed") manifest = _minimal_manifest() result = build_summaries(manifest) assert "summary.pdf" in result assert isinstance(result["summary.pdf"], bytes) assert len(result["summary.pdf"]) > 0 def test_no_pdf_when_xhtml2pdf_absent(self, monkeypatch: pytest.MonkeyPatch): """When xhtml2pdf is not importable, summary.pdf must be absent.""" import builtins real_import = builtins.__import__ def mock_import(name, *args, **kwargs): if name == "xhtml2pdf": raise ImportError("forced absence") return real_import(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", mock_import) manifest = _minimal_manifest() result = build_summaries(manifest) assert "summary.pdf" not in result assert "summary.html" in result # --------------------------------------------------------------------------- # test_html_summary_contains_no_script_tags — security # --------------------------------------------------------------------------- class TestHtmlSummarySecurityNoScriptInjection: def test_no_script_tags_from_normal_manifest(self): html = generate_html_summary(_minimal_manifest()) assert "" not in html assert "<script>" in html def test_script_in_investigation_is_escaped(self): manifest = _minimal_manifest(investigation='">') html = generate_html_summary(manifest) assert "' ) html = generate_html_summary(manifest) assert "'}}] ) html = generate_html_summary(manifest) assert "