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>
This commit is contained in:
1
tests/e2e/__init__.py
Normal file
1
tests/e2e/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# e2e test package
|
||||
218
tests/e2e/conftest.py
Normal file
218
tests/e2e/conftest.py
Normal 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
107
tests/e2e/helpers.py
Normal 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
295
tests/e2e/test_attest.py
Normal 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
232
tests/e2e/test_auth.py
Normal 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
186
tests/e2e/test_dropbox.py
Normal 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]}"
|
||||
)
|
||||
83
tests/e2e/test_fieldkit.py
Normal file
83
tests/e2e/test_fieldkit.py
Normal 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
112
tests/e2e/test_keys.py
Normal 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}"
|
||||
)
|
||||
161
tests/e2e/test_navigation.py
Normal file
161
tests/e2e/test_navigation.py
Normal 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"
|
||||
Reference in New Issue
Block a user