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>
422 lines
14 KiB
Python
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 "<script>" 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
|