diff --git a/CHANGELOG.md b/CHANGELOG.md index 92b531e..9dbae26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Test-suite tenant guard** - `tests/conftest.py` hard-fails the whole pytest session (exit code 1, zero tests run) if the effective tenant resolves to the production tenant `jpmschweitzer`, mirroring the guard library-desk applies on its side. Suite-level assertions pin that the session runs under `llm_tester` namespaces (Qdrant `memories_llm_tester`, Redis `session:llm_tester:*`), and the e2e isolation constants now derive from the shared `TEST_TENANT`/`PRODUCTION_TENANT` config constants instead of string literals - **Explicit tenant on every library-desk request** - the librarian client now resolves and sends the `user` parameter explicitly on every request (library-desk is removing its server-side default; a missing user would 422). The content extraction endpoints now carry the tenant too, `search_web` no longer falls back to a phantom `tatlock-librarian` user, and a client-level assertion rejects an empty/whitespace tenant before any bytes hit the wire. A parametrized sweep pins the wire contract for all 15 tenant-scoped client methods - **Tenant isolation guard** - non-production environments (development/testing) now FORCE the effective tenant to the reserved test tenant `llm_tester` (only `llm_tester` itself or a `test_`-prefixed override is accepted), regardless of `DEFAULT_USER` misconfiguration, at both config resolution and request-context resolution (`get_user()`). Startup refuses (clear error) when a non-production environment is explicitly configured with the production tenant `jpmschweitzer`, and one loud startup log line states the effective/forced tenant diff --git a/tests/conftest.py b/tests/conftest.py index 5bfa42f..68ce14a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,11 +12,51 @@ from src.main import app @pytest.fixture(scope="session", autouse=True) -def _initialize_app(): +def _tenant_guard(): + """ + Hard-fail the whole suite if the effective tenant resolves to the + production tenant ("jpmschweitzer"). + + Isolation is tenant-based: tests that touch shared services + (Qdrant memories collections, Wiki.js via library-desk, Neo4j, + Redis) must run under the reserved test tenant "llm_tester" (or a + test_-prefixed namespace). This mirrors the guard library-desk + applies on its side. + """ + from src.core.config import PRODUCTION_TENANT, config + from src.core.context import get_default_user + from src.core.multi_tenancy import get_memory_collection_name + + effective = get_default_user() + if ( + effective == PRODUCTION_TENANT + or config.effective_default_user == PRODUCTION_TENANT + ): + pytest.exit( + f"TENANT GUARD: refusing to run the test suite - the effective " + f"tenant resolves to the production tenant '{PRODUCTION_TENANT}' " + f"(ENVIRONMENT={config.ENVIRONMENT.value}, " + f"DEFAULT_USER={config.DEFAULT_USER}). Tests must run under " + f"'llm_tester' or a test_-prefixed tenant.", + returncode=1, + ) + + # The Qdrant memories namespace derived from the effective tenant + # must never be the production collection. + assert get_memory_collection_name(effective) != get_memory_collection_name( + PRODUCTION_TENANT + ), "test suite would target the production memories collection" + + +@pytest.fixture(scope="session", autouse=True) +def _initialize_app(_tenant_guard): """ Run application lifespan (Claude health check, household registration, etc.) once per test session. ASGITransport doesn't trigger lifespan events, so we call it explicitly. + + Depends on _tenant_guard so the suite refuses to start under the + production tenant before any initialization happens. """ from src.core.startup import initialize_application asyncio.run(initialize_application()) diff --git a/tests/core/test_tenant_guard.py b/tests/core/test_tenant_guard.py index 4e020fd..b709f5b 100644 --- a/tests/core/test_tenant_guard.py +++ b/tests/core/test_tenant_guard.py @@ -141,6 +141,44 @@ class TestRequestContextGuard: assert get_user() == "alice" +@pytest.mark.unit +class TestSuiteRunsUnderTestTenant: + """ + The live test session itself must resolve to the test tenant. + + The session guard in tests/conftest.py hard-fails the suite when the + effective tenant is the production tenant; these tests assert the + namespaces every shared-service touch would use (Qdrant memories + collection, Redis session keys) are the llm_tester ones. + """ + + def test_effective_tenant_is_not_production(self): + from src.core.context import get_default_user + + assert get_default_user() != PRODUCTION_TENANT + + def test_effective_tenant_is_the_reserved_test_tenant(self): + from src.core.context import get_default_user + + assert get_default_user() == TEST_TENANT + + def test_memories_collection_namespace_is_test_tenant(self): + from src.core.context import get_default_user + from src.core.multi_tenancy import get_memory_collection_name + + assert ( + get_memory_collection_name(get_default_user()) + == f"memories_{TEST_TENANT}" + ) + + def test_redis_session_namespace_is_test_tenant(self): + from src.core.context import get_default_user + from src.core.multi_tenancy import get_session_key + + key = get_session_key(get_default_user(), "conv_test") + assert key.startswith(f"session:{TEST_TENANT}:") + + @pytest.mark.unit class TestStartupTenantGuardLog: """One loud startup log line states the effective tenant.""" diff --git a/tests/e2e/test_orchestration_e2e.py b/tests/e2e/test_orchestration_e2e.py index 92520ad..e93c91f 100644 --- a/tests/e2e/test_orchestration_e2e.py +++ b/tests/e2e/test_orchestration_e2e.py @@ -26,12 +26,18 @@ from typing import AsyncGenerator from dataclasses import dataclass +from src.core.config import PRODUCTION_TENANT, TEST_TENANT + # Test configuration BASE_URL = "http://localhost:8777" QDRANT_URL = "http://localhost:6333" API_TIMEOUT = 120.0 # LLM calls can be slow -TEST_USER = "llm_tester" +# All e2e writes to shared services go to the reserved test tenant's +# namespaces (Qdrant memories_llm_tester, wiki llm_tester scope) - never +# the production tenant's. +TEST_USER = TEST_TENANT TEST_COLLECTION = f"memories_{TEST_USER}" +assert TEST_USER != PRODUCTION_TENANT @dataclass @@ -986,7 +992,7 @@ class TestUserContextIsolation: in_test_collection = any(unique_value in v for v in test_values) # Check production collection (should NOT be there) - prod_collection = "memories_jpmschweitzer" + prod_collection = f"memories_{PRODUCTION_TENANT}" if await qdrant.collection_exists(prod_collection): prod_points = await qdrant.scroll_points(prod_collection) prod_values = [str(p.get("payload", {})) for p in prod_points]