fieldwitness/tests/e2e/test_auth.py
Aaron D. Lee 0312204340
Some checks failed
CI / lint (push) Failing after 13s
CI / typecheck (push) Failing after 11s
Fix e2e test infrastructure and app bugs found by Playwright
Fixes:
- Add frontends/web/ to sys.path in e2e conftest for temp_storage import
- Fix .fieldwitness → .fwmetadata in e2e conftest
- Fix NameError in /health endpoint (auth_is_authenticated → is_authenticated)
- Fix NameError in /login POST (config → app.config["FIELDWITNESS_CONFIG"])
- Add session-scoped admin_user fixture for reliable test ordering
- Fix navigation test assertions (health fetch URL, title checks, logout)
- Increase server startup timeout and use /login for health polling

Status: 17/39 e2e tests passing (auth + navigation). Remaining failures
are selector/assertion mismatches needing template-specific tuning.
350 unit/integration tests continue passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:58:34 -04:00

235 lines
8.2 KiB
Python

"""
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, admin_user: None, 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, admin_user: None, 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, admin_user: None, 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, admin_user: None, 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:
# A login-required route should redirect to /setup when no users exist.
# The root "/" is intentionally public (unauthenticated landing page) so
# it does NOT redirect; /encode is @login_required and triggers the flow.
page.goto(f"{base_url}/encode")
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)