* fix: improve mental model consolidation * fix skill names * fixes * fix: add missing list_tenants to test mocks and update CLI for async refresh
411 lines
15 KiB
Python
411 lines
15 KiB
Python
"""
|
|
Tests for multi-tenant schema isolation.
|
|
|
|
Verifies that concurrent retain operations from different tenants
|
|
are properly isolated in their respective PostgreSQL schemas.
|
|
"""
|
|
|
|
import asyncio
|
|
import uuid
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from hindsight_api.engine.memory_engine import _current_schema, fq_table
|
|
from hindsight_api.extensions import RequestContext, TenantContext, TenantExtension
|
|
from hindsight_api.migrations import run_migrations
|
|
|
|
|
|
class MultiSchemaTestTenantExtension(TenantExtension):
|
|
"""
|
|
Test tenant extension that maps API keys to schema names.
|
|
|
|
API keys are in format: "key-{schema_name}"
|
|
Provisions schemas on first access using run_migrations(schema=name).
|
|
"""
|
|
|
|
def __init__(self, config: dict):
|
|
super().__init__(config)
|
|
self.db_url = config.get("db_url")
|
|
# Pre-configured valid schemas for test
|
|
self.valid_schemas = config.get("valid_schemas", set())
|
|
# Track provisioned schemas
|
|
self._provisioned: set[str] = set()
|
|
|
|
async def authenticate(self, context: RequestContext) -> TenantContext:
|
|
if not context.api_key:
|
|
from hindsight_api.extensions import AuthenticationError
|
|
|
|
raise AuthenticationError("API key required")
|
|
|
|
# Parse schema from API key (format: "key-{schema}")
|
|
if context.api_key.startswith("key-"):
|
|
schema = context.api_key[4:] # Remove "key-" prefix
|
|
if schema in self.valid_schemas:
|
|
# Provision schema on first access
|
|
if schema not in self._provisioned and self.db_url:
|
|
run_migrations(self.db_url, schema=schema)
|
|
self._provisioned.add(schema)
|
|
return TenantContext(schema_name=schema)
|
|
|
|
from hindsight_api.extensions import AuthenticationError
|
|
|
|
raise AuthenticationError(f"Unknown API key: {context.api_key}")
|
|
|
|
async def list_tenants(self) -> list:
|
|
from hindsight_api.extensions.tenant import Tenant
|
|
|
|
return [Tenant(schema=schema) for schema in self.valid_schemas]
|
|
|
|
|
|
async def drop_schema(conn, schema_name: str) -> None:
|
|
"""Drop a schema and all its contents."""
|
|
await conn.execute(f'DROP SCHEMA IF EXISTS "{schema_name}" CASCADE')
|
|
|
|
|
|
async def count_memories_in_schema(conn, schema_name: str, bank_id: str) -> int:
|
|
"""Count memory units in a specific schema for a bank."""
|
|
result = await conn.fetchval(
|
|
f'SELECT COUNT(*) FROM "{schema_name}".memory_units WHERE bank_id = $1',
|
|
bank_id,
|
|
)
|
|
return result or 0
|
|
|
|
|
|
async def get_memory_texts_in_schema(conn, schema_name: str, bank_id: str) -> list[str]:
|
|
"""Get all memory texts in a specific schema for a bank."""
|
|
rows = await conn.fetch(
|
|
f'SELECT text FROM "{schema_name}".memory_units WHERE bank_id = $1 ORDER BY text',
|
|
bank_id,
|
|
)
|
|
return [row["text"] for row in rows]
|
|
|
|
|
|
class TestSchemaIsolation:
|
|
"""Tests for multi-tenant schema isolation."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_inserts_isolated_by_schema(self, memory, pg0_db_url):
|
|
"""
|
|
Multiple concurrent database operations from different tenants
|
|
should store data in their respective schemas without cross-contamination.
|
|
|
|
Uses run_migrations(schema=x) to provision schemas like a real extension.
|
|
"""
|
|
import asyncpg
|
|
|
|
# Test schemas
|
|
schemas = ["tenant_alpha", "tenant_beta", "tenant_gamma"]
|
|
bank_id = f"test-isolation-{uuid.uuid4().hex[:8]}"
|
|
|
|
# Clean up any existing schemas
|
|
conn = await asyncpg.connect(pg0_db_url)
|
|
try:
|
|
for schema in schemas:
|
|
await drop_schema(conn, schema)
|
|
finally:
|
|
await conn.close()
|
|
|
|
# Configure tenant extension that provisions schemas via run_migrations
|
|
tenant_ext = MultiSchemaTestTenantExtension({
|
|
"db_url": pg0_db_url,
|
|
"valid_schemas": set(schemas),
|
|
})
|
|
memory._tenant_extension = tenant_ext
|
|
|
|
# Define concurrent insert tasks for each tenant
|
|
async def insert_for_tenant(schema_name: str, content_prefix: str):
|
|
"""Insert memories for a specific tenant using schema context."""
|
|
# Authenticate to set the schema context
|
|
tenant_request = RequestContext(api_key=f"key-{schema_name}")
|
|
await memory._authenticate_tenant(tenant_request)
|
|
|
|
# Now fq_table will use the correct schema
|
|
pool = await memory._get_pool()
|
|
from hindsight_api.engine.db_utils import acquire_with_retry
|
|
|
|
async with acquire_with_retry(pool) as conn:
|
|
# Insert 3 memories for this tenant
|
|
for i in range(3):
|
|
await conn.execute(
|
|
f"""
|
|
INSERT INTO {fq_table('memory_units')} (bank_id, text, event_date, fact_type)
|
|
VALUES ($1, $2, now(), 'world')
|
|
""",
|
|
bank_id,
|
|
f"MARKER_{content_prefix}_DOC{i}: Memory for {schema_name}",
|
|
)
|
|
|
|
# Run concurrent inserts for all tenants
|
|
await asyncio.gather(
|
|
insert_for_tenant("tenant_alpha", "ALPHA"),
|
|
insert_for_tenant("tenant_beta", "BETA"),
|
|
insert_for_tenant("tenant_gamma", "GAMMA"),
|
|
)
|
|
|
|
# Verify isolation - each schema should only have its own data
|
|
conn = await asyncpg.connect(pg0_db_url)
|
|
try:
|
|
for schema in schemas:
|
|
texts = await get_memory_texts_in_schema(conn, schema, bank_id)
|
|
prefix = schema.replace("tenant_", "").upper()
|
|
|
|
# Should have exactly 3 memories
|
|
assert len(texts) == 3, f"Schema {schema} should have 3 memories, got {len(texts)}"
|
|
|
|
# All texts should contain the schema's marker
|
|
for text in texts:
|
|
assert f"MARKER_{prefix}" in text, (
|
|
f"Memory in {schema} missing its marker: {text}"
|
|
)
|
|
|
|
# Should NOT contain other tenants' markers
|
|
other_prefixes = ["ALPHA", "BETA", "GAMMA"]
|
|
other_prefixes.remove(prefix)
|
|
for other in other_prefixes:
|
|
for text in texts:
|
|
assert f"MARKER_{other}" not in text, (
|
|
f"Cross-contamination! Schema {schema} has {other}'s marker: {text}"
|
|
)
|
|
|
|
finally:
|
|
# Cleanup
|
|
for schema in schemas:
|
|
await drop_schema(conn, schema)
|
|
await conn.close()
|
|
|
|
# Reset tenant extension
|
|
memory._tenant_extension = None
|
|
_current_schema.set("public")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_schema_context_isolation_in_concurrent_tasks(self, pg0_db_url):
|
|
"""
|
|
Verify that _current_schema contextvar is properly isolated
|
|
between concurrent async tasks.
|
|
"""
|
|
results = {}
|
|
errors = []
|
|
|
|
async def check_schema_context(schema_name: str, delay: float):
|
|
"""Set schema context, wait, then verify it's still correct."""
|
|
try:
|
|
# Set the schema
|
|
_current_schema.set(schema_name)
|
|
|
|
# Small delay to allow interleaving
|
|
await asyncio.sleep(delay)
|
|
|
|
# Verify schema is still correct
|
|
current = _current_schema.get()
|
|
if current != schema_name:
|
|
errors.append(f"Expected {schema_name}, got {current}")
|
|
|
|
# Verify fq_table uses correct schema
|
|
table = fq_table("memory_units")
|
|
expected = f"{schema_name}.memory_units"
|
|
if table != expected:
|
|
errors.append(f"Expected {expected}, got {table}")
|
|
|
|
results[schema_name] = current
|
|
|
|
except Exception as e:
|
|
errors.append(f"Error in {schema_name}: {e}")
|
|
|
|
# Run many concurrent tasks with different schemas
|
|
tasks = []
|
|
for i in range(10):
|
|
for schema in ["schema_a", "schema_b", "schema_c"]:
|
|
# Vary delays to create interleaving
|
|
delay = 0.01 * (i % 3)
|
|
tasks.append(check_schema_context(f"{schema}_{i}", delay))
|
|
|
|
await asyncio.gather(*tasks)
|
|
|
|
# No errors should have occurred
|
|
assert not errors, f"Schema context isolation errors: {errors}"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_memories_respects_schema(self, memory, pg0_db_url):
|
|
"""
|
|
list_memory_units should only return memories from the current schema.
|
|
|
|
Uses run_migrations(schema=x) to provision schemas.
|
|
"""
|
|
import asyncpg
|
|
|
|
schemas = ["tenant_list_a", "tenant_list_b"]
|
|
bank_id = f"test-list-{uuid.uuid4().hex[:8]}"
|
|
|
|
# Clean up any existing schemas and provision via migrations
|
|
conn = await asyncpg.connect(pg0_db_url)
|
|
try:
|
|
for schema in schemas:
|
|
await drop_schema(conn, schema)
|
|
finally:
|
|
await conn.close()
|
|
|
|
# Provision schemas using run_migrations
|
|
for schema in schemas:
|
|
run_migrations(pg0_db_url, schema=schema)
|
|
|
|
# Insert test data directly into each schema
|
|
conn = await asyncpg.connect(pg0_db_url)
|
|
try:
|
|
for schema in schemas:
|
|
await conn.execute(
|
|
f"""
|
|
INSERT INTO "{schema}".memory_units (bank_id, text, event_date, fact_type)
|
|
VALUES ($1, $2, now(), 'world')
|
|
""",
|
|
bank_id,
|
|
f"Direct insert for {schema}",
|
|
)
|
|
finally:
|
|
await conn.close()
|
|
|
|
# Configure tenant extension
|
|
tenant_ext = MultiSchemaTestTenantExtension({
|
|
"db_url": pg0_db_url,
|
|
"valid_schemas": set(schemas),
|
|
})
|
|
memory._tenant_extension = tenant_ext
|
|
|
|
try:
|
|
# Query as tenant_list_a - should only see tenant_list_a's data
|
|
tenant_a_request = RequestContext(api_key="key-tenant_list_a")
|
|
await memory._authenticate_tenant(tenant_a_request)
|
|
|
|
result_a = await memory.list_memory_units(bank_id=bank_id, request_context=tenant_a_request)
|
|
texts_a = [item["text"] for item in result_a.get("items", [])]
|
|
|
|
assert len(texts_a) == 1, f"Expected 1 memory for tenant_list_a, got {len(texts_a)}"
|
|
assert "tenant_list_a" in texts_a[0], f"Wrong content: {texts_a[0]}"
|
|
|
|
# Query as tenant_list_b - should only see tenant_list_b's data
|
|
tenant_b_request = RequestContext(api_key="key-tenant_list_b")
|
|
await memory._authenticate_tenant(tenant_b_request)
|
|
|
|
result_b = await memory.list_memory_units(bank_id=bank_id, request_context=tenant_b_request)
|
|
texts_b = [item["text"] for item in result_b.get("items", [])]
|
|
|
|
assert len(texts_b) == 1, f"Expected 1 memory for tenant_list_b, got {len(texts_b)}"
|
|
assert "tenant_list_b" in texts_b[0], f"Wrong content: {texts_b[0]}"
|
|
|
|
finally:
|
|
# Cleanup
|
|
conn = await asyncpg.connect(pg0_db_url)
|
|
try:
|
|
for schema in schemas:
|
|
await drop_schema(conn, schema)
|
|
finally:
|
|
await conn.close()
|
|
|
|
memory._tenant_extension = None
|
|
_current_schema.set("public")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_high_concurrency_schema_isolation(self, memory, pg0_db_url):
|
|
"""
|
|
Stress test: Many concurrent operations across multiple schemas
|
|
should maintain perfect isolation.
|
|
|
|
Uses run_migrations(schema=x) to provision schemas like a real extension.
|
|
"""
|
|
import asyncpg
|
|
|
|
# Create more schemas for stress test
|
|
num_schemas = 5
|
|
ops_per_schema = 10
|
|
schemas = [f"stress_tenant_{i}" for i in range(num_schemas)]
|
|
bank_id = f"test-stress-{uuid.uuid4().hex[:8]}"
|
|
|
|
# Clean up any existing schemas first
|
|
conn = await asyncpg.connect(pg0_db_url)
|
|
try:
|
|
for schema in schemas:
|
|
await drop_schema(conn, schema)
|
|
finally:
|
|
await conn.close()
|
|
|
|
# Provision schemas using run_migrations
|
|
for schema in schemas:
|
|
run_migrations(pg0_db_url, schema=schema)
|
|
|
|
# Configure tenant extension (schemas already provisioned)
|
|
tenant_ext = MultiSchemaTestTenantExtension({
|
|
"db_url": pg0_db_url,
|
|
"valid_schemas": set(schemas),
|
|
})
|
|
# Mark schemas as already provisioned so extension doesn't re-run migrations
|
|
tenant_ext._provisioned = set(schemas)
|
|
memory._tenant_extension = tenant_ext
|
|
|
|
errors = []
|
|
|
|
async def insert_one(schema: str, item_id: int):
|
|
"""Single insert operation for tracking."""
|
|
try:
|
|
# Authenticate to set the schema context
|
|
tenant_request = RequestContext(api_key=f"key-{schema}")
|
|
await memory._authenticate_tenant(tenant_request)
|
|
|
|
# Insert using fq_table
|
|
pool = await memory._get_pool()
|
|
from hindsight_api.engine.db_utils import acquire_with_retry
|
|
|
|
async with acquire_with_retry(pool) as conn:
|
|
await conn.execute(
|
|
f"""
|
|
INSERT INTO {fq_table('memory_units')} (bank_id, text, event_date, fact_type)
|
|
VALUES ($1, $2, now(), 'world')
|
|
""",
|
|
bank_id,
|
|
f"STRESS_MARKER_{schema}_ITEM{item_id}: Memory for {schema}",
|
|
)
|
|
except Exception as e:
|
|
errors.append(f"Insert error for {schema}: {e}")
|
|
|
|
# Run many concurrent operations
|
|
tasks = []
|
|
for i in range(ops_per_schema):
|
|
for schema in schemas:
|
|
tasks.append(insert_one(schema, i))
|
|
|
|
await asyncio.gather(*tasks)
|
|
|
|
# Check for errors during insert
|
|
assert not errors, f"Errors during insert: {errors}"
|
|
|
|
# Verify no cross-contamination
|
|
conn = await asyncpg.connect(pg0_db_url)
|
|
try:
|
|
for schema in schemas:
|
|
texts = await get_memory_texts_in_schema(conn, schema, bank_id)
|
|
|
|
# Should have exactly ops_per_schema memories
|
|
assert len(texts) == ops_per_schema, (
|
|
f"Schema {schema} should have {ops_per_schema} memories, got {len(texts)}"
|
|
)
|
|
|
|
# All memories should reference this schema only
|
|
for text in texts:
|
|
# Check it contains our schema marker
|
|
assert f"STRESS_MARKER_{schema}" in text, (
|
|
f"Memory in {schema} doesn't contain schema marker: {text}"
|
|
)
|
|
|
|
# Check it doesn't contain other schema markers
|
|
for other_schema in schemas:
|
|
if other_schema != schema:
|
|
assert f"STRESS_MARKER_{other_schema}" not in text, (
|
|
f"Cross-contamination! {schema} has {other_schema}'s data: {text}"
|
|
)
|
|
finally:
|
|
# Cleanup
|
|
for schema in schemas:
|
|
await drop_schema(conn, schema)
|
|
await conn.close()
|
|
|
|
memory._tenant_extension = None
|
|
_current_schema.set("public")
|