Add comprehensive test suite: integration tests + Playwright e2e
Some checks failed
CI / lint (push) Failing after 12s
CI / typecheck (push) Failing after 12s

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>
This commit is contained in:
Aaron D. Lee
2026-04-02 20:22:12 -04:00
parent 5b0d90eeaf
commit 16318daea3
16 changed files with 2930 additions and 0 deletions

1
tests/e2e/__init__.py Normal file
View File

@@ -0,0 +1 @@
# e2e test package

218
tests/e2e/conftest.py Normal file
View File

@@ -0,0 +1,218 @@
"""
Playwright e2e test fixtures for the FieldWitness Flask web UI.
Isolation strategy
------------------
- Each test session uses a fresh tmp_path via the `live_server` fixture.
- `FIELDWITNESS_DATA_DIR` is set in the OS environment before the Flask app
factory runs, so paths.py picks it up through its lazy `__getattr__`.
- The app is never imported at module level here — it is imported inside the
fixture function *after* the env var is set.
- The Flask dev server is run in a daemon thread with use_reloader=False so
the test process remains the controller.
"""
from __future__ import annotations
import os
import socket
import threading
import time
from pathlib import Path
from typing import Generator
import pytest
from playwright.sync_api import Page
# ---------------------------------------------------------------------------
# Constants shared across fixtures
# ---------------------------------------------------------------------------
TEST_ADMIN_USER = "testadmin"
TEST_ADMIN_PASS = "Fieldwitness-e2e-2024!"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _find_free_port() -> int:
"""Return a free TCP port on localhost."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _wait_for_server(base_url: str, timeout: float = 10.0) -> None:
"""Poll until the server responds or timeout is reached."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
import urllib.request
urllib.request.urlopen(f"{base_url}/health", timeout=1)
return
except Exception:
time.sleep(0.1)
raise RuntimeError(f"Server at {base_url} did not start within {timeout}s")
# ---------------------------------------------------------------------------
# Session-scoped fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def e2e_data_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""A single data directory for the entire test session.
Using session scope means one Flask app instance serves all tests,
matching how Playwright typically works (one browser, many pages).
"""
return tmp_path_factory.mktemp("fieldwitness_e2e")
@pytest.fixture(scope="session")
def live_server(e2e_data_dir: Path) -> Generator[str, None, None]:
"""Start the Flask app on a random port, yield the base URL.
The server runs in a daemon thread so it dies when the test process exits.
Path isolation strategy
-----------------------
fieldwitness.paths.BASE_DIR is a module-level Path computed once at import
time from FIELDWITNESS_DATA_DIR. By the time conftest runs, fieldwitness is
already imported (pytest imports it for coverage), so setting the env var
is too late. Instead we directly patch `fieldwitness.paths.BASE_DIR` for
the duration of the test session; the module-level __getattr__ then resolves
every derived path (IDENTITY_DIR, AUTH_DB, …) from that patched BASE_DIR.
"""
data_dir = e2e_data_dir / ".fieldwitness"
data_dir.mkdir(parents=True, exist_ok=True)
# Patch BASE_DIR so all lazy path resolution uses our temp directory
import fieldwitness.paths as _paths
original_base_dir = _paths.BASE_DIR
_paths.BASE_DIR = data_dir
# Also set the env var so any sub-process or re-import gets the right dir
os.environ["FIELDWITNESS_DATA_DIR"] = str(data_dir)
port = _find_free_port()
from fieldwitness.config import FieldWitnessConfig
from frontends.web.app import create_app
config = FieldWitnessConfig(
https_enabled=False,
auth_enabled=True,
deadman_enabled=False,
killswitch_enabled=False,
chain_enabled=False,
chain_auto_wrap=False,
max_upload_mb=16,
session_timeout_minutes=60,
login_lockout_attempts=10,
login_lockout_minutes=1,
)
flask_app = create_app(config=config)
flask_app.config["TESTING"] = True
flask_app.config["WTF_CSRF_ENABLED"] = False # Flask-WTF checks this per request
flask_app.config["SECRET_KEY"] = "e2e-test-secret-key-not-for-production"
def _run():
from werkzeug.serving import make_server
srv = make_server("127.0.0.1", port, flask_app)
srv.serve_forever()
thread = threading.Thread(target=_run, daemon=True)
thread.start()
base_url = f"http://127.0.0.1:{port}"
_wait_for_server(base_url)
yield base_url
# Restore original BASE_DIR when the session ends
_paths.BASE_DIR = original_base_dir
# ---------------------------------------------------------------------------
# Function-scoped fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def authenticated_page(live_server: str, page: Page) -> Page:
"""Return a Playwright page that is authenticated as the test admin.
Handles first-run setup (creating the admin user) on the first call.
Subsequent calls log in directly.
"""
# Check if we need first-run setup
page.goto(f"{live_server}/")
page.wait_for_load_state("networkidle")
if "/setup" in page.url:
# First-run: create admin account
page.fill("input[name='username']", TEST_ADMIN_USER)
page.fill("input[name='password']", TEST_ADMIN_PASS)
page.fill("input[name='password_confirm']", TEST_ADMIN_PASS)
page.click("button[type='submit']")
page.wait_for_load_state("networkidle")
elif "/login" in page.url:
# Subsequent runs: log in
page.fill("input[name='username']", TEST_ADMIN_USER)
page.fill("input[name='password']", TEST_ADMIN_PASS)
page.click("button[type='submit']")
page.wait_for_load_state("networkidle")
# Ensure we are at the index (not stuck on setup or login)
if "/login" in page.url or "/setup" in page.url:
raise RuntimeError(
f"Failed to authenticate. Currently at: {page.url}\n"
f"Page content: {page.content()[:500]}"
)
return page
@pytest.fixture()
def dropbox_token(live_server: str, authenticated_page: Page) -> str:
"""Create a drop box upload token via the admin UI and return the token URL.
Returns the full upload URL so tests can navigate directly to the source
upload page without needing to parse the admin UI.
"""
page = authenticated_page
page.goto(f"{live_server}/dropbox/admin")
page.wait_for_load_state("networkidle")
# Fill in token creation form
label_input = page.locator("input[name='label']")
if label_input.count():
label_input.fill("e2e-test-source")
page.fill("input[name='hours']", "24")
page.fill("input[name='max_files']", "10")
# Submit and capture the flash message containing the upload URL
page.click("button[type='submit']")
page.wait_for_load_state("networkidle")
# Extract the upload URL from the success flash message
flash_text = page.locator(".alert-success, .alert.success, [class*='alert']").first.inner_text()
# The URL is embedded in the flash: "Share this URL with your source: http://..."
import re
match = re.search(r"http://\S+", flash_text)
if not match:
raise RuntimeError(f"Could not find upload URL in flash message: {flash_text!r}")
return match.group(0)

107
tests/e2e/helpers.py Normal file
View File

@@ -0,0 +1,107 @@
"""
Test helpers for e2e tests — generate in-memory test files.
All helpers return raw bytes suitable for use with page.set_input_files().
"""
from __future__ import annotations
import io
import struct
import zlib
def create_test_image(width: int = 64, height: int = 64) -> bytes:
"""Generate a minimal valid PNG image in memory.
Returns raw PNG bytes. Does not require Pillow — builds a PNG manually
using only the stdlib so that helpers.py has zero external dependencies.
"""
# PNG signature
png_sig = b"\x89PNG\r\n\x1a\n"
def make_chunk(chunk_type: bytes, data: bytes) -> bytes:
length = struct.pack(">I", len(data))
crc = struct.pack(">I", zlib.crc32(chunk_type + data) & 0xFFFFFFFF)
return length + chunk_type + data + crc
# IHDR: width, height, bit depth=8, color type=2 (RGB), compression=0,
# filter=0, interlace=0
ihdr_data = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
ihdr = make_chunk(b"IHDR", ihdr_data)
# IDAT: raw scanlines (filter byte 0 per row, then RGB pixels)
raw_rows = []
for y in range(height):
row = b"\x00" # filter type None
for x in range(width):
# Vary pixel color so the image is non-trivial
r = (x * 4) & 0xFF
g = (y * 4) & 0xFF
b = ((x + y) * 2) & 0xFF
row += bytes([r, g, b])
raw_rows.append(row)
compressed = zlib.compress(b"".join(raw_rows))
idat = make_chunk(b"IDAT", compressed)
iend = make_chunk(b"IEND", b"")
return png_sig + ihdr + idat + iend
def create_test_pdf() -> bytes:
"""Generate a minimal valid single-page PDF in memory.
The PDF is structurally valid (Acrobat/browsers will accept it) but
contains only a blank page with a text label.
"""
body = b"""%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R/Resources<</Font<</F1 4 0 R>>>>>>endobj
4 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
5 0 obj<</Length 44>>
stream
BT /F1 12 Tf 100 700 Td (FieldWitness test PDF) Tj ET
endstream
endobj
"""
xref_offset = len(body)
body += (
b"xref\n"
b"0 6\n"
b"0000000000 65535 f \n"
b"0000000009 00000 n \n"
b"0000000058 00000 n \n"
b"0000000115 00000 n \n"
b"0000000266 00000 n \n"
b"0000000346 00000 n \n"
b"trailer<</Size 6/Root 1 0 R>>\n"
b"startxref\n"
)
body += str(xref_offset).encode() + b"\n%%EOF\n"
return body
def create_test_csv() -> bytes:
"""Generate a simple CSV file in memory."""
lines = [
"timestamp,sensor_id,value,unit",
"2024-01-15T10:00:00Z,TEMP-001,23.5,celsius",
"2024-01-15T10:01:00Z,TEMP-001,23.7,celsius",
"2024-01-15T10:02:00Z,TEMP-001,23.6,celsius",
"2024-01-15T10:03:00Z,HUMID-001,65.2,percent",
]
return "\n".join(lines).encode("utf-8")
def create_test_txt() -> bytes:
"""Generate a plain-text file in memory."""
content = (
"FieldWitness e2e test document\n"
"===============================\n\n"
"This file was generated by the automated test suite.\n"
"It contains no sensitive information.\n"
)
return content.encode("utf-8")

295
tests/e2e/test_attest.py Normal file
View File

@@ -0,0 +1,295 @@
"""
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

