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:
+218
-270
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user