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
- **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
+41 -1
View File
@@ -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())
+38
View File
@@ -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."""
+8 -2
View File
@@ -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]