fieldwitness/tests/e2e/test_attest.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

296 lines
10 KiB
Python

"""
e2e tests for the attestation and verification pages.
Each test that needs to attest a file first ensures an identity key exists by
navigating to /keys and generating one. That step is idempotent — the server
silently ignores a second generate request if a key already exists.
Terminology used in comments
-----------------------------
- attest form: <form method="POST" enctype="multipart/form-data"> at /attest
- file input name: "image" (the field name in the HTML, even for non-images)
- optional text inputs: "caption", "location_name"
- verify form: same structure at /verify
- file input name: "image"
"""
from __future__ import annotations
import io
from pathlib import Path
import pytest
from playwright.sync_api import Page, expect
from tests.e2e.helpers import create_test_csv, create_test_image, create_test_pdf, create_test_txt
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _ensure_identity(page: Page, live_server: str) -> None:
"""Generate an Ed25519 identity if one does not already exist."""
page.goto(f"{live_server}/keys/")
page.wait_for_load_state("networkidle")
# If the "Generate Identity" button is visible the key is missing; click it.
gen_button = page.locator("form[action*='generate_identity'] button")
if gen_button.count() > 0:
gen_button.click()
page.wait_for_load_state("networkidle")
def _attest_bytes(
page: Page,
live_server: str,
file_bytes: bytes,
filename: str,
caption: str = "",
) -> None:
"""Upload *file_bytes* as *filename* via the /attest form."""
page.goto(f"{live_server}/attest")
page.wait_for_load_state("networkidle")
page.set_input_files(
"input[name='image']",
files=[{"name": filename, "mimeType": _mime(filename), "buffer": file_bytes}],
)
if caption:
page.fill("input[name='caption']", caption)
page.click("button[type='submit']")
page.wait_for_load_state("networkidle")
def _mime(filename: str) -> str:
ext = filename.rsplit(".", 1)[-1].lower()
return {
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"pdf": "application/pdf",
"csv": "text/csv",
"txt": "text/plain",
}.get(ext, "application/octet-stream")
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.e2e
def test_attest_page_loads(live_server: str, authenticated_page: Page) -> None:
"""The /attest page renders the file upload form."""
page = authenticated_page
page.goto(f"{live_server}/attest")
page.wait_for_load_state("networkidle")
expect(page.locator("input[name='image']")).to_be_visible()
expect(page.locator("button[type='submit']")).to_be_visible()
@pytest.mark.e2e
def test_attest_image_file(live_server: str, authenticated_page: Page) -> None:
"""Attesting a PNG image shows the success result page with all hash fields."""
page = authenticated_page
_ensure_identity(page, live_server)
img_bytes = create_test_image(64, 64)
_attest_bytes(page, live_server, img_bytes, "test_capture.png", caption="e2e test image")
# Result page has the success alert
expect(page.locator(".alert-success")).to_contain_text("Attestation created successfully")
# Record ID and SHA-256 must be present
expect(page.locator("body")).to_contain_text("Record ID")
expect(page.locator("body")).to_contain_text("SHA-256")
# Caption was saved
expect(page.locator("body")).to_contain_text("e2e test image")
@pytest.mark.e2e
def test_attest_pdf_file(live_server: str, authenticated_page: Page) -> None:
"""Attesting a PDF succeeds and the result page notes SHA-256-only (no perceptual hashes)."""
page = authenticated_page
_ensure_identity(page, live_server)
pdf_bytes = create_test_pdf()
_attest_bytes(page, live_server, pdf_bytes, "evidence.pdf")
expect(page.locator(".alert-success")).to_contain_text("Attestation created successfully")
# Non-image attestation note must appear
expect(page.locator(".alert-info")).to_contain_text("cryptographic hash")
# Perceptual hash fields must NOT appear for PDFs
expect(page.locator("body")).not_to_contain_text("pHash")
@pytest.mark.e2e
def test_attest_csv_file(live_server: str, authenticated_page: Page) -> None:
"""Attesting a CSV file succeeds."""
page = authenticated_page
_ensure_identity(page, live_server)
csv_bytes = create_test_csv()
_attest_bytes(page, live_server, csv_bytes, "sensor_data.csv")
expect(page.locator(".alert-success")).to_contain_text("Attestation created successfully")
@pytest.mark.e2e
def test_attest_requires_identity(live_server: str, authenticated_page: Page) -> None:
"""The submit button is disabled when no identity key is configured.
NOTE: This test only checks the rendered HTML state. We do not actually
delete the identity key — that would break subsequent tests in the session.
Instead we verify the template logic: the template disables the button and
shows a warning when has_identity is False.
We observe the button state based on whether an identity was just generated.
"""
page = authenticated_page
page.goto(f"{live_server}/attest")
page.wait_for_load_state("networkidle")
# If identity is absent, a warning alert should be visible and the button disabled.
# If identity is present, the button is enabled.
submit = page.locator("button[type='submit']")
warning = page.locator(".alert-warning")
if warning.count() > 0:
# No identity — button must be disabled
expect(submit).to_be_disabled()
else:
# Identity present — button must be enabled
expect(submit).to_be_enabled()
@pytest.mark.e2e
def test_verify_attested_file(live_server: str, authenticated_page: Page) -> None:
"""Attest a file then immediately verify it — verification must succeed."""
page = authenticated_page
_ensure_identity(page, live_server)
img_bytes = create_test_image(80, 80)
filename = "verify_me.png"
# Attest
_attest_bytes(page, live_server, img_bytes, filename)
expect(page.locator(".alert-success")).to_contain_text("Attestation created successfully")
# Verify the same bytes
page.goto(f"{live_server}/verify")
page.wait_for_load_state("networkidle")
page.set_input_files(
"input[name='image']",
files=[{"name": filename, "mimeType": "image/png", "buffer": img_bytes}],
)
page.click("button[type='submit']")
page.wait_for_load_state("networkidle")
# Verification result must show a match
expect(page.locator(".alert-success")).to_contain_text("matching attestation")
@pytest.mark.e2e
def test_verify_tampered_file(live_server: str, authenticated_page: Page) -> None:
"""A file modified after attestation must not verify (no matching attestation)."""
page = authenticated_page
_ensure_identity(page, live_server)
original_bytes = create_test_image(90, 90)
# Attest the original
_attest_bytes(page, live_server, original_bytes, "tampered.png")
expect(page.locator(".alert-success")).to_contain_text("Attestation created successfully")
# Tamper: flip a single byte near the end of the image data
tampered = bytearray(original_bytes)
tampered[-50] ^= 0xFF
tampered_bytes = bytes(tampered)
# Verify the tampered version — must not match
page.goto(f"{live_server}/verify")
page.wait_for_load_state("networkidle")
page.set_input_files(
"input[name='image']",
files=[{"name": "tampered.png", "mimeType": "image/png", "buffer": tampered_bytes}],
)
page.click("button[type='submit']")
page.wait_for_load_state("networkidle")
# Warning = no match found (the alert-warning class is used for "not found")
# OR the alert-success is absent
success_alert = page.locator(".alert-success")
warning_alert = page.locator(".alert-warning")
assert warning_alert.count() > 0 or success_alert.count() == 0, (
"Tampered file incorrectly verified as matching"
)
@pytest.mark.e2e
def test_attestation_log(live_server: str, authenticated_page: Page) -> None:
"""After attesting multiple files the /attest/log page lists them all."""
page = authenticated_page
_ensure_identity(page, live_server)
# Attest three distinct files
for i in range(3):
img = create_test_image(32 + i * 8, 32 + i * 8)
_attest_bytes(page, live_server, img, f"log_test_{i}.png", caption=f"log entry {i}")
expect(page.locator(".alert-success")).to_contain_text("Attestation created successfully")
# Check the log page
page.goto(f"{live_server}/attest/log")
page.wait_for_load_state("networkidle")
# The log should contain at least 3 rows (may have more from other tests)
rows = page.locator("table tbody tr")
assert rows.count() >= 3, (
f"Expected at least 3 rows in attestation log, got {rows.count()}"
)
@pytest.mark.e2e
def test_batch_attest(live_server: str, authenticated_page: Page) -> None:
"""The /attest/batch JSON endpoint accepts multiple files and returns results."""
import json
page = authenticated_page
_ensure_identity(page, live_server)
# Use the fetch API to POST two files to the batch endpoint
img1 = create_test_image(48, 48)
img2 = create_test_image(56, 56)
# Encode images as base64 for transfer to the browser context
import base64
img1_b64 = base64.b64encode(img1).decode()
img2_b64 = base64.b64encode(img2).decode()
result = page.evaluate(
"""async ([img1_b64, img2_b64]) => {
const b64 = (s) => Uint8Array.from(atob(s), c => c.charCodeAt(0));
const form = new FormData();
form.append('images', new File([b64(img1_b64)], 'batch1.png', {type: 'image/png'}));
form.append('images', new File([b64(img2_b64)], 'batch2.png', {type: 'image/png'}));
const resp = await fetch('/attest/batch', {method: 'POST', body: form});
return await resp.json();
}""",
[img1_b64, img2_b64],
)
assert result.get("total") == 2, f"Unexpected batch result: {result}"
assert result.get("errors") == 0, f"Batch had errors: {result}"
assert result.get("attested", 0) + result.get("skipped", 0) == 2