fieldwitness/tests/test_evidence_summary.py
Aaron D. Lee 16318daea3
Some checks failed
CI / lint (push) Failing after 12s
CI / typecheck (push) Failing after 12s
Add comprehensive test suite: integration tests + Playwright e2e
Integration tests (350 passing):
- test_evidence_summary.py: HTML/PDF generation, XSS safety, anchor rendering
- test_tor.py: Tor module unit tests (mocked, no Tor needed)
- test_c2pa_importer.py: Import result dataclass, trust evaluation, graceful degradation
- test_file_attestation.py: All file types (PNG, PDF, CSV, empty, large), determinism
- test_paths.py: Registry correctness, env var override, all paths under BASE_DIR
- test_killswitch_coverage.py: Tor keys, trusted keys, carrier history destruction

Playwright e2e infrastructure:
- tests/e2e/ with conftest (live server, auth fixtures), helpers (test file generators)
- test_auth.py: Setup flow, login/logout, protected routes
- test_attest.py: Image/PDF/CSV attestation, verify, attestation log
- test_dropbox.py: Token creation, source upload, branding check
- test_keys.py: Identity display, trust store
- test_fieldkit.py: Status dashboard, killswitch page
- test_navigation.py: All nav links, responsive layout

Run: pytest (unit/integration) or pytest -m e2e tests/e2e/ (browser)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:22:12 -04:00

422 lines
14 KiB
Python

"""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("<!DOCTYPE html>")
assert "</html>" 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 "<!DOCTYPE html>" 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 "<!DOCTYPE html>" 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 "<script" not in html.lower()
def test_script_in_filename_is_escaped(self):
manifest = _minimal_manifest()
manifest["attestation_records"][0]["filename"] = '<script>alert(1)</script>'
html = generate_html_summary(manifest)
assert "<script>" not in html
assert "&lt;script&gt;" in html
def test_script_in_investigation_is_escaped(self):
manifest = _minimal_manifest(investigation='"><script>alert(1)</script>')
html = generate_html_summary(manifest)
assert "<script>" not in html
def test_script_in_attestor_fingerprint_is_escaped(self):
manifest = _minimal_manifest()
manifest["attestation_records"][0]["attestor_fingerprint"] = (
'"><script>evil()</script>'
)
html = generate_html_summary(manifest)
assert "<script>" not in html
def test_script_in_anchor_digest_is_escaped(self):
manifest = _minimal_manifest(
anchors=[{"anchor": {"anchored_at": "2026-01-01T00:00:00Z",
"digest": '"><script>x()</script>'}}]
)
html = generate_html_summary(manifest)
assert "<script>" not in html