diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py
index 73543c1..d548306 100644
--- a/src/central/gui/routes.py
+++ b/src/central/gui/routes.py
@@ -620,6 +620,13 @@ async def adapters_edit_form(
"SELECT alias FROM config.api_keys ORDER BY alias"
)
+ # Get map tile settings from config.system
+ sys_row = await conn.fetchrow(
+ "SELECT map_tile_url, map_attribution FROM config.system WHERE id = true"
+ )
+ tile_url = sys_row["map_tile_url"] if sys_row else "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
+ tile_attribution = sys_row["map_attribution"] if sys_row else "© OpenStreetMap contributors"
+
settings = row["settings"] or {}
adapter = {
"name": row["name"],
@@ -643,6 +650,8 @@ async def adapters_edit_form(
"api_keys": [{"alias": k["alias"]} for k in api_keys],
"valid_satellites": _get_valid_satellites(),
"valid_feeds": sorted(_get_valid_feeds()),
+ "tile_url": tile_url,
+ "tile_attribution": tile_attribution,
},
)
csrf_protect.set_csrf_cookie(signed_token, response)
@@ -749,6 +758,39 @@ async def adapters_edit_submit(
else:
new_settings["feed"] = feed
+ # Region validation (applies to all adapters)
+ region_north_str = form.get("region_north", "").strip()
+ region_south_str = form.get("region_south", "").strip()
+ region_east_str = form.get("region_east", "").strip()
+ region_west_str = form.get("region_west", "").strip()
+
+ form_data["region_north"] = region_north_str
+ form_data["region_south"] = region_south_str
+ form_data["region_east"] = region_east_str
+ form_data["region_west"] = region_west_str
+
+ try:
+ region_north = float(region_north_str)
+ region_south = float(region_south_str)
+ region_east = float(region_east_str)
+ region_west = float(region_west_str)
+
+ # Validate latitude bounds
+ if not (-90 <= region_south < region_north <= 90):
+ errors["region"] = "Invalid latitude: south must be less than north, both between -90 and 90"
+ # Validate longitude bounds
+ elif not (-180 <= region_west < region_east <= 180):
+ errors["region"] = "Invalid longitude: west must be less than east, both between -180 and 180"
+ else:
+ new_settings["region"] = {
+ "north": region_north,
+ "south": region_south,
+ "east": region_east,
+ "west": region_west,
+ }
+ except ValueError:
+ errors["region"] = "Region coordinates must be valid numbers"
+
# If there are errors, re-render the form
if errors:
adapter = {
@@ -764,6 +806,13 @@ async def adapters_edit_submit(
"SELECT alias FROM config.api_keys ORDER BY alias"
)
+ # Get map tile settings for re-render
+ sys_row = await conn.fetchrow(
+ "SELECT map_tile_url, map_attribution FROM config.system WHERE id = true"
+ )
+ tile_url = sys_row["map_tile_url"] if sys_row else "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
+ tile_attribution = sys_row["map_attribution"] if sys_row else "© OpenStreetMap contributors"
+
csrf_token, signed_token = csrf_protect.generate_csrf_tokens()
response = templates.TemplateResponse(
request=request,
@@ -777,6 +826,8 @@ async def adapters_edit_submit(
"api_keys": [{"alias": k["alias"]} for k in api_keys],
"valid_satellites": _get_valid_satellites(),
"valid_feeds": sorted(_get_valid_feeds()),
+ "tile_url": tile_url,
+ "tile_attribution": tile_attribution,
},
status_code=200,
)
diff --git a/src/central/gui/templates/_region_picker.html b/src/central/gui/templates/_region_picker.html
new file mode 100644
index 0000000..5c53bc9
--- /dev/null
+++ b/src/central/gui/templates/_region_picker.html
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+ {% if errors and errors.region %}
+
{{ errors.region }}
+ {% endif %}
+
+
+
+
+
diff --git a/src/central/gui/templates/adapters_edit.html b/src/central/gui/templates/adapters_edit.html
index fe7e093..35ae6c8 100644
--- a/src/central/gui/templates/adapters_edit.html
+++ b/src/central/gui/templates/adapters_edit.html
@@ -2,6 +2,13 @@
{% block title %}Central — Edit {{ adapter.name }}{% endblock %}
+{% block head %}
+
+
+
+
+{% endblock %}
+
{% block content %}
Edit Adapter: {{ adapter.name }}
@@ -29,18 +36,8 @@
diff --git a/tests/test_adapters.py b/tests/test_adapters.py
index 2f96e0b..17352f0 100644
--- a/tests/test_adapters.py
+++ b/tests/test_adapters.py
@@ -84,14 +84,17 @@ class TestAdaptersEditForm:
mock_request.state.operator = MagicMock(id=1, username="testop")
mock_conn = AsyncMock()
- mock_conn.fetchrow.return_value = {
- "name": "nws",
- "enabled": True,
- "cadence_s": 60,
- "settings": {"contact_email": "test@example.com", "region": {"north": 49, "south": 24, "east": -66, "west": -125}},
- "paused_at": None,
- "updated_at": None,
- }
+ mock_conn.fetchrow.side_effect = [
+ {
+ "name": "nws",
+ "enabled": True,
+ "cadence_s": 60,
+ "settings": {"contact_email": "test@example.com", "region": {"north": 49, "south": 24, "east": -66, "west": -125}},
+ "paused_at": None,
+ "updated_at": None,
+ },
+ {"map_tile_url": "https://tile.example.com/{z}/{x}/{y}.png", "map_attribution": "Test"},
+ ]
mock_conn.fetch.return_value = [] # No API keys
mock_pool = MagicMock()
@@ -156,6 +159,10 @@ class TestAdaptersEditSubmit:
mock_form.get.side_effect = lambda k, d="": {
"cadence_s": "120",
"contact_email": "new@example.com",
+ "region_north": "49.0",
+ "region_south": "24.0",
+ "region_east": "-66.0",
+ "region_west": "-125.0",
}.get(k, d)
mock_form.getlist.return_value = []
mock_form.__contains__ = lambda self, k: k == "enabled"
@@ -166,7 +173,7 @@ class TestAdaptersEditSubmit:
"name": "nws",
"enabled": True,
"cadence_s": 60,
- "settings": {"contact_email": "old@example.com"},
+ "settings": {"contact_email": "old@example.com", "region": {"north": 49, "south": 24, "east": -66, "west": -125}},
"paused_at": None,
"updated_at": None,
}
@@ -200,20 +207,27 @@ class TestAdaptersEditSubmit:
mock_form.get.side_effect = lambda k, d="": {
"cadence_s": "30",
"contact_email": "test@example.com",
+ "region_north": "49.0",
+ "region_south": "24.0",
+ "region_east": "-66.0",
+ "region_west": "-125.0",
}.get(k, d)
mock_form.getlist.return_value = []
mock_form.__contains__ = lambda self, k: k == "enabled"
mock_request.form = AsyncMock(return_value=mock_form)
mock_conn = AsyncMock()
- mock_conn.fetchrow.return_value = {
- "name": "nws",
- "enabled": True,
- "cadence_s": 60,
- "settings": {"contact_email": "test@example.com"},
- "paused_at": None,
- "updated_at": None,
- }
+ mock_conn.fetchrow.side_effect = [
+ {
+ "name": "nws",
+ "enabled": True,
+ "cadence_s": 60,
+ "settings": {"contact_email": "test@example.com", "region": {"north": 49, "south": 24, "east": -66, "west": -125}},
+ "paused_at": None,
+ "updated_at": None,
+ },
+ {"map_tile_url": None, "map_attribution": None}, # system settings for re-render
+ ]
mock_conn.fetch.return_value = []
mock_pool = MagicMock()
@@ -252,6 +266,10 @@ class TestAdaptersEditSubmit:
mock_form.get.side_effect = lambda k, d="": {
"cadence_s": "300",
"api_key_alias": "nonexistent_key",
+ "region_north": "49.5",
+ "region_south": "31.0",
+ "region_east": "-102.0",
+ "region_west": "-124.5",
}.get(k, d)
mock_form.getlist.return_value = ["VIIRS_SNPP_NRT"]
mock_form.__contains__ = lambda self, k: k == "enabled"
@@ -263,11 +281,12 @@ class TestAdaptersEditSubmit:
"name": "firms",
"enabled": True,
"cadence_s": 300,
- "settings": {"api_key_alias": "firms", "satellites": ["VIIRS_SNPP_NRT"]},
+ "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,
},
None, # Second call: check api_key exists - returns None
+ {"map_tile_url": None, "map_attribution": None}, # system settings for re-render
]
mock_conn.fetch.return_value = []
@@ -306,20 +325,27 @@ class TestAdaptersEditSubmit:
mock_form.get.side_effect = lambda k, d="": {
"cadence_s": "120",
"feed": "invalid_feed",
+ "region_north": "49.0",
+ "region_south": "24.0",
+ "region_east": "-66.0",
+ "region_west": "-125.0",
}.get(k, d)
mock_form.getlist.return_value = []
mock_form.__contains__ = lambda self, k: k == "enabled"
mock_request.form = AsyncMock(return_value=mock_form)
mock_conn = AsyncMock()
- mock_conn.fetchrow.return_value = {
- "name": "usgs_quake",
- "enabled": True,
- "cadence_s": 120,
- "settings": {"feed": "all_hour"},
- "paused_at": None,
- "updated_at": None,
- }
+ mock_conn.fetchrow.side_effect = [
+ {
+ "name": "usgs_quake",
+ "enabled": True,
+ "cadence_s": 120,
+ "settings": {"feed": "all_hour", "region": {"north": 49, "south": 24, "east": -66, "west": -125}},
+ "paused_at": None,
+ "updated_at": None,
+ },
+ {"map_tile_url": None, "map_attribution": None}, # system settings for re-render
+ ]
mock_conn.fetch.return_value = []
mock_pool = MagicMock()
@@ -360,6 +386,10 @@ class TestAdaptersAudit:
mock_form.get.side_effect = lambda k, d="": {
"cadence_s": "120",
"contact_email": "new@example.com",
+ "region_north": "49.0",
+ "region_south": "24.0",
+ "region_east": "-66.0",
+ "region_west": "-125.0",
}.get(k, d)
mock_form.getlist.return_value = []
mock_form.__contains__ = lambda self, k: k == "enabled"
@@ -370,7 +400,7 @@ class TestAdaptersAudit:
"name": "nws",
"enabled": True,
"cadence_s": 60,
- "settings": {"contact_email": "old@example.com"},
+ "settings": {"contact_email": "old@example.com", "region": {"north": 49, "south": 24, "east": -66, "west": -125}},
"paused_at": None,
"updated_at": None,
}
@@ -422,6 +452,10 @@ class TestAdaptersJsonbRegression:
mock_form.get.side_effect = lambda k, d="": {
"cadence_s": "120",
"contact_email": "test@example.com",
+ "region_north": "49.0",
+ "region_south": "24.0",
+ "region_east": "-66.0",
+ "region_west": "-125.0",
}.get(k, d)
mock_form.getlist.return_value = []
mock_form.__contains__ = lambda self, k: k == "enabled"
@@ -432,7 +466,7 @@ class TestAdaptersJsonbRegression:
"name": "nws",
"enabled": True,
"cadence_s": 60,
- "settings": {"contact_email": "old@example.com"}, # dict, as asyncpg returns
+ "settings": {"contact_email": "old@example.com", "region": {"north": 49, "south": 24, "east": -66, "west": -125}}, # dict, as asyncpg returns
"paused_at": None,
"updated_at": None,
}
@@ -471,6 +505,10 @@ class TestAdaptersJsonbRegression:
mock_form.get.side_effect = lambda k, d="": {
"cadence_s": "120",
"contact_email": "new@example.com",
+ "region_north": "49.0",
+ "region_south": "24.0",
+ "region_east": "-66.0",
+ "region_west": "-125.0",
}.get(k, d)
mock_form.getlist.return_value = []
mock_form.__contains__ = lambda self, k: k == "enabled"
@@ -481,7 +519,7 @@ class TestAdaptersJsonbRegression:
"name": "nws",
"enabled": True,
"cadence_s": 60,
- "settings": {"contact_email": "old@example.com"}, # dict
+ "settings": {"contact_email": "old@example.com", "region": {"north": 49, "south": 24, "east": -66, "west": -125}}, # dict
"paused_at": None,
"updated_at": None,
}
diff --git a/tests/test_region_picker.py b/tests/test_region_picker.py
new file mode 100644
index 0000000..f5a8816
--- /dev/null
+++ b/tests/test_region_picker.py
@@ -0,0 +1,372 @@
+"""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
+
+ mock_csrf = MagicMock()
+ mock_csrf.generate_csrf_tokens.return_value = ("token", "signed")
+ mock_csrf.set_csrf_cookie = MagicMock()
+
+ 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", mock_csrf)
+
+ 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_form.get.side_effect = lambda k, d="": {
+ "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)
+
+ mock_csrf = MagicMock()
+ mock_csrf.validate_csrf = AsyncMock()
+
+ 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", mock_csrf)
+
+ 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_form.get.side_effect = lambda k, d="": {
+ "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
+
+ mock_csrf = MagicMock()
+ mock_csrf.validate_csrf = AsyncMock()
+ mock_csrf.generate_csrf_tokens.return_value = ("token", "signed")
+ mock_csrf.set_csrf_cookie = MagicMock()
+
+ 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", mock_csrf)
+
+ 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_form.get.side_effect = lambda k, d="": {
+ "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
+
+ mock_csrf = MagicMock()
+ mock_csrf.validate_csrf = AsyncMock()
+ mock_csrf.generate_csrf_tokens.return_value = ("token", "signed")
+ mock_csrf.set_csrf_cookie = MagicMock()
+
+ 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", mock_csrf)
+
+ 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_form.get.side_effect = lambda k, d="": {
+ "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
+
+ mock_csrf = MagicMock()
+ mock_csrf.validate_csrf = AsyncMock()
+ mock_csrf.generate_csrf_tokens.return_value = ("token", "signed")
+ mock_csrf.set_csrf_cookie = MagicMock()
+
+ 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", mock_csrf)
+
+ 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_form.get.side_effect = lambda k, d="": {
+ "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)
+
+ mock_csrf = MagicMock()
+ mock_csrf.validate_csrf = AsyncMock()
+
+ 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", mock_csrf)
+
+ # 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