232
tests/e2e/test_auth.py Normal file
View File

@@ -0,0 +1,232 @@
"""
e2e tests for the authentication system.
Tests cover first-run setup, login/logout flows, bad-credential rejection,
and the enforcement of auth guards on protected routes.
The first-run setup test uses its own isolated live server because it must
observe a database with zero users — after the session-scoped live_server has
created the admin user those conditions can never be recreated in the same
process.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from playwright.sync_api import Page, expect
from tests.e2e.conftest import TEST_ADMIN_PASS, TEST_ADMIN_USER, _find_free_port, _wait_for_server
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
PROTECTED_ROUTES = [
"/attest",
"/keys/",
"/admin/users",
"/fieldkit/",
"/dropbox/admin",
"/federation/",
]
# ---------------------------------------------------------------------------
# Tests that use the shared live_server (admin already created)
# ---------------------------------------------------------------------------
@pytest.mark.e2e
def test_login(live_server: str, page: Page) -> None:
"""Correct credentials reach the index page."""
page.goto(f"{live_server}/login")
page.wait_for_load_state("networkidle")
page.fill("input[name='username']", TEST_ADMIN_USER)
page.fill("input[name='password']", TEST_ADMIN_PASS)
page.click("button[type='submit']")
page.wait_for_load_state("networkidle")
# Should land on the index, not /login
expect(page).not_to_have_url(f"{live_server}/login")
# Flash message confirms success
expect(page.locator("body")).to_contain_text("Login successful")
@pytest.mark.e2e
def test_login_wrong_password(live_server: str, page: Page) -> None:
"""Wrong password stays on login with an error message."""
page.goto(f"{live_server}/login")
page.wait_for_load_state("networkidle")
page.fill("input[name='username']", TEST_ADMIN_USER)
page.fill("input[name='password']", "definitely-wrong-password")
page.click("button[type='submit']")
page.wait_for_load_state("networkidle")
expect(page).to_have_url(f"{live_server}/login")
expect(page.locator("body")).to_contain_text("Invalid")
@pytest.mark.e2e
def test_login_unknown_user(live_server: str, page: Page) -> None:
"""Unknown username is rejected without leaking whether the user exists."""
page.goto(f"{live_server}/login")
page.wait_for_load_state("networkidle")
page.fill("input[name='username']", "nobody_here")
page.fill("input[name='password']", "anything")
page.click("button[type='submit']")
page.wait_for_load_state("networkidle")
expect(page).to_have_url(f"{live_server}/login")
# Must not expose "user not found" vs "wrong password" distinction
expect(page.locator("body")).to_contain_text("Invalid")
@pytest.mark.e2e
def test_logout(live_server: str, authenticated_page: Page) -> None:
"""Logout clears session and redirects away from protected pages."""
page = authenticated_page
# Confirm we're authenticated
page.goto(f"{live_server}/")
expect(page).not_to_have_url(f"{live_server}/login")
# Submit the logout form (it's a POST for CSRF reasons)
page.evaluate("""() => {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/logout';
document.body.appendChild(form);
form.submit();
}""")
page.wait_for_load_state("networkidle")
# After logout, navigating to a protected page should redirect to login
page.goto(f"{live_server}/attest")
page.wait_for_load_state("networkidle")
expect(page).to_have_url(f"{live_server}/login")
@pytest.mark.e2e
def test_protected_routes_require_auth(live_server: str, page: Page) -> None:
"""Unauthenticated requests to protected routes redirect to /login."""
for route in PROTECTED_ROUTES:
page.goto(f"{live_server}{route}")
page.wait_for_load_state("networkidle")
assert "/login" in page.url or "/setup" in page.url, (
f"Route {route} did not redirect to /login — currently at {page.url}"
)
@pytest.mark.e2e
def test_verify_is_publicly_accessible(live_server: str, page: Page) -> None:
"""/verify must be accessible without authentication (third-party verifier use case)."""
page.goto(f"{live_server}/verify")
page.wait_for_load_state("networkidle")
# Should NOT redirect to login
assert "/login" not in page.url, f"Expected /verify to be public, got redirect to {page.url}"
expect(page.locator("body")).to_contain_text("Verify")
# ---------------------------------------------------------------------------
# First-run setup test — needs its own isolated server with an empty database
# ---------------------------------------------------------------------------
def _spawn_fresh_server(data_dir: Path) -> tuple[str, "subprocess.Popen"]:
"""Spawn a fresh Flask process in a subprocess pointing at *data_dir*.
Using a subprocess instead of a thread avoids the global BASE_DIR race
condition: the subprocess gets its own Python interpreter and module
namespace, so its fieldwitness.paths.BASE_DIR is completely independent
from the main test process's session-scoped server.
"""
import subprocess
import sys
port = _find_free_port()
server_code = f"""
import os, sys
os.environ["FIELDWITNESS_DATA_DIR"] = {str(data_dir)!r}
sys.path.insert(0, {str(Path(__file__).parents[2] / "frontends" / "web")!r})
from fieldwitness.config import FieldWitnessConfig
from frontends.web.app import create_app
config = FieldWitnessConfig(
https_enabled=False, auth_enabled=True, deadman_enabled=False,
killswitch_enabled=False, chain_enabled=False, chain_auto_wrap=False,
max_upload_mb=16, session_timeout_minutes=60,
login_lockout_attempts=10, login_lockout_minutes=1,
)
app = create_app(config=config)
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False
app.config["SECRET_KEY"] = "e2e-setup-test-secret"
from werkzeug.serving import make_server
srv = make_server("127.0.0.1", {port}, app)
srv.serve_forever()
"""
proc = subprocess.Popen(
[sys.executable, "-c", server_code],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
base_url = f"http://127.0.0.1:{port}"
try:
_wait_for_server(base_url, timeout=15)
except RuntimeError:
proc.kill()
raise
return base_url, proc
@pytest.mark.e2e
def test_first_run_setup(tmp_path: Path, page: Page) -> None:
"""A fresh app with no users redirects to /setup and allows admin creation.
Uses a subprocess-based server so its fieldwitness.paths.BASE_DIR is
completely isolated from the session-scoped live_server running in the
same process — no global module state is shared.
"""
import subprocess
data_dir = tmp_path / ".fieldwitness"
data_dir.mkdir()
base_url, proc = _spawn_fresh_server(data_dir)
try:
# Root should redirect to setup (no users exist yet)
page.goto(f"{base_url}/")
page.wait_for_load_state("networkidle")
assert "/setup" in page.url, f"Expected redirect to /setup, got {page.url}"
expect(page.locator("h5")).to_contain_text("Initial Setup")
# Fill and submit the setup form
page.fill("input[name='username']", "setup_admin")
page.fill("input[name='password']", "Setup-Password-99!")
page.fill("input[name='password_confirm']", "Setup-Password-99!")
page.click("button[type='submit']")
page.wait_for_load_state("networkidle")
# Should now be on the index (setup auto-logs-in the new admin)
assert "/setup" not in page.url, f"Still on setup page after submission: {page.url}"
assert "/login" not in page.url, f"Setup did not log in automatically: {page.url}"
finally:
proc.terminate()
proc.wait(timeout=5)

186
tests/e2e/test_dropbox.py Normal file
View File

@@ -0,0 +1,186 @@
"""
e2e tests for the source drop box feature.
The drop box is split into two distinct surfaces:
- Admin surface (/dropbox/admin) — authenticated, token management
- Source surface (/dropbox/upload/<token>) — unauthenticated, CSRF-exempt
Tests that exercise the source surface navigate in a fresh browser context
(or just navigate directly to the upload URL) to confirm there is no
session/authentication requirement on that path.
"""
from __future__ import annotations
import re
import time
import pytest
from playwright.sync_api import Page, expect
from tests.e2e.helpers import create_test_image, create_test_txt
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _create_token(page: Page, live_server: str, label: str = "e2e source", hours: int = 24) -> str:
"""Create a drop box token via the admin UI and return the full upload URL."""
page.goto(f"{live_server}/dropbox/admin")
page.wait_for_load_state("networkidle")
label_input = page.locator("input[name='label']")
if label_input.count():
label_input.fill(label)
page.fill("input[name='hours']", str(hours))
page.fill("input[name='max_files']", "5")
page.click("button[type='submit']")
page.wait_for_load_state("networkidle")
# Flash message contains the upload URL
flash = page.locator("[class*='alert']").first.inner_text()
match = re.search(r"http://\S+", flash)
if not match:
raise RuntimeError(f"No upload URL found in flash message: {flash!r}")
return match.group(0)
# ---------------------------------------------------------------------------
# Admin panel tests
# ---------------------------------------------------------------------------
@pytest.mark.e2e
def test_dropbox_admin_page(live_server: str, authenticated_page: Page) -> None:
"""The /dropbox/admin page loads and shows the token creation form."""
page = authenticated_page
page.goto(f"{live_server}/dropbox/admin")
page.wait_for_load_state("networkidle")
expect(page.locator("input[name='label']")).to_be_visible()
expect(page.locator("input[name='hours']")).to_be_visible()
expect(page.locator("input[name='max_files']")).to_be_visible()
expect(page.locator("button[type='submit']")).to_be_visible()
@pytest.mark.e2e
def test_create_upload_token(live_server: str, authenticated_page: Page) -> None:
"""Creating a token shows a success flash and the token appears in the active list."""
page = authenticated_page
upload_url = _create_token(page, live_server, label="my-e2e-source")
# The upload URL must contain the expected prefix
assert "/dropbox/upload/" in upload_url, f"Unexpected upload URL: {upload_url}"
# The token should now appear in the active token table
# (token[:12] is shown in the table as per the template)
token_slug = upload_url.split("/dropbox/upload/")[1].split("?")[0]
table = page.locator("table")
if table.count() > 0:
expect(table).to_contain_text(token_slug[:12])
@pytest.mark.e2e
def test_source_upload_page_accessible_without_auth(
live_server: str, authenticated_page: Page, page: Page
) -> None:
"""The source upload page is accessible without any authentication.
We get the URL via the admin (authenticated), then open it in a *separate*
fresh page that has no session cookie.
"""
upload_url = _create_token(authenticated_page, live_server, label="anon-test")
# Navigate to the upload URL in the unauthenticated page
page.goto(upload_url)
page.wait_for_load_state("networkidle")
# Must not redirect to login
assert "/login" not in page.url, (
f"Source upload page redirected to login: {page.url}"
)
# Upload form must be present
expect(page.locator("input[type='file'], input[name='files']")).to_be_visible()
@pytest.mark.e2e
def test_source_upload_file(live_server: str, authenticated_page: Page, page: Page) -> None:
"""A source can upload a file via the drop box and receives a receipt code."""
upload_url = _create_token(authenticated_page, live_server, label="upload-test")
# Submit a file as the anonymous source (unauthenticated page)
page.goto(upload_url)
page.wait_for_load_state("networkidle")
txt_bytes = create_test_txt()
page.set_input_files(
"input[type='file'], input[name='files']",
files=[{"name": "tip.txt", "mimeType": "text/plain", "buffer": txt_bytes}],
)
page.click("button[type='submit']")
page.wait_for_load_state("networkidle")
# The response page should show a receipt code
body_text = page.locator("body").inner_text()
assert any(word in body_text.lower() for word in ("receipt", "success", "received", "upload")), (
f"No success/receipt indication found after upload. Body: {body_text[:300]}"
)
@pytest.mark.e2e
def test_invalid_token_rejected(live_server: str, page: Page) -> None:
"""A request with an invalid/missing token returns 404, not a login redirect."""
page.goto(f"{live_server}/dropbox/upload/totally-invalid-token-xyz")
page.wait_for_load_state("networkidle")
# Should be a 404 / plain-text "expired or invalid" message, NOT a redirect to login
assert "/login" not in page.url, (
f"Invalid token redirected to login instead of showing 404: {page.url}"
)
body = page.locator("body").inner_text()
assert any(word in body.lower() for word in ("expired", "invalid", "not found")), (
f"Expected 'expired or invalid' message, got: {body[:200]}"
)
@pytest.mark.e2e
def test_revoke_token(live_server: str, authenticated_page: Page, page: Page) -> None:
"""An admin can revoke a token; after revocation the upload URL returns 404."""
admin_page = authenticated_page
upload_url = _create_token(admin_page, live_server, label="revoke-test")
token = upload_url.split("/dropbox/upload/")[1].split("?")[0]
# Verify the token works before revocation
page.goto(upload_url)
page.wait_for_load_state("networkidle")
assert "/login" not in page.url
# Revoke via admin UI
admin_page.goto(f"{live_server}/dropbox/admin")
admin_page.wait_for_load_state("networkidle")
# Find the revoke button for this token and click it
revoke_form = admin_page.locator(f"form input[name='token'][value='{token}']").locator("..")
if revoke_form.count() == 0:
# Try by partial token match in the table row
revoke_form = admin_page.locator("form").filter(
has=admin_page.locator(f"input[value='{token}']")
)
if revoke_form.count() > 0:
revoke_form.locator("button[type='submit']").click()
admin_page.wait_for_load_state("networkidle")
# Now the upload URL should return 404
page.goto(upload_url)
page.wait_for_load_state("networkidle")
body = page.locator("body").inner_text()
assert any(word in body.lower() for word in ("expired", "invalid", "not found")), (
f"Expected 404 after revocation, got: {body[:200]}"
)

View File

@@ -0,0 +1,83 @@
"""
e2e tests for the Fieldkit pages.
Safety note: we do NOT actually fire the killswitch in any test — doing so
would destroy the session-scoped data directory and break all subsequent tests.
We verify the UI renders correctly and the form fields are present, but we do
not submit the final "Execute Purge" action.
"""
from __future__ import annotations
import pytest
from playwright.sync_api import Page, expect
@pytest.mark.e2e
def test_fieldkit_status_page_loads(live_server: str, authenticated_page: Page) -> None:
"""The /fieldkit/ status dashboard loads and shows expected sections."""
page = authenticated_page
page.goto(f"{live_server}/fieldkit/")
page.wait_for_load_state("networkidle")
# Two key sections: dead man's switch and killswitch
expect(page.locator("body")).to_contain_text("Dead Man")
expect(page.locator("body")).to_contain_text("Killswitch")
@pytest.mark.e2e
def test_fieldkit_status_shows_disarmed_deadman(
live_server: str, authenticated_page: Page
) -> None:
"""With deadman disabled in test config, the switch shows 'Disarmed'."""
page = authenticated_page
page.goto(f"{live_server}/fieldkit/")
page.wait_for_load_state("networkidle")
# The conftest configures deadman_enabled=False so the badge must be Disarmed
deadman_section = page.locator("body")
expect(deadman_section).to_contain_text("Disarmed")
@pytest.mark.e2e
def test_killswitch_page_loads(live_server: str, authenticated_page: Page) -> None:
"""The /fieldkit/killswitch page loads and shows the confirmation form."""
page = authenticated_page
page.goto(f"{live_server}/fieldkit/killswitch")
page.wait_for_load_state("networkidle")
# Must have the text confirmation input
expect(page.locator("input[name='confirm']")).to_be_visible()
# Must have the password confirmation input
expect(page.locator("input[name='password']")).to_be_visible()
# The destructive submit button must be present but we do NOT click it
expect(page.locator("button[type='submit']")).to_be_visible()
@pytest.mark.e2e
def test_killswitch_requires_admin(live_server: str, page: Page) -> None:
"""The killswitch page requires authentication; unauthenticated access is redirected."""
page.goto(f"{live_server}/fieldkit/killswitch")
page.wait_for_load_state("networkidle")
assert "/login" in page.url or "/setup" in page.url, (
f"Expected auth redirect for killswitch, got {page.url}"
)
@pytest.mark.e2e
def test_fieldkit_link_from_status(live_server: str, authenticated_page: Page) -> None:
"""The 'Killswitch Panel' link on the status page navigates correctly."""
page = authenticated_page
page.goto(f"{live_server}/fieldkit/")
page.wait_for_load_state("networkidle")
link = page.locator("a[href*='killswitch']")
expect(link).to_be_visible()
link.click()
page.wait_for_load_state("networkidle")
assert "killswitch" in page.url, f"Expected killswitch URL, got {page.url}"
expect(page.locator("input[name='confirm']")).to_be_visible()

112
tests/e2e/test_keys.py Normal file
View File

@@ -0,0 +1,112 @@
"""
e2e tests for the key management pages (/keys/*).
These tests verify that:
- The key management dashboard loads and displays identity/channel key state.
- Generating a channel key succeeds (idempotent — second call is a no-op or success).
- Generating an identity key succeeds.
We do NOT test key export (download) in the browser because Playwright's
download handling requires additional setup and the export route is tested
by the unit tests. The export button presence is verified instead.
"""
from __future__ import annotations
import pytest
from playwright.sync_api import Page, expect
@pytest.mark.e2e
def test_keys_page_loads(live_server: str, authenticated_page: Page) -> None:
"""The /keys/ dashboard loads and shows both key sections."""
page = authenticated_page
page.goto(f"{live_server}/keys/")
page.wait_for_load_state("networkidle")
# Two key sections must be present
expect(page.locator("body")).to_contain_text("Channel Key")
expect(page.locator("body")).to_contain_text("Identity")
@pytest.mark.e2e
def test_generate_identity_key(live_server: str, authenticated_page: Page) -> None:
"""If no identity key exists, generating one succeeds and shows a fingerprint."""
page = authenticated_page
page.goto(f"{live_server}/keys/")
page.wait_for_load_state("networkidle")
gen_button = page.locator("form[action*='generate_identity'] button")
if gen_button.count() > 0:
# No identity — generate one
gen_button.click()
page.wait_for_load_state("networkidle")
# Success flash or fingerprint visible
body = page.locator("body").inner_text()
assert any(word in body.lower() for word in ("generated", "fingerprint", "identity")), (
f"Expected identity generation confirmation, got: {body[:300]}"
)
else:
# Identity already exists — fingerprint should be displayed
expect(page.locator("body")).to_contain_text("Fingerprint")
@pytest.mark.e2e
def test_generate_channel_key(live_server: str, authenticated_page: Page) -> None:
"""If no channel key exists, generating one succeeds."""
page = authenticated_page
page.goto(f"{live_server}/keys/")
page.wait_for_load_state("networkidle")
gen_button = page.locator("form[action*='generate_channel'] button")
if gen_button.count() > 0:
gen_button.click()
page.wait_for_load_state("networkidle")
body = page.locator("body").inner_text()
assert any(word in body.lower() for word in ("generated", "fingerprint", "channel")), (
f"Expected channel key generation confirmation, got: {body[:300]}"
)
else:
# Channel key already configured
expect(page.locator("body")).to_contain_text("Fingerprint")
@pytest.mark.e2e
def test_keys_page_shows_fingerprints_after_generation(
live_server: str, authenticated_page: Page
) -> None:
"""After generating both keys the dashboard shows non-empty fingerprints."""
page = authenticated_page
# Ensure both keys exist
page.goto(f"{live_server}/keys/")
page.wait_for_load_state("networkidle")
for action in ("generate_identity", "generate_channel"):
btn = page.locator(f"form[action*='{action}'] button")
if btn.count() > 0:
btn.click()
page.wait_for_load_state("networkidle")
page.goto(f"{live_server}/keys/")
page.wait_for_load_state("networkidle")
# Both fingerprints should now be visible
fingerprints = page.locator("code")
assert fingerprints.count() >= 2, (
f"Expected at least 2 fingerprint <code> elements, got {fingerprints.count()}"
)
@pytest.mark.e2e
def test_keys_page_requires_auth(live_server: str, page: Page) -> None:
"""Unauthenticated access to /keys/ redirects to /login."""
page.goto(f"{live_server}/keys/")
page.wait_for_load_state("networkidle")
assert "/login" in page.url or "/setup" in page.url, (
f"Expected auth redirect, got {page.url}"
)

View File

@@ -0,0 +1,161 @@
"""
e2e tests for general navigation and page health.
These tests verify that:
- The homepage loads after authentication.
- All primary navigation links resolve without 5xx errors.
- The layout is accessible at a mobile viewport width.
The navigation link test does NOT follow every link exhaustively — it checks
the primary links that appear in the base navigation bar (the links that every
page shares). Blueprint-specific internal links are covered by their own test
files.
"""
from __future__ import annotations
import pytest
from playwright.sync_api import Page, expect
# Primary navigation links as rendered by base.html.
# Each entry is (link text substring | href fragment, expected URL fragment).
# We match by href since the link text includes Bootstrap icons which vary.
PRIMARY_NAV_HREFS = [
"/",
"/encode",
"/decode",
"/generate",
"/attest",
"/verify",
"/keys/",
"/fieldkit/",
"/dropbox/admin",
"/federation/",
]
@pytest.mark.e2e
def test_homepage_loads(live_server: str, authenticated_page: Page) -> None:
"""The index page loads after login and shows the main feature cards."""
page = authenticated_page
page.goto(f"{live_server}/")
page.wait_for_load_state("networkidle")
# Core headings from index.html
expect(page.locator("body")).to_contain_text("FieldWitness")
# At least one of the feature card links is visible
expect(page.locator("a[href='/encode']")).to_be_visible()
@pytest.mark.e2e
def test_all_nav_links_no_server_error(live_server: str, authenticated_page: Page) -> None:
"""Every primary navigation link returns a non-5xx response."""
page = authenticated_page
errors: list[str] = []
for href in PRIMARY_NAV_HREFS:
url = f"{live_server}{href}"
response = page.goto(url)
page.wait_for_load_state("networkidle")
status = response.status if response else None
if status is not None and status >= 500:
errors.append(f"{href} → HTTP {status}")
assert not errors, "Navigation links returned server errors:\n" + "\n".join(errors)
@pytest.mark.e2e
def test_health_endpoint_authenticated(live_server: str, authenticated_page: Page) -> None:
"""The /health endpoint returns JSON with full details when authenticated."""
import json
page = authenticated_page
# Use fetch() so we get the JSON body (page.goto would return HTML shell)
result = page.evaluate("""async () => {
const resp = await fetch('/health');
return {status: resp.status, body: await resp.json()};
}""")
assert result["status"] == 200, f"Health check failed with status {result['status']}"
data = result["body"]
assert "status" in data, f"Unexpected health response: {data}"
assert data["status"] in ("ok", "degraded"), f"Unknown health status: {data['status']}"
@pytest.mark.e2e
def test_health_endpoint_unauthenticated(live_server: str, page: Page) -> None:
"""The /health endpoint returns minimal JSON for unauthenticated callers."""
result = page.evaluate("""async () => {
const resp = await fetch('/health');
return {status: resp.status, body: await resp.json()};
}""")
assert result["status"] == 200
data = result["body"]
# Unauthenticated response must have only status and version, not operational details
assert "status" in data
assert "modules" not in data, "Health leaked module details to unauthenticated caller"
assert "keys" not in data, "Health leaked key details to unauthenticated caller"
@pytest.mark.e2e
def test_responsive_layout_mobile(live_server: str, authenticated_page: Page) -> None:
"""The index page renders without horizontal overflow at 375px viewport width."""
page = authenticated_page
page.set_viewport_size({"width": 375, "height": 812})
page.goto(f"{live_server}/")
page.wait_for_load_state("networkidle")
# The page must render — verify the main heading is still in the DOM
expect(page.locator("body")).to_contain_text("FieldWitness")
# Check for horizontal overflow: scrollWidth should not exceed clientWidth
overflow = page.evaluate("""() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
}""")
assert not overflow, (
"Page has horizontal overflow at 375px viewport — layout breaks on mobile"
)
@pytest.mark.e2e
def test_page_titles_are_set(live_server: str, authenticated_page: Page) -> None:
"""Key pages have non-empty <title> elements (not the default Flask title)."""
page = authenticated_page
pages_and_expected = [
("/", "FieldWitness"),
("/attest", "Attest"),
("/verify", "Verify"),
("/keys/", "Keys"),
("/fieldkit/", "Fieldkit"),
]
for href, expected_fragment in pages_and_expected:
page.goto(f"{live_server}{href}")
page.wait_for_load_state("networkidle")
title = page.title()
assert expected_fragment.lower() in title.lower(), (
f"Page {href}: expected title to contain '{expected_fragment}', got '{title}'"
)
@pytest.mark.e2e
def test_logout_link_present_when_authenticated(
live_server: str, authenticated_page: Page
) -> None:
"""The navigation bar shows a logout affordance when the user is logged in."""
page = authenticated_page
page.goto(f"{live_server}/")
page.wait_for_load_state("networkidle")
# Logout is a POST form in the navbar; we just confirm the form/button exists
logout = page.locator("form[action*='logout'], a[href*='logout']")
assert logout.count() > 0, "No logout link/form found in navigation when authenticated"