feat: implement FastHTML app shell with auth/CSRF middleware (Step 7.1)
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>
This commit is contained in:
141
tests/test_web_app.py
Normal file
141
tests/test_web_app.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user