central/tests/test_requires_api_key.py

361 lines
15 KiB
Python
Raw Permalink Normal View History

"""Tests for requires_api_key enforcement."""
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock, AsyncMock, patch
import pytest
from central.config_models import AdapterConfig
class TestConfigStoreSetAdapterLastError:
"""Tests for ConfigStore.set_adapter_last_error method."""
@pytest.mark.asyncio
async def test_set_adapter_last_error_updates_row(self):
"""set_adapter_last_error should update the last_error column."""
from central.config_store import ConfigStore
mock_pool = MagicMock()
mock_conn = MagicMock()
mock_conn.execute = AsyncMock()
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock()
mock_pool.acquire = MagicMock(return_value=mock_conn)
config_store = ConfigStore.__new__(ConfigStore)
config_store._pool = mock_pool
await config_store.set_adapter_last_error("firms", "missing api key: firms")
mock_conn.execute.assert_called_once()
call_args = mock_conn.execute.call_args[0]
assert "UPDATE config.adapters SET last_error" in call_args[0]
assert call_args[1] == "missing api key: firms"
assert call_args[2] == "firms"
@pytest.mark.asyncio
async def test_clear_adapter_last_error(self):
"""set_adapter_last_error with None should clear the error."""
from central.config_store import ConfigStore
mock_pool = MagicMock()
mock_conn = MagicMock()
mock_conn.execute = AsyncMock()
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock()
mock_pool.acquire = MagicMock(return_value=mock_conn)
config_store = ConfigStore.__new__(ConfigStore)
config_store._pool = mock_pool
await config_store.set_adapter_last_error("firms", None)
mock_conn.execute.assert_called_once()
call_args = mock_conn.execute.call_args[0]
assert call_args[1] is None
assert call_args[2] == "firms"
class TestRoutesApiKeyMissing:
"""Tests for routes api_key_missing computation."""
@pytest.mark.asyncio
async def test_adapters_list_includes_api_key_missing_flag(self):
"""adapters_list should compute api_key_missing for each adapter."""
from central.gui.routes import adapters_list
mock_request = MagicMock()
mock_request.state = MagicMock()
mock_request.state.operator = {"username": "test"}
mock_request.state.csrf_token = "test_token"
mock_pool = MagicMock()
mock_conn = MagicMock()
mock_conn.fetch = AsyncMock(return_value=[
{"name": "firms", "enabled": False, "cadence_s": 300, "settings": {}, "paused_at": None, "updated_at": None, "last_error": None},
])
mock_conn.fetchval = AsyncMock(return_value=None) # No API key exists
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock()
mock_pool.acquire = MagicMock(return_value=mock_conn)
# Mock adapter class with requires_api_key
mock_firms_cls = MagicMock()
mock_firms_cls.requires_api_key = "firms"
mock_firms_cls.display_name = "FIRMS"
with patch("central.gui.routes._get_templates") as mock_templates:
with patch("central.gui.routes.get_pool", return_value=mock_pool):
with patch("central.gui.routes._adapter_classes", return_value={"firms": mock_firms_cls}):
mock_template_response = MagicMock()
mock_templates.return_value.TemplateResponse = MagicMock(return_value=mock_template_response)
await adapters_list(mock_request)
# Check the context passed to template
call_kwargs = mock_templates.return_value.TemplateResponse.call_args[1]
adapters = call_kwargs["context"]["adapters"]
assert len(adapters) == 1
assert adapters[0]["api_key_missing"] is True
assert adapters[0]["requires_api_key_alias"] == "firms"
class TestAdapterClassRequiresApiKey:
"""Tests for adapter class requires_api_key attribute."""
def test_firms_adapter_requires_api_key(self):
"""FIRMS adapter should declare requires_api_key."""
from central.adapters.firms import FIRMSAdapter
assert FIRMSAdapter.requires_api_key == "firms"
def test_nws_adapter_no_requires_api_key(self):
"""NWS adapter should not require an API key."""
from central.adapters.nws import NWSAdapter
assert NWSAdapter.requires_api_key is None
def test_usgs_quake_adapter_no_requires_api_key(self):
"""USGS Quake adapter should not require an API key."""
from central.adapters.usgs_quake import USGSQuakeAdapter
assert USGSQuakeAdapter.requires_api_key is None
class TestSupervisorApiKeyPrecondition:
"""Tests for supervisor API key precondition check in _start_adapter."""
@pytest.mark.asyncio
async def test_start_adapter_refuses_when_required_key_missing(self, tmp_path: Path):
"""Adapter with requires_api_key but missing key should not start."""
from central.supervisor import Supervisor
from central.adapters.firms import FIRMSAdapter
# Create mock config store
mock_config_store = MagicMock()
mock_config_store.get_api_key = AsyncMock(return_value=None) # Key missing
mock_config_store.set_adapter_last_error = AsyncMock()
# Create mock NATS
mock_nats = MagicMock()
mock_nats.publish = AsyncMock()
# Build supervisor with FIRMS adapter
supervisor = Supervisor.__new__(Supervisor)
supervisor._config_store = mock_config_store
supervisor._adapters = {"firms": FIRMSAdapter}
supervisor._adapter_states = {}
supervisor._nats = mock_nats
supervisor._cursor_db_path = tmp_path / "cursors.db"
supervisor._log = MagicMock()
config = AdapterConfig(
name="firms",
enabled=True,
cadence_s=300,
settings={"api_key_alias": "firms", "satellites": ["VIIRS_SNPP_NRT"]},
updated_at=datetime.now(timezone.utc),
)
await supervisor._start_adapter(config)
# Should have checked for key
mock_config_store.get_api_key.assert_called_once_with("firms")
# Should have set error
mock_config_store.set_adapter_last_error.assert_called_once()
args = mock_config_store.set_adapter_last_error.call_args[0]
assert args[0] == "firms"
assert "missing api key" in args[1].lower()
# Should NOT have created adapter state (adapter did not start)
assert "firms" not in supervisor._adapter_states
# Should NOT have published to NATS
mock_nats.publish.assert_not_called()
@pytest.mark.asyncio
async def test_start_adapter_succeeds_after_key_added_and_clears_last_error(self, tmp_path: Path):
"""Adapter with requires_api_key and key present should start and clear last_error."""
from central.supervisor import Supervisor
from central.adapters.firms import FIRMSAdapter
# Create mock config store with key present
mock_config_store = MagicMock()
mock_config_store.get_api_key = AsyncMock(return_value="encrypted-firms-key")
mock_config_store.set_adapter_last_error = AsyncMock()
# Create mock NATS
mock_nats = MagicMock()
mock_nats.publish = AsyncMock()
# Build supervisor with FIRMS adapter
supervisor = Supervisor.__new__(Supervisor)
supervisor._config_store = mock_config_store
supervisor._adapters = {"firms": FIRMSAdapter}
supervisor._adapter_states = {}
supervisor._nats = mock_nats
supervisor._cursor_db_path = tmp_path / "cursors.db"
supervisor._log = MagicMock()
config = AdapterConfig(
name="firms",
enabled=True,
cadence_s=300,
settings={"api_key_alias": "firms", "satellites": ["VIIRS_SNPP_NRT"]},
updated_at=datetime.now(timezone.utc),
)
# Mock the adapter instantiation to avoid actual HTTP calls
with patch.object(FIRMSAdapter, "__init__", return_value=None):
with patch.object(FIRMSAdapter, "startup", new_callable=AsyncMock):
await supervisor._start_adapter(config)
# Should have checked for key
mock_config_store.get_api_key.assert_called_once_with("firms")
# Should have cleared any stale error (called with None)
mock_config_store.set_adapter_last_error.assert_called_once_with("firms", None)
# Should have created adapter state
assert "firms" in supervisor._adapter_states
@pytest.mark.asyncio
async def test_start_adapter_does_not_check_when_no_requires_api_key(self, tmp_path: Path):
"""Adapter without requires_api_key should skip the API key check."""
from central.supervisor import Supervisor
from central.adapters.nws import NWSAdapter
# Create mock config store
mock_config_store = MagicMock()
mock_config_store.get_api_key = AsyncMock()
mock_config_store.set_adapter_last_error = AsyncMock()
# Create mock NATS
mock_nats = MagicMock()
mock_nats.publish = AsyncMock()
# Build supervisor with NWS adapter (no requires_api_key)
supervisor = Supervisor.__new__(Supervisor)
supervisor._config_store = mock_config_store
supervisor._adapters = {"nws": NWSAdapter}
supervisor._adapter_states = {}
supervisor._nats = mock_nats
supervisor._cursor_db_path = tmp_path / "cursors.db"
supervisor._log = MagicMock()
config = AdapterConfig(
name="nws",
enabled=True,
cadence_s=300,
settings={"contact_email": "test@example.com"},
updated_at=datetime.now(timezone.utc),
)
# Mock the adapter instantiation to avoid actual HTTP calls
with patch.object(NWSAdapter, "__init__", return_value=None):
with patch.object(NWSAdapter, "startup", new_callable=AsyncMock):
await supervisor._start_adapter(config)
# Should NOT have called get_api_key (no requires_api_key)
mock_config_store.get_api_key.assert_not_called()
# Should have cleared stale error (routine clear)
mock_config_store.set_adapter_last_error.assert_called_once_with("nws", None)
# Should have created adapter state
assert "nws" in supervisor._adapter_states
class TestAdaptersEditSubmitErrorRerender:
"""Tests for adapters_edit_submit error re-render including api_key_missing."""
@pytest.mark.asyncio
async def test_adapters_edit_submit_error_rerender_includes_api_key_missing(self):
"""Error re-render on /adapters/firms should include api_key_missing in context."""
from central.gui.routes import adapters_edit_submit
from pydantic import BaseModel
from typing import Literal
mock_request = MagicMock()
mock_request.state = MagicMock()
mock_request.state.operator = {"username": "test"}
mock_request.state.csrf_token = "test_token"
# Mock form with invalid cadence (below minimum of 10)
mock_form = MagicMock()
def form_get(k, d=""):
values = {
"csrf_token": "test_token",
"cadence_s": "5", # Invalid - below minimum
"api_key_alias": "firms",
"satellites": "",
"region_north": "",
"region_south": "",
"region_east": "",
"region_west": "",
}
return values.get(k, d)
mock_form.get = MagicMock(side_effect=form_get)
mock_form.getlist = MagicMock(return_value=["VIIRS_SNPP_NRT"])
mock_form.__contains__ = lambda self, k: k == "enabled"
mock_request.form = AsyncMock(return_value=mock_form)
mock_pool = MagicMock()
mock_conn = MagicMock()
mock_conn.fetchrow = AsyncMock(side_effect=[
# First call: adapter row
{
"name": "firms",
"enabled": False,
"cadence_s": 300,
"settings": {"api_key_alias": "firms", "satellites": ["VIIRS_SNPP_NRT"]},
"paused_at": None,
"updated_at": datetime.now(timezone.utc),
"last_error": None,
},
# Second call: system row for map tiles
{"map_tile_url": "https://tile.example.com/{z}/{x}/{y}.png", "map_attribution": "Test"},
])
mock_conn.fetchval = AsyncMock(return_value=None) # No API key exists
mock_conn.fetch = AsyncMock(return_value=[]) # No API keys
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock()
mock_pool.acquire = MagicMock(return_value=mock_conn)
# Mock FIRMS adapter class
class MockFIRMSSettings(BaseModel):
api_key_alias: str = ""
satellites: list[Literal["VIIRS_SNPP_NRT", "VIIRS_NOAA20_NRT"]] = []
mock_firms_cls = MagicMock()
mock_firms_cls.requires_api_key = "firms"
mock_firms_cls.api_key_field = "api_key_alias"
mock_firms_cls.display_name = "FIRMS"
mock_firms_cls.description = "Fire detection"
mock_firms_cls.settings_schema = MockFIRMSSettings
with patch("central.gui.routes._get_templates") as mock_templates:
with patch("central.gui.routes.get_pool", return_value=mock_pool):
with patch("central.gui.routes._adapter_classes", return_value={"firms": mock_firms_cls}):
with patch("central.gui.routes.describe_fields", return_value=[]):
mock_template_response = MagicMock()
mock_template_response.status_code = 200
mock_templates.return_value.TemplateResponse = MagicMock(return_value=mock_template_response)
result = await adapters_edit_submit(mock_request, "firms")
# Verify TemplateResponse was called (error re-render)
assert mock_templates.return_value.TemplateResponse.called
# Check the context passed to template
call_kwargs = mock_templates.return_value.TemplateResponse.call_args[1]
context = call_kwargs["context"]
# Should have errors (invalid cadence)
assert context.get("errors") is not None
assert "cadence_s" in context["errors"]
# Should include api_key_missing
assert context["api_key_missing"] is True
assert context["requires_api_key_alias"] == "firms"