- test_rbac_guards.py: 11 tests covering platform_admin pass-through, customer_admin/operator 403 rejection, tenant membership checks, and platform_admin bypass for tenant-scoped guards - test_invitations.py: 11 tests covering HMAC token roundtrip, tamper/expiry rejection, invitation create/accept/resend/list - test_portal_auth.py: 7 tests covering role field (not is_admin), tenant_ids list, active_tenant_id, platform_admin all-tenants, customer_admin own-tenants-only - All 27 tests pass
189 lines
7.0 KiB
Python
189 lines
7.0 KiB
Python
"""
|
|
Unit tests for RBAC guard FastAPI dependencies.
|
|
|
|
Tests:
|
|
- test_platform_admin_passes: platform_admin caller passes require_platform_admin
|
|
- test_customer_admin_rejected: customer_admin gets 403 from require_platform_admin
|
|
- test_customer_operator_rejected: customer_operator gets 403 from require_platform_admin
|
|
- test_tenant_admin_own_tenant: customer_admin with membership passes require_tenant_admin
|
|
- test_tenant_admin_no_membership: customer_admin without membership gets 403
|
|
- test_platform_admin_bypasses_tenant_check: platform_admin passes require_tenant_admin
|
|
without a UserTenantRole row (no DB query for membership)
|
|
- test_operator_rejected_from_admin: customer_operator gets 403 from require_tenant_admin
|
|
- test_tenant_member_all_roles: customer_admin and customer_operator with membership pass
|
|
- test_tenant_member_no_membership: user with no membership gets 403
|
|
- test_platform_admin_bypasses_tenant_member: platform_admin passes require_tenant_member
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from shared.api.rbac import (
|
|
PortalCaller,
|
|
require_platform_admin,
|
|
require_tenant_admin,
|
|
require_tenant_member,
|
|
)
|
|
from shared.models.auth import UserTenantRole
|
|
|
|
|
|
def _make_caller(role: str, tenant_id: uuid.UUID | None = None) -> PortalCaller:
|
|
return PortalCaller(user_id=uuid.uuid4(), role=role, tenant_id=tenant_id)
|
|
|
|
|
|
def _make_membership(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 _mock_session_with_membership(membership: UserTenantRole | None) -> AsyncMock:
|
|
session = AsyncMock()
|
|
session.execute.return_value = MagicMock(
|
|
scalar_one_or_none=MagicMock(return_value=membership)
|
|
)
|
|
return session
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# require_platform_admin tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_platform_admin_passes() -> None:
|
|
"""platform_admin caller should pass require_platform_admin and return the caller."""
|
|
caller = _make_caller("platform_admin")
|
|
result = require_platform_admin(caller=caller)
|
|
assert result is caller
|
|
|
|
|
|
def test_customer_admin_rejected() -> None:
|
|
"""customer_admin should get 403 from require_platform_admin."""
|
|
caller = _make_caller("customer_admin")
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
require_platform_admin(caller=caller)
|
|
assert exc_info.value.status_code == 403
|
|
|
|
|
|
def test_customer_operator_rejected() -> None:
|
|
"""customer_operator should get 403 from require_platform_admin."""
|
|
caller = _make_caller("customer_operator")
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
require_platform_admin(caller=caller)
|
|
assert exc_info.value.status_code == 403
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# require_tenant_admin tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tenant_admin_own_tenant() -> None:
|
|
"""customer_admin with UserTenantRole membership passes require_tenant_admin."""
|
|
tenant_id = uuid.uuid4()
|
|
caller = _make_caller("customer_admin")
|
|
membership = _make_membership(caller.user_id, tenant_id, "customer_admin")
|
|
session = _mock_session_with_membership(membership)
|
|
|
|
result = await require_tenant_admin(tenant_id=tenant_id, caller=caller, session=session)
|
|
assert result is caller
|
|
session.execute.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tenant_admin_no_membership() -> None:
|
|
"""customer_admin without UserTenantRole row gets 403 from require_tenant_admin."""
|
|
tenant_id = uuid.uuid4()
|
|
caller = _make_caller("customer_admin")
|
|
session = _mock_session_with_membership(None)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await require_tenant_admin(tenant_id=tenant_id, caller=caller, session=session)
|
|
assert exc_info.value.status_code == 403
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_platform_admin_bypasses_tenant_check() -> None:
|
|
"""platform_admin passes require_tenant_admin without any DB membership query."""
|
|
tenant_id = uuid.uuid4()
|
|
caller = _make_caller("platform_admin")
|
|
session = AsyncMock() # Should NOT be called
|
|
|
|
result = await require_tenant_admin(tenant_id=tenant_id, caller=caller, session=session)
|
|
assert result is caller
|
|
session.execute.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_operator_rejected_from_admin() -> None:
|
|
"""customer_operator always gets 403 from require_tenant_admin (cannot be admin)."""
|
|
tenant_id = uuid.uuid4()
|
|
caller = _make_caller("customer_operator")
|
|
session = AsyncMock()
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await require_tenant_admin(tenant_id=tenant_id, caller=caller, session=session)
|
|
assert exc_info.value.status_code == 403
|
|
session.execute.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# require_tenant_member tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tenant_member_customer_admin() -> None:
|
|
"""customer_admin with membership passes require_tenant_member."""
|
|
tenant_id = uuid.uuid4()
|
|
caller = _make_caller("customer_admin")
|
|
membership = _make_membership(caller.user_id, tenant_id, "customer_admin")
|
|
session = _mock_session_with_membership(membership)
|
|
|
|
result = await require_tenant_member(tenant_id=tenant_id, caller=caller, session=session)
|
|
assert result is caller
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tenant_member_customer_operator() -> None:
|
|
"""customer_operator with membership passes require_tenant_member."""
|
|
tenant_id = uuid.uuid4()
|
|
caller = _make_caller("customer_operator")
|
|
membership = _make_membership(caller.user_id, tenant_id, "customer_operator")
|
|
session = _mock_session_with_membership(membership)
|
|
|
|
result = await require_tenant_member(tenant_id=tenant_id, caller=caller, session=session)
|
|
assert result is caller
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tenant_member_no_membership() -> None:
|
|
"""User with no UserTenantRole row gets 403 from require_tenant_member."""
|
|
tenant_id = uuid.uuid4()
|
|
caller = _make_caller("customer_admin")
|
|
session = _mock_session_with_membership(None)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await require_tenant_member(tenant_id=tenant_id, caller=caller, session=session)
|
|
assert exc_info.value.status_code == 403
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_platform_admin_bypasses_tenant_member() -> None:
|
|
"""platform_admin passes require_tenant_member without DB membership check."""
|
|
tenant_id = uuid.uuid4()
|
|
caller = _make_caller("platform_admin")
|
|
session = AsyncMock()
|
|
|
|
result = await require_tenant_member(tenant_id=tenant_id, caller=caller, session=session)
|
|
assert result is caller
|
|
session.execute.assert_not_called()
|