Compare commits

..

8 commits

Author SHA1 Message Date
76c5e94b39
Merge pull request #52 from zvx-echo6/feat/events-json-subject
feat(events-subject): fix /events.json always-null subject column
2026-05-21 13:07:59 -06:00
Matt Johnson
578c9bc0fe feat(events-subject): derive /events.json subject from inner payload
The events_json SELECT read payload->>'subject', but the CloudEvents
envelope has no top-level subject, so every JSON consumer saw
subject: null. The /events GUI already derives readable subjects via
per-adapter templates/_event_summaries/{adapter}.html (PR L-c).

This makes the JSON path produce the same plain-text subjects with no
duplicated logic: _derive_subject(event) renders the same partial the
table uses (falling back to _default.html) and html.unescapes the
autoescaped output so JSON consumers get plain text (e.g. ">=1 MeV"
rather than the escaped ">=1 MeV"). _fetch_events now sets subject
from it and drops the always-null SQL expression. The GUI Subject cell
is unchanged.

Adds TestEventsJsonSubject (parameterized over discover_adapters(), no
hardcoded list): non-null subject per adapter, equality with the rendered
partial, pinned human text for the deterministic adapters, swpc_alerts
truncation, and null fallbacks. Updates one TestEventRowDataAttributes
assertion that pinned the old SQL pass-through contract.

One route change plus tests; central-gui restart required.
Full suite: 629 passed, 1 skipped (central and unprivileged zvx).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:07:19 +00:00
d0375225b2
Merge pull request #51 from zvx-echo6/chore/lint-cleanup-test-imports
chore(lint-cleanup): remove 10 pre-existing ruff issues in 4 test files
2026-05-21 12:21:05 -06:00
Matt Johnson
ff3d9bb3c3 chore(lint-cleanup): remove 10 pre-existing ruff issues in 4 test files
Cleans up unused imports and dead locals flagged by ruff in the test
files PR #50 (M-b) touched. Tests-only; no production code, no service
restart.

- test_supervisor_hotreload.py: drop unused AsyncMock/patch imports,
  dead expected_wait/expected_next_poll locals, and two dead
  state = AdapterState(...) blocks plus their now-orphaned local imports
- test_supervisor_integration.py: drop unused asyncio/patch/pytest_asyncio
  imports and AdapterState from two function-local imports

ruff tests/ 92 -> 82 (the 4 named files now 0; all other files unchanged).
Full suite: 590 passed, 1 skipped (central and unprivileged zvx).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:20:18 +00:00
e33a896592
Merge pull request #50 from zvx-echo6/chore/config-store-test-isolation
chore(M-b): clear get_settings lru_cache in test fixtures (fixes order-dependent crypto failures + 3 latent siblings)
2026-05-21 09:52:29 -06:00
zvx
f666014821 chore(M-b): clear get_settings lru_cache in test fixtures (fixes order-dependent crypto failures + 3 latent siblings)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:51:51 +00:00
69dddd0240
Merge pull request #49 from zvx-echo6/chore/hermetic-enrichment-cache
chore(M): make enrichment-cache path test-hermetic via conftest autouse fixture
2026-05-21 08:24:10 -06:00
zvx
765635e720 chore(M): make enrichment-cache path test-hermetic via conftest autouse fixture
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 14:23:31 +00:00
7 changed files with 260 additions and 45 deletions

View file

