T01: core infrastructure + auth + multi-tenant + RLS

- 10 models: tenants, users, user_tenants, roles, sessions, audit_log, deletion_log, notifications, password_reset_tokens, api_tokens
- Session-based auth (Redis + PostgreSQL audit trail)
- Multi-tenant with ORM-level filtering + PostgreSQL RLS (set_config)
- RBAC with roles/permissions + field-level permissions
- CSRF protection via Origin header validation
- Auth rate limiting (Redis counters with TTL)
- CORS with explicit origins (no wildcard)
- Health endpoint (no auth required)
- Notification service + audit log middleware
- 29 tests, 26 ACs, all passing
- Coverage: 62% (infrastructure modules pending coverage in later tasks)
This commit is contained in:
leocrm-bot
2026-06-29 00:10:10 +02:00
parent 3cc0b2e1b4
commit 7a7daf8100
137 changed files with 3866 additions and 10195 deletions
+218 -270
View File
@@ -1,19 +1,22 @@
"""Test fixtures: in-memory SQLite DB, async test client, auth helpers.
"""Test fixtures: PostgreSQL test DB, Redis, async test client, auth helpers.
Design:
- One AsyncEngine + session_factory per test (function-scoped) so each test
has a fresh, isolated DB.
- The FastAPI app's get_db dependency is overridden to use the same engine.
- register/headers/login helpers talk to the HTTP API (integration tests).
Each test gets a fresh database schema (created from metadata) and a clean Redis.
Auth helpers talk to the HTTP API (integration tests).
"""
from __future__ import annotations
import asyncio
import json
import uuid
from collections.abc import AsyncGenerator
from typing import Any
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,
@@ -21,301 +24,246 @@ from sqlalchemy.ext.asyncio import (
create_async_engine,
)
from app.core.db import Base, get_db
from app.config import get_settings
from app.core.db import Base, reset_engine_for_testing, close_engine
from app.core.auth import hash_password
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
from app.models.role import Role
from app.models.company import Company
from app.main import create_app
TEST_DB_URL = "postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm_test"
def _get_sync_engine():
"""Create a sync engine for DDL operations (drop/create schema).
Uses postgres superuser because leocrm user doesn't own the public schema.
"""
from sqlalchemy import create_engine
return create_engine(
"postgresql+psycopg2://postgres@localhost:5432/leocrm_test",
echo=False,
)
@pytest.fixture(scope="session", autouse=True)
def db_setup():
"""Drop and recreate all tables once per test session."""
sync_eng = _get_sync_engine()
with sync_eng.connect() as conn:
# Drop all tables and types
conn.execute(text("DROP SCHEMA public CASCADE;"))
conn.execute(text("CREATE SCHEMA public;"))
conn.execute(text("GRANT ALL ON SCHEMA public TO leocrm;"))
conn.commit()
sync_eng.dispose()
# Create tables using async engine
async def _create():
eng = create_async_engine(TEST_DB_URL, echo=False)
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await eng.dispose()
asyncio.get_event_loop().run_until_complete(_create())
yield
# Cleanup after session
sync_eng = _get_sync_engine()
with sync_eng.connect() as conn:
conn.execute(text("DROP SCHEMA public CASCADE;"))
conn.execute(text("CREATE SCHEMA public;"))
conn.execute(text("GRANT ALL ON SCHEMA public TO leocrm;"))
conn.commit()
sync_eng.dispose()
@pytest.fixture(autouse=True)
def clean_tables(db_setup):
"""Clean all table data before each test (preserve schema)."""
sync_eng = _get_sync_engine()
with sync_eng.connect() as conn:
# TRUNCATE all tables with CASCADE — fast and reliable isolation
conn.execute(text("TRUNCATE TABLE api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;"))
conn.commit()
sync_eng.dispose()
yield
@pytest_asyncio.fixture
async def redis_client() -> AsyncGenerator[aioredis.Redis, None]:
"""Redis client for tests — flushes DB before and after."""
r = aioredis.from_url("redis://localhost:6379/0", decode_responses=True)
await r.flushdb()
yield r
await r.flushdb()
await r.aclose()
@pytest_asyncio.fixture
async def engine() -> AsyncGenerator[AsyncEngine, None]:
"""Per-test in-memory SQLite engine with schema created from metadata."""
eng = create_async_engine(
"sqlite+aiosqlite:///:memory:",
echo=False,
future=True,
)
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
"""Async engine for the test database."""
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]:
async def session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
"""Session factory bound to the test engine."""
return async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
@pytest_asyncio.fixture
async def client(
session_factory: async_sessionmaker[AsyncSession],
) -> AsyncGenerator[AsyncClient, None]:
"""httpx.AsyncClient wired to a fresh FastAPI app with the test DB injected."""
async def db_session(session_factory: async_sessionmaker[AsyncSession]) -> AsyncGenerator[AsyncSession, None]:
"""Database session for direct DB operations in tests."""
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()
async def _override_get_db() -> AsyncGenerator[AsyncSession, None]:
async with session_factory() as session:
try:
yield session
except Exception:
await session.rollback()
raise
app.dependency_overrides[get_db] = _override_get_db
@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
# === Convenience: register via API (covers bootstrap, user, token, headers) ===
# ─── Seed Data Helpers ───
ORIGIN_HEADER = {"Origin": "http://localhost:5173"}
@pytest_asyncio.fixture
async def registered_user(
client: AsyncClient,
) -> dict[str, Any]:
"""Register a bootstrap user via the API.
Returns a dict with user, token, headers, email, password, name.
Use this fixture (or the dependent `auth_headers`) for all auth-required tests.
async def seed_tenant_and_users(db: AsyncSession) -> dict[str, Any]:
"""Seed two tenants with admin, editor, viewer users.
Returns dict with all created entity IDs.
"""
payload = {
"email": "admin@test.com",
"password": "TestPass123!",
"name": "Test Admin",
}
resp = await client.post("/api/v1/auth/register", json=payload)
assert resp.status_code == 201, f"register failed: {resp.status_code} {resp.text}"
data = resp.json()
token = data["access_token"]
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()
# Admin in tenant A
admin_a = User(
tenant_id=tenant_a.id,
email="admin@tenanta.com",
name="Admin A",
password_hash=hash_password("TestPass123!"),
role="admin",
is_active=True,
preferences={},
)
# Viewer in tenant A
viewer_a = User(
tenant_id=tenant_a.id,
email="viewer@tenanta.com",
name="Viewer A",
password_hash=hash_password("TestPass123!"),
role="viewer",
is_active=True,
preferences={},
)
# Editor in tenant A
editor_a = User(
tenant_id=tenant_a.id,
email="editor@tenanta.com",
name="Editor A",
password_hash=hash_password("TestPass123!"),
role="editor",
is_active=True,
preferences={},
)
# Admin in tenant B
admin_b = User(
tenant_id=tenant_b.id,
email="admin@tenantb.com",
name="Admin B",
password_hash=hash_password("TestPass123!"),
role="admin",
is_active=True,
preferences={},
)
db.add_all([admin_a, viewer_a, editor_a, admin_b])
await db.flush()
# User-tenant memberships
ut1 = UserTenant(user_id=admin_a.id, tenant_id=tenant_a.id, is_default=True)
ut2 = UserTenant(user_id=viewer_a.id, tenant_id=tenant_a.id, is_default=True)
ut3 = UserTenant(user_id=editor_a.id, tenant_id=tenant_a.id, is_default=True)
ut4 = UserTenant(user_id=admin_b.id, tenant_id=tenant_b.id, is_default=True)
# Admin A is also member of tenant B (for switch-tenant test)
ut5 = UserTenant(user_id=admin_a.id, tenant_id=tenant_b.id, is_default=False)
db.add_all([ut1, ut2, ut3, ut4, ut5])
await db.flush()
# Create a custom role with field permissions in tenant A
custom_role = Role(
tenant_id=tenant_a.id,
name="sales_rep",
permissions={"companies": {"read": True, "create": True, "update": True, "delete": False}},
field_permissions={"annual_revenue": "hidden"},
)
db.add(custom_role)
await db.flush()
# Create a company in tenant A
company_a = Company(
tenant_id=tenant_a.id,
name="Company Alpha",
industry="IT",
created_by=admin_a.id,
updated_by=admin_a.id,
)
# Create a company in tenant B
company_b = Company(
tenant_id=tenant_b.id,
name="Company Beta",
industry="Finance",
created_by=admin_b.id,
updated_by=admin_b.id,
)
db.add_all([company_a, company_b])
await db.flush()
await db.commit()
return {
"user": data["user"],
"token": token,
"headers": {"Authorization": f"Bearer {token}"},
"email": payload["email"],
"password": payload["password"],
"name": payload["name"],
"tenant_a": tenant_a,
"tenant_b": tenant_b,
"admin_a": admin_a,
"viewer_a": viewer_a,
"editor_a": editor_a,
"admin_b": admin_b,
"company_a": company_a,
"company_b": company_b,
"custom_role": custom_role,
}
@pytest_asyncio.fixture
async def auth_headers(registered_user: dict[str, Any]) -> dict[str, str]:
"""Authorization headers for the bootstrap user."""
return registered_user["headers"]
@pytest_asyncio.fixture
async def second_user(
client: AsyncClient,
registered_user: dict[str, Any],
) -> dict[str, Any]:
"""Register a second user (admin-only flow) and return its auth info."""
# Use admin headers to create another user
admin_headers = registered_user["headers"]
payload = {
"email": "rep@test.com",
"password": "RepPass123!",
"name": "Test Rep",
"role": "sales_rep",
}
resp = await client.post("/api/v1/users/", json=payload, headers=admin_headers)
assert resp.status_code == 201, f"create user failed: {resp.status_code} {resp.text}"
new_user = resp.json()
# Log in as the new user to get a token
login_resp = await client.post(
async def login_client(client: AsyncClient, email: str, password: str = "TestPass123!") -> dict[str, str]:
"""Login via HTTP API and return cookies dict."""
resp = await client.post(
"/api/v1/auth/login",
data={"username": payload["email"], "password": payload["password"]},
json={"email": email, "password": password},
headers=ORIGIN_HEADER,
)
assert login_resp.status_code == 200, (
f"login failed: {login_resp.status_code} {login_resp.text}"
)
token = login_resp.json()["access_token"]
return {
"user": new_user,
"token": token,
"headers": {"Authorization": f"Bearer {token}"},
"email": payload["email"],
"password": payload["password"],
"name": payload["name"],
}
assert resp.status_code == 200, f"Login failed: {resp.status_code} {resp.text}"
return dict(resp.cookies)
@pytest_asyncio.fixture
async def seed_data(
client: AsyncClient,
registered_user: dict[str, Any],
session_factory: async_sessionmaker[AsyncSession],
) -> dict[str, Any]:
"""Seed a representative data set for business-logic tests.
Creates (via the API to exercise FK constraints end-to-end):
- 2 accounts owned by the bootstrap admin user
- 3 contacts (2 attached to accounts, 1 standalone)
- 5 deals across various stages
- 10 activities (some overdue, some completed)
- 3 tags (VIP, Strategic, Enterprise)
- 4 notes (mixed parents)
Returns a dict with all IDs + a ref to the auth headers for convenience.
"""
headers = registered_user["headers"]
# 2 accounts
acc1 = await client.post(
"/api/v1/accounts/",
json={
"name": "Acme Corp",
"industry": "sme",
"size": "sme",
"website": "https://acme.test",
},
headers=headers,
)
assert acc1.status_code == 201, f"seed acc1: {acc1.status_code} {acc1.text}"
acc1_id = acc1.json()["id"]
acc2 = await client.post(
"/api/v1/accounts/",
json={"name": "Globex GmbH", "industry": "enterprise", "size": "enterprise"},
headers=headers,
)
assert acc2.status_code == 201, f"seed acc2: {acc2.status_code} {acc2.text}"
acc2_id = acc2.json()["id"]
# 3 contacts (2 with account, 1 standalone)
c1 = await client.post(
"/api/v1/contacts/",
json={
"first_name": "Anna",
"last_name": "Schmidt",
"email": "anna@acme.example",
"account_id": acc1_id,
},
headers=headers,
)
assert c1.status_code == 201, c1.text
c1_id = c1.json()["id"]
c2 = await client.post(
"/api/v1/contacts/",
json={
"first_name": "Bob",
"last_name": "Mueller",
"email": "bob@globex.example",
"account_id": acc2_id,
},
headers=headers,
)
assert c2.status_code == 201, c2.text
c2_id = c2.json()["id"]
c3 = await client.post(
"/api/v1/contacts/",
json={"first_name": "Clara", "last_name": "Weber", "email": "clara@x.example"},
headers=headers,
)
assert c3.status_code == 201, c3.text
c3_id = c3.json()["id"]
# 5 deals (different stages)
deal_ids: list[int] = []
for i, (title, stage) in enumerate(
[
("Deal A", "lead"),
("Deal B", "qualified"),
("Deal C", "proposal"),
("Deal D", "negotiation"),
("Deal E", "won"),
]
):
d = await client.post(
"/api/v1/deals/",
json={
"title": title,
"value": str(1000 * (i + 1)),
"stage": stage,
"account_id": acc1_id if i % 2 == 0 else acc2_id,
},
headers=headers,
)
assert d.status_code == 201, d.text
deal_ids.append(d.json()["id"])
# 10 activities (4 overdue, 4 future, 2 completed)
from datetime import UTC, datetime, timedelta
past = (datetime.now(UTC) - timedelta(days=2)).isoformat()
future = (datetime.now(UTC) + timedelta(days=2)).isoformat()
future2 = (datetime.now(UTC) + timedelta(days=10)).isoformat()
activity_ids: list[int] = []
for i in range(10):
if i < 4:
due = past
body_data: dict[str, object] = {
"type": "task",
"subject": f"Overdue task {i}",
"due_date": due,
"deal_id": deal_ids[i % 5],
}
elif i < 8:
due = future if i % 2 == 0 else future2
body_data = {
"type": "call",
"subject": f"Upcoming call {i}",
"due_date": due,
"account_id": acc1_id,
}
else:
body_data = {"type": "meeting", "subject": f"Done meeting {i}", "account_id": acc1_id}
a = await client.post("/api/v1/activities/", json=body_data, headers=headers)
assert a.status_code == 201, a.text
activity_ids.append(a.json()["id"])
# 3 tags
tag_ids: list[int] = []
for name in ["VIP", "Strategic", "Enterprise"]:
t = await client.post(
"/api/v1/tags/", json={"name": name, "color": "#FF0080"}, headers=headers
)
assert t.status_code == 201, t.text
tag_ids.append(t.json()["id"])
# 4 notes (mixed parents: 2 account, 1 contact, 1 deal)
n1 = await client.post(
"/api/v1/notes/",
json={"body": "Note on Acme", "parent_type": "account", "parent_id": acc1_id},
headers=headers,
)
assert n1.status_code == 201, n1.text
n2 = await client.post(
"/api/v1/notes/",
json={"body": "Note on Globex", "parent_type": "account", "parent_id": acc2_id},
headers=headers,
)
assert n2.status_code == 201, n2.text
n3 = await client.post(
"/api/v1/notes/",
json={"body": "Note on Anna", "parent_type": "contact", "parent_id": c1_id},
headers=headers,
)
assert n3.status_code == 201, n3.text
n4 = await client.post(
"/api/v1/notes/",
json={"body": "Note on Deal A", "parent_type": "deal", "parent_id": deal_ids[0]},
headers=headers,
)
assert n4.status_code == 201, n4.text
return {
"account_ids": [acc1_id, acc2_id],
"contact_ids": [c1_id, c2_id, c3_id],
"deal_ids": deal_ids,
"activity_ids": activity_ids,
"tag_ids": tag_ids,
"note_ids": [n1.json()["id"], n2.json()["id"], n3.json()["id"], n4.json()["id"]],
"headers": headers,
}
async def get_auth_client(client: AsyncClient, email: str, password: str = "TestPass123!") -> AsyncClient:
"""Return a client that's logged in."""
await login_client(client, email, password)
return client