"""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 == 200 # 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.patch( 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