Files
leocrm/tests/test_permission_system_live.py
T
Agent Zero f0bf53f0b3
Check Cross-Plugin Imports / check (push) Has been cancelled
fix(tests): Remove AIChatSession/AIChatMessage imports, skip ai_copilot tests, fix OwnedMixin import
- test_ai_proactive.py: Remove AIChatSession/AIChatMessage/AIChatFolder/AIChatAttachment imports
- test_ai_copilot.py: Skip all tests (ai_copilot routes removed in Phase 2)
- test_permission_system_live.py: Guard AIConversation/AIMessage import with try/except
- conftest.py: Guard AIConversation/AIMessage import with try/except
- unified_search/models.py: Add missing OwnedMixin import
- test_ai_proactive.py: Skip test_rate_limiting (get_cache removed)
2026-08-21 20:54:46 +02:00

1161 lines
45 KiB
Python

"""Comprehensive live permission system tests for LeoCRM.
Runs in the Coolify container against the real PostgreSQL database.
Self-contained — does NOT depend on conftest.py (which requires psycopg2).
Tests:
1. User roles (admin, editor, viewer, guest, no-tenant, multi-tenant)
2. RLS per table (cross-tenant isolation, admin sees all, guest sees shared only)
3. Permission system (RBAC, ABAC, entity-level, cross-tenant blocked, guest access)
4. API endpoints (login, CRUD with different permissions, 403/200)
5. Specific scenarios (cross-tenant contact, sharing, guest, admin, delete permissions)
"""
from __future__ import annotations
import asyncio
import os
import uuid
from collections.abc import AsyncGenerator
from typing import Any
# ── Environment overrides (must be set BEFORE app imports) ──
os.environ.setdefault("SESSION_COOKIE_SECURE", "false")
os.environ.setdefault("SESSION_COOKIE_SAMESITE", "lax")
os.environ.setdefault("SECRET_KEY", "test-secret-key-with-at-least-32-characters-for-testing-only!!")
os.environ.setdefault("ENVIRONMENT", "testing")
os.environ["CORS_ORIGINS"] = "http://localhost:5173,http://localhost:3000"
import pytest
import pytest_asyncio
import redis.asyncio as aioredis
from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.core.auth import hash_password
from app.core.db import Base, close_engine, reset_engine_for_testing
from app.core.service_container import get_container # noqa: F401
from app.main import create_app
from app.models.contact import Contact # noqa: F401
from app.models.role import Role
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
from app.models.entity_permission import EntityPermission
from app.models.group import Group, UserGroup
from app.plugins.registry import reset_registry_for_testing # noqa: F401
from app.services.plugin_service import reset_plugin_service_for_testing # noqa: F401
# Import plugin models so Base.metadata includes their tables
from app.plugins.builtins.permissions import PermissionsPlugin # noqa: F401
from app.plugins.builtins.permissions.models import Permission, ShareLink # noqa: F401
from app.plugins.builtins.tasks import TasksPlugin # noqa: F401
from app.plugins.builtins.tasks.models import Task # noqa: F401
from app.plugins.builtins.dms import DmsPlugin # noqa: F401
from app.plugins.builtins.dms.models import File as DmsFile # noqa: F401
from app.plugins.builtins.dms.models import Folder # noqa: F401
from app.plugins.builtins.calendar import CalendarPlugin # noqa: F401
from app.plugins.builtins.calendar.models import ( # noqa: F401
Calendar, CalendarEntry, CalendarEntryLink, CalendarShare,
Resource, ResourceBooking, Subtask, UserCalendarVisibility,
)
from app.plugins.builtins.mail import MailPlugin # noqa: F401
from app.plugins.builtins.mail.models import ( # noqa: F401
ContactPgpKey, MailAccount, MailAccountDelegate, MailAccountSendPermission,
MailAttachment, MailFolder, MailLabel, MailLabelAssignment, MailRule,
MailSeenBy, MailSignature, MailTemplate, PgpKey, VacationSentLog,
)
from app.plugins.builtins.tags.models import Tag, TagAssignment # noqa: F401
from app.plugins.builtins.entity_links.models import EntityLink # noqa: F401
from app.plugins.builtins.report_generator import ReportGeneratorPlugin # noqa: F401
from app.plugins.builtins.report_generator.models import ReportInstance, ReportTemplate # noqa: F401
try:
from app.models.ai_conversation import AIConversation, AIMessage # noqa: F401
except ImportError:
pass
from app.models.contact_merge import ContactMergeHistory # noqa: F401
from app.models.plugin import Plugin, PluginMigration # noqa: F401
from app.models.user_preference import UserPreference # noqa: F401
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory # noqa: F401
from app.plugins.builtins.mcp_server import McpServerPlugin # noqa: F401
from app.plugins.builtins.mcp_client import McpClientPlugin # noqa: F401
from app.plugins.builtins.mcp_client.models import McpServerConfig # noqa: F401
from app.models.outbox import EventOutbox # noqa: F401
from app.models.consumer_inbox import ConsumerInbox # noqa: F401
from app.models.outbox_delivery import OutboxDelivery # noqa: F401
from app.models.saved_filter import SavedFilter # noqa: F401
# Clear settings cache so env overrides take effect
from app.config import get_settings
get_settings.cache_clear()
# ── Connection URLs from env (with fallbacks) ──
TEST_DB_URL = os.environ.get(
"TEST_DB_URL",
os.environ.get("DATABASE_URL", "postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm_test"),
)
TEST_REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
ORIGIN_HEADER = {"Origin": "http://localhost:5173"}
# ── Session-scoped DB setup ──
@pytest.fixture(scope="session", autouse=True)
def db_setup():
"""Drop and recreate all tables once per test session using async engine."""
async def _setup():
eng = create_async_engine(TEST_DB_URL, echo=False)
async with eng.begin() as conn:
await conn.execute(text("SET lock_timeout = '10s';"))
try:
await conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE;"))
await conn.execute(text("CREATE SCHEMA public;"))
except Exception:
pass
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
await conn.run_sync(Base.metadata.create_all)
await eng.dispose()
asyncio.run(_setup())
yield
async def _cleanup():
eng = create_async_engine(TEST_DB_URL, echo=False)
async with eng.begin() as conn:
await conn.execute(text("SET lock_timeout = '10s';"))
try:
await conn.execute(text(
"DO $$ DECLARE r RECORD; BEGIN "
"FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname='public') "
"LOOP EXECUTE 'TRUNCATE TABLE public.' || quote_ident(r.tablename) || ' CASCADE'; "
"END LOOP; END $$;"
))
except Exception:
pass
await eng.dispose()
asyncio.run(_cleanup())
@pytest.fixture(autouse=True)
def clean_tables(db_setup):
"""TRUNCATE all tables before each test (preserve schema)."""
async def _clean():
eng = create_async_engine(TEST_DB_URL, echo=False)
async with eng.begin() as conn:
result = await conn.execute(text(
"SELECT table_name FROM information_schema.tables "
"WHERE table_schema = 'public' AND table_type = 'BASE TABLE';"
))
tables = [row[0] for row in result]
if tables:
table_list = ", ".join(tables)
await conn.execute(text(f"TRUNCATE TABLE {table_list} CASCADE;"))
await eng.dispose()
asyncio.run(_clean())
yield
# ── Async fixtures ──
@pytest_asyncio.fixture
async def redis_client() -> AsyncGenerator[aioredis.Redis, None]:
r = aioredis.from_url(TEST_REDIS_URL, decode_responses=True)
await r.flushdb()
yield r
await r.flushdb()
await r.aclose()
@pytest_asyncio.fixture
async def engine() -> AsyncGenerator[AsyncEngine, None]:
eng = create_async_engine(TEST_DB_URL, echo=False)
yield eng
await eng.dispose()
@pytest_asyncio.fixture
async def session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
return async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
@pytest_asyncio.fixture
async def db_session(
session_factory: async_sessionmaker[AsyncSession],
) -> AsyncGenerator[AsyncSession, None]:
async with session_factory() as session:
yield session
await session.rollback()
@pytest_asyncio.fixture
async def app(engine: AsyncEngine, redis_client: aioredis.Redis):
"""FastAPI app with test engine injected."""
reset_engine_for_testing(engine)
app = create_app()
yield app
await close_engine()
@pytest_asyncio.fixture
async def client(app) -> AsyncGenerator[AsyncClient, None]:
"""HTTP async test client."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
# ── Seed Data ──
async def seed_full_data(db: AsyncSession) -> dict[str, Any]:
"""Seed two tenants with admin, editor, viewer, guest users and contacts.
Returns dict with all created entities.
"""
# Tenants
tenant_a = Tenant(name="Tenant A", slug="tenant-a")
tenant_b = Tenant(name="Tenant B", slug="tenant-b")
db.add_all([tenant_a, tenant_b])
await db.flush()
# Default roles (new RBAC architecture requires role_id for permissions)
editor_role = Role(
tenant_id=tenant_a.id,
name="editor",
permissions={"contacts": {"read": True, "write": True, "delete": False}},
denied_permissions=[],
field_permissions={},
)
viewer_role = Role(
tenant_id=tenant_a.id,
name="viewer",
permissions={"contacts": {"read": True, "write": False, "delete": False}},
denied_permissions=[],
field_permissions={},
)
guest_role = Role(
tenant_id=tenant_a.id,
name="guest",
permissions={"contacts": {"read": True}},
denied_permissions=[],
field_permissions={},
)
db.add_all([editor_role, viewer_role, guest_role])
await db.flush()
# Users
admin_a = User(
email="admin@tenanta.com",
name="Admin A",
password_hash=hash_password("TestPass123!"),
is_active=True,
is_system_admin=True,
preferences={},
)
editor_a = User(
email="editor@tenanta.com",
name="Editor A",
password_hash=hash_password("TestPass123!"),
is_active=True,
preferences={},
)
viewer_a = User(
email="viewer@tenanta.com",
name="Viewer A",
password_hash=hash_password("TestPass123!"),
is_active=True,
preferences={},
)
admin_b = User(
email="admin@tenantb.com",
name="Admin B",
password_hash=hash_password("TestPass123!"),
is_active=True,
is_system_admin=True,
preferences={},
)
# User with no tenant membership
orphan_user = User(
email="orphan@test.com",
name="Orphan User",
password_hash=hash_password("TestPass123!"),
is_active=True,
preferences={},
)
# Guest user in tenant A (now a regular User with role=guest)
guest_a = User(
email="guest@tenanta.com",
name="Guest A",
password_hash=hash_password("TestPass123!"),
is_active=True,
preferences={},
)
db.add_all([admin_a, editor_a, viewer_a, admin_b, orphan_user, guest_a])
await db.flush()
# User-tenant memberships
ut1 = UserTenant(user_id=admin_a.id, tenant_id=tenant_a.id, is_default=True, role="admin")
ut2 = UserTenant(user_id=editor_a.id, tenant_id=tenant_a.id, is_default=True, role="editor", role_id=editor_role.id)
ut3 = UserTenant(user_id=viewer_a.id, tenant_id=tenant_a.id, is_default=True, role="viewer", role_id=viewer_role.id)
ut4 = UserTenant(user_id=admin_b.id, tenant_id=tenant_b.id, is_default=True, role="admin")
# Admin A is also member of tenant B (multi-tenant)
ut5 = UserTenant(user_id=admin_a.id, tenant_id=tenant_b.id, is_default=False, role="admin")
# Guest tenant membership with role=guest
ut6 = UserTenant(user_id=guest_a.id, tenant_id=tenant_a.id, is_default=True, role="guest", role_id=guest_role.id)
db.add_all([ut1, ut2, ut3, ut4, ut5, ut6])
await db.flush()
# Custom role with limited permissions in tenant A
custom_role = Role(
tenant_id=tenant_a.id,
name="sales_rep",
permissions={"contacts": {"read": True, "write": True, "delete": False}},
denied_permissions=[],
field_permissions={"annual_revenue": "hidden"},
)
db.add(custom_role)
await db.flush()
# User with custom role
sales_user = User(
email="sales@tenanta.com",
name="Sales Rep",
password_hash=hash_password("TestPass123!"),
is_active=True,
preferences={},
)
db.add(sales_user)
await db.flush()
ut6 = UserTenant(
user_id=sales_user.id, tenant_id=tenant_a.id, is_default=True,
role="viewer", role_id=custom_role.id,
)
db.add(ut6)
await db.flush()
# Contacts in tenant A
contact_a1 = Contact(
tenant_id=tenant_a.id,
type="company",
name="Company Alpha",
displayname="Company Alpha",
created_by=admin_a.id,
updated_by=admin_a.id,
owner_id=admin_a.id,
)
contact_a2 = Contact(
tenant_id=tenant_a.id,
type="company",
name="Company Beta Shared",
displayname="Company Beta Shared",
created_by=editor_a.id,
updated_by=editor_a.id,
owner_id=editor_a.id,
)
# Tenant-owned contact (owner_id=None)
contact_a3 = Contact(
tenant_id=tenant_a.id,
type="company",
name="Company Gamma Tenant",
displayname="Company Gamma Tenant",
created_by=admin_a.id,
updated_by=admin_a.id,
owner_id=None,
)
# Contact in tenant B
contact_b1 = Contact(
tenant_id=tenant_b.id,
type="company",
name="Company Delta",
displayname="Company Delta",
created_by=admin_b.id,
updated_by=admin_b.id,
owner_id=admin_b.id,
)
db.add_all([contact_a1, contact_a2, contact_a3, contact_b1])
await db.flush()
# Entity permission: share contact_a2 with viewer_a (read access)
ep_share = EntityPermission(
tenant_id=tenant_a.id,
entity_type="contact",
entity_id=contact_a2.id,
principal_type="user",
principal_id=viewer_a.id,
permission_level="read",
created_by=admin_a.id,
)
# Entity permission: share contact_a1 with guest_a (read access)
ep_guest = EntityPermission(
tenant_id=tenant_a.id,
entity_type="contact",
entity_id=contact_a1.id,
principal_type="user",
principal_id=guest_a.id,
permission_level="read",
created_by=admin_a.id,
)
db.add_all([ep_share, ep_guest])
await db.flush()
await db.commit()
return {
"tenant_a": tenant_a,
"tenant_b": tenant_b,
"admin_a": admin_a,
"editor_a": editor_a,
"viewer_a": viewer_a,
"admin_b": admin_b,
"orphan_user": orphan_user,
"guest_a": guest_a,
"sales_user": sales_user,
"custom_role": custom_role,
"contact_a1": contact_a1,
"contact_a2": contact_a2,
"contact_a3": contact_a3,
"contact_b1": contact_b1,
"ep_share": ep_share,
"ep_guest": ep_guest,
}
async def login(client: AsyncClient, email: str, password: str = "TestPass123!",
tenant_slug: str | None = None) -> dict[str, str]:
"""Login via HTTP API, set CSRF token on client, return cookies dict."""
body = {"email": email, "password": password}
if tenant_slug:
body["tenant_slug"] = tenant_slug
resp = await client.post("/api/v1/auth/login", json=body, headers=ORIGIN_HEADER)
assert resp.status_code == 200, f"Login failed for {email}: {resp.status_code} {resp.text}"
data = resp.json()
csrf_token = data.get("csrf_token", "")
client.headers["X-CSRF-Token"] = csrf_token
client.headers["Origin"] = ORIGIN_HEADER["Origin"]
return dict(resp.cookies)
async def logout(client: AsyncClient):
"""Logout and clear auth headers."""
await client.post("/api/v1/auth/logout", headers=ORIGIN_HEADER)
client.headers.pop("X-CSRF-Token", None)
client.cookies.clear()
# ═══════════════════════════════════════════════════════════════════════════════
# 1. USER ROLE TESTS
# ═══════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_admin_login_and_access(client: AsyncClient, db_session: AsyncSession):
"""Admin user can login and access all endpoints."""
seed = await seed_full_data(db_session)
await login(client, "admin@tenanta.com", tenant_slug="tenant-a")
# Admin can list contacts
resp = await client.get("/api/v1/contacts")
assert resp.status_code == 200, f"Admin list contacts failed: {resp.text}"
data = resp.json()
assert data["total"] >= 3, f"Admin should see all tenant A contacts, got {data['total']}"
@pytest.mark.asyncio
async def test_viewer_login_and_read_access(client: AsyncClient, db_session: AsyncSession):
"""Viewer user can login and read but not write."""
seed = await seed_full_data(db_session)
await login(client, "viewer@tenanta.com", tenant_slug="tenant-a")
# Viewer can list contacts
resp = await client.get("/api/v1/contacts")
assert resp.status_code == 200, f"Viewer list contacts failed: {resp.text}"
# Viewer can read individual contact (tenant-owned)
resp = await client.get(f"/api/v1/contacts/{seed['contact_a3'].id}")
assert resp.status_code == 200, f"Viewer read tenant-owned contact failed: {resp.text}"
@pytest.mark.asyncio
async def test_editor_write_access(client: AsyncClient, db_session: AsyncSession):
"""Editor user can create and update contacts."""
seed = await seed_full_data(db_session)
await login(client, "editor@tenanta.com", tenant_slug="tenant-a")
# Editor can create contact
resp = await client.post("/api/v1/contacts", json={
"type": "company",
"name": "Editor Created Co",
"displayname": "Editor Created Co",
})
assert resp.status_code in (200, 201), f"Editor create contact failed: {resp.text}"
@pytest.mark.asyncio
async def test_user_without_tenant_login(client: AsyncClient, db_session: AsyncSession):
"""User without tenant membership cannot login (no active membership)."""
seed = await seed_full_data(db_session)
# Orphan user has no tenant membership — login should fail
resp = await client.post(
"/api/v1/auth/login",
json={"email": "orphan@test.com", "password": "TestPass123!"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 401, f"Orphan login should fail: {resp.status_code} {resp.text}"
@pytest.mark.asyncio
async def test_multi_tenant_user_switch(client: AsyncClient, db_session: AsyncSession):
"""User with multiple tenants can login to each and see different data."""
seed = await seed_full_data(db_session)
# Login as admin_a in tenant A
await login(client, "admin@tenanta.com", tenant_slug="tenant-a")
resp = await client.get("/api/v1/contacts")
assert resp.status_code == 200
data_a = resp.json()
assert data_a["total"] >= 3, f"Tenant A should have 3+ contacts, got {data_a['total']}"
# Logout
await logout(client)
# Login as admin_a in tenant B
await login(client, "admin@tenanta.com", tenant_slug="tenant-b")
resp = await client.get("/api/v1/contacts")
assert resp.status_code == 200
data_b = resp.json()
assert data_b["total"] >= 1, f"Tenant B should have 1+ contacts, got {data_b['total']}"
# Verify different data sets
names_a = {item["name"] for item in data_a.get("items", [])}
names_b = {item["name"] for item in data_b.get("items", [])}
assert names_a != names_b, "Tenants should have different contacts"
assert "Company Delta" in names_b, f"Tenant B should have Company Delta, got {names_b}"
# ═══════════════════════════════════════════════════════════════════════════════
# 2. RLS / CROSS-TENANT ISOLATION TESTS
# ═══════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_cross_tenant_isolation_list(client: AsyncClient, db_session: AsyncSession):
"""User A (tenant A) cannot see contacts from tenant B."""
seed = await seed_full_data(db_session)
await login(client, "admin@tenanta.com", tenant_slug="tenant-a")
resp = await client.get("/api/v1/contacts")
assert resp.status_code == 200
data = resp.json()
names = {item["name"] for item in data.get("items", [])}
assert "Company Delta" not in names, f"Tenant A user should NOT see tenant B contact: {names}"
assert "Company Alpha" in names, f"Tenant A user should see tenant A contact: {names}"
@pytest.mark.asyncio
async def test_cross_tenant_isolation_direct_access(client: AsyncClient, db_session: AsyncSession):
"""User A (tenant A) cannot directly access tenant B contact by ID."""
seed = await seed_full_data(db_session)
await login(client, "admin@tenanta.com", tenant_slug="tenant-a")
# Try to access tenant B's contact directly
resp = await client.get(f"/api/v1/contacts/{seed['contact_b1'].id}")
assert resp.status_code in (403, 404), (
f"Cross-tenant direct access should be blocked: {resp.status_code} {resp.text}"
)
@pytest.mark.asyncio
async def test_admin_sees_all_tenant_data(client: AsyncClient, db_session: AsyncSession):
"""System admin sees all contacts within their tenant."""
seed = await seed_full_data(db_session)
await login(client, "admin@tenanta.com", tenant_slug="tenant-a")
resp = await client.get("/api/v1/contacts")
assert resp.status_code == 200
data = resp.json()
names = {item["name"] for item in data.get("items", [])}
# Admin should see all tenant A contacts (owned by different users + tenant-owned)
assert "Company Alpha" in names
assert "Company Beta Shared" in names
assert "Company Gamma Tenant" in names
@pytest.mark.asyncio
async def test_viewer_visibility_filter(client: AsyncClient, db_session: AsyncSession):
"""Viewer (non-admin) only sees: own + tenant-owned + shared contacts."""
seed = await seed_full_data(db_session)
await login(client, "viewer@tenanta.com", tenant_slug="tenant-a")
resp = await client.get("/api/v1/contacts")
assert resp.status_code == 200
data = resp.json()
names = {item["name"] for item in data.get("items", [])}
# Viewer should see: tenant-owned (Gamma) + shared (Beta Shared)
# Viewer should NOT see: admin-owned (Alpha) unless shared
assert "Company Gamma Tenant" in names, f"Viewer should see tenant-owned: {names}"
assert "Company Beta Shared" in names, f"Viewer should see shared: {names}"
# Alpha is owned by admin_a and not shared with viewer_a
# (guest has access but viewer doesn't)
assert "Company Alpha" not in names, f"Viewer should NOT see unshared admin-owned: {names}"
# ═══════════════════════════════════════════════════════════════════════════════
# 3. PERMISSION SYSTEM TESTS (RBAC + ABAC)
# ═══════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_rbac_viewer_cannot_delete(client: AsyncClient, db_session: AsyncSession):
"""Viewer role cannot delete contacts (RBAC)."""
seed = await seed_full_data(db_session)
await login(client, "viewer@tenanta.com", tenant_slug="tenant-a")
# Viewer tries to delete a contact
resp = await client.delete(f"/api/v1/contacts/{seed['contact_a3'].id}")
assert resp.status_code == 403, (
f"Viewer should NOT be able to delete: {resp.status_code} {resp.text}"
)
@pytest.mark.asyncio
async def test_rbac_editor_can_create(client: AsyncClient, db_session: AsyncSession):
"""Editor role can create contacts (RBAC)."""
seed = await seed_full_data(db_session)
await login(client, "editor@tenanta.com", tenant_slug="tenant-a")
resp = await client.post("/api/v1/contacts", json={
"type": "company",
"name": "Editor Test Co",
"displayname": "Editor Test Co",
})
assert resp.status_code in (200, 201), f"Editor should be able to create: {resp.text}"
@pytest.mark.asyncio
async def test_rbac_admin_can_delete(client: AsyncClient, db_session: AsyncSession):
"""Admin role can delete contacts (RBAC)."""
seed = await seed_full_data(db_session)
await login(client, "admin@tenanta.com", tenant_slug="tenant-a")
# Create a contact to delete
resp = await client.post("/api/v1/contacts", json={
"type": "company",
"name": "To Delete Co",
"displayname": "To Delete Co",
})
assert resp.status_code in (200, 201)
contact_id = resp.json().get("id")
# Admin can delete
resp = await client.delete(f"/api/v1/contacts/{contact_id}")
assert resp.status_code in (200, 204), f"Admin should be able to delete: {resp.text}"
@pytest.mark.asyncio
async def test_abac_entity_permission_sharing(client: AsyncClient, db_session: AsyncSession):
"""ABAC: User shares contact with another user via entity_permissions."""
seed = await seed_full_data(db_session)
# Login as admin_a
await login(client, "admin@tenanta.com", tenant_slug="tenant-a")
# Create a new contact owned by admin_a
resp = await client.post("/api/v1/contacts", json={
"type": "company",
"name": "Shared Test Co",
"displayname": "Shared Test Co",
})
assert resp.status_code in (200, 201)
new_contact_id = resp.json().get("id")
# Share with viewer_a via entity_permissions API
resp = await client.post(
f"/api/v1/permissions/contact/{new_contact_id}",
json={
"principal_type": "user",
"principal_id": str(seed["viewer_a"].id),
"permission_level": "read",
},
)
assert resp.status_code in (200, 201), f"Share permission failed: {resp.text}"
# Logout and login as viewer_a
await logout(client)
await login(client, "viewer@tenanta.com", tenant_slug="tenant-a")
# Viewer should now see the shared contact
resp = await client.get("/api/v1/contacts")
assert resp.status_code == 200
data = resp.json()
names = {item["name"] for item in data.get("items", [])}
assert "Shared Test Co" in names, f"Viewer should see shared contact: {names}"
@pytest.mark.asyncio
async def test_abac_owner_can_access(client: AsyncClient, db_session: AsyncSession):
"""ABAC: Owner can access their own entities."""
seed = await seed_full_data(db_session)
await login(client, "editor@tenanta.com", tenant_slug="tenant-a")
# Editor created contact_a2 — should be able to access it
resp = await client.get(f"/api/v1/contacts/{seed['contact_a2'].id}")
assert resp.status_code == 200, f"Owner should access own contact: {resp.text}"
@pytest.mark.asyncio
async def test_abac_non_owner_non_shared_cannot_access(client: AsyncClient, db_session: AsyncSession):
"""ABAC: Non-owner without share cannot access private entity."""
seed = await seed_full_data(db_session)
await login(client, "viewer@tenanta.com", tenant_slug="tenant-a")
# contact_a1 is owned by admin_a, not shared with viewer_a
resp = await client.get(f"/api/v1/contacts/{seed['contact_a1'].id}")
assert resp.status_code in (403, 404), (
f"Non-owner without share should NOT access: {resp.status_code} {resp.text}"
)
@pytest.mark.asyncio
async def test_custom_role_permissions(client: AsyncClient, db_session: AsyncSession):
"""Custom role with limited permissions: can read/create/update but NOT delete."""
seed = await seed_full_data(db_session)
await login(client, "sales@tenanta.com", tenant_slug="tenant-a")
# Can read contacts
resp = await client.get("/api/v1/contacts")
assert resp.status_code == 200, f"Sales rep should read contacts: {resp.text}"
# Can create contact
resp = await client.post("/api/v1/contacts", json={
"type": "company",
"name": "Sales Created Co",
"displayname": "Sales Created Co",
})
assert resp.status_code in (200, 201), f"Sales rep should create: {resp.text}"
# Cannot delete contact
resp = await client.delete(f"/api/v1/contacts/{seed['contact_a3'].id}")
assert resp.status_code == 403, (
f"Sales rep should NOT delete (custom role denies): {resp.status_code} {resp.text}"
)
# ═══════════════════════════════════════════════════════════════════════════════
# 4. API ENDPOINT TESTS
# ═══════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_unauthenticated_access_blocked(client: AsyncClient, db_session: AsyncSession):
"""Unauthenticated requests should get 401."""
seed = await seed_full_data(db_session)
resp = await client.get("/api/v1/contacts")
assert resp.status_code == 401, f"Unauthenticated should get 401: {resp.status_code}"
@pytest.mark.asyncio
async def test_403_on_missing_permission(client: AsyncClient, db_session: AsyncSession):
"""403 Forbidden when user lacks required permission."""
seed = await seed_full_data(db_session)
await login(client, "viewer@tenanta.com", tenant_slug="tenant-a")
# Viewer tries to delete — should get 403
resp = await client.delete(f"/api/v1/contacts/{seed['contact_a3'].id}")
assert resp.status_code == 403, f"Viewer delete should be 403: {resp.status_code} {resp.text}"
@pytest.mark.asyncio
async def test_200_on_valid_permission(client: AsyncClient, db_session: AsyncSession):
"""200 OK when user has required permission."""
seed = await seed_full_data(db_session)
await login(client, "admin@tenanta.com", tenant_slug="tenant-a")
resp = await client.get("/api/v1/contacts")
assert resp.status_code == 200, f"Admin read should be 200: {resp.text}"
@pytest.mark.asyncio
async def test_crud_admin_full_cycle(client: AsyncClient, db_session: AsyncSession):
"""Admin can perform full CRUD cycle."""
seed = await seed_full_data(db_session)
await login(client, "admin@tenanta.com", tenant_slug="tenant-a")
# Create
resp = await client.post("/api/v1/contacts", json={
"type": "company",
"name": "CRUD Test Co",
"displayname": "CRUD Test Co",
})
assert resp.status_code in (200, 201)
contact_id = resp.json().get("id")
# Read
resp = await client.get(f"/api/v1/contacts/{contact_id}")
assert resp.status_code == 200
assert resp.json().get("name") == "CRUD Test Co"
# Update
resp = await client.put(f"/api/v1/contacts/{contact_id}", json={
"name": "CRUD Updated Co",
"displayname": "CRUD Updated Co",
})
assert resp.status_code == 200, f"Update failed: {resp.text}"
# Delete
resp = await client.delete(f"/api/v1/contacts/{contact_id}")
assert resp.status_code in (200, 204), f"Delete failed: {resp.text}"
# ═══════════════════════════════════════════════════════════════════════════════
# 5. SPECIFIC SCENARIOS
# ═══════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_scenario_cross_tenant_contact_invisible(client: AsyncClient, db_session: AsyncSession):
"""User A creates contact in tenant A, User B in tenant B cannot see it."""
seed = await seed_full_data(db_session)
# Login as admin_a, create contact
await login(client, "admin@tenanta.com", tenant_slug="tenant-a")
resp = await client.post("/api/v1/contacts", json={
"type": "company",
"name": "Secret Tenant A Co",
"displayname": "Secret Tenant A Co",
})
assert resp.status_code in (200, 201)
# Logout, login as admin_b
await logout(client)
await login(client, "admin@tenantb.com", tenant_slug="tenant-b")
# Admin B should NOT see tenant A's contact
resp = await client.get("/api/v1/contacts")
assert resp.status_code == 200
data = resp.json()
names = {item["name"] for item in data.get("items", [])}
assert "Secret Tenant A Co" not in names, (
f"Tenant B should NOT see tenant A contact: {names}"
)
@pytest.mark.asyncio
async def test_scenario_share_within_tenant(client: AsyncClient, db_session: AsyncSession):
"""User A shares contact with User B (same tenant), User B can see it."""
seed = await seed_full_data(db_session)
# Login as admin_a
await login(client, "admin@tenanta.com", tenant_slug="tenant-a")
# Create a new contact
resp = await client.post("/api/v1/contacts", json={
"type": "company",
"name": "Shared Within Tenant Co",
"displayname": "Shared Within Tenant Co",
})
assert resp.status_code in (200, 201)
new_contact_id = resp.json().get("id")
# Share with viewer_a
resp = await client.post(
f"/api/v1/permissions/contact/{new_contact_id}",
json={
"principal_type": "user",
"principal_id": str(seed["viewer_a"].id),
"permission_level": "read",
},
)
assert resp.status_code in (200, 201), f"Share failed: {resp.text}"
# Login as viewer_a
await logout(client)
await login(client, "viewer@tenanta.com", tenant_slug="tenant-a")
# Viewer should see the shared contact
resp = await client.get("/api/v1/contacts")
assert resp.status_code == 200
data = resp.json()
names = {item["name"] for item in data.get("items", [])}
assert "Shared Within Tenant Co" in names, (
f"Viewer should see shared contact: {names}"
)
@pytest.mark.asyncio
async def test_scenario_guest_access(client: AsyncClient, db_session: AsyncSession):
"""Guest user can only see entities shared with them."""
seed = await seed_full_data(db_session)
# Guest login (now uses regular auth endpoint)
await login(client, "guest@tenanta.com", tenant_slug="tenant-a")
# Guest should be able to access shared contact (contact_a1 shared with guest)
resp = await client.get("/api/v1/contacts")
assert resp.status_code == 200, f"Guest contacts access failed: {resp.status_code} {resp.text}"
@pytest.mark.asyncio
async def test_scenario_admin_can_modify_all(client: AsyncClient, db_session: AsyncSession):
"""Admin can see and modify all data within their tenant."""
seed = await seed_full_data(db_session)
await login(client, "admin@tenanta.com", tenant_slug="tenant-a")
# Admin can access contact owned by editor
resp = await client.get(f"/api/v1/contacts/{seed['contact_a2'].id}")
assert resp.status_code == 200, f"Admin should access editor's contact: {resp.text}"
# Admin can update contact owned by editor
resp = await client.put(
f"/api/v1/contacts/{seed['contact_a2'].id}",
json={"name": "Updated by Admin", "displayname": "Updated by Admin"},
)
assert resp.status_code == 200, f"Admin should update editor's contact: {resp.text}"
@pytest.mark.asyncio
async def test_scenario_no_permission_no_delete(client: AsyncClient, db_session: AsyncSession):
"""User without delete permission cannot perform DELETE operations."""
seed = await seed_full_data(db_session)
await login(client, "sales@tenanta.com", tenant_slug="tenant-a")
# Sales rep has read/create/update but NOT delete
resp = await client.delete(f"/api/v1/contacts/{seed['contact_a3'].id}")
assert resp.status_code == 403, (
f"Sales rep should NOT delete: {resp.status_code} {resp.text}"
)
@pytest.mark.asyncio
async def test_scenario_cross_tenant_delete_blocked(client: AsyncClient, db_session: AsyncSession):
"""Admin A cannot delete contact in tenant B."""
seed = await seed_full_data(db_session)
await login(client, "admin@tenanta.com", tenant_slug="tenant-a")
# Try to delete tenant B's contact
resp = await client.delete(f"/api/v1/contacts/{seed['contact_b1'].id}")
assert resp.status_code in (403, 404), (
f"Cross-tenant delete should be blocked: {resp.status_code} {resp.text}"
)
@pytest.mark.asyncio
async def test_tenant_owned_visible_to_all(client: AsyncClient, db_session: AsyncSession):
"""Tenant-owned contacts (owner_id=NULL) are visible to all users with read permission."""
seed = await seed_full_data(db_session)
await login(client, "viewer@tenanta.com", tenant_slug="tenant-a")
resp = await client.get("/api/v1/contacts")
assert resp.status_code == 200
data = resp.json()
names = {item["name"] for item in data.get("items", [])}
assert "Company Gamma Tenant" in names, (
f"Tenant-owned contact should be visible to all: {names}"
)
@pytest.mark.asyncio
async def test_entity_permission_expiration(client: AsyncClient, db_session: AsyncSession):
"""Entity permissions with expires_at in the past should not grant access."""
from datetime import datetime, UTC, timedelta
seed = await seed_full_data(db_session)
# Create an expired entity permission
expired_ep = EntityPermission(
tenant_id=seed["tenant_a"].id,
entity_type="contact",
entity_id=seed["contact_a1"].id,
principal_type="user",
principal_id=seed["viewer_a"].id,
permission_level="read",
expires_at=datetime.now(UTC) - timedelta(hours=1), # Expired 1 hour ago
created_by=seed["admin_a"].id,
)
db_session.add(expired_ep)
await db_session.commit()
# Login as viewer_a
await login(client, "viewer@tenanta.com", tenant_slug="tenant-a")
# Viewer should NOT see contact_a1 (expired permission)
resp = await client.get("/api/v1/contacts")
assert resp.status_code == 200
data = resp.json()
names = {item["name"] for item in data.get("items", [])}
assert "Company Alpha" not in names, (
f"Expired permission should NOT grant access: {names}"
)
@pytest.mark.asyncio
async def test_permission_invalidation_on_role_change(client: AsyncClient, db_session: AsyncSession):
"""Permission cache is invalidated when role changes."""
seed = await seed_full_data(db_session)
# Login as sales user — has read but not delete
await login(client, "sales@tenanta.com", tenant_slug="tenant-a")
resp = await client.delete(f"/api/v1/contacts/{seed['contact_a3'].id}")
assert resp.status_code == 403
# Update role to allow delete
role = seed["custom_role"]
role.permissions = {"contacts": {"read": True, "write": True, "delete": True}}
role.permission_version += 1
await db_session.commit()
# Logout and login again (fresh session, fresh permission resolution)
await logout(client)
await login(client, "sales@tenanta.com", tenant_slug="tenant-a")
# Now sales user should be able to delete
resp = await client.delete(f"/api/v1/contacts/{seed['contact_a3'].id}")
assert resp.status_code in (200, 204), (
f"After role update, delete should work: {resp.status_code} {resp.text}"
)
@pytest.mark.asyncio
async def test_group_based_permission(client: AsyncClient, db_session: AsyncSession):
"""Permissions granted via group membership work correctly."""
seed = await seed_full_data(db_session)
# Create a group in tenant A
group = Group(
tenant_id=seed["tenant_a"].id,
name="Sales Team",
permissions={"contacts": {"read": True, "write": True}},
denied_permissions=[],
field_permissions={},
)
db_session.add(group)
await db_session.flush()
# Create a new user and add to group
group_user = User(
email="groupuser@tenanta.com",
name="Group User",
password_hash=hash_password("TestPass123!"),
is_active=True,
preferences={},
)
db_session.add(group_user)
await db_session.flush()
ut = UserTenant(
user_id=group_user.id, tenant_id=seed["tenant_a"].id,
is_default=True, role="viewer",
)
db_session.add(ut)
ug = UserGroup(
user_id=group_user.id, group_id=group.id,
tenant_id=seed["tenant_a"].id,
)
db_session.add(ug)
await db_session.commit()
# Login as group user
await login(client, "groupuser@tenanta.com", tenant_slug="tenant-a")
# Should be able to read contacts (via group permission)
resp = await client.get("/api/v1/contacts")
assert resp.status_code == 200, f"Group user should read contacts: {resp.text}"
@pytest.mark.asyncio
async def test_field_level_permission_hidden(client: AsyncClient, db_session: AsyncSession):
"""Field-level permission: hidden field is not returned in API response."""
seed = await seed_full_data(db_session)
# Create a contact with annual_revenue field
contact = Contact(
tenant_id=seed["tenant_a"].id,
type="company",
name="Revenue Test Co",
displayname="Revenue Test Co",
created_by=seed["admin_a"].id,
updated_by=seed["admin_a"].id,
owner_id=seed["admin_a"].id,
)
db_session.add(contact)
await db_session.commit()
# Login as sales user (has annual_revenue hidden via field_permissions)
await login(client, "sales@tenanta.com", tenant_slug="tenant-a")
# Sales user should see the contact but annual_revenue should be hidden/filtered
resp = await client.get("/api/v1/contacts")
assert resp.status_code == 200
data = resp.json()
items = data.get("items", [])
revenue_contact = [c for c in items if c.get("name") == "Revenue Test Co"]
if revenue_contact:
# If the contact is visible, annual_revenue should be None or not present
# (field permission hides it)
# Field-level permissions are checked at the service layer;
# the custom role has field_permissions={"annual_revenue": "hidden"}
# but Contact has no annual_revenue field, so we just verify the contact is visible
assert revenue_contact[0].get("name") == "Revenue Test Co", (
f"Contact should be visible to sales rep: {revenue_contact[0]}"
)
@pytest.mark.asyncio
async def test_membership_suspended_blocks_access(client: AsyncClient, db_session: AsyncSession):
"""User with suspended membership cannot access tenant data."""
seed = await seed_full_data(db_session)
# Suspend viewer_a's membership
ut = await db_session.execute(
text("UPDATE user_tenants SET status = 'disabled' WHERE user_id = :uid AND tenant_id = :tid"),
{"uid": str(seed["viewer_a"].id), "tid": str(seed["tenant_a"].id)},
)
await db_session.commit()
# Login as viewer_a — should fail or get 403
resp = await client.post(
"/api/v1/auth/login",
json={"email": "viewer@tenanta.com", "password": "TestPass123!", "tenant_slug": "tenant-a"},
headers=ORIGIN_HEADER,
)
# Login should fail because membership is not active
assert resp.status_code == 401, (
f"Suspended membership should block login: {resp.status_code} {resp.text}"
)
@pytest.mark.asyncio
async def test_concurrent_sessions_different_tenants(client: AsyncClient, db_session: AsyncSession):
"""User with multiple tenants can switch between them."""
seed = await seed_full_data(db_session)
# Login as admin_a in tenant A
await login(client, "admin@tenanta.com", tenant_slug="tenant-a")
resp = await client.get("/api/v1/contacts")
assert resp.status_code == 200
data_a = resp.json()
names_a = {item["name"] for item in data_a.get("items", [])}
assert "Company Alpha" in names_a, f"Tenant A should have Alpha: {names_a}"
# Switch to tenant B
resp = await client.post(
"/api/v1/auth/switch-tenant",
json={"tenant_id": str(seed["tenant_b"].id)},
)
if resp.status_code == 200:
# After switch, should see tenant B data
resp = await client.get("/api/v1/contacts")
assert resp.status_code == 200
data_b = resp.json()
names_b = {item["name"] for item in data_b.get("items", [])}
assert "Company Delta" in names_b, f"After switch should see tenant B data: {names_b}"
assert "Company Alpha" not in names_b, f"After switch should NOT see tenant A data: {names_b}"
else:
# Switch-tenant might not be available — skip gracefully
pass