Add web layer foundation: - FastHTML app factory with Beforeware pattern - Auth middleware validating trusted proxy IPs and X-Oidc-Username header - CSRF dual-token validation (cookie + header + Origin/Referer) - Request ID generation (ULID) and NDJSON request logging - Role-based permission helpers (can_edit_event, can_delete_event) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
142 lines
4.9 KiB
Python
142 lines
4.9 KiB
Python
# ABOUTME: Integration tests for FastHTML application creation.
|
|
# ABOUTME: Tests app factory, middleware wiring, and route configuration.
|
|
|
|
import os
|
|
|
|
import pytest
|
|
|
|
|
|
def make_test_settings(
|
|
csrf_secret: str = "test-secret",
|
|
trusted_proxy_ips: str = "127.0.0.1",
|
|
auth_header_name: str = "X-Oidc-Username",
|
|
):
|
|
"""Create Settings for testing by setting env vars temporarily."""
|
|
from animaltrack.config import Settings
|
|
|
|
old_env = os.environ.copy()
|
|
try:
|
|
os.environ["CSRF_SECRET"] = csrf_secret
|
|
os.environ["TRUSTED_PROXY_IPS"] = trusted_proxy_ips
|
|
os.environ["AUTH_HEADER_NAME"] = auth_header_name
|
|
return Settings()
|
|
finally:
|
|
os.environ.clear()
|
|
os.environ.update(old_env)
|
|
|
|
|
|
class TestCreateApp:
|
|
"""Tests for the create_app factory function."""
|
|
|
|
def test_creates_app_with_provided_settings(self, seeded_db):
|
|
"""create_app(settings=...) uses provided settings."""
|
|
from animaltrack.web.app import create_app
|
|
|
|
settings = make_test_settings()
|
|
|
|
app, rt = create_app(settings=settings, db=seeded_db)
|
|
|
|
assert app is not None
|
|
assert rt is not None
|
|
assert app.state.settings is settings
|
|
assert app.state.db is seeded_db
|
|
|
|
def test_app_has_db_on_state(self, seeded_db):
|
|
"""Database accessible via app.state.db."""
|
|
from animaltrack.web.app import create_app
|
|
|
|
settings = make_test_settings()
|
|
app, rt = create_app(settings=settings, db=seeded_db)
|
|
|
|
assert hasattr(app.state, "db")
|
|
assert app.state.db is seeded_db
|
|
|
|
def test_app_has_settings_on_state(self, seeded_db):
|
|
"""Settings accessible via app.state.settings."""
|
|
from animaltrack.web.app import create_app
|
|
|
|
settings = make_test_settings()
|
|
app, rt = create_app(settings=settings, db=seeded_db)
|
|
|
|
assert hasattr(app.state, "settings")
|
|
assert app.state.settings.csrf_secret == "test-secret"
|
|
|
|
|
|
class TestAppWithTestClient:
|
|
"""Integration tests using Starlette TestClient."""
|
|
|
|
@pytest.fixture
|
|
def client(self, seeded_db):
|
|
"""Create a test client for the app."""
|
|
from starlette.testclient import TestClient
|
|
|
|
from animaltrack.web.app import create_app
|
|
|
|
# TestClient uses 'testclient' as the host, so we need to trust it
|
|
settings = make_test_settings(trusted_proxy_ips="testclient")
|
|
app, rt = create_app(settings=settings, db=seeded_db)
|
|
return TestClient(app, raise_server_exceptions=False)
|
|
|
|
def test_healthz_returns_200(self, client):
|
|
"""GET /healthz returns 200 OK."""
|
|
resp = client.get("/healthz")
|
|
assert resp.status_code == 200
|
|
|
|
def test_unauthenticated_route_returns_401(self, client):
|
|
"""Protected route without auth returns 401."""
|
|
# Any route that requires auth
|
|
resp = client.get("/")
|
|
assert resp.status_code == 401
|
|
|
|
def test_authenticated_request_succeeds(self, client):
|
|
"""Request with valid auth header succeeds."""
|
|
resp = client.get(
|
|
"/",
|
|
headers={"X-Oidc-Username": "ppetru"},
|
|
)
|
|
# Should get a valid response (200 or 404 if route not implemented yet)
|
|
# The key is it shouldn't be 401
|
|
assert resp.status_code != 401
|
|
|
|
def test_untrusted_proxy_returns_403(self, seeded_db):
|
|
"""Request from untrusted IP returns 403."""
|
|
from starlette.testclient import TestClient
|
|
|
|
from animaltrack.web.app import create_app
|
|
|
|
# Configure with a different trusted IP (not 'testclient')
|
|
settings = make_test_settings(trusted_proxy_ips="10.0.0.1")
|
|
app, rt = create_app(settings=settings, db=seeded_db)
|
|
client = TestClient(app, raise_server_exceptions=False)
|
|
|
|
resp = client.get("/", headers={"X-Oidc-Username": "ppetru"})
|
|
# Should fail because TestClient uses host 'testclient', not in trusted list
|
|
assert resp.status_code == 403
|
|
assert b"not from trusted proxy" in resp.content
|
|
|
|
def test_csrf_required_on_post(self, client):
|
|
"""POST without CSRF token returns 403."""
|
|
# POST to / route - should fail CSRF check before reaching handler
|
|
resp = client.post(
|
|
"/",
|
|
headers={"X-Oidc-Username": "ppetru"},
|
|
)
|
|
assert resp.status_code == 403
|
|
assert b"CSRF" in resp.content
|
|
|
|
def test_csrf_with_valid_tokens_succeeds(self, client):
|
|
"""POST with matching CSRF tokens proceeds."""
|
|
csrf_token = "test-csrf-token-123"
|
|
resp = client.post(
|
|
"/",
|
|
headers={
|
|
"X-Oidc-Username": "ppetru",
|
|
"X-CSRF-Token": csrf_token,
|
|
"Origin": "http://testserver",
|
|
},
|
|
cookies={"csrf_token": csrf_token},
|
|
)
|
|
# Should get through CSRF check (200 or 405 if method not allowed)
|
|
# The key is it shouldn't be 403 CSRF error
|
|
assert resp.status_code != 403 or b"CSRF" not in resp.content
|