Files
crm-system/tests/test_tenant.py
T
leocrm-bot 6bf0746b94 T02: companies + contacts + import/export + N:M + soft-delete + GDPR + FTS
- Company CRUD with soft-delete, FTS search (tsvector + GIN), filter, pagination
- Contact CRUD with N:M company linking via company_contacts
- CSV/XLSX export, CSV import with dry-run preview
- GDPR hard-delete with deletion_log
- Audit log on all mutations
- 27 new tests (24 ACs), 56 total tests pass
- Migration 0002: contacts, company_contacts, FTS search_tsv
- Fixed T01 tests: POST→201, PATCH→PUT compatibility
2026-06-29 00:44:34 +02:00

282 lines
11 KiB
Python

"""Tenant + RBAC + User/Role management tests — ACs 10-17, 21-22."""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client
from app.models.audit import AuditLog
from app.models.notification import Notification
from app.models.company import Company
@pytest.mark.asyncio
class TestUserManagement:
"""ACs 10-14: User CRUD as admin/viewer."""
async def test_list_users_as_admin_returns_200(self, client: AsyncClient, db_session):
"""AC 10: GET /api/v1/users as admin -> 200 + paginated list."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.get("/api/v1/users")
assert resp.status_code == 200
data = resp.json()
assert "items" in data
assert "total" in data
assert "page" in data
assert "page_size" in data
assert data["total"] >= 3 # admin, viewer, editor
async def test_list_users_as_viewer_returns_403(self, client: AsyncClient, db_session):
"""AC 11: GET /api/v1/users as viewer -> 403."""
await seed_tenant_and_users(db_session)
await login_client(client, "viewer@tenanta.com")
resp = await client.get("/api/v1/users")
assert resp.status_code == 403
async def test_create_user_valid_data_returns_201(self, client: AsyncClient, db_session):
"""AC 12: POST /api/v1/users valid data -> 201."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.post(
"/api/v1/users",
json={
"email": "newuser@example.com",
"name": "New User",
"password": "NewPass123!",
"role": "viewer",
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
assert resp.json()["email"] == "newuser@example.com"
async def test_update_user_returns_200(self, client: AsyncClient, db_session):
"""AC 13: PATCH /api/v1/users/{id} -> 200."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
# Get viewer user ID
from app.models.user import User
q = select(User).where(User.email == "viewer@tenanta.com")
result = await db_session.execute(q)
viewer = result.scalar_one()
resp = await client.patch(
f"/api/v1/users/{viewer.id}",
json={"name": "Updated Viewer"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["name"] == "Updated Viewer"
async def test_delete_user_returns_204(self, client: AsyncClient, db_session):
"""AC 14: DELETE /api/v1/users/{id} -> 204."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
from app.models.user import User
q = select(User).where(User.email == "viewer@tenanta.com")
result = await db_session.execute(q)
viewer = result.scalar_one()
resp = await client.delete(
f"/api/v1/users/{viewer.id}",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 204
@pytest.mark.asyncio
class TestRoleManagement:
"""ACs 15-16: Role list and create."""
async def test_list_roles_returns_200(self, client: AsyncClient, db_session):
"""AC 15: GET /api/v1/roles -> 200 + list with permissions."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.get("/api/v1/roles")
assert resp.status_code == 200
data = resp.json()
assert "items" in data
assert len(data["items"]) >= 1
assert "permissions" in data["items"][0]
assert "field_permissions" in data["items"][0]
async def test_create_role_with_custom_permissions_returns_201(self, client: AsyncClient, db_session):
"""AC 16: POST /api/v1/roles with custom permissions -> 201."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.post(
"/api/v1/roles",
json={
"name": "manager",
"permissions": {"companies": {"read": True, "create": True, "update": True, "delete": True}},
"field_permissions": {"annual_revenue": "read"},
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
assert resp.json()["name"] == "manager"
assert "companies" in resp.json()["permissions"]
@pytest.mark.asyncio
class TestCrossTenant:
"""AC 17: Cross-tenant access on company -> 404 (not 403)."""
async def test_cross_tenant_company_access_returns_404(self, client: AsyncClient, db_session):
"""AC 17: Cross-tenant access on company -> 404 (not 403)."""
seed = await seed_tenant_and_users(db_session)
# Login as admin A (tenant A)
await login_client(client, "admin@tenanta.com")
# Try to access company B (tenant B)
company_b_id = str(seed["company_b"].id)
resp = await client.get(f"/api/v1/companies/{company_b_id}")
assert resp.status_code == 404
assert resp.status_code != 403
@pytest.mark.asyncio
class TestRBAC:
"""AC 21: RBAC — viewer can read company but not create (POST -> 403)."""
async def test_viewer_can_read_company(self, client: AsyncClient, db_session):
"""AC 21a: Viewer can GET /api/v1/companies."""
await seed_tenant_and_users(db_session)
await login_client(client, "viewer@tenanta.com")
resp = await client.get("/api/v1/companies")
assert resp.status_code == 200
async def test_viewer_cannot_create_company(self, client: AsyncClient, db_session):
"""AC 21b: Viewer cannot POST /api/v1/companies -> 403."""
await seed_tenant_and_users(db_session)
await login_client(client, "viewer@tenanta.com")
resp = await client.post(
"/api/v1/companies",
json={"name": "Test Company"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 403
@pytest.mark.asyncio
class TestFieldPermissions:
"""AC 22: Field-level permissions — hidden field not in response."""
async def test_hidden_field_not_in_response(self, client: AsyncClient, db_session):
"""AC 22: Field-level permissions — hidden field not in response.
The sales_rep role has annual_revenue as hidden.
We need a user with that role.
"""
seed = await seed_tenant_and_users(db_session)
# Create a user with sales_rep role
from app.core.auth import hash_password
from app.models.user import User, UserTenant
sales_user = User(
tenant_id=seed["tenant_a"].id,
email="sales@tenanta.com",
name="Sales Rep",
password_hash=hash_password("TestPass123!"),
role="sales_rep",
is_active=True,
preferences={},
)
db_session.add(sales_user)
await db_session.flush()
ut = UserTenant(user_id=sales_user.id, tenant_id=seed["tenant_a"].id, is_default=True)
db_session.add(ut)
await db_session.commit()
await login_client(client, "sales@tenanta.com")
# Get company A
resp = await client.get(f"/api/v1/companies/{seed['company_a'].id}")
assert resp.status_code == 200
data = resp.json()
# annual_revenue should be filtered out for sales_rep role
assert "annual_revenue" not in data
@pytest.mark.asyncio
class TestAuditLog:
"""AC 19: Audit log entry created on company.create/update/delete."""
async def test_audit_log_on_company_create(self, client: AsyncClient, db_session):
"""AC 19: Audit log entry created on company.create."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.post(
"/api/v1/companies",
json={"name": "Audit Test Co"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
# Check audit_log table
from sqlalchemy import select as sel
q = sel(AuditLog).where(AuditLog.entity_type == "company", AuditLog.action == "create")
result = await db_session.execute(q)
entries = result.scalars().all()
assert len(entries) >= 1
async def test_audit_log_on_company_update(self, client: AsyncClient, db_session):
"""AC 19: Audit log entry created on company.update."""
seed = await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.put(
f"/api/v1/companies/{seed['company_a'].id}",
json={"name": "Updated Company Alpha"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
from sqlalchemy import select as sel
q = sel(AuditLog).where(AuditLog.entity_type == "company", AuditLog.action == "update")
result = await db_session.execute(q)
entries = result.scalars().all()
assert len(entries) >= 1
async def test_audit_log_on_company_delete(self, client: AsyncClient, db_session):
"""AC 19: Audit log entry created on company.delete."""
seed = await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.delete(
f"/api/v1/companies/{seed['company_a'].id}",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 204
from sqlalchemy import select as sel
q = sel(AuditLog).where(AuditLog.entity_type == "company", AuditLog.action == "delete")
result = await db_session.execute(q)
entries = result.scalars().all()
assert len(entries) >= 1
@pytest.mark.asyncio
class TestNotificationOnAssign:
"""AC 20: Notification created on user assign."""
async def test_notification_created_on_user_create(self, client: AsyncClient, db_session):
"""AC 20: Notification created on user assign (via user creation)."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.post(
"/api/v1/users",
json={
"email": "assigned@example.com",
"name": "Assigned User",
"password": "NewPass123!",
"role": "viewer",
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
new_user_id = resp.json()["id"]
# Check notification was created for the new user
from sqlalchemy import select as sel
q = sel(Notification).where(Notification.user_id == new_user_id)
result = await db_session.execute(q)
notifs = result.scalars().all()
assert len(notifs) >= 1