central/tests/test_adapters.py
Matt Johnson 7de460bc06 fix(4-1): resolve api_key alias from per-adapter settings, not class attr
The /adapters list view's "⚠️ API Key Missing" chip, the /adapters/{name}
edit form's disabled enable-checkbox, the POST error re-render path, AND
the supervisor's adapter-start precondition all compared the hardcoded
SourceAdapter class attribute `requires_api_key` against `config.api_keys`,
ignoring the per-row `settings[api_key_field]` alias the operator
actually selected via the form.

FIRMS' class attr is `requires_api_key = "firms"`; the api_keys_new.html
placeholder text steers operators toward aliases like `firms_production`
instead, and the FIRMSSettings.api_key_alias field is exactly the
overridable slot that the form writes. The four predicates ignored that
slot, so a working key under any non-default alias was treated as
missing — chip on, checkbox disabled, supervisor refusing to start with
`last_error = "missing api key: firms"`.

Audit: FIRMS is the only adapter today with `requires_api_key != None`.
Every other adapter is unaffected by either the route or supervisor
predicate.

Helper module:
- src/central/api_key_resolver.py exposes:
    resolve_api_key_alias(adapter_cls, settings) -> str | None
        Pure sync function. Returns the alias to consult, or None when no
        key is required. Supervisor uses this directly + its own
        get_api_key.
    adapter_has_resolved_api_key(conn, adapter_cls, settings) -> (bool, alias)
        Async wrapper that runs the SELECT 1 against config.api_keys.
        The three GUI routes use this.
  Resolution: settings[api_key_field] when set to a non-empty str,
  otherwise the class-attr default.

Four call sites swapped:
- routes.py:adapters_list           (/adapters list — warning chip)
- routes.py:adapters_edit_form      (/adapters/{name} edit GET — disabled checkbox)
- routes.py:adapters_edit_submit    (POST error re-render)
- supervisor.py:_start_adapter      (adapter-start precondition)

Side-effect tests/test_adapters.py fix:
- TestAdaptersJsonbRegression::test_adapters_edit_fetches_api_keys_into_context
  used `AsyncMock()` (no return_value) for mock_conn.__aexit__. AsyncMock
  without a return_value yields a MagicMock — which is truthy, and the
  async context manager protocol reads truthy from __aexit__ as
  "exception suppressed." That silently swallowed any error inside
  `async with` blocks. The route refactor moved an assignment inside the
  one async with at site 2, so a swallowed mock error left the variable
  unbound. Fixed: `AsyncMock(return_value=None)` + a comment so the next
  person doesn't re-introduce the bug. fetchval mock added because the
  resolver now issues it (the swallowed exception previously hid the
  missing mock).

Verification:
- pytest: 479 passed (was 469; +10 new resolver tests).
- grep -rn "adapter_cls.requires_api_key" /opt/central/src returns only
  the new helper (2 lines, same file).
- Resolver against live FIRMS settings: resolved_alias='firms_production',
  has_key=True, api_key_missing=False -> NO warning chip, checkbox
  CLICKABLE.
- Supervisor on live CT104: FIRMS flipped enabled=true via DB UPDATE;
  supervisor started the adapter with `api_key_present: true,
  api_key_alias: 'firms_production'`; last_error cleared from "missing
  api key: firms" -> NULL; two satellite polls completed (VIIRS_SNPP_NRT
  477 features, VIIRS_NOAA20_NRT 400 features); 869 new events published
  to JetStream.

NOTE: This commit's verification flipped FIRMS to enabled=true in the
running config — the adapter is now actively polling. Pause via the UI
if that's not intended for now; the bug fix itself does not require
FIRMS to be enabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:08:11 +00:00

485 lines
20 KiB
Python

