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>
409 lines
14 KiB
Python
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)]
|
|
]
|