Files
training-software/tests/test_permissions.py
Paperclip CTO 01661bf5a8
Some checks failed
CI / lint (push) Successful in 6s
CI / test (push) Failing after 7s
CI / build-container (push) Has been skipped
fix: restore CI by fixing lint, test collection, and notification syntax
2026-05-18 14:53:29 +02:00

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__