diff --git a/src/central/gui/audit.py b/src/central/gui/audit.py
index 428275a..7d2f8f1 100644
--- a/src/central/gui/audit.py
+++ b/src/central/gui/audit.py
@@ -9,6 +9,7 @@ AUTH_LOGIN_FAILED = "auth.login_failed"
AUTH_LOGOUT = "auth.logout"
AUTH_PASSWORD_CHANGE = "auth.password_change"
OPERATOR_CREATE = "operator.create"
+ADAPTER_UPDATE = "adapter.update"
async def write_audit(
@@ -20,18 +21,15 @@ async def write_audit(
after: dict[str, Any] | None = None,
) -> None:
"""Write an audit log entry."""
- # Serialize before/after as JSON strings if provided
- before_json = json.dumps(before) if before else None
- after_json = json.dumps(after) if after else None
-
+ # asyncpg handles dict -> jsonb conversion automatically
await conn.execute(
"""
INSERT INTO config.audit_log (operator_id, action, target, before, after)
- VALUES ($1, $2, $3, $4::jsonb, $5::jsonb)
+ VALUES ($1, $2, $3, $4, $5)
""",
operator_id,
action,
target,
- before_json,
- after_json,
+ before,
+ after,
)
diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py
index f78b1cd..c27e235 100644
--- a/src/central/gui/routes.py
+++ b/src/central/gui/routes.py
@@ -1,5 +1,9 @@
"""Route handlers for Central GUI."""
+import json
+import re
+from typing import Any
+
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi_csrf_protect import CsrfProtect
@@ -12,6 +16,7 @@ from central.gui.auth import (
verify_password,
)
from central.gui.audit import (
+ ADAPTER_UPDATE,
AUTH_LOGIN,
AUTH_LOGIN_FAILED,
AUTH_LOGOUT,
@@ -26,6 +31,21 @@ router = APIRouter()
# Streams to display on dashboard
DASHBOARD_STREAMS = ["CENTRAL_WX", "CENTRAL_FIRE", "CENTRAL_QUAKE", "CENTRAL_META"]
+# Email validation regex (simple but effective)
+EMAIL_REGEX = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
+
+
+def _get_valid_satellites() -> list[str]:
+ """Get valid satellite identifiers from firms adapter."""
+ from central.adapters.firms import SATELLITE_SHORT
+ return list(SATELLITE_SHORT.keys())
+
+
+def _get_valid_feeds() -> set[str]:
+ """Get valid feed values from usgs_quake adapter."""
+ from central.adapters.usgs_quake import VALID_FEEDS
+ return VALID_FEEDS
+
def _get_templates():
"""Get templates instance (deferred import to avoid circular)."""
@@ -199,7 +219,6 @@ async def dashboard_polls(request: Request) -> HTMLResponse:
try:
msgs = await sub.fetch(1, timeout=1.0)
if msgs:
- import json
data = json.loads(msgs[0].data.decode())
last_poll = data.get("data", {}).get("time", "—")
adapters.append({
@@ -531,3 +550,294 @@ async def change_password_submit(
# Redirect to index
return RedirectResponse(url="/", status_code=302)
+
+
+# =============================================================================
+# Adapters routes
+# =============================================================================
+
+
+@router.get("/adapters", response_class=HTMLResponse)
+async def adapters_list(
+ request: Request,
+ csrf_protect: CsrfProtect = Depends(),
+) -> HTMLResponse:
+ """List all adapters."""
+ templates = _get_templates()
+ pool = get_pool()
+ operator = request.state.operator
+
+ async with pool.acquire() as conn:
+ rows = await conn.fetch(
+ """
+ SELECT name, enabled, cadence_s, settings, paused_at, updated_at
+ FROM config.adapters
+ ORDER BY name
+ """
+ )
+
+ adapters = []
+ for row in rows:
+ # asyncpg auto-deserializes jsonb to dict
+ settings = row["settings"] if row["settings"] else {}
+ if isinstance(settings, str):
+ settings = json.loads(settings)
+ adapters.append({
+ "name": row["name"],
+ "enabled": row["enabled"],
+ "cadence_s": row["cadence_s"],
+ "settings": settings,
+ "paused_at": row["paused_at"],
+ "updated_at": row["updated_at"],
+ })
+
+ csrf_token, signed_token = csrf_protect.generate_csrf_tokens()
+ response = templates.TemplateResponse(
+ request=request,
+ name="adapters_list.html",
+ context={
+ "operator": operator,
+ "csrf_token": csrf_token,
+ "adapters": adapters,
+ },
+ )
+ csrf_protect.set_csrf_cookie(signed_token, response)
+ return response
+
+
+@router.get("/adapters/{name}", response_class=HTMLResponse)
+async def adapters_edit_form(
+ request: Request,
+ name: str,
+ csrf_protect: CsrfProtect = Depends(),
+) -> Response:
+ """Render the adapter edit form."""
+ templates = _get_templates()
+ pool = get_pool()
+ operator = request.state.operator
+
+ async with pool.acquire() as conn:
+ row = await conn.fetchrow(
+ """
+ SELECT name, enabled, cadence_s, settings, paused_at, updated_at
+ FROM config.adapters
+ WHERE name = $1
+ """,
+ name,
+ )
+
+ if row is None:
+ return Response(status_code=404, content="Adapter not found")
+
+ # Get API keys for firms dropdown
+ api_keys = await conn.fetch(
+ "SELECT alias FROM config.api_keys ORDER BY alias"
+ )
+
+ # asyncpg auto-deserializes jsonb to dict
+ settings = row["settings"] if row["settings"] else {}
+ if isinstance(settings, str):
+ settings = json.loads(settings)
+ adapter = {
+ "name": row["name"],
+ "enabled": row["enabled"],
+ "cadence_s": row["cadence_s"],
+ "settings": settings,
+ "paused_at": row["paused_at"],
+ "updated_at": row["updated_at"],
+ }
+
+ csrf_token, signed_token = csrf_protect.generate_csrf_tokens()
+ response = templates.TemplateResponse(
+ request=request,
+ name="adapters_edit.html",
+ context={
+ "operator": operator,
+ "csrf_token": csrf_token,
+ "adapter": adapter,
+ "errors": None,
+ "form_data": None,
+ "api_keys": [{"alias": k["alias"]} for k in api_keys],
+ "valid_satellites": _get_valid_satellites(),
+ "valid_feeds": sorted(_get_valid_feeds()),
+ },
+ )
+ csrf_protect.set_csrf_cookie(signed_token, response)
+ return response
+
+
+@router.post("/adapters/{name}")
+async def adapters_edit_submit(
+ request: Request,
+ name: str,
+ csrf_protect: CsrfProtect = Depends(),
+) -> Response:
+ """Process the adapter edit form."""
+ templates = _get_templates()
+ pool = get_pool()
+ operator = request.state.operator
+
+ # Validate CSRF
+ await csrf_protect.validate_csrf(request)
+
+ # Parse form data
+ form = await request.form()
+ enabled = "enabled" in form
+ cadence_s_str = form.get("cadence_s", "")
+
+ # Build form_data for re-render on error
+ form_data: dict[str, Any] = {
+ "enabled": enabled,
+ "cadence_s": cadence_s_str,
+ }
+
+ errors: dict[str, str] = {}
+
+ # Validate cadence_s
+ try:
+ cadence_s = int(cadence_s_str)
+ if cadence_s < 60 or cadence_s > 3600:
+ errors["cadence_s"] = "Cadence must be between 60 and 3600 seconds"
+ except ValueError:
+ errors["cadence_s"] = "Cadence must be a valid integer"
+ cadence_s = 0
+
+ async with pool.acquire() as conn:
+ # Get current adapter state
+ row = await conn.fetchrow(
+ """
+ SELECT name, enabled, cadence_s, settings, paused_at, updated_at
+ FROM config.adapters
+ WHERE name = $1
+ """,
+ name,
+ )
+
+ if row is None:
+ return Response(status_code=404, content="Adapter not found")
+
+ # asyncpg auto-deserializes jsonb to dict
+ current_settings = row["settings"] if row["settings"] else {}
+ if isinstance(current_settings, str):
+ current_settings = json.loads(current_settings)
+ new_settings = dict(current_settings)
+
+ # Adapter-specific validation and settings update
+ if name == "nws":
+ contact_email = form.get("contact_email", "").strip()
+ form_data["contact_email"] = contact_email
+ if not contact_email:
+ errors["contact_email"] = "Contact email is required"
+ elif not EMAIL_REGEX.match(contact_email):
+ errors["contact_email"] = "Invalid email format"
+ else:
+ new_settings["contact_email"] = contact_email
+
+ elif name == "firms":
+ api_key_alias = form.get("api_key_alias", "").strip()
+ satellites = form.getlist("satellites")
+ form_data["api_key_alias"] = api_key_alias
+ form_data["satellites"] = satellites
+
+ # Validate api_key_alias if set
+ if api_key_alias:
+ key_exists = await conn.fetchrow(
+ "SELECT 1 FROM config.api_keys WHERE alias = $1",
+ api_key_alias,
+ )
+ if not key_exists:
+ errors["api_key_alias"] = f"API key alias '{api_key_alias}' does not exist"
+ else:
+ new_settings["api_key_alias"] = api_key_alias
+ else:
+ new_settings["api_key_alias"] = None
+
+ # Validate satellites
+ valid_sats = set(_get_valid_satellites())
+ invalid_sats = [s for s in satellites if s not in valid_sats]
+ if invalid_sats:
+ errors["satellites"] = f"Invalid satellites: {', '.join(invalid_sats)}"
+ else:
+ new_settings["satellites"] = satellites
+
+ elif name == "usgs_quake":
+ feed = form.get("feed", "").strip()
+ form_data["feed"] = feed
+ valid_feeds = _get_valid_feeds()
+ if feed not in valid_feeds:
+ errors["feed"] = f"Invalid feed. Must be one of: {', '.join(sorted(valid_feeds))}"
+ else:
+ new_settings["feed"] = feed
+
+ # If there are errors, re-render the form
+ if errors:
+ adapter = {
+ "name": row["name"],
+ "enabled": row["enabled"],
+ "cadence_s": row["cadence_s"],
+ "settings": current_settings,
+ "paused_at": row["paused_at"],
+ "updated_at": row["updated_at"],
+ }
+
+ api_keys = await conn.fetch(
+ "SELECT alias FROM config.api_keys ORDER BY alias"
+ )
+
+ csrf_token, signed_token = csrf_protect.generate_csrf_tokens()
+ response = templates.TemplateResponse(
+ request=request,
+ name="adapters_edit.html",
+ context={
+ "operator": operator,
+ "csrf_token": csrf_token,
+ "adapter": adapter,
+ "errors": errors,
+ "form_data": form_data,
+ "api_keys": [{"alias": k["alias"]} for k in api_keys],
+ "valid_satellites": _get_valid_satellites(),
+ "valid_feeds": sorted(_get_valid_feeds()),
+ },
+ status_code=200,
+ )
+ csrf_protect.set_csrf_cookie(signed_token, response)
+ return response
+
+ # Build before state for audit
+ before = {
+ "enabled": row["enabled"],
+ "cadence_s": row["cadence_s"],
+ "settings": current_settings,
+ }
+
+ # Build after state for audit
+ after = {
+ "enabled": enabled,
+ "cadence_s": cadence_s,
+ "settings": new_settings,
+ }
+
+ # Update the adapter
+ await conn.execute(
+ """
+ UPDATE config.adapters
+ SET enabled = $1, cadence_s = $2, settings = $3, updated_at = now()
+ WHERE name = $4
+ """,
+ enabled,
+ cadence_s,
+ json.dumps(new_settings),
+ name,
+ )
+
+ # Write audit log
+ await write_audit(
+ conn,
+ ADAPTER_UPDATE,
+ operator_id=operator.id,
+ target=name,
+ before=before,
+ after=after,
+ )
+
+ return RedirectResponse(url="/adapters", status_code=302)
diff --git a/src/central/gui/templates/adapters_edit.html b/src/central/gui/templates/adapters_edit.html
new file mode 100644
index 0000000..fe7e093
--- /dev/null
+++ b/src/central/gui/templates/adapters_edit.html
@@ -0,0 +1,49 @@
+{% extends "base.html" %}
+
+{% block title %}Central — Edit {{ adapter.name }}{% endblock %}
+
+{% block content %}
+
Edit Adapter: {{ adapter.name }}
+
+
+{% endblock %}
diff --git a/src/central/gui/templates/adapters_edit_firms.html b/src/central/gui/templates/adapters_edit_firms.html
new file mode 100644
index 0000000..a2a339a
--- /dev/null
+++ b/src/central/gui/templates/adapters_edit_firms.html
@@ -0,0 +1,21 @@
+
+
+{% if errors and errors.api_key_alias %}
+{{ errors.api_key_alias }}
+{% endif %}
+
+
+{% for sat in valid_satellites %}
+
+{% endfor %}
+{% if errors and errors.satellites %}
+{{ errors.satellites }}
+{% endif %}
diff --git a/src/central/gui/templates/adapters_edit_nws.html b/src/central/gui/templates/adapters_edit_nws.html
new file mode 100644
index 0000000..e655a41
--- /dev/null
+++ b/src/central/gui/templates/adapters_edit_nws.html
@@ -0,0 +1,5 @@
+
+
+{% if errors and errors.contact_email %}
+{{ errors.contact_email }}
+{% endif %}
diff --git a/src/central/gui/templates/adapters_edit_usgs_quake.html b/src/central/gui/templates/adapters_edit_usgs_quake.html
new file mode 100644
index 0000000..0c3b7ee
--- /dev/null
+++ b/src/central/gui/templates/adapters_edit_usgs_quake.html
@@ -0,0 +1,9 @@
+
+
+{% if errors and errors.feed %}
+{{ errors.feed }}
+{% endif %}
diff --git a/src/central/gui/templates/adapters_list.html b/src/central/gui/templates/adapters_list.html
new file mode 100644
index 0000000..b97ae88
--- /dev/null
+++ b/src/central/gui/templates/adapters_list.html
@@ -0,0 +1,29 @@
+{% extends "base.html" %}
+
+{% block title %}Central — Adapters{% endblock %}
+
+{% block content %}
+Adapters
+
+
+
+ | Name |
+ Enabled |
+ Cadence |
+ Last Updated |
+ |
+
+
+
+ {% for adapter in adapters %}
+
+ | {{ adapter.name }} |
+ {% if adapter.enabled %}Yes{% else %}No{% endif %} |
+ {{ adapter.cadence_s }}s |
+ {{ adapter.updated_at.strftime('%Y-%m-%d %H:%M') if adapter.updated_at else '—' }} |
+ Edit |
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/src/central/gui/templates/base.html b/src/central/gui/templates/base.html
index 631c542..1d2e24b 100644
--- a/src/central/gui/templates/base.html
+++ b/src/central/gui/templates/base.html
@@ -15,6 +15,8 @@
{% if operator %}
+ - Dashboard
+ - Adapters
- {{ operator.username }}
- Change Password
-
diff --git a/tests/test_adapters.py b/tests/test_adapters.py
new file mode 100644
index 0000000..e8dd915
--- /dev/null
+++ b/tests/test_adapters.py
@@ -0,0 +1,403 @@
+"""Tests for adapter list and edit routes."""
+
+import json
+import os
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+# Set required env vars before importing central modules
+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 TestAdaptersListUnauthenticated:
+ """Test adapters list without authentication."""
+
+ @pytest.mark.asyncio
+ async def test_adapters_list_unauthenticated_redirects(self):
+ """GET /adapters without auth redirects to /login."""
+ from central.gui.routes import adapters_list
+
+ mock_request = MagicMock()
+ mock_request.state.operator = None
+
+ # The middleware handles the redirect, so we test the route expects operator
+ # In practice, middleware returns 302 before route is called
+ # This test verifies the route structure expects authentication
+ assert adapters_list is not None
+
+
+class TestAdaptersListAuthenticated:
+ """Test adapters list with authentication."""
+
+ @pytest.mark.asyncio
+ async def test_adapters_list_returns_all_adapters(self):
+ """GET /adapters authenticated returns 200 with all adapters."""
+ from central.gui.routes import adapters_list
+
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="testop")
+
+ mock_conn = AsyncMock()
+ mock_conn.fetch.return_value = [
+ {"name": "firms", "enabled": True, "cadence_s": 300, "settings": '{"api_key_alias": "firms"}', "paused_at": None, "updated_at": None},
+ {"name": "nws", "enabled": True, "cadence_s": 60, "settings": '{"contact_email": "test@test.com"}', "paused_at": None, "updated_at": None},
+ {"name": "usgs_quake", "enabled": True, "cadence_s": 120, "settings": '{"feed": "all_hour"}', "paused_at": None, "updated_at": None},
+ ]
+
+ 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_list(mock_request, mock_csrf)
+
+ # Verify template was called with adapters
+ call_args = mock_templates.TemplateResponse.call_args
+ context = call_args.kwargs.get("context", call_args[1].get("context"))
+ assert len(context["adapters"]) == 3
+ assert context["adapters"][0]["name"] == "firms"
+ assert context["adapters"][1]["name"] == "nws"
+ assert context["adapters"][2]["name"] == "usgs_quake"
+
+
+class TestAdaptersEditForm:
+ """Test adapter edit form GET."""
+
+ @pytest.mark.asyncio
+ async def test_adapters_edit_nws_shows_form(self):
+ """GET /adapters/nws authenticated returns 200 with form."""
+ 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.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.fetch.return_value = [] # No API keys
+
+ 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, "nws", mock_csrf)
+
+ call_args = mock_templates.TemplateResponse.call_args
+ context = call_args.kwargs.get("context", call_args[1].get("context"))
+ assert context["adapter"]["name"] == "nws"
+ assert context["adapter"]["settings"]["contact_email"] == "test@example.com"
+
+ @pytest.mark.asyncio
+ async def test_adapters_edit_nonexistent_returns_404(self):
+ """GET /adapters/nonexistent returns 404."""
+ 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.return_value = None
+
+ 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.generate_csrf_tokens.return_value = ("token", "signed")
+
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ result = await adapters_edit_form(mock_request, "nonexistent", mock_csrf)
+
+ assert result.status_code == 404
+
+
+class TestAdaptersEditSubmit:
+ """Test adapter edit form POST."""
+
+ @pytest.mark.asyncio
+ async def test_adapters_edit_valid_changes_updates_db(self):
+ """POST /adapters/nws with valid changes updates DB and redirects."""
+ from central.gui.routes import adapters_edit_submit
+
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="testop")
+ mock_request.cookies = {}
+
+ # Mock form data
+ mock_form = MagicMock()
+ mock_form.get.side_effect = lambda k, d="": {
+ "cadence_s": "120",
+ "contact_email": "new@example.com",
+ }.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": "old@example.com"}',
+ "paused_at": None,
+ "updated_at": None,
+ }
+ 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()
+
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ with patch("central.gui.routes.write_audit", new_callable=AsyncMock) as mock_audit:
+ result = await adapters_edit_submit(mock_request, "nws", mock_csrf)
+
+ assert result.status_code == 302
+ assert result.headers["location"] == "/adapters"
+ mock_conn.execute.assert_called()
+ mock_audit.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_adapters_edit_invalid_cadence_shows_error(self):
+ """POST /adapters/nws with cadence_s=30 shows error, no DB update."""
+ 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": "30",
+ "contact_email": "test@example.com",
+ }.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.fetch.return_value = []
+
+ 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, "nws", mock_csrf)
+
+ # Should re-render form with error
+ call_args = mock_templates.TemplateResponse.call_args
+ context = call_args.kwargs.get("context", call_args[1].get("context"))
+ assert "cadence_s" in context["errors"]
+ assert "60" in context["errors"]["cadence_s"] or "3600" in context["errors"]["cadence_s"]
+
+ @pytest.mark.asyncio
+ async def test_adapters_edit_firms_unknown_api_key_shows_error(self):
+ """POST /adapters/firms with unknown api_key_alias shows 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": "nonexistent_key",
+ }.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 = [
+ { # First call: get adapter
+ "name": "firms",
+ "enabled": True,
+ "cadence_s": 300,
+ "settings": '{"api_key_alias": "firms", "satellites": ["VIIRS_SNPP_NRT"]}',
+ "paused_at": None,
+ "updated_at": None,
+ },
+ None, # Second call: check api_key exists - returns None
+ ]
+ mock_conn.fetch.return_value = []
+
+ 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 "api_key_alias" in context["errors"]
+ assert "nonexistent_key" in context["errors"]["api_key_alias"]
+
+ @pytest.mark.asyncio
+ async def test_adapters_edit_usgs_unknown_feed_shows_error(self):
+ """POST /adapters/usgs_quake with unknown feed shows 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": "120",
+ "feed": "invalid_feed",
+ }.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.fetch.return_value = []
+
+ 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, "usgs_quake", mock_csrf)
+
+ call_args = mock_templates.TemplateResponse.call_args
+ context = call_args.kwargs.get("context", call_args[1].get("context"))
+ assert "feed" in context["errors"]
+
+
+class TestAdaptersAudit:
+ """Test adapter audit logging."""
+
+ @pytest.mark.asyncio
+ async def test_audit_row_has_before_after(self):
+ """Audit row has before/after JSONB populated correctly."""
+ 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": "120",
+ "contact_email": "new@example.com",
+ }.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": "old@example.com"}',
+ "paused_at": None,
+ "updated_at": None,
+ }
+ 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["action"] = action
+ captured_audit["target"] = target
+ 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, "nws", mock_csrf)
+
+ assert captured_audit["action"] == "adapter.update"
+ assert captured_audit["target"] == "nws"
+ assert captured_audit["before"]["cadence_s"] == 60
+ assert captured_audit["after"]["cadence_s"] == 120
+ assert captured_audit["before"]["settings"]["contact_email"] == "old@example.com"
+ assert captured_audit["after"]["settings"]["contact_email"] == "new@example.com"