mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-21 18:14:44 +02:00
* 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>
357 lines
14 KiB
Python
357 lines
14 KiB
Python
"""Tests for region picker functionality."""
|
|
|
|
import json
|
|
import os
|
|
from unittest.mock import MagicMock, AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
os.environ.setdefault("CENTRAL_DB_DSN", "postgresql://test:test@localhost/test")
|
|
os.environ.setdefault("CENTRAL_CSRF_SECRET", "testsecret12345678901234567890ab")
|
|
os.environ.setdefault("CENTRAL_NATS_URL", "nats://localhost:4222")
|
|
|
|
|
|
class TestRegionPickerInTemplate:
|
|
"""Test region picker is included in adapter edit page."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_adapters_firms_includes_map_div(self):
|
|
"""GET /adapters/firms includes the map div with tile URL."""
|
|
from central.gui.routes import adapters_edit_form
|
|
|
|
mock_request = MagicMock()
|
|
mock_request.state.operator = MagicMock(id=1, username="testop")
|
|
|
|
mock_conn = AsyncMock()
|
|
mock_conn.fetchrow.side_effect = [
|
|
{ # Adapter row
|
|
"name": "firms",
|
|
"enabled": True,
|
|
"cadence_s": 300,
|
|
"settings": {
|
|
"api_key_alias": "firms",
|
|
"satellites": ["VIIRS_SNPP_NRT"],
|
|
"region": {"north": 49.5, "south": 31.0, "east": -102.0, "west": -124.5}
|
|
},
|
|
"paused_at": None,
|
|
"updated_at": None,
|
|
},
|
|
{ # System settings row
|
|
"map_tile_url": "https://tile.example.com/{z}/{x}/{y}.png",
|
|
"map_attribution": "Test Attribution",
|
|
},
|
|
]
|
|
mock_conn.fetch.return_value = [{"alias": "firms"}]
|
|
|
|
mock_pool = MagicMock()
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
mock_templates = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_templates.TemplateResponse.return_value = mock_response
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
result = await adapters_edit_form(mock_request, "firms")
|
|
|
|
call_args = mock_templates.TemplateResponse.call_args
|
|
context = call_args.kwargs.get("context", call_args[1].get("context"))
|
|
|
|
# Should include tile URL from config.system
|
|
assert context["tile_url"] == "https://tile.example.com/{z}/{x}/{y}.png"
|
|
assert context["tile_attribution"] == "Test Attribution"
|
|
|
|
|
|
class TestRegionValidation:
|
|
"""Test region coordinate validation in POST handler."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_valid_region_updates_settings(self):
|
|
"""POST with valid bbox updates settings.region."""
|
|
from central.gui.routes import adapters_edit_submit
|
|
|
|
mock_request = MagicMock()
|
|
mock_request.state.operator = MagicMock(id=1, username="testop")
|
|
|
|
mock_form = MagicMock()
|
|
mock_request.state.csrf_token = "test_csrf_token"
|
|
mock_form.get.side_effect = lambda k, d="": {
|
|
"csrf_token": "test_csrf_token",
|
|
"cadence_s": "300",
|
|
"api_key_alias": "firms",
|
|
"region_north": "45.0",
|
|
"region_south": "35.0",
|
|
"region_east": "-100.0",
|
|
"region_west": "-120.0",
|
|
}.get(k, d)
|
|
mock_form.getlist.return_value = ["VIIRS_SNPP_NRT"]
|
|
mock_form.__contains__ = lambda self, k: k == "enabled"
|
|
mock_request.form = AsyncMock(return_value=mock_form)
|
|
|
|
mock_conn = AsyncMock()
|
|
mock_conn.fetchrow.side_effect = [
|
|
{ # Adapter row
|
|
"name": "firms",
|
|
"enabled": True,
|
|
"cadence_s": 300,
|
|
"settings": {"api_key_alias": "firms", "region": {"north": 49.5, "south": 31.0, "east": -102.0, "west": -124.5}},
|
|
"paused_at": None,
|
|
"updated_at": None,
|
|
},
|
|
{"id": 1}, # api_key exists check
|
|
]
|
|
mock_conn.execute = AsyncMock()
|
|
|
|
mock_pool = MagicMock()
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
captured_settings = {}
|
|
|
|
async def capture_execute(query, *args):
|
|
if "UPDATE config.adapters" in query:
|
|
captured_settings["settings"] = args[2]
|
|
|
|
mock_conn.execute.side_effect = capture_execute
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
with patch("central.gui.routes.write_audit", new_callable=AsyncMock):
|
|
result = await adapters_edit_submit(mock_request, "firms")
|
|
|
|
assert result.status_code == 302
|
|
assert captured_settings["settings"]["region"]["north"] == 45.0
|
|
assert captured_settings["settings"]["region"]["south"] == 35.0
|
|
assert captured_settings["settings"]["region"]["east"] == -100.0
|
|
assert captured_settings["settings"]["region"]["west"] == -120.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_north_less_than_south_shows_error(self):
|
|
"""POST with north <= south shows validation error."""
|
|
from central.gui.routes import adapters_edit_submit
|
|
|
|
mock_request = MagicMock()
|
|
mock_request.state.operator = MagicMock(id=1, username="testop")
|
|
|
|
mock_form = MagicMock()
|
|
mock_request.state.csrf_token = "test_csrf_token"
|
|
mock_form.get.side_effect = lambda k, d="": {
|
|
"csrf_token": "test_csrf_token",
|
|
"cadence_s": "300",
|
|
"api_key_alias": "firms",
|
|
"region_north": "30.0", # Less than south!
|
|
"region_south": "35.0",
|
|
"region_east": "-100.0",
|
|
"region_west": "-120.0",
|
|
}.get(k, d)
|
|
mock_form.getlist.return_value = ["VIIRS_SNPP_NRT"]
|
|
mock_form.__contains__ = lambda self, k: k == "enabled"
|
|
mock_request.form = AsyncMock(return_value=mock_form)
|
|
|
|
mock_conn = AsyncMock()
|
|
mock_conn.fetchrow.side_effect = [
|
|
{
|
|
"name": "firms",
|
|
"enabled": True,
|
|
"cadence_s": 300,
|
|
"settings": {"api_key_alias": "firms", "region": {"north": 49.5, "south": 31.0, "east": -102.0, "west": -124.5}},
|
|
"paused_at": None,
|
|
"updated_at": None,
|
|
},
|
|
{"id": 1}, # api_key exists
|
|
{"map_tile_url": None, "map_attribution": None}, # system settings for re-render
|
|
]
|
|
mock_conn.fetch.return_value = [{"alias": "firms"}]
|
|
|
|
mock_pool = MagicMock()
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
mock_templates = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_templates.TemplateResponse.return_value = mock_response
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
result = await adapters_edit_submit(mock_request, "firms")
|
|
|
|
call_args = mock_templates.TemplateResponse.call_args
|
|
context = call_args.kwargs.get("context", call_args[1].get("context"))
|
|
assert "region" in context["errors"]
|
|
assert "latitude" in context["errors"]["region"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_west_greater_than_east_shows_error(self):
|
|
"""POST with west >= east shows validation error."""
|
|
from central.gui.routes import adapters_edit_submit
|
|
|
|
mock_request = MagicMock()
|
|
mock_request.state.operator = MagicMock(id=1, username="testop")
|
|
|
|
mock_form = MagicMock()
|
|
mock_request.state.csrf_token = "test_csrf_token"
|
|
mock_form.get.side_effect = lambda k, d="": {
|
|
"csrf_token": "test_csrf_token",
|
|
"cadence_s": "300",
|
|
"api_key_alias": "firms",
|
|
"region_north": "45.0",
|
|
"region_south": "35.0",
|
|
"region_east": "-130.0", # Less than west!
|
|
"region_west": "-120.0",
|
|
}.get(k, d)
|
|
mock_form.getlist.return_value = ["VIIRS_SNPP_NRT"]
|
|
mock_form.__contains__ = lambda self, k: k == "enabled"
|
|
mock_request.form = AsyncMock(return_value=mock_form)
|
|
|
|
mock_conn = AsyncMock()
|
|
mock_conn.fetchrow.side_effect = [
|
|
{
|
|
"name": "firms",
|
|
"enabled": True,
|
|
"cadence_s": 300,
|
|
"settings": {"api_key_alias": "firms", "region": {"north": 49.5, "south": 31.0, "east": -102.0, "west": -124.5}},
|
|
"paused_at": None,
|
|
"updated_at": None,
|
|
},
|
|
{"id": 1},
|
|
{"map_tile_url": None, "map_attribution": None},
|
|
]
|
|
mock_conn.fetch.return_value = [{"alias": "firms"}]
|
|
|
|
mock_pool = MagicMock()
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
mock_templates = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_templates.TemplateResponse.return_value = mock_response
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
result = await adapters_edit_submit(mock_request, "firms")
|
|
|
|
call_args = mock_templates.TemplateResponse.call_args
|
|
context = call_args.kwargs.get("context", call_args[1].get("context"))
|
|
assert "region" in context["errors"]
|
|
assert "longitude" in context["errors"]["region"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_latitude_out_of_bounds_shows_error(self):
|
|
"""POST with lat > 90 shows validation error."""
|
|
from central.gui.routes import adapters_edit_submit
|
|
|
|
mock_request = MagicMock()
|
|
mock_request.state.operator = MagicMock(id=1, username="testop")
|
|
|
|
mock_form = MagicMock()
|
|
mock_request.state.csrf_token = "test_csrf_token"
|
|
mock_form.get.side_effect = lambda k, d="": {
|
|
"csrf_token": "test_csrf_token",
|
|
"cadence_s": "300",
|
|
"api_key_alias": "firms",
|
|
"region_north": "95.0", # > 90!
|
|
"region_south": "35.0",
|
|
"region_east": "-100.0",
|
|
"region_west": "-120.0",
|
|
}.get(k, d)
|
|
mock_form.getlist.return_value = ["VIIRS_SNPP_NRT"]
|
|
mock_form.__contains__ = lambda self, k: k == "enabled"
|
|
mock_request.form = AsyncMock(return_value=mock_form)
|
|
|
|
mock_conn = AsyncMock()
|
|
mock_conn.fetchrow.side_effect = [
|
|
{
|
|
"name": "firms",
|
|
"enabled": True,
|
|
"cadence_s": 300,
|
|
"settings": {"api_key_alias": "firms", "region": {"north": 49.5, "south": 31.0, "east": -102.0, "west": -124.5}},
|
|
"paused_at": None,
|
|
"updated_at": None,
|
|
},
|
|
{"id": 1},
|
|
{"map_tile_url": None, "map_attribution": None},
|
|
]
|
|
mock_conn.fetch.return_value = [{"alias": "firms"}]
|
|
|
|
mock_pool = MagicMock()
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
mock_templates = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_templates.TemplateResponse.return_value = mock_response
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
result = await adapters_edit_submit(mock_request, "firms")
|
|
|
|
call_args = mock_templates.TemplateResponse.call_args
|
|
context = call_args.kwargs.get("context", call_args[1].get("context"))
|
|
assert "region" in context["errors"]
|
|
|
|
|
|
class TestRegionAuditLog:
|
|
"""Test region changes are captured in audit log."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_audit_captures_region_change(self):
|
|
"""Audit log captures region change in before/after settings."""
|
|
from central.gui.routes import adapters_edit_submit
|
|
|
|
mock_request = MagicMock()
|
|
mock_request.state.operator = MagicMock(id=1, username="testop")
|
|
|
|
mock_form = MagicMock()
|
|
mock_request.state.csrf_token = "test_csrf_token"
|
|
mock_form.get.side_effect = lambda k, d="": {
|
|
"csrf_token": "test_csrf_token",
|
|
"cadence_s": "300",
|
|
"api_key_alias": "firms",
|
|
"region_north": "45.0",
|
|
"region_south": "35.0",
|
|
"region_east": "-100.0",
|
|
"region_west": "-120.0",
|
|
}.get(k, d)
|
|
mock_form.getlist.return_value = ["VIIRS_SNPP_NRT"]
|
|
mock_form.__contains__ = lambda self, k: k == "enabled"
|
|
mock_request.form = AsyncMock(return_value=mock_form)
|
|
|
|
mock_conn = AsyncMock()
|
|
mock_conn.fetchrow.side_effect = [
|
|
{
|
|
"name": "firms",
|
|
"enabled": True,
|
|
"cadence_s": 300,
|
|
"settings": {
|
|
"api_key_alias": "firms",
|
|
"region": {"north": 49.5, "south": 31.0, "east": -102.0, "west": -124.5}
|
|
},
|
|
"paused_at": None,
|
|
"updated_at": None,
|
|
},
|
|
{"id": 1},
|
|
]
|
|
mock_conn.execute = AsyncMock()
|
|
|
|
mock_pool = MagicMock()
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
captured_audit = {}
|
|
|
|
async def capture_audit(conn, action, operator_id=None, target=None, before=None, after=None):
|
|
captured_audit["before"] = before
|
|
captured_audit["after"] = after
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
with patch("central.gui.routes.write_audit", side_effect=capture_audit):
|
|
result = await adapters_edit_submit(mock_request, "firms")
|
|
|
|
# Before should have old region
|
|
assert captured_audit["before"]["settings"]["region"]["north"] == 49.5
|
|
# After should have new region
|
|
assert captured_audit["after"]["settings"]["region"]["north"] == 45.0
|
|
assert captured_audit["after"]["settings"]["region"]["south"] == 35.0
|