@ -1,6 +1,7 @@
"""Route handlers for Central GUI."""
import base64
import html
import json
import logging
import re
@ -2727,6 +2728,24 @@ def _parse_events_params(params) -> tuple[dict | None, str | None]:
}, None
def _derive_subject(event: dict) -> str | None:
"""Derive an event's plain-text subject for the JSON API.
Renders the same per-adapter ``_event_summaries/{adapter}.html`` partial
the /events table uses (falling back to ``_default.html``), so the JSON
subject carries the same human text as the GUI's Subject cell with no
duplicated derivation logic. The partials are HTML-autoescaped for the
table (e.g. ``>`` -> ``&gt;``); we ``html.unescape`` so JSON consumers get
plain text. Returns ``None`` when the partial yields no text -- an unknown
adapter, or an event whose source fields don't support a subject (e.g. a
wfigs row with neither county nor state).
"""
template = _get_templates().env.select_template(
[f"_event_summaries/{event.get('adapter')}.html", "_event_summaries/_default.html"]
)
return html.unescape(template.render(event=event)).strip() or None
async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
"""
Fetch events from database using parsed parameters.
@ -2794,7 +2813,6 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
received,
adapter,
category,
payload->>'subject' as subject,
ST_AsGeoJSON(geom) as geometry,
payload as data,
regions
@ -2824,17 +2842,23 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
if row["geometry"]:
geometry = json.loads(row["geometry"])
events.append({
event = {
"id": row["id"],
"time": row["time"].isoformat(),
"received": row["received"].isoformat(),
"adapter": row["adapter"],
"category": row["category"],
"subject": row["subject"],
"geometry": geometry,
"data": dict(row["data"]) if row["data"] else {},
"regions": list(row["regions"]) if row["regions"] else [],
})
}
# Subject is derived from the inner adapter payload by rendering the
# same _event_summaries partial the /events table uses, so the JSON
# `subject` matches the GUI's Subject cell. (The CloudEvents envelope
# has no top-level `subject`; the old `payload->>'subject'` was always
# null for every consumer.)
event["subject"] = _derive_subject(event)
events.append(event)
# Build next_cursor if there are more results
next_cursor = None

View file

@ -13,6 +13,25 @@ from unittest.mock import AsyncMock, MagicMock, patch
from central.bootstrap_config import Settings
@pytest.fixture(autouse=True)
def isolate_enrichment_cache(tmp_path, monkeypatch):
"""Redirect the supervisor's enrichment cache off the production path.
`central.supervisor.ENRICHMENT_CACHE_DB_PATH` defaults to
/var/lib/central/enrichment_cache.db. Constructing a Supervisor opens it,
so without this fixture the suite writes to (or, for any user without write
access to /var/lib/central, fails on) the live cache. Point it at a
per-test temp dir so no test ever touches the production path.
"""
import central.supervisor as supervisor_mod
monkeypatch.setattr(
supervisor_mod,
"ENRICHMENT_CACHE_DB_PATH",
tmp_path / "enrichment_cache.db",
)
@pytest.fixture(scope="session")
def event_loop():
"""Create an event loop for the test session."""

View file

@ -12,6 +12,7 @@ from central.config_source import (
ConfigSource,
DbConfigSource,
)
from central.bootstrap_config import get_settings
from central.crypto import KEY_SIZE, clear_key_cache
# Test database DSN
@ -31,11 +32,20 @@ def master_key_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
@pytest.fixture(autouse=True)
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Configure master key path for all tests."""
clear_key_cache()
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Configure master key path for all tests.
Clear get_settings (and the crypto key cache) AFTER setting the env so
crypto rebuilds from the test key regardless of suite order, and again on
teardown so the test key never leaks into a later test. See PR M-b.
"""
monkeypatch.setenv("CENTRAL_DB_DSN", TEST_DB_DSN)
monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path))
clear_key_cache()
get_settings.cache_clear()
yield
clear_key_cache()
get_settings.cache_clear()
@pytest_asyncio.fixture

View file

@ -13,6 +13,7 @@ import asyncpg
import pytest
import pytest_asyncio
from central.bootstrap_config import get_settings
from central.config_store import ConfigStore
from central.crypto import KEY_SIZE, clear_key_cache
@ -34,12 +35,24 @@ def master_key_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
@pytest.fixture(autouse=True)
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Configure master key path for all tests."""
clear_key_cache()
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Configure master key path for all tests.
CENTRAL_MASTER_KEY_PATH feeds Settings, which get_settings() lru-caches. An
earlier test can warm that cache with the default /etc/central/master.key
before this fixture runs, so the env change alone is not enough clear
get_settings (and the crypto key cache) AFTER setting the env so crypto
rebuilds from the test key regardless of suite order, and again on teardown
so the test key never leaks into a later test.
"""
monkeypatch.setenv("CENTRAL_DB_DSN", TEST_DB_DSN)
monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path))
monkeypatch.setenv("CENTRAL_CSRF_SECRET", "test-csrf-secret-for-testing-only-32chars")
clear_key_cache()
get_settings.cache_clear()
yield
clear_key_cache()
get_settings.cache_clear()
@pytest_asyncio.fixture
@ -338,3 +351,13 @@ class TestListenerReconnect:
pytest.fail("Listener did not stop after cancellation")
assert listen_task.cancelled() or listen_task.done()
def test_master_key_path_is_isolated(master_key_path: Path) -> None:
"""Contract: after setup_master_key runs, get_settings() resolves the master
key to the per-session test key never the production /etc/central path
regardless of suite order. Fails on the pre-fix code in a full-suite run
where get_settings was warmed with the default path by an earlier test.
"""
assert get_settings().master_key_path == master_key_path
assert get_settings().master_key_path != Path("/etc/central/master.key")

View file

@ -1,12 +1,15 @@
"""Tests for events feed frontend routes."""
import html
import json
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from central.gui.routes import events_list, events_rows, events_json
from central.adapter_discovery import discover_adapters
from central.gui import templates as _gui_templates
from central.gui.routes import events_list, events_rows, events_json, _derive_subject
class TestEventsFeedFrontendAuthenticated:
@ -683,7 +686,11 @@ class TestEventRowDataAttributes:
assert len(context["events"]) == 1
assert context["events"][0]["adapter"] == "usgs_quake"
assert context["events"][0]["category"] == "quake.event"
assert context["events"][0]["subject"] == "M4.2 Earthquake"
# `subject` is now derived from the inner payload (rendered partial),
# not a DB pass-through, so the mock's input value is no longer echoed;
# just confirm the field is present. See TestEventsJsonSubject for the
# derivation contract.
assert "subject" in context["events"][0]
# --- PR L-b: operator /events tab polish ---------------------------------
@ -1085,3 +1092,106 @@ class TestTableRendersThroughHTTP:
cells = _first_row_cells(resp.text)
assert cells[1].endswith("UTC") and "2026" in cells[1] # Time non-empty
assert cells[4] == self._expected_adapter_display() # Adapter display_name
# --- feat(events-json-subject): JSON subject derivation ------------------
# Representative inner adapter payloads (payload->'data'->'data'), captured from
# production -- one per registered adapter. Keyed by adapter name so the
# coverage test below fails loudly if a new adapter ships without a sample.
_SAMPLE_INNER = {
"eonet": {"title": "Kress Wildfire, Swisher, Texas"},
"firms": {"frp": 0.34, "confidence": "nominal"},
"gdacs": {"title": "Green flood alert in Austria", "alertlevel": "Green"},
"inciweb": {"title": "MTHLF Jericho Creek"},
"nwis": {"value": 93.2, "unit_of_measure": "ft^3/s"},
"nws": {"event": "Special Weather Statement", "severity": "Moderate"},
"swpc_alerts": {
"product_id": "EF3A",
"message": (
"Space Weather Message Code: ALTEF3\r\nSerial Number: 3691\r\n"
"Issue Time: 2026 May 21 0509 UTC\r\n\r\nCONTINUED ALERT: "
"Electron 2MeV Integral Flux exceeded 1000pfu"
),
},
"swpc_kindex": {"Kp": 1.0},
"swpc_protons": {"flux": 15.06399917602539, "energy": ">=1 MeV"},
"usgs_quake": {"magnitude": 1.009682538298, "place": "17 km W of Searles Valley, CA"},
"wfigs_incidents": {"county": "Montezuma", "state": "CO"},
"wfigs_perimeters": {"county": "Carbon", "state": "MT"},
}
# Exact expected subjects for the deterministic adapters. swpc_alerts is omitted
# (its message runs through Jinja's truncate(80)) and is checked separately.
# swpc_protons expects unescaped ">=" -- _derive_subject html.unescapes the
# autoescaped partial output so JSON consumers get plain text.
_EXPECTED_SUBJECT = {
"eonet": "Kress Wildfire, Swisher, Texas",
"firms": "Fire detected — 0.34 MW radiative power",
"gdacs": "Green flood alert in Austria — Green alert",
"inciweb": "MTHLF Jericho Creek",
"nwis": "Water reading: 93.2 ft^3/s",
"nws": "Special Weather Statement — Moderate",
"swpc_kindex": "Geomagnetic activity (Kp index): 1.0",
"swpc_protons": "Solar proton flux: 15.06 pfu at >=1 MeV",
"usgs_quake": "Magnitude 1.0 — 17 km W of Searles Valley, CA",
"wfigs_incidents": "Wildfire incident — Montezuma, CO",
"wfigs_perimeters": "Wildfire perimeter — Carbon, MT",
}
def _subject_event(adapter: str, inner: dict) -> dict:
"""Build a minimal event dict shaped like _fetch_events output."""
return {"adapter": adapter, "data": {"data": {"data": inner}}}
class TestEventsJsonSubject:
"""/events.json `subject` is derived from the inner payload and carries the
same human text as the GUI's per-adapter Subject cell (feat/events-json-subject).
The old `payload->>'subject'` SQL was always null (the CloudEvents envelope
has no top-level subject). Parameterized over discover_adapters() -- no
hardcoded adapter list.
"""
def test_sample_covers_every_registered_adapter(self):
"""No hardcoded list: samples must track the live registry exactly."""
assert set(_SAMPLE_INNER) == set(discover_adapters())
@pytest.mark.parametrize("adapter", sorted(discover_adapters()))
def test_subject_non_null_per_adapter(self, adapter):
"""Every registered adapter derives a non-null subject for a real event."""
event = _subject_event(adapter, _SAMPLE_INNER[adapter])
assert _derive_subject(event) is not None
@pytest.mark.parametrize("adapter", sorted(discover_adapters()))
def test_subject_matches_rendered_partial(self, adapter):
"""Derived subject equals the adapter's own partial (unescaped) -- the
JSON path and the GUI Subject cell never diverge."""
event = _subject_event(adapter, _SAMPLE_INNER[adapter])
oracle = html.unescape(
_gui_templates.env.get_template(f"_event_summaries/{adapter}.html").render(event=event)
).strip()
assert _derive_subject(event) == oracle
@pytest.mark.parametrize("adapter", sorted(_EXPECTED_SUBJECT))
def test_subject_exact_human_text(self, adapter):
"""Pin the human-readable subject for the deterministic adapters."""
event = _subject_event(adapter, _SAMPLE_INNER[adapter])
assert _derive_subject(event) == _EXPECTED_SUBJECT[adapter]
def test_swpc_alerts_prefixes_id_and_truncates_message(self):
"""swpc_alerts subject prefixes the product id and truncates the body."""
event = _subject_event("swpc_alerts", _SAMPLE_INNER["swpc_alerts"])
subject = _derive_subject(event)
assert subject is not None
assert subject.startswith("Space weather alert EF3A: ")
assert subject.endswith("...")
def test_unknown_adapter_yields_none(self):
"""Unknown adapters fall back to _default.html -> no subject."""
assert _derive_subject(_subject_event("does_not_exist", {"x": 1})) is None
def test_missing_source_fields_yields_none(self):
"""An event lacking its adapter's source fields derives no subject."""
assert _derive_subject(_subject_event("usgs_quake", {})) is None

