Compare commits

..

4 commits

Author SHA1 Message Date
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
5 changed files with 116 additions and 14 deletions

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

@ -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

View file

@ -20,6 +20,7 @@ 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 +57,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 +149,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
@ -574,3 +590,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