diff --git a/pyproject.toml b/pyproject.toml index 3c7e570..766cd0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -124,6 +124,10 @@ dev = [ "ruff>=0.1.0", "mypy>=1.0.0", ] +test-e2e = [ + "pytest-playwright>=0.4.0", + "playwright>=1.40.0", +] [project.scripts] fieldwitness = "fieldwitness.cli:main" @@ -151,6 +155,9 @@ packages = ["src/fieldwitness", "frontends"] testpaths = ["tests"] python_files = ["test_*.py"] addopts = "-v --cov=fieldwitness --cov-report=term-missing" +markers = [ + "e2e: end-to-end Playwright browser tests (require `playwright install` and `pip install fieldwitness[test-e2e]`)", +] [tool.black] line-length = 100 diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..ec941e0 --- /dev/null +++ b/tests/e2e/__init__.py @@ -0,0 +1 @@ +# e2e test package diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..9add910 --- /dev/null +++ b/tests/e2e/conftest.py @@ -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) diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py new file mode 100644 index 0000000..1a154c5 --- /dev/null +++ b/tests/e2e/helpers.py @@ -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<>endobj +2 0 obj<>endobj +3 0 obj<>>>>>endobj +4 0 obj<>endobj +5 0 obj<> +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<>\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") diff --git a/tests/e2e/test_attest.py b/tests/e2e/test_attest.py new file mode 100644 index 0000000..c706ca3 --- /dev/null +++ b/tests/e2e/test_attest.py @@ -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: