test: hard-fail the suite when the tenant resolves to production

Session-scoped autouse guard in tests/conftest.py refuses to run any
test (pytest.exit, returncode 1) when the effective tenant resolves
to the production tenant jpmschweitzer - the same guard library-desk
applies on its side. _initialize_app now depends on the guard so the
refusal happens before any initialization.

Suite-level assertions pin that the live session runs under the
llm_tester namespaces: Qdrant memories_llm_tester collection and
Redis session:llm_tester:* keys. The biographer/memory unit tests
already run fully mocked (no shared-service writes); the e2e
isolation tests already used llm_tester - their constants now derive
from the shared TEST_TENANT/PRODUCTION_TENANT config constants so a
drift fails loudly instead of silently splitting.

Verified: ENVIRONMENT=production pytest run exits 1 with the TENANT
GUARD message and zero tests executed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 11:08:39 +02:00
co-authored by Claude Fable 5
parent b9eae38556
commit b47c5b9281
4 changed files with 88 additions and 3 deletions
+1
View File
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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 - **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 - **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
+41 -1
View File
@@ -12,11 +12,51 @@ from src.main import app
@pytest.fixture(scope="session", autouse=True) @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.) Run application lifespan (Claude health check, household registration, etc.)
once per test session. ASGITransport doesn't trigger lifespan events, once per test session. ASGITransport doesn't trigger lifespan events,
so we call it explicitly. 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 from src.core.startup import initialize_application
asyncio.run(initialize_application()) asyncio.run(initialize_application())
+38
View File
@@ -141,6 +141,44 @@ class TestRequestContextGuard:
assert get_user() == "alice" 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 @pytest.mark.unit
class TestStartupTenantGuardLog: class TestStartupTenantGuardLog:
"""One loud startup log line states the effective tenant.""" """One loud startup log line states the effective tenant."""
+8 -2
View File
@@ -26,12 +26,18 @@ from typing import AsyncGenerator
from dataclasses import dataclass from dataclasses import dataclass
from src.core.config import PRODUCTION_TENANT, TEST_TENANT
# Test configuration # Test configuration
BASE_URL = "http://localhost:8777" BASE_URL = "http://localhost:8777"
QDRANT_URL = "http://localhost:6333" QDRANT_URL = "http://localhost:6333"
API_TIMEOUT = 120.0 # LLM calls can be slow 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}" TEST_COLLECTION = f"memories_{TEST_USER}"
assert TEST_USER != PRODUCTION_TENANT
@dataclass @dataclass
@@ -986,7 +992,7 @@ class TestUserContextIsolation:
in_test_collection = any(unique_value in v for v in test_values) in_test_collection = any(unique_value in v for v in test_values)
# Check production collection (should NOT be there) # Check production collection (should NOT be there)
prod_collection = "memories_jpmschweitzer" prod_collection = f"memories_{PRODUCTION_TENANT}"
if await qdrant.collection_exists(prod_collection): if await qdrant.collection_exists(prod_collection):
prod_points = await qdrant.scroll_points(prod_collection) prod_points = await qdrant.scroll_points(prod_collection)
prod_values = [str(p.get("payload", {})) for p in prod_points] prod_values = [str(p.get("payload", {})) for p in prod_points]