Files
library-desk/tests/conftest.py
T
jpmschweitzerandClaude Fable 5 b2399de5f9 test: pin suite to reserved llm_tester tenant with guard and teardown
Rewrite tests/conftest.py for the shared-services testing model where
tenancy is the only isolation wall:

- Remove the hardcoded production host default (192.168.86.149):
  TEST_HOST env with a safe localhost default; LIBRARY_DESK_URL selects
  the local wakeup server (8778), never the production container (8089).
- Pin the suite to the reserved test tenant llm_tester (TEST_TENANT may
  only choose a tenant inside the reserved llm_tester* namespace).
- Session guard (autouse) hard-aborts the whole run if the effective
  tenant is jpmschweitzer or outside the reserved namespace.
- Integration-marked tests only run with RUN_INTEGRATION_TESTS=1 and a
  passing guard; they are skipped otherwise.
- Session-scoped teardown deletes ALL llm_tester artifacts created
  during the run: Qdrant *_llm_tester collections, Neo4j
  User_Llm_Tester* nodes, wiki subtree users/llm_tester (and hyphen
  variant), llm_tester Redis keys on the service DB - with hard
  assert_safe_test_tenant() checks before every delete. Uses a sync
  fixture + asyncio.run to avoid the session loop-scope mismatch.
- Legacy tests/test_integration.py marked integration and pinned to the
  test tenant (taxonomy/list reads no longer touch the production
  namespace; Qdrant tests use the tenant-scoped collection name).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:21:23 +02:00

409 lines
14 KiB
Python