View file

@ -5,7 +5,7 @@ import base64
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock
import asyncpg
import pytest
@ -14,6 +14,7 @@ import pytest_asyncio
from central.config_models import AdapterConfig
from central.config_source import DbConfigSource
from central.config_store import ConfigStore
from central.bootstrap_config import get_settings
from central.crypto import KEY_SIZE, clear_key_cache
# Test database DSN
@ -33,11 +34,20 @@ def master_key_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
@pytest.fixture(autouse=True)
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Configure master key path for all tests."""
clear_key_cache()
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Configure master key path for all tests.
Clear get_settings (and the crypto key cache) AFTER setting the env so
crypto rebuilds from the test key regardless of suite order, and again on
teardown so the test key never leaks into a later test. See PR M-b.
"""
monkeypatch.setenv("CENTRAL_DB_DSN", TEST_DB_DSN)
monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path))
clear_key_cache()
get_settings.cache_clear()
yield
clear_key_cache()
get_settings.cache_clear()
@pytest_asyncio.fixture
@ -196,10 +206,7 @@ class TestRateLimitGuarantee:
state.config = new_config
state.adapter.cadence_s = 90
# Calculate expected next poll time
expected_next_poll = last_poll + timedelta(seconds=90)
now = datetime.now(timezone.utc)
expected_wait = max(0, (expected_next_poll - now).total_seconds())
# The wait time should be based on last_poll + new_cadence
# Since last_poll was 30 seconds ago and new cadence is 90,
@ -220,7 +227,6 @@ class TestRateLimitGuarantee:
If operator increases cadence to 120s after a gap of 150s,
the poll should happen now (not wait another 120s).
"""
from central.supervisor import AdapterState
mock_adapter = MagicMock()
mock_adapter.name = "test"
@ -238,13 +244,6 @@ class TestRateLimitGuarantee:
updated_at=datetime.now(timezone.utc),
)
state = AdapterState(
name="test",
adapter=mock_adapter,
config=config,
last_completed_poll=last_poll,
)
# Calculate next poll time
now = datetime.now(timezone.utc)
next_poll_at = last_poll.timestamp() + config.cadence_s
@ -264,7 +263,6 @@ class TestRateLimitGuarantee:
poll should be at (last_completed_poll + cadence_s), not immediately
(unless that time has already passed).
"""
from central.supervisor import AdapterState
mock_adapter = MagicMock()
mock_adapter.name = "test"
@ -283,13 +281,6 @@ class TestRateLimitGuarantee:
updated_at=datetime.now(timezone.utc),
)
state = AdapterState(
name="test",
adapter=mock_adapter,
config=config,
last_completed_poll=last_poll,
)
# Calculate next poll time
now = datetime.now(timezone.utc)
next_poll_at = last_poll.timestamp() + config.cadence_s

