test(04-rbac-01): unit tests for RBAC guards, invitation system, portal auth
- 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
This commit is contained in:
188
tests/unit/test_rbac_guards.py
Normal file
188
tests/unit/test_rbac_guards.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user