"""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, "last_error": None},
{"name": "nws", "enabled": True, "cadence_s": 60, "settings": {"contact_email": "test@test.com"}, "paused_at": None, "updated_at": None, "last_error": None},
{"name": "usgs_quake", "enabled": True, "cadence_s": 120, "settings": {"feed": "all_hour"}, "paused_at": None, "updated_at": None, "last_error": 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 adapter classes
mock_firms_cls = MagicMock()
mock_firms_cls.requires_api_key = "firms"
mock_firms_cls.display_name = "FIRMS"
mock_nws_cls = MagicMock()
mock_nws_cls.requires_api_key = None
mock_nws_cls.display_name = "NWS"
mock_usgs_cls = MagicMock()
mock_usgs_cls.requires_api_key = None
mock_usgs_cls.display_name = "USGS Quake"
mock_adapter_classes = {"firms": mock_firms_cls, "nws": mock_nws_cls, "usgs_quake": mock_usgs_cls}
with patch("central.gui.routes._get_templates", return_value=mock_templates):
with patch("central.gui.routes.get_pool", return_value=mock_pool):
with patch("central.gui.routes._adapter_classes", return_value=mock_adapter_classes):
result = await adapters_list(mock_request)
# 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_request.state.csrf_token = "test_csrf"
mock_conn = AsyncMock()
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,
"last_error": None,
},
{"map_tile_url": "https://tile.example.com/{z}/{x}/{y}.png", "map_attribution": "Test"},
]
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, "nws")
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"
# Verify fields are generated
assert "fields" in context
@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)
with patch("central.gui.routes.get_pool", return_value=mock_pool):
result = await adapters_edit_form(mock_request, "nonexistent")
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_request.state.csrf_token = "test_csrf_token"
mock_form.get.side_effect = lambda k, d="": {
"csrf_token": "test_csrf_token",
"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"
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", "region": {"north": 49, "south": 24, "east": -66, "west": -125}},
"paused_at": None,
"updated_at": None,
"last_error": 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)
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")
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=5 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_request.state.csrf_token = "test_csrf_token"
mock_form = MagicMock()
mock_form.get.side_effect = lambda k, d="": {
"csrf_token": "test_csrf_token",
"cadence_s": "5",
"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.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,
"last_error": None,
},
{"map_tile_url": None, "map_attribution": None}, # system settings for re-render
]
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, "nws")
# 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 "10" in context["errors"]["cadence_s"]
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_request.state.csrf_token = "test_csrf_token"
mock_form.get.side_effect = lambda k, d="": {
"csrf_token": "test_csrf_token",
"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"
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", "region": {"north": 49, "south": 24, "east": -66, "west": -125}},
"paused_at": None,
"updated_at": None,
"last_error": 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)
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")
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
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_request.state.csrf_token = "test_csrf_token"
mock_form.get.side_effect = lambda k, d="": {
"csrf_token": "test_csrf_token",
"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"
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", "region": {"north": 49, "south": 24, "east": -66, "west": -125}}, # dict, as asyncpg returns
"paused_at": None,
"updated_at": None,
"last_error": 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)
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")
# 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}"
@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_request.state.csrf_token = "test_csrf_token"
mock_form.get.side_effect = lambda k, d="": {
"csrf_token": "test_csrf_token",
"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"
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", "region": {"north": 49, "south": 24, "east": -66, "west": -125}}, # dict
"paused_at": None,
"updated_at": None,
"last_error": 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)
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")
# 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"
@pytest.mark.asyncio
async def test_adapters_edit_fetches_api_keys_into_context(self):
"""GET /adapters/firms includes api_keys from database in context."""
from central.gui.routes import adapters_edit_form
mock_request = MagicMock()
mock_request.state.operator = MagicMock(id=1, username="testop")
mock_request.state.csrf_token = "test_csrf_token"
mock_conn = MagicMock()
mock_conn.fetchrow = AsyncMock(side_effect=[
# Adapter row
{"name": "firms", "enabled": True, "cadence_s": 300, "settings": {},
"paused_at": None, "updated_at": None, "last_error": None},
# System row
{"map_tile_url": "https://tile.example.com", "map_attribution": "Test"},
])
mock_conn.fetch = AsyncMock(return_value=[
{"alias": "firms_key"},
{"alias": "other_key"},
])
# The /adapters/{name} edit handler also issues a fetchval against
# config.api_keys to resolve whether the adapter's key is present.
# Return 1 (truthy) so the handler proceeds — this test only asserts
# api_keys reaches the template context, not the warning state.
mock_conn.fetchval = AsyncMock(return_value=1)
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
# AsyncMock() with no return_value yields a MagicMock — which is truthy,
# and the async context manager protocol reads a truthy __aexit__ return
# as "exception suppressed." That silently swallows any error inside the
# `async with` block. Pin return_value=None so exceptions propagate.
mock_conn.__aexit__ = AsyncMock(return_value=None)
mock_pool = MagicMock()
mock_pool.acquire = MagicMock(return_value=mock_conn)
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"))
assert "api_keys" in context
assert len(context["api_keys"]) == 2
assert context["api_keys"][0]["alias"] == "firms_key"
assert context["api_keys"][1]["alias"] == "other_key"