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>
235 lines
8.2 KiB
Python
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)
|