""" Unit tests for the updated auth/verify endpoint. Tests verify that the response shape matches the new RBAC contract: - Returns `role` (not `is_admin`) - Returns `tenant_ids` as a list of UUID strings - Returns `active_tenant_id` as the first tenant ID (or None) - platform_admin returns all tenant IDs from the tenants table - customer_admin returns only their UserTenantRole tenant IDs """ from __future__ import annotations import uuid from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch import bcrypt import pytest from shared.api.portal import AuthVerifyRequest, AuthVerifyResponse, verify_credentials from shared.models.auth import PortalUser, UserTenantRole from shared.models.tenant import Tenant def _make_user(role: str, email: str = "test@example.com") -> PortalUser: user = MagicMock(spec=PortalUser) user.id = uuid.uuid4() user.email = email user.name = "Test User" user.role = role user.language = "en" # Real bcrypt hash for password "testpassword" user.hashed_password = bcrypt.hashpw(b"testpassword", bcrypt.gensalt()).decode() user.created_at = datetime.now(tz=timezone.utc) return user def _make_tenant_role(user_id: uuid.UUID, tenant_id: uuid.UUID, role: str) -> UserTenantRole: m = MagicMock(spec=UserTenantRole) m.user_id = user_id m.tenant_id = tenant_id m.role = role return m def _make_tenant(name: str = "Acme") -> Tenant: t = MagicMock(spec=Tenant) t.id = uuid.uuid4() t.name = name return t # --------------------------------------------------------------------------- # test_auth_verify_returns_role # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_auth_verify_returns_role() -> None: """ auth/verify response contains 'role' field (not 'is_admin'). """ user = _make_user("customer_admin") session = AsyncMock() call_count = 0 def execute_side_effect(stmt): nonlocal call_count call_count += 1 result = MagicMock() if call_count == 1: # User lookup result.scalar_one_or_none = MagicMock(return_value=user) else: # UserTenantRole lookup — empty result.scalars = MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))) return result session.execute = AsyncMock(side_effect=execute_side_effect) body = AuthVerifyRequest(email=user.email, password="testpassword") response = await verify_credentials(body=body, session=session) assert isinstance(response, AuthVerifyResponse) assert response.role == "customer_admin" # Ensure is_admin is NOT in the response model fields assert not hasattr(response, "is_admin") or not isinstance(getattr(response, "is_admin", None), bool) # --------------------------------------------------------------------------- # test_auth_verify_returns_tenant_ids # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_auth_verify_returns_tenant_ids() -> None: """ auth/verify response contains tenant_ids as a list of UUID strings. """ user = _make_user("customer_admin") tenant_id_1 = uuid.uuid4() tenant_id_2 = uuid.uuid4() memberships = [ _make_tenant_role(user.id, tenant_id_1, "customer_admin"), _make_tenant_role(user.id, tenant_id_2, "customer_admin"), ] session = AsyncMock() call_count = 0 def execute_side_effect(stmt): nonlocal call_count call_count += 1 result = MagicMock() if call_count == 1: result.scalar_one_or_none = MagicMock(return_value=user) else: result.scalars = MagicMock(return_value=MagicMock(all=MagicMock(return_value=memberships))) return result session.execute = AsyncMock(side_effect=execute_side_effect) body = AuthVerifyRequest(email=user.email, password="testpassword") response = await verify_credentials(body=body, session=session) assert isinstance(response.tenant_ids, list) assert len(response.tenant_ids) == 2 assert str(tenant_id_1) in response.tenant_ids assert str(tenant_id_2) in response.tenant_ids # --------------------------------------------------------------------------- # test_auth_verify_returns_active_tenant # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_auth_verify_returns_active_tenant_first() -> None: """ auth/verify response contains active_tenant_id as the first tenant ID. """ user = _make_user("customer_admin") tenant_id_1 = uuid.uuid4() memberships = [ _make_tenant_role(user.id, tenant_id_1, "customer_admin"), ] session = AsyncMock() call_count = 0 def execute_side_effect(stmt): nonlocal call_count call_count += 1 result = MagicMock() if call_count == 1: result.scalar_one_or_none = MagicMock(return_value=user) else: result.scalars = MagicMock(return_value=MagicMock(all=MagicMock(return_value=memberships))) return result session.execute = AsyncMock(side_effect=execute_side_effect) body = AuthVerifyRequest(email=user.email, password="testpassword") response = await verify_credentials(body=body, session=session) assert response.active_tenant_id == str(tenant_id_1) @pytest.mark.asyncio async def test_auth_verify_active_tenant_none_for_no_memberships() -> None: """ auth/verify response contains active_tenant_id=None for users with no tenant memberships. """ user = _make_user("customer_admin") session = AsyncMock() call_count = 0 def execute_side_effect(stmt): nonlocal call_count call_count += 1 result = MagicMock() if call_count == 1: result.scalar_one_or_none = MagicMock(return_value=user) else: result.scalars = MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))) return result session.execute = AsyncMock(side_effect=execute_side_effect) body = AuthVerifyRequest(email=user.email, password="testpassword") response = await verify_credentials(body=body, session=session) assert response.active_tenant_id is None assert response.tenant_ids == [] # --------------------------------------------------------------------------- # test_auth_verify_platform_admin_returns_all_tenants # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_auth_verify_platform_admin_returns_all_tenants() -> None: """ platform_admin auth/verify returns all tenant IDs from the tenants table. """ user = _make_user("platform_admin") tenant_1 = _make_tenant("Acme") tenant_2 = _make_tenant("Globex") all_tenants = [tenant_1, tenant_2] session = AsyncMock() call_count = 0 def execute_side_effect(stmt): nonlocal call_count call_count += 1 result = MagicMock() if call_count == 1: # User lookup result.scalar_one_or_none = MagicMock(return_value=user) else: # All tenants query for platform_admin result.scalars = MagicMock(return_value=MagicMock(all=MagicMock(return_value=all_tenants))) return result session.execute = AsyncMock(side_effect=execute_side_effect) body = AuthVerifyRequest(email=user.email, password="testpassword") response = await verify_credentials(body=body, session=session) assert response.role == "platform_admin" assert len(response.tenant_ids) == 2 assert str(tenant_1.id) in response.tenant_ids assert str(tenant_2.id) in response.tenant_ids # --------------------------------------------------------------------------- # test_auth_verify_customer_admin_only_own_tenants # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_auth_verify_customer_admin_only_own_tenants() -> None: """ customer_admin auth/verify returns only their UserTenantRole tenant IDs. Not the full tenant list. """ user = _make_user("customer_admin") own_tenant_id = uuid.uuid4() other_tenant_id = uuid.uuid4() # Should NOT appear in response memberships = [_make_tenant_role(user.id, own_tenant_id, "customer_admin")] session = AsyncMock() call_count = 0 def execute_side_effect(stmt): nonlocal call_count call_count += 1 result = MagicMock() if call_count == 1: result.scalar_one_or_none = MagicMock(return_value=user) else: result.scalars = MagicMock(return_value=MagicMock(all=MagicMock(return_value=memberships))) return result session.execute = AsyncMock(side_effect=execute_side_effect) body = AuthVerifyRequest(email=user.email, password="testpassword") response = await verify_credentials(body=body, session=session) assert response.role == "customer_admin" assert len(response.tenant_ids) == 1 assert str(own_tenant_id) in response.tenant_ids assert str(other_tenant_id) not in response.tenant_ids