"""Standalone Cross-Tenant Security Tests — no conftest.py dependency. Uses the production database directly with a dedicated engine. Tests RLS and visibility filter tenant isolation. """ from __future__ import annotations import os import uuid import pytest import pytest_asyncio from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine # Set test environment BEFORE any app imports os.environ["SESSION_COOKIE_SECURE"] = "false" os.environ["SESSION_COOKIE_SAMESITE"] = "lax" os.environ["ENVIRONMENT"] = "testing" os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing-only-32chars") from app.core.db import set_tenant_context, set_user_context from app.models.contact import Contact from app.models.tenant import Tenant from app.models.user import User, UserTenant from app.models.entity_permission import EntityPermission from app.services.entity_permission_service import get_effective_access, get_visible_ids from app.core.visibility import apply_visibility_filter, check_single_entity_access # Use DB from env (defaults to local test DB) DB_URL = os.environ.get( "DATABASE_URL", "postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm_test", ) @pytest_asyncio.fixture async def engine(): e = create_async_engine(DB_URL, echo=False) yield e await e.dispose() @pytest_asyncio.fixture async def db(engine): async with engine.connect() as conn: await conn.begin() session = AsyncSession(bind=conn, expire_on_commit=False) yield session await session.rollback() await conn.rollback() @pytest_asyncio.fixture async def tenant_a(db: AsyncSession): t = Tenant(id=uuid.uuid4(), name="CT Test A", slug=f"ct-test-a-{uuid.uuid4().hex[:8]}") db.add(t) await db.flush() return t @pytest_asyncio.fixture async def tenant_b(db: AsyncSession): t = Tenant(id=uuid.uuid4(), name="CT Test B", slug=f"ct-test-b-{uuid.uuid4().hex[:8]}") db.add(t) await db.flush() return t @pytest_asyncio.fixture async def user_a(db: AsyncSession, tenant_a): from app.core.auth import hash_password u = User(id=uuid.uuid4(), name="CT User A", email=f"ct-a-{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("TestPass123!"), first_name="User", last_name="A", is_active=True, is_system_admin=False) db.add(u) await db.flush() ut = UserTenant(user_id=u.id, tenant_id=tenant_a.id, role="viewer", status="active", is_default=True) db.add(ut) await db.flush() return u @pytest_asyncio.fixture async def user_b(db: AsyncSession, tenant_b): from app.core.auth import hash_password u = User(id=uuid.uuid4(), name="CT User B", email=f"ct-b-{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("TestPass123!"), first_name="User", last_name="B", is_active=True, is_system_admin=False) db.add(u) await db.flush() ut = UserTenant(user_id=u.id, tenant_id=tenant_b.id, role="viewer", status="active", is_default=True) db.add(ut) await db.flush() return u @pytest_asyncio.fixture async def contact_a(db: AsyncSession, tenant_a, user_a): c = Contact(id=uuid.uuid4(), tenant_id=tenant_a.id, firstname="CT", surname="A", email_1=f"ct-a-{uuid.uuid4().hex[:8]}@contact.local", owner_id=user_a.id, created_by=user_a.id, updated_by=user_a.id) db.add(c) await db.flush() return c @pytest_asyncio.fixture async def contact_b(db: AsyncSession, tenant_b, user_b): c = Contact(id=uuid.uuid4(), tenant_id=tenant_b.id, firstname="CT", surname="B", email_1=f"ct-b-{uuid.uuid4().hex[:8]}@contact.local", owner_id=user_b.id, created_by=user_b.id, updated_by=user_b.id) db.add(c) await db.flush() return c # ── Tests ──────────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_visibility_filter_blocks_cross_tenant( db, tenant_a, tenant_b, user_a, contact_a, contact_b ): """Defense-in-Depth: visibility filter must not return cross-tenant contacts.""" await set_tenant_context(db, tenant_a.id) await set_user_context(db, user_a.id, [], False) query = select(Contact).where(Contact.deleted_at.is_(None)) filtered = await apply_visibility_filter(db, query, "contact", Contact, user_a.id, tenant_a.id, False) result = await db.execute(filtered) contacts = result.scalars().all() for c in contacts: assert c.tenant_id == tenant_a.id, f"Visibility filter returned cross-tenant contact! {c.tenant_id} != {tenant_a.id}" @pytest.mark.asyncio async def test_check_single_entity_access_cross_tenant( db, tenant_a, tenant_b, user_a, contact_a, contact_b ): """check_single_entity_access must block cross-tenant access.""" await set_tenant_context(db, tenant_a.id) await set_user_context(db, user_a.id, [], False) access_a = await check_single_entity_access(db, "contact", contact_a.id, user_a.id, tenant_a.id, "read", False) assert access_a is True, "User A should have access to own contact" access_b = await check_single_entity_access(db, "contact", contact_b.id, user_a.id, tenant_a.id, "read", False) assert access_b is False, "Cross-tenant access allowed!" @pytest.mark.asyncio async def test_get_visible_ids_tenant_scoped( db, tenant_a, tenant_b, user_a, contact_a, contact_b ): """get_visible_ids must only return same-tenant entity IDs.""" visible_ids, _ = await get_visible_ids(db, tenant_a.id, user_a.id, "contact") assert contact_a.id in visible_ids, "User A's own contact not in visible_ids!" assert contact_b.id not in visible_ids, "Cross-tenant contact in visible_ids!" @pytest.mark.asyncio async def test_entity_permissions_tenant_scoped( db, tenant_a, tenant_b, user_a, user_b, contact_a, contact_b ): """entity_permissions must not leak across tenants.""" perm = EntityPermission( id=uuid.uuid4(), tenant_id=tenant_a.id, entity_type="contact", entity_id=contact_a.id, principal_type="user", principal_id=user_a.id, permission_level="read", ) db.add(perm) await db.flush() access_b = await get_effective_access(db, tenant_b.id, user_b.id, "contact", contact_a.id) assert access_b == "none", f"Entity permission leaked across tenants! Access: {access_b}" access_a = await get_effective_access(db, tenant_a.id, user_a.id, "contact", contact_a.id) assert access_a in ("read", "write", "admin", "owner"), f"User A should have access: {access_a}" @pytest.mark.asyncio async def test_rls_tenant_isolation_policy_exists(db): """RLS tenant isolation policy must exist on contacts table.""" result = await db.execute(text(""" SELECT polname FROM pg_policy WHERE polrelid = 'contacts'::regclass AND polname LIKE '%tenant%' """)) policies = result.fetchall() assert len(policies) > 0, "No tenant isolation policy found on contacts table!" @pytest.mark.asyncio async def test_rls_enabled_on_tenant_tables(db): """RLS must be enabled on critical tenant tables.""" for table in ["contacts", "addresses", "attachments", "entity_permissions", "entity_policies"]: result = await db.execute(text(f"SELECT relrowsecurity FROM pg_class WHERE relname = '{table}'")) rls = result.scalar() if rls is not None: assert rls is True, f"RLS not enabled on {table}!" @pytest.mark.asyncio async def test_rls_disabled_on_system_tables(db): """RLS must be disabled on system identity tables (bootstrap fix).""" for table in ["users", "user_tenants", "groups", "user_groups", "roles"]: result = await db.execute(text(f"SELECT relrowsecurity FROM pg_class WHERE relname = '{table}'")) rls = result.scalar() if rls is not None: assert rls is False, f"RLS should be disabled on {table} for login bootstrap!" @pytest.mark.asyncio async def test_tenant_context_variable_consistency(db, tenant_a): """set_tenant_context must set app.current_tenant_id (the only standard).""" await set_tenant_context(db, tenant_a.id) result = await db.execute(text("SELECT current_setting('app.current_tenant_id', true)")) assert result.scalar() == str(tenant_a.id), "app.current_tenant_id not set correctly"