Files
konstruct/tests/unit/test_rbac_guards.py
Adolfo Delorenzo 7b0594e7cc 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
2026-03-24 13:55:55 -06:00

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()