""" 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)