From 765635e720f9100adf716fe0c5bc6f2621a41f08 Mon Sep 17 00:00:00 2001 From: zvx Date: Thu, 21 May 2026 14:23:31 +0000 Subject: [PATCH 1/2] chore(M): make enrichment-cache path test-hermetic via conftest autouse fixture Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/conftest.py | 19 ++++++++++++++++ tests/test_supervisor_integration.py | 34 ++++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 97a4f5d..fca89f0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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.""" diff --git a/tests/test_supervisor_integration.py b/tests/test_supervisor_integration.py index 20360fe..fa3a420 100644 --- a/tests/test_supervisor_integration.py +++ b/tests/test_supervisor_integration.py @@ -139,12 +139,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 +580,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 From f6660148215cae108982d24e69e090a00b597a97 Mon Sep 17 00:00:00 2001 From: zvx Date: Thu, 21 May 2026 15:51:51 +0000 Subject: [PATCH 2/2] 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) --- tests/test_config_source.py | 16 ++++++++++++--- tests/test_config_store.py | 29 +++++++++++++++++++++++++--- tests/test_supervisor_hotreload.py | 16 ++++++++++++--- tests/test_supervisor_integration.py | 16 ++++++++++++--- 4 files changed, 65 insertions(+), 12 deletions(-) diff --git a/tests/test_config_source.py b/tests/test_config_source.py index bc944c1..a26a12a 100644 --- a/tests/test_config_source.py +++ b/tests/test_config_source.py @@ -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 diff --git a/tests/test_config_store.py b/tests/test_config_store.py index 4653e32..e80d515 100644 --- a/tests/test_config_store.py +++ b/tests/test_config_store.py @@ -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") diff --git a/tests/test_supervisor_hotreload.py b/tests/test_supervisor_hotreload.py index 54db782..10343b8 100644 --- a/tests/test_supervisor_hotreload.py +++ b/tests/test_supervisor_hotreload.py @@ -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 diff --git a/tests/test_supervisor_integration.py b/tests/test_supervisor_integration.py index fa3a420..517dbe5 100644 --- a/tests/test_supervisor_integration.py +++ b/tests/test_supervisor_integration.py @@ -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: