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>
296 lines
10 KiB
Python
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
|