Merge pull request #17 from zvx-echo6/feature/1b-4-adapters-list-edit

feat(gui): adapters list and edit UI (1b-4)
This commit is contained in:
malice 2026-05-17 16:04:54 -06:00 committed by GitHub
commit 4368c83613
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 933 additions and 8 deletions

View file

@ -9,6 +9,7 @@ AUTH_LOGIN_FAILED = "auth.login_failed"
AUTH_LOGOUT = "auth.logout" AUTH_LOGOUT = "auth.logout"
AUTH_PASSWORD_CHANGE = "auth.password_change" AUTH_PASSWORD_CHANGE = "auth.password_change"
OPERATOR_CREATE = "operator.create" OPERATOR_CREATE = "operator.create"
ADAPTER_UPDATE = "adapter.update"
async def write_audit( async def write_audit(
@ -20,18 +21,15 @@ async def write_audit(
after: dict[str, Any] | None = None, after: dict[str, Any] | None = None,
) -> None: ) -> None:
"""Write an audit log entry.""" """Write an audit log entry."""
# Serialize before/after as JSON strings if provided # asyncpg handles dict -> jsonb conversion automatically
before_json = json.dumps(before) if before else None
after_json = json.dumps(after) if after else None
await conn.execute( await conn.execute(
""" """
INSERT INTO config.audit_log (operator_id, action, target, before, after) 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, operator_id,
action, action,
target, target,
before_json, before,
after_json, after,
) )

View file

@ -1,5 +1,9 @@
"""Route handlers for Central GUI.""" """Route handlers for Central GUI."""
import json
import re
from typing import Any
from fastapi import APIRouter, Depends, Form, Request from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi_csrf_protect import CsrfProtect from fastapi_csrf_protect import CsrfProtect
@ -12,6 +16,7 @@ from central.gui.auth import (
verify_password, verify_password,
) )
from central.gui.audit import ( from central.gui.audit import (
ADAPTER_UPDATE,
AUTH_LOGIN, AUTH_LOGIN,
AUTH_LOGIN_FAILED, AUTH_LOGIN_FAILED,
AUTH_LOGOUT, AUTH_LOGOUT,
@ -26,6 +31,21 @@ router = APIRouter()
# Streams to display on dashboard # Streams to display on dashboard
DASHBOARD_STREAMS = ["CENTRAL_WX", "CENTRAL_FIRE", "CENTRAL_QUAKE", "CENTRAL_META"] 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(): def _get_templates():
"""Get templates instance (deferred import to avoid circular).""" """Get templates instance (deferred import to avoid circular)."""
@ -199,7 +219,6 @@ async def dashboard_polls(request: Request) -> HTMLResponse:
try: try:
msgs = await sub.fetch(1, timeout=1.0) msgs = await sub.fetch(1, timeout=1.0)
if msgs: if msgs:
import json
data = json.loads(msgs[0].data.decode()) data = json.loads(msgs[0].data.decode())
last_poll = data.get("data", {}).get("time", "") last_poll = data.get("data", {}).get("time", "")
adapters.append({ adapters.append({
@ -531,3 +550,285 @@ async def change_password_submit(
# Redirect to index # Redirect to index
return RedirectResponse(url="/", status_code=302) 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:
settings = row["settings"] or {}
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"
)
settings = row["settings"] or {}
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")
current_settings = row["settings"] or {}
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,
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)

View file

@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}Central — Edit {{ adapter.name }}{% endblock %}
{% block content %}
<h1>Edit Adapter: {{ adapter.name }}</h1>
<form method="post" action="/adapters/{{ adapter.name }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<fieldset>
<legend>Universal Settings</legend>
<label>
<input type="checkbox" name="enabled" {% if adapter.enabled %}checked{% endif %}>
Enabled
</label>
<label for="cadence_s">Cadence (seconds)</label>
<input type="number" id="cadence_s" name="cadence_s" value="{{ form_data.cadence_s if form_data else adapter.cadence_s }}" min="60" max="3600" required>
{% if errors and errors.cadence_s %}
<small style="color: var(--pico-color-red-500);">{{ errors.cadence_s }}</small>
{% endif %}
</fieldset>
<fieldset>
<legend>Adapter-Specific Settings</legend>
{% include "adapters_edit_" + adapter.name + ".html" %}
</fieldset>
<fieldset>
<legend>Region (read-only)</legend>
{% if adapter.settings.region %}
<p>
<strong>North:</strong> {{ adapter.settings.region.north }}<br>
<strong>South:</strong> {{ adapter.settings.region.south }}<br>
<strong>East:</strong> {{ adapter.settings.region.east }}<br>
<strong>West:</strong> {{ adapter.settings.region.west }}
</p>
{% else %}
<p>No region configured.</p>
{% endif %}
<small>Region editing comes in 1b-5.</small>
</fieldset>
<button type="submit">Save Changes</button>
<a href="/adapters" role="button" class="outline">Cancel</a>
</form>
{% endblock %}

View file

@ -0,0 +1,21 @@
<label for="api_key_alias">API Key Alias</label>
<select id="api_key_alias" name="api_key_alias">
<option value="" {% if not (form_data.api_key_alias if form_data else adapter.settings.api_key_alias) %}selected{% endif %}>(none)</option>
{% for key in api_keys %}
<option value="{{ key.alias }}" {% if (form_data.api_key_alias if form_data else adapter.settings.api_key_alias) == key.alias %}selected{% endif %}>{{ key.alias }}</option>
{% endfor %}
</select>
{% if errors and errors.api_key_alias %}
<small style="color: var(--pico-color-red-500);">{{ errors.api_key_alias }}</small>
{% endif %}
<label>Satellites</label>
{% for sat in valid_satellites %}
<label>
<input type="checkbox" name="satellites" value="{{ sat }}" {% if sat in (form_data.satellites if form_data else adapter.settings.satellites or []) %}checked{% endif %}>
{{ sat }}
</label>
{% endfor %}
{% if errors and errors.satellites %}
<small style="color: var(--pico-color-red-500);">{{ errors.satellites }}</small>
{% endif %}

View file

@ -0,0 +1,5 @@
<label for="contact_email">Contact Email</label>
<input type="email" id="contact_email" name="contact_email" value="{{ form_data.contact_email if form_data else adapter.settings.contact_email }}" required>
{% if errors and errors.contact_email %}
<small style="color: var(--pico-color-red-500);">{{ errors.contact_email }}</small>
{% endif %}

View file

@ -0,0 +1,9 @@
<label for="feed">Feed</label>
<select id="feed" name="feed" required>
{% for f in valid_feeds %}
<option value="{{ f }}" {% if (form_data.feed if form_data else adapter.settings.feed) == f %}selected{% endif %}>{{ f }}</option>
{% endfor %}
</select>
{% if errors and errors.feed %}
<small style="color: var(--pico-color-red-500);">{{ errors.feed }}</small>
{% endif %}

View file

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Central — Adapters{% endblock %}
{% block content %}
<h1>Adapters</h1>
<table>
<thead>
<tr>
<th>Name</th>
<th>Enabled</th>
<th>Cadence</th>
<th>Last Updated</th>
<th></th>
</tr>
</thead>
<tbody>
{% for adapter in adapters %}
<tr>
<td>{{ adapter.name }}</td>
<td>{% if adapter.enabled %}Yes{% else %}No{% endif %}</td>
<td>{{ adapter.cadence_s }}s</td>
<td>{{ adapter.updated_at.strftime('%Y-%m-%d %H:%M') if adapter.updated_at else '—' }}</td>
<td><a href="/adapters/{{ adapter.name }}">Edit</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -15,6 +15,8 @@
</ul> </ul>
<ul> <ul>
{% if operator %} {% if operator %}
<li><a href="/">Dashboard</a></li>
<li><a href="/adapters">Adapters</a></li>
<li>{{ operator.username }}</li> <li>{{ operator.username }}</li>
<li><a href="/change-password">Change Password</a></li> <li><a href="/change-password">Change Password</a></li>
<li> <li>

511
tests/test_adapters.py Normal file
View file

@ -0,0 +1,511 @@
"""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"
class TestAdaptersJsonbRegression:
"""Regression tests for JSONB double-encoding bug."""
@pytest.mark.asyncio
async def test_settings_passed_as_dict_not_string(self):
"""Verify settings is passed to UPDATE as dict, not json.dumps string.
Regression test for double-encoding bug where json.dumps() was called
on settings before passing to asyncpg, which already handles dict->jsonb.
"""
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": "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": "old@example.com"}, # dict, as asyncpg returns
"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):
await adapters_edit_submit(mock_request, "nws", mock_csrf)
# Get the settings argument passed to execute (3rd positional arg after query)
call_args = mock_conn.execute.call_args
# args[0] is the query, args[1:] are the parameters
settings_arg = call_args[0][3] # enabled=$1, cadence=$2, settings=$3
# CRITICAL: settings must be a dict, NOT a string
# If json.dumps() was called, this would be a str like {contact_email: ...}
assert isinstance(settings_arg, dict), f"settings should be dict, got {type(settings_arg)}: {settings_arg}"
assert settings_arg["contact_email"] == "test@example.com"
@pytest.mark.asyncio
async def test_audit_before_after_passed_as_dict(self):
"""Verify audit before/after are passed as dicts, not json.dumps strings."""
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"}, # dict
"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["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):
await adapters_edit_submit(mock_request, "nws", mock_csrf)
# CRITICAL: before and after must be dicts, NOT strings
assert isinstance(captured_audit["before"], dict), f"before should be dict, got {type(captured_audit['before'])}"
assert isinstance(captured_audit["after"], dict), f"after should be dict, got {type(captured_audit['after'])}"
assert isinstance(captured_audit["before"]["settings"], dict), "before.settings should be dict"
assert isinstance(captured_audit["after"]["settings"], dict), "after.settings should be dict"