View file

@ -9,17 +9,16 @@ IMPORTANT: These tests are designed to:
- PASS on fixed code (last_completed_poll is preserved across disable/enable)
"""
import asyncio
import base64
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock
import pytest
import pytest_asyncio
from central.config_models import AdapterConfig
from central.bootstrap_config import get_settings
from central.crypto import KEY_SIZE, clear_key_cache
@ -56,11 +55,20 @@ def master_key_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
@pytest.fixture(autouse=True)
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Configure master key path for all tests."""
clear_key_cache()
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Configure master key path for all tests.
Clear get_settings (and the crypto key cache) AFTER setting the env so
crypto rebuilds from the test key regardless of suite order, and again on
teardown so the test key never leaks into a later test. See PR M-b.
"""
monkeypatch.setenv("CENTRAL_DB_DSN", TEST_DB_DSN)
monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path))
clear_key_cache()
get_settings.cache_clear()
yield
clear_key_cache()
get_settings.cache_clear()
class MockConfigSource:
@ -139,12 +147,18 @@ class MockNWSAdapter:
@pytest.fixture
def mock_nats():
"""Mock NATS connection."""
"""Mock NATS connection.
nats-py's `nc.jetstream()` is synchronous, so model it with a sync
MagicMock. (As an AsyncMock attribute, `supervisor._js = nc.jetstream()`
would assign an unawaited coroutine the "coroutine ... was never awaited"
warning rather than the JetStream mock.)
"""
mock_nc = AsyncMock()
mock_nc.publish = AsyncMock()
mock_js = AsyncMock()
mock_js.publish = AsyncMock()
mock_nc.jetstream.return_value = mock_js
mock_nc.jetstream = MagicMock(return_value=mock_js)
return mock_nc
@ -179,7 +193,7 @@ class TestEnableDisableEnableIntegration:
- Assert next poll fires immediately (last+cadence is in past)
- Assert exactly ONE poll happens, not multiple catch-up
"""
from central.supervisor import Supervisor, AdapterState
from central.supervisor import Supervisor
config_source = MockConfigSource()
initial_config = AdapterConfig(
@ -290,7 +304,7 @@ class TestEnableDisableEnableIntegration:
- Re-enable adapter 20 seconds later (still within cadence window)
- Assert next poll fires at last_poll + 60s, NOT immediately
"""
from central.supervisor import Supervisor, AdapterState
from central.supervisor import Supervisor
config_source = MockConfigSource()
initial_config = AdapterConfig(
@ -574,3 +588,27 @@ class TestEnableDisableEnableIntegration:
# State should be gone
assert "nws" not in supervisor._adapter_states
def test_enrichment_cache_path_is_hermetic(mock_config_store, tmp_path: Path) -> None:
"""No test may touch the production enrichment cache.
The autouse `isolate_enrichment_cache` fixture (conftest) must redirect
ENRICHMENT_CACHE_DB_PATH off /var/lib/central onto a per-test temp dir, and
constructing a Supervisor must open the cache there not in production.
"""
import central.supervisor as supervisor_mod
patched = supervisor_mod.ENRICHMENT_CACHE_DB_PATH
assert tmp_path in patched.parents
assert "/var/lib/central" not in str(patched)
supervisor = supervisor_mod.Supervisor(
config_source=MockConfigSource(),
config_store=mock_config_store,
nats_url="nats://localhost:4222",
cloudevents_config=None,
)
# __init__ opened the cache at the temp path, leaving the db file behind.
assert patched.exists()
assert supervisor._enrichment_cache is not None