470 lines
17 KiB
Python
470 lines
17 KiB
Python
"""
|
|
Object-level authorization matrix tests (TRA-235).
|
|
|
|
Tests run without a real database — all role/org lookups are mocked
|
|
so the permission logic can be validated in isolation.
|
|
|
|
Naming convention:
|
|
test_<PermClass>__<scenario>__<expected_outcome>
|
|
|
|
Each test group covers:
|
|
- unauthenticated (must return False for has_permission)
|
|
- every role class
|
|
- owner vs non-owner (for object-level checks)
|
|
"""
|
|
|
|
import uuid
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from api.permissions import (
|
|
ROLE_ADMIN,
|
|
ROLE_AUTHOR,
|
|
ROLE_LEARNER,
|
|
ROLE_MANAGER,
|
|
ROLE_TRAINER,
|
|
IsAdmin,
|
|
IsAtLeastManager,
|
|
IsAtLeastTrainer,
|
|
IsAttemptOwner,
|
|
IsAuthor,
|
|
IsCertificateOwner,
|
|
IsCertificateVerifyPublic,
|
|
IsContentEditor,
|
|
IsEnrollmentOwner,
|
|
IsLearner,
|
|
IsManager,
|
|
IsOrgScopedObject,
|
|
IsOwnNotification,
|
|
IsOwnProgressOrPrivileged,
|
|
IsPrivileged,
|
|
IsReadOnlyOrPrivileged,
|
|
IsReportOrgScoped,
|
|
IsSessionTrainerOrPrivileged,
|
|
IsTrainer,
|
|
_user_roles,
|
|
)
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
USER_ID = str(uuid.uuid4())
|
|
OTHER_USER_ID = str(uuid.uuid4())
|
|
ORG_ID = str(uuid.uuid4())
|
|
OTHER_ORG_ID = str(uuid.uuid4())
|
|
|
|
|
|
def _make_user(roles: list[str], org_id: str = ORG_ID, pk: str = USER_ID, authenticated: bool = True):
|
|
"""Build a mock user with role_bindings that return the given slugs."""
|
|
user = MagicMock()
|
|
user.pk = pk
|
|
user.is_authenticated = authenticated
|
|
|
|
binding_qs = MagicMock()
|
|
binding_qs.select_related.return_value = binding_qs
|
|
|
|
# values_list for role slugs
|
|
def values_list_side_effect(*args, **kwargs):
|
|
flat = kwargs.get("flat", False)
|
|
if "role__slug" in args:
|
|
return roles if flat else [(r,) for r in roles]
|
|
if "org_id" in args:
|
|
return [org_id] if flat else [(org_id,)]
|
|
return []
|
|
|
|
binding_qs.values_list.side_effect = values_list_side_effect
|
|
user.role_bindings = binding_qs
|
|
return user
|
|
|
|
|
|
def _make_request(user, method: str = "GET"):
|
|
req = MagicMock()
|
|
req.user = user
|
|
req.method = method
|
|
return req
|
|
|
|
|
|
def _anon_request(method: str = "GET"):
|
|
user = _make_user([], authenticated=False)
|
|
return _make_request(user, method)
|
|
|
|
|
|
def _perm_and_obj_ok(perm_class, roles, method="GET", obj=None, org_id=ORG_ID):
|
|
user = _make_user(roles, org_id=org_id)
|
|
req = _make_request(user, method)
|
|
p = perm_class()
|
|
view = MagicMock()
|
|
ok_view = p.has_permission(req, view)
|
|
if obj is None or not ok_view:
|
|
return ok_view
|
|
return p.has_object_permission(req, view, obj)
|
|
|
|
|
|
def _obj_ok(perm_class, roles, obj, method="GET", org_id=ORG_ID, user_pk=USER_ID):
|
|
user = _make_user(roles, org_id=org_id, pk=user_pk)
|
|
req = _make_request(user, method)
|
|
p = perm_class()
|
|
view = MagicMock()
|
|
return p.has_object_permission(req, view, obj)
|
|
|
|
|
|
def _owned_obj(owner_pk: str = USER_ID, org_id: str = ORG_ID):
|
|
obj = MagicMock()
|
|
obj.user = MagicMock()
|
|
obj.user.pk = owner_pk
|
|
obj.org_id = org_id
|
|
return obj
|
|
|
|
|
|
# ── _user_roles helper ────────────────────────────────────────────────────────
|
|
|
|
class TestUserRolesHelper:
|
|
def test_returns_frozenset_of_role_slugs(self):
|
|
user = _make_user([ROLE_LEARNER, ROLE_TRAINER])
|
|
result = _user_roles(user)
|
|
assert result == frozenset({ROLE_LEARNER, ROLE_TRAINER})
|
|
|
|
def test_empty_when_no_bindings(self):
|
|
user = _make_user([])
|
|
assert _user_roles(user) == frozenset()
|
|
|
|
|
|
# ── Role-level view permissions ───────────────────────────────────────────────
|
|
|
|
class TestIsLearner:
|
|
def test_learner_passes(self):
|
|
assert _perm_and_obj_ok(IsLearner, [ROLE_LEARNER])
|
|
|
|
def test_trainer_fails(self):
|
|
assert not _perm_and_obj_ok(IsLearner, [ROLE_TRAINER])
|
|
|
|
def test_unauthenticated_fails(self):
|
|
req = _anon_request()
|
|
assert not IsLearner().has_permission(req, MagicMock())
|
|
|
|
|
|
class TestIsTrainer:
|
|
def test_trainer_passes(self):
|
|
assert _perm_and_obj_ok(IsTrainer, [ROLE_TRAINER])
|
|
|
|
def test_learner_fails(self):
|
|
assert not _perm_and_obj_ok(IsTrainer, [ROLE_LEARNER])
|
|
|
|
|
|
class TestIsAuthor:
|
|
def test_author_passes(self):
|
|
assert _perm_and_obj_ok(IsAuthor, [ROLE_AUTHOR])
|
|
|
|
def test_learner_fails(self):
|
|
assert not _perm_and_obj_ok(IsAuthor, [ROLE_LEARNER])
|
|
|
|
|
|
class TestIsManager:
|
|
def test_manager_passes(self):
|
|
assert _perm_and_obj_ok(IsManager, [ROLE_MANAGER])
|
|
|
|
def test_admin_fails_at_manager_level(self):
|
|
# Admin does NOT satisfy IsManager specifically (use IsAtLeastManager)
|
|
assert not _perm_and_obj_ok(IsManager, [ROLE_ADMIN])
|
|
|
|
|
|
class TestIsAdmin:
|
|
def test_admin_passes(self):
|
|
assert _perm_and_obj_ok(IsAdmin, [ROLE_ADMIN])
|
|
|
|
def test_manager_fails(self):
|
|
assert not _perm_and_obj_ok(IsAdmin, [ROLE_MANAGER])
|
|
|
|
|
|
class TestIsAtLeastTrainer:
|
|
@pytest.mark.parametrize("role", [ROLE_TRAINER, ROLE_AUTHOR, ROLE_MANAGER, ROLE_ADMIN])
|
|
def test_elevated_roles_pass(self, role):
|
|
assert _perm_and_obj_ok(IsAtLeastTrainer, [role])
|
|
|
|
def test_learner_fails(self):
|
|
assert not _perm_and_obj_ok(IsAtLeastTrainer, [ROLE_LEARNER])
|
|
|
|
def test_unauthenticated_fails(self):
|
|
req = _anon_request()
|
|
assert not IsAtLeastTrainer().has_permission(req, MagicMock())
|
|
|
|
|
|
class TestIsAtLeastManager:
|
|
@pytest.mark.parametrize("role", [ROLE_MANAGER, ROLE_ADMIN])
|
|
def test_privileged_roles_pass(self, role):
|
|
assert _perm_and_obj_ok(IsAtLeastManager, [role])
|
|
|
|
@pytest.mark.parametrize("role", [ROLE_LEARNER, ROLE_TRAINER, ROLE_AUTHOR])
|
|
def test_non_privileged_roles_fail(self, role):
|
|
assert not _perm_and_obj_ok(IsAtLeastManager, [role])
|
|
|
|
|
|
class TestIsPrivileged:
|
|
@pytest.mark.parametrize("role", [ROLE_MANAGER, ROLE_ADMIN])
|
|
def test_manager_and_admin_pass(self, role):
|
|
assert _perm_and_obj_ok(IsPrivileged, [role])
|
|
|
|
@pytest.mark.parametrize("role", [ROLE_LEARNER, ROLE_TRAINER, ROLE_AUTHOR])
|
|
def test_others_fail(self, role):
|
|
assert not _perm_and_obj_ok(IsPrivileged, [role])
|
|
|
|
|
|
class TestIsContentEditor:
|
|
@pytest.mark.parametrize("role", [ROLE_AUTHOR, ROLE_MANAGER, ROLE_ADMIN])
|
|
def test_content_roles_pass(self, role):
|
|
assert _perm_and_obj_ok(IsContentEditor, [role])
|
|
|
|
@pytest.mark.parametrize("role", [ROLE_LEARNER, ROLE_TRAINER])
|
|
def test_non_content_roles_fail(self, role):
|
|
assert not _perm_and_obj_ok(IsContentEditor, [role])
|
|
|
|
|
|
# ── Object-level: owner/non-owner ─────────────────────────────────────────────
|
|
|
|
class TestIsEnrollmentOwner:
|
|
def test_owner_passes(self):
|
|
obj = _owned_obj(owner_pk=USER_ID)
|
|
assert _obj_ok(IsEnrollmentOwner, [ROLE_LEARNER], obj, user_pk=USER_ID)
|
|
|
|
def test_non_owner_learner_fails(self):
|
|
obj = _owned_obj(owner_pk=OTHER_USER_ID)
|
|
assert not _obj_ok(IsEnrollmentOwner, [ROLE_LEARNER], obj, user_pk=USER_ID)
|
|
|
|
def test_manager_bypasses_ownership(self):
|
|
obj = _owned_obj(owner_pk=OTHER_USER_ID)
|
|
assert _obj_ok(IsEnrollmentOwner, [ROLE_MANAGER], obj, user_pk=USER_ID)
|
|
|
|
def test_admin_bypasses_ownership(self):
|
|
obj = _owned_obj(owner_pk=OTHER_USER_ID)
|
|
assert _obj_ok(IsEnrollmentOwner, [ROLE_ADMIN], obj, user_pk=USER_ID)
|
|
|
|
def test_trainer_does_not_bypass_ownership(self):
|
|
obj = _owned_obj(owner_pk=OTHER_USER_ID)
|
|
assert not _obj_ok(IsEnrollmentOwner, [ROLE_TRAINER], obj, user_pk=USER_ID)
|
|
|
|
|
|
class TestIsAttemptOwner:
|
|
def test_owner_passes(self):
|
|
obj = _owned_obj(owner_pk=USER_ID)
|
|
assert _obj_ok(IsAttemptOwner, [ROLE_LEARNER], obj, user_pk=USER_ID)
|
|
|
|
def test_non_owner_fails(self):
|
|
obj = _owned_obj(owner_pk=OTHER_USER_ID)
|
|
assert not _obj_ok(IsAttemptOwner, [ROLE_LEARNER], obj, user_pk=USER_ID)
|
|
|
|
def test_manager_bypasses(self):
|
|
obj = _owned_obj(owner_pk=OTHER_USER_ID)
|
|
assert _obj_ok(IsAttemptOwner, [ROLE_MANAGER], obj, user_pk=USER_ID)
|
|
|
|
|
|
class TestIsCertificateOwner:
|
|
def test_owner_passes(self):
|
|
obj = _owned_obj(owner_pk=USER_ID)
|
|
assert _obj_ok(IsCertificateOwner, [ROLE_LEARNER], obj, user_pk=USER_ID)
|
|
|
|
def test_non_owner_learner_fails(self):
|
|
obj = _owned_obj(owner_pk=OTHER_USER_ID)
|
|
assert not _obj_ok(IsCertificateOwner, [ROLE_LEARNER], obj, user_pk=USER_ID)
|
|
|
|
def test_manager_bypasses(self):
|
|
obj = _owned_obj(owner_pk=OTHER_USER_ID)
|
|
assert _obj_ok(IsCertificateOwner, [ROLE_MANAGER], obj, user_pk=USER_ID)
|
|
|
|
|
|
class TestIsOwnNotification:
|
|
def _notif(self, owner_pk: str):
|
|
n = MagicMock()
|
|
n.user_id = owner_pk
|
|
return n
|
|
|
|
def test_owner_passes(self):
|
|
obj = self._notif(USER_ID)
|
|
assert _obj_ok(IsOwnNotification, [ROLE_LEARNER], obj, user_pk=USER_ID)
|
|
|
|
def test_non_owner_fails(self):
|
|
obj = self._notif(OTHER_USER_ID)
|
|
assert not _obj_ok(IsOwnNotification, [ROLE_LEARNER], obj, user_pk=USER_ID)
|
|
|
|
def test_admin_does_not_bypass_notification_ownership(self):
|
|
# Admins use delivery-events endpoint, not per-notification bypass
|
|
obj = self._notif(OTHER_USER_ID)
|
|
assert not _obj_ok(IsOwnNotification, [ROLE_ADMIN], obj, user_pk=USER_ID)
|
|
|
|
|
|
class TestIsOwnProgressOrPrivileged:
|
|
def _progress_obj(self, enrollment_user_id: str):
|
|
obj = MagicMock()
|
|
obj.enrollment = MagicMock()
|
|
obj.enrollment.user_id = enrollment_user_id
|
|
return obj
|
|
|
|
def test_own_progress_learner_passes(self):
|
|
obj = self._progress_obj(USER_ID)
|
|
assert _obj_ok(IsOwnProgressOrPrivileged, [ROLE_LEARNER], obj, user_pk=USER_ID)
|
|
|
|
def test_other_learner_fails(self):
|
|
obj = self._progress_obj(OTHER_USER_ID)
|
|
assert not _obj_ok(IsOwnProgressOrPrivileged, [ROLE_LEARNER], obj, user_pk=USER_ID)
|
|
|
|
def test_trainer_bypasses(self):
|
|
obj = self._progress_obj(OTHER_USER_ID)
|
|
assert _obj_ok(IsOwnProgressOrPrivileged, [ROLE_TRAINER], obj, user_pk=USER_ID)
|
|
|
|
def test_manager_bypasses(self):
|
|
obj = self._progress_obj(OTHER_USER_ID)
|
|
assert _obj_ok(IsOwnProgressOrPrivileged, [ROLE_MANAGER], obj, user_pk=USER_ID)
|
|
|
|
def test_author_does_not_bypass(self):
|
|
obj = self._progress_obj(OTHER_USER_ID)
|
|
assert not _obj_ok(IsOwnProgressOrPrivileged, [ROLE_AUTHOR], obj, user_pk=USER_ID)
|
|
|
|
|
|
# ── Org-scope object permissions ─────────────────────────────────────────────
|
|
|
|
class TestIsOrgScopedObject:
|
|
def _org_obj(self, org_id: str = ORG_ID):
|
|
obj = MagicMock()
|
|
obj.org_id = org_id
|
|
return obj
|
|
|
|
def test_same_org_author_passes(self):
|
|
obj = self._org_obj(ORG_ID)
|
|
assert _obj_ok(IsOrgScopedObject, [ROLE_AUTHOR], obj, org_id=ORG_ID)
|
|
|
|
def test_different_org_author_fails(self):
|
|
obj = self._org_obj(OTHER_ORG_ID)
|
|
assert not _obj_ok(IsOrgScopedObject, [ROLE_AUTHOR], obj, org_id=ORG_ID)
|
|
|
|
def test_admin_bypasses_org_scope(self):
|
|
obj = self._org_obj(OTHER_ORG_ID)
|
|
assert _obj_ok(IsOrgScopedObject, [ROLE_ADMIN], obj, org_id=ORG_ID)
|
|
|
|
def test_manager_same_org_passes(self):
|
|
obj = self._org_obj(ORG_ID)
|
|
assert _obj_ok(IsOrgScopedObject, [ROLE_MANAGER], obj, org_id=ORG_ID)
|
|
|
|
def test_manager_different_org_fails(self):
|
|
obj = self._org_obj(OTHER_ORG_ID)
|
|
assert not _obj_ok(IsOrgScopedObject, [ROLE_MANAGER], obj, org_id=ORG_ID)
|
|
|
|
|
|
class TestIsSessionTrainerOrPrivileged:
|
|
def _session(self, trainer_user_id):
|
|
s = MagicMock()
|
|
s.trainer_user_id = trainer_user_id
|
|
return s
|
|
|
|
def test_session_trainer_passes(self):
|
|
obj = self._session(USER_ID)
|
|
assert _obj_ok(IsSessionTrainerOrPrivileged, [ROLE_TRAINER], obj, user_pk=USER_ID)
|
|
|
|
def test_different_trainer_fails(self):
|
|
obj = self._session(OTHER_USER_ID)
|
|
assert not _obj_ok(IsSessionTrainerOrPrivileged, [ROLE_TRAINER], obj, user_pk=USER_ID)
|
|
|
|
def test_manager_bypasses(self):
|
|
obj = self._session(OTHER_USER_ID)
|
|
assert _obj_ok(IsSessionTrainerOrPrivileged, [ROLE_MANAGER], obj, user_pk=USER_ID)
|
|
|
|
def test_admin_bypasses(self):
|
|
obj = self._session(OTHER_USER_ID)
|
|
assert _obj_ok(IsSessionTrainerOrPrivileged, [ROLE_ADMIN], obj, user_pk=USER_ID)
|
|
|
|
def test_learner_fails(self):
|
|
obj = self._session(USER_ID)
|
|
assert not _obj_ok(IsSessionTrainerOrPrivileged, [ROLE_LEARNER], obj, user_pk=USER_ID)
|
|
|
|
def test_none_trainer_id_fails(self):
|
|
obj = self._session(None)
|
|
assert not _obj_ok(IsSessionTrainerOrPrivileged, [ROLE_TRAINER], obj, user_pk=USER_ID)
|
|
|
|
|
|
# ── Composite / special-purpose ───────────────────────────────────────────────
|
|
|
|
class TestIsReadOnlyOrPrivileged:
|
|
@pytest.mark.parametrize("method", ["GET", "HEAD", "OPTIONS"])
|
|
def test_safe_methods_all_authenticated_roles_pass(self, method):
|
|
for role in [ROLE_LEARNER, ROLE_TRAINER, ROLE_AUTHOR, ROLE_MANAGER, ROLE_ADMIN]:
|
|
user = _make_user([role])
|
|
req = _make_request(user, method)
|
|
assert IsReadOnlyOrPrivileged().has_permission(req, MagicMock()), role
|
|
|
|
@pytest.mark.parametrize("method", ["POST", "PATCH", "DELETE", "PUT"])
|
|
def test_unsafe_methods_only_privileged_pass(self, method):
|
|
for role in [ROLE_MANAGER, ROLE_ADMIN]:
|
|
user = _make_user([role])
|
|
req = _make_request(user, method)
|
|
assert IsReadOnlyOrPrivileged().has_permission(req, MagicMock()), role
|
|
|
|
@pytest.mark.parametrize("method", ["POST", "PATCH", "DELETE"])
|
|
def test_unsafe_methods_non_privileged_fail(self, method):
|
|
for role in [ROLE_LEARNER, ROLE_TRAINER, ROLE_AUTHOR]:
|
|
user = _make_user([role])
|
|
req = _make_request(user, method)
|
|
assert not IsReadOnlyOrPrivileged().has_permission(req, MagicMock()), role
|
|
|
|
def test_unauthenticated_safe_method_fails(self):
|
|
req = _anon_request("GET")
|
|
assert not IsReadOnlyOrPrivileged().has_permission(req, MagicMock())
|
|
|
|
|
|
class TestIsCertificateVerifyPublic:
|
|
@pytest.mark.parametrize("method", ["GET", "HEAD", "OPTIONS"])
|
|
def test_public_get_passes(self, method):
|
|
req = _anon_request(method)
|
|
assert IsCertificateVerifyPublic().has_permission(req, MagicMock())
|
|
|
|
@pytest.mark.parametrize("method", ["POST", "PATCH", "DELETE"])
|
|
def test_non_safe_methods_fail(self, method):
|
|
req = _anon_request(method)
|
|
assert not IsCertificateVerifyPublic().has_permission(req, MagicMock())
|
|
|
|
|
|
class TestIsReportOrgScoped:
|
|
def _report(self, org_id: str = ORG_ID):
|
|
r = MagicMock()
|
|
r.org_id = org_id
|
|
return r
|
|
|
|
@pytest.mark.parametrize("role", [ROLE_LEARNER, ROLE_TRAINER, ROLE_AUTHOR])
|
|
def test_non_privileged_has_permission_fails(self, role):
|
|
user = _make_user([role])
|
|
req = _make_request(user, "GET")
|
|
assert not IsReportOrgScoped().has_permission(req, MagicMock())
|
|
|
|
@pytest.mark.parametrize("role", [ROLE_MANAGER, ROLE_ADMIN])
|
|
def test_privileged_has_permission_passes(self, role):
|
|
user = _make_user([role])
|
|
req = _make_request(user, "GET")
|
|
assert IsReportOrgScoped().has_permission(req, MagicMock())
|
|
|
|
def test_manager_same_org_object_passes(self):
|
|
obj = self._report(ORG_ID)
|
|
assert _obj_ok(IsReportOrgScoped, [ROLE_MANAGER], obj, org_id=ORG_ID)
|
|
|
|
def test_manager_other_org_object_fails(self):
|
|
obj = self._report(OTHER_ORG_ID)
|
|
assert not _obj_ok(IsReportOrgScoped, [ROLE_MANAGER], obj, org_id=ORG_ID)
|
|
|
|
def test_admin_bypasses_org_scope(self):
|
|
obj = self._report(OTHER_ORG_ID)
|
|
assert _obj_ok(IsReportOrgScoped, [ROLE_ADMIN], obj, org_id=ORG_ID)
|
|
|
|
|
|
# ── 401 / unauthenticated coverage ───────────────────────────────────────────
|
|
|
|
class TestUnauthenticatedAlwaysDenied:
|
|
"""All role-level guards must deny unauthenticated requests."""
|
|
|
|
_permission_classes = [
|
|
IsLearner, IsTrainer, IsAuthor, IsManager, IsAdmin,
|
|
IsAtLeastTrainer, IsAtLeastManager, IsPrivileged, IsContentEditor,
|
|
IsReportOrgScoped,
|
|
]
|
|
|
|
@pytest.mark.parametrize("perm_class", _permission_classes)
|
|
def test_unauthenticated_denied(self, perm_class):
|
|
req = _anon_request()
|
|
assert not perm_class().has_permission(req, MagicMock()), perm_class.__name__
|