central/tests/test_session_auth.py
malice 494ad1c799
feat(gui): implement first-run setup wizard (1b-8) (#24)
* feat(gui): implement first-run setup wizard (1b-8)

Add a 5-step setup wizard that replaces the single-step /setup:
1. Create Operator - create initial operator account
2. System Settings - configure map tile URL and attribution
3. API Keys - optionally add API keys for adapters
4. Configure Adapters - enable/disable adapters with region picker
5. Finish Setup - review and complete setup

Key changes:
- Update middleware to handle wizard URL structure and step routing
- Add wizard routes for each step with proper auth checks
- Create new templates using base_wizard.html for consistent styling
- Add audit events for system.update and setup.complete
- Update tests for new middleware behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(gui): handle CSRF errors on wizard paths

Update csrf_exception_handler to re-render wizard forms with error
message instead of redirecting to /login when CSRF validation fails.

- /setup/operator: re-render with error
- /setup/system: re-render with current system values + error
- /setup/keys: re-render with current keys list + error
- /setup/adapters: re-render with current adapter config + error
- /setup/finish: re-render with summary data + error
- /setup: redirect to /setup (middleware routes to appropriate step)

Add error display to setup_keys.html and setup_finish.html templates.
Add 7 new CSRF handler tests for wizard paths.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(gui): region picker render + click-to-draw

Bug A: Maps render blank on /setup/adapters for FIRMS and USGS
because Leaflet computed zero dimensions before container layout
settled. Fix: add setTimeout invalidateSize() after map creation.

Bug B: No click-to-draw functionality - only drag corners. Fix:
add L.Control.Draw for rectangle drawing with CREATED event handler
to replace existing rectangle.

Both fixes applied to:
- setup_adapters.html (wizard inline JS)
- _region_picker.html (standalone edit page)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(gui): handle revisiting /setup/operator after operator created

When an operator already exists, /setup/operator now shows a
confirmation page instead of the create form. This prevents:
- Unique constraint violations on duplicate username
- Silent creation of duplicate operators

GET /setup/operator: queries config.operators; if any exist,
renders confirmation state with existing_operator context.

POST /setup/operator: checks operator count before INSERT; if
non-zero, renders confirmation state without inserting.

Template updated with conditional to show "Operator Already
Configured" message when existing_operator is set.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(csrf): replace fastapi-csrf-protect with session-bound CSRF

Fixes CSRF race condition where every GET rotated the CSRF token,
causing POST failures when users had multiple tabs or slow connections.

Changes:
- Remove fastapi-csrf-protect dependency
- Add session-bound CSRF tokens stored in config.sessions table
- Add pre-auth CSRF for unauthenticated routes (/login, /setup/operator)
- Add csrf.py module for pre-auth token generation/validation
- Update routes to use new CSRF token handling
- Add migration 013 to add csrf_token column to sessions

The session-bound approach ensures CSRF tokens remain stable for the
duration of a session, eliminating the race condition.

Note: Route tests (test_wizard.py, test_adapters.py, etc.) need
refactoring to mock get_settings() instead of CsrfProtect dependency.
Core auth/CSRF handler tests pass (74 tests).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(csrf): update test suite for session-bound CSRF tokens

- Add CSRF fixtures to conftest.py for pre-auth and session CSRF
- Update test_wizard.py: use bypass_pre_auth_csrf and patch_route_settings
- Update test_adapters.py: set request.state.csrf_token and form mock data
- Update test_api_keys.py: add CSRF token to form data for POST routes
- Update test_streams.py: change return_value to side_effect for CSRF support
- Update test_region_picker.py: add CSRF token handling
- Update test_config_store.py: set CENTRAL_CSRF_SECRET env var in fixture

All 285 tests now pass with session-bound CSRF validation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Matt Johnson <mj@k7zvx.com>
2026-05-17 22:06:22 -06:00

175 lines
6.7 KiB
Python

"""Tests for session authentication middleware."""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import datetime, timezone
from starlette.testclient import TestClient
from fastapi import FastAPI, Request
from central.gui.middleware import SessionMiddleware
from central.gui.auth import Operator
class TestSessionMiddleware:
"""Tests for SessionMiddleware."""
@pytest.mark.asyncio
async def test_no_cookie_sets_none_on_exempt_path(self):
"""SessionMiddleware sets operator=None when no session cookie on exempt path."""
mock_pool = MagicMock()
mock_pool.acquire = MagicMock()
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
app = FastAPI()
@app.get("/health")
async def health(request: Request):
return {"operator": getattr(request.state, "operator", "missing")}
app.add_middleware(SessionMiddleware)
client = TestClient(app)
response = client.get("/health")
assert response.status_code == 200
assert response.json()["operator"] is None
@pytest.mark.asyncio
async def test_valid_cookie_sets_operator_on_exempt_path(self):
"""SessionMiddleware sets operator when valid session cookie on exempt path."""
mock_pool = MagicMock()
mock_conn = MagicMock()
mock_conn.fetchrow = AsyncMock(return_value={
"id": 1,
"username": "admin",
"created_at": datetime.now(timezone.utc),
"password_changed_at": datetime.now(timezone.utc),
"csrf_token": "mock_csrf_token_12345",
})
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock()
mock_pool.acquire = MagicMock(return_value=mock_conn)
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
app = FastAPI()
@app.get("/health")
async def health(request: Request):
op = getattr(request.state, "operator", None)
if op:
return {"username": op.username}
return {"operator": None}
app.add_middleware(SessionMiddleware)
client = TestClient(app, cookies={"central_session": "valid-token"})
response = client.get("/health")
assert response.status_code == 200
assert response.json()["username"] == "admin"
@pytest.mark.asyncio
async def test_no_cookie_redirects_on_protected_path(self):
"""SessionMiddleware redirects to /login when no cookie on protected path."""
mock_pool = MagicMock()
mock_pool.acquire = MagicMock()
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
app = FastAPI()
@app.get("/")
async def index(request: Request):
return {"message": "home"}
@app.get("/login")
async def login():
return {"message": "login"}
app.add_middleware(SessionMiddleware)
client = TestClient(app, follow_redirects=False)
response = client.get("/")
assert response.status_code == 302
assert response.headers["location"] == "/login"
@pytest.mark.asyncio
async def test_valid_cookie_allows_protected_path(self):
"""SessionMiddleware allows protected path with valid session."""
mock_pool = MagicMock()
mock_conn = MagicMock()
mock_conn.fetchrow = AsyncMock(return_value={
"id": 1,
"username": "admin",
"created_at": datetime.now(timezone.utc),
"password_changed_at": datetime.now(timezone.utc),
"csrf_token": "mock_csrf_token_12345",
})
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock()
mock_pool.acquire = MagicMock(return_value=mock_conn)
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
app = FastAPI()
@app.get("/")
async def index(request: Request):
op = request.state.operator
return {"message": "home", "user": op.username}
app.add_middleware(SessionMiddleware)
client = TestClient(app, cookies={"central_session": "valid-token"})
response = client.get("/")
assert response.status_code == 200
assert response.json()["user"] == "admin"
@pytest.mark.asyncio
async def test_invalid_cookie_redirects_on_protected_path(self):
"""SessionMiddleware redirects when session is invalid/expired."""
mock_pool = MagicMock()
mock_conn = MagicMock()
mock_conn.fetchrow = AsyncMock(return_value=None) # No session found
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock()
mock_pool.acquire = MagicMock(return_value=mock_conn)
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
app = FastAPI()
@app.get("/")
async def index(request: Request):
return {"operator": getattr(request.state, "operator", "missing")}
@app.get("/login")
async def login():
return {"message": "login"}
app.add_middleware(SessionMiddleware)
client = TestClient(app, cookies={"central_session": "expired-token"}, follow_redirects=False)
response = client.get("/")
assert response.status_code == 302
assert response.headers["location"] == "/login"
@pytest.mark.asyncio
async def test_middleware_handles_db_error(self):
"""SessionMiddleware handles database errors gracefully on exempt path."""
mock_pool = MagicMock()
mock_conn = MagicMock()
mock_conn.fetchrow = AsyncMock(side_effect=Exception("DB error"))
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock()
mock_pool.acquire = MagicMock(return_value=mock_conn)
with patch("central.gui.middleware.get_pool", return_value=mock_pool):
app = FastAPI()
@app.get("/health")
async def health(request: Request):
return {"operator": getattr(request.state, "operator", "missing")}
app.add_middleware(SessionMiddleware)
client = TestClient(app, cookies={"central_session": "some-token"})
response = client.get("/health")
# Should not crash, just set operator to None
assert response.status_code == 200
assert response.json()["operator"] is None