"""
Pytest configuration and shared fixtures for Library Desk tests.
TENANT SAFETY MODEL
===================
There is no separate test infrastructure: integration tests run against the
SHARED production services (Qdrant / Neo4j / Wiki.js / Redis / Wiki.js).
Tenancy is the ONLY isolation wall, therefore:
- The suite is pinned to the reserved test tenant ``llm_tester`` (env
``TEST_TENANT`` may only select a tenant inside the reserved
``llm_tester*`` namespace — anything else aborts the whole session).
- The production tenant ``jpmschweitzer`` is NEVER written to. A session
guard hard-fails immediately if the effective tenant is the production
tenant or outside the reserved namespace.
- Integration tests (marked ``integration``) only run when
``RUN_INTEGRATION_TESTS=1`` is set; otherwise they are skipped. Offline
unit tests never contact the shared services.
- A session-scoped teardown deletes ALL ``llm_tester`` artifacts created
during the run (Qdrant ``*_llm_tester`` collections, Neo4j nodes under
the ``User_Llm_Tester*`` labels, the ``users/llm_tester`` wiki subtree,
and ``llm_tester``-prefixed Redis keys on the service DB), with a hard
assertion on the tenant string before any delete.
HOSTS
=====
``TEST_HOST`` selects where the shared services live. It defaults to
``localhost`` (safe: nothing listens there unless you forwarded the
services yourself). For live runs against the shared stack set it
explicitly, e.g. ``TEST_HOST=192.168.86.149``.
The API under test is the LOCAL wakeup server (``./wakeup.sh``, port 8778),
selected via ``LIBRARY_DESK_URL`` (default ``http://localhost:8778``).
NEVER point tests at the production container (port 8089).
"""
import asyncio
import logging
import os
import pytest
from src.core.multi_tenancy import sanitize_user_id
logger = logging.getLogger(__name__)
pytest_plugins = ("pytest_asyncio",)
# ---------------------------------------------------------------------------
# Tenancy constants
# ---------------------------------------------------------------------------
#: The production tenant. No test may ever write under it.
PRODUCTION_TENANT = "jpmschweitzer"
#: The reserved test tenant namespace. The effective tenant must be
#: exactly this or a sub-tenant of it (llm_tester_*).
RESERVED_TEST_TENANT = "llm_tester"
#: Effective tenant for the whole suite (guard-checked below).
TEST_TENANT = os.environ.get("TEST_TENANT", RESERVED_TEST_TENANT)
# ---------------------------------------------------------------------------
# Hosts / URLs
# ---------------------------------------------------------------------------
#: Shared-services host. Default localhost — NOT the production host.
TEST_HOST = os.environ.get("TEST_HOST", "localhost")
#: Base URL of the local dev server under test (./wakeup.sh, port 8778).
LIBRARY_DESK_URL = os.environ.get("LIBRARY_DESK_URL", "http://localhost:8778")
RUN_INTEGRATION = os.environ.get("RUN_INTEGRATION_TESTS") == "1"
def is_reserved_test_tenant(tenant: str) -> bool:
"""True if tenant is inside the reserved llm_tester namespace."""
sanitized = sanitize_user_id(tenant)
return sanitized == RESERVED_TEST_TENANT or sanitized.startswith(
RESERVED_TEST_TENANT + "_"
)
def assert_safe_test_tenant(tenant: str) -> str:
"""
Hard assertion used before ANY destructive operation.
Raises AssertionError unless the tenant is inside the reserved test
namespace and is not the production tenant.
"""
sanitized = sanitize_user_id(tenant)
assert sanitized != sanitize_user_id(PRODUCTION_TENANT), (
f"TENANT GUARD: refusing to touch production tenant {tenant!r}"
)
assert is_reserved_test_tenant(tenant), (
f"TENANT GUARD: {tenant!r} is not in the reserved test namespace "
f"({RESERVED_TEST_TENANT}*)"
)
return sanitized
# ---------------------------------------------------------------------------
# Session guard + integration gating
# ---------------------------------------------------------------------------
def pytest_collection_modifyitems(config, items):
"""Skip integration-marked tests unless explicitly enabled AND safe."""
if RUN_INTEGRATION and is_reserved_test_tenant(TEST_TENANT):
return
reason = (
"integration tests disabled (set RUN_INTEGRATION_TESTS=1, TEST_HOST "
"and TEST_TENANT inside the reserved llm_tester namespace to run "
"against the shared services)"
)
skip_marker = pytest.mark.skip(reason=reason)
for item in items:
if "integration" in item.keywords:
item.add_marker(skip_marker)
@pytest.fixture(scope="session", autouse=True)
def tenant_guard():
"""
Session guard: hard-fail the entire run if the effective tenant is the
production tenant or outside the reserved test namespace.
"""
if sanitize_user_id(TEST_TENANT) == sanitize_user_id(PRODUCTION_TENANT):
pytest.exit(
f"TENANT GUARD: effective test tenant is the PRODUCTION tenant "
f"({TEST_TENANT!r}) - aborting the whole session.",
returncode=3,
)
if not is_reserved_test_tenant(TEST_TENANT):
pytest.exit(
f"TENANT GUARD: effective test tenant {TEST_TENANT!r} is not in "
f"the reserved test namespace ({RESERVED_TEST_TENANT}*) - "
f"aborting the whole session.",
returncode=3,
)
yield
# ---------------------------------------------------------------------------
# Session teardown: purge ALL llm_tester artifacts created during the run
# ---------------------------------------------------------------------------
async def _purge_qdrant_test_artifacts() -> None:
"""Delete Qdrant collections belonging to the reserved test tenant."""
from qdrant_client import QdrantClient
tenant = assert_safe_test_tenant(TEST_TENANT)
client = QdrantClient(url=f"http://{TEST_HOST}:6333", timeout=10)
try:
for coll in client.get_collections().collections:
name = coll.name
# Only *_<tenant> style collections (library_desk_llm_tester,
# volatile_llm_tester, memories_llm_tester, ...).
if not (name.endswith(f"_{tenant}") or f"_{tenant}_" in name):
continue
assert PRODUCTION_TENANT not in name # hard guard
assert tenant in name
client.delete_collection(name)
logger.info(f"[teardown] deleted Qdrant collection {name}")
finally:
client.close()
async def _purge_neo4j_test_artifacts() -> None:
"""Delete Neo4j nodes under the reserved test tenant's labels."""
from src.clients.neo4j_client import Neo4jClient
from src.config import get_settings
from src.core.multi_tenancy import get_neo4j_user_base_label
tenant = assert_safe_test_tenant(TEST_TENANT)
label_prefix = get_neo4j_user_base_label(tenant) # e.g. User_Llm_Tester
assert "Jpmschweitzer" not in label_prefix # hard guard
assert "Llm_Tester" in label_prefix
settings = get_settings()
client = Neo4jClient(
uri=f"bolt://{TEST_HOST}:7687",
user=settings.neo4j_user,
password=settings.neo4j_password,
)
try:
await client.connect()
result = await client.execute_write(
"""
MATCH (n)
WHERE any(l IN labels(n) WHERE l STARTS WITH $prefix)
DETACH DELETE n
RETURN count(n) AS deleted
""",
{"prefix": label_prefix},
)
deleted = result[0]["deleted"] if result else 0
if deleted:
logger.info(f"[teardown] deleted {deleted} Neo4j {label_prefix}* nodes")
finally:
await client.close()
async def _purge_wiki_test_artifacts() -> None:
"""Delete the reserved test tenant's wiki subtree (users/llm_tester)."""
from src.clients.wikijs_client import WikiJSClient
from src.config import get_settings
tenant = assert_safe_test_tenant(TEST_TENANT)
settings = get_settings()
client = WikiJSClient(
base_url=settings.wikijs_url,
api_token=settings.wiki_graphql_api,
)
try:
for prefix in (f"users/{tenant}", f"users/{tenant.replace('_', '-')}"):
assert PRODUCTION_TENANT not in prefix # hard guard
pages = await client.list_all_pages(path_prefix=prefix)
for page in pages:
path = page.get("path", "")
assert PRODUCTION_TENANT not in path # hard guard
assert path.lstrip("/").startswith("users/llm")
await client.delete_page(page["id"])
logger.info(f"[teardown] deleted wiki page {path} (id={page['id']})")
finally:
await client.close()
async def _purge_redis_test_artifacts() -> None:
"""Delete llm_tester-prefixed keys on the Redis DB the service uses."""
import redis.asyncio as aioredis
from src.config import get_settings
tenant = assert_safe_test_tenant(TEST_TENANT)
settings = get_settings()
client = aioredis.from_url(
f"redis://{TEST_HOST}:6379/{settings.redis_db}",
encoding="utf-8",
decode_responses=True,
)
try:
deleted = 0
for pattern in (f"*{tenant}*", f"*{tenant.replace('_', '-')}*"):
async for key in client.scan_iter(match=pattern, count=200):
assert PRODUCTION_TENANT not in key # hard guard
assert "llm" in key
await client.delete(key)
deleted += 1
if deleted:
logger.info(f"[teardown] deleted {deleted} Redis keys for {tenant}")
finally:
await client.aclose()
@pytest.fixture(scope="session", autouse=True)
def purge_test_tenant_artifacts(tenant_guard):
"""
Session teardown: after the run, delete ALL artifacts under the reserved
test tenant on the shared services. Only active for integration runs
(offline unit-test runs never touch the shared services and must not
try to connect to them).
"""
yield
if not RUN_INTEGRATION:
return
# Hard assertion before ANY delete.
assert_safe_test_tenant(TEST_TENANT)
async def _teardown():
for name, purge in (
("qdrant", _purge_qdrant_test_artifacts),
("neo4j", _purge_neo4j_test_artifacts),
("wiki", _purge_wiki_test_artifacts),
("redis", _purge_redis_test_artifacts),
):
try:
await purge()
except AssertionError:
raise # tenant-guard violations must never be swallowed
except Exception as e:
logger.warning(f"[teardown] {name} purge failed: {e}")
# Sync fixture + asyncio.run avoids the session/function loop-scope
# mismatch (see CLAUDE.md).
asyncio.run(_teardown())
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def test_user() -> str:
"""The reserved test tenant. Pinned for the whole suite."""
return TEST_TENANT
@pytest.fixture
def library_desk_url() -> str:
"""Base URL of the LOCAL dev server under test (never production :8089)."""
return LIBRARY_DESK_URL
@pytest.fixture
def neo4j_test_uri() -> str:
"""Test Neo4j URI."""
return f"bolt://{TEST_HOST}:7687"
@pytest.fixture
def neo4j_test_auth() -> tuple:
"""Test Neo4j authentication."""
from src.config import get_settings
settings = get_settings()
return ("neo4j", settings.neo4j_password)
@pytest.fixture
def qdrant_test_url() -> str:
"""Test Qdrant URL."""
return f"http://{TEST_HOST}:6333"
@pytest.fixture
def wikijs_test_config() -> dict:
"""Test Wiki.js configuration (URL from settings, host env-overridable)."""
from src.config import get_settings
settings = get_settings()
return {
"base_url": settings.wikijs_url,
"api_token": settings.wiki_graphql_api
}
@pytest.fixture
def searxng_test_url() -> str:
"""Test SearXNG URL."""
return f"http://{TEST_HOST}:8080"
@pytest.fixture
def ollama_test_config() -> dict:
"""Test Ollama configuration."""
from src.config import get_settings
settings = get_settings()
return {
"base_url": f"http://{TEST_HOST}:11434",
"model": settings.ollama_embedding_model
}
@pytest.fixture
def redis_test_url() -> str:
"""Test Redis URL."""
from src.config import get_settings
settings = get_settings()
return f"redis://{TEST_HOST}:6379/{settings.redis_db}"
@pytest.fixture
def sample_document() -> dict:
"""Sample document for testing."""
return {
"id": "test_doc_1",
"title": "Test Document",
"content": "This is a test document for unit testing.",
"metadata": {
"source": "test",
"author": TEST_TENANT
}
}
@pytest.fixture
def sample_chunks() -> list:
"""Sample document chunks for testing."""
return [
{
"content": "First chunk of text.",
"metadata": {"chunk_index": 0}
},
{
"content": "Second chunk of text.",
"metadata": {"chunk_index": 1}
},
{
"content": "Third chunk of text.",
"metadata": {"chunk_index": 2}
}
]
@pytest.fixture
def sample_embeddings() -> list:
"""Sample embeddings for testing (768-dimensional for nomic-embed-text)."""
import random
random.seed(42) # Reproducible embeddings
# Generate 3 sample 768-dimensional embeddings
return [
[random.random() for _ in range(768)],
[random.random() for _ in range(768)],
[random.random() for _ in range(768)]
]